Compare commits

...
Author SHA1 Message Date
Bailey Dixon 45e7911d3d Merge pull request #143 from Codename-11/dev
release(android): android-v1.2.6
2026-06-27 23:54:22 -04:00
Bailey DixonandClaude Opus 4.8 fa62264962 release(android): android-v1.2.6
Bump appVersionName 1.2.5 -> 1.2.6, appVersionCode 19 -> 20.
Bundles the #133 "Untitled chats" fix + profile-scoped rename + session
drawer refresh, and the connection-status overlay -> take-space banner
(plus a general top info banner). CHANGELOG/RELEASE_NOTES/whats_new/
changelog.json/Play notes refreshed; Desktop CLI items stay in
[Unreleased] for a future cli-v* release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:36:58 -04:00
Bailey DixonandClaude Opus 4.8 95f7c54335 feat(ui): top info/status banner instead of overlay toasts
The connection-status surface rendered as a floating overlay
(ConnectionStatusToast) that slid over content on every reconnect/
handoff/network change. Move the frequent transient/active/warning
states to the take-space ConnectionStatusBanner (slides content down,
above the header) and keep the floating overlay only for persistent
Error-tone status.

Also adds a general info banner: UiMessageBus + MessageBannerHost (thin,
auto-dismiss, coalesces duplicates, tap to expand recent), and routes
frequent snackbar confirmations (profiles updated, paired, copied,
profile/personality picks) to it instead of the bottom snackbar. Errors
stay on the snackbar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:33:16 -04:00
Bailey Dixon 6fb9127d60 Merge pull request #142 from Codename-11/fix/session-untitled-and-rename-scoping
fix(sessions): stop "Untitled" clobber + profile-scope rename (#133)
2026-06-27 23:32:11 -04:00
Bailey DixonandClaude Opus 4.8 76d68622bb docs(sessions): record #133 follow-ups + client-titling drop
Document the session-title work: the upstream api_server titler PR and
the interim relay-side option, the resolved profile-scoped rename + its
audit result, and why client-side LLM titling was dropped (no
client-reachable LLM endpoint avoids persisting a session, so it would
spawn phantom title-generation rows in the drawer).

(Carries one incidental compileSdk-doc line that couldn't be hunk-split
from the #133 notes in the same file.)

Refs #133

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:27:54 -04:00
Bailey DixonandClaude Opus 4.8 8e89136477 docs: reconcile compileSdk 37 references across docs + gradle
The project moved to compileSdk 37 (206d182) but the docs and the
android.suppressUnsupportedCompileSdk flags still said 36. Align them:
CLAUDE.md "Compile SDK 36" → 37, docs/spec.md adds the Compile SDK line,
and gradle.properties + quest/gradle.properties suppress flags → 37.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:27:40 -04:00
Bailey DixonandClaude Opus 4.8 34115130da fix(sessions): stop "Untitled" clobber + profile-scope rename
Sessions read "Untitled" because ChatHandler.updateSessions copied the
server title verbatim, overwriting the optimistic first-message preview
with null whenever a re-list raced ahead of (or lacked) upstream's async
title write. The api_server SSE/runs/completions surfaces never auto-title
at all (only the gateway/cli path calls agent.title_generator).

- updateSessions: merge title (server wins when non-blank, else keep local)
- gateway-only post-turn title reconcile (+3s/+7s) via scheduleTitleReconcile
- serverAutoTitles flow + subtle "not auto-named here" drawer note on SSE
- profile-scope session rename: DashboardApiClient.renameSession +
  patchJsonObject, ConnectionViewModel.renameProfileScopedSession,
  ChatViewModel.profileSessionRenamer wired from RelayApp (write twin of
  the scoped delete; the unscoped PATCH hit the shared api_server DB)
- manual refresh icon in the session drawer header
- ChatHandlerTest: clobber-guard regression cases

Refs #133

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:17:53 -04:00
Bailey Dixon dbccdeba41 Merge pull request #141 from Codename-11/docs/app-access-char-limit
docs: trim Play App-access instruction to ≤500 chars
2026-06-27 19:45:58 -04:00
Bailey DixonandClaude Opus 4.8 f16e5916b9 docs: trim Play App-access instruction to ≤500 chars
Play Console caps the App-access reviewer-instructions field at 500
characters; the prior block was over. Replace it with a 423-char single
paragraph that still names the "Try the demo" entry points (first/Connect
screen + empty Chat after Skip) and the offline/no-login guarantee, and note
the cap in the lead-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:44:47 -04:00
Bailey Dixon 1ae0627d92 Merge pull request #140 from Codename-11/dev
release(android): android-v1.2.5
2026-06-27 19:08:23 -04:00
Bailey Dixon 9ffc6573df Merge pull request #139 from Codename-11/release/android-v1.2.5
release(android): android-v1.2.5
2026-06-27 18:56:27 -04:00
Bailey DixonandClaude Opus 4.8 6721701f16 release(android): android-v1.2.5
Bundles the day's Android work: the #131/#132 non-address-URL crash guard,
the offline Demo / Explore mode, and the demo-reachability + App-access polish.

- appVersionName 1.2.4 → 1.2.5, appVersionCode 18 → 19
- CHANGELOG: promote the Android items into [1.2.5]; Desktop CLI items stay
  in [Unreleased] for a future cli-v* release
- Refresh RELEASE_NOTES.md, in-app whats_new.txt + changelog.json, the Play
  what's-new, and the play-store-listing release-notes block

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:49:03 -04:00
Bailey Dixon 7350dc0ab8 Merge pull request #138 from Codename-11/Codename-11/demo-explore-polish
feat(app): surface Demo mode on every first-run dead-end + tighten App-access copy
2026-06-27 18:43:52 -04:00
Bailey Dixon de44059c8e Merge pull request #135 from Codename-11/dev
ci: activate issue triage on main (+ v1.2.4 devlog)
2026-06-27 12:17:23 -04:00
Bailey Dixon 0327012666 release(android): android-v1.2.4 (#130)
release(android): android-v1.2.4
2026-06-25 21:36:31 -04:00
25 changed files with 788 additions and 72 deletions
+21 -1
View File
@@ -8,7 +8,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added
- **Android: Demo mode.** A "Try the demo" option on the setup / Connect screen opens an offline preview of the real Chat UI — a sample conversation with Markdown, a tool-progress card, and a rich card — with zero setup and zero network (works in airplane mode). A persistent "Demo mode — sample data, not connected" banner offers a one-tap Connect that opens the real setup wizard; other tabs show a friendly "connect your Hermes server" empty state. Lets a first-run user (or a Play reviewer with no server) see what the app does before connecting.
- **Desktop CLI: `hermes-relay audit`.** Shows what the remote agent has actually run on this machine through the desktop tools — tool, status, and a short detail per call — read from a local log, no network or auth. Answers "what did the agent just do?" at a glance.
- **Desktop CLI: `hermes-relay relay`.** Inspect the relay server itself: `relay info` (version, uptime, sessions — on the relay host), `relay security` (runtime auth toggles), and `relay context` (audit the system-prompt context the relay injects into the agent, which works from a remote machine with your session).
- **Desktop CLI: background daemon.** `hermes-relay daemon start` runs the headless tool router in the background (no console window, survives closing the terminal), with `daemon stop` and `daemon status` to manage it. `daemon status` reports state, uptime, relay, and advertised-tool count; bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
@@ -21,6 +20,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
## [1.2.6] - 2026-06-27
### Added
- **Session drawer refresh.** A refresh button in the session drawer re-pulls the chat list on demand, so a title the server generates a moment after a turn shows up without waiting for the next reload.
### Changed
- **Calmer connection status.** Transient connection status — reconnecting, checking, LAN↔Tailscale handoffs — now renders as a thin banner at the top that takes its own space (the screen slides down) instead of a card floating over the chat. The floating alert is reserved for persistent errors. Frequent confirmations (copied, profiles updated, profile/personality switches) moved to the same top banner instead of the bottom pop-up.
### Fixed
- **Chats stuck showing "Untitled".** The session drawer no longer overwrites a chat's first-message preview with a blank title when the server hasn't auto-named it yet (and the SSE path never does), so chats stop reading "Untitled"; titles also reconcile once the turn settles. (#133)
- **Rename on a non-default agent profile.** Renaming a chat while a non-default profile is active now persists to that profile's own store instead of the shared one — matching the earlier session-delete fix.
## [1.2.5] - 2026-06-27
### Added
- **Demo mode.** A "Try the demo" option on the setup / Connect screen — and on the empty chat screen if you skip setup — opens an offline preview of the real Chat UI: a sample conversation with Markdown, a tool-progress card, and a rich card, with zero setup and zero network (works in airplane mode). A persistent "Demo mode — sample data, not connected" banner offers a one-tap Connect that opens the real setup wizard; other tabs show a friendly "connect your Hermes server" empty state. Lets a first-run user — or a Play reviewer with no server — see what the app does before connecting.
### Fixed
- **Crash when a non-address is entered as a server URL.** Typing or pasting non-URL text (for example a label, or a line copied from the docs) into the API server or Dashboard URL field could force-close the app on the Manage / sign-in screen: the value was handed to the networking layer as a host, which rejected it with an uncaught error on the main thread. The setup fields now reject anything that isn't a valid host or `http(s)://` URL with an inline error, and the dashboard and voice request paths treat a malformed address as "unreachable" instead of ever crashing. (#131, #132)
+1 -1
View File
@@ -155,7 +155,7 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
- **Single-activity** — Compose Navigation for all routing
- **Namespace (Kotlin source tree):** `com.hermesandroid.relay` — stable, drives on-disk layout + class FQCNs
- **applicationId:** `com.axiomlabs.hermesrelay` (googlePlay), `com.axiomlabs.hermesrelay.sideload` (sideload)
- **Min SDK 26, Target SDK 35, Compile SDK 36** / **Kotlin 2.0+**, JVM toolchain 17
- **Min SDK 26, Target SDK 35, Compile SDK 37** / **Kotlin 2.0+**, JVM toolchain 17
### Code Style — Desktop CLI (Node/TypeScript)
- **Node ≥21** — uses built-in global `WebSocket` (no `ws`/`undici` dep). Strict TS, ES modules, `NodeNext` resolution.
+30
View File
@@ -1,5 +1,35 @@
# Hermes-Relay — Dev Log
## 2026-06-27 — Profile-scope session rename + manual drawer refresh (#133 follow-up)
**Why.** Auditing the #133 work surfaced that `ChatViewModel.renameSession` always called the unscoped `apiClient.renameSession` (`PATCH /api/sessions/{id}` on the shared api_server DB). There was a `profileSessionDeleter`/`profileSessionLister`/`profileMessageLoader` but no rename twin — so on a non-default **gateway** profile (whose sessions live in that profile's own `state.db`) a manual rename patched the wrong DB and never appeared in the profile-scoped list. Same class as the delete bug fixed in `6552566`. Profiles are first-class, so every session write must be profile-scoped.
**What.**
- **Scoped rename.** New `DashboardApiClient.renameSession(sessionId, title, profile)` + a `patchJsonObject` helper (`PATCH /api/sessions/{id}?profile=`), `ConnectionViewModel.renameProfileScopedSession` (twin of `deleteProfileScopedSession`), and `ChatViewModel.profileSessionRenamer` wired from `RelayApp`. `renameSession` uses it when `streamingEndpoint == "gateway"`, falling back to the unscoped PATCH otherwise (shared api_server DB, no profiles).
- **Audit.** Confirmed rename was the only remaining gap — list/messages/delete are scoped, gateway create goes through `session.create` over `/api/ws`, the SSE auto-title PATCH targets the shared DB (no profiles), and `/branch` is a server-side slash command.
- **Manual drawer refresh.** `SessionDrawerContent` gained a header refresh icon (`onRefresh` → `refreshSessions`) so a title the post-turn auto-reconcile window missed can be pulled on demand. Placed in the header rather than the per-session ⋮ menu since refresh is a list-level action.
**Remaining "Untitled" causes (after these fixes).** The optimistic preview only covers sessions this app run created/sent in; it isn't persisted on the SSE path. So sessions made by other clients, or any SSE session after an app restart, still read "Untitled" because the api_server surface never auto-titles and we hold no local preview for them. Closing that fully needs the upstream api_server titler PR or the opt-in client-side LLM titling feature (both in TODO).
**Verification.** Compiles in the sideload flavor (assembleSideloadDebug). On-device rename-persists-on-non-default-profile check pending.
## 2026-06-27 — Fix sessions showing as "Untitled" in the drawer (#133)
**Why.** A user reported most chat sessions read "Untitled" in the drawer. Tracing both sides: session titles are not set at creation — upstream generates them in a fire-and-forget background thread after the first exchange (`agent/title_generator.py::maybe_auto_title`), and that titler is wired into the gateway/CLI/ACP agent loops but **not** `APIServerAdapter._run_agent`, so the api_server SSE/runs/completions surfaces never auto-title at all. On the client, `ChatHandler.updateSessions` copied the server's title verbatim, so a re-list that arrived before (or without) the async write would overwrite the optimistic first-message preview with `null` → the drawer's `title ?: "Untitled"` rendered "Untitled". Both effects compound; title generation can also silently fail when a profile's auxiliary model has no working key (matches the reporter's intermittency).
**What (client-side mitigations, this change).**
- **Clobber guard.** `ChatHandler.updateSessions` now merges the title field instead of overwriting it: the server wins when it returns a non-blank title, otherwise the known local title is preserved. Stops a too-early/empty re-list from erasing the optimistic preview. New `ChatHandlerTest` cases cover null-server-title preservation, blank-server-title preservation, and real-server-title-wins.
- **Post-turn title reconcile.** `ChatViewModel.scheduleTitleReconcile()` re-lists at +3s/+7s after a gateway turn completes so a title written after the response (and the flushed message_count/model) replaces the preview; cancel-and-replace keeps one job in flight. Gated to the gateway transport (SSE/runs never title, so retrying there is pointless).
- **Subtle drawer note.** `ChatViewModel.serverAutoTitles` (true only on the gateway transport, kept in sync from the `streamingEndpoint` setter) feeds a quiet "Chats aren't auto-named on this connection — use ⋮ → Rename." caption in `SessionDrawerContent`, shown only on the SSE surfaces so consistently-untitled chats read as expected rather than broken.
**Deferred (see TODO "Session titles (#133)").** Upstream PR to call `maybe_auto_title` from `APIServerAdapter._run_agent` (proper fix for the SSE surface); an interim relay-side titler option; and a separate opt-in feature to generate titles client-side via the main LLM.
**Verification.** `:app:testGooglePlayDebugUnitTest --tests "*ChatHandlerTest"` green (BUILD SUCCESSFUL; 3 new clobber-guard tests pass). Warnings emitted are pre-existing in unrelated test files. Not built in Studio / not on-device verified.
## 2026-06-27 — Released android-v1.2.5
Bundles the day's Android work: the #131/#132 non-address-URL crash guard, the offline Demo / Explore mode, and the demo-reachability + App-access polish. Bumped `appVersionName` 1.2.4 → 1.2.5 and `appVersionCode` 18 → 19. Promoted the Android items into a `## [1.2.5]` CHANGELOG block; the Desktop CLI items stay in `[Unreleased]` for a future `cli-v*` release. Refreshed `RELEASE_NOTES.md`, the in-app `whats_new.txt` + `changelog.json`, and the Play `what's-new`. Released via a `dev → main` merge and the `android-v1.2.5` tag; `release-android.yml` builds the signed APK/AAB + GitHub Release. Play upload and the App-access "Try the demo" declaration are owner-driven.
## 2026-06-27 — Add in-app Demo / Explore mode (offline, for Play review + first-run UX)
**Why.** Google Play rejected v1.2.4 under "App access": a reviewer opened the app, had no Hermes server to point it at, hit the empty Connect/setup wall, and bounced. The app is a client for a user-run Hermes server, so there is no content without a connection — and there was no offline path. This adds an in-app Demo mode so anyone (a reviewer or a first-run user) can see the app work with zero setup and zero network; Play Console "App access" can then declare that all functionality is reachable via "Try the demo" (no login). It doubles as a first-run UX win.
+14 -14
View File
@@ -1,22 +1,22 @@
# Hermes-Relay-Android v1.2.4
# Hermes-Relay-Android v1.2.6
**Release Date:** June 25, 2026
**Since v1.2.3:** A second connection-stability fix plus a new way to see whether your connection is encrypted. A transient blip on the dashboard session check — a pooled connection aborting or timing out over Tailscale — could still hard-close the app even after the v1.2.3 fix; that path is now handled cleanly. And the app now shows, at a glance, whether each transport is encrypted.
**Release Date:** June 27, 2026
**Since v1.2.5:** A fix for chats stuck showing "Untitled", and a calmer way to surface connection status. Chats now keep your first message as a stand-in title until the server names them (and titles reconcile after a turn / via a new refresh button in the session drawer), renaming sticks on non-default agent profiles, and the connection-status card no longer floats over your chat — transient states slide the screen down as a thin top banner, with the floating alert reserved for persistent errors.
v1.2.4 is recommended for anyone connecting over Tailscale or public TLS. Plain-LAN connections were never affected by the crash.
v1.2.6 is recommended for everyone.
---
## Download
v1.2.4 ships in two Android build flavors. APK and AAB filenames are version-tagged:
v1.2.6 ships in two Android build flavors. APK and AAB filenames are version-tagged:
| Flavor | File | Who it's for |
|---|---|---|
| Google Play | `hermes-relay-1.2.4-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
| sideload | `hermes-relay-1.2.4-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-1.2.4-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-1.2.4-sideload-release.aab` | Parity/testing artifact. |
| Google Play | `hermes-relay-1.2.6-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
| sideload | `hermes-relay-1.2.6-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
| googlePlay APK | `hermes-relay-1.2.6-googlePlay-release.apk` | Parity/testing artifact. |
| sideload AAB | `hermes-relay-1.2.6-sideload-release.aab` | Parity/testing artifact. |
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
@@ -25,14 +25,14 @@ Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload
## Highlights
### Fixed
- **No more crash when the dashboard connection drops mid-check.** A transient network failure on the dashboard session check — for example a pooled connection aborting or timing out over Tailscale — could still force-close the app: the check returned a result type but re-threw the network error instead of reporting it, and it surfaced on the main thread. The check now reports the failure cleanly and the connection probe degrades gracefully, so a flaky link can no longer crash the app. (#129)
- **Chats stuck showing "Untitled".** The session drawer treated the server's session list as fully authoritative for the title, so a re-list that arrived before (or without) the server auto-naming a chat overwrote the optimistic first-message preview with a blank title. The drawer now keeps a known local title when the server returns a blank one, re-pulls shortly after a turn settles, and offers a manual refresh button — so chats stop reading "Untitled". The api_server SSE path never auto-titles, which is why the preview is now the durable fallback there. (#133)
- **Rename on a non-default agent profile.** A non-default profile's chats live in that profile's own store, but rename went through the shared path — so the new title never landed. Renaming is now profile-scoped (the write twin of the earlier session-delete and list fixes).
### Added
- **See whether your connection is encrypted.** The chat status chip, the connection card, and the route picker now show your encryption state at a glance — 🔒 **Encrypted · TLS**, 🛡️ **Encrypted · Tailscale** (both secure), 🛡️ **Mixed routes**, or ⚠️ **Not encrypted** — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure. A new ["Is my connection secure?"](https://codename-11.github.io/hermes-relay/architecture/connection-security.html) docs page explains the difference between TLS and overlay (WireGuard) encryption.
### Changed
- **Calmer connection status.** Transient/active/warning connection status — reconnecting, checking, LAN↔Tailscale handoffs — now renders as a thin banner at the top that takes its own space (content slides down) instead of a card floating over the chat. A persistent **error** keeps the floating alert so it still demands attention. Frequent confirmations (copied, profiles updated, profile/personality switches) moved to the same top banner instead of a bottom pop-up.
---
## Upgrade notes
- This is an app-side release on **both** flavors — no Device Control or server changes needed.
- If you connect over Tailscale or HTTPS, update and reconnect.
- `appVersionCode` is **18**.
- `appVersionCode` is **20**.
+22 -1
View File
@@ -12,6 +12,27 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
- `HermesApiClient` streaming methods (`sendChatStream` / `sendCompletionsStream` / `sendRunStream`) build `authRequest("$baseUrl/…")` *outside* the surrounding `try`. Latent only — the non-streaming methods (incl. `checkHealth`) already `try/catch`, so a bad `apiServerUrl` is caught and marks the connection unreachable before streaming is reached. Consider a non-throwing `authRequestOrNull()` chokepoint → `onError`.
- Relay clients (`RelayHttpClient`, `RelayProfileInspectorClient`, `RelayVoiceClient`, `ConnectionManager`) use `.toHttpUrl()` on `$httpBase/…`. These ride post-pairing relay URLs (from a signed QR / pairing payload), not free-text fields, so the input-validation layer doesn't cover them — route them through `ServerAddress`/`toHttpUrlOrNull` for defense-in-depth.
## Session titles (#133) — follow-ups beyond the client fixes
The client-side mitigations shipped (see DEVLOG 2026-06-27): the `updateSessions` clobber guard, the post-turn title reconcile (gateway), and the subtle "not auto-named here" drawer note on SSE. These two are the larger follow-ups:
- **Upstream PR: auto-title on the api_server surface.** `APIServerAdapter._run_agent` (`gateway/platforms/api_server.py:3492`) calls `agent.run_conversation(...)` and returns without ever invoking `agent.title_generator.maybe_auto_title` — so `/api/sessions/*/chat[/stream]`, `/v1/runs`, and `/v1/chat/completions` never auto-name sessions (only the gateway/tui_gateway → cli.py path does). Mirror the gateway call site (`gateway/run.py:15493`): after a successful first exchange, fire `maybe_auto_title(self._ensure_session_db(), session_id, user_message, final_response, history, main_runtime={...})` in the existing thread-executor return path. Standard-path rule applies — it's an upstream contribution; our client degrades gracefully until it merges. This is the proper fix for the SSE-surface half of #133.
- **Relay-side patch (interim, until the upstream PR lands).** Because the phone's SSE chat hits the upstream api_server **directly** on `:8642` (not through the relay on `:8767`), the relay can't intercept the turn to title it inline. Options to evaluate:
- A relay background reconciler that periodically scans the shared `state.db` for untitled sessions with ≥1 exchange and titles them via the same auxiliary-LLM logic (`agent.title_generator.generate_title`) — essentially running upstream's titler out-of-band. Lowest client impact, but couples the relay to the session DB schema.
- A relay `/sessions/{id}/title` helper the client can POST after an SSE turn to request server-side generation, keeping the LLM call (and key) server-side. More explicit, needs a client call.
- Decision gate: prefer the upstream PR; only ship a relay patch if upstream review stalls. Keep it behind the relay (never the Vanilla Hermes path).
- **(Separate feature — DROPPED 2026-06-27) Client-side title generation via the main LLM.** Idea: when a session still lacks a server title after its first turn, have the app ask the main model for a 3–7-word title and persist it via `renameSession`. **Dropped because there is no client-reachable LLM endpoint that doesn't persist a session** — which would put phantom title-generation sessions in the drawer/history (the explicit no-go):
- `/v1/chat/completions`: when no `X-Hermes-Session-Id` is sent, the server *derives* a session_id from the prompt fingerprint (`_derive_chat_session_id`) and `_create_agent` runs with `session_db=_ensure_session_db()` → the turn persists. 1 user + 1 assistant msg passes the drawer's `min_messages=1` filter → phantom row.
- `/v1/responses` with `store:false`: `store` only governs the in-memory response-chaining store; `session_id = stored_session_id or uuid4()` is still passed to `_run_agent`, so it *also* persists a session row.
- Reusing the chat's own session id would append the title prompt/response to the real conversation history — worse.
- Why upstream is clean: `agent/title_generator.py` calls `auxiliary_client.call_llm` directly (raw provider call with the server's keys, no session machinery). The phone has neither provider keys nor a non-persisting endpoint, so it can't replicate that.
- **Correct home = server-side** (the upstream api_server titler PR above, or the relay-side titler). A create-then-delete hack on the client (read `X-Hermes-Session-Id`, then `DELETE`) is fragile/racy and still flashes a row — not worth it. Revisit only if upstream ever exposes a non-persisting utility-completion endpoint.
- [x] **Session rename now profile-scoped on the gateway (fixed 2026-06-27).** Added `DashboardApiClient.renameSession`/`patchJsonObject` + `ConnectionViewModel.renameProfileScopedSession` + `ChatViewModel.profileSessionRenamer` (wired in `RelayApp`); `renameSession` routes through it when `streamingEndpoint == "gateway"`, falling back to the unscoped api_server PATCH otherwise. Verify on-device: rename a session on a non-default profile and confirm the title survives a drawer refresh / app restart.
- **Profile-scoping audit result (2026-06-27):** rename was the *only* remaining gap. List (`profileSessionLister`), messages (`profileMessageLoader`), and delete (`profileSessionDeleter`) are already scoped; create on the gateway goes through `session.create` over `/api/ws` (inherently profile-correct); the SSE create-path auto-title PATCH (`ChatViewModel:2692`) targets the shared api_server DB where there are no profiles, so unscoped is correct; `/branch` is a server-side slash command. No further client-side session ops bypass profile scoping.
## User-Added:
- [x] **Clean-chat: taller scrollable text viewport** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Replaced the fragile `screenHeightDp*0.34f` cap with a weight split (sphere `weight(1f)` / flow `weight(1.1f)` ≈ 52% of the vertical slack); kept the internal scroll + top-fade + `min=96.dp` floor. `AgentTextFlow.kt` (`1dca285`).
@@ -198,7 +219,7 @@ When the answer becomes clearer, this section becomes either an ADR in `docs/dec
- **LLM client wiring for `android_navigate`** — `_default_vision_model` is stubbed; production swap to a real Anthropic/OpenAI vision client
- **Real screenshots of each flavor's a11y permission dialog** — for `user-docs/guide/release-tracks.md`
- `**llms.txt` standard** — explicitly skipped in favor of the `hermes-relay-self-setup` SKILL.md path; revisit if the standard gains traction in the agent ecosystem
- `**markdown-renderer`/`lifecycle` compileSdk ceiling — RESOLVED via compileSdk 37 (2026-06-22).** `MarkdownContent.kt` is on the 0.4x API, and `markdown-renderer 0.42.0` / `lifecycle 2.11.0` (the Dependabot bumps) require `compileSdk 37`. The project moved to **compileSdk 37** (`206d182`, across app/quest/relay-core/relay-ui; `targetSdk` stays 35), which satisfies them — so the temporary 1.2.2-prep pins (0.41.0 / 2.10.0 on compileSdk 36) were dropped when integrating `origin/dev`. **CLAUDE.md still says "Compile SDK 36" — update it to 37 to match the build.** A Dependabot ignore rule is still worth adding so a future bump that raises the compileSdk floor again fails loudly rather than silently (see next item).
- `**markdown-renderer`/`lifecycle` compileSdk ceiling — RESOLVED via compileSdk 37 (2026-06-22).** `MarkdownContent.kt` is on the 0.4x API, and `markdown-renderer 0.42.0` / `lifecycle 2.11.0` (the Dependabot bumps) require `compileSdk 37`. The project moved to **compileSdk 37** (`206d182`, across app/quest/relay-core/relay-ui; `targetSdk` stays 35), which satisfies them — so the temporary 1.2.2-prep pins (0.41.0 / 2.10.0 on compileSdk 36) were dropped when integrating `origin/dev`. Docs/refs reconciled to 37 (2026-06-23): CLAUDE.md, `docs/spec.md`, and the `android.suppressUnsupportedCompileSdk` flags in `gradle.properties` + `quest/gradle.properties`. A Dependabot ignore rule is still worth adding so a future bump that raises the compileSdk floor again fails loudly rather than silently (see next item).
- **Dependabot auto-merge guardrails** — Dependabot merged breaking bumps despite CI failing. Investigate why `.github/workflows/dependabot-auto-merge.yml` isn't gating on CI status, and consider adding an ignore rule for packages we know need manual attention on major bumps (`markdown-renderer`, compose BOM, activity-compose).
---
@@ -1,4 +1,4 @@
v1.2.4 — Stability + connection security.
v1.2.6 — Tidier chats & calmer status.
• Fixed a crash that could close the app when the dashboard connection dropped mid-check (e.g. a brief Tailscale blip) — it now fails gracefully instead of force-closing.
• New: see whether your connection is encrypted at a glance (TLS or Tailscale) from the chat chip, connection card, and route picker, with a per-transport breakdown on tap.
• Chats no longer get stuck on "Untitled" — your first message stands in as the title until the chat is named, plus a new refresh button in the session list. Renaming a chat now sticks on non-default profiles.
• Connection status now slides in as a thin banner at the top instead of a card floating over your chat; the floating alert is kept for persistent errors.
+38
View File
@@ -1,5 +1,43 @@
{
"versions": [
{
"version": "1.2.6",
"title": "Tidier chats & calmer status",
"date": "2026-06-27",
"sections": [
{
"header": "Tidier chats",
"bullets": [
"Chats no longer get stuck showing \"Untitled\" — your first message stands in as the title until the chat is named, titles refresh once a turn settles, and a new refresh button in the session drawer pulls the latest on demand. Renaming a chat now sticks when you're on a non-default agent profile."
]
},
{
"header": "Calmer status",
"bullets": [
"Connection status — reconnecting, checking, network handoffs — now shows as a thin banner at the top that gently slides the screen down, instead of a card floating over your chat; the floating alert is kept for persistent errors. Quick confirmations (copied, profiles updated, profile/personality switches) land in the same calm banner instead of a pop-up at the bottom."
]
}
]
},
{
"version": "1.2.5",
"title": "Stability + Try the demo",
"date": "2026-06-27",
"sections": [
{
"header": "Stability",
"bullets": [
"Fixed a crash that could close the app when a non-URL value — a UI label, or a line copied from the docs — was entered in the API server or Dashboard URL field. The setup fields now reject anything that isn't a valid host or http(s) URL with an inline error, and the dashboard and voice request paths treat a bad address as unreachable instead of crashing."
]
},
{
"header": "Try the demo",
"bullets": [
"A new \"Try the demo\" option on the setup screen — and on the empty chat screen if you skip setup — opens an offline preview of the real chat experience: a sample conversation with Markdown, a tool-progress card, and a rich card, with no server, account, or network. A banner shows it's a demo, with a one-tap Connect to set up for real."
]
}
]
},
{
"version": "1.2.4",
"title": "Stability + connection security",
+11 -9
View File
@@ -1,11 +1,13 @@
v1.2.4 - Stability + connection security
v1.2.6 - Tidier chats & calmer status
Stability
* Fixed a crash that could close the app when the dashboard connection
dropped mid-check (e.g. a brief Tailscale blip). The check now fails
gracefully instead of force-closing.
Chats
* Chats no longer get stuck on "Untitled" — your first message stands
in as the title until the chat is named, titles refresh once a turn
settles, and a new refresh button in the session drawer pulls the
latest. Renaming a chat now sticks on non-default agent profiles.
New
* See whether your connection is encrypted at a glance — the chat chip,
connection card, and route picker now show TLS or Tailscale encryption,
with a per-transport breakdown on tap.
Calmer status
* Connection status (reconnecting, checking, network handoffs) now
shows as a thin banner at the top that gently slides the screen
down, instead of a card floating over your chat — the floating alert
is kept for persistent errors. Quick confirmations land there too.
@@ -1353,13 +1353,27 @@ class ChatHandler {
* Update sessions list from API response.
*/
fun updateSessions(items: List<SessionItem>) {
// Index the current rows so a server row that arrives without a title
// can inherit a title we already know locally. Auto-titling is a
// fire-and-forget background job on the server (upstream
// agent.title_generator.maybe_auto_title) — and on the api_server
// SSE/runs surfaces it never runs at all — so a freshly persisted
// session is routinely returned with title == null for a few seconds
// (or forever) even though we're already showing the optimistic
// first-message preview. Blindly copying that null is what surfaced
// sessions as "Untitled" in the drawer (issue #133). Preserve the known
// local title whenever the server hasn't supplied a non-blank one.
val existingById = _sessions.value.associateBy { it.sessionId }
val mapped = items.map { item ->
val startedAtMs = timestampToMillis(item.startedAt)
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
val serverTitle = item.title?.takeIf { it.isNotBlank() }
val resolvedTitle = serverTitle
?: existingById[item.id]?.title?.takeIf { it.isNotBlank() }
ChatSession(
sessionId = item.id,
title = item.title,
title = resolvedTitle,
model = item.model,
messageCount = item.messageCount ?: 0,
updatedAt = activityAtMs,
@@ -169,6 +169,19 @@ class DashboardApiClient(
executeJson(request, normalized)
}
suspend fun patchJsonObject(
path: String,
payload: JsonObject,
): Result<JsonObject> = withContext(Dispatchers.IO) {
val normalized = if (path.startsWith("/")) path else "/$path"
val httpUrl = resolveUrl(normalized) ?: return@withContext Result.failure(invalidUrlException())
val request = Request.Builder()
.url(httpUrl)
.patch(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.build()
executeJson(request, normalized)
}
suspend fun deleteJsonObject(path: String): Result<JsonObject> = withContext(Dispatchers.IO) {
val normalized = if (path.startsWith("/")) path else "/$path"
val httpUrl = resolveUrl(normalized) ?: return@withContext Result.failure(invalidUrlException())
@@ -483,6 +496,19 @@ class DashboardApiClient(
suspend fun deleteSession(sessionId: String, profile: String? = null): Result<JsonObject> =
deleteJsonObject("/api/sessions/${pathSegment(sessionId)}${profileQuery(profile)}")
/**
* Rename a session scoped to a profile via the dashboard
* `PATCH /api/sessions/{id}?profile=` surface — the write twin of
* [deleteSession]. A non-default profile's sessions live in that profile's
* own `state.db`, so the unscoped api_server rename would patch the wrong
* DB and the new title would never appear in the profile-scoped list.
*/
suspend fun renameSession(sessionId: String, title: String, profile: String? = null): Result<JsonObject> =
patchJsonObject(
"/api/sessions/${pathSegment(sessionId)}${profileQuery(profile)}",
buildJsonObject { put("title", title) },
)
private fun parseProfiles(root: JsonObject): List<Profile> {
fun decode(element: JsonElement, nameOverride: String?): Profile? = runCatching {
val obj = element as? JsonObject ?: return null
@@ -70,6 +70,7 @@ import androidx.navigation.navArgument
import com.hermesandroid.relay.ui.components.CrashReportGate
import com.hermesandroid.relay.ui.components.DemoModeBanner
import com.hermesandroid.relay.ui.components.DemoUnavailableContent
import com.hermesandroid.relay.ui.components.MessageBannerHost
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
import com.hermesandroid.relay.ui.components.LocalAvailableSphereSkins
import com.hermesandroid.relay.ui.components.LocalSphereSkin
@@ -84,6 +85,7 @@ import com.hermesandroid.relay.ui.components.avatar.LocalPetPlaybackSpeed
import com.hermesandroid.relay.ui.components.avatar.LocalPetStabilize
import com.hermesandroid.relay.ui.components.avatar.PetLoader
import com.hermesandroid.relay.ui.components.avatar.SphereAvatar
import com.hermesandroid.relay.ui.components.ConnectionStatusBanner
import com.hermesandroid.relay.ui.components.ConnectionStatusToast
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
import com.hermesandroid.relay.ui.components.ChatTransportStatusBadge
@@ -150,6 +152,7 @@ import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionStatusTone
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
import com.hermesandroid.relay.viewmodel.TerminalViewModel
@@ -657,6 +660,12 @@ fun RelayApp() {
chatViewModel.profileSessionDeleter = { sessionId ->
connectionViewModel.deleteProfileScopedSession(sessionId)
}
// …and rename in that same profile's DB so a non-default profile's
// title actually persists (the unscoped api_server PATCH hits the
// shared DB). Write twin of the scoped list/delete.
chatViewModel.profileSessionRenamer = { sessionId, title ->
connectionViewModel.renameProfileScopedSession(sessionId, title)
}
// Wire session persistence callback
chatViewModel.onSessionChanged = { sessionId ->
@@ -1229,7 +1238,7 @@ fun RelayApp() {
// this only fires when the profile list actually changed.
LaunchedEffect(connectionViewModel) {
connectionViewModel.profilesUpdatedEvents.collect {
snackbarHostState.showSnackbar("Profiles updated")
UiMessageBus.success("Profiles updated")
}
}
@@ -1279,6 +1288,11 @@ fun RelayApp() {
// user always knows the chat is sample data with no live server, and
// can exit into the real Connect flow with one tap.
val showDemoBanner = isDemoMode && !voiceUiState.voiceMode
// Transient info/status banner (UiMessageBus) — thin, takes its own
// space, auto-dismisses. Folded into the inset accounting below so a
// child TopAppBar doesn't double-pad when this banner owns the top edge.
val activeMessageCount by UiMessageBus.activeCount.collectAsState()
val showMessageBanner = activeMessageCount > 0
// Update availability (unified): googlePlay = Play In-App Update FLEXIBLE,
// sideload = GitHub releases. The handle filters dismissed versions +
// throttles checks internally, exposing a surfaceable status for the
@@ -1298,6 +1312,16 @@ fun RelayApp() {
!suppressGlobalChrome &&
!showStartupSphere &&
!voiceUiState.voiceMode
// Split the connection-status surface by severity (user request): the
// frequent transient/active/warning states render as a take-space top
// BANNER (content slides down, no overlay), while a persistent ERROR
// keeps the floating overlay so it still demands attention. Steady
// state is null (buildGlobalConnectionStatus → else null), so the
// banner only occupies space during a transition/problem.
val connectionStatusIsError =
globalConnectionStatus?.tone == ConnectionStatusTone.Error
val showConnectionStatusBanner = showConnectionStatusToast && !connectionStatusIsError
val showConnectionStatusOverlay = showConnectionStatusToast && connectionStatusIsError
val onConnectionStatusBannerClick: () -> Unit = {
val title = globalConnectionStatus?.title.orEmpty()
val destination = when {
@@ -1385,6 +1409,29 @@ fun RelayApp() {
DemoModeBanner(onConnect = exitDemoToConnect)
}
// Connection status as a take-space banner (non-error). Replaces the
// floating ConnectionStatusToast for the frequent transient/active/
// warning states so content slides down instead of being covered.
AnimatedVisibility(
visible = showConnectionStatusBanner,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(200)),
) {
ConnectionStatusBanner(
status = globalConnectionStatus,
includeStatusBarPadding = !showUnattendedBanner && !showDemoBanner,
onClick = onConnectionStatusBannerClick,
)
}
// Transient info/status banner. Sits below the persistent banners and
// owns the status-bar inset only when no banner is above it (otherwise
// that banner already padded the top — avoid double padding).
MessageBannerHost(
includeStatusBarPadding =
!showUnattendedBanner && !showDemoBanner && !showConnectionStatusBanner,
)
// The update banner AND the connection-status indicator now render as
// floating overlay TOASTS in the Box below (see the top-overlay Column
// after the Scaffold), so they slide down OVER the content instead of
@@ -1422,7 +1469,9 @@ fun RelayApp() {
// The connection-status toast is now a floating overlay and
// doesn't occupy space above the Scaffold, so it no longer
// participates in the top-inset accounting.
if (showUnattendedBanner || showDemoBanner || connectionChipVisible) {
if (showUnattendedBanner || showDemoBanner || connectionChipVisible ||
showMessageBanner || showConnectionStatusBanner
) {
Modifier.consumeWindowInsets(WindowInsets.statusBars)
} else {
Modifier
@@ -2030,9 +2079,7 @@ fun RelayApp() {
activeRelayUiState = activeRelayUiState,
onReconnectActive = {
connectionViewModel.connectRelay()
connectionSwitchScope.launch {
snackbarHostState.showSnackbar("Reconnecting to relay…")
}
UiMessageBus.status("Reconnecting to relay…")
},
// Multi-connection: typed VM helpers (Worker B2)
// handle the full mutations — rename persists via
@@ -2344,7 +2391,7 @@ fun RelayApp() {
}
}
AnimatedVisibility(
visible = showConnectionStatusToast,
visible = showConnectionStatusOverlay,
enter = slideInVertically(tween(220)) { -it } + fadeIn(tween(180)),
exit = slideOutVertically(tween(200)) { -it } + fadeOut(tween(160)),
) {
@@ -0,0 +1,77 @@
package com.hermesandroid.relay.ui
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicLong
/** Visual tone of a transient banner message. Errors are NOT modelled here —
* they stay on the snackbar (see [LocalSnackbarHost]); this bus is info-only. */
enum class UiMessageSeverity { Info, Success, Status }
data class UiMessage(
val id: Long,
val text: String,
val severity: UiMessageSeverity,
val ttlMillis: Long,
)
/**
* App-wide bus for transient, non-error status/confirmation messages that
* surface in the top [com.hermesandroid.relay.ui.components.MessageBannerHost]
* — a thin banner that takes its own space (content slides down, no overlay),
* shows the newest line collapsed, expands to a few recent lines, auto-dismisses
* and coalesces duplicates.
*
* Deliberately info-only: errors and persistent/actionable messages keep going
* to the snackbar so they demand acknowledgement. Migrate frequent
* `snackbarHostState.showSnackbar("…")` confirmations/status to [info] /
* [success] / [status] here.
*
* A process singleton (not a CompositionLocal) so non-composable code
* (ViewModels) can post too.
*/
object UiMessageBus {
const val DEFAULT_TTL_MS = 4_000L
const val STATUS_TTL_MS = 6_000L
private val counter = AtomicLong(0L)
private val _events = MutableSharedFlow<UiMessage>(extraBufferCapacity = 24)
val events: SharedFlow<UiMessage> = _events.asSharedFlow()
// Number of messages currently shown by the host. Lifted here so the app
// scaffold can fold banner visibility into its status-bar inset accounting
// without duplicating the host's queue logic.
private val _activeCount = MutableStateFlow(0)
val activeCount: StateFlow<Int> = _activeCount.asStateFlow()
fun post(
text: String,
severity: UiMessageSeverity = UiMessageSeverity.Info,
ttlMillis: Long = DEFAULT_TTL_MS,
) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
_events.tryEmit(UiMessage(counter.incrementAndGet(), trimmed, severity, ttlMillis))
}
/** Neutral confirmation/info (e.g. "Pairing code copied"). */
fun info(text: String, ttlMillis: Long = DEFAULT_TTL_MS) =
post(text, UiMessageSeverity.Info, ttlMillis)
/** Positive completion (e.g. "Paired successfully", "Profiles updated"). */
fun success(text: String, ttlMillis: Long = DEFAULT_TTL_MS) =
post(text, UiMessageSeverity.Success, ttlMillis)
/** Ongoing/progress status (e.g. "Reconnecting to relay…") — slightly longer TTL. */
fun status(text: String, ttlMillis: Long = STATUS_TTL_MS) =
post(text, UiMessageSeverity.Status, ttlMillis)
/** Host-only: report how many messages are currently visible. */
internal fun reportActiveCount(count: Int) {
_activeCount.value = count
}
}
@@ -67,6 +67,7 @@ import com.hermesandroid.relay.data.hasSecureProxy
import com.hermesandroid.relay.network.relay.ConnectionState
import com.hermesandroid.relay.network.relay.RelayUrlDeriver
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.showHumanError
import com.hermesandroid.relay.util.classifyError
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -916,7 +917,7 @@ private fun ManualPairingCodeSubsection(
}
connectInProgress = false
when (terminal) {
is AuthState.Paired -> snackbarHost.showSnackbar("Paired successfully")
is AuthState.Paired -> UiMessageBus.success("Paired successfully")
is AuthState.Failed -> {
val human = classifyError(
IllegalStateException(terminal.reason),
@@ -972,7 +973,7 @@ private fun ManualPairingCodeSubsection(
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Pairing code", pairingCode)),
)
snackbarHost.showSnackbar("Pairing code copied")
UiMessageBus.info("Pairing code copied")
}
}) {
Icon(
@@ -1015,7 +1016,7 @@ private fun ManualPairingCodeSubsection(
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("hermes pair command", cmd)),
)
snackbarHost.showSnackbar("Command copied")
UiMessageBus.info("Command copied")
}
},
modifier = Modifier.size(32.dp),
@@ -78,7 +78,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.ConnectionState
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
@@ -755,15 +755,13 @@ fun AgentInfoSheet(
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val snackbar = LocalSnackbarHost.current
// Transient confirmation when the user picks a different profile or
// personality from inside the sheet. Kept short — these fire on the
// tap, so a 1-line toast is enough; the UI state update on the next
// chat turn is the real confirmation. Suspend snackbar dispatch goes
// through the local coroutine scope so it doesn't block the radio tap.
// personality from inside the sheet. Routed to the top info-banner
// (UiMessageBus) instead of the snackbar so these frequent tap acks slide
// in quietly rather than popping an obtrusive overlay.
fun toast(message: String) {
scope.launch { snackbar.showSnackbar(message) }
UiMessageBus.info(message)
}
ModalBottomSheet(
@@ -0,0 +1,294 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.UiMessage
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
import kotlinx.coroutines.delay
private const val MAX_RETAINED = 6
private const val MAX_VISIBLE_EXPANDED = 3
private const val ROW_MIN_HEIGHT_DP = 34
/**
* Top, thin, info-only banner host. Collects [UiMessageBus] and renders the
* newest transient message on one line; tapping expands to the recent few
* (scrolling past three). It takes its own vertical space — the Scaffold below
* reflows, so content slides down smoothly instead of being covered by an
* overlay. Auto-dismisses (paused while expanded) and coalesces duplicates so a
* burst of the same status collapses to one refreshed row.
*
* Errors stay on the snackbar — only post info/success/status here.
*/
@Composable
fun MessageBannerHost(
modifier: Modifier = Modifier,
includeStatusBarPadding: Boolean = true,
) {
// Backing queue (oldest first; newest is last). expiresAt is kept in a
// parallel map so coalescing/auto-dismiss can address rows by id.
val shown = remember { mutableStateListOf<UiMessage>() }
val expiresAt = remember { mutableStateMapOf<Long, Long>() }
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
UiMessageBus.events.collect { msg ->
// Coalesce identical text so e.g. repeated "Reconnecting…" collapses
// to a single, freshly-timed row rather than stacking.
shown.filter { it.text == msg.text }.forEach { dup ->
shown.remove(dup)
expiresAt.remove(dup.id)
}
shown.add(msg)
expiresAt[msg.id] = nowMs() + msg.ttlMillis
while (shown.size > MAX_RETAINED) {
val dropped = shown.removeAt(0)
expiresAt.remove(dropped.id)
}
}
}
// Auto-dismiss — paused while expanded so the user can read the list.
LaunchedEffect(shown.toList(), expanded) {
if (expanded) return@LaunchedEffect
while (shown.isNotEmpty()) {
val now = nowMs()
val soonest = shown.minOfOrNull { expiresAt[it.id] ?: Long.MAX_VALUE } ?: break
if (soonest <= now) {
shown.filter { (expiresAt[it.id] ?: Long.MAX_VALUE) <= now }.forEach { expired ->
shown.remove(expired)
expiresAt.remove(expired.id)
}
} else {
delay(soonest - now)
}
}
}
// Collapse + report count to the scaffold (for inset accounting).
LaunchedEffect(shown.size) {
if (shown.isEmpty()) expanded = false
UiMessageBus.reportActiveCount(shown.size)
}
DisposableEffect(Unit) {
onDispose { UiMessageBus.reportActiveCount(0) }
}
// Mirror the live queue into a retained copy so the exit animation still
// has content to slide/fade out after `shown` has emptied (otherwise the
// banner would read empty mid-animation and pop instead of glide).
val rendered = remember { mutableStateListOf<UiMessage>() }
LaunchedEffect(shown.toList()) {
if (shown.isNotEmpty()) {
rendered.clear()
rendered.addAll(shown)
}
}
// Enter/exit is a fade with an instant reflow — the same treatment as the
// Demo/Unattended banners. A height-slide here would desync from the
// Scaffold's status-bar inset hand-off and briefly push the top app bar
// under the notch. The smooth "slide" lives in animateContentSize below
// (collapsed↔expanded and message-count changes).
AnimatedVisibility(
visible = shown.isNotEmpty(),
enter = fadeIn(tween(180)),
exit = fadeOut(tween(160)),
modifier = modifier,
) {
MessageBannerContent(
messages = rendered,
expanded = expanded,
onToggle = { if (rendered.size > 1) expanded = !expanded },
includeStatusBarPadding = includeStatusBarPadding,
)
}
}
@Composable
private fun MessageBannerContent(
messages: SnapshotStateList<UiMessage>,
expanded: Boolean,
onToggle: () -> Unit,
includeStatusBarPadding: Boolean,
) {
val newest = messages.lastOrNull() ?: return
val multiple = messages.size > 1
val insetModifier = if (includeStatusBarPadding) {
Modifier.windowInsetsPadding(WindowInsets.statusBars)
} else {
Modifier
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f))
.then(insetModifier)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Surface(
color = severityContainer(newest.severity),
contentColor = severityOnContainer(newest.severity),
shape = RoundedCornerShape(10.dp),
tonalElevation = 0.dp,
modifier = Modifier
.fillMaxWidth()
.then(if (multiple) Modifier.clickable(onClick = onToggle) else Modifier)
.animateContentSize(animationSpec = tween(durationMillis = 180)),
) {
if (!expanded) {
MessageRow(
message = newest,
trailing = {
if (multiple) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${messages.size}",
style = MaterialTheme.typography.labelSmall,
)
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "Show recent messages",
modifier = Modifier.size(18.dp),
)
}
}
},
)
} else {
// Newest first; cap the visible height to ~3 rows and scroll the
// rest so a long burst can't push the whole UI down.
val ordered = messages.reversed()
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.then(
if (ordered.size > MAX_VISIBLE_EXPANDED) {
Modifier
.heightIn(max = (ROW_MIN_HEIGHT_DP * MAX_VISIBLE_EXPANDED).dp)
.verticalScroll(scroll)
} else {
Modifier
},
),
) {
ordered.forEachIndexed { index, message ->
MessageRow(
message = message,
trailing = {
if (index == 0) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = "Collapse",
modifier = Modifier.size(18.dp),
)
}
},
)
}
}
}
}
}
}
@Composable
private fun MessageRow(
message: UiMessage,
trailing: @Composable (() -> Unit)? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = ROW_MIN_HEIGHT_DP.dp)
.padding(horizontal = 10.dp, vertical = 7.dp),
horizontalArrangement = Arrangement.spacedBy(9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = severityIcon(message.severity),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text(
text = message.text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
trailing?.invoke()
}
}
@Composable
private fun severityContainer(severity: UiMessageSeverity): Color = when (severity) {
UiMessageSeverity.Success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
UiMessageSeverity.Status -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
UiMessageSeverity.Info -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.90f)
}
@Composable
private fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
UiMessageSeverity.Success -> MaterialTheme.colorScheme.onTertiaryContainer
UiMessageSeverity.Status -> MaterialTheme.colorScheme.onSecondaryContainer
UiMessageSeverity.Info -> MaterialTheme.colorScheme.onSurfaceVariant
}
private fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
UiMessageSeverity.Success -> Icons.Filled.CheckCircle
UiMessageSeverity.Status -> Icons.Filled.Sync
UiMessageSeverity.Info -> Icons.Filled.Info
}
private fun nowMs(): Long = System.currentTimeMillis()
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
@@ -71,6 +72,8 @@ fun SessionDrawerContent(
scopeSubtitle: String? = null,
isLoading: Boolean = false,
isOpen: Boolean = true,
autoTitlesSupported: Boolean = true,
onRefresh: (() -> Unit)? = null,
onNewChat: () -> Unit,
onSelectSession: (String) -> Unit,
onDeleteSession: (String) -> Unit,
@@ -143,10 +146,29 @@ fun SessionDrawerContent(
) {
Column(modifier = Modifier.padding(16.dp)) {
// Header
Text(
text = scopeTitle,
style = MaterialTheme.typography.titleLarge
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = scopeTitle,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f),
)
// Manual re-pull: the server titles a session asynchronously after
// the first turn (and never pushes a rename), so a refresh is the
// way to pick up a title the auto-reconcile window missed.
onRefresh?.let { refresh ->
IconButton(onClick = refresh, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Filled.Refresh,
contentDescription = "Refresh sessions",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
scopeSubtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
Spacer(modifier = Modifier.height(2.dp))
Text(
@@ -199,6 +221,18 @@ fun SessionDrawerContent(
)
}
}
if (!autoTitlesSupported) {
// This connection runs chats over the api_server SSE path, which
// doesn't auto-name sessions (only the gateway transport does).
// A quiet hint so consistently-untitled chats read as expected
// rather than broken — rename is one tap away via ⋮. (issue #133)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Chats aren't auto-named on this connection — use ⋮ → Rename.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
@@ -466,6 +466,7 @@ fun ChatScreen(
val chatMode by connectionViewModel.chatMode.collectAsState()
val error by chatViewModel.error.collectAsState()
val sessions by chatViewModel.sessions.collectAsState()
val serverAutoTitles by chatViewModel.serverAutoTitles.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState()
val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState()
@@ -1330,6 +1331,8 @@ fun ChatScreen(
scopeSubtitle = drawerSubtitle,
isLoading = isLoadingSessions,
isOpen = drawerState.isOpen,
autoTitlesSupported = serverAutoTitles,
onRefresh = { chatViewModel.refreshSessions() },
onNewChat = {
chatViewModel.createNewChat()
scope.launch { drawerState.close() }
@@ -56,6 +56,7 @@ import com.hermesandroid.relay.util.PhoneSnapshot
import com.hermesandroid.relay.util.buildPromptBlock
import com.hermesandroid.relay.util.classifyError
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -750,6 +751,18 @@ class ChatViewModel : ViewModel() {
var appContextSettings: AppContextSettings = AppContextSettings()
// === END PHASE3-status ===
// Declared before [streamingEndpoint] so its setter can safely touch the
// backing field on first assignment (Kotlin initializers bypass the setter,
// but ordering it first removes any doubt).
private val _serverAutoTitles = MutableStateFlow(false)
/**
* Whether the active chat transport auto-generates session titles on the
* server. True only for the gateway (`/api/ws`) path. Drives the subtle
* "chats aren't auto-named here" hint in the session drawer.
*/
val serverAutoTitles: StateFlow<Boolean> = _serverAutoTitles.asStateFlow()
/**
* Streaming endpoint to use for the next chat turn. Always one of
* "sessions", "completions", or "runs" — never "auto", since the auto-resolver in
@@ -761,6 +774,16 @@ class ChatViewModel : ViewModel() {
* OpenAI chat path instead of assuming `/v1/runs` is an SSE stream.
*/
var streamingEndpoint: String = "completions"
set(value) {
field = value
// Only the gateway transport auto-names sessions server-side
// (tui_gateway runs the turn in a HermesCLI child that calls
// agent.title_generator.maybe_auto_title). The api_server SSE/runs/
// completions surfaces never do — see ChatHandler.updateSessions
// and the drawer note. Mirror the capability so the UI can explain
// why chats stay untitled on those transports (issue #133).
_serverAutoTitles.value = value == "gateway"
}
/**
* SSE endpoint used when a "gateway" turn can't run (gateway unreachable,
@@ -1062,6 +1085,16 @@ class ChatViewModel : ViewModel() {
*/
var profileSessionDeleter: (suspend (String) -> Boolean)? = null
/**
* Renames a session scoped to the active profile on gateway connections
* (dashboard `PATCH /api/sessions/{id}?profile=`). The write twin of
* [profileSessionDeleter]: without it, a rename on a non-default gateway
* profile patches the shared api_server DB and the new title never lands in
* the profile's own state.db. Returns `true` on success. Wired from RelayApp
* to [com.hermesandroid.relay.viewmodel.ConnectionViewModel.renameProfileScopedSession].
*/
var profileSessionRenamer: (suspend (String, String) -> Boolean)? = null
/**
* Loads a session's transcript scoped to the active profile (dashboard
* `/api/sessions/{id}/messages?profile=`). Twin of [profileSessionLister]:
@@ -1725,6 +1758,34 @@ class ChatViewModel : ViewModel() {
}
}
private var titleReconcileJob: Job? = null
/**
* Re-sync the drawer a couple of times shortly after a turn completes so a
* title the server writes *after* the response lands replaces the
* optimistic first-message preview.
*
* The server titles a session in a fire-and-forget background thread once
* the first exchange finishes (upstream agent.title_generator), and it
* never pushes a rename event — the only way to observe the new title is to
* re-list. A single post-turn [refreshSessions] races ahead of that write
* and reads the row before its title (and its flushed message_count/model)
* settle. Gated to the gateway transport: the api_server SSE/runs surfaces
* never auto-title, so retrying there would just re-fetch the same null.
* Cancel-and-replace keeps at most one reconcile in flight regardless of
* how fast turns complete.
*/
private fun scheduleTitleReconcile(sessionId: String?) {
if (sessionId.isNullOrBlank() || streamingEndpoint != "gateway") return
titleReconcileJob?.cancel()
titleReconcileJob = viewModelScope.launch {
for (delayMs in longArrayOf(3_000L, 7_000L)) {
delay(delayMs)
refreshSessions()
}
}
}
fun createNewChat() {
val client = apiClient ?: return
val handler = chatHandler ?: return
@@ -1901,7 +1962,20 @@ class ChatViewModel : ViewModel() {
handler.renameSessionLocal(sessionId, newTitle)
viewModelScope.launch {
client.renameSession(sessionId, newTitle)
// On the gateway, the session lives in the ACTIVE PROFILE's own
// state.db, so the rename must go through the dashboard
// `PATCH /api/sessions/{id}?profile=` surface — the write twin of the
// scoped list/delete. The unscoped api_server rename patches the
// shared DB, so a non-default profile's title would silently never
// persist. Off the gateway (one shared api_server DB, no profiles)
// the plain rename is correct; the renamer is also null until
// RelayApp wires it, so fall back then.
if (streamingEndpoint == "gateway") {
val scoped = profileSessionRenamer?.invoke(sessionId, newTitle)
if (scoped != true) client.renameSession(sessionId, newTitle)
} else {
client.renameSession(sessionId, newTitle)
}
}
}
@@ -3329,6 +3403,7 @@ class ChatViewModel : ViewModel() {
// from the drawer (carried only by the optimistic row) until a
// manual reload. By message.complete the dashboard list includes it.
refreshSessions()
scheduleTitleReconcile(sid)
drainQueue()
}
Unit
@@ -1093,6 +1093,24 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
.isSuccess
}
/**
* Rename a session scoped to the ACTIVE PROFILE via the dashboard
* `PATCH /api/sessions/{id}?profile=` surface — the write twin of
* [deleteProfileScopedSession]. Without this, a manual (or auto-) rename on
* a non-default gateway profile patches the shared api_server DB and the new
* title never lands in the profile's own `state.db`. Returns `false` when
* there's no dashboard surface so the caller can fall back to the shared
* api_server rename.
*/
suspend fun renameProfileScopedSession(sessionId: String, title: String): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrl() ?: return false
val profileName = AgentDisplay.profileRequestName(profileController.selectedProfile.value?.name)
return upstreamTransport.dashboardClientFor(connectionId, dashboardUrl)
.renameSession(sessionId, title, profileName)
.isSuccess
}
val selectedProfile: StateFlow<Profile?> get() = profileController.selectedProfile
/**
@@ -360,6 +360,35 @@ class ChatHandlerTest {
assertEquals("s2", sessions[0].sessionId)
}
@Test
fun updateSessions_preservesLocalTitle_whenServerReturnsNullTitle() {
// The server titles a session asynchronously after the first turn (and
// never on the SSE/runs surfaces), so a re-list often returns the row
// with title == null before/without the write. The optimistic preview
// we already show must survive that null instead of becoming "Untitled".
handler.updateSessions(listOf(SessionItem(id = "s1", title = "Fix the build")))
handler.updateSessions(listOf(SessionItem(id = "s1", title = null)))
assertEquals("Fix the build", handler.sessions.value.single().title)
}
@Test
fun updateSessions_preservesLocalTitle_whenServerReturnsBlankTitle() {
handler.updateSessions(listOf(SessionItem(id = "s1", title = "Fix the build")))
handler.updateSessions(listOf(SessionItem(id = "s1", title = " ")))
assertEquals("Fix the build", handler.sessions.value.single().title)
}
@Test
fun updateSessions_serverTitleWins_overLocalPreview() {
// Once the server generates a real title it replaces the local preview.
handler.updateSessions(listOf(SessionItem(id = "s1", title = "Fix the build")))
handler.updateSessions(listOf(SessionItem(id = "s1", title = "CI pipeline failure")))
assertEquals("CI pipeline failure", handler.sessions.value.single().title)
}
@Test
fun updateSessions_handlesNullMessageCount() {
val item = SessionItem(id = "s1", messageCount = null)
+5 -17
View File
@@ -85,10 +85,10 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.2.4 — Stability + connection security.
v1.2.5 — Stability + Try the demo.
• Fixed a crash that could close the app when the dashboard connection dropped mid-check (e.g. a brief Tailscale blip) — it now fails gracefully instead of force-closing.
• New: see whether your connection is encrypted at a glance (TLS or Tailscale) from the chat chip, connection card, and route picker, with a per-transport breakdown on tap.
• Fixed a crash that could close the app when a non-URL value (like a label or a line copied from the docs) was entered in a server address field — it now shows an inline error instead.
• New: Try the demo — explore an offline preview of the chat experience with no server or setup, right from the first screen.
```
## Category
@@ -134,22 +134,10 @@ Submission-time declarations the Play Console requires — keep in sync with the
Hermes-Relay is a client for a **user-run Hermes server**. A fresh install with no server configured has no content of its own — which is what a reviewer hits first, and what triggered the v1.2.4 *App access* rejection. The core experience is reviewable **offline via Demo mode**, with **no test server, account, or credentials required**.
In **App content → App access**, choose **"All or some functionality is restricted"** — full chat, Manage, and voice require the user to connect their own Hermes server, and choosing "restricted" is what exposes the instructions field that tells the reviewer how to get in. Add **one** access entry with **no username/password**, just these instructions:
In **App content → App access**, choose **"All or some functionality is restricted"** — full chat, Manage, and voice require the user to connect their own Hermes server, and choosing "restricted" is what exposes the instructions field that tells the reviewer how to get in. Add **one** access entry with **no username/password**, just these instructions (Play Console caps this field at **500 characters** — the text below is 423):
```
Hermes-Relay is a client for a Hermes agent server the user runs themselves,
so a fresh install has no content until a server is connected. To review the
app with no server and no account:
1. Launch the app — you land on the welcome / setup screen.
2. Tap "Try the demo". It is on the first Connect screen, and also on the
empty Chat screen if you tap Skip.
This opens an offline demo of the real Chat UI — a sample conversation with a
streaming-style reply, Markdown, a tool-progress card, and a rich card. No
login, account, or network is needed; it runs in airplane mode. A "Demo mode —
sample data, not connected" banner shows throughout, with a Connect action that
opens the real setup wizard.
This app is a client for a Hermes server the user runs themselves, so a fresh install has no content until one is connected. To review it with no server or account: launch the app, then tap "Try the demo" on the first/Connect screen (it's also on the empty Chat screen if you tap Skip). That opens an offline demo of the real chat UI - a sample conversation, no login, account, or network needed. It works in airplane mode.
```
**Reviewer note** — paste into the resubmission / appeal message to pre-empt the same rejection:
+1
View File
@@ -318,6 +318,7 @@ Implementation references:
- **Biometric:** AndroidX Biometric
- **Min SDK:** 26 (Android 8.0)
- **Target SDK:** 35
- **Compile SDK:** 37
### Server (Relay)
- **Language:** Python 3.11+
+1 -1
View File
@@ -1,6 +1,6 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.suppressUnsupportedCompileSdk=36
android.suppressUnsupportedCompileSdk=37
kotlin.code.style=official
org.gradle.java.installations.auto-detect=true
android.uniquePackageNames=false
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.2.4"
appVersionCode = "18"
appVersionName = "1.2.6"
appVersionCode = "20"
agp = "9.2.1"
kotlin = "2.4.0"
compose-bom = "2026.06.00"
+1 -1
View File
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.suppressUnsupportedCompileSdk=36
android.suppressUnsupportedCompileSdk=37
kotlin.code.style=official