Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8156a821eb | ||
|
|
e6368dada8 | ||
|
|
e55662e0e1 | ||
|
|
051844bc14 | ||
|
|
1e5ce986be | ||
|
|
06b24631e3 | ||
|
|
2d2e54ba79 | ||
|
|
2f7bd843bc | ||
|
|
d1527d47e4 | ||
|
|
bc0853360a | ||
|
|
35362b8895 | ||
|
|
ff7ca89b90 |
+14
-3
@@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Changed
|
||||
|
||||
- **Android Chat and Voice use tablet space intentionally.** Expanded layouts keep introductions, transcripts, composer controls, and status chrome on readable centered rails, while landscape Voice Focus separates identity controls from conversation activity without changing phone or portrait interaction behavior.
|
||||
|
||||
## [Android 1.15.0] - 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
- **Android prefers current upstream Hermes for standard media, Git, usage, and notices.** Authenticated Dashboard file delivery, current-session `/api/git/*`, Gateway `usage.bars`, and keyed agent notices work without the optional Hermes-Relay Plugin; Relay remains additive for older-host media compatibility, sensitivity metadata, repository discovery and guarded mutations, multi-provider usage, and true Relay tools.
|
||||
- **Android Settings separates standard Hermes from Relay tools.** Media now sits with Chat and Voice under Hermes, while proactive Threads, Terminal, Notification Companion, Relay sessions, and Device Control remain clearly grouped behind the optional plugin.
|
||||
- **Android Supervised Mode uses app-specific parent access.** Parents choose a six-digit PIN or password, receive a shareable six-word recovery phrase, and can remove the credential without losing their supervised profile, capability, appearance, visibility, session, or relock settings. Android device credentials and biometrics no longer grant parent access.
|
||||
@@ -17,9 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Standard Hermes attachments no longer demand Relay pairing.** Host-local images, audio, video, and files download through the authenticated Dashboard, stay loaded across history reconciliation, and fall back to one neutral compatibility card on older hosts instead of flashing `Relay URL not configured` or retrying indefinitely.
|
||||
- **Removing optional Relay does not strand Standard voice or leak preferences across connections.** Runtime fallback keeps Dashboard voice usable, preserves configured choices through temporary outages, and normalizes only connection-scoped named-profile settings after explicit Relay removal.
|
||||
- **Relay prompt context advertises only real callable phone tools.** Phone-control and cross-platform delivery guidance now follows the exact selected session/profile tool catalog instead of implying unavailable `android_*` or `send_message` capabilities.
|
||||
- **Passively observed Desktop/TUI turns now show live activity in the Android session drawer.** A uniquely matched selected session projects Working or Waiting without Android resuming, activating, or interrupting the external runtime; ambiguous cross-profile matches remain neutral. (Related: #365)
|
||||
- **Hermes-Relay Plugin installs through the native Hermes command again.** The manifest remains fully described for current hosts while avoiding the installer/runtime schema mismatch in affected Hermes releases.
|
||||
- **Android Chat keeps one transport owner through sign-out and outages.** Dashboard/Gateway conversations now preserve their transcript, draft, profile, and session for sign-in or retry instead of silently sending the next turn to a reachable Direct API database. Legacy API-only connections and explicitly selected Direct API chats remain supported.
|
||||
- **Android keeps completed chat text visible when Dashboard sign-in expires.** Generic and reason-coded history `401` responses settle the local turn, preserve its transcript, and surface the existing sign-in recovery without reading another profile's API history.
|
||||
- **Android keeps long-running context compaction alive.** A client-visible compaction status extends and refreshes the Gateway turn watchdog instead of interrupting healthy compression after the ordinary idle window. (Supersedes #484.)
|
||||
@@ -28,6 +32,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Supervised Gateway setup stays parent-owned.** Add Gateway is single-flight and checks live parent authority before allocating a draft, relock/back cancels the exact pending setup, and the locked Chat footer no longer attempts protected navigation.
|
||||
- **Generated images stay visible and use their intended Chat animation.** Completed image media survives a marker-lagging history refresh, and both the built-in `image_generate` tool and profile tools ending in `_create_image` use the image-generation presentation.
|
||||
|
||||
## [Plugin 1.11.1] - 2026-08-31
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Hermes-Relay Plugin installs through the native Hermes command again.** The manifest remains fully described for current hosts while avoiding the installer/runtime schema mismatch in affected Hermes releases.
|
||||
- **Relay prompt context advertises only real callable phone tools.** Phone-control and cross-platform delivery guidance now follows the exact selected session/profile tool catalog instead of implying unavailable `android_*` or `send_message` capabilities.
|
||||
|
||||
## [Android 1.14.0] - 2026-08-30
|
||||
|
||||
### Added
|
||||
@@ -76,7 +87,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Dense pairing QRs scan reliably.** Dashboard, CLI, and TUI render integer-sized modules with a full quiet zone.
|
||||
- **Remote-access migration keeps existing listeners safe.** Recommended setup avoids taking over `:443`, explicit legacy cleanup remains available, and default disable actions remove only the listeners they own.
|
||||
|
||||
## [0.4.0-beta.6] - 2026-08-30
|
||||
## [0.4.0-beta.6] - 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hermes-Relay CLI+UI v__VERSION__
|
||||
|
||||
**Release Date:** 2026-08-30
|
||||
**Release Date:** 2026-08-31
|
||||
|
||||
This beta preserves complete multi-route pairing while preventing Desktop from dialing Dashboard-ingress Relay routes before Dashboard WebSocket ticket support is available. (Related: #399)
|
||||
|
||||
|
||||
+5
-19
@@ -1,29 +1,15 @@
|
||||
# Hermes-Relay Plugin v__VERSION__
|
||||
|
||||
**Release Date:** August 30, 2026
|
||||
**Release Date:** August 31, 2026
|
||||
|
||||
## Summary
|
||||
|
||||
This release lets one authenticated Hermes Dashboard origin carry Gateway plus optional Relay extensions, adds a bounded Git workspace, and reorganizes the Dashboard plugin around operator tasks. Standard chat, session history, profiles, Manage, and standard voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## Added
|
||||
|
||||
- **Dashboard same-origin Relay ingress.** Fixed allowlisted HTTP and WebSocket routes proxy to the local Relay while Dashboard admission and Relay session authentication remain independent. (Related: #399)
|
||||
- **Bounded Git workspace.** Configured roots, path containment, line totals, diffs, branches, staging, commits, remotes, grants, and explicit confirmations protect repository operations.
|
||||
|
||||
## Changed
|
||||
|
||||
- **Task-oriented Dashboard UI.** Overview, Devices, Activity, Remote Access, Git, and Settings now have dedicated surfaces with QR-first pairing, responsive device cards, and honest media diagnostics. (#486)
|
||||
- **One explicit route topology.** Dashboard, CLI, and TUI pairing advertise Dashboard, Relay, and optional API surfaces with stable priorities across Tailscale, public HTTPS, and LAN.
|
||||
- **Dedicated Tailscale listener.** Recommended setup uses tailnet HTTPS `:10443` to local Dashboard `:9119`, avoiding ownership of a reverse proxy's `:443`. Existing `:443`, `:9119`, and direct `:8767` routes remain migration compatibility.
|
||||
This patch restores native installation compatibility on affected Hermes versions and makes Relay prompt context advertise only capabilities the selected session can actually call. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Public and roaming invites no longer synthesize closed direct Relay `:8767` or wrong Dashboard `:9119` routes.
|
||||
- Ambiguous, credential-bearing, or plaintext public candidates fail closed before an invite is exposed.
|
||||
- Dense pairing QRs use integer-sized modules and a full quiet zone.
|
||||
- Inactive optional API routes are omitted; protected Dashboard-ingress `401/403` responses display as authentication-required while direct Relay and API failures remain failures.
|
||||
- Default Tailscale disable actions remove only owned listeners, and explicit migration cleanup accepts only the bounded supported ports.
|
||||
- **Native installer compatibility.** The plugin keeps its complete current manifest while avoiding the installer/runtime schema mismatch that caused `manifest_version 2` installs to fail after an apparent Hermes update.
|
||||
- **Capability-gated phone context.** Phone-control and cross-platform delivery guidance now follows the selected session/profile tool catalog instead of implying unavailable `android_*` or `send_message` callables.
|
||||
|
||||
## Install / update
|
||||
|
||||
@@ -35,7 +21,7 @@ This release lets one authenticated Hermes Dashboard origin carry Gateway plus o
|
||||
# or, if already installed:
|
||||
hermes-relay-update
|
||||
|
||||
Restart or reload the Hermes Dashboard and Relay after updating so the new manifest, routes, and committed Dashboard bundle are active.
|
||||
Restart or reload the Hermes Dashboard and Relay after updating so the new manifest and prompt context are active.
|
||||
|
||||
## Verify
|
||||
|
||||
|
||||
+22
-23
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.14.0
|
||||
# Hermes-Relay Android v1.15.0
|
||||
|
||||
**Release Date:** August 30, 2026
|
||||
**Release Date:** August 31, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.14.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.15.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,33 +12,32 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This release makes saved Hermes connections reliable across LAN, Tailscale, and public HTTPS while keeping Dashboard authentication bound to its exact trusted origin. It also adds delegated-agent previews and an optional native Git workspace, and improves Voice, Assistant, Threads, profile drafts, and Clarify interactions.
|
||||
|
||||
## Added
|
||||
|
||||
- **Delegated-agent previews.** Follow bounded lifecycle, progress, tool previews, and available read-only child history without leaving the parent chat. Partial history and reconnect gaps remain explicit. (#447)
|
||||
- **Native Git workspace.** Review repository state, diffs, branches, staging, commits, and remotes from Chat or Settings. Git operations require Hermes-Relay Plugin v1.11.0 and retain confirmation and grant boundaries.
|
||||
This release makes the everyday Android experience work through current upstream Hermes without treating the optional Hermes-Relay Plugin as a prerequisite. It also strengthens parent access, cross-client activity, transport ownership, recovery, and release visibility.
|
||||
|
||||
## Changed
|
||||
|
||||
- **Route-aware connections.** Dashboard, Relay, and optional API health are evaluated independently across LAN, Tailscale, and public HTTPS. Same-origin Relay ingress stays on the Dashboard origin that owns authentication, while direct compatibility routes keep separate credentials. (Related: #399)
|
||||
- **Voice Focus controls.** Stop and immediate spoken steering remain accessible while Hermes is Thinking, Transcribing, or Speaking, including TalkBack, Switch Access, keyboard, and sideload overlay surfaces.
|
||||
- **Upstream-first standard surfaces.** Chat attachments, inbound host files, current-session Git reads, Nous usage bars, and keyed Hermes notices use authenticated Dashboard/Gateway contracts first. Relay remains an additive compatibility and enhancement layer.
|
||||
- **Clear Settings ownership.** Media sits with Chat and Voice under Hermes. Proactive Threads, Terminal, Notification Companion, Relay sessions, enhanced voice, and Device Control stay grouped under Relay tools.
|
||||
- **App-specific supervised parent access.** Parents choose a PIN or password and receive a six-word recovery phrase; Android device credentials and biometrics no longer grant parent authority.
|
||||
- **Complete What's New history.** Release highlights, the remaining improvements and fixes, compatibility notes, toast counts, and history now come from one structured inventory.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Wake-word detection packages one compatible ONNX Runtime for sherpa and Java JNI on every supported ABI. (#444)
|
||||
- Continuous voice waits for barge-in microphone teardown before listening again. (#464)
|
||||
- Fresh chats retain their selected profile without reopening a previous session or carrying a proactive Thread route across profiles. (#436)
|
||||
- Provisional Threads can be removed locally and reconcile with promoted sessions without deleting server history. (#461)
|
||||
- Clarify cards expose a reachable Other answer, keyboard Send, and authoritative expiry behavior. (#446)
|
||||
- Passive Android browsing no longer claims or interrupts a turn owned by another client. (Related: #365)
|
||||
- Assistant sessions show retryable no-speech feedback, recover their active state after recreation, and redact conversation details behind the keyguard. (Related: #424)
|
||||
- Protected Relay ingress `401/403` responses are recognized as authentication boundaries rather than outages; malformed, different-origin, and direct unauthorized routes still fail closed.
|
||||
- Host-local images, audio, video, and files download through the authenticated Dashboard and remain loaded across history reconciliation instead of flashing `Relay URL not configured` or returning to Loading.
|
||||
- Standard voice remains usable after explicit Relay removal, while temporary Relay outages preserve configured choices and default-profile preferences stay isolated across connections.
|
||||
- Android can display uniquely matched Desktop/TUI Working and Waiting activity without resuming, activating, or interrupting the external turn.
|
||||
- Dashboard/Gateway chats preserve their transcript, draft, profile, and session through sign-out or outages instead of silently changing to Direct API.
|
||||
- Completed text survives Dashboard-history authentication expiry and returns the existing sign-in recovery path.
|
||||
- Long-running context compaction refreshes the turn watchdog instead of being interrupted as idle.
|
||||
- Bot Chat history renders immediately after its route-owned handler binds.
|
||||
- An exact idle live-session snapshot settles an Android-owned turn whose terminal frame was lost, reconciles history, and drains its queued follow-up.
|
||||
- Supervised Add Gateway remains parent-owned across relock, back navigation, and pending setup cancellation.
|
||||
- Completed generated images stay visible during marker persistence lag and retain the intended image-generation presentation.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.14.0** (versionCode **52**).
|
||||
- Standard Chat, sessions, profiles, Manage, and standard voice continue to work against unmodified upstream Hermes without the optional Relay plugin.
|
||||
- Install Hermes-Relay Plugin v1.11.0 for same-origin Relay extensions, Git workspace actions, Bridge, media, proactive features, and enhanced voice.
|
||||
- App version: **1.15.0** (versionCode **53**).
|
||||
- Standard Chat, sessions, profiles, Manage, standard voice, outbound attachments, and ordinary inbound files work against current unmodified upstream Hermes without the optional Hermes-Relay Plugin.
|
||||
- Install Hermes-Relay Plugin v1.11.1 for Terminal, proactive Threads/offline delivery, Notification Companion, Relay sessions, provider-native/realtime voice, compatibility media metadata, Secure Link, and phone/device control.
|
||||
- Explicit Direct API/API-only connections remain supported and are never used as a silent failover for a Dashboard-owned chat.
|
||||
- Granular Device Control and the system Voice Focus overlay remain sideload-only; the Google Play build does not declare their restricted permissions.
|
||||
- Existing connections, drafts, sessions, profile ownership, and legacy direct Relay routes remain data-preserving compatibility paths.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
v1.14.0 - Connections, delegated work, Git, and voice
|
||||
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
|
||||
|
||||
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
|
||||
Standard Chat, Voice, attachments, returned files, current-session Git, usage, and Hermes notices now prefer upstream Dashboard and Gateway support without requiring Relay. Returned media stays loaded, voice survives Relay removal, and Settings clearly separates standard Hermes from Relay tools. Supervised Mode also gains app-specific parent access and recovery.
|
||||
|
||||
@@ -1,6 +1,108 @@
|
||||
{
|
||||
"schema": 3,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.15.0",
|
||||
"title": "Standard Hermes first, with clearer Relay boundaries",
|
||||
"date": "2026-08-31",
|
||||
"summary": "Chat, voice, attachments, inbound files, current-session Git, usage, and Hermes notices now prefer current upstream Dashboard and Gateway support. Relay stays optional for compatibility and the tools it uniquely provides.",
|
||||
"changes": [
|
||||
{
|
||||
"id": "upstream-standard-surfaces",
|
||||
"kind": "improved",
|
||||
"title": "Use standard Hermes without Relay prompts",
|
||||
"summary": "Chat attachments, inbound files, current-session Git, Nous usage, and Hermes notices use upstream routes first.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "stable-inbound-media",
|
||||
"kind": "fixed",
|
||||
"title": "Keep returned files loaded",
|
||||
"summary": "Images, audio, video, and documents download through the Dashboard and no longer flash Relay errors or return to Loading.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "supervised-parent-access",
|
||||
"kind": "improved",
|
||||
"title": "Use app-specific parent access",
|
||||
"summary": "A parent PIN or password plus recovery phrase protects Supervised Mode without trusting the phone unlock credential.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "settings-relay-boundaries",
|
||||
"kind": "improved",
|
||||
"title": "See which features need Relay",
|
||||
"summary": "Media sits with standard Hermes settings while Threads, Terminal, notifications, enhanced voice, and device tools stay under Relay tools."
|
||||
},
|
||||
{
|
||||
"id": "complete-release-history",
|
||||
"kind": "improved",
|
||||
"title": "Read the complete release record",
|
||||
"summary": "What's New shows one release summary, selected highlights, every remaining change, and relevant compatibility notes."
|
||||
},
|
||||
{
|
||||
"id": "relay-removal-voice",
|
||||
"kind": "fixed",
|
||||
"title": "Keep Standard voice after removing Relay",
|
||||
"summary": "Dashboard voice remains ready, temporary outages preserve choices, and shared default-profile settings stay isolated."
|
||||
},
|
||||
{
|
||||
"id": "passive-external-activity",
|
||||
"kind": "fixed",
|
||||
"title": "Observe another client's activity safely",
|
||||
"summary": "A uniquely matched Desktop or TUI turn can show Working or Waiting without Android taking control."
|
||||
},
|
||||
{
|
||||
"id": "chat-transport-ownership",
|
||||
"kind": "fixed",
|
||||
"title": "Keep each chat on its chosen transport",
|
||||
"summary": "Dashboard chats preserve their transcript, draft, profile, and session through sign-out or outages instead of silently changing databases."
|
||||
},
|
||||
{
|
||||
"id": "history-auth-recovery",
|
||||
"kind": "fixed",
|
||||
"title": "Preserve completed replies at sign-in expiry",
|
||||
"summary": "A Dashboard history authentication failure keeps completed text visible and opens the existing sign-in recovery path."
|
||||
},
|
||||
{
|
||||
"id": "compaction-watchdog",
|
||||
"kind": "fixed",
|
||||
"title": "Let long context compaction finish",
|
||||
"summary": "Visible compaction activity refreshes the turn watchdog instead of being interrupted as idle."
|
||||
},
|
||||
{
|
||||
"id": "bot-chat-binding",
|
||||
"kind": "fixed",
|
||||
"title": "Render Bot Chat history immediately",
|
||||
"summary": "Route-owned Bot Chats observe their bound history from first composition."
|
||||
},
|
||||
{
|
||||
"id": "missing-terminal-recovery",
|
||||
"kind": "fixed",
|
||||
"title": "Settle turns after a lost terminal frame",
|
||||
"summary": "An exact idle live-session snapshot reconciles the Android-owned turn and drains its queued follow-up."
|
||||
},
|
||||
{
|
||||
"id": "supervised-gateway-setup",
|
||||
"kind": "fixed",
|
||||
"title": "Keep Gateway setup parent-owned",
|
||||
"summary": "Relock and back navigation cancel the exact pending setup without bypassing parent authority."
|
||||
},
|
||||
{
|
||||
"id": "generated-image-retention",
|
||||
"kind": "fixed",
|
||||
"title": "Keep completed generated images visible",
|
||||
"summary": "Generated media survives marker persistence lag and retains its intended Chat animation."
|
||||
}
|
||||
],
|
||||
"compatibility": [
|
||||
"Current upstream Hermes provides standard Chat, sessions, Manage, voice, attachments, inbound files, current-session Git reads, usage, and notices without the optional Hermes-Relay Plugin.",
|
||||
"Hermes-Relay Plugin 1.11.1 remains required for Terminal, proactive Threads and offline delivery, Notification Companion, Relay sessions, enhanced voice, Secure Link, and phone or device control.",
|
||||
"Granular Device Control and the system Voice Focus overlay remain sideload-only."
|
||||
],
|
||||
"playNotes": "Standard Chat, Voice, attachments, returned files, current-session Git, usage, and Hermes notices now prefer upstream Dashboard and Gateway support without requiring Relay. Returned media stays loaded, voice survives Relay removal, and Settings clearly separates standard Hermes from Relay tools. Supervised Mode also gains app-specific parent access and recovery.",
|
||||
"sections": []
|
||||
},
|
||||
{
|
||||
"version": "1.14.0",
|
||||
"title": "Connections, delegated work, Git, and voice",
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
v1.14.0 - Connections, delegated work, Git, and voice
|
||||
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
|
||||
|
||||
Summary
|
||||
* Connections now recover cleanly across networks. You can also follow delegated agents, work with Git repositories, and rely on steadier voice, sessions, Threads, profiles, Assistant, and Clarify controls.
|
||||
* Chat, voice, attachments, inbound files, current-session Git, usage, and Hermes notices now prefer current upstream Dashboard and Gateway support. Relay stays optional for compatibility and the tools it uniquely provides.
|
||||
|
||||
Highlights
|
||||
* Connections recover independently — Move between LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication.
|
||||
* Follow delegated-agent activity — See lifecycle, progress, tool previews, and available read-only child history from the parent chat.
|
||||
* Work with repositories from Android — Review status, diffs, branches, staging, commits, and remotes from Chat or Settings.
|
||||
* Steer voice at any time — Stop or redirect Hermes while it is Thinking, Transcribing, or Speaking, including with accessibility controls.
|
||||
* Use standard Hermes without Relay prompts — Chat attachments, inbound files, current-session Git, Nous usage, and Hermes notices use upstream routes first.
|
||||
* Keep returned files loaded — Images, audio, video, and documents download through the Dashboard and no longer flash Relay errors or return to Loading.
|
||||
* Use app-specific parent access — A parent PIN or password plus recovery phrase protects Supervised Mode without trusting the phone unlock credential.
|
||||
|
||||
Improved
|
||||
* Release notes stay out of your way — A dismissible post-update notice keeps startup usable and leaves the complete history available from Settings.
|
||||
* Chat uses one consistent presentation — The overlapping clean-focus mode was removed while the separate Voice Focus experience remains available.
|
||||
* See which features need Relay — Media sits with standard Hermes settings while Threads, Terminal, notifications, enhanced voice, and device tools stay under Relay tools.
|
||||
* Read the complete release record — What's New shows one release summary, selected highlights, every remaining change, and relevant compatibility notes.
|
||||
|
||||
Fixed
|
||||
* Wake-word detection starts reliably — Compatible native voice components are now packaged for every supported phone architecture.
|
||||
* The visible Sphere keeps moving smoothly — Foreground animation no longer falls back to a stepped ambient pulse.
|
||||
* Continuous voice keeps the microphone — The next listening turn waits for barge-in recording to release cleanly.
|
||||
* New chats keep the selected profile — Fresh drafts no longer reopen an older session or carry a Thread route into another profile.
|
||||
* Provisional Threads can be removed safely — Local removal and later session promotion no longer risk duplicate rows or server history.
|
||||
* Clarify keeps custom answers reachable — Other answers, keyboard Send, and expired prompts now behave consistently.
|
||||
* Browsing no longer interrupts another client — Passive Android observation does not claim a turn owned by Desktop, TUI, or another client.
|
||||
* Assistant sessions recover more clearly — No-speech feedback, recreated session state, and keyguard privacy now remain intact.
|
||||
* Protected Relay routes report the right problem — Authentication challenges are no longer presented as outages, while unsafe routes still fail closed.
|
||||
* Connections and sessions become ready sooner — Unavailable optional API and Relay routes no longer delay a healthy Dashboard or authenticated session history.
|
||||
* Keep Standard voice after removing Relay — Dashboard voice remains ready, temporary outages preserve choices, and shared default-profile settings stay isolated.
|
||||
* Observe another client's activity safely — A uniquely matched Desktop or TUI turn can show Working or Waiting without Android taking control.
|
||||
* Keep each chat on its chosen transport — Dashboard chats preserve their transcript, draft, profile, and session through sign-out or outages instead of silently changing databases.
|
||||
* Preserve completed replies at sign-in expiry — A Dashboard history authentication failure keeps completed text visible and opens the existing sign-in recovery path.
|
||||
* Let long context compaction finish — Visible compaction activity refreshes the turn watchdog instead of being interrupted as idle.
|
||||
* Render Bot Chat history immediately — Route-owned Bot Chats observe their bound history from first composition.
|
||||
* Settle turns after a lost terminal frame — An exact idle live-session snapshot reconciles the Android-owned turn and drains its queued follow-up.
|
||||
* Keep Gateway setup parent-owned — Relock and back navigation cancel the exact pending setup without bypassing parent authority.
|
||||
* Keep completed generated images visible — Generated media survives marker persistence lag and retains its intended Chat animation.
|
||||
|
||||
Compatibility
|
||||
* Standard Chat, sessions, profiles, Manage, and standard voice continue to work without the optional Hermes-Relay Plugin.
|
||||
* The Git workspace and same-origin Relay extensions require Hermes-Relay Plugin 1.11.0.
|
||||
* Granular Device Control and the system Voice Focus overlay remain available only in the sideload build.
|
||||
* Current upstream Hermes provides standard Chat, sessions, Manage, voice, attachments, inbound files, current-session Git reads, usage, and notices without the optional Hermes-Relay Plugin.
|
||||
* Hermes-Relay Plugin 1.11.1 remains required for Terminal, proactive Threads and offline delivery, Notification Companion, Relay sessions, enhanced voice, Secure Link, and phone or device control.
|
||||
* Granular Device Control and the system Voice Focus overlay remain sideload-only.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Width policy shared by Chat's content and persistent status surfaces. */
|
||||
internal data class ChatResponsiveLayout(
|
||||
val introMaxWidth: Dp?,
|
||||
val avatarSize: Dp?,
|
||||
val chromeMaxWidth: Dp?,
|
||||
val transcriptMaxWidth: Dp?,
|
||||
val focusVoiceMaxWidth: Dp?,
|
||||
)
|
||||
|
||||
internal fun chatResponsiveLayout(screenWidthDp: Int): ChatResponsiveLayout = when {
|
||||
screenWidthDp >= 840 -> ChatResponsiveLayout(
|
||||
introMaxWidth = 720.dp,
|
||||
avatarSize = 360.dp,
|
||||
chromeMaxWidth = 960.dp,
|
||||
transcriptMaxWidth = 960.dp,
|
||||
focusVoiceMaxWidth = 1120.dp,
|
||||
)
|
||||
screenWidthDp >= 600 -> ChatResponsiveLayout(
|
||||
introMaxWidth = 600.dp,
|
||||
avatarSize = 300.dp,
|
||||
chromeMaxWidth = 760.dp,
|
||||
transcriptMaxWidth = 760.dp,
|
||||
focusVoiceMaxWidth = 760.dp,
|
||||
)
|
||||
else -> ChatResponsiveLayout(
|
||||
introMaxWidth = null,
|
||||
avatarSize = null,
|
||||
chromeMaxWidth = null,
|
||||
transcriptMaxWidth = null,
|
||||
focusVoiceMaxWidth = null,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun useSplitVoiceLayout(screenWidthDp: Int, screenHeightDp: Int): Boolean =
|
||||
screenWidthDp >= 840 && screenWidthDp > screenHeightDp
|
||||
@@ -2200,6 +2200,9 @@ fun RelayApp() {
|
||||
// Routine in-progress reconnect surfaces here (amber cue)
|
||||
// instead of a take-space banner or a floating toast.
|
||||
reconnecting = connectionReconnecting,
|
||||
maxContentWidth = chatResponsiveLayout(
|
||||
LocalConfiguration.current.screenWidthDp,
|
||||
).chromeMaxWidth,
|
||||
modifier = Modifier.petPerchSurface(
|
||||
key = APP_STATUS_PET_WALK_REGION,
|
||||
routes = APP_STATUS_PET_ROUTES,
|
||||
|
||||
@@ -4,6 +4,7 @@ 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.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
@@ -24,6 +26,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
@@ -48,63 +51,70 @@ fun RelayStatusStrip(
|
||||
* surfaces — the top chrome stays empty so chat content never shifts.
|
||||
*/
|
||||
reconnecting: Boolean = false,
|
||||
maxContentWidth: Dp? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
|
||||
),
|
||||
)
|
||||
.padding(start = 14.dp, end = 14.dp, top = 3.dp, bottom = 4.dp)
|
||||
.clip(appearanceRoundedCornerShape(16.dp))
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.relayPanel(
|
||||
shape = appearanceRoundedCornerShape(16.dp),
|
||||
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
|
||||
borderColor = RelayRefresh.Line,
|
||||
),
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
Column(
|
||||
modifier = modifier
|
||||
.then(maxContentWidth?.let { Modifier.widthIn(max = it) } ?: Modifier)
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 22.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom,
|
||||
),
|
||||
)
|
||||
.padding(start = 14.dp, end = 14.dp, top = 3.dp, bottom = 4.dp)
|
||||
.clip(appearanceRoundedCornerShape(16.dp))
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.relayPanel(
|
||||
shape = appearanceRoundedCornerShape(16.dp),
|
||||
background = RelayRefresh.Navy2.copy(alpha = 0.88f),
|
||||
borderColor = RelayRefresh.Line,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 22.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
leadingBadge()
|
||||
if (securityGlyph != null) {
|
||||
securityGlyph()
|
||||
}
|
||||
// Route is in flux mid-reconnect, so the amber cue replaces the
|
||||
// route label rather than stacking beside it in the 22dp strip.
|
||||
when {
|
||||
reconnecting -> ReconnectingCue(modifier = Modifier.weight(1f))
|
||||
routeLabel.isNotBlank() -> Text(
|
||||
text = "· $routeLabel",
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
leadingBadge()
|
||||
if (securityGlyph != null) {
|
||||
securityGlyph()
|
||||
}
|
||||
// Route is in flux mid-reconnect, so the amber cue replaces the
|
||||
// route label rather than stacking beside it in the 22dp strip.
|
||||
when {
|
||||
reconnecting -> ReconnectingCue(modifier = Modifier.weight(1f))
|
||||
routeLabel.isNotBlank() -> Text(
|
||||
text = "· $routeLabel",
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = trailing,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = trailing,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,14 +23,18 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -71,6 +75,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -81,6 +86,7 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
@@ -92,7 +98,9 @@ import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalBackgroundVisualizationEnabled
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.chatResponsiveLayout
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.ui.useSplitVoiceLayout
|
||||
import com.hermesandroid.relay.util.HumanError
|
||||
import kotlinx.coroutines.delay
|
||||
import com.hermesandroid.relay.viewmodel.BackgroundRunPhase
|
||||
@@ -112,6 +120,8 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
|
||||
internal const val VOICE_MODE_MIC_TEST_TAG = "voiceModeMic"
|
||||
internal const val VOICE_FOCUS_SPLIT_LAYOUT_TEST_TAG = "voiceFocusSplitLayout"
|
||||
internal const val VOICE_FOCUS_STACKED_LAYOUT_TEST_TAG = "voiceFocusStackedLayout"
|
||||
|
||||
/**
|
||||
* Full-screen voice-mode overlay. Renders the MorphingSphere in its voiceMode
|
||||
@@ -186,6 +196,56 @@ fun VoiceModeOverlay(
|
||||
val backgroundVisualizationEnabled = LocalBackgroundVisualizationEnabled.current
|
||||
val surface = MaterialTheme.colorScheme.surface
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val responsiveLayout = chatResponsiveLayout(configuration.screenWidthDp)
|
||||
val splitFocusLayout = useSplitVoiceLayout(
|
||||
screenWidthDp = configuration.screenWidthDp,
|
||||
screenHeightDp = configuration.screenHeightDp,
|
||||
)
|
||||
|
||||
val focusMicTap: () -> Unit = {
|
||||
dispatchVoiceMicTap(
|
||||
uiState = uiState,
|
||||
onStartListening = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicTap()
|
||||
},
|
||||
onStopListening = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicRelease()
|
||||
},
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
}
|
||||
val focusMicHoldPress: () -> Unit = {
|
||||
dispatchVoiceMicHoldPress(
|
||||
uiState = uiState,
|
||||
onStartListening = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicTap()
|
||||
},
|
||||
onInterruptAndStart = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onInterrupt()
|
||||
onMicTap()
|
||||
},
|
||||
)
|
||||
}
|
||||
val focusMicHoldRelease: () -> Unit = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicRelease()
|
||||
}
|
||||
|
||||
var controlsExpanded by remember { mutableStateOf(false) }
|
||||
val focusMode = presentationMode == VoicePresentationMode.Focus
|
||||
@@ -253,6 +313,11 @@ fun VoiceModeOverlay(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onExit = onDismiss,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
responsiveLayout.focusVoiceMaxWidth?.let {
|
||||
Modifier.widthIn(max = it)
|
||||
} ?: Modifier,
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
@@ -328,162 +393,93 @@ fun VoiceModeOverlay(
|
||||
exit = fadeOut(tween(120)),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 92.dp, bottom = 160.dp, start = 20.dp, end = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1.0f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (backgroundVisualizationEnabled) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
state = voiceStateToSphereState(uiState.state),
|
||||
voiceAmplitude = uiState.amplitude,
|
||||
voiceMode = true,
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
if (splitFocusLayout) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.widthIn(max = responsiveLayout.focusVoiceMaxWidth ?: 1120.dp)
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.padding(top = 92.dp, bottom = 28.dp, start = 32.dp, end = 32.dp)
|
||||
.testTag(VOICE_FOCUS_SPLIT_LAYOUT_TEST_TAG),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
VoiceFocusIdentityPane(
|
||||
uiState = uiState,
|
||||
backgroundVisualizationEnabled = backgroundVisualizationEnabled,
|
||||
onMicTap = focusMicTap,
|
||||
onMicHoldPress = focusMicHoldPress,
|
||||
onMicHoldRelease = focusMicHoldRelease,
|
||||
showMic = true,
|
||||
modifier = Modifier
|
||||
.weight(0.86f)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
Spacer(Modifier.width(32.dp))
|
||||
VoiceFocusStatusAndTranscriptPane(
|
||||
uiState = uiState,
|
||||
transcriptMessages = visibleTranscriptMessages,
|
||||
pendingTranscriptText = pendingTranscriptText,
|
||||
transcriptListState = transcriptListState,
|
||||
showThinking = showThinking,
|
||||
onBackgroundRunCancel = onBackgroundRunCancel,
|
||||
onBackgroundRunTap = onBackgroundRunTap,
|
||||
onPermissionDeniedChipTap = onPermissionDeniedChipTap,
|
||||
onHermesConfirmationAnswer = onHermesConfirmationAnswer,
|
||||
onPresentationModeChange = onPresentationModeChange,
|
||||
onCardAction = onCardAction,
|
||||
onCardInput = onCardInput,
|
||||
horizontalContentPadding = 0.dp,
|
||||
modifier = Modifier
|
||||
.weight(1.14f)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VoiceWaveform(
|
||||
amplitude = uiState.amplitude,
|
||||
state = uiState.state,
|
||||
outputAudioActive = uiState.outputAudioActive,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = uiState.handoffStatus != null,
|
||||
enter = fadeIn(tween(140)),
|
||||
exit = fadeOut(tween(180)),
|
||||
) {
|
||||
VoiceHandoffStrip(
|
||||
status = uiState.handoffStatus,
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
BackgroundRunChip(
|
||||
run = uiState.backgroundRun,
|
||||
onCancel = onBackgroundRunCancel,
|
||||
onTap = onBackgroundRunTap,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
DestructiveCountdownRow(
|
||||
countdown = uiState.destructiveCountdown,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
)
|
||||
|
||||
PermissionDeniedChip(
|
||||
callout = uiState.permissionDeniedCallout,
|
||||
onTap = onPermissionDeniedChipTap,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
HermesConfirmationCard(
|
||||
confirmation = uiState.hermesConfirmation,
|
||||
onAnswer = onHermesConfirmationAnswer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1.25f, fill = true),
|
||||
) {
|
||||
val hasTranscript = visibleTranscriptMessages.isNotEmpty() ||
|
||||
pendingTranscriptText != null
|
||||
if (hasTranscript) {
|
||||
val latestId = visibleTranscriptMessages.lastOrNull()?.id
|
||||
LazyColumn(
|
||||
state = transcriptListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
// Match ChatScreen's identity contract. A history
|
||||
// reconcile can adopt the same authoritative server
|
||||
// id into a live row while Compose still holds the
|
||||
// pre-reconcile row for a frame. uiKey remains stable
|
||||
// and unique across that transition.
|
||||
items(visibleTranscriptMessages, key = ::voiceTranscriptItemKey) { msg ->
|
||||
CompactTranscriptRow(
|
||||
message = msg,
|
||||
showThinking = showThinking,
|
||||
expanded = msg.id == latestId || msg.isStreaming,
|
||||
onViewConversation = {
|
||||
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||
},
|
||||
onCardAction = onCardAction,
|
||||
onCardInput = onCardInput,
|
||||
)
|
||||
}
|
||||
if (pendingTranscriptText != null) {
|
||||
item(key = "aux:pending-voice-transcript") {
|
||||
CompactTranscriptRow(
|
||||
message = ChatMessage(
|
||||
id = "pending-voice-transcript",
|
||||
role = MessageRole.USER,
|
||||
content = pendingTranscriptText,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
),
|
||||
showThinking = showThinking,
|
||||
expanded = true,
|
||||
onViewConversation = {
|
||||
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||
},
|
||||
onCardAction = onCardAction,
|
||||
onCardInput = onCardInput,
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(12.dp)) }
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = stateHint(uiState.state),
|
||||
transitionSpec = {
|
||||
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
|
||||
},
|
||||
label = "stateHint",
|
||||
) { hint ->
|
||||
Text(
|
||||
text = hint,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
.then(
|
||||
responsiveLayout.focusVoiceMaxWidth?.let {
|
||||
Modifier.widthIn(max = it)
|
||||
} ?: Modifier,
|
||||
)
|
||||
.fillMaxSize()
|
||||
.padding(top = 92.dp, bottom = 160.dp, start = 20.dp, end = 20.dp)
|
||||
.testTag(VOICE_FOCUS_STACKED_LAYOUT_TEST_TAG),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
VoiceFocusIdentityPane(
|
||||
uiState = uiState,
|
||||
backgroundVisualizationEnabled = backgroundVisualizationEnabled,
|
||||
onMicTap = focusMicTap,
|
||||
onMicHoldPress = focusMicHoldPress,
|
||||
onMicHoldRelease = focusMicHoldRelease,
|
||||
showMic = false,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
VoiceFocusStatusAndTranscriptPane(
|
||||
uiState = uiState,
|
||||
transcriptMessages = visibleTranscriptMessages,
|
||||
pendingTranscriptText = pendingTranscriptText,
|
||||
transcriptListState = transcriptListState,
|
||||
showThinking = showThinking,
|
||||
onBackgroundRunCancel = onBackgroundRunCancel,
|
||||
onBackgroundRunTap = onBackgroundRunTap,
|
||||
onPermissionDeniedChipTap = onPermissionDeniedChipTap,
|
||||
onHermesConfirmationAnswer = onHermesConfirmationAnswer,
|
||||
onPresentationModeChange = onPresentationModeChange,
|
||||
onCardAction = onCardAction,
|
||||
onCardInput = onCardInput,
|
||||
horizontalContentPadding = 24.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1.25f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -498,6 +494,12 @@ fun VoiceModeOverlay(
|
||||
exit = fadeOut(tween(180)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.then(
|
||||
responsiveLayout.chromeMaxWidth?.let {
|
||||
Modifier.widthIn(max = it)
|
||||
} ?: Modifier,
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 176.dp, start = 24.dp, end = 24.dp),
|
||||
) {
|
||||
BackgroundRunChip(
|
||||
@@ -515,6 +517,12 @@ fun VoiceModeOverlay(
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.then(
|
||||
responsiveLayout.chromeMaxWidth?.let {
|
||||
Modifier.widthIn(max = it)
|
||||
} ?: Modifier,
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 64.dp, start = 16.dp, end = 16.dp),
|
||||
) {
|
||||
Surface(
|
||||
@@ -570,57 +578,205 @@ fun VoiceModeOverlay(
|
||||
// that visually said "Stop" but was actually wired to "Start."
|
||||
VoiceMicButton(
|
||||
uiState = uiState,
|
||||
onTap = {
|
||||
dispatchVoiceMicTap(
|
||||
uiState = uiState,
|
||||
onStartListening = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicTap()
|
||||
},
|
||||
onStopListening = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicRelease()
|
||||
},
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
},
|
||||
onHoldPress = {
|
||||
dispatchVoiceMicHoldPress(
|
||||
uiState = uiState,
|
||||
onStartListening = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicTap()
|
||||
},
|
||||
onInterruptAndStart = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onInterrupt()
|
||||
onMicTap()
|
||||
},
|
||||
)
|
||||
},
|
||||
onHoldRelease = {
|
||||
try {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
onMicRelease()
|
||||
},
|
||||
onTap = focusMicTap,
|
||||
onHoldPress = focusMicHoldPress,
|
||||
onHoldRelease = focusMicHoldRelease,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 48.dp),
|
||||
visible = focusMode,
|
||||
visible = focusMode && !splitFocusLayout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceFocusIdentityPane(
|
||||
uiState: VoiceUiState,
|
||||
backgroundVisualizationEnabled: Boolean,
|
||||
onMicTap: () -> Unit,
|
||||
onMicHoldPress: () -> Unit,
|
||||
onMicHoldRelease: () -> Unit,
|
||||
showMic: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.testTag("voiceFocusIdentityPane"),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (backgroundVisualizationEnabled) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
state = voiceStateToSphereState(uiState.state),
|
||||
voiceAmplitude = uiState.amplitude,
|
||||
voiceMode = true,
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
VoiceWaveform(
|
||||
amplitude = uiState.amplitude,
|
||||
state = uiState.state,
|
||||
outputAudioActive = uiState.outputAudioActive,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
if (showMic) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
VoiceMicButton(
|
||||
uiState = uiState,
|
||||
onTap = onMicTap,
|
||||
onHoldPress = onMicHoldPress,
|
||||
onHoldRelease = onMicHoldRelease,
|
||||
visible = true,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceFocusStatusAndTranscriptPane(
|
||||
uiState: VoiceUiState,
|
||||
transcriptMessages: List<ChatMessage>,
|
||||
pendingTranscriptText: String?,
|
||||
transcriptListState: LazyListState,
|
||||
showThinking: Boolean,
|
||||
onBackgroundRunCancel: () -> Unit,
|
||||
onBackgroundRunTap: () -> Unit,
|
||||
onPermissionDeniedChipTap: (PermissionDeniedCallout) -> Unit,
|
||||
onHermesConfirmationAnswer: (String) -> Unit,
|
||||
onPresentationModeChange: (VoicePresentationMode) -> Unit,
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit,
|
||||
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit,
|
||||
horizontalContentPadding: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.testTag("voiceFocusTranscriptPane")) {
|
||||
AnimatedVisibility(
|
||||
visible = uiState.handoffStatus != null,
|
||||
enter = fadeIn(tween(140)),
|
||||
exit = fadeOut(tween(180)),
|
||||
) {
|
||||
VoiceHandoffStrip(
|
||||
status = uiState.handoffStatus,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
BackgroundRunChip(
|
||||
run = uiState.backgroundRun,
|
||||
onCancel = onBackgroundRunCancel,
|
||||
onTap = onBackgroundRunTap,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
DestructiveCountdownRow(
|
||||
countdown = uiState.destructiveCountdown,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = horizontalContentPadding),
|
||||
)
|
||||
PermissionDeniedChip(
|
||||
callout = uiState.permissionDeniedCallout,
|
||||
onTap = onPermissionDeniedChipTap,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
|
||||
)
|
||||
HermesConfirmationCard(
|
||||
confirmation = uiState.hermesConfirmation,
|
||||
onAnswer = onHermesConfirmationAnswer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = horizontalContentPadding, vertical = 4.dp),
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = true),
|
||||
) {
|
||||
val hasTranscript = transcriptMessages.isNotEmpty() || pendingTranscriptText != null
|
||||
if (hasTranscript) {
|
||||
val latestId = transcriptMessages.lastOrNull()?.id
|
||||
LazyColumn(
|
||||
state = transcriptListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
// uiKey remains stable while Gateway reconciliation adopts
|
||||
// an authoritative server id into a live transcript row.
|
||||
items(transcriptMessages, key = ::voiceTranscriptItemKey) { msg ->
|
||||
CompactTranscriptRow(
|
||||
message = msg,
|
||||
showThinking = showThinking,
|
||||
expanded = msg.id == latestId || msg.isStreaming,
|
||||
onViewConversation = {
|
||||
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||
},
|
||||
onCardAction = onCardAction,
|
||||
onCardInput = onCardInput,
|
||||
)
|
||||
}
|
||||
if (pendingTranscriptText != null) {
|
||||
item(key = "aux:pending-voice-transcript") {
|
||||
CompactTranscriptRow(
|
||||
message = ChatMessage(
|
||||
id = "pending-voice-transcript",
|
||||
role = MessageRole.USER,
|
||||
content = pendingTranscriptText,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
),
|
||||
showThinking = showThinking,
|
||||
expanded = true,
|
||||
onViewConversation = {
|
||||
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||
},
|
||||
onCardAction = onCardAction,
|
||||
onCardInput = onCardInput,
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(12.dp)) }
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = stateHint(uiState.state),
|
||||
transitionSpec = {
|
||||
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
|
||||
},
|
||||
label = "stateHint",
|
||||
) { hint ->
|
||||
Text(
|
||||
text = hint,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceMicButton(
|
||||
uiState: VoiceUiState,
|
||||
|
||||
@@ -98,6 +98,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
@@ -119,6 +120,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.chatResponsiveLayout
|
||||
import com.hermesandroid.relay.ui.theme.radialNavyBackground
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelOption
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
@@ -749,6 +751,7 @@ fun ChatScreen(
|
||||
gitWorkspaceAvailable: Boolean = gitWorkspaceSummary != null,
|
||||
onNavigateToGitWorkspace: () -> Unit = {},
|
||||
) {
|
||||
val responsiveLayout = chatResponsiveLayout(LocalConfiguration.current.screenWidthDp)
|
||||
val supervised = supervisedPolicy.enabled
|
||||
val supervisedVisibility = supervisedPolicy.visibility.resolved()
|
||||
LaunchedEffect(supervisedPolicy) {
|
||||
@@ -3311,7 +3314,13 @@ fun ChatScreen(
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 32.dp)
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
.then(
|
||||
responsiveLayout.introMaxWidth?.let {
|
||||
Modifier.widthIn(max = it)
|
||||
} ?: Modifier,
|
||||
),
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(0.15f))
|
||||
|
||||
@@ -3320,12 +3329,15 @@ fun ChatScreen(
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.weight(0.7f, fill = false)
|
||||
) {
|
||||
val avatarModifier = responsiveLayout.avatarSize?.let { size ->
|
||||
Modifier
|
||||
.size(size)
|
||||
.clipToBounds()
|
||||
} ?: Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.weight(0.7f, fill = false)
|
||||
Box(modifier = avatarModifier) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
state = if (error != null) SphereState.Error else SphereState.Idle,
|
||||
@@ -3568,7 +3580,14 @@ fun ChatScreen(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center)
|
||||
.fillMaxHeight()
|
||||
.then(
|
||||
responsiveLayout.transcriptMaxWidth?.let {
|
||||
Modifier.widthIn(max = it)
|
||||
} ?: Modifier,
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = if (showTranscriptSearch) 112.dp else 0.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
@@ -4650,6 +4669,13 @@ fun ChatScreen(
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
)) pasteImageFromClipboard else ({ }),
|
||||
onLongPressAttach = { if (!supervised) showCommandPalette = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.then(
|
||||
responsiveLayout.chromeMaxWidth?.let {
|
||||
Modifier.widthIn(max = it)
|
||||
} ?: Modifier,
|
||||
),
|
||||
charLimit = charLimit,
|
||||
caption = turnStatus ?: inputCaption,
|
||||
voiceReady = voiceReady,
|
||||
|
||||
+2
-2
@@ -25,10 +25,10 @@ class ChangelogHistoryScreenshotTest {
|
||||
ChangelogScreen(onClose = {})
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("v1.14.0 — Connections, delegated work, Git, and voice").assertExists()
|
||||
compose.onNodeWithText("v1.15.0 — Standard Hermes first, with clearer Relay boundaries").assertExists()
|
||||
compose.onNodeWithText("Highlights").assertExists()
|
||||
compose.onNodeWithText("Fixed").assertExists()
|
||||
compose.onNodeWithText("Wake-word detection starts reliably").assertExists()
|
||||
compose.onNodeWithText("Keep Standard voice after removing Relay").assertExists()
|
||||
compose.onNodeWithText("Compatibility").assertExists()
|
||||
compose.onNodeWithText("Installed").assertExists()
|
||||
compose.onRoot().captureRoboImage("build/ui-regression/changelog-history.png")
|
||||
|
||||
+5
-5
@@ -61,10 +61,10 @@ class WhatsNewToastScreenshotTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Connections, delegated work, Git, and voice").assertExists()
|
||||
compose.onNodeWithText("Also: 2 improvements · 10 fixes").assertExists()
|
||||
compose.onNodeWithText("Standard Hermes first, with clearer Relay boundaries").assertExists()
|
||||
compose.onNodeWithText("Also: 2 improvements · 9 fixes").assertExists()
|
||||
compose.onNodeWithText(
|
||||
"Release notes stay out of your way, Chat uses one consistent presentation…",
|
||||
"See which features need Relay, Read the complete release record…",
|
||||
).assertExists()
|
||||
compose.onNodeWithText("View all").assertExists()
|
||||
compose.onNodeWithContentDescription("Close").assertExists()
|
||||
@@ -86,7 +86,7 @@ class WhatsNewToastScreenshotTest {
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Connections, delegated work, Git, and voice").performClick()
|
||||
compose.onNodeWithText("Standard Hermes first, with clearer Relay boundaries").performClick()
|
||||
expanded = false
|
||||
compose.onNodeWithText("View all").performClick()
|
||||
compose.onNodeWithContentDescription("Close").performClick()
|
||||
@@ -108,7 +108,7 @@ class WhatsNewToastScreenshotTest {
|
||||
}
|
||||
}
|
||||
compose.mainClock.advanceTimeBy(300L)
|
||||
compose.onNodeWithText("Connections, delegated work, Git, and voice")
|
||||
compose.onNodeWithText("Standard Hermes first, with clearer Relay boundaries")
|
||||
.performTouchInput { swipeLeft(durationMillis = 300L) }
|
||||
compose.mainClock.advanceTimeBy(300L)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatResponsiveLayoutTest {
|
||||
@Test
|
||||
fun compactPhonesKeepExistingUnboundedLayout() {
|
||||
val layout = chatResponsiveLayout(screenWidthDp = 599)
|
||||
|
||||
assertNull(layout.introMaxWidth)
|
||||
assertNull(layout.avatarSize)
|
||||
assertNull(layout.chromeMaxWidth)
|
||||
assertNull(layout.transcriptMaxWidth)
|
||||
assertNull(layout.focusVoiceMaxWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediumWindowsUseBoundedChatSurfaces() {
|
||||
val layout = chatResponsiveLayout(screenWidthDp = 600)
|
||||
|
||||
assertEquals(600.dp, layout.introMaxWidth)
|
||||
assertEquals(300.dp, layout.avatarSize)
|
||||
assertEquals(760.dp, layout.chromeMaxWidth)
|
||||
assertEquals(760.dp, layout.transcriptMaxWidth)
|
||||
assertEquals(760.dp, layout.focusVoiceMaxWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expandedTabletsUseReadableCenteredRails() {
|
||||
val layout = chatResponsiveLayout(screenWidthDp = 1280)
|
||||
|
||||
assertEquals(720.dp, layout.introMaxWidth)
|
||||
assertEquals(360.dp, layout.avatarSize)
|
||||
assertEquals(960.dp, layout.chromeMaxWidth)
|
||||
assertEquals(960.dp, layout.transcriptMaxWidth)
|
||||
assertEquals(1120.dp, layout.focusVoiceMaxWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun focusVoiceSplitsOnlyOnExpandedLandscapeWindows() {
|
||||
assertTrue(useSplitVoiceLayout(screenWidthDp = 1280, screenHeightDp = 800))
|
||||
assertFalse(useSplitVoiceLayout(screenWidthDp = 800, screenHeightDp = 1280))
|
||||
assertFalse(useSplitVoiceLayout(screenWidthDp = 839, screenHeightDp = 600))
|
||||
}
|
||||
}
|
||||
+29
@@ -86,6 +86,35 @@ class VoiceModeOverlayInteractionTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "w1280dp-h800dp-xhdpi")
|
||||
fun expandedLandscape_usesSplitLayoutAndKeepsMicSemanticAction() {
|
||||
var micTaps = 0
|
||||
compose.mainClock.autoAdvance = false
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
VoiceModeOverlay(
|
||||
uiState = idleVoiceState(),
|
||||
onMicTap = { micTaps += 1 },
|
||||
onMicRelease = {},
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(VOICE_FOCUS_SPLIT_LAYOUT_TEST_TAG).assertExists()
|
||||
compose.onNodeWithTag(VOICE_FOCUS_STACKED_LAYOUT_TEST_TAG).assertDoesNotExist()
|
||||
compose.onNodeWithTag(VOICE_MODE_MIC_TEST_TAG)
|
||||
.assertHasClickAction()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
|
||||
compose.runOnIdle { assertEquals(1, micTaps) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun focusMode_emptySpaceDoesNotClickThroughToChat() {
|
||||
var backgroundTaps = 0
|
||||
|
||||
@@ -91,9 +91,9 @@ 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.14.0 - Connections, delegated work, Git, and voice
|
||||
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
|
||||
|
||||
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
|
||||
Standard Chat, Voice, attachments, returned files, current-session Git, usage, and Hermes notices now prefer upstream Dashboard and Gateway support without requiring Relay. Returned media stays loaded, voice survives Relay removal, and Settings clearly separates standard Hermes from Relay tools. Supervised Mode also gains app-specific parent access and recovery.
|
||||
```
|
||||
## Category
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.14.0"
|
||||
appVersionCode = "52"
|
||||
appVersionName = "1.15.0"
|
||||
appVersionCode = "53"
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"label": "Hermes-Relay",
|
||||
"description": "Paired devices, Bridge activity, media tokens, and remote access for Hermes-Relay",
|
||||
"icon": "Activity",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.1",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.1",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.1",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: hermes-relay
|
||||
# Temporary v1 shim for Hermes installers that reject manifests the runtime supports; see TODO.md.
|
||||
manifest_version: 1
|
||||
api_version: 1
|
||||
version: 1.11.0
|
||||
version: 1.11.1
|
||||
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
|
||||
author: Axiom Labs
|
||||
license: MIT
|
||||
|
||||
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
|
||||
# CLI+UI releases use desktop/package.json and desktop-v* tags. The /health endpoint
|
||||
# reports this plugin version, and stale values make live diagnosis harder than
|
||||
# it should be.
|
||||
__version__ = "1.11.0"
|
||||
__version__ = "1.11.1"
|
||||
|
||||
from .server import create_app, main # noqa: E402 — must come after __version__
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-relay"
|
||||
version = "1.11.0"
|
||||
version = "1.11.1"
|
||||
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user