Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e7911d3d | ||
|
|
fa62264962 | ||
|
|
95f7c54335 | ||
|
|
6fb9127d60 | ||
|
|
76d68622bb | ||
|
|
8e89136477 | ||
|
|
34115130da | ||
|
|
dbccdeba41 | ||
|
|
f16e5916b9 | ||
|
|
1ae0627d92 | ||
|
|
9ffc6573df | ||
|
|
6721701f16 | ||
|
|
7350dc0ab8 | ||
|
|
de44059c8e | ||
|
|
0327012666 |
+21
-1
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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**.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.suppressUnsupportedCompileSdk=36
|
||||
android.suppressUnsupportedCompileSdk=37
|
||||
kotlin.code.style=official
|
||||
|
||||
Reference in New Issue
Block a user