Compare commits

...
Author SHA1 Message Date
Bailey Dixon 68f8b58a0b Merge pull request #232 from Codename-11/dev
release(android): android-v1.4.9
2026-07-19 21:27:28 -04:00
Bailey Dixon 481ad48c57 chore(release): prepare Android 1.4.9 2026-07-19 19:55:35 -04:00
Bailey Dixon 74be630e05 Merge pull request #231 from Codename-11/feature/dashboard-primary-connections
feat(android): make dashboard the primary connection
2026-07-19 19:47:04 -04:00
Bailey Dixon a61d27a92d feat(android): make dashboard the primary connection
Make the Hermes dashboard the standard connection path with API fallback and optional Relay pairing. Redesign onboarding and connection management, add startup preference and route/security details, reconcile pinned profile identity and hostname discovery, and update tests, docs, and localized resources.
2026-07-19 19:39:28 -04:00
Bailey Dixon 22e4d24817 Merge pull request #229 from Codename-11/fix/windows-gateway-pid-probe
fix(plugin): avoid signalling gateway PID on Windows
2026-07-18 21:58:15 -04:00
Bailey Dixon 7edaa2df14 test(plugin): preserve PID identity coverage 2026-07-18 21:57:07 -04:00
Bailey Dixon 165feaa0d6 fix(plugin): avoid signalling gateway PID on Windows 2026-07-18 21:55:03 -04:00
Bailey Dixon 8c516c3c8d Merge pull request #228 from Codename-11/main
chore: back-merge Android 1.4.8 hotfix
2026-07-18 12:22:29 -04:00
Bailey Dixon 40bb0a4ef8 Merge pull request #227 from Codename-11/fix/privacy-policy-url-hotfix
fix(android): restore Play privacy policy URL
2026-07-18 11:42:37 -04:00
Bailey Dixon d96898a6aa fix(android): restore Play privacy policy URL 2026-07-18 11:30:17 -04:00
Bailey Dixon b4e595e320 Merge pull request #226 from Codename-11/dev
release: Android 1.4.7
2026-07-18 10:29:31 -04:00
Bailey Dixon 31c41fb2ff Merge pull request #225 from Codename-11/fix/android-1.4.7-release-prep
release(android): android-v1.4.7
2026-07-18 09:56:28 -04:00
Bailey Dixon ab0f7b726a release(android): android-v1.4.7 2026-07-18 09:49:04 -04:00
Bailey Dixon f72904ab53 Merge pull request #206 from Codename-11/docs/branch-release-contract
docs: reconcile branch and release contract
2026-07-18 09:41:46 -04:00
Bailey Dixon 4fc5f668de merge: refresh branch contract from dev 2026-07-18 09:34:49 -04:00
Bailey Dixon cedc340091 Merge pull request #224 from Codename-11/fix/reconcile-dev-release
fix: reconcile release history and Android localization
2026-07-18 09:33:48 -04:00
Bailey Dixon 97cb30c927 merge: back-merge main release history into dev 2026-07-18 09:23:01 -04:00
Bailey Dixon ed41be3390 merge: reconcile Android localization into dev 2026-07-18 09:22:39 -04:00
Bailey Dixon 08816cfe63 Merge pull request #222 from Codename-11/fix/smooth-stream-rendering
fix(android): smooth streamed reply rendering
2026-07-17 14:44:40 -04:00
Bailey Dixon 01a0cde589 fix(android): smooth streamed reply rendering 2026-07-17 14:35:12 -04:00
Bailey Dixon ed6742afe4 Merge pull request #219 from Codename-11/fix/smooth-stream-finalization
fix(android): smooth streamed reply finalization
2026-07-17 09:22:45 -04:00
Bailey Dixon a6264df910 fix(android): smooth streamed reply finalization 2026-07-17 09:14:34 -04:00
Bailey Dixon 64e2e2eca6 Merge pull request #218 from Codename-11/fix/post-stream-history-scroll
fix(android): preserve chat anchor across history reload
2026-07-17 07:42:46 -04:00
Bailey Dixon 1ccaf2c4f1 fix(android): preserve chat anchor across history reload 2026-07-17 07:33:40 -04:00
Bailey Dixon 7686bb41e7 Merge pull request #217 from Codename-11/fix/stream-final-scroll-anchor
fix(android): retain chat bottom after stream completion
2026-07-16 21:03:11 -04:00
Bailey Dixon a940b4b8ea fix(android): retain chat bottom after stream completion 2026-07-16 20:55:01 -04:00
Bailey Dixon 46afdeab59 Merge pull request #216 from Codename-11/fix/critical-relay-security
fix(security): enforce Relay privileged boundaries
2026-07-16 20:23:16 -04:00
Bailey Dixon d4a8aad050 fix(ci): classify PR paths from merge commit 2026-07-16 19:38:00 -04:00
Bailey Dixon 0a6e95ae74 fix(ci): retry transient path classification failures 2026-07-16 19:36:05 -04:00
Bailey Dixon a6fc53e5cf docs: record critical relay hardening 2026-07-16 19:33:11 -04:00
Bailey Dixon c013daacda fix(security): prevent relay session self-upgrade 2026-07-16 19:27:39 -04:00
Bailey Dixon f5b1d377a4 fix(security): enforce terminal session grants 2026-07-16 19:26:12 -04:00
Bailey Dixon bb1e406f3f fix(security): redact remote profile config 2026-07-16 19:26:06 -04:00
Bailey Dixon 10213ca8ed fix(security): authorize Android bridge HTTP routes 2026-07-16 19:25:52 -04:00
Bailey Dixon cbfccd8ccf fix(security): keep voice provider origins host-controlled 2026-07-16 19:21:17 -04:00
Bailey Dixon c3c98caa31 fix(security): require host-authorized pairing 2026-07-16 19:18:02 -04:00
Bailey Dixon ed60abd57c Merge pull request #215 from Codename-11/fix/docs-docker-assets
fix(website): restore production docs build context
2026-07-16 17:42:30 -04:00
Bailey Dixon cab0d90530 fix(website): restore production docs build context 2026-07-16 17:40:03 -04:00
Bailey Dixon d977600f9d Merge pull request #214 from Codename-11/dev
merge: promote localized public experience
2026-07-16 15:51:48 -04:00
Bailey Dixon c902c00101 Merge pull request #213 from Codename-11/feature/docs-home-hub
feat: modernize and localize public experience
2026-07-16 15:38:49 -04:00
Bailey Dixon aa6b48a068 merge: sync latest main into public experience work
# Conflicts:
#	DEVLOG.md
2026-07-16 15:31:18 -04:00
Bailey Dixon 50297d1496 feat: modernize and localize public experience 2026-07-16 15:29:39 -04:00
Bailey Dixon d80f36a087 merge: add Android German Portuguese and Japanese 2026-07-16 08:46:29 -04:00
Bailey Dixon b6117c2d41 Merge pull request #212 from Codename-11/fix/docs-clean-urls
fix(website): serve VitePress clean URLs
2026-07-16 08:04:39 -04:00
Bailey Dixon b0ee6935fe fix(website): serve VitePress clean URLs 2026-07-16 08:02:26 -04:00
Bailey Dixon 33538fde0c Merge pull request #211 from Codename-11/fix/legacy-docs-redirect
fix(docs): add temporary legacy redirects
2026-07-16 07:58:37 -04:00
Bailey Dixon 603919c8ff fix(docs): add temporary legacy redirects 2026-07-16 07:56:09 -04:00
Bailey Dixon 52df3adbf6 Merge pull request #210 from Codename-11/fix/retire-github-pages
fix(docs): retire GitHub Pages
2026-07-16 07:42:52 -04:00
Bailey Dixon 3eab11c639 fix(docs): retire GitHub Pages 2026-07-15 21:48:20 -04:00
Bailey Dixon 2673f228bb Merge pull request #209 from Codename-11/fix/website-coolify-deployment
fix(website): add Coolify root-context build
2026-07-15 20:37:28 -04:00
Bailey Dixon 53b8f6a418 fix(website): add Coolify root-context build 2026-07-15 20:35:51 -04:00
Bailey Dixon 87cd9e7b9d docs: keep main as GitHub default branch 2026-07-15 14:56:32 -04:00
Bailey Dixon 51a020bd22 merge: refresh branch contract from dev 2026-07-15 14:37:07 -04:00
Bailey Dixon 34ff4d0629 docs: reconcile branch and release contract 2026-07-15 14:37:01 -04:00
Bailey Dixon c452c25148 feat(android): integrate expanded language support 2026-07-15 11:24:54 -04:00
Bailey Dixon 0db5c02722 feat(android): add Japanese localization 2026-07-15 10:57:40 -04:00
Bailey Dixon 4630695c17 feat(android): add Brazilian Portuguese localization 2026-07-15 10:52:15 -04:00
Bailey Dixon f4ee440015 feat(android): add German localization 2026-07-15 10:52:15 -04:00
235 changed files with 24108 additions and 3345 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ contact_links:
url: https://github.com/Codename-11/hermes-relay/security/advisories/new
about: Report privately via GitHub Security Advisories — do not open a public issue. See SECURITY.md for the full policy.
- name: User documentation
url: https://codename-11.github.io/hermes-relay/
url: https://hermes-relay.dev/docs/
about: Read setup, pairing, remote access, and troubleshooting docs.
- name: Contributing guide
url: https://github.com/Codename-11/hermes-relay/blob/main/CONTRIBUTING.md
+1 -1
View File
@@ -25,7 +25,7 @@ otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
## Checklist
- [ ] Target branch is `dev` unless this is a release PR
- [ ] Target branch is `dev`, unless this is a `dev` → `main` release PR or a focused production-tag hotfix PR to `main`
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
- [ ] Translation changes: locale status/review references are accurate, `python scripts/check-android-locales.py` ran, and device/emulator review is documented, or N/A
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
+4 -1
View File
@@ -50,6 +50,7 @@ jobs:
- name: Syntax check (plugin relay — canonical location)
run: |
python -m py_compile plugin/relay/config.py
python -m py_compile plugin/relay/server.py
python -m py_compile plugin/relay/channels/terminal.py
python -m py_compile plugin/relay/channels/chat.py
@@ -103,4 +104,6 @@ jobs:
plugin/tests/test_relay_security.py \
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py \
plugin/tests/test_native_layout_imports.py
plugin/tests/test_native_layout_imports.py \
plugin/tests/test_profile_discovery.py \
plugin/tests/test_profiles_updated_broadcast.py
+7 -7
View File
@@ -33,6 +33,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 2
- name: Test path classifier
run: node .github/scripts/classify-ci-paths.test.cjs
@@ -42,13 +44,11 @@ jobs:
uses: actions/github-script@v8
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
const paths = files.map((file) => file.filename);
const { stdout } = await exec.getExecOutput(
'git',
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
);
const paths = stdout.split(/\r?\n/).filter(Boolean);
const { classifyCiPaths } = require(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/classify-ci-paths.cjs`,
);
-75
View File
@@ -1,75 +0,0 @@
# Hermes-Relay — Docs Deployment
#
# Builds VitePress docs and deploys to GitHub Pages.
# Triggers on pushes to main that change user-docs/ content,
# or manually via workflow_dispatch.
name: Deploy Docs
on:
push:
branches: [main]
paths:
- 'user-docs/**'
- '.github/workflows/docs.yml'
workflow_dispatch:
# Allow only one concurrent deployment
concurrency:
group: pages
cancel-in-progress: false
# Sets permissions for GITHUB_TOKEN to enable Pages deployment
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
name: Build Docs
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0 # Full history for lastUpdated timestamps
- name: Setup Node.js
uses: actions/setup-node@v7
with:
# Node 24 ships npm 11, matching the npm that generates
# user-docs/package-lock.json. On npm 10 (Node 20), `npm ci` rejects
# the lock over the optional `search-insights` peer dep of bundled
# docsearch. Keep this aligned with the npm used to write the lock.
node-version: 24
cache: npm
cache-dependency-path: user-docs/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: user-docs
- name: Build VitePress site
run: npm run build
working-directory: user-docs
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: user-docs/.vitepress/dist
deploy:
name: Deploy to GitHub Pages
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
@@ -0,0 +1,89 @@
name: Deploy legacy docs redirects
on:
pull_request:
paths:
- "legacy-pages-redirect/**"
- "website/public/privacy.html"
- ".github/workflows/legacy-docs-redirect.yml"
push:
branches: [main]
paths:
- "legacy-pages-redirect/**"
- "website/public/privacy.html"
- ".github/workflows/legacy-docs-redirect.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: legacy-docs-pages
cancel-in-progress: true
jobs:
build:
name: Build redirect artifact
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Build redirect-only site
shell: bash
run: |
set -euo pipefail
source_file="legacy-pages-redirect/redirect.html"
privacy_file="website/public/privacy.html"
output_dir="legacy-pages-redirect/_site"
rm -rf "$output_dir"
mkdir -p \
"$output_dir/guide/getting-started" \
"$output_dir/privacy" \
"$output_dir/reference/relay-server" \
"$output_dir/architecture"
for target in \
index.html \
404.html \
guide/getting-started.html \
guide/getting-started/index.html \
reference/relay-server.html \
reference/relay-server/index.html \
architecture/connection-security.html; do
cp "$source_file" "$output_dir/$target"
done
cp "$privacy_file" "$output_dir/privacy.html"
cp "$privacy_file" "$output_dir/privacy/index.html"
touch "$output_dir/.nojekyll"
test "$(find "$output_dir" -type f | wc -l)" -eq 10
grep -Fq '<h1>Privacy Policy</h1>' "$output_dir/privacy.html"
grep -Fq 'https://hermes-relay.dev/privacy.html' "$output_dir/privacy.html"
if grep -R -E '<title>VitePress|<div id="app">' "$output_dir"; then
echo "Full documentation content must not be deployed by this workflow." >&2
exit 1
fi
- name: Configure Pages
if: github.event_name != 'pull_request'
uses: actions/configure-pages@v6
- name: Upload redirect artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v5
with:
path: legacy-pages-redirect/_site
deploy:
name: Deploy redirect shim
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
@@ -81,6 +81,7 @@ jobs:
- name: Validate release metadata and source compatibility
run: |
python3 scripts/check-version-tracks.py
python3 scripts/check-privacy-policy.py --live
python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
+21 -1
View File
@@ -3,7 +3,7 @@
# Triggered when an Android release tag (android-v*) is pushed.
# Validates the tag matches the app version in libs.versions.toml,
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
# GitHub Release. Plugin/Python package releases use plugin-v* tags.
# GitHub Release. Server/Python package releases use server-v* tags.
name: Release Android
@@ -35,6 +35,8 @@ jobs:
version_code: ${{ steps.version.outputs.version_code }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Extract version from tag
id: version
@@ -65,9 +67,26 @@ jobs:
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
exit 1
fi
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
exit 1
fi
echo "Version validated: $TAG_VERSION"
- name: Verify public privacy policy URLs
run: python3 scripts/check-privacy-policy.py --live
- name: Verify tagged commit belongs to main
run: |
set -euo pipefail
git fetch origin main --no-tags
tag_commit="$(git rev-parse HEAD)"
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "Android releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
fi
- name: Require successful Play preflight for this exact release tree
if: ${{ !contains(steps.version.outputs.version, '-') }}
env:
@@ -107,6 +126,7 @@ jobs:
- name: Validate release metadata and Android API compatibility
run: |
python3 scripts/check-version-tracks.py
python3 scripts/check-privacy-policy.py
python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py
+13 -9
View File
@@ -1,8 +1,8 @@
name: Release CLI
name: Release Desktop
on:
push:
tags: ['cli-v*']
tags: ['desktop-v*']
permissions:
contents: write
@@ -36,13 +36,17 @@ jobs:
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#cli-v}"
version="${GITHUB_REF_NAME#desktop-v}"
if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then
echo "Expected a cli-v* tag, got $GITHUB_REF_NAME" >&2
echo "Expected a desktop-v* tag, got $GITHUB_REF_NAME" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
npm run check:version-sync -- --expect "$version"
if ! grep -Fq "## [$version]" ../CHANGELOG.md; then
echo "CHANGELOG.md has no release heading for $version" >&2
exit 1
fi
- name: Verify tagged commit belongs to main
shell: bash
@@ -52,7 +56,7 @@ jobs:
git fetch origin main --no-tags
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "CLI releases must be tagged from main; $tag_commit is not in origin/main" >&2
echo "Desktop releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
fi
@@ -234,9 +238,9 @@ jobs:
# (the other publish-release steps only consume downloaded build artifacts).
- uses: actions/checkout@v7
- name: Extract CLI version
- name: Extract Desktop version
id: version
run: echo "version=${GITHUB_REF_NAME#cli-v}" >> "$GITHUB_OUTPUT"
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v8
with:
@@ -253,7 +257,7 @@ jobs:
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
# (cli-v0.3.0) so the install/pin commands stay accurate without manual edits.
# (desktop-v0.3.0) so install/pin commands stay accurate without manual edits.
- name: Render release notes
env:
VERSION: ${{ steps.version.outputs.version }}
@@ -266,7 +270,7 @@ jobs:
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay-CLI v${{ steps.version.outputs.version }}
name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
tag_name: ${{ github.ref_name }}
draft: false
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
+25 -8
View File
@@ -1,32 +1,49 @@
name: Release Plugin
name: Release Server
on:
push:
tags:
- "plugin-v*"
- "server-v*"
permissions:
contents: write
jobs:
validate:
name: Validate Plugin release
name: Validate Server release
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/plugin-v}" >> "$GITHUB_OUTPUT"
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
- name: Verify Plugin version sync
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
- name: Verify Server version sync and changelog
run: |
python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
exit 1
fi
env:
TAG_VERSION: ${{ steps.version.outputs.version }}
- name: Verify tagged commit belongs to main
run: |
set -euo pipefail
git fetch origin main --no-tags
tag_commit="$(git rev-parse HEAD)"
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
echo "Server releases must be tagged from main; $tag_commit is not in origin/main" >&2
exit 1
fi
test:
name: Test Plugin package
needs: validate
@@ -100,8 +117,8 @@ jobs:
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
name: Hermes-Relay-Plugin v${{ needs.validate.outputs.version }}
tag_name: plugin-v${{ needs.validate.outputs.version }}
name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
tag_name: server-v${{ needs.validate.outputs.version }}
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
fail_on_unmatched_files: true
body_path: release_notes_rendered.md
+2
View File
@@ -93,3 +93,5 @@ keystore.properties
# Legacy generated desktop tray assets may remain after upgrading a worktree.
desktop/tray/ui/vendor/
# Generated from assets/screenshots/02_chat.png before docs dev/build.
/user-docs/public/chat-demo.png
+33 -10
View File
@@ -5,26 +5,49 @@ coding agent (Claude Code, Codex, Cursor, etc.).
## Read this first
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
architecture, the upstream Hermes API reference, repository layout, per-language
code style, the dev loop, and the Key Files map. Read it before touching code,
then `docs/spec.md` and `docs/decisions.md`.
This file is the provider-neutral canonical agent context. Read it before
touching code, then `docs/spec.md` and `docs/decisions.md`. Provider adapters
such as **[CLAUDE.md](CLAUDE.md)** may add tool-specific guidance, but they do
not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
- Release process → **[RELEASE.md](RELEASE.md)**
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
## Branch contract
| Contract item | Canonical source or target |
|---|---|
| Integration branch | `dev`; normal feature, fix, docs, and chore PRs target `dev` |
| Release branch | `main`; release history and hotfix integration only |
| Tag source | The new `main` tip after an approved `dev` → `main` release PR, or after an approved hotfix PR to `main` |
| Staging source | An exact tested `dev` SHA or release-candidate tag; staging is an environment, never a branch |
| Production source | Immutable `android-v*`, `server-v*`, or `desktop-v*` tags, selected by surface |
| Hotfix base | The immutable production tag for the affected surface |
| Back-merge target | `dev`; merge `main` back immediately after every hotfix |
Feature completion means merged and verified on `dev`; it does not mean
released. A release train is separate work owned by a Forge release
issue/session: reconcile only the affected surface version and notes on `dev`,
open the `dev` → `main` release PR, tag the resulting `main` tip, publish the
surface artifacts, deploy or roll out, and verify the live result. Never create
a staging branch.
## Non-negotiables (the short list)
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection —
chat via the API server, Vanilla Hermes voice via the Hermes dashboard — must work
against unmodified upstream hermes-agent. Server-side needs go through upstream
PRs or the optional relay plugin, never fork patches.
- **Vanilla Hermes path = upstream-only.** The standard (no-plugin) connection
uses the upstream Dashboard/Gateway for chat, authentication, Manage, sessions,
and Vanilla Hermes voice. The API server is an optional automatic fallback and
advanced headless-compatibility surface; Relay adds optional extensions. This
path must work against unmodified upstream hermes-agent. Server-side needs go
through upstream PRs or the optional relay plugin, never fork patches.
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
- **Conventional Commits + `main`/`dev` branching.** Normal branches start at
`dev` and PR back to `dev`; merge commits/no-ff are the repository policy.
Version bumps happen only during release preparation on `dev`, and production
tags are cut only from `main`.
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
- **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints
+34
View File
@@ -6,6 +6,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Fixed
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
## [1.4.9] - 2026-07-19
### Changed
- **Hermes connections now use the Dashboard/Gateway as their standard surface.** Chat, sessions, Manage, and voice share one upstream sign-in; the API server is an optional automatic fallback or headless compatibility path, while Relay remains optional for power features.
- **Connection management and onboarding now explain each path clearly.** Nearby and remote dashboard setup, Tailscale and custom ports, Relay pairing, startup preference, route details, and security posture are presented in dedicated flows.
### Fixed
- **Server default consistently displays Hermes' pinned active profile.** Chat, session drawers, agent details, settings, voice, diagnostics, and profile inspection now use the active profile identity while preserving server-default routing semantics.
- **Discovered connections show useful host identity.** Successful local dashboard probes resolve and retain a hostname without overwriting a user-supplied connection label.
## [1.4.8] - 2026-07-18
### Fixed
- **The Google Play privacy-policy URL is permanently available.** The canonical policy now lives on hermes-relay.dev, the historical GitHub Pages URL serves the complete policy for compatibility, and Android release automation blocks publication if either public page is unavailable.
- **Android opens the hosted privacy policy directly.** The About screen no longer sends users to a repository source file.
## [1.4.7] - 2026-07-18
### Added
- **Android adds German, Brazilian Portuguese, and Japanese.** Complete AI-assisted catalogs cover both product flavors, with language-picker integration and freshness validation against the canonical English resources.
### Fixed
- **Long streamed replies grow smoothly and remain at the latest text.** Android frame-paces bursty token delivery, expands the active bubble within clipped bounds, preserves bottom-following through completion, and avoids replacing the visible live transcript while readers who intentionally scroll up remain undisturbed.
## [Android 1.4.6] - 2026-07-15
### Added
+15 -19
View File
@@ -1,6 +1,9 @@
# Hermes-Relay — Claude Code Context
# Hermes-Relay — Claude Code Adapter
> Read this before touching code. Then read docs/spec.md and docs/decisions.md.
> Read [AGENTS.md](AGENTS.md) first. It is the provider-neutral canonical agent
> context. Branch, release, staging, and hotfix rules live in `AGENTS.md` and
> [RELEASE.md](RELEASE.md); this file only adds Claude-specific project and tool
> guidance. Then read `docs/spec.md` and `docs/decisions.md`.
## What This Is
@@ -183,18 +186,16 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
### Git
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-plugin-version.sh` for `plugin-vX.Y.Z`, and `desktop/package.json` for `cli-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
- **Branch/release policy:** follow the branch-contract table in `AGENTS.md` and
the executable release and hotfix procedures in `RELEASE.md`. Do not maintain
a Claude-specific parallel policy here.
### Testing
- **Android:** JUnit + Compose testing for UI, MockK for mocks
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-plugin.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
- **CI and release gates:** follow the repository-wide requirements in
`AGENTS.md` and `RELEASE.md`; Claude-specific guidance does not redefine them.
## Key Files
@@ -406,7 +407,7 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
2. **Python syntax check** — `python -m py_compile plugin/<file>.py`. Full tests run on the server.
3. **Kotlin changes** — do NOT run `gradle build`. Bailey builds via Android Studio's ▶ button. Never `adb install` from Claude.
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Android CI runs lint alongside build/test for faster feedback, but a local lint run still surfaces issues before the workflow spends runner time compiling and packaging.
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
5. **Commit + push** — follow `AGENTS.md` and `RELEASE.md`; normal work PRs to `dev`.
6. **Pull + restart on server** — see Server Deployment below.
7. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
@@ -455,15 +456,10 @@ must not depend on this hook.
### Release Process
See [RELEASE.md](RELEASE.md) for the full recipe.
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
- **Relay plugin version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-plugin-version-sync.py`; bump with `scripts/bump-plugin-version.sh`
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, plugin, and CLI versions without forcing them to match
- `**appVersionCode` is monotonic** — always increment across Android prereleases
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `plugin-v*`, or `cli-v*` → push tag → CI builds + GitHub Release
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
See [AGENTS.md](AGENTS.md) for the canonical branch contract and
[RELEASE.md](RELEASE.md) for version sources, release trains, surface tags,
hotfixes, secrets, publishing, and verification. Claude-specific automation
must not infer release authority from feature completion.
## Integration Points
+1 -1
View File
@@ -60,4 +60,4 @@ hermes-relay daemon status
On Windows, open **Hermes Relay Systray** from the Start menu and right-click its notification-area icon. No separate desktop window is installed.
See the [CLI and systray guide](https://codename-11.github.io/hermes-relay/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
See the [CLI and systray guide](https://hermes-relay.dev/docs/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
+12 -2
View File
@@ -92,9 +92,19 @@ After the plugin is in place, restart hermes and verify pairing with `hermes-pai
We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
**Branching model (as of 2026-04-19): `main` + `dev`.** Feature branches — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back into `dev` via `--no-ff` PRs. `main` is released state only; it receives release merges from `dev` and nothing else. There is no straight-to-main exemption — even single-file typos go through `dev`.
**Branching model: `main` + `dev`.** Feature branches — `feature/<name>`,
`fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back
into `dev` via merge-commit/no-ff PRs. This includes small documentation fixes.
`main` is release history, not the normal contribution target; it receives
approved release PRs from `dev` and focused hotfix PRs based on production tags.
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `plugin-vX.Y.Z`, or `cli-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
Feature completion means merged and verified on `dev`; it does not mean the
change has been released. A separate Forge release issue/session owns release
preparation, the `dev` → `main` release PR, tagging, artifacts, rollout or
deployment, and live verification. Release-prep commits land on `dev`; tags are
cut from the resulting `main` tip as `android-vX.Y.Z`, `server-vX.Y.Z`, or
`desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release and hotfix
procedures.
## Stale PR salvage and contributor credit
+268
View File
@@ -1,5 +1,219 @@
# Hermes-Relay — Dev Log
## 2026-07-19 — Android 1.4.9 release preparation
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
connection and onboarding work merged to `dev`. The public changelog, GitHub
release notes, in-app What's New surfaces, English and Simplified Chinese Play
notes, and store-listing copy now describe the Android-only patch. Unreleased
Relay security hardening and the Windows gateway PID fix remain assigned to the
independent server release track.
## 2026-07-19 — Connection management detail pass
The Connections list now uses compact capability chips and exposes a cold-start
preference when more than one server is saved. Last used remains the default;
pinning a startup connection does not change the active connection during the
current run. Dashboard-only connections now render their configured Dashboard
and authentication status in Routes instead of an empty endpoint list, while
optional API fallback routes remain explicitly separate.
Advanced settings render directly in their dedicated tab without a redundant
expander. Security adds Dashboard authentication, credential-storage, Relay
session, sign-out, and transport posture facts sourced from current runtime
state. Android catalogs and localization source hashes were refreshed alongside
focused startup-persistence coverage.
## 2026-07-18 — Dashboard-primary connection contract
The canonical Android connection model now treats one Hermes installation as a
stable identity with independently optional endpoints. Dashboard/Gateway owns
the standard upstream chat, authentication, sessions, Manage, and voice path;
the API server is an automatic fallback and advanced headless compatibility
surface; Relay remains an additive extension for terminal, bridge, media, and
enhanced voice capabilities. Existing API-first records and pairing payloads
remain compatible without defining normal onboarding.
The audit reconciled `AGENTS.md`, `docs/spec.md`, `docs/decisions.md`,
`docs/upstream-surface-matrix.md`, `README.md`,
`user-docs/features/connections.md`, `user-docs/features/dashboard.md`,
`CHANGELOG.md`, and `DEVLOG.md`. `RELEASE.md` was reviewed and required no
change because it contains no connection-routing contract.
## 2026-07-18 — Non-signalling Windows gateway PID probes
Relay profile discovery now prefers Hermes' supported psutil liveness check and
falls back to native Windows process handles when psutil is unavailable. The
POSIX signal-zero probe is restricted to POSIX hosts, preventing relay startup
and periodic profile rescans from signalling or terminating the gateway named
by `gateway.pid`. Regression coverage uses disposable child processes instead
of pointing PID fixtures at the test runner.
## 2026-07-18 — Android privacy-policy URL hotfix
The canonical Android privacy policy moved to a stable public page on
hermes-relay.dev. The historical GitHub Pages path now serves the complete
policy for compatibility with existing store metadata instead of depending on
a client-side redirect. Android's About screen, public privacy references, and
Play submission documentation use the canonical site URL.
The legacy Pages workflow publishes both file and directory policy paths from
the same canonical HTML source. Repository validation checks the policy's
required disclosures and URL wiring, while Play preflight and stable tag release
jobs additionally require both deployed policy URLs to return complete policy
content before an artifact can be uploaded or promoted. Android advanced to
1.4.8 with versionCode 31 for the replacement Play submission.
## 2026-07-18 — Android 1.4.7 release preparation
Android advanced to 1.4.7 with versionCode 30 after the localization, streaming,
release-history, and branch-contract reconciliation landed on `dev`. The public
changelog, GitHub release body, in-app What's New surfaces, Play metadata, and
store-listing copy now describe the Android-only patch while the unreleased Relay
security work remains assigned to its independent server release track.
## 2026-07-17 — Smooth streamed-reply rendering and finalization
Uninterrupted Gateway turns treat their structured live assistant, reasoning, and
tool events as authoritative instead of immediately republishing the transcript
through a full history read. Rejoined sockets, Sessions SSE, detached turns,
profile-aware resume, errors, and missing-data recovery retain their required
authoritative reconciliation paths.
Bursty provider deltas now enter a main-thread frame pacer that publishes adaptive
UTF-16-safe slices at a display-sized cadence. The visible live tail uses one stable
plain-text node, ignores leading blank transport lines, and expands inside a short
clipped size animation. Tool, thinking, completion, cancellation, and error
boundaries still flush buffered content immediately and preserve event order.
Bottom-following is driven by stable row identity, real drag interactions, measured
positive tail growth, and structural anchors. The active response retains its live
renderer through completion, settles the exact footer for two frames, and releases
to full Markdown after another row becomes the tail or the session is revisited.
This prevents both the completion-time top snap and the transient scroll-to-bottom
button without interrupting readers who intentionally move into history.
Focused stream-pacing, Unicode-boundary, leading-whitespace, Gateway reconnect,
completion-policy, and scroll regressions passed. Repeated sideload builds and live
phone tests verified smooth following, stable completion, exact-bottom settling,
frame-paced text insertion, and clipped bubble growth.
## 2026-07-17 — Stable chat rows across post-turn history reconciliation
Android chat now separates the stable Compose identity of a visible message row
from its authoritative server message ID. The post-turn history reconcile can
adopt persisted IDs and rebuild message boundaries without making LazyColumn
remove and reinsert the long answer currently anchoring the viewport.
Regression coverage exercises both the same-count user/assistant ID adoption
and a list-expansion reconcile that inserts persisted rows around a matched live
tail. The focused ChatHandler and scroll-snapshot unit tests passed.
## 2026-07-16 — Stable chat position after stream completion
Android chat now observes the assistant message identity and the streaming-to-final
transition as conversation-tail changes. When a reader is already following the
response, completion performs an instant multi-frame bottom settle after the
streaming renderer is replaced by the final Markdown layout. The existing
user-scroll gate remains authoritative, so reading older messages is not
interrupted.
Focused snapshot regression coverage verifies completion detection, ordinary
stream growth, stream startup, and server message-ID reconciliation.
## 2026-07-16 — Critical Relay authorization hardening
Relay privileged interfaces now enforce host-authorized policy at every shared
dispatch boundary. Anonymous pairing-code minting was removed; pairing clients
can no longer choose session lifetime or grants; Android bridge HTTP routes and
terminal messages require live sessions with active route grants; ordinary
session bearers can only reduce their own lifetime and existing grants; remote
profile config reads expose an explicit public schema without host paths; and
voice requests cannot override credential-bearing provider origins.
Six bounded exploit harnesses stopped at the restored boundaries. The combined
security regression set passed 96 tests, Python compilation passed, Ruff passed
for changed modules and tests apart from the pre-existing unused `signal`
import in `server.py`, and `git diff --check` passed. The broad plugin
discovery run progressed through unrelated suites but was interrupted by the
existing Windows async-suite `KeyboardInterrupt` behavior, so the focused
security and neighboring route suites remain the authoritative local result.
The required-check path classifier now reads changed paths from the checked-out
PR merge commit instead of GitHub's PR-files API, so an API outage cannot skip
every surface check.
## 2026-07-16 — Fix production docs asset context
Updated the production docs Docker stage to build from the same full repository
checkout used by CI rather than a hand-maintained file allowlist. This supplies
the canonical screenshot manifest, localization registry and validators, route
contract source, version metadata, and preview renderer without creating
Docker-only `ENOENT` failures when those build inputs grow. Both Node build
stages now install Python 3 so the localized website and docs validators run
inside the production image build as they do in CI.
## 2026-07-16 — Localized marketing site
The Astro product site now publishes German, Spanish, Japanese, Brazilian
Portuguese, and Simplified Chinese routes from one typed copy contract. Each
route localizes marketing copy, navigation, accessibility labels, metadata, and
links into the matching first-run documentation while retaining canonical
screenshots, command examples, and UI recreations as shipped-product evidence.
Locale-aware canonical URLs, alternate-language links, Open Graph locale data,
structured-data language, sitemap entries, and a responsive language selector
were added. The localization registry records English-source freshness, and the
website development and build commands reject missing or stale translations.
Astro diagnostics, deterministic asset checks, the six-page production build,
built-site validation, desktop/mobile browser checks, and `git diff --check`
passed.
## 2026-07-16 — Temporary redirect shim for pre-migration Android builds
Restored GitHub Pages only as a redirect-only compatibility endpoint for app
versions that still open `https://codename-11.github.io/hermes-relay/`. The
shim preserves known paths, query strings, and fragments while forwarding to
`https://hermes-relay.dev/docs/`; it does not publish the VitePress site.
The production Nginx configuration resolves VitePress clean URLs to their
`.html` artifacts so both legacy and corrected in-app links reach real pages.
Removal criteria and the operator review date are tracked in `TODO.md`.
## 2026-07-15 — German, Brazilian Portuguese, and Japanese localization
Android now includes complete German, Brazilian Portuguese, and Japanese
catalogs across the Google Play and sideload flavors. German and Brazilian
Portuguese were recovered from unfinished translation drafts and refreshed
against the current English resource contract; Japanese was generated through
the same deterministic translation harness. The in-app picker, Android locale
configuration, localization registry, contributor references, and user-facing
language lists now describe the expanded set consistently.
The catalogs remain marked as AI-translated until fluent review is recorded.
Structural validation covers resource parity, placeholders, plurals, arrays,
formatting flags, XML parsing, and canonical source hashes. The 10-catalog
validator, five focused `AppLanguageTest` cases, both flavor Kotlin/resource
compilations, sideload debug lint, and `git diff --check` passed.
## 2026-07-15 — Retire GitHub Pages and move docs to hermes-relay.dev
Disabled and removed the GitHub Pages deployment path, changed the repository
homepage to `https://hermes-relay.dev`, and moved the existing VitePress guide
to `https://hermes-relay.dev/docs/` inside the production Coolify image. Active
README, website, Android, release-note, and pet-schema links now target the new
docs origin while historical DEVLOG entries remain unchanged.
## 2026-07-15 — Coolify root-context website deployment hotfix
Added a repository-owned multi-stage Dockerfile for the Astro marketing site
and corrected its Coolify instructions. Production builds now keep the
repository root as Docker context, run the existing `build:production` gate
from `website/`, and serve the generated static output with Nginx. This keeps
the site's canonical screenshot comparison against `docs/media/` intact while
avoiding Nixpacks' incorrect Android/Gradle provider selection at monorepo root.
Verification: local website checks, production build, link validation, Docker
image build, and Nginx-served smoke checks passed before deployment.
## 2026-07-15 — Android 1.4.6 and Plugin 1.4.2 release preparation
The profile-continuity and profile-image work was prepared as a two-surface
@@ -52,6 +266,60 @@ Verification: seven profile-avatar endpoint tests and the focused Android host
avatar client plus profile-controller suites passed. Android lint and final diff
checks are recorded with the completed work.
## 2026-07-15 — Repository branch, release, and hotfix contract reconciliation
Repository guidance now has one provider-neutral branch contract in `AGENTS.md`:
normal work, including documentation, branches from and returns to `dev`; release
preparation happens on `dev`; approved release PRs merge `dev` to `main`; and
immutable surface tags are cut from the new `main` tip. Staging is an environment
sourced from an exact tested SHA or release-candidate tag. Production uses
`android-v*`, `server-v*`, or `desktop-v*`. Hotfixes branch from the affected
production tag, change and patch-bump only that surface, merge to `main`, tag,
verify, and immediately merge `main` back into `dev`.
Stable release workflows now require the tagged commit to be contained in
`main`, require the tag to match the authoritative surface version source, and
require a matching `CHANGELOG.md` release heading before any build or publish
job. Server and Desktop use the canonical `server-v*` and `desktop-v*` prefixes;
runtime update discovery retains fallback support for immutable historical
`plugin-v*` and `cli-v*` releases. No historical tag was moved or rewritten.
Audit inventory:
- Canonical/root guidance: `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`,
`RELEASE.md`, `README.md`, `DEVLOG.md`, `PLUGIN_RELEASE_NOTES.md`, and
`CLI_RELEASE_NOTES.md`.
- Contributor metadata: `.github/PULL_REQUEST_TEMPLATE.md`; every file in
`.github/ISSUE_TEMPLATE/` (`bug_report.yml`, `config.yml`, `docs.yml`,
`feature_request.yml`, and `translation.yml`); `.github/copilot-instructions.md`;
and `.github/dependabot.yml`.
- GitHub workflows: `approve-release-android.yml`, `ci-android.yml`,
`ci-contract.yml`, `ci-dashboard.yml`, `ci-desktop.yml`, `ci-plugin.yml`,
`ci-required.yml`, `dependabot-auto-merge.yml`, `docs.yml`, `issue-triage.yml`,
`play-listing.yml`, `play-preflight-android.yml`, `release-android.yml`,
`release-cli.yml`, and `release-plugin.yml`.
- Developer documentation: every Markdown file directly under `docs/`, plus
`docs/audits/`, `docs/diagrams/`, and `docs/mockups/`. Historical files under
`docs/plans/` were inspected for classification but not rewritten as current
instructions. Current contradictions were corrected in `docs/decisions.md`
and `docs/worktree-workflow.md`.
- Public documentation: every Markdown file under `user-docs/`, including the
architecture, desktop, features, guide, reference, and `zh-CN` trees. Current
tag guidance was corrected in `user-docs/desktop/index.md` and
`user-docs/desktop/installation.md`.
- Release/runtime seams: all three release workflows; `scripts/bump-version.sh`,
`scripts/bump-plugin-version.sh`, `scripts/check-version-tracks.py`, and
`scripts/check-plugin-version-sync.py`; Desktop install/update sources under
`desktop/scripts/` and `desktop/src/`; and Server update-discovery sources and
focused tests under `plugin/`.
The repository audit also confirmed that GitHub-owned settings cannot be
reconciled through repository files. The default branch correctly remained
`main`, the release-history branch. At audit time, `dev` was unprotected, squash
and rebase merges were enabled, and `main` protection did not apply to
administrators. An operator must align those remaining settings with the
documented contract.
## 2026-07-15 — Android 1.4.5 release and automated Play gate
Android 1.4.5 shipped as versionCode 28 after the signed release build, final
+1 -1
View File
@@ -33,4 +33,4 @@ Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat a
---
Tag prefixes: Android releases use android-v*, plugin releases use plugin-v*, and CLI releases use cli-v*.
Tag prefixes: Android releases use android-v*, Server releases use server-v*, and Desktop releases use desktop-v*.
+43 -47
View File
@@ -22,7 +22,7 @@
<p align="center">
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a><br>
<a href="https://codename-11.github.io/hermes-relay/">Documentation</a> ·
<a href="https://hermes-relay.dev/docs/">Documentation</a> ·
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
@@ -50,56 +50,52 @@ Install → connect → talk, in about two minutes.
### 1 · Install the app
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://hermes-relay.dev/docs/guide/getting-started.html#sideload-apk).
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
### 2 · Have Hermes running
### 2 · Have the Hermes Dashboard running
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
The normal Android connection uses the upstream Hermes Dashboard/Gateway for
chat, sign-in, sessions, Manage, and voice. Installing Hermes and choosing a
provider is vanilla Hermes setup:
```bash
hermes setup --portal # install / log in / pick a provider — skip if already done
mkdir -p ~/.hermes
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
cat >> ~/.hermes/.env <<EOF
API_SERVER_ENABLED=true
API_SERVER_HOST=0.0.0.0
API_SERVER_PORT=8642
API_SERVER_KEY=$API_SERVER_KEY
EOF
chmod 600 ~/.hermes/.env
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
hermes gateway
hermes setup --portal # install / log in / pick a provider — skip if already done
hermes dashboard # start the standard Dashboard/Gateway surface
```
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
Make the dashboard reachable from your phone over a trusted LAN, Tailscale, or
an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started)
covers Windows, remote access, and dashboard authentication. You do not need to
enable the separate API server or invent an API key for the standard path.
### 3 · Connect and talk
Open the app and pick how to connect — any of:
Open the app, choose **Connect to Hermes**, and enter or discover the dashboard
address (conventionally `http://<host>:9119`). Sign in through the dashboard's
configured provider when prompted. The app probes the available upstream
capabilities and finishes with a connection summary.
- **Vanilla Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
- **Vanilla Hermes** → type the address (`http://<host>:8642`) and key by hand.
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
The separate API server can be discovered automatically or added later under
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
key is requested only when that optional endpoint is configured. Existing
API-first setup QRs remain importable.
The wizard probes everything and finishes with a capability card:
| Line | What it means |
|------|---------------|
| **Chat** | API server reachable — you can talk |
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
| **Chat** | Dashboard/Gateway ready — you can talk |
| **Manage** | Models, keys, skills, and profiles are available from the phone |
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
| **Remote** | Fallback route configured — keeps working away from home |
| **Relay** | Optional power tools — fine to leave unpaired |
| **API fallback** | Optional API route available/unavailable |
| **Relay** | Optional extensions — fine to leave unpaired |
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole Vanilla Hermes setup.
One dashboard sign-in unlocks Chat, Manage, sessions, and standard voice. That's
the whole Vanilla Hermes setup.
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
> **Going places?** Add the Dashboard's Tailscale address — for example `http://100.x.y.z:9119` or a separately published `https://host.ts.net` URL — under **Settings → Connections → Routes**. Android tests it as a Dashboard route; no API server or API key is required. The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
### 4 · Optional: install Relay for power tools
@@ -133,7 +129,7 @@ the QR from the phone's Connections screen — or use
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ on the server. The API server and Relay are optional.
## Screenshots
@@ -162,12 +158,12 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
</tr>
</table>
The Android app also ships a complete AI-assisted Spanish catalog. Choose
**Español** from **Settings → Appearance → Language**; translation status and
fluent review are tracked independently so community corrections remain easy
to contribute.
The Android app ships complete AI-assisted catalogs for **Deutsch**, **Español**,
**日本語**, **Português (Brasil)**, and **简体中文**. Choose a language from
**Settings → Appearance → Language**; translation status and fluent review are
tracked independently so community corrections remain easy to contribute.
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
<p align="center"><sub>▶ <a href="https://hermes-relay.dev/docs/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
## Features
@@ -183,7 +179,7 @@ to contribute.
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks).
## Hands on any machine — the Hermes-Relay CLI&nbsp;<sub>(alpha)</sub>
@@ -201,11 +197,11 @@ hermes-relay daemon start # background tool router — agen
hermes-relay update # self-update via GitHub Releases
```
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on a separate `cli-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=cli), with old alpha prereleases still visible under `desktop-v*`.
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on the `desktop-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=desktop), with historical releases still visible under `cli-v*`.
On Windows, the default installer adds the optional right-click-only systray: no dashboard or app window, just TUI launch, User/Administrator-aware daemon controls, pairing, local grant review, audit, diagnostics, logs, desktop-use status/cancellation, sign-in startup, and emergency stop.
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
- **Docs:** [CLI guide](https://hermes-relay.dev/docs/desktop/) · [`desktop/README.md`](desktop/README.md)
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
## How It Works
@@ -229,14 +225,14 @@ configure API, dashboard, and relay routes without merging their auth models.
| | |
|---|---|
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, features, configuration — start here** |
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | Pairing, subcommands, local tool routing |
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
| **[User Guide](https://hermes-relay.dev/docs/)** | **Quick start, features, configuration — start here** |
| [Android](https://hermes-relay.dev/docs/guide/) | Android install + setup + features |
| [Hermes-Relay CLI](https://hermes-relay.dev/docs/desktop/) | Pairing, subcommands, local tool routing |
| [Architecture](https://hermes-relay.dev/docs/architecture/) | How the system works under the hood |
| [API Reference](https://hermes-relay.dev/docs/reference/api.html) | Hermes API endpoints used by both surfaces |
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `plugin-v*`, `cli-v*`) |
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `server-v*`, `desktop-v*`; historical prefixes remain immutable) |
<details>
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
+2 -2
View File
@@ -9,7 +9,7 @@
<p align="center">
<strong>简体中文</strong> · <a href="README.md">English</a><br>
<a href="https://codename-11.github.io/hermes-relay/zh-CN/">中文文档</a> ·
<a href="https://hermes-relay.dev/docs/zh-CN/">中文文档</a> ·
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
<a href="CHANGELOG.md">更新日志</a>
</p>
@@ -76,7 +76,7 @@ hermes relay start --no-ssl
hermes pair
```
完整说明请阅读[中文快速开始](https://codename-11.github.io/hermes-relay/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
完整说明请阅读[中文快速开始](https://hermes-relay.dev/docs/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
## 中文界面
+124 -65
View File
@@ -13,23 +13,24 @@ with optional prerelease identifiers.
- `PATCH` — bug fixes, backwards compatible
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
Hermes-Relay now ships three independently versioned surfaces. Public GitHub
Release titles use product names (`Hermes-Relay-Android`,
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable
for automation.
Hermes-Relay ships three independently versioned production surfaces. Public
GitHub Release titles use product names (`Hermes-Relay-Android`,
`Hermes-Relay-Server`, `Hermes-Relay-Desktop`); immutable tag prefixes select
the corresponding build and deployment lane.
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|---|---|---|---|---|
| Hermes-Relay-Android | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
| Hermes-Relay-Plugin | `plugin-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
| Hermes-Relay-CLI | `cli-v*` | `desktop/package.json` | `cd desktop && npm version --no-git-tag-version <version>` | `.github/workflows/release-cli.yml` |
| Hermes-Relay-Server | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
| Hermes-Relay-Desktop | `desktop-v*` | `desktop/package.json` | `cd desktop && npm version --no-git-tag-version <version>` | `.github/workflows/release-cli.yml` |
This split is intentional. The plugin carries relay features for both Android
and CLI clients, so plugin fixes can ship without forcing an Android app
`versionCode` bump, and CLI alphas can continue on their own cadence. Historical
Android releases before this naming split used bare `v*` tags. Historical
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used
`desktop-v*` tags. New releases use the explicit tag prefixes above.
plugin/server releases used `relay-v*` and `plugin-v*` tags. Historical
desktop/CLI releases also include `cli-v*` tags. Those tags remain immutable;
new releases use the canonical prefixes above.
### Android app versioning
@@ -87,7 +88,7 @@ lockstep:
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
Always bump Plugin releases via:
Always bump Server releases via:
```bash
bash scripts/bump-plugin-version.sh 0.6.2
@@ -105,23 +106,23 @@ Check all release tracks at once with:
python scripts/check-version-tracks.py
```
This aggregate check reports Android, plugin, and CLI versions
This aggregate check reports Android, Server, and Desktop versions
side by side and validates that each track's own source files are internally
consistent. It deliberately does not require all three tracks to share the same
SemVer.
The `plugin-v*` release workflow validates the tag against the same metadata,
The `server-v*` release workflow validates the tag against the same metadata,
runs plugin tests, builds a wheel and sdist, generates checksums, and
publishes a `Hermes-Relay-Plugin vX.Y.Z` GitHub Release with the package
publishes a `Hermes-Relay-Server vX.Y.Z` GitHub Release with the package
artifacts.
### CLI / tray versioning
`desktop/package.json` is the CLI release track's source of truth. Its version
`desktop/package.json` is the Desktop/CLI release track's source of truth. Its version
must match the generated CLI and native Windows systray metadata. The systray is
a menu-only controller for the installed CLI; it has no application window,
WebView, embedded terminal, or separate desktop product surface. The public
release remains one `Hermes-Relay-CLI` track containing CLI binaries plus the
release remains one `Hermes-Relay-Desktop` track containing CLI binaries plus the
optional Windows installer.
| File | Purpose |
@@ -166,12 +167,28 @@ the accumulator: every merged PR appends bullets there. A release is a
separate act, taken when the accumulated state on `dev` is worth shipping
(see "When to cut a release" below). Cutting a release means opening a
surface-specific release PR from `dev` into `main`, merging it `--no-ff`,
then tagging `main`.
then tagging `main`. Feature completion means merged and verified on `dev`; it
does not mean released.
**Server tracks `dev` for staging.** The hermes-host deployment pulls
`dev` so merged features get exercised against real data before they
reach a tag. Users (Play Store, sideload, `hermes-relay-update`) only
see state that lives on `main` and on release tags.
**Staging is an environment, not a branch.** Deploy an exact tested `dev` SHA or
an immutable release-candidate tag to staging. Record that source in the Forge
release issue/session. Never deploy a moving branch name as the source of record
and never create a staging branch. Production deploys only immutable
`android-v*`, `server-v*`, or `desktop-v*` tags cut from `main`.
### Normal contribution and release flow
1. Branch `feature/*`, `fix/*`, `docs/*`, or `chore/*` from `dev`.
2. Open the PR into `dev` and require CI to pass.
3. Merge with a merge commit/no-ff according to repository policy.
4. Accumulate user-facing work under `CHANGELOG.md` `[Unreleased]`.
5. Treat the feature as complete when it is merged and verified on `dev`.
6. Start a separate Forge release issue/session when a release train is approved.
7. Prepare the affected surface release on `dev`, including its version and notes.
8. Open and approve the release PR from `dev` into `main`.
9. Tag the new `main` tip with the affected surface prefix.
10. Build and publish that surface's artifacts, roll out or deploy from the
immutable tag, and verify the release and live environment.
### Branch names
@@ -211,23 +228,35 @@ version files and, for Android, on `appVersionCode` (which must be
monotonic).
Version-bump commits live on `dev` as the last commit of release-prep
work. Android commits use `release(android): android-vX.Y.Z`; plugin commits
use `release(plugin): plugin-vX.Y.Z`; CLI commits use
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` →
work. Android commits use `release(android): android-vX.Y.Z`; server commits
use `release(server): server-vX.Y.Z`; desktop commits use
`release(desktop): desktop-vX.Y.Z`. A release PR then merges `dev` →
`main` with `--no-ff`, and the matching tag is cut from the resulting
`main` tip.
### Branch protection
Light branch protection is enabled:
Repository files define the contract and CI, but GitHub owns the default branch,
branch protection, rulesets, allowed merge methods, and required-check settings.
Those settings require an operator or infrastructure automation.
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
here. PR must pass CI (Android + Plugin) before merge. Force push and
branch deletion blocked.
- **`dev`** — direct pushes blocked for non-trivial work; feature
branches PR in. PR must pass CI. Force push and branch deletion
blocked.
- Signed commits + review approval NOT required (solo-dev overhead).
The intended settings are:
- **`main`** — PRs required; `Required checks` required and current; force push
and deletion blocked. Normal work does not target this branch.
- **`dev`** — PRs and `Required checks` required; force push and deletion
blocked. This is the normal contribution target.
- **Merge policy** — merge commits allowed; squash and rebase merges disabled so
the no-ff contract cannot be bypassed in the GitHub UI.
- **Default branch** — `main`, which remains the release-history branch and the
repository's canonical landing page. Normal contribution PRs must explicitly
target `dev`.
As of the 2026-07-15 repository audit, the default branch was correctly `main`.
The remaining GitHub-owned gaps were that `dev` had no protection, squash and
rebase merges were enabled, and `main` protection did not apply to
administrators. Those settings must be reconciled separately; this documentation
PR does not mutate them.
## One-time Setup
@@ -388,6 +417,15 @@ tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
the unstable build.
## Release train ownership
Every release train gets its own Forge release issue/session. That owner records
the exact tested staging source, reconciles the affected surface version and
notes on `dev`, owns the `dev` → `main` PR, tags the new `main` tip, observes the
artifact workflow, performs the rollout or deployment, and captures live
verification. Feature implementation sessions stop at merged and verified on
`dev`; they do not inherit release authority.
## Release Process
### 1. Bump the Android app version
@@ -430,7 +468,7 @@ the new app version and a higher `appVersionCode`.
three* surfaces (Android + CLI + plugin), but releases are
per-surface. Move only the entries for the surface you're cutting into
the new versioned block, and leave the other surfaces' entries under
the fresh `[Unreleased]` for their own `cli-v*` / `plugin-v*` cut.
the fresh `[Unreleased]` for their own `desktop-v*` / `server-v*` cut.
(Those tracks' GitHub-Release bodies come from `CLI_RELEASE_NOTES.md` /
`PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's
historical record.)
@@ -585,14 +623,14 @@ publication.
Plugin/Python version files are intentionally not part of an Android app
release unless the plugin package itself is also being released.
### Plugin / Python package release
### Server / Python package release
Use this when plugin or relay behavior changes independently of Android app
delivery, for example CLI channel support, bridge routes, pairing server fixes,
voice auth, dashboard plugin UI, or packaging changes.
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for
`plugin-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
`server-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
Summary and the Added/Changed/Fixed groups from the plugin-relevant bullets in the
promoted `CHANGELOG.md` block, keep the `__VERSION__` token in the Install command
(the workflow substitutes it), and apply the same public-distribution scrub as §2.
@@ -603,31 +641,31 @@ git pull --ff-only origin dev
bash scripts/bump-plugin-version.sh 0.6.2
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md PLUGIN_RELEASE_NOTES.md
git commit -m "release(plugin): plugin-v0.6.2"
git commit -m "release(server): server-v0.6.2"
git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff.
# After merge, tag from the new main tip:
git checkout main
git pull --ff-only origin main
git tag plugin-v0.6.2
git push origin plugin-v0.6.2
git tag server-v0.6.2
git push origin server-v0.6.2
```
Pushing `plugin-v*` triggers `.github/workflows/release-plugin.yml`, which
Pushing `server-v*` triggers `.github/workflows/release-plugin.yml`, which
validates all plugin-owned version metadata with
`scripts/check-plugin-version-sync.py`. Run
`python scripts/check-version-tracks.py` locally before tagging when a change
touches more than one release surface. The workflow also runs plugin tests,
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
Release named `Hermes-Relay-Plugin v<version>` for the plugin package.
Release named `Hermes-Relay-Server v<version>` for the server/plugin package.
### CLI / Windows systray release
Use this when the standalone CLI, daemon, desktop tools, or Windows tray changes.
Android and plugin versions do not need to move with it.
First rewrite `CLI_RELEASE_NOTES.md` for the new CLI release and promote only
First rewrite `CLI_RELEASE_NOTES.md` for the new Desktop release and promote only
CLI/tray-relevant changelog bullets into the release block. Then:
```powershell
@@ -641,7 +679,7 @@ cd ..
git add desktop/package.json desktop/package-lock.json desktop/src/version.ts `
desktop/tray/Cargo.toml desktop/tray/Cargo.lock CHANGELOG.md CLI_RELEASE_NOTES.md
git commit -m "release(cli): cli-v0.4.0-alpha.2"
git commit -m "release(desktop): desktop-v0.4.0-alpha.2"
git push origin dev
# Open the release PR (dev -> main) and merge with --no-ff.
@@ -651,8 +689,8 @@ git pull --ff-only origin main
cd desktop
npm run check:version-sync -- --expect 0.4.0-alpha.2
cd ..
git tag cli-v0.4.0-alpha.2
git push origin cli-v0.4.0-alpha.2
git tag desktop-v0.4.0-alpha.2
git push origin desktop-v0.4.0-alpha.2
```
The tag workflow rejects version drift and tags whose commit is not in
@@ -768,7 +806,8 @@ plugin changes from forcing an Android app `versionCode` bump.
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
1. Validates the tag matches `appVersionName` in
1. Verifies the stable tag resolves to a commit contained in `main` and that the
tag matches `appVersionName` in
`gradle/libs.versions.toml` (mismatches fail the workflow).
2. Runs the Android debug build and the stable sideload pairing/connection
regression slice with explicit timeouts.
@@ -778,30 +817,35 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
googlePlay AAB are attached (see §Release assets).
5. Generates `SHA256SUMS.txt` covering the two attached files.
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
6. Promotes the exact preflighted Production draft to `completed`; a missing
credential or rejected Play edit fails before public GitHub publication.
7. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
7. Prints a `$GITHUB_STEP_SUMMARY` showing whether release signing
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
that the artifacts are debug-signed and unsuitable for Play Store.
8. Prints a `$GITHUB_STEP_SUMMARY` with the release and Play result.
On every push of a tag matching `plugin-v*`,
On every push of a tag matching `server-v*`,
`.github/workflows/release-plugin.yml`:
1. Validates the tag matches all plugin-owned version metadata checked by
`scripts/check-plugin-version-sync.py`.
1. Verifies the tag commit is contained in `main`, validates the tag against
all server/plugin-owned version metadata checked by
`scripts/check-plugin-version-sync.py`, and requires the matching release
heading in `CHANGELOG.md`.
2. Runs plugin syntax checks and the focused route/auth/session test slice.
3. Builds the Python wheel and sdist with `python -m build`.
4. Generates `dist/SHA256SUMS.txt`.
5. Creates a GitHub Release named `Hermes-Relay-Plugin v<version>` with the wheel,
5. Creates a GitHub Release named `Hermes-Relay-Server v<version>` with the wheel,
sdist, and checksum file attached.
On every push of a tag matching `cli-v*`,
On every push of a tag matching `desktop-v*`,
`.github/workflows/release-cli.yml` builds and publishes the CLI binaries and
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
substitutes `__VERSION__` (bare, e.g. `0.3.0`) and `__TAG__` (full, e.g.
`cli-v0.3.0`) so the install/pin commands stay accurate. Fill its Summary and
`desktop-v0.3.0`) so the install/pin commands stay accurate. It rejects tags
whose commit is not contained in `main`, whose version differs from
`desktop/package.json`, or whose version has no `CHANGELOG.md` release heading.
Fill its Summary and
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
Dashboard-only changes are covered by
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
@@ -816,19 +860,28 @@ in the built bundle.
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
| `HERMES_KEY_ALIAS` | Key alias | Alias set during `keytool -genkey` |
| `HERMES_KEY_PASSWORD` | Key password | Usually the same as the store password |
| `PLAY_SERVICE_ACCOUNT_JSON` | **Optional** — Play auto-upload | Paste the full Play Developer API service-account JSON (step 3) |
| `PLAY_SERVICE_ACCOUNT_JSON` | Stable Play submission | Paste the full Play Developer API service-account JSON (step 3) |
If `PLAY_SERVICE_ACCOUNT_JSON` is set, the `android-v*` release workflow uploads
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable
tags only — prereleases are skipped). CI does the upload; you still click **Start
rollout** in Play Console. If the secret is unset, the workflow skips the upload
and you upload manually (§5) — nothing else changes.
Stable Android releases require `PLAY_SERVICE_ACCOUNT_JSON`. Preflight uploads
the Production draft and the tag workflow promotes that exact version code to
`completed`. The workflow does not fall back to manual upload or publish GitHub
first. With Play Managed Publishing off, an approved release publishes
automatically; with it on, Play holds the approved change for an operator action
that the Developer API does not expose.
## Hotfix Recipe
When production has a bug and you need to ship a fix without picking up
unreleased work from `dev`, branch from the affected release tag and only
bump the version source for the surface you are shipping.
When production has a bug, use the same invariant for every surface:
1. Branch from the affected immutable `android-v*`, `server-v*`, or `desktop-v*`
production tag, never from the moving `main` or `dev` branch.
2. Make the smallest safe fix and add focused verification.
3. Bump only the affected surface's patch version and release notes.
4. Open the focused hotfix PR into `main` and merge with a merge commit/no-ff.
5. Tag the new `main` tip with the affected surface's patch tag.
6. Verify the artifacts and production rollout or deployment.
7. Merge `main` back into `dev` immediately so integration inherits the fix and
version history.
For an Android app hotfix:
@@ -842,17 +895,23 @@ For an Android app hotfix:
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
so Android release CI builds and publishes.
7. Upload to Play Console as normal.
7. Verify the automated Play submission, GitHub artifacts, and rollout.
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
so `dev` picks up the hotfix and the versionCode bump. Without this,
`dev`'s `appVersionCode` lags behind `main` and the next app release
bump collides.
For a Plugin hotfix, branch from the affected `plugin-v*` tag, apply
For a Server hotfix, branch from the affected `server-v*` tag, apply
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
`main`, and tag `plugin-v<next-version>`. Do not touch
`main`, tag `server-v<next-version>`, verify the package/deployment, and merge
`main` back to `dev`. Do not touch
`gradle/libs.versions.toml` unless an Android app release is also shipping.
For a Desktop hotfix, branch from the affected `desktop-v*` tag, update only
`desktop/package.json` and its generated lock/runtime/tray metadata, merge to
`main`, tag `desktop-v<next-version>`, verify all binaries and the installer,
then merge `main` back to `dev`.
## Troubleshooting
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
+11 -11
View File
@@ -1,30 +1,30 @@
# Hermes-Relay-Android v1.4.6
# Hermes-Relay-Android v1.4.9
**Release Date:** July 15, 2026
**Release Date:** July 19, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.4.6-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.4.9-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.
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://codename-11.github.io/hermes-relay/guide/sideload) for installation help.
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://hermes-relay.dev/docs/guide/sideload) for installation help.
## Summary
This patch keeps Server default aligned with Hermes' active profile, adds per-profile icon import and organization controls, and prevents the session drawer from mixing profile databases.
This patch makes the Hermes dashboard the standard connection path and refreshes setup, connection management, and profile identity throughout Android.
## Added
## Changed
- Reorder or hide profiles per connection without changing server configuration.
- Choose a profile icon from the Android file picker or import `avatar.png`/`profile.jpg` from a paired Relay host.
- Connect through the Hermes dashboard for Chat, sessions, Manage, and voice with one sign-in. The API server remains an automatic fallback or headless compatibility path, and Relay remains optional for power features.
- Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, and Relay paths with clearer status, route, startup, Advanced, and Security controls.
## Fixed
- Server default resolves Hermes' sticky active profile before Gateway session create/resume and dashboard session operations, keeping the agent, drawer, transcript, and writes in one profile database.
- Host image import distinguishes an outdated Relay from a genuinely missing avatar and presents **Choose file** as a reliable fallback.
- Server default now shows Hermes' pinned active profile consistently across Chat, sessions, agent details, settings, voice, diagnostics, and profile inspection.
- Successful local discovery adds useful hostname identity without replacing a custom connection label.
## Install / Verify
- App version: **1.4.6** (versionCode **29**).
- App version: **1.4.9** (versionCode **32**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
+42 -14
View File
@@ -6,6 +6,36 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Active — Remove temporary GitHub Pages docs redirects
PR #210 moved current source and production documentation to
`https://hermes-relay.dev/docs/`, but Android 1.4.0 and earlier releases still
contain hardcoded `https://codename-11.github.io/hermes-relay/` links. GitHub
Pages therefore serves a redirect-only compatibility shim from
`legacy-pages-redirect/`; it must never regain full documentation content.
Retire the shim only after the first Android release containing merge commit
`52df3adbf6d61d0ddbfb69671546f7c4953f956a` has been available for at least
90 days **and** at least two Android releases containing the corrected links
have shipped. If either condition is unmet at review time, retain it and set a
new review date.
Removal checklist:
- Remove `.github/workflows/legacy-docs-redirect.yml` and
`legacy-pages-redirect/` through a reviewed PR.
- Delete/disable the repository Pages site after that PR merges.
- Verify `https://codename-11.github.io/hermes-relay/` no longer serves the
shim and `https://hermes-relay.dev/docs/` plus representative deep links
still return HTTP 200.
- Update `DEVLOG.md` and the canonical Obsidian Hermes-Relay project note.
A one-shot operator reminder is scheduled for **2026-10-15 at 09:00 ET** to
review these gates; it is a review trigger, not authorization for automatic
removal.
---
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
Android profile hot-swap and concurrent Gateway turns are separate from proactive
@@ -615,13 +645,12 @@ every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
conversation gets its own beat; long-press haptic on the action menu; streaming dots
gated to pre-first-token. Deferred:
- **Streaming↔final render parity — conservative 1.4.1 slice implemented; live
reflow check remains.** Blank-terminated, unambiguous top-level prose/headings use
the final Markdown renderer during generation while the active tail stays raw.
Lists, quotes, tables, HTML, and fences intentionally remain lightweight until the
final parse because partial CommonMark containers can re-parent earlier blocks.
Verify that the chosen boundary removes the common heading/prose pop without
introducing partial-fence or list flicker.
- **Streaming↔final render parity — live reflow check remains.** Blank-terminated,
unambiguous top-level prose/headings use the final Markdown renderer during
generation while the active tail stays lightweight. Completion intentionally
parses one full CommonMark document so global link references, indentation, and
nested containers remain correct; the viewport now anchors that same remeasure.
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
@@ -643,13 +672,12 @@ gated to pre-first-token. Deferred:
kebab). Needs on-device confirmation of the current conflict first.
- **Drop the no-op tap ripple on bubbles.** The 1.4.1 jump-to-bottom unread badge is
code-complete; `combinedClickable(onClick={})` still ripples on a normal bubble tap.
- **Sessions-transport `animateItem` flash.** Stream-complete rebuilds the list with
new ids → every visible bubble replays its enter animation (gateway transport,
stable id, is unaffected). Reuse the streaming bubble's id for the final message.
- **Viewport re-pin on the `isStreaming` true→false height growth** (gateway
transport): `ChatScreen` early-returns on `onlyStreamingFlagChanged`; issue one
`withFrameNanos{}` + instant `scrollToItem(last)` when the flag flips and the user
isn't scrolled away. Largely neutralized once render parity removes the height delta.
- **Sessions per-turn reconciliation.** Current upstream includes assistant/tool
rows in `run.completed.messages`, but Android still uses a full profile-aware
history read for successful Sessions turns so older servers and persisted message
boundaries remain safe. Replace it only with a bounded partial-turn merge that
preserves the prior transcript and client-only fields, with a full-history fallback
when the completion payload is absent or incomplete.
- **Full 15-role `Typography` + metadata contrast.** Type.kt declares only 7 roles at
0 tracking; the rest inherit M3 defaults with 0.1–0.5sp tracking (ChatScreen uses
several) — declare all 15 for one coherent scale. Separately, floor muted-metadata
@@ -0,0 +1,48 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
/** Local device-review helper. Never runs in or ships with the application APK. */
@RunWith(AndroidJUnit4::class)
class ConnectionReviewSeedTest {
@Test
fun seedOfflineSecondaryConnection() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = ConnectionStore(context)
store.isHydrated.first { it }
if (store.connections.value.none { it.id == REVIEW_ID }) {
store.addConnection(
Connection(
id = REVIEW_ID,
label = "Lab NAS",
apiServerUrl = "",
relayUrl = "",
tokenStoreKey = Connection.buildTokenStoreKey(REVIEW_ID),
dashboardUrl = "http://192.0.2.10:9119",
lastUsedAt = System.currentTimeMillis() - 2L * 24L * 60L * 60L * 1_000L,
),
)
}
}
@Test
fun removeOfflineSecondaryConnection() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = ConnectionStore(context)
store.isHydrated.first { it }
if (store.connections.value.any { it.id == REVIEW_ID }) {
store.removeConnection(REVIEW_ID)
}
}
private companion object {
const val REVIEW_ID = "00000000-0000-4000-8000-000000000220"
}
}
@@ -121,42 +121,39 @@ class OnboardingFlowTest {
}
@Test
fun connectPage_showsStandardChoiceFirst() {
fun connectPage_showsNearbyFirst() {
setOnboardingContent()
navigateToPage(4)
composeTestRule
.onNodeWithText("Vanilla Hermes")
.onNodeWithText("Enter address instead")
.assertIsDisplayed()
}
@Test
fun standardSetup_showsApiFields() {
fun manualSetup_showsHermesAddressWithoutApiCredentials() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
composeTestRule.onNodeWithText("Enter address instead").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("API server URL")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("API key")
.onNodeWithText("Hermes address")
.assertIsDisplayed()
}
@Test
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
fun manualSetup_findButton_isShown() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
composeTestRule.onNodeWithText("Enter address instead").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Connect")
.assertIsEnabled()
.onNodeWithText("Find Hermes")
.assertIsDisplayed()
}
@Test
@@ -164,6 +161,9 @@ class OnboardingFlowTest {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Other connection methods").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Pair Relay by code")
.assertIsDisplayed()
@@ -1 +1 @@
Server default now keeps its agent, chats, drawer, and transcript in the active Hermes profile. Reorder or hide profiles per connection, choose an icon from your phone, or import avatar.png/profile.jpg from an updated paired Relay. Image import now clearly distinguishes an outdated Relay from a missing avatar.
Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice. Setup and connection details now explain nearby, remote, Tailscale, custom-port, optional Relay, route, and security choices. Server default also displays Hermes' pinned active profile consistently across the app.
@@ -1 +1 @@
“服务器默认”现在会让代理、聊天、会话抽屉和记录保持在 Hermes 当前活动配置文件中。可按连接重新排序或隐藏配置文件,从手机选择图标,或从已更新且配对的 Relay 导入 avatar.png/profile.jpg。图像导入也会明确区分 Relay 版本过旧与头像文件缺失。
现在可通过 Hermes 控制面板一次登录使用聊天、会话、管理和语音。设置与连接详情会清楚说明附近设备、远程访问、Tailscale、自定义端口、可选 Relay、路由和安全选项。“服务器默认”也会在整个应用中一致显示 Hermes 当前固定的活跃配置文件。
+56
View File
@@ -1,5 +1,61 @@
{
"versions": [
{
"version": "1.4.9",
"title": "Clearer Hermes connections",
"date": "2026-07-19",
"sections": [
{
"header": "Dashboard-first setup",
"bullets": [
"Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice; API fallback and optional Relay remain available.",
"Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, startup, route, and security choices."
]
},
{
"header": "Consistent identity",
"bullets": [
"Server default now displays Hermes' pinned active profile consistently across the app.",
"Successful local discovery adds useful hostname identity without replacing a custom connection label."
]
}
]
},
{
"version": "1.4.8",
"title": "Privacy policy restored",
"date": "2026-07-18",
"sections": [
{
"header": "Google Play compliance",
"bullets": [
"The privacy policy now lives at hermes-relay.dev and the historical store URL remains valid for compatibility.",
"The About screen opens the hosted policy directly, and releases verify it is publicly available before publishing."
]
}
]
},
{
"version": "1.4.7",
"title": "Smoother replies, more languages",
"date": "2026-07-18",
"sections": [
{
"header": "Smooth streaming",
"bullets": [
"Long replies grow at a display-paced cadence and stay anchored at the newest text through completion.",
"Scrolling into history preserves your reading position instead of forcing the conversation back to the bottom."
]
},
{
"header": "More languages",
"bullets": [
"Use German, Brazilian Portuguese, or Japanese throughout both Android product flavors.",
"Catalog freshness validation keeps every shipped translation aligned with the canonical English resources."
]
}
]
},
{
"version": "1.4.6",
"title": "Profiles stay together",
+4 -7
View File
@@ -1,8 +1,5 @@
v1.4.6 - Profiles stay together
v1.4.9 - Clearer Hermes connections
Profile continuity
* Server default now keeps its agent, chats, drawer, and transcript in the active Hermes profile.
* Reorder or hide profiles per connection without changing the server.
Profile icons
* Choose an image file on your phone or import avatar.png/profile.jpg from an updated paired Relay.
* Connect through the Hermes dashboard with one sign-in; API fallback and optional Relay remain available.
* Onboarding and connection details now explain nearby, remote, Tailscale, custom-port, route, and security choices.
* Server default consistently displays Hermes' pinned active profile, and discovered servers show useful host identity.
@@ -31,7 +31,16 @@ object AgentDisplay {
fun effectiveDisplayProfile(
selectedProfile: Profile?,
profiles: List<Profile>,
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
serverDefaultProfileName: String? = null,
): Profile? {
selectedProfile?.let { return it }
val resolvedServerDefault = profileRequestName(serverDefaultProfileName)
return resolvedServerDefault
?.let { activeName ->
profiles.firstOrNull { it.name.equals(activeName, ignoreCase = true) }
}
?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
}
// The NAME goes in the name slot. Non-default profiles use their profile
// name first. The synthetic default profile uses its description only when
@@ -7,6 +7,9 @@ import java.util.Locale
enum class AppLanguage(val languageTag: String) {
SYSTEM_DEFAULT(""),
ENGLISH("en"),
GERMAN("de"),
BRAZILIAN_PORTUGUESE("pt-BR"),
JAPANESE("ja"),
SIMPLIFIED_CHINESE("zh-Hans"),
SPANISH("es"),
;
@@ -27,8 +30,11 @@ enum class AppLanguage(val languageTag: String) {
val locale = Locale.forLanguageTag(primaryTag)
return when (locale.language.lowercase(Locale.ROOT)) {
"de" -> GERMAN
"en" -> ENGLISH
"es" -> SPANISH
"ja" -> JAPANESE
"pt" -> BRAZILIAN_PORTUGUESE
"zh" -> {
val simplified = locale.script.equals("Hans", ignoreCase = true) ||
locale.script.isEmpty() ||
@@ -129,6 +129,19 @@ data class ChatMessage(
* the live message can be matched to its server row.
*/
val backgroundTask: BackgroundTaskState? = null,
/**
* Stable identity for Compose list rendering.
*
* Gateway/user rows start with client UUIDs, then post-turn history
* reconciliation adopts the server message id into [id]. That server-id
* adoption must not make a visible bubble look removed and reinserted to
* LazyColumn: doing so discards its scroll anchor, which is especially
* disruptive when the row is a long answer occupying the viewport.
*
* New rows default to their current [id]. Reconciled rows retain this key
* through `copy`, while [id] remains the authoritative lookup/wire id.
*/
val uiKey: String = id,
)
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
@@ -0,0 +1,31 @@
package com.hermesandroid.relay.data
/**
* Pure, persisted-state-derived availability for a Hermes connection.
*
* This deliberately describes configured surfaces, not live reachability or
* authentication. Runtime layers can combine it with their probe/auth state
* without treating a missing optional API server or Relay as a broken Hermes
* connection.
*/
data class ConnectionCapabilities(
val dashboardGatewayConfigured: Boolean,
val apiServerConfigured: Boolean,
val relayConfigured: Boolean,
) {
val gatewayChatAvailable: Boolean get() = dashboardGatewayConfigured
val manageAvailable: Boolean get() = dashboardGatewayConfigured
val standardVoiceAvailable: Boolean get() = dashboardGatewayConfigured
val apiChatFallbackAvailable: Boolean get() = apiServerConfigured
val relayFeaturesAvailable: Boolean get() = relayConfigured
val chatConfigured: Boolean get() = gatewayChatAvailable || apiChatFallbackAvailable
val anySurfaceConfigured: Boolean
get() = dashboardGatewayConfigured || apiServerConfigured || relayConfigured
}
val Connection.capabilities: ConnectionCapabilities
get() = ConnectionCapabilities(
dashboardGatewayConfigured = resolvedDashboardUrl.isNotBlank(),
apiServerConfigured = apiServerUrl.isNotBlank(),
relayConfigured = relayUrl.isNotBlank(),
)
@@ -19,7 +19,8 @@ data class DashboardConnectionStatus(
* A "connection" = a distinct Hermes server connection the app can switch between.
*
* Each connection has its own:
* - API server URL + relay URL
* - One or more independently-configured Hermes surfaces. Dashboard/Gateway
* is the standard primary path; API server and Relay are optional.
* - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
* session token, device ID, API key, and paired-session metadata.
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
@@ -73,17 +74,34 @@ data class Connection(
val preferredRouteRole: String? = null,
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
val pairedAt: Long? = null,
/** Last time the user explicitly selected this connection. */
val lastUsedAt: Long? = null,
val lastActiveSessionId: String? = null,
val transportHint: String? = null,
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
val expiresAt: Long? = null,
) {
/**
* Effective Dashboard/Gateway endpoint. Legacy records did not persist a
* dashboard URL, so they retain the conventional same-host `:9119`
* derivation from the API server. Dashboard-only records persist an
* explicit URL and may leave [apiServerUrl] and [relayUrl] blank.
*/
val resolvedDashboardUrl: String
get() = dashboardUrl
?.trim()
?.takeIf { it.isNotBlank() }
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
/** Stable display/host identity that does not depend on the API surface. */
val primaryEndpointUrl: String
get() = resolvedDashboardUrl.takeIf { it.isNotBlank() }
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
?: relayUrl.trim()
val primaryHost: String
get() = extractHost(primaryEndpointUrl).orEmpty()
companion object {
/**
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
@@ -111,12 +129,40 @@ data class Connection(
* user typed a malformed value — better to show something recognizable
* than to crash).
*/
fun extractDefaultLabel(apiServerUrl: String): String {
return try {
URI(apiServerUrl).host ?: apiServerUrl
} catch (_: Exception) {
apiServerUrl
}
fun extractDefaultLabel(apiServerUrl: String): String =
extractHost(apiServerUrl) ?: apiServerUrl
/** Preserve explicit labels while upgrading an auto-generated IP label to a discovered host name. */
fun chooseDiscoveredLabel(
currentLabel: String,
primaryHost: String,
discoveredHostname: String?,
): String {
val current = currentLabel.trim()
val discovered = discoveredHostname?.trim()?.takeIf { it.isNotBlank() }
val isAutomatic = current.isBlank() || current.equals(primaryHost.trim(), ignoreCase = true)
return if (isAutomatic && discovered != null) discovered else currentLabel
}
/**
* Dashboard-first label for a connection whose surfaces are optional.
* The one-argument overload above remains for source compatibility.
*/
fun extractDefaultLabel(
dashboardUrl: String?,
apiServerUrl: String,
relayUrl: String,
): String {
val primary = dashboardUrl?.trim()?.takeIf { it.isNotBlank() }
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
?: relayUrl.trim()
return extractHost(primary) ?: primary
}
private fun extractHost(url: String): String? = try {
URI(url).host
} catch (_: Exception) {
null
}
fun deriveDefaultDashboardUrl(
@@ -199,7 +245,7 @@ data class Connection(
return routes
.distinctBy {
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
"${it.role.lowercase()}|${it.routeAuthority()}"
}
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
@@ -222,13 +268,13 @@ data class Connection(
existing: List<EndpointCandidate>,
): List<EndpointCandidate> {
val rebuiltHostPorts = rebuilt
.map { "${it.api.host.lowercase()}:${it.api.port}" }
.mapNotNull { it.mergeAuthority() }
.toSet()
val preserved = existing
.filter { it.priority > 0 }
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
.filterNot { it.mergeAuthority() in rebuiltHostPorts }
return (rebuilt + preserved)
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
.distinctBy { "${it.role.lowercase()}|${it.routeAuthority()}" }
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
@@ -296,6 +342,85 @@ data class Connection(
)
}
/**
* De-duplication identity for rebuilding stored routes. Prefer the
* legacy API authority when present so an older API-only candidate and
* its dashboard-enriched replacement still collide. Dashboard-only
* candidates fall back to their primary route authority.
*/
private fun EndpointCandidate.mergeAuthority(): String? =
api?.let { endpoint -> "api|${endpoint.host.lowercase()}:${endpoint.port}" }
?: routeAuthority()?.let { authority -> "route|$authority" }
/**
* Build a Dashboard/Gateway-primary route from a remote host or URL.
* API and Relay are retained only when explicitly configured; callers
* no longer need to invent an API key or legacy surface URL.
*/
fun endpointCandidateFromDashboardUrl(
role: String,
priority: Int,
dashboardUrl: String,
apiServerUrl: String? = null,
relayUrl: String? = null,
): EndpointCandidate? {
val normalizedDashboard = normalizeDashboardUrlInput(dashboardUrl)
val dashboardUri = runCatching { URI(normalizedDashboard) }.getOrNull() ?: return null
if (dashboardUri.scheme?.lowercase() !in setOf("http", "https") ||
dashboardUri.host.isNullOrBlank()
) return null
val api = apiServerUrl
?.trim()
?.takeIf { it.isNotBlank() }
?.let { apiUrl ->
val apiUri = runCatching { URI(apiUrl.trimEnd('/')) }.getOrNull()
?: return@let null
val tls = when (apiUri.scheme?.lowercase()) {
"http" -> false
"https" -> true
else -> return@let null
}
val host = apiUri.host?.takeIf { it.isNotBlank() } ?: return@let null
ApiEndpoint(host, if (apiUri.port > 0) apiUri.port else 8642, tls)
}
val relay = relayUrl
?.trim()
?.takeIf { it.isNotBlank() }
?.let { url ->
val hint = when {
url.startsWith("wss://", ignoreCase = true) -> "wss"
url.startsWith("ws://", ignoreCase = true) -> "ws"
else -> null
}
RelayEndpoint(url, hint)
}
return EndpointCandidate(
role = role.ifBlank { inferRouteRole(normalizedDashboard) },
priority = priority,
dashboard = DashboardEndpoint(normalizedDashboard),
api = api,
relay = relay,
)
}
fun normalizeDashboardUrlInput(
raw: String,
defaultPort: Int = DEFAULT_DASHBOARD_PORT,
): String {
val trimmed = raw.trim().trimEnd('/')
if (trimmed.isEmpty()) return trimmed
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
val withScheme = "http://$trimmed"
val uri = runCatching { URI(withScheme) }.getOrNull()
val canAppendPort = uri != null &&
!uri.host.isNullOrBlank() &&
uri.port <= 0 &&
uri.rawPath.isNullOrEmpty() &&
uri.rawQuery == null
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
}
fun inferRouteRole(apiServerUrl: String): String {
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
.getOrNull()
@@ -60,6 +60,7 @@ import kotlinx.serialization.json.Json
class ConnectionStore private constructor(
private val dataStore: DataStore<Preferences>,
private val context: Context?,
private val scope: CoroutineScope,
) {
/**
@@ -71,6 +72,7 @@ class ConnectionStore private constructor(
constructor(context: Context) : this(
dataStore = context.relayDataStore,
context = context.applicationContext,
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
)
/**
@@ -81,9 +83,13 @@ class ConnectionStore private constructor(
internal constructor(dataStore: DataStore<Preferences>) : this(
dataStore = dataStore,
context = null,
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
)
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
internal constructor(
dataStore: DataStore<Preferences>,
scope: CoroutineScope,
) : this(dataStore = dataStore, context = null, scope = scope)
private val json = Json {
ignoreUnknownKeys = true
@@ -100,6 +106,13 @@ class ConnectionStore private constructor(
private val _activeConnectionId = MutableStateFlow<String?>(null)
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
/**
* Optional cold-start pin. `null` means restore the last connection the
* user actively selected, which remains the recommended default.
*/
private val _startupConnectionId = MutableStateFlow<String?>(null)
val startupConnectionId: StateFlow<String?> = _startupConnectionId.asStateFlow()
/**
* Flips to `true` once the initial DataStore hydrate completes (success OR
* failure). Until then [connections] / [activeConnection] hold their empty
@@ -128,6 +141,7 @@ class ConnectionStore private constructor(
val oldJson = prefs[KEY_LEGACY_PROFILES]
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
val activeOld = prefs[KEY_LEGACY_ACTIVE_PROFILE_ID]
val startupId = prefs[KEY_STARTUP_CONNECTION_ID]
// Prefer the new key. If absent and the old key has data,
// migrate it once: write to the new key and clear the old ones
@@ -143,15 +157,21 @@ class ConnectionStore private constructor(
p.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
}
}
_connections.value = decodeConnections(oldJson)
_activeConnectionId.value = activeOld
val restored = decodeConnections(oldJson)
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
_connections.value = restored
_startupConnectionId.value = validStartupId
_activeConnectionId.value = validStartupId ?: activeOld
Log.i(
TAG,
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
)
} else {
_connections.value = decodeConnections(newJson)
_activeConnectionId.value = activeNew
val restored = decodeConnections(newJson)
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
_connections.value = restored
_startupConnectionId.value = validStartupId
_activeConnectionId.value = validStartupId ?: activeNew
}
} catch (e: Exception) {
Log.w(TAG, "Initial hydrate failed: ${e.message}")
@@ -229,6 +249,10 @@ class ConnectionStore private constructor(
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
_activeConnectionId.value = null
}
if (prefs[KEY_STARTUP_CONNECTION_ID] == id) {
prefs.remove(KEY_STARTUP_CONNECTION_ID)
_startupConnectionId.value = null
}
}
removed?.let { deleteTokenStoresFor(it) }
}
@@ -247,10 +271,12 @@ class ConnectionStore private constructor(
removed = decodeConnections(prefs[KEY_CONNECTIONS])
prefs.remove(KEY_CONNECTIONS)
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
prefs.remove(KEY_STARTUP_CONNECTION_ID)
prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
_connections.value = emptyList()
_activeConnectionId.value = null
_startupConnectionId.value = null
}
removed.forEach { deleteTokenStoresFor(it) }
}
@@ -259,6 +285,7 @@ class ConnectionStore private constructor(
suspend fun replaceConnections(
connections: List<Connection>,
activeConnectionId: String? = null,
startupConnectionId: String? = null,
) {
writeMutex.withLock {
var removed: List<Connection> = emptyList()
@@ -266,6 +293,8 @@ class ConnectionStore private constructor(
val normalizedActiveId = activeConnectionId
?.takeIf { id -> normalizedConnections.any { it.id == id } }
?: normalizedConnections.firstOrNull()?.id
val normalizedStartupId = startupConnectionId
?.takeIf { id -> normalizedConnections.any { it.id == id } }
dataStore.edit { prefs ->
removed = decodeConnections(prefs[KEY_CONNECTIONS])
@@ -281,8 +310,14 @@ class ConnectionStore private constructor(
}
prefs.remove(KEY_LEGACY_PROFILES)
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
if (normalizedStartupId == null) {
prefs.remove(KEY_STARTUP_CONNECTION_ID)
} else {
prefs[KEY_STARTUP_CONNECTION_ID] = normalizedStartupId
}
_connections.value = normalizedConnections
_activeConnectionId.value = normalizedActiveId
_activeConnectionId.value = normalizedStartupId ?: normalizedActiveId
_startupConnectionId.value = normalizedStartupId
}
removed.forEach { deleteTokenStoresFor(it) }
}
@@ -315,12 +350,44 @@ class ConnectionStore private constructor(
suspend fun setActiveConnection(id: String) {
writeMutex.withLock {
dataStore.edit { prefs ->
val current = decodeConnections(prefs[KEY_CONNECTIONS])
if (current.any { it.id == id }) {
val next = current.map { connection ->
if (connection.id == id) {
connection.copy(lastUsedAt = System.currentTimeMillis())
} else {
connection
}
}
prefs[KEY_CONNECTIONS] = encodeConnections(next)
_connections.value = next
}
prefs[KEY_ACTIVE_CONNECTION_ID] = id
_activeConnectionId.value = id
}
}
}
/** Set a specific cold-start connection, or `null` to restore last used. */
suspend fun setStartupConnection(id: String?) {
writeMutex.withLock {
dataStore.edit { prefs ->
val validId = id?.takeIf { candidate ->
decodeConnections(prefs[KEY_CONNECTIONS]).any { it.id == candidate }
}
if (validId == null) {
prefs.remove(KEY_STARTUP_CONNECTION_ID)
_activeConnectionId.value?.let { activeId ->
prefs[KEY_ACTIVE_CONNECTION_ID] = activeId
}
} else {
prefs[KEY_STARTUP_CONNECTION_ID] = validId
}
_startupConnectionId.value = validId
}
}
}
/**
* Update just the `lastActiveSessionId` on the identified connection.
* Called whenever the user picks a chat session so connection-switch can
@@ -504,6 +571,7 @@ class ConnectionStore private constructor(
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id")
private val KEY_STARTUP_CONNECTION_ID = stringPreferencesKey("startup_connection_id")
// Pre-rename DataStore keys — read once in init on first launch after
// the rename, then wiped. See the init block above.
@@ -52,6 +52,45 @@ object ConnectionValidation {
kind = "relay URL",
)
/** Dashboard/Gateway URL must be HTTP(S) when configured. */
fun validateDashboardUrl(raw: String): String? = validateOptionalUrl(
raw = raw,
allowedSchemes = setOf("http", "https"),
kind = "Dashboard URL",
)
/** A blank API server means the optional SSE fallback is not configured. */
fun validateOptionalApiServerUrl(raw: String): String? = validateOptionalUrl(
raw = raw,
allowedSchemes = setOf("http", "https"),
kind = "API server URL",
)
/** A blank Relay URL means Relay-only power features are not configured. */
fun validateOptionalRelayUrl(raw: String): String? = validateOptionalUrl(
raw = raw,
allowedSchemes = setOf("ws", "wss"),
kind = "relay URL",
)
/**
* Validate the independently optional connection surfaces. A connection
* needs at least one endpoint, but Dashboard-only, API-only, and
* Relay-only records are all structurally valid.
*/
fun validateConnectionEndpoints(
dashboardUrl: String?,
apiServerUrl: String,
relayUrl: String,
): String? {
if (dashboardUrl.isNullOrBlank() && apiServerUrl.isBlank() && relayUrl.isBlank()) {
return "Configure at least one Hermes endpoint"
}
return validateDashboardUrl(dashboardUrl.orEmpty())
?: validateOptionalApiServerUrl(apiServerUrl)
?: validateOptionalRelayUrl(relayUrl)
}
/**
* Catches the "added the same server twice" mistake. Matches when the
* candidate's api + relay URLs exactly match an existing connection
@@ -67,12 +106,33 @@ object ConnectionValidation {
apiServerUrl: String,
relayUrl: String,
excludeId: String? = null,
dashboardUrl: String? = null,
): Connection? = connections.firstOrNull { c ->
c.id != excludeId &&
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) &&
c.relayUrl.equals(relayUrl, ignoreCase = true)
if (c.id == excludeId) {
false
} else {
val legacyExactMatch =
(apiServerUrl.isNotBlank() || relayUrl.isNotBlank()) &&
urlsEqual(c.apiServerUrl, apiServerUrl) &&
urlsEqual(c.relayUrl, relayUrl)
val candidateDashboard = dashboardUrl
?.takeIf { it.isNotBlank() }
?: Connection.deriveDefaultDashboardUrl(apiServerUrl)
val dashboardMatch = !candidateDashboard.isNullOrBlank() &&
urlsEqual(c.resolvedDashboardUrl, candidateDashboard)
legacyExactMatch || dashboardMatch
}
}
private fun validateOptionalUrl(
raw: String,
allowedSchemes: Set<String>,
kind: String,
): String? = if (raw.isBlank()) null else validateUrl(raw, allowedSchemes, kind)
private fun urlsEqual(first: String, second: String): Boolean =
first.trim().trimEnd('/').equals(second.trim().trimEnd('/'), ignoreCase = true)
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return "$kind can't be blank"
@@ -72,10 +72,11 @@ class DataManager(
* - v5 (2026-06-08): full connection backups. Adds active connection id
* and `connectionSecrets`, including API keys, relay tokens, device id,
* paired metadata, and dashboard cookies.
* - v6 (2026-07-19): preserves the optional pinned startup connection.
*/
@Serializable
data class AppBackup(
val version: Int = 5,
val version: Int = 6,
val serverUrl: String? = null, // legacy (v1 compat)
val apiServerUrl: String? = null,
val relayUrl: String? = null,
@@ -83,6 +84,7 @@ class DataManager(
val onboardingCompleted: Boolean = false,
val connections: List<Connection> = emptyList(),
val activeConnectionId: String? = null,
val startupConnectionId: String? = null,
val containsSensitiveData: Boolean = true,
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
val exportedAt: Long = System.currentTimeMillis(),
@@ -160,6 +162,7 @@ class DataManager(
onboardingCompleted = onboardingCompleted,
connections = connectionsSnapshot,
activeConnectionId = connectionStore?.activeConnectionId?.value,
startupConnectionId = connectionStore?.startupConnectionId?.value,
containsSensitiveData = true,
connectionSecrets = connectionSecrets,
exportedAt = System.currentTimeMillis(),
@@ -173,6 +176,7 @@ class DataManager(
store.replaceConnections(
connections = backup.connections,
activeConnectionId = backup.activeConnectionId,
startupConnectionId = backup.startupConnectionId,
)
val connectionsById = backup.connections.associateBy { it.id }
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.net.URI
/**
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
@@ -38,8 +39,10 @@ import kotlinx.serialization.Serializable
data class EndpointCandidate(
val role: String,
val priority: Int = 0,
val api: ApiEndpoint,
val relay: RelayEndpoint,
/** Optional legacy/API-server surface. Dashboard-only routes omit it. */
val api: ApiEndpoint? = null,
/** Optional Hermes-Relay bridge surface. Standard upstream routes omit it. */
val relay: RelayEndpoint? = null,
val dashboard: DashboardEndpoint? = null,
val proxy: ProxyEndpoint? = null,
val security: String? = null,
@@ -137,13 +140,42 @@ fun EndpointCandidate.displayLabel(): String {
return when (role.lowercase()) {
"lan" -> "LAN"
"tailscale" -> "Tailscale"
"public" -> if (api.tls) "HTTPS" else "Public"
"public" -> if (primaryRouteUrl()?.startsWith("https://", ignoreCase = true) == true) {
"HTTPS"
} else {
"Public"
}
"https" -> "HTTPS"
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
else -> "Custom VPN ($role)"
}
}
/** Dashboard-first URL identity for routing, diagnostics, and UI labels. */
fun EndpointCandidate.primaryRouteUrl(): String? =
dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
?: api?.url
?: relay?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
?: proxy?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
/** Stable host/port identity without assuming that an API surface exists. */
fun EndpointCandidate.routeAuthority(): String? {
val rawUrl = primaryRouteUrl() ?: return null
val httpUrl = when {
rawUrl.startsWith("ws://", ignoreCase = true) -> "http://${rawUrl.substringAfter("://")}"
rawUrl.startsWith("wss://", ignoreCase = true) -> "https://${rawUrl.substringAfter("://")}"
else -> rawUrl
}
val uri = runCatching { URI(httpUrl) }.getOrNull() ?: return null
val host = uri.host?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val port = when {
uri.port > 0 -> uri.port
uri.scheme.equals("https", ignoreCase = true) -> 443
else -> 80
}
return "$host:$port"
}
fun EndpointCandidate.hasSecureProxy(): Boolean =
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
@@ -9,6 +9,7 @@ import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.CertPinStore
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
@@ -325,17 +326,18 @@ class ConnectionManager(
// the synthesized list just collapses to the same URL anyway.
scope.launch {
val resolved = resolveBestEndpointSafe()
val targetUrl = resolved?.relay?.url ?: url
val resolvedRelayUrl = resolved?.relay?.url?.takeIf { it.isNotBlank() }
val targetUrl = resolvedRelayUrl ?: url.takeIf { it.isNotBlank() }
if (resolved != null) {
_activeEndpoint.value = resolved
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
"relay=${resolved.relay.url} (fallback would have been $url)")
"route=${resolved.primaryRouteUrl()} (relay fallback would have been $url)")
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
endpointRole = resolved.role,
url = resolved.relay.url,
url = resolved.primaryRouteUrl(),
)
} else {
_activeEndpoint.value = null
@@ -348,7 +350,11 @@ class ConnectionManager(
url = url,
)
}
connectToUrlOnMainPath(targetUrl)
if (targetUrl != null) {
connectToUrlOnMainPath(targetUrl)
} else {
Log.d(TAG, "connect: selected route has no Relay surface; route published for HTTP/Gateway clients")
}
}
}
@@ -656,10 +662,11 @@ class ConnectionManager(
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
// the swap path never re-checked it.)
if (!shouldReconnect) return@launch
val normalizedNew = normalizeRelayUrl(resolved.relay.url)
val relayUrl = resolved.relay?.url?.takeIf { it.isNotBlank() } ?: return@launch
val normalizedNew = normalizeRelayUrl(relayUrl)
if (normalizedNew != current) {
Log.i(TAG, "network change: swapping $current → $normalizedNew")
connectToUrlOnMainPath(resolved.relay.url, closeReason)
connectToUrlOnMainPath(relayUrl, closeReason)
} else if (_connectionState.value == ConnectionState.Disconnected &&
reconnectGate()
) {
@@ -4,6 +4,8 @@ import android.content.Context
import android.util.Log
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
@@ -56,7 +58,10 @@ data class RouteProbeOutcome(
* candidate is reachable we use it; reachability never promotes a lower
* priority over a higher one. Reachability is **only** the tiebreaker
* among candidates that share the same priority.
* * **Reachability probe.** `HEAD ${api.url}/health` with a 2-second
* * **Reachability probe.** Dashboard-first routes use `GET
* ${dashboard.url}/api/status`; legacy API routes use `HEAD
* ${api.url}/health`. Relay-only routes use `HEAD ${relay.httpUrl}/health`.
* Each request has a 4-second
* per-candidate timeout. Positive results are cached longer than negative
* results so repeated `connect()` calls don't hammer healthy routes, while
* transient handoff misses do not pin a good fallback offline.
@@ -101,6 +106,13 @@ class EndpointResolver(
*/
private data class CacheEntry(val expiresAt: Long, val reachable: Boolean)
private data class ProbeTarget(
val baseUrl: String,
val requestUrl: String,
val path: String,
val useHead: Boolean,
)
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
@@ -156,13 +168,13 @@ class EndpointResolver(
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
/**
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
* Stable cache key for a candidate: `"<role>|<primary host>:<port>"`.
* Roles are preserved case-verbatim (HMAC canonicalization contract)
* but hostnames are lowercased — two roles pointing at the same
* host:port share reachability state.
*/
internal fun cacheKey(candidate: EndpointCandidate): String =
"${candidate.role}|${candidate.api.host.lowercase()}:${candidate.api.port}"
"${candidate.role}|${candidate.routeAuthority() ?: candidate.primaryRouteUrl().orEmpty().lowercase()}"
}
/**
@@ -194,14 +206,14 @@ class EndpointResolver(
val winner = raceGroup(group)
if (winner != null) {
Log.i(TAG, "resolve winner: role=${winner.role} " +
"api=${winner.api.host}:${winner.api.port} priority=$priority")
"route=${winner.primaryRouteUrl()} priority=$priority")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
detail = "priority=$priority",
endpointRole = winner.role,
url = winner.relay.url,
url = winner.primaryRouteUrl(),
)
return winner
}
@@ -281,7 +293,7 @@ class EndpointResolver(
}
/**
* One-shot HEAD /health probe against a candidate. 2-second timeout,
* One-shot probe against a candidate's primary configured surface.
* no retries — callers that need retry semantics can re-invoke after
* the cache expires.
*
@@ -290,18 +302,19 @@ class EndpointResolver(
*/
private suspend fun probe(candidate: EndpointCandidate): Boolean {
val startedAtMs = clock()
val url = "${candidate.api.url}/health".toHttpUrlOrNull()
val target = probeTarget(candidate)
val url = target?.requestUrl?.toHttpUrlOrNull()
?: run {
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
detail = "Invalid API URL",
detail = "No valid Dashboard, API, or Relay URL",
endpointRole = candidate.role,
url = candidate.api.url,
url = candidate.primaryRouteUrl(),
)
recordOutcome(candidate, reachable = false, detail = "Invalid API URL")
recordOutcome(candidate, reachable = false, detail = "Invalid route URL")
return false
}
val fastClient = httpClient.newBuilder()
@@ -310,11 +323,10 @@ class EndpointResolver(
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
val request = Request.Builder()
val requestBuilder = Request.Builder()
.url(url)
.head()
.header("Accept", "*/*")
.build()
val request = if (target.useHead) requestBuilder.head().build() else requestBuilder.get().build()
return withContext(Dispatchers.IO) {
try {
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
@@ -331,13 +343,13 @@ class EndpointResolver(
title = probeTitle,
detail = if (ok) null else "HTTP ${resp.code}",
endpointRole = candidate.role,
url = candidate.api.url,
url = target.baseUrl,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(
candidate,
reachable = ok,
detail = if (ok) null else "HTTP ${resp.code} from /health",
detail = if (ok) null else "HTTP ${resp.code} from ${target.path}",
)
ok
}
@@ -346,9 +358,9 @@ class EndpointResolver(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
endpointRole = candidate.role,
url = candidate.api.url,
url = target.baseUrl,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
@@ -359,23 +371,23 @@ class EndpointResolver(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
endpointRole = candidate.role,
url = candidate.api.url,
url = target.baseUrl,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
} catch (e: Exception) {
Log.d(TAG, "probe failed role=${candidate.role} " +
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
"route=${target.baseUrl}: ${e.javaClass.simpleName}")
DiagnosticsLog.record(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
detail = e.javaClass.simpleName,
endpointRole = candidate.role,
url = candidate.api.url,
url = target.baseUrl,
elapsedMs = clock() - startedAtMs,
)
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
@@ -384,6 +396,53 @@ class EndpointResolver(
}
}
/** Choose the standard Dashboard/Gateway surface first when advertised. */
private fun probeTarget(candidate: EndpointCandidate): ProbeTarget? {
candidate.dashboard?.url
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?.let { base ->
return ProbeTarget(
baseUrl = base,
requestUrl = "$base/api/status",
path = "/api/status",
useHead = false,
)
}
candidate.api?.url?.let { base ->
return ProbeTarget(
baseUrl = base,
requestUrl = "$base/health",
path = "/health",
useHead = true,
)
}
candidate.relay?.url
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?.let { relayUrl ->
val httpBase = when {
relayUrl.startsWith("ws://", ignoreCase = true) ->
"http://${relayUrl.substringAfter("://")}"
relayUrl.startsWith("wss://", ignoreCase = true) ->
"https://${relayUrl.substringAfter("://")}"
else -> return null
}
return ProbeTarget(
baseUrl = relayUrl,
requestUrl = "$httpBase/health",
path = "/health",
useHead = true,
)
}
return null
}
/**
* Map a probe exception to a short, actionable string for the Routes
* card. The TLS case is the headline: a route saved with `https://`
@@ -8,22 +8,29 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.Inet4Address
import java.net.InetAddress
import java.util.concurrent.TimeUnit
data class HermesLanDiscoveryResult(
val host: String,
val hostname: String? = null,
val apiUrl: String,
val dashboardUrl: String?,
val apiReachable: Boolean,
val dashboardReachable: Boolean,
)
) {
val displayHost: String
get() = hostname ?: host
}
/**
* User-triggered local-network discovery for standard Hermes setup.
@@ -59,7 +66,9 @@ object HermesLanDiscovery {
hosts.map { host ->
async {
semaphore.withPermit {
probeHost(client, host, apiPort, dashboardPort)
probeHost(client, host, apiPort, dashboardPort)?.let { result ->
result.copy(hostname = resolveHostname(host))
}
}
}
}.awaitAll()
@@ -130,6 +139,24 @@ object HermesLanDiscovery {
}
}
private suspend fun resolveHostname(address: String): String? =
withTimeoutOrNull(350L) {
runInterruptible(Dispatchers.IO) {
normalizeResolvedHostname(
address = address,
resolved = InetAddress.getByName(address).canonicalHostName,
)
}
}
internal fun normalizeResolvedHostname(address: String, resolved: String?): String? {
val normalized = resolved?.trim()?.trimEnd('.')?.takeIf { it.isNotBlank() } ?: return null
if (normalized.equals(address.trim(), ignoreCase = true)) return null
if (normalized.equals("localhost", ignoreCase = true)) return null
if (normalized.matches(Regex("^\\d{1,3}(?:\\.\\d{1,3}){3}$"))) return null
return normalized
}
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
val lower = body.lowercase()
return contentType.contains("json", ignoreCase = true) && (
@@ -1315,7 +1315,9 @@ class ChatHandler {
// `id = messageId` adopts the server id: for an id-matched (SSE)
// row it's a no-op, but for a positionally reconciled (gateway /
// user) row whose `prior` still carries a client UUID it swaps in
// the server id so EVERY future reload matches by id.
// the server id so EVERY future reload matches by id. `uiKey` is
// deliberately not overwritten: Compose must continue treating
// this as the same visible row across the post-turn reload.
prior.copy(
id = messageId,
role = role,
@@ -2103,9 +2103,19 @@ class GatewayChatClient(
private val rejoinAttempts = java.util.concurrent.atomic.AtomicInteger(0)
/** True if this socket loss should be answered with a rejoin attempt. */
fun beginRejoin(): Boolean =
!ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
@Volatile
private var reconcileRequired = false
/**
* True if this socket loss should be answered with a rejoin attempt.
* Mark reconciliation before reconnecting so a terminal event arriving
* immediately after `gateway.ready` cannot race ahead of the signal.
*/
fun beginRejoin(): Boolean {
val shouldRejoin = !ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
if (shouldRejoin) reconcileRequired = true
return shouldRejoin
}
private var watchdog: Job? = null
@@ -2132,6 +2142,12 @@ class GatewayChatClient(
// Ask requests block with no further events, so they arm with
// their own (longer) duration via watchdogTimeoutFor.
armWatchdog(watchdogTimeoutFor(type))
// Queue this immediately before the terminal callbacks. Both are
// marshalled through the same dispatcher, preserving callback order
// even when the WebSocket reader and reconnect coroutine differ.
if (type == "message.complete" && reconcileRequired) {
callbacks.onReconcileRequired()
}
mapper.onEvent(type, payload)
if (mapper.turnEnded) {
disarmWatchdog()
@@ -2305,6 +2321,7 @@ class GatewayChatClient(
onToolCallFailed = { a, b -> callbackDispatcher { callbacks.onToolCallFailed(a, b) } },
onToolOutputRisk = { v -> callbackDispatcher { callbacks.onToolOutputRisk(v) } },
onTurnComplete = { callbackDispatcher { callbacks.onTurnComplete() } },
onReconcileRequired = { callbackDispatcher { callbacks.onReconcileRequired() } },
onComplete = { callbackDispatcher { callbacks.onComplete() } },
onUsage = { v -> callbackDispatcher { callbacks.onUsage(v) } },
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
@@ -335,6 +335,12 @@ class GatewayTurnCallbacks(
/** Attach deterministic output-risk metadata to the matching tool card. */
val onToolOutputRisk: (GatewayToolOutputRisk) -> Unit = { _ -> },
val onTurnComplete: () -> Unit,
/**
* Fired before [onComplete] when this turn rejoined after a socket gap.
* Events emitted while the socket was unavailable are not replayed, so
* the caller must reconcile the durable transcript after completion.
*/
val onReconcileRequired: () -> Unit,
val onComplete: () -> Unit,
val onUsage: (UsageInfo?) -> Unit,
val onError: (String) -> Unit,
@@ -103,10 +103,13 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BridgePreferencesRepository
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.data.VoicePreferencesRepository
import com.hermesandroid.relay.data.VoiceSettings
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.data.displayLabel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
@@ -128,6 +131,7 @@ import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
import com.hermesandroid.relay.ui.screens.ChatScreen
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
import com.hermesandroid.relay.ui.screens.DashboardManagementScreen
import com.hermesandroid.relay.ui.screens.DashboardSignInScreen
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
@@ -151,12 +155,17 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
import com.hermesandroid.relay.viewmodel.TerminalViewModel
import com.hermesandroid.relay.viewmodel.VoiceViewModel
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import com.hermesandroid.relay.audio.VoicePlayer
import com.hermesandroid.relay.audio.VoiceRecorder
import com.hermesandroid.relay.audio.VoiceSfxPlayer
@@ -182,6 +191,68 @@ suspend fun SnackbarHostState.showHumanError(err: HumanError) {
)
}
/** Startup chrome should wait for either standard chat surface, not Relay. */
internal fun hasConfiguredStartupChat(connection: Connection?): Boolean =
connection?.capabilities?.chatConfigured == true
/**
* App-root chat health derived only from the two transports that can carry a
* conversation. Optional Relay state is deliberately absent.
*/
internal fun resolveAppChatRuntimeStatus(
connection: Connection?,
gatewayAvailability: GatewayAvailability,
apiHealth: ConnectionViewModel.HealthStatus,
): ChatRuntimeStatus {
val capabilities = connection?.capabilities
val gateway = when {
capabilities?.dashboardGatewayConfigured != true -> ChatTransportReadiness.NotConfigured
gatewayAvailability == GatewayAvailability.Ready -> ChatTransportReadiness.Ready
gatewayAvailability == GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
}
val api = when {
capabilities?.apiServerConfigured != true -> ChatTransportReadiness.NotConfigured
apiHealth == ConnectionViewModel.HealthStatus.Reachable -> ChatTransportReadiness.Ready
apiHealth == ConnectionViewModel.HealthStatus.Unknown ||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
}
return resolveChatRuntimeStatus(gateway = gateway, apiSse = api)
}
/** Route represented by the app footer's currently usable chat transport. */
internal fun resolveFooterRouteCandidate(
runtimeStatus: ChatRuntimeStatus,
activeEndpoint: EndpointCandidate?,
connection: Connection?,
effectiveDashboardUrl: String,
): EndpointCandidate? {
val connected = runtimeStatus as? ChatRuntimeStatus.Connected ?: return null
return when (connected.transport) {
ChatTransportPath.Gateway -> {
val dashboardUrl = effectiveDashboardUrl.trim().trimEnd('/')
.ifBlank { connection?.resolvedDashboardUrl.orEmpty() }
if (dashboardUrl.isBlank()) {
null
} else {
val activeDashboardUrl = activeEndpoint?.dashboard?.url
?.trim()
?.trimEnd('/')
activeEndpoint?.takeIf { activeDashboardUrl == dashboardUrl }
?: Connection.endpointCandidateFromDashboardUrl(
role = Connection.inferRouteRole(dashboardUrl),
priority = activeEndpoint?.priority ?: 0,
dashboardUrl = dashboardUrl,
)
}
}
ChatTransportPath.ApiSse -> activeEndpoint?.takeIf { it.api != null }
?: connection?.routeCandidates?.firstOrNull { it.api != null }
}
}
sealed class Screen(
val route: String,
val label: String,
@@ -214,6 +285,17 @@ sealed class Screen(
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
data object DashboardSignIn : Screen(
"dashboard_sign_in?source={source}",
"Dashboard sign in",
Icons.Filled.Settings,
) {
const val ARG_SOURCE: String = "source"
const val SOURCE_GENERAL: String = "general"
const val SOURCE_PAIR: String = "pair"
const val SOURCE_ONBOARDING: String = "onboarding"
fun route(source: String = SOURCE_GENERAL): String = "dashboard_sign_in?source=$source"
}
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
// Non-bottom-nav destinations — reached by explicit navigation, not the
@@ -359,6 +441,12 @@ fun RelayApp() {
// ConnectionStore's mutations are all suspend fns and we don't want to
// block the main dispatcher from inside the composable body.
val connectionSwitchScope = rememberCoroutineScope()
// Add-connection preparation may outlive the initiating list frame now
// that navigation happens immediately. Keep the job by placeholder id so
// an instant Back can wait for creation and then discard it safely.
val pendingAddConnectionJobs = remember {
mutableMapOf<String, kotlinx.coroutines.Job>()
}
// One-time init: the terminal channel ViewModel registers with the shared
// multiplexer and observes the relay connection state so it can attach/
@@ -434,11 +522,13 @@ fun RelayApp() {
}
}
// Initialize ChatViewModel reactively when the chat-routed API client becomes available
// Bind chat state independently of the optional API fallback client.
val chatApiClient by connectionViewModel.chatApiClient.collectAsState()
val chatTransportReady by connectionViewModel.chatReady.collectAsState()
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val effectiveSessionProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
val effectiveDisplayProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
@@ -633,9 +723,10 @@ fun RelayApp() {
}
}
LaunchedEffect(chatApiClient) {
val client = chatApiClient ?: return@LaunchedEffect
var boundCatalogConnectionId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(chatApiClient, activeConnectionId) {
val handler = connectionViewModel.chatHandler
val connectionChanged = boundCatalogConnectionId != activeConnectionId
// A route handoff / reconnect rebuilds the API client (new instance)
// while the chat is unchanged — the bound handler is the same. Take the
// cheap path: swap the client reference only, no re-init. This is what
@@ -643,11 +734,24 @@ fun RelayApp() {
// switch or a reconnect. A genuine re-bind (different handler) falls
// through to the full one-time wiring below.
if (chatViewModel.boundHandler === handler) {
chatViewModel.updateApiClient(client)
if (connectionChanged) {
chatViewModel.resetConnectionCatalogs()
// The active pointer changes before an API target is rebuilt.
// Never let the outgoing API client refill the new connection's
// catalogs during that window. Dashboard-only (null -> null)
// refreshes immediately through the already-installed loader.
chatViewModel.updateApiClient(null)
} else {
chatViewModel.updateApiClient(chatApiClient)
}
boundCatalogConnectionId = activeConnectionId
return@LaunchedEffect
}
chatViewModel.initialize(client, handler)
// The Dashboard/Gateway transport is independently sufficient for
// chat, so handler and dashboard callbacks must bind even when no API
// fallback client is configured.
chatViewModel.initialize(chatApiClient, handler)
// Wire inbound-media dependencies. Idempotent rewire of the
// ChatHandler callbacks.
@@ -675,10 +779,7 @@ fun RelayApp() {
)
}
chatViewModel.setDisplayProfileProvider {
AgentDisplay.effectiveDisplayProfile(
selectedProfile = connectionViewModel.selectedProfile.value,
profiles = connectionViewModel.agentProfiles.value,
)
connectionViewModel.effectiveDisplayProfile.value
}
chatViewModel.setDisplayAliasProvider {
connectionViewModel.profileDisplayAlias.value
@@ -694,6 +795,12 @@ fun RelayApp() {
chatViewModel.setProfileMessageLoader { sessionId ->
connectionViewModel.loadProfileScopedMessages(sessionId)
}
// Personality choices live in Dashboard `/api/config` on a
// dashboard-only connection. The setter immediately refreshes after
// this callback is installed, covering the null-API initialization.
chatViewModel.setDashboardConfigLoader {
connectionViewModel.loadActiveDashboardConfig()
}
// …and delete from that same profile's DB so a non-default profile's
// session can't be resurrected by the next profile-scoped list.
chatViewModel.profileSessionDeleter = { sessionId ->
@@ -710,6 +817,7 @@ fun RelayApp() {
chatViewModel.onSessionChanged = { sessionId ->
connectionViewModel.saveLastSessionId(sessionId)
}
boundCatalogConnectionId = activeConnectionId
}
// Reload sessions / switch profile context only on a SEMANTIC change
@@ -718,16 +826,15 @@ fun RelayApp() {
// means a route handoff (which churns the client) no longer triggers a
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
// already no-ops when the context key + session are unchanged.
val chatClientReady = chatApiClient != null
LaunchedEffect(
chatClientReady,
chatTransportReady,
activeConnectionId,
selectedProfile?.name,
effectiveSessionProfileName,
lastSessionId,
profileSelectionSettled,
) {
if (!chatClientReady) return@LaunchedEffect
if (!chatTransportReady) return@LaunchedEffect
// Cold-start profile-isolation guard: hold the first profile-scoped load
// until the persisted profile selection has SETTLED, so the session
// drawer (and the restored session context) don't briefly load the
@@ -770,7 +877,7 @@ fun RelayApp() {
)
}
LaunchedEffect(selectedProfile?.name, agentProfiles, profileDisplayAlias) {
LaunchedEffect(selectedProfile?.name, effectiveDisplayProfile?.name, agentProfiles, profileDisplayAlias) {
chatViewModel.refreshAgentDisplayName(relabelGenericMessages = true)
}
@@ -847,10 +954,10 @@ fun RelayApp() {
// re-runs activeGatewayChatClient(), which RETARGETS the in-flight gateway
// client to follow the new dashboard route instead of stranding the turn on
// the dead one.
val effectiveApiUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
// Debounce a route FLIP before re-acquiring the gateway chat client. The
// network-layer hysteresis (ConnectionManager) already keeps _activeEndpoint
// stable on a transient endpoint-resolution miss, so effectiveApiUrl should
// stable on a transient endpoint-resolution miss, so the Dashboard URL should
// not flap — this is belt-and-suspenders against any residual sub-second
// LAN⇄Tailscale flip, which would otherwise shutdown the warm gateway socket
// (when idle) or retarget mid-turn (burning MAX_TURN_REJOINS). The FIRST
@@ -859,9 +966,17 @@ fun RelayApp() {
// unaffected; only a genuine url change waits for a settle window, and if
// the url flips back within it the LaunchedEffect cancels + restarts so no
// rebuild happens.
var lastAcquiredApiUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(streamingEndpoint, serverCapabilities, gatewayAvailability, effectiveApiUrl) {
if (lastAcquiredApiUrl != null && lastAcquiredApiUrl != effectiveApiUrl) {
var lastAcquiredDashboardUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(
streamingEndpoint,
serverCapabilities,
gatewayAvailability,
effectiveDashboardUrl,
) {
if (
lastAcquiredDashboardUrl != null &&
lastAcquiredDashboardUrl != effectiveDashboardUrl
) {
delay(750L)
}
val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
@@ -870,7 +985,7 @@ fun RelayApp() {
chatViewModel.updateGatewayClient(
if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null,
)
lastAcquiredApiUrl = effectiveApiUrl
lastAcquiredDashboardUrl = effectiveDashboardUrl
}
// What's New auto-show
@@ -961,7 +1076,6 @@ fun RelayApp() {
CrashReportGate()
val navController = rememberNavController()
var postOnboardingRoute by remember { mutableStateOf<String?>(null) }
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
// Collect navigation requests posted by external launchers (e.g., the
@@ -1026,17 +1140,6 @@ fun RelayApp() {
}
}
LaunchedEffect(onboardingCompleted, postOnboardingRoute) {
val route = postOnboardingRoute
if (onboardingCompleted && route != null) {
postOnboardingRoute = null
navController.navigate(route) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
launchSingleTop = true
}
}
}
// startDestination uses the route TEMPLATE so it matches the
// composable registered below; optional args default to null/false.
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
@@ -1127,11 +1230,8 @@ fun RelayApp() {
// bottom navigation bar so the voice overlay can own the entire screen
// without the Chat/Terminal/Bridge/Settings tabs peeking through below.
val voiceUiState by voiceViewModel.uiState.collectAsState()
val globalConnectionStatus by connectionViewModel.globalConnectionStatus.collectAsState()
val postResumeQuiet by connectionViewModel.postResumeQuiet.collectAsState()
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val relayReady by connectionViewModel.relayReady.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
@@ -1166,17 +1266,16 @@ fun RelayApp() {
startupGateTimedOut = true
}
val hasStartupConnection = activeConnection?.apiServerUrl?.isNotBlank() == true
// A published activeEndpoint counts as "hermes online": the route
// resolver only publishes a winner after a successful HEAD /health
// probe against that route's API URL. At cold start this evidence
// lands within ~1s — long before the client-based health probe,
// which can't run until the API client exists (the client build
// used to queue behind the Keystore decrypt; see
// apiKeyForClientBuild in ConnectionViewModel).
val startupApiUp = apiReachable ||
apiHealth == ConnectionViewModel.HealthStatus.Reachable ||
activeEndpoint != null
val hasStartupConnection = hasConfiguredStartupChat(activeConnection)
val appChatRuntimeStatus = resolveAppChatRuntimeStatus(
connection = activeConnection,
gatewayAvailability = gatewayAvailability,
apiHealth = apiHealth,
)
// A Dashboard/Gateway-only connection is a complete standard Hermes
// connection. Startup readiness follows the same transport-neutral
// priority as the footer instead of waiting for an optional API probe.
val startupChatUp = appChatRuntimeStatus is ChatRuntimeStatus.Connected
// An Unreachable verdict only counts after it SURVIVES a settle
// window: the first health probe often runs against the persisted
@@ -1185,9 +1284,9 @@ fun RelayApp() {
// first verdict was what flashed the disconnected chat UI at users
// who were connected-just-waiting. The keyed effect restarts on
// every health flip, cancelling a pending settle.
LaunchedEffect(apiHealth, startupGateReleased) {
LaunchedEffect(appChatRuntimeStatus, startupGateReleased) {
if (startupGateReleased) return@LaunchedEffect
if (apiHealth == ConnectionViewModel.HealthStatus.Unreachable) {
if (hasStartupConnection && appChatRuntimeStatus is ChatRuntimeStatus.Unavailable) {
delay(3_000L)
startupUnreachableSettled = true
} else {
@@ -1211,16 +1310,16 @@ fun RelayApp() {
StartupCheckState.Done,
"route · ${startupEndpoint.displayLabel()}",
)
startupApiUp ->
startupChatUp ->
StartupCheck(StartupCheckState.Done, "route · direct")
appReady ->
StartupCheck(StartupCheckState.Active, "resolving route")
else -> StartupCheck(StartupCheckState.Pending, "route")
},
when {
startupApiUp ->
startupChatUp ->
StartupCheck(StartupCheckState.Done, "hermes online")
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
appChatRuntimeStatus is ChatRuntimeStatus.Unavailable ->
StartupCheck(StartupCheckState.Failed, "hermes unreachable")
appReady ->
StartupCheck(StartupCheckState.Active, "contacting hermes")
@@ -1232,7 +1331,7 @@ fun RelayApp() {
when {
chatReady && initialChatSettled ->
StartupCheck(StartupCheckState.Done, "conversation ready")
startupApiUp ->
startupChatUp ->
StartupCheck(StartupCheckState.Active, "loading conversation")
else -> StartupCheck(StartupCheckState.Pending, "conversation")
},
@@ -1460,7 +1559,7 @@ fun RelayApp() {
// stays fully silent (the health "Connecting" cue used to flash here for a
// few seconds and then clear with no "Connected" toast).
val connectionReconnecting =
globalConnectionStatus?.active == true && !postResumeQuiet &&
appChatRuntimeStatus is ChatRuntimeStatus.Connecting && !postResumeQuiet &&
!suppressGlobalChrome && !showStartupSphere && !voiceUiState.voiceMode
// === END v0.4.1 polish ===
@@ -1600,7 +1699,13 @@ fun RelayApp() {
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
val routeLabel = activeEndpoint?.displayLabel()
val footerRoute = resolveFooterRouteCandidate(
runtimeStatus = appChatRuntimeStatus,
activeEndpoint = activeEndpoint,
connection = activeConnection,
effectiveDashboardUrl = effectiveDashboardUrl,
)
val routeLabel = footerRoute?.displayLabel()
?: activeConnection?.label
?: stringResource(R.string.status_no_route)
val transportStatus = resolveChatTransportStatus(
@@ -1613,12 +1718,9 @@ fun RelayApp() {
} else {
routeLabel
}
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() }
val profileLabel = AgentDisplay.profileDisplayName(effectiveDisplayProfile)
?: stringResource(R.string.status_profile_default)
val displayProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
val displayProfile = effectiveDisplayProfile
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
?: AgentDisplay.displayModelName(displayProfile?.model)
?: AgentDisplay.displayModelName(serverModelName)
@@ -1701,8 +1803,9 @@ fun RelayApp() {
}
},
onManageSignIn = {
postOnboardingRoute = Screen.Manage.route
connectionViewModel.completeOnboarding()
navController.navigate(
Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_ONBOARDING),
)
},
onOpenPermissions = {
navController.navigate(Screen.PermissionsSettings.route)
@@ -1832,6 +1935,11 @@ fun RelayApp() {
onNavigateToConnections = {
navController.navigate(Screen.ConnectionsSettings.route)
},
onNavigateToSignIn = {
navController.navigate(Screen.DashboardSignIn.route()) {
launchSingleTop = true
}
},
// Standard back: return to wherever Manage was opened
// from (Settings → Hermes management, the agent sheet,
// etc.). The prior forced navigate(Chat) with
@@ -1865,6 +1973,39 @@ fun RelayApp() {
)
}
}
composable(
route = Screen.DashboardSignIn.route,
arguments = listOf(
navArgument(Screen.DashboardSignIn.ARG_SOURCE) {
type = NavType.StringType
defaultValue = Screen.DashboardSignIn.SOURCE_GENERAL
},
),
) { backStackEntry ->
val source = backStackEntry.arguments
?.getString(Screen.DashboardSignIn.ARG_SOURCE)
?: Screen.DashboardSignIn.SOURCE_GENERAL
DashboardSignInScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
onAuthenticated = {
when (source) {
Screen.DashboardSignIn.SOURCE_ONBOARDING -> {
connectionViewModel.completeOnboarding()
navController.navigate(Screen.Chat.route()) {
popUpTo(Screen.Onboarding.route) { inclusive = true }
launchSingleTop = true
}
}
Screen.DashboardSignIn.SOURCE_PAIR -> {
navController.popBackStack()
navController.popBackStack()
}
else -> navController.popBackStack()
}
},
)
}
composable(Screen.Terminal.route) {
if (coldStartAuthState is AuthState.Paired) {
TerminalScreen(
@@ -2082,6 +2223,7 @@ fun RelayApp() {
voiceClient = voiceClient,
connectionId = activeConnectionId,
selectedProfile = selectedProfile,
displayProfile = effectiveDisplayProfile,
standardVoiceAvailability = standardVoiceAvailability,
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
relayVoiceReady = relayVoiceReady,
@@ -2230,18 +2372,20 @@ fun RelayApp() {
navController.navigate(Screen.ConnectionDetail.route(id))
},
onAddConnection = {
connectionSwitchScope.launch {
// Create and switch to the placeholder before
// opening the wizard. Otherwise a fast scan or
// standard save can write into the outgoing
// connection's auth store.
val id = connectionViewModel.beginAddConnection(
preAllocatedId = java.util.UUID.randomUUID().toString(),
)
navController.navigate(
Screen.Pair.route(connectionId = id)
)
val id = java.util.UUID.randomUUID().toString()
// Draw step 1 immediately. Placeholder persistence
// and the heavy connection-context switch continue
// underneath the discovery UI instead of blocking
// navigation on encrypted-store/client setup.
navController.navigate(Screen.Pair.route(connectionId = id))
val job = connectionSwitchScope.launch {
try {
connectionViewModel.beginAddConnection(preAllocatedId = id)
} finally {
pendingAddConnectionJobs.remove(id)
}
}
pendingAddConnectionJobs[id] = job
},
onBack = { navController.popBackStack() },
// Pass the VM so the list cards can read live status
@@ -2283,7 +2427,7 @@ fun RelayApp() {
onRepair = { id ->
connectionSwitchScope.launch {
connectionViewModel.switchConnection(id).join()
navController.navigate(Screen.Pair.route(id))
navController.navigate(Screen.Pair.route(id, autoStart = "relay"))
}
},
onRevoke = { id ->
@@ -2333,9 +2477,14 @@ fun RelayApp() {
?.getString(Screen.Pair.ARG_CONNECTION_ID)
val autoStartArg = backStackEntry.arguments
?.getString(Screen.Pair.ARG_AUTO_START)
val pairConnections by connectionViewModel.connections.collectAsState()
val pairActiveId by connectionViewModel.activeConnectionId.collectAsState()
val pairSetupReady = connectionIdArg == null ||
(pairActiveId == connectionIdArg && pairConnections.any { it.id == connectionIdArg })
com.hermesandroid.relay.ui.screens.PairScreen(
connectionViewModel = connectionViewModel,
autoStart = autoStartArg,
setupReady = pairSetupReady,
// Offer demo only on the bare "Connect" entry (the
// "No Hermes connection" path) — not on add-connection /
// re-pair flows, which have a placeholder connection in
@@ -2354,8 +2503,9 @@ fun RelayApp() {
navController.popBackStack()
},
onManageSignIn = {
navController.popBackStack()
navController.navigate(Screen.Manage.route) {
navController.navigate(
Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_PAIR),
) {
launchSingleTop = true
}
},
@@ -2367,6 +2517,10 @@ fun RelayApp() {
// never got a pairedAt stamp.
if (connectionIdArg != null) {
connectionSwitchScope.launch {
// If Back wins the race with background
// preparation, wait until the placeholder
// exists before attempting to discard it.
pendingAddConnectionJobs.remove(connectionIdArg)?.join()
connectionViewModel.discardPlaceholderConnection(connectionIdArg)
}
}
@@ -2502,9 +2656,9 @@ fun RelayApp() {
// doesn't happen to match the one we're inspecting
// (shouldn't normally happen since the entry is
// keyed off the same Profile).
val selectedProfile by connectionViewModel
.selectedProfile.collectAsState()
val modelLabel = selectedProfile
val inspectorDisplayProfile by connectionViewModel
.effectiveDisplayProfile.collectAsState()
val modelLabel = inspectorDisplayProfile
?.takeIf { it.name == profileNameArg }
?.model
File diff suppressed because it is too large Load Diff
@@ -89,7 +89,10 @@ import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.ConnectionState
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import kotlinx.coroutines.launch
// ---------------------------------------------------------------------------
@@ -656,6 +659,7 @@ fun AgentInfoSheet(
// Profile + personality state — same flows the old pickers consumed.
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val resolvedDisplayProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val profilePresentation by connectionViewModel.profilePresentation.collectAsState()
var showProfileManager by remember { mutableStateOf(false) }
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
@@ -735,6 +739,26 @@ fun AgentInfoSheet(
val allConnections by connectionViewModel.connectionStore.connections.collectAsState()
val activeConnectionId by connectionViewModel.connectionStore
.activeConnectionId.collectAsState()
val activeConnection = remember(allConnections, activeConnectionId) {
allConnections.firstOrNull { it.id == activeConnectionId }
}
val chatRuntimeStatus = resolveChatRuntimeStatus(
gateway = when (gatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
GatewayAvailability.Unknown -> if (
activeConnection?.resolvedDashboardUrl.isNullOrBlank()
) ChatTransportReadiness.NotConfigured else ChatTransportReadiness.Connecting
GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
},
apiSse = when {
apiServerReachable -> ChatTransportReadiness.Ready
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
chatMode != ChatMode.DISCONNECTED -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
},
)
// Mid-stream gate — mirrors what ProfilePicker's `enabled` flag was doing:
// a radio tap during an in-flight chat turn would race the request. Apply
@@ -758,12 +782,8 @@ fun AgentInfoSheet(
val profileOverridesPersonality =
selectedProfile?.systemMessage?.isNotBlank() == true
val effectiveDisplayProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
val serverResolvedAgentName = AgentDisplay.agentName(
profile = effectiveDisplayProfile,
profile = resolvedDisplayProfile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = null,
@@ -816,7 +836,7 @@ fun AgentInfoSheet(
?.name
?.takeIf { it.isNotBlank() }
AgentSheetHeader(
profile = effectiveDisplayProfile,
profile = resolvedDisplayProfile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
localDisplayAlias = profileDisplayAlias,
@@ -826,8 +846,7 @@ fun AgentInfoSheet(
// default with the session provider.
sessionModelName = selectedModelOverride ?: gatewayCurrentModel,
modelProviderLabel = currentProviderLabel,
apiServerReachable = apiServerReachable,
chatMode = chatMode,
chatRuntimeStatus = chatRuntimeStatus,
isCustomized = selectedProfile != null ||
selectedPersonality != "default" ||
profileDisplayAlias != null,
@@ -1133,8 +1152,7 @@ fun AgentInfoSheet(
}
val inspectProfileText = stringResource(R.string.conn_info_inspect_profile)
val inspectorTarget = selectedProfile
?: serverDefaultProfile
val inspectorTarget = resolvedDisplayProfile
?: visibleProfileKeys
.asSequence()
.mapNotNull { key -> agentProfiles.firstOrNull { it.name == key } }
@@ -1526,10 +1544,8 @@ fun AgentInfoSheet(
// Pre-resolve strings for Connection section
val connectionTitle = stringResource(R.string.conn_info_connection_title)
val switchServersHint = stringResource(R.string.conn_info_switch_servers_hint)
val authLabel = stringResource(R.string.conn_info_auth)
val apiReachableLabel = stringResource(R.string.conn_info_api_reachable)
val pairedLabel = stringResource(R.string.conn_info_paired)
val hermesLabel = stringResource(R.string.conn_info_hermes)
val relayAuthLabel = stringResource(R.string.conn_info_relay_auth)
val apiFallbackLabel = stringResource(R.string.active_section_optional_api_fallback)
SectionLabel(
title = connectionTitle,
@@ -1546,10 +1562,24 @@ fun AgentInfoSheet(
val sessionTransport = sessionPathTransport(
connectionViewModel.resolveStreamingEndpoint(streamingEndpoint),
)
val routeLabel = activeEndpoint?.displayLabel()
?: com.hermesandroid.relay.data.Connection
.extractDefaultLabel(apiServerUrl)
.takeIf { it.isNotBlank() }
val routeLabel = if (sessionTransport.isGateway) {
val dashboardUrl = activeConnection?.resolvedDashboardUrl.orEmpty()
activeConnection?.routeCandidates
?.firstOrNull { it.dashboard?.url?.trimEnd('/') == dashboardUrl.trimEnd('/') }
?.displayLabel()
?: dashboardUrl.takeIf { it.isNotBlank() }?.let { url ->
com.hermesandroid.relay.data.Connection.endpointCandidateFromDashboardUrl(
role = com.hermesandroid.relay.data.Connection.inferRouteRole(url),
priority = 0,
dashboardUrl = url,
)?.displayLabel()
}
} else {
activeEndpoint?.displayLabel()
?: com.hermesandroid.relay.data.Connection
.extractDefaultLabel(apiServerUrl)
.takeIf { it.isNotBlank() }
}
val relayConnected = relayConnectionState == ConnectionState.Connected
val threadsActive = connectionViewModel.proactiveEnabled.collectAsState().value &&
connectionViewModel.authState.collectAsState().value is
@@ -1570,7 +1600,7 @@ fun AgentInfoSheet(
// Multi-connection switcher. Renders inline as a radio list
// (mirrors the Profile + Personality sections above) when the
// user has ≥2 paired connections. Replaces the separate top-
// user has ≥2 connections. Replaces the separate top-
// bar ConnectionChip that used to be the only switch surface
// — folding it here keeps all agent/connection controls in
// one place, matching Bailey's ask in the 2026-04-20 audit.
@@ -1583,11 +1613,10 @@ fun AgentInfoSheet(
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
allConnections.forEach { connection ->
val isActive = connection.id == activeConnectionId
val hostname = com.hermesandroid.relay.data.Connection
.extractDefaultLabel(connection.apiServerUrl)
val hostname = connection.primaryHost.ifBlank { connection.label }
val statusLine = when {
connection.pairedAt == null -> stringResource(R.string.conn_info_hostname_hermes, hostname)
else -> stringResource(R.string.conn_info_hostname_paired, hostname)
else -> stringResource(R.string.conn_info_hostname_relay_paired, hostname)
}
ProfileRadioRow(
primary = connection.label,
@@ -1605,9 +1634,17 @@ fun AgentInfoSheet(
}
}
ChipRow(label = authLabel) { authStateChip(authState) }
ChipRow(label = apiReachableLabel) {
val (label, bg, fg) = if (apiServerReachable) {
if (relayUrl.isNotBlank() || authState is AuthState.Paired) {
ChipRow(label = relayAuthLabel) { authStateChip(authState) }
}
ChipRow(label = apiFallbackLabel) {
val (label, bg, fg) = if (apiServerUrl.isBlank()) {
Triple(
stringResource(R.string.active_section_not_configured),
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (apiServerReachable) {
Triple(
stringResource(R.string.conn_info_yes),
MaterialTheme.colorScheme.primaryContainer,
@@ -2307,8 +2344,7 @@ private fun AgentSheetHeader(
serverModelName: String,
sessionModelName: String? = null,
modelProviderLabel: String? = null,
apiServerReachable: Boolean,
chatMode: ChatMode,
chatRuntimeStatus: ChatRuntimeStatus,
isCustomized: Boolean,
) {
val agentName = AgentDisplay.agentName(
@@ -2326,17 +2362,18 @@ private fun AgentSheetHeader(
// The global default — surfaced as a quiet caption only when THIS session
// runs something different (the always-visible global-vs-session split).
val serverDefaultLabel = AgentDisplay.displayModelName(serverModelName)
val isConnecting = !apiServerReachable && chatMode != ChatMode.DISCONNECTED
val isConnected = chatRuntimeStatus is ChatRuntimeStatus.Connected
val isConnecting = chatRuntimeStatus is ChatRuntimeStatus.Connecting
val connectedText = stringResource(R.string.conn_info_connected)
val connectingText = stringResource(R.string.conn_info_connecting)
val disconnectedText = stringResource(R.string.conn_info_disconnected)
val statusText = when {
apiServerReachable -> connectedText
isConnected -> connectedText
isConnecting -> connectingText
else -> disconnectedText
}
val statusColor = when {
apiServerReachable -> MaterialTheme.colorScheme.primary
isConnected -> MaterialTheme.colorScheme.primary
isConnecting -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
@@ -32,7 +32,7 @@ import com.hermesandroid.relay.data.ConnectionSecurityLevel
import com.hermesandroid.relay.data.SurfaceSecurity
private const val LEARN_MORE_URL =
"https://codename-11.github.io/hermes-relay/architecture/connection-security.html"
"https://hermes-relay.dev/docs/architecture/connection-security.html"
/**
* Per-surface "Connection security" detail sheet — the tap target for the
@@ -0,0 +1,77 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
data class ConnectionSetupTimelineStep(
val title: String,
val detail: String,
)
/** Compact completed-state timeline shared by connection and auth flows. */
@Composable
fun ConnectionSetupTimeline(
steps: List<ConnectionSetupTimelineStep>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
steps.forEachIndexed { index, step ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(28.dp)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(17.dp),
)
}
if (index != steps.lastIndex) {
Spacer(
modifier = Modifier
.size(width = 2.dp, height = 34.dp)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)),
)
}
}
Column(
modifier = Modifier.padding(bottom = if (index == steps.lastIndex) 0.dp else 10.dp),
) {
Text(step.title, style = MaterialTheme.typography.titleSmall)
Text(
step.detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -44,6 +44,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
@@ -55,6 +56,8 @@ import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.data.isEncryptedOverlayRoute
import com.hermesandroid.relay.data.isKnownRole
import com.hermesandroid.relay.data.isTlsUrl
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
@@ -72,8 +75,8 @@ import kotlinx.coroutines.launch
* on app start, tried first on every resolve; cleared by
* [onClearPreferred].
*
* Verbose by design — Bailey explicitly asked for per-row visibility, so we
* do NOT collapse into a master row. The card itself is wrapped in a
* Verbose by design: per-route visibility is retained instead of collapsing
* into a master row. The card itself is wrapped in a
* [SettingsExpandableCard] by the caller for page layout hygiene.
*
* Not visible on legacy installs: when [endpoints] is empty we render a
@@ -170,8 +173,7 @@ fun EndpointsCard(
candidate = candidate,
isActive = activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
activeEndpoint.api.port == candidate.api.port,
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
isProbing = isProbing,
outcome = outcomeFor(candidate),
@@ -283,8 +285,8 @@ private fun EndpointRow(
// read "host:8642" can behave completely differently. The
// scheme must be visible to be debuggable.
Text(
text = candidate.api.url +
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
text = candidate.primaryRouteUrl().orEmpty() +
(candidate.relay?.transportHint?.let { " · $it" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
@@ -400,7 +402,7 @@ private fun EndpointRow(
title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) },
text = {
Text(
text = stringResource(R.string.endpoints_remove_route_body, "${candidate.api.host}:${candidate.api.port}"),
text = stringResource(R.string.endpoints_remove_route_body, candidate.routeAuthority().orEmpty()),
style = MaterialTheme.typography.bodySmall,
)
},
@@ -421,7 +423,7 @@ private fun EndpointRow(
pinDialogText?.let { body ->
AlertDialog(
onDismissRequest = { pinDialogText = null },
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.api.host)) },
title = { Text(stringResource(R.string.endpoints_pin_title, candidate.routeAuthority().orEmpty())) },
text = {
Text(
text = body,
@@ -521,7 +523,7 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
* be classified independently before it's the active route.
*/
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
isTlsUrl(api.url) -> SurfaceSecurityKind.Tls
isTlsUrl(primaryRouteUrl().orEmpty()) -> SurfaceSecurityKind.Tls
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
else -> SurfaceSecurityKind.Plain
}
@@ -546,6 +548,7 @@ fun RouteEditorDialog(
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
onDismiss: () -> Unit,
) {
val uriHandler = LocalUriHandler.current
val knownRoles = listOf("tailscale", "public")
var selectedRole by remember {
mutableStateOf(
@@ -561,7 +564,7 @@ fun RouteEditorDialog(
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
)
}
var url by remember { mutableStateOf(original?.api?.url.orEmpty()) }
var url by remember(original) { mutableStateOf(original?.primaryRouteUrl().orEmpty()) }
var errorText by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
@@ -617,11 +620,10 @@ fun RouteEditorDialog(
// https?" is answered before Save, not after a failed probe.
val previewCandidate = remember(url, effectiveRole) {
url.takeIf { it.isNotBlank() }?.let {
Connection.endpointCandidateFromApiUrl(
Connection.endpointCandidateFromDashboardUrl(
role = effectiveRole.ifBlank { "custom" },
priority = original?.priority ?: 1,
apiServerUrl = Connection.normalizeApiUrlInput(it),
relayUrl = "",
dashboardUrl = Connection.normalizeDashboardUrlInput(it),
)
}
}
@@ -643,7 +645,7 @@ fun RouteEditorDialog(
url.isBlank() ->
supportingBlank
previewCandidate != null ->
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.api.url)
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.primaryRouteUrl().orEmpty())
else ->
supportingEnter
},
@@ -652,6 +654,18 @@ fun RouteEditorDialog(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
if (selectedRole == "tailscale") {
Text(
text = stringResource(R.string.endpoints_tailscale_setup_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(
onClick = { uriHandler.openUri(REMOTE_ACCESS_DOCS_URL) },
) {
Text(stringResource(R.string.endpoints_setup_help))
}
}
}
},
confirmButton = {
@@ -683,4 +697,7 @@ fun RouteEditorDialog(
)
}
private const val REMOTE_ACCESS_DOCS_URL =
"https://hermes-relay.dev/docs/guide/remote-access"
private const val CUSTOM_ROLE = "__custom__"
@@ -11,34 +11,21 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.semantics.CollectionInfo
import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontFamily
@@ -69,7 +56,6 @@ import com.mikepenz.markdown.model.markdownExtendedSpans
import com.hermesandroid.relay.ui.theme.LocalBrand
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import kotlinx.coroutines.delay
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMElementTypes.HEADER
import org.intellij.markdown.flavours.gfm.GFMElementTypes.ROW
@@ -303,285 +289,42 @@ fun StreamingMarkdownContent(
isStreaming: Boolean = true,
modifier: Modifier = Modifier,
) {
val blocks = remember(content, isStreaming) {
if (isStreaming) {
parseStreamingMarkdownBlocks(content)
} else {
listOf(StreamingMarkdownBlock.Markdown(content))
}
}
Column(
modifier = modifier,
// Match the final renderer's block spacer so moving the active tail
// into the settled Markdown prefix does not add a second layout jump.
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
blocks.forEach { block ->
when (block) {
is StreamingMarkdownBlock.Markdown -> MarkdownContent(
content = block.content,
textColor = textColor,
)
is StreamingMarkdownBlock.Text -> Text(
text = block.text,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
)
is StreamingMarkdownBlock.Code -> StreamingCodeBlock(block)
}
}
}
}
@Composable
private fun StreamingCodeBlock(block: StreamingMarkdownBlock.Code) {
// Discord-like fenced block: a contrasting inset surface with a thin header
// (language label + copy), and a horizontally-scrollable monospace body.
// Header is shown whenever there's a language to label or code to copy, so
// even a bare ``` fence gets the copy affordance once it has content.
val hasHeader = block.language.isNotBlank() || block.code.isNotBlank()
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceContainerLowest,
border = androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
),
) {
Column {
if (hasHeader) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 4.dp, top = 2.dp, bottom = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = block.language.ifBlank { "code" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
CodeCopyButton(code = block.code)
}
}
Text(
text = block.code.ifEmpty { " " },
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
),
softWrap = false,
)
}
}
}
/**
* Small copy affordance for a code block — copies [code] to the clipboard and
* briefly flips to a check for feedback. No-op while [code] is blank.
*/
@Composable
private fun CodeCopyButton(code: String) {
val clipboard = LocalClipboardManager.current
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(1500)
copied = false
}
}
IconButton(
onClick = {
if (code.isNotBlank()) {
clipboard.setText(AnnotatedString(code))
copied = true
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy code",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
if (isStreaming) {
// Keep one stable layout node for the entire live turn. Promoting each
// blank-terminated paragraph into Markdown replaced the Text subtree
// repeatedly; LazyColumn then exposed its fallback anchor for a frame,
// which looked like the whole chat reloaded. Updating this Text value
// only remeasures the growing bubble. Full Markdown is parsed once the
// owning row releases its stable live-tail layout.
Text(
// CommonMark ignores blank lines before the first block. The live
// Text renderer must do the same or a response whose transport
// prefix contains newlines appears to start several lines down.
// Preserve indentation on the first non-blank line so indented
// code and deliberately spaced prose are not altered.
text = content.withoutLeadingBlankLines(),
modifier = modifier,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
)
}
}
internal sealed interface StreamingMarkdownBlock {
data class Markdown(val content: String) : StreamingMarkdownBlock
data class Text(val text: String) : StreamingMarkdownBlock
data class Code(val language: String, val code: String) : StreamingMarkdownBlock
}
/**
* Splits an in-flight response into stable Markdown and one structurally
* incomplete tail.
*
* Only conservative, blank-terminated top-level prose/heading blocks promote
* to the real renderer. Lists, quotes, tables, indented blocks, HTML, and code
* remain on the lightweight streaming surface until the message settles; those
* containers can legally absorb later lines, so promoting them early causes a
* visible re-parenting jump when the final CommonMark tree is parsed.
*/
internal fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
if (content.isBlank()) return emptyList()
val normalized = content
.replace("\r\n", "\n")
.replace('\r', '\n')
val blocks = mutableListOf<StreamingMarkdownBlock>()
val settledEnd = findStableMarkdownBoundary(normalized).coerceAtLeast(0)
if (settledEnd > 0) {
normalized.substring(0, settledEnd).trimEnd().let { stable ->
if (stable.isNotBlank()) blocks += StreamingMarkdownBlock.Markdown(stable)
}
}
val activeTail = normalized.substring(settledEnd)
if (activeTail.isNotBlank()) {
blocks += parseActiveStreamingTail(activeTail)
}
return blocks
}
/** Last unambiguous blank-line boundary in a contiguous simple-markdown prefix. */
private fun findStableMarkdownBoundary(content: String): Int {
var offset = 0
var blockStart = 0
var activeFence: StreamingFence? = null
var lastStableBoundary = 0
while (offset < content.length) {
val newline = content.indexOf('\n', offset)
val lineEnd = if (newline >= 0) newline else content.length
val line = content.substring(offset, lineEnd)
activeFence = when (val current = activeFence) {
null -> streamingFence(line)
else -> if (isClosingFence(line, current)) null else current
}
// Whitespace-only lines can be meaningful indentation inside a list.
// Require a truly empty delimiter and stop at the first ambiguous
// container so every promoted prefix remains structurally final.
if (activeFence == null && newline >= 0 && line.isEmpty()) {
val candidate = content.substring(blockStart, offset).trimEnd()
if (candidate.isNotBlank() && !isConservativeStableBlock(candidate)) break
lastStableBoundary = newline + 1
blockStart = lastStableBoundary
}
if (newline < 0) break
offset = newline + 1
}
return lastStableBoundary
}
private fun isConservativeStableBlock(block: String): Boolean = block
.lineSequence()
.filter { it.isNotEmpty() }
.none { line ->
line.firstOrNull()?.isWhitespace() == true ||
AMBIGUOUS_STREAMING_BLOCK.matches(line)
}
private fun parseActiveStreamingTail(content: String): List<StreamingMarkdownBlock> {
val blocks = mutableListOf<StreamingMarkdownBlock>()
val paragraph = StringBuilder()
val code = StringBuilder()
var activeFence: StreamingFence? = null
var language = ""
fun flushParagraph() {
val text = paragraph.toString().trim('\n').trimEnd()
if (text.isNotBlank()) {
blocks += StreamingMarkdownBlock.Text(text)
}
paragraph.clear()
}
fun flushCode() {
blocks += StreamingMarkdownBlock.Code(
language = language,
code = code.toString().trimEnd('\n'),
)
code.clear()
}
val lines = content.split('\n')
lines.forEachIndexed { index, line ->
val lineWithBreak = if (index == lines.lastIndex) line else "$line\n"
if (activeFence == null) {
val fence = streamingFence(line)
if (fence != null) {
flushParagraph()
activeFence = fence
language = streamingFenceLanguage(line, fence)
} else {
paragraph.append(lineWithBreak)
}
} else if (isClosingFence(line, activeFence)) {
flushCode()
activeFence = null
language = ""
} else {
code.append(lineWithBreak)
}
}
if (activeFence != null) {
flushCode()
} else {
flushParagraph()
MarkdownContent(
content = content,
textColor = textColor,
modifier = modifier,
)
}
return blocks
}
private data class StreamingFence(
val marker: Char,
val length: Int,
)
internal fun String.withoutLeadingBlankLines(): String {
var contentStart = 0
while (contentStart < length) {
val lineEnd = indexOf('\n', startIndex = contentStart)
if (lineEnd < 0) break
private fun streamingFence(line: String): StreamingFence? {
val trimmed = line.trimStart()
val marker = trimmed.firstOrNull()?.takeIf { it == '`' || it == '~' } ?: return null
val length = trimmed.takeWhile { it == marker }.length
return length.takeIf { it >= 3 }?.let { StreamingFence(marker, it) }
val line = substring(contentStart, lineEnd).removeSuffix("\r")
if (line.isNotBlank()) break
contentStart = lineEnd + 1
}
return substring(contentStart)
}
private fun isClosingFence(line: String, activeFence: StreamingFence): Boolean {
val trimmed = line.trimStart()
if (trimmed.firstOrNull() != activeFence.marker) return false
val markerLength = trimmed.takeWhile { it == activeFence.marker }.length
return markerLength >= activeFence.length && trimmed.drop(markerLength).isBlank()
}
private fun streamingFenceLanguage(line: String, fence: StreamingFence): String {
val tail = line.trimStart().drop(fence.length).trim()
return tail
.takeWhile { !it.isWhitespace() && it != fence.marker }
.take(32)
}
private val AMBIGUOUS_STREAMING_BLOCK = Regex(
"""^(?:[-+*]\s|\d{1,9}[.)]\s|>|```|~~~|<|\||(?:-{3,}|={3,})\s*$).*""",
)
@@ -1,5 +1,7 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
@@ -83,6 +85,12 @@ fun MessageBubble(
showThinking: Boolean = true,
isFirstInGroup: Boolean = true,
isLastInGroup: Boolean = true,
/**
* Keeps a just-completed live tail on its stable Text layout. The owning
* list releases this once another row becomes the tail, allowing full
* Markdown to render without disturbing the visible bottom anchor.
*/
retainStreamingLayout: Boolean = false,
onCopyMessage: (String) -> Unit = {},
/**
* Quote this message into the input field. Null hides the Quote entry in
@@ -361,6 +369,26 @@ fun MessageBubble(
shape = bubbleShape,
color = backgroundColor,
modifier = Modifier
.then(
if (!isUser && !isSystem &&
(message.isStreaming || retainStreamingLayout)
) {
// The frame-paced text node is already measured at its
// new size. Animate and clip the owning surface so a
// newly wrapped line is revealed inside the expanding
// bubble instead of drawing below the previous bounds
// for one frame. TopStart keeps existing prose fixed.
Modifier.animateContentSize(
animationSpec = tween(
durationMillis = 72,
easing = LinearOutSlowInEasing,
),
alignment = Alignment.TopStart,
)
} else {
Modifier
}
)
.then(
if (!isUser && !isSystem && isDarkTheme) {
Modifier.leftEdgeGlow(
@@ -396,15 +424,16 @@ fun MessageBubble(
color = textColor
)
} else {
// Settled blocks keep the real Markdown renderer while
// only the structurally incomplete tail stays raw. The
// same composable settles the final tail so retained
// parser state survives the streaming -> final handoff.
// Keep one plain Text node stable while content grows
// and while this completed response remains the visible
// tail. Full Markdown renders once another row becomes
// the tail (or the session is revisited), where its
// remeasure cannot reset the active viewport.
if (markdownBody.isNotEmpty()) {
StreamingMarkdownContent(
content = markdownBody,
textColor = textColor,
isStreaming = message.isStreaming,
isStreaming = message.isStreaming || retainStreamingLayout,
)
}
}
@@ -538,7 +567,11 @@ fun MessageBubble(
// burst of fragments doesn't stack three near-touching time labels.
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
if (isLastInGroup) {
// Keep the live bubble's footer structurally quiet. Showing a
// timestamp while text is still growing makes it chase every
// token and exaggerates any single-frame layout lag. Reveal it
// once the message settles into its final layout.
if (isLastInGroup && !message.isStreaming) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = timeFormat.format(Date(message.timestamp)),
@@ -219,6 +219,16 @@ data class RelayPairing(
val transportHint: String? = null,
)
/** True only when a scoped Relay scan can immediately begin authentication. */
internal fun HermesPairingPayload.hasUsableRelayPairing(): Boolean {
val relay = relay ?: return false
if (relay.code.isBlank()) return false
val uri = runCatching { URI(relay.url.trim()) }.getOrNull() ?: return false
val supportedScheme = uri.scheme.equals("ws", ignoreCase = true) ||
uri.scheme.equals("wss", ignoreCase = true)
return supportedScheme && !uri.host.isNullOrBlank()
}
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
@@ -258,8 +268,8 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
return try {
// Quick check: must contain a `host` field and be valid JSON. We no
// longer reject based on the `hermes` version int — future v4+ QRs
// should still parse on this phone so Bailey doesn't have to ship a
// whole release to keep up with wire-format growth.
// should still parse so wire-format growth does not require an app
// release for every compatible payload version.
val obj = json.decodeFromString<JsonObject>(raw)
val version = obj["hermes"]?.jsonPrimitive?.intOrNull ?: 1
if (version < 1) return null
@@ -532,7 +542,8 @@ private fun mapBoxToViewport(
@Composable
fun QrPairingScanner(
onPairingDetected: (HermesPairingPayload) -> Unit,
onDismiss: () -> Unit
onDismiss: () -> Unit,
relayOnly: Boolean = false,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -628,7 +639,10 @@ fun QrPairingScanner(
)
}
Text(
text = stringResource(R.string.qr_scanner_title),
text = stringResource(
if (relayOnly) R.string.qr_scanner_relay_title
else R.string.qr_scanner_title,
),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.Center)
@@ -740,7 +754,11 @@ fun QrPairingScanner(
) {
val rawValue = barcode.rawValue ?: continue
val payload = parseHermesPairingQr(rawValue)
if (payload != null && hasDetected.compareAndSet(false, true)) {
if (
payload != null &&
(!relayOnly || payload.hasUsableRelayPairing()) &&
hasDetected.compareAndSet(false, true)
) {
lockedPayload = payload
return@addOnSuccessListener
}
@@ -798,13 +816,19 @@ fun QrPairingScanner(
modifier = Modifier.size(32.dp)
)
Text(
text = stringResource(R.string.qr_scanner_instruction),
text = stringResource(
if (relayOnly) R.string.qr_scanner_relay_instruction
else R.string.qr_scanner_instruction,
),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.qr_scanner_subtext),
text = stringResource(
if (relayOnly) R.string.qr_scanner_relay_subtext
else R.string.qr_scanner_subtext,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@@ -1,11 +1,10 @@
package com.hermesandroid.relay.ui.onboarding
import androidx.compose.foundation.background
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
@@ -20,23 +19,21 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.ui.theme.gradientBorder
@Composable
fun OnboardingPage(
@@ -44,7 +41,6 @@ fun OnboardingPage(
title: String,
description: String,
modifier: Modifier = Modifier,
transparentHero: Boolean = false,
heroContent: @Composable BoxScope.() -> Unit = {
FeatureHero(
icon = icon,
@@ -53,101 +49,44 @@ fun OnboardingPage(
},
content: @Composable ColumnScope.() -> Unit = {}
) {
val isDarkTheme = LocalBrand.current.isDark
val heroShape = RoundedCornerShape(30.dp)
val bodyShape = RoundedCornerShape(26.dp)
val heroBrush = Brush.radialGradient(
colors = if (isDarkTheme) {
listOf(
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.96f),
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.92f),
MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.98f),
)
} else {
listOf(
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.70f),
MaterialTheme.colorScheme.surface.copy(alpha = 0.98f),
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.60f),
)
}
)
// Short viewports (small phones, large font scale, split screen) shrink or
// drop the hero so the body text fits; the vertical scroll below is the
// safety net when even that isn't enough. The enclosing pager Box centers
// short content, so no Arrangement.Center here — it conflicts with
// verticalScroll when content overflows.
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val heroHeight = when {
maxHeight < 480.dp -> 0.dp
maxHeight < 620.dp -> 160.dp
else -> 232.dp
}
Column(
Column(
modifier = modifier
.fillMaxWidth()
.widthIn(max = 560.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 42.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 560.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
.height(210.dp),
contentAlignment = Alignment.Center,
) {
if (heroHeight > 0.dp) {
Card(
modifier = Modifier
.fillMaxWidth()
.height(heroHeight)
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
shape = heroShape,
colors = CardDefaults.cardColors(
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
)
) {
val heroModifier = Modifier
.fillMaxWidth()
.height(heroHeight)
Box(
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
contentAlignment = Alignment.Center
) {
heroContent()
}
}
Spacer(modifier = Modifier.height(18.dp))
}
Card(
modifier = Modifier
.fillMaxWidth()
.gradientBorder(shape = bodyShape, isDarkTheme = isDarkTheme),
shape = bodyShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
content()
}
}
heroContent()
}
Text(
text = title,
style = MaterialTheme.typography.headlineLarge.copy(
fontSize = 38.sp,
lineHeight = 40.sp,
),
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 25.sp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
content()
}
}
@@ -156,26 +95,45 @@ private fun FeatureHero(
icon: ImageVector,
title: String,
) {
val isDarkTheme = LocalBrand.current.isDark
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(136.dp)
.size(176.dp)
.clip(CircleShape)
.background(
MaterialTheme.colorScheme.primary.copy(alpha = if (isDarkTheme) 0.14f else 0.10f)
Color(0xFF7B55F6).copy(alpha = 0.07f)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(74.dp),
tint = MaterialTheme.colorScheme.primary
)
Box(
modifier = Modifier
.size(128.dp)
.clip(CircleShape)
.background(Color(0xFF7B55F6).copy(alpha = 0.10f)),
contentAlignment = Alignment.Center,
) {
Surface(
modifier = Modifier.size(86.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f),
border = BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f),
),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(46.dp),
tint = Color(0xFF7B55F6),
)
}
}
}
}
}
}
@@ -1,10 +1,5 @@
package com.hermesandroid.relay.ui.onboarding
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -18,19 +13,31 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.MenuBook
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.Terminal
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.MaterialTheme
@@ -45,22 +52,26 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.SphereState
import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
@@ -68,6 +79,8 @@ import kotlinx.coroutines.launch
/** Page identifiers for dynamic onboarding flow. */
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect }
private val OnboardingAccent = Color(0xFF7B55F6)
/**
* Standard-first onboarding:
*
@@ -156,22 +169,31 @@ fun OnboardingScreen(
}
Column(modifier = Modifier.fillMaxSize()) {
// Top bar with Skip — only on informational pages, not the wizard
// (which has its own "Skip for now" affordance).
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
val currentPage = pagerState.currentPage
val currentPageType = pages[currentPage]
// The selected welcome frame has no toolbar. Later information
// pages keep quiet navigation without reserving space above it.
if (currentPage in 1 until lastPage) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(currentPage - 1) }
},
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_back))
}
TextButton(onClick = { showSkipConfirm = true }) {
Text(
text = stringResource(R.string.onboarding_skip),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(stringResource(R.string.onboarding_skip))
}
}
}
@@ -205,232 +227,297 @@ fun OnboardingScreen(
// Bottom navigation only on informational pages — the wizard
// owns its own back/pair affordances.
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
// Short viewports get a tighter footer so more of the pager
// content stays above the fold; indicator + Back/Next remain
// pinned outside the (scrollable) pager pages either way.
if (currentPageType != OnboardingPage.Connect) {
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = if (compactHeight) 16.dp else 48.dp),
horizontalAlignment = Alignment.CenterHorizontally
.padding(horizontal = 42.dp)
.padding(bottom = if (compactHeight) 10.dp else 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(if (compactHeight) 8.dp else 12.dp),
) {
PageIndicator(
pageCount = pageCount,
currentPage = pagerState.currentPage
GradientOnboardingButton(
label = when {
currentPage == 0 -> stringResource(R.string.onboarding_get_started)
currentPage == lastPage - 1 -> stringResource(R.string.onboarding_connect)
else -> stringResource(R.string.onboarding_next)
},
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage((currentPage + 1).coerceAtMost(lastPage))
}
},
)
Spacer(modifier = Modifier.height(if (compactHeight) 12.dp else 24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(
visible = pagerState.currentPage > 0,
enter = fadeIn(),
exit = fadeOut()
) {
TextButton(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
}
) {
Text(text = stringResource(R.string.onboarding_back))
}
}
if (pagerState.currentPage == 0) {
Spacer(modifier = Modifier.width(1.dp))
}
Button(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(
(pagerState.currentPage + 1).coerceAtMost(lastPage)
)
}
}
) {
if (currentPage == 0) {
TextButton(onClick = onTryDemo) {
Text(
text = if (pagerState.currentPage == lastPage - 1) {
stringResource(R.string.onboarding_connect)
} else {
stringResource(R.string.onboarding_next)
}
text = stringResource(R.string.chat_try_demo),
color = OnboardingAccent,
style = MaterialTheme.typography.labelLarge,
)
}
}
SegmentedOnboardingProgress(
pageCount = pageCount,
currentPage = currentPage,
)
Text(
text = stringResource(R.string.onboarding_step_count, currentPage + 1, pageCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.68f),
)
}
}
}
}
}
@Composable
private fun GradientOnboardingButton(
label: String,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick),
color = Color.Transparent,
shape = RoundedCornerShape(14.dp),
) {
Row(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
listOf(Color(0xFF7047F5), Color(0xFF6446F0)),
),
)
.padding(horizontal = 22.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Spacer(Modifier.width(24.dp))
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
}
}
@Composable
private fun SegmentedOnboardingProgress(
pageCount: Int,
currentPage: Int,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 36.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(pageCount) { index ->
Box(
modifier = Modifier
.weight(1f)
.height(4.dp)
.clip(RoundedCornerShape(999.dp))
.background(
if (index == currentPage) {
OnboardingAccent
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f)
},
),
)
}
}
}
@Composable
private fun WelcomePage() {
val context = LocalContext.current
OnboardingPage(
icon = Icons.Outlined.RocketLaunch,
title = stringResource(R.string.onboarding_welcome_title),
description = stringResource(R.string.onboarding_welcome_description),
transparentHero = true,
heroContent = {
Box(modifier = Modifier.fillMaxSize()) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
state = SphereState.Idle,
intensity = 0.12f,
),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 4.dp, vertical = 2.dp),
)
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.80f))
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = stringResource(R.string.onboarding_welcome_badge),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.86f))
.padding(start = 7.dp, end = 12.dp, top = 5.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.onboarding_hermes_logo),
modifier = Modifier.size(30.dp)
)
Text(
text = "Hermes-Relay",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
val titleParts = stringResource(R.string.onboarding_welcome_title).split("\n", limit = 2)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
Box(
modifier = Modifier
.fillMaxWidth()
.height(380.dp),
contentAlignment = Alignment.Center,
) {
SetupPathSummary(
label = stringResource(R.string.onboarding_chat_manage_label),
description = stringResource(R.string.onboarding_chat_manage_description),
)
SetupPathSummary(
label = stringResource(R.string.onboarding_power_tools_label),
description = stringResource(R.string.onboarding_power_tools_description),
Image(
painter = painterResource(R.drawable.onboarding_hero_option1),
contentDescription = stringResource(R.string.onboarding_hermes_logo),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alignment = BiasAlignment(horizontalBias = 0f, verticalBias = -0.5f),
)
}
Text(
text = stringResource(R.string.onboarding_setup_guide_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = buildAnnotatedString {
append(titleParts.first())
if (titleParts.size > 1) append("\n")
withStyle(SpanStyle(color = OnboardingAccent)) {
if (titleParts.size > 1) append(titleParts[1])
}
},
color = MaterialTheme.colorScheme.onBackground,
fontSize = 38.sp,
lineHeight = 40.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/guide/getting-started"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_setup_guide))
}
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.onboarding_hermes_docs))
}
}
Spacer(Modifier.height(14.dp))
Text(
text = stringResource(R.string.onboarding_api_server_docs),
style = MaterialTheme.typography.bodySmall.copy(
textDecoration = TextDecoration.Underline
text = stringResource(R.string.onboarding_welcome_description),
style = MaterialTheme.typography.bodyLarge.copy(
lineHeight = 27.sp,
fontWeight = FontWeight.Normal,
),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server")))
}
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
WelcomeCapabilityRow(
icon = Icons.Filled.Dashboard,
label = stringResource(R.string.onboarding_chat_manage_label),
description = stringResource(R.string.onboarding_chat_manage_description),
)
Spacer(Modifier.height(14.dp))
WelcomeCapabilityRow(
icon = Icons.Filled.Bolt,
label = stringResource(R.string.onboarding_power_tools_label),
description = stringResource(R.string.onboarding_power_tools_description),
)
Spacer(Modifier.height(12.dp))
}
}
@Composable
private fun WelcomeCapabilityRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
description: String,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 47.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(30.dp),
) {
Box {
Surface(
modifier = Modifier.size(58.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
border = androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f),
),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = OnboardingAccent,
)
}
}
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF57E389)),
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun SetupPathSummary(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
description: String,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
Surface(
modifier = Modifier.size(48.dp),
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
border = androidx.compose.foundation.BorderStroke(
1.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.22f),
),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = OnboardingAccent,
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(96.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
}
@@ -448,14 +535,17 @@ private fun ChatPage() {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
icon = Icons.Filled.Bolt,
label = stringResource(R.string.onboarding_streaming_label),
description = stringResource(R.string.onboarding_streaming_description),
)
SetupPathSummary(
icon = Icons.Filled.Person,
label = stringResource(R.string.onboarding_profiles_label),
description = stringResource(R.string.onboarding_profiles_description),
)
SetupPathSummary(
icon = Icons.Filled.Mic,
label = stringResource(R.string.onboarding_voice_label),
description = stringResource(R.string.onboarding_voice_description),
)
@@ -475,14 +565,17 @@ private fun ManagePage() {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
icon = Icons.Filled.Tune,
label = stringResource(R.string.onboarding_control_label),
description = stringResource(R.string.onboarding_control_description),
)
SetupPathSummary(
icon = Icons.Filled.Extension,
label = stringResource(R.string.onboarding_skills_hub_label),
description = stringResource(R.string.onboarding_skills_hub_description),
)
SetupPathSummary(
icon = Icons.Filled.Lock,
label = stringResource(R.string.onboarding_one_sign_in_label),
description = stringResource(R.string.onboarding_one_sign_in_description),
)
@@ -504,14 +597,17 @@ private fun PowerToolsPage(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SetupPathSummary(
icon = Icons.Outlined.Terminal,
label = stringResource(R.string.onboarding_terminal_label),
description = stringResource(R.string.onboarding_terminal_description),
)
SetupPathSummary(
icon = Icons.Filled.PhoneAndroid,
label = stringResource(R.string.onboarding_bridge_label),
description = stringResource(R.string.onboarding_bridge_description),
)
SetupPathSummary(
icon = Icons.Filled.GraphicEq,
label = stringResource(R.string.onboarding_realtime_label),
description = stringResource(R.string.onboarding_realtime_description),
)
@@ -394,7 +394,7 @@ fun AboutScreen(
}
OutlinedButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/"))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/"))
context.startActivity(intent)
},
modifier = Modifier.weight(1f)
@@ -432,7 +432,7 @@ fun AboutScreen(
// Privacy policy link
OutlinedButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay/blob/main/docs/privacy.md"))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/privacy.html"))
context.startActivity(intent)
},
modifier = Modifier.fillMaxWidth()
@@ -244,6 +244,9 @@ fun AppearanceSettingsScreen(
val languageLabels = mapOf(
AppLanguage.SYSTEM_DEFAULT to stringResource(R.string.appearance_language_system),
AppLanguage.ENGLISH to stringResource(R.string.appearance_language_english),
AppLanguage.GERMAN to stringResource(R.string.appearance_language_german),
AppLanguage.BRAZILIAN_PORTUGUESE to stringResource(R.string.appearance_language_brazilian_portuguese),
AppLanguage.JAPANESE to stringResource(R.string.appearance_language_japanese),
AppLanguage.SIMPLIFIED_CHINESE to stringResource(R.string.appearance_language_simplified_chinese),
AppLanguage.SPANISH to stringResource(R.string.appearance_language_spanish),
)
@@ -6,6 +6,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.MutatePriority
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -37,6 +38,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.verticalScroll
@@ -76,6 +78,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -85,7 +88,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -206,7 +208,10 @@ import androidx.compose.ui.text.style.TextAlign
import kotlin.math.roundToInt
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ChatConnectState
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.VoiceViewModel
import com.hermesandroid.relay.voice.VoiceOverlayHost
import com.hermesandroid.relay.voice.VoiceOverlaySession
@@ -218,7 +223,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val DEFAULT_CHAR_LIMIT = 4096
/**
* A same-author run breaks into a new visual group once the gap to the
* neighboring message exceeds this — so a conversation resumed after a pause
@@ -233,19 +237,38 @@ private const val GROUP_GAP_MS = 5 * 60_000L
*
* Captured inside a `snapshotFlow { ... }` so distinctUntilChanged can
* detect any meaningful change (new message, longer text, longer reasoning,
* new tool card, streaming on/off) and re-trigger an auto-follow scroll.
* new tool card, message-id reconciliation, streaming on/off) and re-trigger
* an auto-follow scroll.
*
* `equals` is auto-generated by `data class`, which gives field-wise
* comparison — exactly the behavior distinctUntilChanged needs.
*/
private data class ChatScrollSnapshot(
internal data class ChatScrollSnapshot(
val messageCount: Int,
val lastMessageId: String?,
val lastMessageUiKey: String?,
val lastContentLength: Int,
val lastThinkingLength: Int,
val lastToolCallCount: Int,
val isStreaming: Boolean
)
internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?): Boolean =
previous?.isStreaming == true &&
!isStreaming &&
previous.messageCount == messageCount &&
previous.lastMessageUiKey == lastMessageUiKey
private class ChatTailTransitionRef(
var snapshot: ChatScrollSnapshot? = null,
)
private data class ChatTailLayoutSnapshot(
val uiKey: String?,
val measuredSizePx: Int?,
val shouldFollowGrowth: Boolean,
)
private fun LazyListState.isAtConversationBottom(slopPx: Int): Boolean {
val layout = layoutInfo
if (layout.totalItemsCount == 0) return true
@@ -267,9 +290,9 @@ private suspend fun LazyListState.scrollToConversationBottom(
if (attempt == 0 || !isAtConversationBottom(slopPx)) {
if (animateNext) {
animateNext = false
animateScrollToItem(lastIndex, Int.MAX_VALUE)
animateScrollToItem(lastIndex)
} else {
scrollToItem(lastIndex, Int.MAX_VALUE)
scrollToItem(lastIndex)
}
}
withFrameNanos { }
@@ -519,6 +542,7 @@ fun ChatScreen(
// and is consumed by the AgentInfoSheet for the Profile section. The list
// of available profiles itself now lives entirely inside the sheet.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
// Server-advertised profile catalog — used to locate the "default" profile
// so the header can render its description/model when no explicit pick
// has been made (the /api/config fallback is more useful than the bare
@@ -789,8 +813,7 @@ fun ChatScreen(
provider = activeVoiceProvider,
model = activeVoiceModel,
voice = activeVoiceName,
profileName = selectedProfile?.description?.takeIf { it.isNotBlank() }
?: selectedProfile?.name,
profileName = AgentDisplay.profileDisplayName(effectiveProfile),
configScope = activeVoiceScope,
outputEnabled = activeVoiceEnabled,
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
@@ -991,7 +1014,10 @@ fun ChatScreen(
// user back to the latest token while they are reading history.
// Reset to false the moment the user returns to the bottom.
var userScrolledAway by remember(currentSessionId) { mutableStateOf(false) }
var isUserDragging by remember(currentSessionId) { mutableStateOf(false) }
var programmaticBottomScroll by remember { mutableStateOf(false) }
var retainedLiveTailUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
var completionSettlingUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
val currentUnreadSnapshot = remember(messages) { messages.toUnreadSnapshot() }
var lastReadSnapshot by remember(currentSessionId) {
mutableStateOf(currentUnreadSnapshot)
@@ -1024,24 +1050,27 @@ fun ChatScreen(
}
}
// Decide "is the user reading history" from GENUINE scroll gestures only.
// A bare isAtBottom flip — the streaming bubble grew and pushed content
// below the fold — must NOT count as the user scrolling away. That false
// signal was the bounce: it popped the scroll-to-bottom FAB and (worse)
// aborted auto-follow even though the user never touched the screen.
// Content growth never sets isScrollInProgress, so keying off the scroll's
// falling edge ignores it; only a real drag/fling that ENDS above the
// bottom flips userScrolledAway. (Programmatic animated scrolls also set
// isScrollInProgress, so they're excluded via programmaticBottomScroll.)
// Decide "is the user reading history" from actual touch drags only.
// isScrollInProgress also becomes true for our own animated bottom scroll;
// if that animation is cancelled by the next stream batch, its falling
// edge can race the programmatic flag and falsely disable auto-follow for
// the rest of the turn. LazyListState's interaction source emits only real
// drag gestures, so it cleanly separates user intent from app scrolling.
// Pause follow at drag start so a new token cannot fight the finger, but do
// not classify the user as reading history until the gesture actually ends
// above the bottom. A tiny/cancelled touch must not poison the next turn.
LaunchedEffect(listState) {
var wasScrolling = false
snapshotFlow { listState.isScrollInProgress }
.collect { scrolling ->
if (wasScrolling && !scrolling && !programmaticBottomScroll) {
userScrolledAway = !isAtBottom
listState.interactionSource.interactions.collect { interaction ->
when (interaction) {
is DragInteraction.Start -> {
isUserDragging = true
}
is DragInteraction.Stop, is DragInteraction.Cancel -> {
isUserDragging = false
userScrolledAway = !listState.isAtConversationBottom(atBottomSlopPx)
}
wasScrolling = scrolling
}
}
}
// Reaching the bottom by any means (user, follow-pin, content shrank)
// always re-arms auto-follow.
@@ -1061,10 +1090,14 @@ fun ChatScreen(
// suppression lifts and the button appears.
val showScrollToBottom by remember {
derivedStateOf {
val retainingVisibleTail = retainedLiveTailUiKey != null &&
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
messages.isNotEmpty() &&
!isAtBottom &&
!programmaticBottomScroll &&
!(isStreaming && smoothAutoScroll && !userScrolledAway)
!((isStreaming || retainingVisibleTail) &&
smoothAutoScroll &&
!userScrolledAway)
}
}
@@ -1196,124 +1229,148 @@ fun ChatScreen(
}
}
// Auto-scroll to bottom while streaming.
//
// Bugs the previous versions had:
// 1. Keys only watched messages.size and content.length — so growth of
// the thinking block (thinkingContent) and tool-card additions
// (toolCalls) silently froze the auto-follow during long reasoning
// and tool execution phases.
// 2. animateScrollToItem(lastIndex) defaults scrollOffset = 0, which
// aligns the TOP of the item with the top of the viewport. For a
// tall streaming bubble (reasoning + tool cards + text) that means
// the user gets snapped back to the start of the message instead
// of staying at the latest token. Fix: scrollOffset = Int.MAX_VALUE
// pins the bottom of the item to the bottom of the viewport.
// 3. There was no userScrolledAway gate, so any delta would yank a
// user reading history back to the bottom.
// 4. The isStreaming flag was a snapshot key, so the stream-complete
// transition (true → false) re-triggered animateScrollToItem even
// when no content actually changed — producing a visible jiggle.
// 5. Sessions endpoint reloads the entire message list on stream
// complete via loadMessageHistory(), and the resulting animateItem()
// placement animations on every bubble fought with our concurrent
// animateScrollToItem — producing a flash where the viewport
// visibly settled twice.
//
// The fix below uses snapshotFlow on a snapshot of every meaningful
// streaming-state field. distinctUntilChanged debounces identical
// emissions; collectLatest cancels any in-flight scroll animation when
// a newer delta arrives, preventing animation pile-ups during rapid
// SSE bursts. The pref `smoothAutoScroll` (default true) gates the
// entire effect — when off, only manual scrolling occurs.
//
// The previous-snapshot var inside the LaunchedEffect coroutine lets us
// distinguish "content arrived" from "state flipped" and "single
// append" from "list rebuild", and pick the right scroll strategy.
LaunchedEffect(listState, smoothAutoScroll) {
if (!smoothAutoScroll) return@LaunchedEffect
var previousSnapshot: ChatScrollSnapshot? = null
val tailMessage = messages.lastOrNull()
val tailTransition = ChatScrollSnapshot(
messageCount = messages.size,
lastMessageId = tailMessage?.id,
lastMessageUiKey = tailMessage?.uiKey,
lastContentLength = tailMessage?.content?.length ?: 0,
lastThinkingLength = tailMessage?.thinkingContent?.length ?: 0,
lastToolCallCount = tailMessage?.toolCalls?.size ?: 0,
isStreaming = tailMessage?.isStreaming == true,
)
val tailTransitionRef = remember(currentSessionId) { ChatTailTransitionRef() }
// New rows and streaming -> final Markdown are structural transitions.
// Anchor their trailing spacer in SideEffect so the request participates in
// the very next remeasure instead of correcting an already-drawn frame.
SideEffect {
val previous = tailTransitionRef.snapshot
val streamStarted = tailTransition.isStreaming && previous?.isStreaming != true
val completed = tailTransition.isCompletionAfter(previous)
val tailStructureChanged = tailTransition.lastMessageUiKey != null &&
(previous == null ||
previous.messageCount != tailTransition.messageCount ||
previous.lastMessageUiKey != tailTransition.lastMessageUiKey)
if (streamStarted) {
// Sending a turn means "follow my new answer" even if the idle
// transcript had previously been left above the bottom. Do not
// clear isUserDragging: a real finger keeps priority until release.
userScrolledAway = false
retainedLiveTailUiKey = tailTransition.lastMessageUiKey
} else if (completed) {
completionSettlingUiKey = tailTransition.lastMessageUiKey
} else if (
tailStructureChanged &&
retainedLiveTailUiKey != null &&
retainedLiveTailUiKey != tailTransition.lastMessageUiKey
) {
retainedLiveTailUiKey = null
}
val shouldAnchor = smoothAutoScroll &&
!isUserDragging &&
(!userScrolledAway || streamStarted) &&
(streamStarted || completed || tailStructureChanged)
if (shouldAnchor) {
listState.requestScrollToItem(tailTransition.messageCount + 1)
}
tailTransitionRef.snapshot = tailTransition
}
// Completion adds the timestamp/footer after the final token. Keep the
// stable live renderer, then consume any small remaining forward range for
// two settled frames. scrollBy preserves the current item anchor and is
// visually inert when already at the exact bottom; unlike scrollToItem it
// cannot align the top of a tall response with the viewport.
LaunchedEffect(
completionSettlingUiKey,
smoothAutoScroll,
userScrolledAway,
isUserDragging,
) {
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
completionSettlingUiKey = null
return@LaunchedEffect
}
var settledFrames = 0
repeat(6) {
withFrameNanos { }
if (messages.lastOrNull()?.uiKey != settlingKey) {
completionSettlingUiKey = null
return@LaunchedEffect
}
if (listState.canScrollForward) {
settledFrames = 0
val viewportHeight = listState.layoutInfo.viewportSize.height
if (viewportHeight > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(viewportHeight.toFloat())
}
}
} else {
settledFrames += 1
if (settledFrames >= 2) {
completionSettlingUiKey = null
return@LaunchedEffect
}
}
}
completionSettlingUiKey = null
}
// Ordinary streaming growth keeps the same row and Text node. Advance the
// existing scroll position by exactly the measured positive height delta;
// never replace the logical anchor with scrollToItem(). User input has a
// higher mutation priority and cancels this work naturally.
LaunchedEffect(listState, smoothAutoScroll, userScrolledAway, isUserDragging) {
if (!smoothAutoScroll || userScrolledAway || isUserDragging) return@LaunchedEffect
var previousLayout: ChatTailLayoutSnapshot? = null
snapshotFlow {
val last = messages.lastOrNull()
// Snapshot every field that can grow during a single turn.
// Any change here means "more content arrived, try to follow".
ChatScrollSnapshot(
messageCount = messages.size,
lastContentLength = last?.content?.length ?: 0,
lastThinkingLength = last?.thinkingContent?.length ?: 0,
lastToolCallCount = last?.toolCalls?.size ?: 0,
isStreaming = last?.isStreaming == true
val tail = messages.lastOrNull()
val tailSize = tail?.uiKey?.let { uiKey ->
listState.layoutInfo.visibleItemsInfo
.firstOrNull { item -> item.key == uiKey }
?.size
}
ChatTailLayoutSnapshot(
uiKey = tail?.uiKey,
measuredSizePx = tailSize,
shouldFollowGrowth = tail?.isStreaming == true ||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey),
)
}
.distinctUntilChanged()
.collectLatest { snapshot ->
val prev = previousSnapshot
previousSnapshot = snapshot
.collect { current ->
val previous = previousLayout
previousLayout = current
val previousSize = previous?.measuredSizePx ?: return@collect
val currentSize = current.measuredSizePx ?: return@collect
if (!current.shouldFollowGrowth || previous.uiKey != current.uiKey) return@collect
if (messages.isEmpty()) return@collectLatest
if (userScrolledAway) return@collectLatest
// Skip "state-only" snapshot deltas where the only thing
// that changed is the isStreaming flag. The viewport is
// already at the right position from the last content
// delta — animating again on the state flip causes a
// visible flash, especially in sessions mode where the
// StreamingDots row vanishes when isStreaming flips false.
val onlyStreamingFlagChanged = prev != null
&& prev.messageCount == snapshot.messageCount
&& prev.lastContentLength == snapshot.lastContentLength
&& prev.lastThinkingLength == snapshot.lastThinkingLength
&& prev.lastToolCallCount == snapshot.lastToolCallCount
&& prev.isStreaming != snapshot.isStreaming
if (onlyStreamingFlagChanged) return@collectLatest
// Sessions endpoint reloads the entire message list on
// stream complete (one streaming message → multiple final
// messages with proper boundaries + tool call cards).
// animateScrollToItem during a list rebuild conflicts with
// the items' animateItem() placement animations and produces
// a visible flash. Use the instant scrollToItem path so the
// viewport snaps to the new bottom while the items animate
// into their final positions independently.
val isListRebuild = prev != null
&& snapshot.messageCount - prev.messageCount > 1
// Growth of the bubble we're already following (thinking /
// content / tool cards on the same message) arrives at token
// frequency on the gateway transport. animateScrollToItem
// per delta is a cancel/restart storm — each collectLatest
// cancellation strands the viewport mid-animation (showing
// earlier content) before the next one yanks it back:
// visible stutter when parked at the bottom during long
// reasoning. Pin instantly instead; reserve the animation
// for the discrete new-bubble event.
val isSameTurnGrowth = prev != null
&& snapshot.messageCount == prev.messageCount
if (isSameTurnGrowth) {
// Tail-follow: a single atomic pin to the clamped bottom.
// The helper's multi-frame settle loop gets cancelled by
// collectLatest on the very next token (streaming arrives
// ~every frame), stranding the viewport mid-settle → the
// bounce. One withFrameNanos to let the grown content lay
// out, then one scrollToItem — instant and cancellation-safe.
withFrameNanos { }
val lastIndex = listState.layoutInfo.totalItemsCount - 1
if (lastIndex >= 0) listState.scrollToItem(lastIndex, Int.MAX_VALUE)
} else {
// Discrete events (new bubble, list rebuild, history load):
// scrollOffset = Int.MAX_VALUE clamps to the deepest offset;
// the helper retries across frames so late markdown/code
// measurement can't leave us anchored above the real bottom.
// Instant for a rebuild (avoids fighting animateItem()).
scrollConversationToBottom(animated = !isListRebuild)
val growthPx = currentSize - previousSize
if (growthPx > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(growthPx.toFloat())
}
}
}
}
// Haptic on stream complete
// Completion haptic only; scroll ownership remains with the transition and
// measured-growth paths above.
var observedActiveStream by remember { mutableStateOf(false) }
LaunchedEffect(isStreaming) {
if (!isStreaming && messages.isNotEmpty()) {
if (isStreaming) {
observedActiveStream = true
} else if (observedActiveStream) {
observedActiveStream = false
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
@@ -1325,23 +1382,6 @@ fun ChatScreen(
}
}
// Display profile - the one the header should reflect. Priority:
// 1. explicit user pick (selectedProfile)
// 2. server-advertised profile named "default"
// 3. null (fall back to personality-derived name)
//
// Computed as a derived state so the header cross-fades when the user
// picks a new profile OR when the server's profile catalog finishes
// loading and a "default" entry shows up.
val effectiveProfile by remember(selectedProfile, agentProfiles) {
derivedStateOf {
AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
}
}
// Agent display name — used in header and info dialog.
//
// Precedence mirrors the messaging-app pattern: show the profile's
@@ -1385,7 +1425,7 @@ fun ChatScreen(
// drawer itself, so the overlay's pointer scrim alone can't block it.
gesturesEnabled = !voiceUiState.voiceMode,
drawerContent = {
val drawerTitle = if (selectedProfile != null) {
val drawerTitle = if (effectiveProfile != null) {
stringResource(R.string.chat_profile_sessions, agentDisplayName)
} else {
stringResource(R.string.chat_server_default_sessions)
@@ -1463,17 +1503,36 @@ fun ChatScreen(
}
},
title = {
val headerApiReachable = apiReachable || isStreaming
val isConnecting = isChatConnecting ||
(!headerApiReachable && chatMode != ChatMode.DISCONNECTED)
// Resolve the same Gateway-first, API-fallback runtime
// model used elsewhere; Relay is intentionally unrelated
// to Chat health.
val chatRuntimeStatus = resolveChatRuntimeStatus(
gateway = when (chatGatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
GatewayAvailability.Unknown -> if (
activeConnection?.resolvedDashboardUrl.isNullOrBlank()
) ChatTransportReadiness.NotConfigured else ChatTransportReadiness.Connecting
GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
},
apiSse = when {
apiReachable -> ChatTransportReadiness.Ready
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
chatMode != ChatMode.DISCONNECTED -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
},
)
val headerChatReady = chatRuntimeStatus is ChatRuntimeStatus.Connected || isStreaming
val isConnecting = isChatConnecting || chatRuntimeStatus is ChatRuntimeStatus.Connecting
// Once we've been connected this session, a later drop reads as
// "Reconnecting…" (we had it, we're getting it back) rather than
// a first-time "Connecting…". Honest wording for the WhatsApp-
// style subtitle status.
var everConnected by remember { mutableStateOf(false) }
if (headerApiReachable) everConnected = true
if (headerChatReady) everConnected = true
val statusText = when {
headerApiReachable -> if (isStreaming) {
headerChatReady -> if (isStreaming) {
stringResource(R.string.chat_streaming)
} else {
stringResource(R.string.chat_connected_label)
@@ -1486,7 +1545,7 @@ fun ChatScreen(
else -> stringResource(R.string.chat_disconnected_label)
}
val statusColor = when {
headerApiReachable -> Color(0xFF4CAF50)
headerChatReady -> Color(0xFF4CAF50)
isConnecting -> Color(0xFFFFA726)
else -> MaterialTheme.colorScheme.error
}
@@ -1525,13 +1584,13 @@ fun ChatScreen(
// connection status \u2014 never the literal "None"/"Default"
// personality label.
val subtitleText = when {
!headerApiReachable -> statusText
!headerChatReady -> statusText
else -> listOfNotNull(
nonDefaultPersonality,
modelName?.takeIf { it.isNotBlank() },
).joinToString(" \u00B7 ").ifBlank { statusText }
}
val subtitleColor = if (headerApiReachable) {
val subtitleColor = if (headerChatReady) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
statusColor
@@ -1589,7 +1648,7 @@ fun ChatScreen(
}
}
ConnectionStatusBadge(
isConnected = headerApiReachable,
isConnected = headerChatReady,
isConnecting = isConnecting,
modifier = Modifier
.size(10.dp)
@@ -1918,7 +1977,7 @@ fun ChatScreen(
// thread itself (not just the header) -
// the desktop's intro.
ChatConnectState.Ready ->
if (selectedProfile != null) {
if (effectiveProfile != null) {
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
} else {
stringResource(R.string.chat_start_conversation)
@@ -2102,8 +2161,15 @@ fun ChatScreen(
) {
item { Spacer(modifier = Modifier.height(8.dp).animateItem()) }
items(messages.size, key = { messages[it].id }) { index ->
// `id` can legitimately change once after a Gateway turn:
// the history reconcile adopts the persisted server id.
// Keep Compose identity stable across that data update so
// LazyColumn retains the visible row and its scroll anchor.
items(messages.size, key = { messages[it].uiKey }) { index ->
val message = messages[index]
val retainLiveLayout =
index == messages.lastIndex &&
message.uiKey == retainedLiveTailUiKey
val processNotification = message.hermesProcessNotificationOrNull()
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
@@ -2142,112 +2208,112 @@ fun ChatScreen(
message.cards.isNotEmpty()
message.backgroundTask?.let { task ->
val taskModifier = Modifier.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
)
BackgroundTaskCard(
task = task,
toolCalls = message.toolCalls,
showTimeline = toolDisplay != "off",
modifier = Modifier
.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
)
.animateItem(),
modifier = taskModifier,
)
}
if (processNotification != null) {
val notificationModifier = Modifier.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
)
SyntheticProcessNotificationNotice(
notification = processNotification,
modifier = Modifier
.padding(top = if (isFirstInGroup) 6.dp else 2.dp)
.animateItem(),
modifier = notificationModifier,
)
} else if (shouldRenderBubble) MessageBubble(
message = message,
modifier = Modifier
.padding(
top = if (hasBackgroundTask) 1.dp
} else if (shouldRenderBubble) {
val bubbleModifier = Modifier.padding(
top = if (hasBackgroundTask) 1.dp
else if (isFirstInGroup) 6.dp
else 1.dp,
)
.animateItem(),
maxBubbleWidth = maxBubbleWidth,
showThinking = showThinking,
isFirstInGroup = isFirstInGroup,
isLastInGroup = isLastInGroup,
recoveringAnswer = recoveringAnswer,
onAttachmentRetry = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onAttachmentManualFetch = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onCardAction = { msgId, cardKey, action ->
// OPEN_URL is resolved at the UI layer
// because launching ACTION_VIEW needs a
// Context. We record the dispatch FIRST
// via the ViewModel so the card collapses
// even if the browser launch throws.
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
com.hermesandroid.relay.ui.components.handleCardActionExternally(
context, action
)
)
MessageBubble(
message = message,
modifier = bubbleModifier,
maxBubbleWidth = maxBubbleWidth,
showThinking = showThinking,
isFirstInGroup = isFirstInGroup,
isLastInGroup = isLastInGroup,
retainStreamingLayout = retainLiveLayout,
recoveringAnswer = recoveringAnswer,
onAttachmentRetry = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onAttachmentManualFetch = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onCardAction = { msgId, cardKey, action ->
// OPEN_URL is resolved at the UI layer
// because launching ACTION_VIEW needs a
// Context. Record the dispatch first so
// the card collapses even if launch fails.
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
com.hermesandroid.relay.ui.components.handleCardActionExternally(
context,
action,
)
} else {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
}
},
onCardInput = { msgId, cardKey, value ->
chatViewModel.answerAsk(msgId, cardKey, value)
},
onEditMessage = if (
isGatewayTransport &&
!isStreaming &&
message.role == MessageRole.USER &&
!message.id.startsWith("voice-intent-") &&
!message.id.startsWith("steer-")
) {
{ msg ->
editingMessage = msg
inputText = msg.content.take(charLimit)
}
} else {
chatViewModel.dispatchCardAction(msgId, cardKey, action)
}
},
onCardInput = { msgId, cardKey, value ->
chatViewModel.answerAsk(msgId, cardKey, value)
},
onEditMessage = if (
isGatewayTransport &&
!isStreaming &&
message.role == MessageRole.USER &&
!message.id.startsWith("voice-intent-") &&
!message.id.startsWith("steer-")
) {
{ msg ->
editingMessage = msg
inputText = msg.content.take(charLimit)
}
} else {
null
},
onQuoteMessage = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val quoted = text.take(600)
.trim()
.lines()
.joinToString("\n") { line -> "> $line" }
inputText = if (inputText.isBlank()) {
"$quoted\n\n"
} else {
"$inputText\n$quoted\n\n"
}
},
onCopyMessage = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
// The new Clipboard API is suspend-based, so the
// setClipEntry call has to live inside a coroutine.
// We piggyback on the same scope.launch that posts
// the snackbar — they are sequential anyway.
scope.launch {
clipboard.setClipEntry(
ClipEntry(
ClipData.newPlainText(
hermesMessageLabel,
text
null
},
onQuoteMessage = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val quoted = text.take(600)
.trim()
.lines()
.joinToString("\n") { line -> "> $line" }
inputText = if (inputText.isBlank()) {
"$quoted\n\n"
} else {
"$inputText\n$quoted\n\n"
}
},
onCopyMessage = { text ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
// The new Clipboard API is suspend-based, so the
// setClipEntry call has to live inside a coroutine.
scope.launch {
clipboard.setClipEntry(
ClipEntry(
ClipData.newPlainText(
hermesMessageLabel,
text,
),
)
)
)
snackbarHostState.showSnackbar(
message = copiedToClipboardMsg,
duration = SnackbarDuration.Short
)
}
}
)
snackbarHostState.showSnackbar(
message = copiedToClipboardMsg,
duration = SnackbarDuration.Short,
)
}
},
)
}
// Steered sends live inside a server-side tool
// result, not a user message — flag the local
@@ -3020,8 +3086,7 @@ fun ChatScreen(
voiceOutputProvider = activeVoiceProvider,
voiceOutputModel = activeVoiceModel,
voiceOutputVoice = activeVoiceName,
voiceProfileName = selectedProfile?.description?.takeIf { it.isNotBlank() }
?: selectedProfile?.name,
voiceProfileName = AgentDisplay.profileDisplayName(effectiveProfile),
voiceConfigScope = activeVoiceScope,
voiceOutputEnabled = activeVoiceEnabled,
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
@@ -8,12 +8,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.Button
@@ -42,12 +46,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
@@ -60,7 +66,6 @@ import com.hermesandroid.relay.ui.components.SessionInfoSheet
import com.hermesandroid.relay.ui.theme.LocalBrand
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.statusText
/**
* Tabbed detail for a single Hermes connection — the level-2 screen the
@@ -105,8 +110,6 @@ fun ConnectionDetailScreen(
val connections by connectionViewModel.connections.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRow by connectionViewModel.relayRowState.collectAsState()
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
val relayEnabled by FeatureFlags.relayEnabled(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
@@ -200,6 +203,12 @@ fun ConnectionDetailScreen(
menuExpanded = false
onRepair(connectionId)
},
leadingIcon = {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = null,
)
},
)
if (connection.pairedAt != null) {
DropdownMenuItem(
@@ -265,8 +274,6 @@ fun ConnectionDetailScreen(
ActiveOverview(
connectionViewModel = connectionViewModel,
connection = connection,
relayConfigured = relayConfigured,
relayStatusText = relayRow.statusText(connectedLabel = stringResource(R.string.detail_connected)),
relayUiState = relayUiState,
relayEnabled = relayEnabled,
onReconnect = onReconnect,
@@ -289,18 +296,23 @@ fun ConnectionDetailScreen(
connectionViewModel = connectionViewModel,
connection = connection,
liveState = relayUiState,
onEditDashboard = {
selectedTab = tabs.indexOf(DetailTab.Advanced)
},
)
DetailTab.Advanced -> ActiveCardAdvancedSection(
connectionViewModel = connectionViewModel,
relayEnabled = relayEnabled,
isDarkTheme = isDarkTheme,
onPairRelay = { onRepair(connectionId) },
onInsecureAckRequested = { showInsecureAckDialog = true },
)
DetailTab.Security -> ActiveCardSecurityPosture(
connectionViewModel = connectionViewModel,
onNavigateToPairedDevices = onNavigateToPairedDevices,
onRevokeRelay = { showRevokeConfirm = true },
)
}
@@ -410,8 +422,6 @@ private enum class DetailTab {
private fun ActiveOverview(
connectionViewModel: ConnectionViewModel,
connection: Connection,
relayConfigured: Boolean,
relayStatusText: String,
relayUiState: RelayUiState,
relayEnabled: Boolean,
onReconnect: () -> Unit,
@@ -421,37 +431,67 @@ private fun ActiveOverview(
onOpenRelayInfo: () -> Unit,
onOpenSessionInfo: () -> Unit,
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val headerLine = if (relayConfigured) relayStatusText else stringResource(R.string.detail_standard_prefix) + hostname
val hostname = connection.primaryHost.ifBlank { connection.label }
val dashboardReady = connection.dashboardLastStatus?.reachable == true
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = headerLine,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = hostname,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Filled.Dashboard,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.detail_dashboard_primary),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = connection.resolvedDashboardUrl.ifBlank { hostname },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
HorizontalDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = if (dashboardReady) Color(0xFF4CAF50) else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Text(
text = if (dashboardReady) {
stringResource(R.string.detail_core_ready)
} else {
stringResource(R.string.detail_core_configured)
},
style = MaterialTheme.typography.bodyMedium,
color = if (dashboardReady) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurface,
)
}
}
}
Text(
text = stringResource(R.string.detail_routes_help),
text = stringResource(R.string.detail_overview_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -463,6 +503,7 @@ private fun ActiveOverview(
onOpenDashboard = onOpenDashboard,
onOpenRelayInfo = onOpenRelayInfo,
onOpenSessionInfo = onOpenSessionInfo,
onPairRelay = onRepair,
)
Row(
@@ -472,9 +513,6 @@ private fun ActiveOverview(
if (relayUiState == RelayUiState.Stale) {
Button(onClick = onReconnect) { Text(stringResource(R.string.detail_reconnect)) }
}
TextButton(onClick = onRepair) {
Text(if (connection.pairedAt == null) stringResource(R.string.detail_pair_relay) else stringResource(R.string.detail_repair))
}
}
}
@@ -489,10 +527,10 @@ private fun InactiveOverview(
onSwitch: () -> Unit,
onRepair: () -> Unit,
) {
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val hostname = connection.primaryHost.ifBlank { connection.label }
val statusLine = when {
connection.pairedAt != null -> stringResource(R.string.detail_paired_relay_configured)
connection.apiServerUrl.isNotBlank() -> stringResource(R.string.detail_standard_not_paired)
connection.capabilities.chatConfigured -> stringResource(R.string.detail_standard_not_paired)
else -> stringResource(R.string.detail_not_configured)
}
@@ -1,6 +1,9 @@
package com.hermesandroid.relay.ui.screens
import android.content.Context
import android.text.format.DateUtils
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -21,12 +24,24 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Badge
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -39,21 +54,33 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.RelayUrlDeriver
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.statusText
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Level-1 list of Hermes connections. Reachable via Settings → Connections.
@@ -91,6 +118,15 @@ fun ConnectionsSettingsScreen(
} else {
false
}
val startupConnectionId: String? = if (connectionViewModel != null) {
val startupId by connectionViewModel.startupConnectionId.collectAsState()
startupId
} else {
null
}
val scope = rememberCoroutineScope()
var switchingConnectionId by remember { mutableStateOf<String?>(null) }
var justSwitchedConnectionId by remember { mutableStateOf<String?>(null) }
// Kick a WSS reconnect on entry in case the user landed here from a Stale
// chip (same intent as the old inline screen).
@@ -101,7 +137,22 @@ fun ConnectionsSettingsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.conn_title)) },
title = {
Column {
Text(stringResource(R.string.conn_title))
if (connections.isNotEmpty()) {
Text(
text = pluralStringResource(
R.plurals.conn_server_count,
connections.size,
connections.size,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
@@ -115,13 +166,6 @@ fun ConnectionsSettingsScreen(
),
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onAddConnection,
icon = { Icon(imageVector = Icons.Filled.Add, contentDescription = null) },
text = { Text(stringResource(R.string.conn_add_connection)) },
)
},
) { innerPadding ->
if (connections.isEmpty()) {
// Shown in practice only during tests / after a wipe; cold start
@@ -149,6 +193,15 @@ fun ConnectionsSettingsScreen(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (connections.size > 1 && connectionViewModel != null) {
item {
StartupConnectionSelector(
connections = connections,
startupConnectionId = startupConnectionId,
onSelect = connectionViewModel::setStartupConnection,
)
}
}
items(connections, key = { it.id }) { connection ->
val isActive = connection.id == activeConnectionId
ConnectionListCard(
@@ -162,10 +215,118 @@ fun ConnectionsSettingsScreen(
connection.hasConfiguredRelay()
},
onClick = { onOpenConnection(connection.id) },
onSwitch = if (!isActive && connectionViewModel != null) {
{
if (switchingConnectionId == null) {
scope.launch {
switchingConnectionId = connection.id
connectionViewModel.switchConnection(connection.id).join()
switchingConnectionId = null
if (connectionViewModel.activeConnectionId.value == connection.id) {
justSwitchedConnectionId = connection.id
delay(800)
if (justSwitchedConnectionId == connection.id) {
justSwitchedConnectionId = null
}
}
}
}
}
} else {
null
},
isSwitching = switchingConnectionId == connection.id,
justSwitched = justSwitchedConnectionId == connection.id,
)
}
// Footer spacer so the last card isn't hidden by the FAB.
item { Spacer(modifier = Modifier.height(72.dp)) }
item {
Button(
onClick = onAddConnection,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = null)
Text(
text = stringResource(R.string.conn_add_connection),
modifier = Modifier.padding(start = 8.dp),
)
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
}
@Composable
private fun StartupConnectionSelector(
connections: List<Connection>,
startupConnectionId: String?,
onSelect: (String?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selectedLabel = startupConnectionId
?.let { id -> connections.firstOrNull { it.id == id }?.label }
?: stringResource(R.string.conn_startup_last_used)
Box {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = true },
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.AccessTime,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 12.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.conn_startup_title),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(text = selectedLabel, style = MaterialTheme.typography.bodyLarge)
}
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.conn_startup_choose),
)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = {
Column {
Text(stringResource(R.string.conn_startup_last_used))
Text(
text = stringResource(R.string.conn_startup_recommended),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
onClick = {
expanded = false
onSelect(null)
},
)
connections.forEach { connection ->
DropdownMenuItem(
text = { Text(connection.label) },
onClick = {
expanded = false
onSelect(connection.id)
},
)
}
}
}
@@ -185,16 +346,28 @@ private fun ConnectionListCard(
activeConnectionViewModel: ConnectionViewModel?,
relayConfigured: Boolean,
onClick: () -> Unit,
onSwitch: (() -> Unit)?,
isSwitching: Boolean,
justSwitched: Boolean,
) {
val context = LocalContext.current
// Active card: muted indigo wash instead of full-strength primaryContainer —
// a card-sized fill of the brand blue overwhelmed body text (2026-06-10
// feedback); small accents keep the vivid blue.
val containerColor = if (isActive) {
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.42f)
val targetContainerColor = if (isActive || isSwitching || justSwitched) {
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.25f)
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val containerColor by animateColorAsState(targetValue = targetContainerColor, label = "connectionCardColor")
val borderColor by animateColorAsState(
targetValue = if (isSwitching || justSwitched) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0f)
},
label = "connectionCardBorder",
)
Card(
modifier = Modifier
@@ -202,6 +375,7 @@ private fun ConnectionListCard(
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = containerColor),
border = BorderStroke(if (isSwitching || justSwitched) 1.5.dp else 0.dp, borderColor),
) {
Column(
modifier = Modifier.padding(16.dp),
@@ -209,52 +383,87 @@ private fun ConnectionListCard(
) {
// ── Title row ────────────────────────────────────────────────
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(10.dp)
.clip(RoundedCornerShape(50))
.background(
if (isActive) com.hermesandroid.relay.ui.theme.RelayRefresh.Green
else MaterialTheme.colorScheme.primary,
),
)
Text(
text = connection.label,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
)
if (isActive) {
Badge(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Text(text = stringResource(R.string.conn_active), modifier = Modifier.padding(horizontal = 6.dp))
Text(
text = stringResource(R.string.conn_active).uppercase(),
modifier = Modifier.padding(horizontal = 6.dp),
)
}
}
if (onSwitch != null || isSwitching || justSwitched) {
OutlinedButton(
onClick = { onSwitch?.invoke() },
enabled = !isSwitching && !justSwitched,
) {
when {
isSwitching -> {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(stringResource(R.string.conn_switching), modifier = Modifier.padding(start = 6.dp))
}
justSwitched -> {
Icon(Icons.Filled.CheckCircle, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringResource(R.string.conn_switched), modifier = Modifier.padding(start = 6.dp))
}
else -> Text(stringResource(R.string.conn_switch))
}
}
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp),
)
}
// ── Subtitle: hostname + status ──────────────────────────────
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
val hasStandardApi = connection.apiServerUrl.isNotBlank()
val pairedStatus = when {
liveState != null &&
(connection.pairedAt != null || liveState != RelayUiState.NotConfigured) ->
liveState.statusText(connectedLabel = stringResource(R.string.conn_connected))
connection.pairedAt != null -> formatPairedRelative(context, connection.pairedAt)
hasStandardApi -> stringResource(R.string.conn_standard_not_paired)
else -> stringResource(R.string.conn_not_paired)
}
val statusColor = if (liveState == RelayUiState.Stale) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
val hostname = connection.primaryHost.ifBlank { connection.label }
val dashboardStatus = connection.dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val connectionStatus = when {
dashboardSignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
dashboardStatus?.reachable == true -> stringResource(R.string.conn_dashboard_available)
dashboardStatus != null && !dashboardStatus.reachable -> stringResource(R.string.conn_dashboard_offline)
connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_unchecked)
else -> stringResource(R.string.conn_dashboard_missing)
}
Text(
text = "$hostname • $pairedStatus",
text = if (isSwitching) {
stringResource(R.string.conn_connecting_to, connection.label)
} else {
"Dashboard · $hostname"
},
style = MaterialTheme.typography.bodySmall,
color = statusColor,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = if (isActive) {
stringResource(R.string.conn_last_used_now)
} else {
connection.lastUsedAt?.let { formatUsedRelative(context, it) } ?: connectionStatus
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// ── Steps/timeline summary (every card) ──────────────────────
ConnectionSurfaceSummary(
@@ -267,6 +476,42 @@ private fun ConnectionListCard(
// detail — the same destination as the card tap.
onOpenDashboard = onClick,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Lan,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(18.dp)
.padding(end = 2.dp),
)
Text(
text = when {
connection.routeCandidates.isNotEmpty() -> connection.routeCandidates
.sortedBy { it.priority }
.joinToString(" · ") { route ->
route.role.replaceFirstChar { it.titlecase() }
}
connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_only_route)
else -> stringResource(R.string.conn_no_routes)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -304,6 +549,12 @@ private fun ConnectionSurfaceSummary(
} else {
null
}
val gatewayAvailability: GatewayAvailability? = if (activeConnectionViewModel != null) {
val availability by activeConnectionViewModel.gatewayAvailability.collectAsState()
availability
} else {
null
}
val standardVoiceAvailability: StandardVoiceAvailability? =
if (activeConnectionViewModel != null) {
val availability by activeConnectionViewModel.standardVoiceAvailability.collectAsState()
@@ -315,17 +566,41 @@ private fun ConnectionSurfaceSummary(
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val apiText = when {
connection.apiServerUrl.isBlank() -> stringResource(R.string.conn_api_missing)
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.conn_api_checking)
activeApiReachable == true -> stringResource(R.string.conn_api_ready)
isActive && activeApiReachable == false -> stringResource(R.string.conn_api_offline)
else -> stringResource(R.string.conn_api_configured)
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive) {
resolveChatRuntimeStatus(
gateway = when (gatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
GatewayAvailability.Unknown, null -> ChatTransportReadiness.Connecting
GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
},
apiSse = when {
connection.apiServerUrl.isBlank() -> ChatTransportReadiness.NotConfigured
activeApiReachable == true -> ChatTransportReadiness.Ready
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
activeApiHealth == ConnectionViewModel.HealthStatus.Unreachable -> ChatTransportReadiness.Unavailable
else -> ChatTransportReadiness.Connecting
},
)
} else {
null
}
val apiTone = when {
activeApiReachable == true -> SummaryTone.Good
connection.apiServerUrl.isBlank() -> SummaryTone.Warning
isActive && activeApiReachable == false -> SummaryTone.Warning
val chatText = when {
chatRuntimeStatus is ChatRuntimeStatus.Connected -> stringResource(R.string.conn_api_ready)
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> stringResource(R.string.conn_api_checking)
gatewayAvailability == GatewayAvailability.SignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
dashboardStatus?.reachable == true && !dashboardSignInRequired -> stringResource(R.string.conn_dashboard_available)
connection.capabilities.chatConfigured -> stringResource(R.string.conn_api_configured)
else -> stringResource(R.string.conn_api_missing)
}
val chatTone = when {
chatRuntimeStatus is ChatRuntimeStatus.Connected -> SummaryTone.Good
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> SummaryTone.Info
gatewayAvailability == GatewayAvailability.SignInRequired -> SummaryTone.Info
dashboardStatus?.reachable == true && !dashboardSignInRequired -> SummaryTone.Good
connection.capabilities.chatConfigured -> SummaryTone.Neutral
else -> SummaryTone.Neutral
}
@@ -374,99 +649,78 @@ private fun ConnectionSurfaceSummary(
else -> SummaryTone.Neutral
}
// A single grouped surface with dot + label + value rows — the
// steps/timeline vocabulary shared with the detail's Features list.
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp),
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
ConnectionSurfaceRow(label = stringResource(R.string.conn_api_label), value = apiText, tone = apiTone)
SurfaceRowDivider()
ConnectionSurfaceRow(
label = stringResource(R.string.conn_dashboard_label),
value = dashboardText,
tone = dashboardTone,
onClick = if (dashboardSignInRequired) onOpenDashboard else null,
)
SurfaceRowDivider()
ConnectionSurfaceRow(
label = stringResource(R.string.conn_voice_label),
value = voiceText,
tone = voiceTone,
onClick = if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
)
SurfaceRowDivider()
ConnectionSurfaceRow(label = stringResource(R.string.conn_relay_label), value = relayText, tone = relayTone)
}
ConnectionStatusChip(Icons.Filled.Chat, stringResource(R.string.conn_chat_label), chatText, chatTone, Modifier.weight(1f))
ConnectionStatusChip(
Icons.Filled.Dashboard,
stringResource(R.string.conn_manage_label),
dashboardText,
dashboardTone,
Modifier.weight(1f),
if (dashboardSignInRequired) onOpenDashboard else null,
)
ConnectionStatusChip(
Icons.Filled.GraphicEq,
stringResource(R.string.conn_voice_label),
voiceText,
voiceTone,
Modifier.weight(1f),
if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
)
ConnectionStatusChip(Icons.Filled.CellTower, stringResource(R.string.conn_relay_label), relayText, relayTone, Modifier.weight(1f))
}
}
private enum class SummaryTone { Neutral, Good, Info, Warning }
/** Hairline divider between summary rows — inset so it reads as a list. */
@Composable
private fun SurfaceRowDivider() {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
)
}
/**
* One health line: status dot + label + value. The dot carries the tone so
* the value text stays short and the row stays light.
*/
@Composable
private fun ConnectionSurfaceRow(
private fun ConnectionStatusChip(
icon: ImageVector,
label: String,
value: String,
tone: SummaryTone,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
val dotColor = when (tone) {
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
SummaryTone.Info -> MaterialTheme.colorScheme.primary
SummaryTone.Warning -> MaterialTheme.colorScheme.error
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
val valueColor = when (tone) {
val accent = when (tone) {
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
SummaryTone.Info -> MaterialTheme.colorScheme.primary
SummaryTone.Warning -> MaterialTheme.colorScheme.error
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 8.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
Surface(
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
shape = RoundedCornerShape(10.dp),
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(RoundedCornerShape(50))
.background(dotColor),
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
color = valueColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(18.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
Text(
text = value,
style = MaterialTheme.typography.labelSmall,
color = accent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -477,21 +731,15 @@ private fun Connection.hasConfiguredRelay(): Boolean {
!RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl)
}
/**
* Hand-rolled "N minutes ago" formatter for the card subtitle. `DateUtils`
* returns awkward copy ("in 0 minutes") for small deltas.
*/
private fun formatPairedRelative(context: Context, pairedAtMillis: Long): String {
val deltaMs = System.currentTimeMillis() - pairedAtMillis
if (deltaMs < 0) return context.getString(R.string.conn_just_paired)
val minutes = TimeUnit.MILLISECONDS.toMinutes(deltaMs)
val hours = TimeUnit.MILLISECONDS.toHours(deltaMs)
val days = TimeUnit.MILLISECONDS.toDays(deltaMs)
return when {
minutes < 1L -> context.getString(R.string.conn_just_paired)
minutes < 60L -> context.resources.getQuantityString(R.plurals.conn_paired_minutes_ago, minutes.toInt(), minutes)
hours < 24L -> context.resources.getQuantityString(R.plurals.conn_paired_hours_ago, hours.toInt(), hours)
days < 30L -> context.resources.getQuantityString(R.plurals.conn_paired_days_ago, days.toInt(), days)
else -> context.getString(R.string.conn_paired)
private fun formatUsedRelative(context: Context, usedAtMillis: Long): String {
val relative = if (System.currentTimeMillis() - usedAtMillis < DateUtils.MINUTE_IN_MILLIS) {
context.getString(R.string.conn_just_now)
} else {
DateUtils.getRelativeTimeSpanString(
usedAtMillis,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
).toString()
}
return context.getString(R.string.conn_last_used_format, relative)
}
@@ -317,6 +317,7 @@ private data class PendingDashboardAction(
fun DashboardManagementScreen(
connectionViewModel: ConnectionViewModel,
onNavigateToConnections: () -> Unit,
onNavigateToSignIn: () -> Unit = {},
onBack: () -> Unit = {},
onNavigateToBridge: () -> Unit = {},
onNavigateToTerminal: () -> Unit = {},
@@ -341,7 +342,6 @@ fun DashboardManagementScreen(
var actionInFlight by remember { mutableStateOf(false) }
var pendingAction by remember { mutableStateOf<PendingDashboardAction?>(null) }
var detailResult by remember { mutableStateOf<DashboardDetailResult?>(null) }
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
var confirmClearDashboardSession by remember { mutableStateOf(false) }
var inputAction by remember { mutableStateOf<PendingDashboardAction?>(null) }
var modelPickerTarget by remember { mutableStateOf<ModelPickerTarget?>(null) }
@@ -759,60 +759,6 @@ fun DashboardManagementScreen(
}
}
fun submitDashboardSignIn(provider: String, username: String, password: String) {
if (dashboardUrl.isBlank() || actionInFlight) return
actionInFlight = true
actionMessage = null
scope.launch {
val result = try {
withDashboardClient(clientFactory) { client ->
client.loginPassword(
provider = provider,
username = username,
password = password,
).mapCatching {
val status = client.getStatus().getOrNull()
val session = client.currentSession().getOrNull()
val gatewayTicketAvailable = if (session?.authenticated == true) {
client.requestWsTicket().isSuccess
} else {
null
}
connectionViewModel.recordDashboardStatus(
status = status,
session = session,
reachable = status != null,
gatewayTicketAvailable = gatewayTicketAvailable,
)
if (session?.authenticated != true) {
throw IllegalStateException(
context.getString(R.string.dashboard_signin_no_session),
)
}
it
}
}
} catch (e: Exception) {
Result.failure(e)
}
actionMessage = result.fold(
onSuccess = {
payloadStates.clear()
refreshingPayloads.clear()
clearDashboardManageDiskCache(context.cacheDir)
forceReloadKey = null
reloadNonce += 1
// Standard voice rides this same cookie session — unlock the
// mic immediately rather than waiting for the next health tick.
connectionViewModel.refreshStandardVoice()
context.getString(R.string.dashboard_signed_in)
},
onFailure = { err -> err.message ?: context.getString(R.string.dashboard_signin_failed) },
)
actionInFlight = false
}
}
pendingAction?.let { pending ->
val isActivateProfile = pending.action.kind == DashboardActionKind.ActivateProfile
val actionLabel = dashboardActionLabel(pending.action)
@@ -1051,32 +997,6 @@ fun DashboardManagementScreen(
)
}
oauthProvider?.let { provider ->
val signedInMsg = stringResource(R.string.dashboard_signed_in)
val signedInWithMsg = stringResource(R.string.dashboard_signed_in_with)
DashboardOAuthSignInDialog(
dashboardUrl = dashboardUrl,
provider = provider,
cookieStoreFactory = cookieStoreFactory,
onDismiss = { oauthProvider = null },
onAuthenticated = { session ->
oauthProvider = null
payloadStates.clear()
refreshingPayloads.clear()
scope.launch {
clearDashboardManageDiskCache(context.cacheDir)
}
forceReloadKey = null
actionMessage = session.provider?.let { signedInWithMsg.format(it) } ?: signedInMsg
reloadNonce += 1
connectionViewModel.refreshStandardVoice()
},
onError = { message ->
actionMessage = message
},
)
}
Scaffold(
topBar = {
TopAppBar(
@@ -1201,8 +1121,7 @@ fun DashboardManagementScreen(
forceReloadKey = payloadKey
reloadNonce += 1
},
onSignIn = ::submitDashboardSignIn,
onOAuthSignIn = { provider -> oauthProvider = provider },
onNavigateToSignIn = onNavigateToSignIn,
)
is DashboardPayloadState.Loaded -> LoadedBody(
section = section,
@@ -1264,8 +1183,7 @@ fun DashboardManagementScreen(
actionInFlight = actionInFlight,
actionMessage = actionMessage,
onClearSession = { confirmClearDashboardSession = true },
onSignIn = ::submitDashboardSignIn,
onOAuthSignIn = { provider -> oauthProvider = provider },
onNavigateToSignIn = onNavigateToSignIn,
onNavigateToConnections = onNavigateToConnections,
onSelectSection = { sectionPick ->
managementSections.indexOf(sectionPick)
@@ -1346,8 +1264,7 @@ private fun ManageOverviewBody(
actionInFlight: Boolean,
actionMessage: String?,
onClearSession: () -> Unit,
onSignIn: (String, String, String) -> Unit,
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
onNavigateToSignIn: () -> Unit,
onNavigateToConnections: () -> Unit,
onSelectSection: (DashboardManagementSection) -> Unit,
) {
@@ -1426,14 +1343,10 @@ private fun ManageOverviewBody(
val signInStatus = status
if (signInStatus?.authRequired == true && authenticated != true) {
item {
DashboardSignInCard(
DashboardSignInGateCard(
dashboardUrl = dashboardUrl,
routeHint = routeHint,
providers = signInStatus.authProviderDetails,
actionInFlight = actionInFlight,
actionMessage = actionMessage,
onSignIn = onSignIn,
onOAuthSignIn = onOAuthSignIn,
onSignIn = onNavigateToSignIn,
)
}
}
@@ -1961,8 +1874,7 @@ private fun ErrorBody(
actionInFlight: Boolean,
actionMessage: String?,
onRetry: () -> Unit,
onSignIn: (String, String, String) -> Unit,
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
onNavigateToSignIn: () -> Unit,
) {
val signInRequired = message.contains("401") ||
message.contains("403") ||
@@ -1974,14 +1886,10 @@ private fun ErrorBody(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (signInRequired) {
DashboardSignInCard(
DashboardSignInGateCard(
dashboardUrl = dashboardUrl,
routeHint = routeHint,
providers = status?.authProviderDetails.orEmpty(),
actionInFlight = actionInFlight,
actionMessage = actionMessage,
onSignIn = onSignIn,
onOAuthSignIn = onOAuthSignIn,
onSignIn = onNavigateToSignIn,
)
} else {
Card(
@@ -2272,6 +2180,52 @@ private fun DashboardSummaryCard(
}
}
@Composable
private fun DashboardSignInGateCard(
dashboardUrl: String,
routeHint: String?,
onSignIn: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.dashboard_signin_required_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.dashboard_signin_required_body, dashboardUrl),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
routeHint?.let {
Text(
text = stringResource(R.string.dashboard_signin_route_hint, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
Button(onClick = onSignIn, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.dashboard_sign_in))
}
}
}
}
/** Clear every Manage snapshot after the shared Dashboard auth session changes. */
internal suspend fun invalidateDashboardManageCache(cacheDir: java.io.File) {
DashboardPayloadCache.states.clear()
DashboardPayloadCache.refreshing.clear()
clearDashboardManageDiskCache(cacheDir)
}
@Composable
private fun DashboardSignInCard(
dashboardUrl: String,
@@ -0,0 +1,467 @@
package com.hermesandroid.relay.ui.screens
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
/**
* Connection-level Dashboard authentication flow. It is deliberately outside
* Manage so onboarding, connection setup, Voice, Chat, and Manage can all use
* the same cookie/session flow without inheriting Manage's navigation stack.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardSignInScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
onAuthenticated: () -> Unit,
) {
val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val routeHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
val connectionId = activeConnection?.id ?: "default"
var providers by remember(dashboardUrl, connectionId) {
mutableStateOf<List<DashboardAuthProvider>>(emptyList())
}
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
var actionInFlight by remember { mutableStateOf(false) }
var actionMessage by remember { mutableStateOf<String?>(null) }
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
var authenticationComplete by remember { mutableStateOf(false) }
val cookieStoreFactory = remember(context, connectionId) {
{
connectionViewModel.activeDashboardCookieStore()
?: EncryptedDashboardCookieStore(context, connectionId)
}
}
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
{
DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = DashboardApiClient.defaultClient(cookieStoreFactory()),
)
}
}
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
val status = client.getStatus().getOrNull()
val session = client.currentSession().getOrNull()
val ticketAvailable = if (session?.authenticated == true) {
client.requestWsTicket().isSuccess
} else {
null
}
connectionViewModel.recordDashboardStatus(
status = status,
session = session,
reachable = status != null,
gatewayTicketAvailable = ticketAvailable,
)
return session
}
fun finishAuthentication() {
scope.launch {
invalidateDashboardManageCache(context.cacheDir)
connectionViewModel.refreshStandardVoice()
connectionViewModel.refreshDashboardProfiles()
authenticationComplete = true
}
}
LaunchedEffect(dashboardUrl, connectionId) {
if (dashboardUrl.isBlank()) {
loading = false
actionMessage = context.getString(R.string.dashboard_no_url_configured)
return@LaunchedEffect
}
val client = clientFactory()
try {
val status = client.getStatus().getOrElse {
actionMessage = it.message ?: context.getString(R.string.dashboard_request_failed)
return@LaunchedEffect
}
providers = status.authProviderDetails.ifEmpty {
client.getAuthProviders().getOrNull().orEmpty()
}
val session = if (status.authRequired) client.currentSession().getOrNull() else null
connectionViewModel.recordDashboardStatus(
status = status,
session = session,
reachable = true,
gatewayTicketAvailable = null,
)
if (!status.authRequired || session?.authenticated == true) {
finishAuthentication()
}
} finally {
loading = false
client.shutdown()
}
}
fun submitPassword(provider: String, username: String, password: String) {
if (actionInFlight || dashboardUrl.isBlank()) return
actionInFlight = true
actionMessage = null
scope.launch {
val client = clientFactory()
try {
val result = client.loginPassword(provider, username, password)
val session = if (result.isSuccess) verifyAndRecord(client) else null
if (result.isSuccess && session?.authenticated == true) {
finishAuthentication()
} else {
actionMessage = result.exceptionOrNull()?.message
?: context.getString(R.string.dashboard_signin_no_session)
}
} catch (e: Exception) {
actionMessage = e.message ?: context.getString(R.string.dashboard_signin_failed)
} finally {
actionInFlight = false
client.shutdown()
}
}
}
oauthProvider?.let { provider ->
DashboardOAuthDialog(
dashboardUrl = dashboardUrl,
provider = provider,
cookieStoreFactory = cookieStoreFactory,
onDismiss = { oauthProvider = null },
onAuthenticated = { session ->
oauthProvider = null
scope.launch {
val client = clientFactory()
try {
verifyAndRecord(client)
} finally {
client.shutdown()
}
actionMessage = session.provider?.let {
context.getString(R.string.dashboard_signed_in_with, it)
} ?: context.getString(R.string.dashboard_signed_in)
finishAuthentication()
}
},
onError = { actionMessage = it },
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.dashboard_sign_in)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.dashboard_back),
)
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
if (authenticationComplete) {
DashboardAuthenticationComplete(onContinue = onAuthenticated)
} else if (loading) {
CircularProgressIndicator()
} else {
DashboardSignInForm(
dashboardUrl = dashboardUrl,
routeHint = routeHint,
providers = providers,
actionInFlight = actionInFlight,
actionMessage = actionMessage,
onSignIn = ::submitPassword,
onOAuthSignIn = { oauthProvider = it },
)
}
}
}
}
@Composable
private fun DashboardAuthenticationComplete(onContinue: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.cw_step_3_3),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.cw_dashboard_connected_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(R.string.cw_dashboard_connected_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ConnectionSetupTimeline(
steps = listOf(
ConnectionSetupTimelineStep(
stringResource(R.string.cw_timeline_discovered),
stringResource(R.string.cw_timeline_discovered_detail),
),
ConnectionSetupTimelineStep(
stringResource(R.string.cw_timeline_access),
stringResource(R.string.cw_timeline_authenticated),
),
ConnectionSetupTimelineStep(
stringResource(R.string.cw_timeline_ready),
stringResource(R.string.cw_timeline_ready_detail),
),
),
)
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.cw_continue))
}
}
}
@Composable
private fun DashboardSignInForm(
dashboardUrl: String,
routeHint: String?,
providers: List<DashboardAuthProvider>,
actionInFlight: Boolean,
actionMessage: String?,
onSignIn: (String, String, String) -> Unit,
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val passwordProvider = providers.firstOrNull { it.supportsPassword }
val redirectProviders = providers.filter { it.isRedirectProvider }
Text(
text = stringResource(R.string.dashboard_signin_required_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(R.string.dashboard_signin_required_body, dashboardUrl),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
routeHint?.let {
Text(
text = stringResource(R.string.dashboard_signin_route_hint, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
redirectProviders.forEach { provider ->
Button(
onClick = { onOAuthSignIn(provider) },
enabled = !actionInFlight,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
}
}
if (passwordProvider != null || providers.isEmpty()) {
if (redirectProviders.isNotEmpty()) HorizontalDivider()
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.dashboard_username)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.dashboard_password)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
Button(
onClick = { onSignIn(passwordProvider?.name ?: "basic", username, password) },
enabled = !actionInFlight && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(if (actionInFlight) stringResource(R.string.dashboard_signing_in) else stringResource(R.string.dashboard_sign_in))
}
}
actionMessage?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
}
@Composable
private fun DashboardOAuthDialog(
dashboardUrl: String,
provider: DashboardAuthProvider,
cookieStoreFactory: () -> DashboardCookieStore,
onDismiss: () -> Unit,
onAuthenticated: (DashboardAuthSession) -> Unit,
onError: (String) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val initialStatus = stringResource(R.string.dashboard_oauth_initial_status)
val verifyingStatus = stringResource(R.string.dashboard_oauth_verifying)
val notAcceptedStatus = stringResource(R.string.dashboard_oauth_not_accepted)
val verifyFailedStatus = stringResource(R.string.dashboard_oauth_verify_failed)
var statusText by remember(initialStatus) { mutableStateOf(initialStatus) }
var checking by remember { mutableStateOf(false) }
val loginUrl = remember(dashboardUrl, provider.name) {
DashboardApiClient.authLoginUrl(
baseUrl = dashboardUrl,
provider = provider.name,
next = DashboardApiClient.authLandingPath(dashboardUrl),
)
}
fun maybeVerify(url: String?) {
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
val root = dashboardUrl.trim().trimEnd('/')
val relative = loadedUrl.trim().removePrefix(root)
val stillAuthenticating = relative.startsWith("/login", true) ||
relative.startsWith("/auth/login", true) ||
relative.startsWith("/auth/callback", true)
if (!loadedUrl.startsWith(root, true) || stillAuthenticating) return
val manager = CookieManager.getInstance()
manager.flush()
val imported = importDashboardCookieHeader(
cookieStoreFactory(),
loadedUrl,
manager.getCookie(loadedUrl),
)
if (checking || imported == 0) return
checking = true
statusText = verifyingStatus
scope.launch {
val client = DashboardApiClient(
dashboardUrl,
DashboardApiClient.defaultClient(cookieStoreFactory()),
)
try {
val session = client.currentSession().getOrNull()
if (session?.authenticated == true) onAuthenticated(session) else {
checking = false
statusText = notAcceptedStatus
}
} catch (e: Exception) {
checking = false
val message = e.message ?: verifyFailedStatus
statusText = message
onError(message)
} finally {
client.shutdown()
}
}
}
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.fillMaxWidth().heightIn(max = 640.dp)) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onDismiss) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.dashboard_close_signin))
}
Text(statusText, style = MaterialTheme.typography.bodySmall)
AndroidView(
modifier = Modifier.fillMaxWidth().weight(1f),
factory = { viewContext ->
CookieManager.getInstance().setAcceptCookie(true)
WebView(viewContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean = false
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
maybeVerify(url)
}
}
loadUrl(loginUrl)
}
},
)
}
}
}
}
@@ -37,11 +37,16 @@ import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.StatusCheck
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.shared.ConnectivityObserver
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ServerCapabilities
import com.hermesandroid.relay.ui.components.DiagnosticDetailDialog
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
import com.hermesandroid.relay.ui.components.StatusCheckTimeline
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
/**
* Dedicated Diagnostics screen — replaces the old modal bottom sheet. Hosts a
@@ -62,17 +67,20 @@ fun DiagnosticsScreen(
onBack: () -> Unit,
) {
val network by connectionViewModel.networkStatus.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
val apiUrl by connectionViewModel.apiServerUrl.collectAsState()
val capabilities by connectionViewModel.serverCapabilities.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val chatReady by connectionViewModel.chatReady.collectAsState()
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
val relayHealth by connectionViewModel.relayServerHealth.collectAsState()
val relayReady by connectionViewModel.relayReady.collectAsState()
val relayUpdateInfo by connectionViewModel.relayUpdateInfo.collectAsState()
val relayInfo by connectionViewModel.relayInfo.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val effectiveSessionProfileName by
connectionViewModel.effectiveSessionProfileName.collectAsState()
val checkedAt by connectionViewModel.diagnosticsCheckedAt.collectAsState()
val refreshing by connectionViewModel.diagnosticsRefreshing.collectAsState()
val voiceReady by connectionViewModel.voiceReady.collectAsState()
@@ -82,17 +90,20 @@ fun DiagnosticsScreen(
val context = androidx.compose.ui.platform.LocalContext.current
val checks = remember(
network, apiHealth, apiUrl, capabilities, authState, chatReady,
network, activeConnection, dashboardUrl, gatewayAvailability,
apiHealth, apiUrl, capabilities, authState,
relayConfigured, relayHealth, relayReady, relayUpdateInfo,
voiceReady, relayVoiceReady, entries,
) {
buildStatusChecks(
network = network,
dashboardUrl = dashboardUrl,
gatewayAvailability = gatewayAvailability,
apiConfigured = activeConnection?.apiServerUrl?.isNotBlank() == true,
apiHealth = apiHealth,
apiUrl = apiUrl,
capabilities = capabilities,
authState = authState,
chatReady = chatReady,
relayConfigured = relayConfigured,
relayHealth = relayHealth,
relayReady = relayReady,
@@ -171,7 +182,7 @@ fun DiagnosticsScreen(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val profileKey = selectedProfile?.name ?: "(default)"
val profileKey = effectiveSessionProfileName ?: "(default)"
val profileState = info.profiles.firstOrNull { it.name == profileKey }?.relayState
?: stringResource(R.string.diag_check_not_checked)
Text(
@@ -233,11 +244,13 @@ fun DiagnosticsScreen(
*/
internal fun buildStatusChecks(
network: ConnectivityObserver.Status,
dashboardUrl: String,
gatewayAvailability: GatewayAvailability,
apiConfigured: Boolean,
apiHealth: ConnectionViewModel.HealthStatus,
apiUrl: String,
capabilities: ServerCapabilities,
authState: AuthState,
chatReady: Boolean,
relayConfigured: Boolean,
relayHealth: ConnectionViewModel.HealthStatus,
relayReady: Boolean,
@@ -279,37 +292,94 @@ internal fun buildStatusChecks(
)
}
// 2) API server reachability.
val apiLabel = context.getString(R.string.diag_check_api_server)
// 2) Dashboard/Gateway — the standard upstream Hermes connection.
val dashboardLabel = context.getString(R.string.cw_dashboard) + " / " +
context.getString(R.string.chat_settings_gateway)
val reachableAt = context.getString(R.string.diag_check_reachable_at)
val reachable = context.getString(R.string.diag_check_reachable)
val notReachableAt = context.getString(R.string.diag_check_not_reachable_at)
val notReachable = context.getString(R.string.diag_check_not_reachable)
val probing = context.getString(R.string.diag_check_probing)
val notChecked = context.getString(R.string.diag_check_not_checked)
val dashboardHost = DiagnosticsLog.sanitizeUrl(dashboardUrl)
val dashboardErr = recentError(DiagnosticCategory.Endpoint)
checks += when {
dashboardUrl.isBlank() ->
StatusCheck(
dashboardLabel, CheckStatus.Unknown,
reason = context.getString(R.string.active_section_not_configured),
category = DiagnosticCategory.Endpoint,
)
gatewayAvailability == GatewayAvailability.Ready ->
StatusCheck(
dashboardLabel, CheckStatus.Pass,
reason = dashboardHost?.let {
context.getString(R.string.diag_check_reachable_at, it)
} ?: reachable,
category = DiagnosticCategory.Endpoint,
)
gatewayAvailability == GatewayAvailability.SignInRequired ->
StatusCheck(
dashboardLabel, CheckStatus.Warn,
reason = context.getString(R.string.cw_dashboard_sign_in_required),
category = DiagnosticCategory.Auth,
)
gatewayAvailability == GatewayAvailability.Unreachable ->
StatusCheck(
dashboardLabel, CheckStatus.Fail,
reason = dashboardErr?.message()
?: dashboardHost?.let {
context.getString(R.string.diag_check_not_reachable_at, it)
}
?: notReachable,
category = DiagnosticCategory.Endpoint,
timestampMs = dashboardErr?.timestampMs,
)
gatewayAvailability == GatewayAvailability.Unsupported ->
StatusCheck(
dashboardLabel, CheckStatus.Fail,
reason = context.getString(R.string.diag_check_no_chat_endpoint),
category = DiagnosticCategory.Session,
)
else ->
StatusCheck(
dashboardLabel, CheckStatus.Unknown,
reason = notChecked,
category = DiagnosticCategory.Endpoint,
)
}
// 3) Optional API-server fallback reachability.
val apiLabel = context.getString(R.string.active_section_optional_api_fallback)
val host = DiagnosticsLog.sanitizeUrl(apiUrl)
val apiErr = recentError(DiagnosticCategory.Api)
checks += when (apiHealth) {
ConnectionViewModel.HealthStatus.Reachable ->
checks += when {
!apiConfigured ->
StatusCheck(
apiLabel, CheckStatus.Unknown,
reason = context.getString(R.string.active_section_not_configured),
category = DiagnosticCategory.Api,
)
apiHealth == ConnectionViewModel.HealthStatus.Reachable ->
StatusCheck(
apiLabel, CheckStatus.Pass,
reason = host?.let { context.getString(R.string.diag_check_reachable_at, it) } ?: reachable,
category = DiagnosticCategory.Api,
)
ConnectionViewModel.HealthStatus.Unreachable ->
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
StatusCheck(
apiLabel, CheckStatus.Fail,
reason = apiErr?.message() ?: (host?.let { context.getString(R.string.diag_check_not_reachable_at, it) } ?: notReachable),
category = DiagnosticCategory.Api,
timestampMs = apiErr?.timestampMs,
)
ConnectionViewModel.HealthStatus.Probing ->
apiHealth == ConnectionViewModel.HealthStatus.Probing ->
StatusCheck(
apiLabel, CheckStatus.Unknown,
reason = probing,
category = DiagnosticCategory.Api,
)
ConnectionViewModel.HealthStatus.Unknown ->
else ->
StatusCheck(
apiLabel, CheckStatus.Unknown,
reason = notChecked,
@@ -317,13 +387,19 @@ internal fun buildStatusChecks(
)
}
// 3) Server capabilities (which chat surfaces the server advertises).
// 4) Optional API-server capabilities.
val capsLabel = context.getString(R.string.diag_check_server_capabilities)
val capsNoHealthy = context.getString(R.string.diag_check_no_server_yet)
val capsNativeSessions = context.getString(R.string.diag_check_native_sessions)
val capsSseFallback = context.getString(R.string.diag_check_sse_fallback)
val capsNoEndpoint = context.getString(R.string.diag_check_no_chat_endpoint)
checks += when {
!apiConfigured ->
StatusCheck(
capsLabel, CheckStatus.Unknown,
reason = context.getString(R.string.active_section_not_configured),
category = DiagnosticCategory.Api,
)
!capabilities.healthy ->
StatusCheck(
capsLabel, CheckStatus.Unknown,
@@ -350,55 +426,96 @@ internal fun buildStatusChecks(
)
}
// 4) Chat transport readiness.
// 5) Chat transport readiness.
val chatLabel = context.getString(R.string.diag_check_chat_transport)
val chatReadyFmt = context.getString(R.string.diag_check_ready_with)
val chatNotReady = context.getString(R.string.diag_check_not_ready)
val chatErr = recentError(DiagnosticCategory.Session) ?: recentError(DiagnosticCategory.Api)
checks += if (chatReady) {
StatusCheck(
chatLabel, CheckStatus.Pass,
reason = context.getString(R.string.diag_check_ready_with, capabilities.preferredChatEndpoint()),
category = DiagnosticCategory.Session,
)
} else {
val degraded = apiHealth == ConnectionViewModel.HealthStatus.Reachable
StatusCheck(
chatLabel,
if (degraded) CheckStatus.Warn else CheckStatus.Fail,
reason = chatErr?.message() ?: chatNotReady,
category = DiagnosticCategory.Session,
timestampMs = chatErr?.timestampMs,
)
val chatErr = recentError(DiagnosticCategory.Session)
?: recentError(DiagnosticCategory.Endpoint)
?: recentError(DiagnosticCategory.Api)
val chatRuntime = resolveChatRuntimeStatus(
gateway = when {
dashboardUrl.isBlank() -> ChatTransportReadiness.NotConfigured
gatewayAvailability == GatewayAvailability.Ready -> ChatTransportReadiness.Ready
gatewayAvailability == GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
},
apiSse = when {
!apiConfigured -> ChatTransportReadiness.NotConfigured
apiHealth == ConnectionViewModel.HealthStatus.Reachable -> ChatTransportReadiness.Ready
apiHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
else -> ChatTransportReadiness.Unavailable
},
)
checks += when (chatRuntime) {
is ChatRuntimeStatus.Connected -> {
val route = when (chatRuntime.transport) {
ChatTransportPath.Gateway -> context.getString(R.string.chat_settings_gateway)
ChatTransportPath.ApiSse -> capabilities.preferredChatEndpoint()
}
StatusCheck(
chatLabel,
if (chatRuntime.fallback) CheckStatus.Warn else CheckStatus.Pass,
reason = context.getString(R.string.diag_check_ready_with, route),
category = DiagnosticCategory.Session,
)
}
ChatRuntimeStatus.Connecting ->
StatusCheck(
chatLabel, CheckStatus.Unknown,
reason = probing,
category = DiagnosticCategory.Session,
)
ChatRuntimeStatus.Unavailable ->
StatusCheck(
chatLabel,
if (gatewayAvailability == GatewayAvailability.SignInRequired) {
CheckStatus.Warn
} else {
CheckStatus.Fail
},
reason = if (gatewayAvailability == GatewayAvailability.SignInRequired) {
context.getString(R.string.cw_dashboard_sign_in_required)
} else {
chatErr?.message() ?: chatNotReady
},
category = DiagnosticCategory.Session,
timestampMs = chatErr?.timestampMs,
)
}
// 5) Relay / pairing auth.
// 6) Optional Relay / pairing auth.
val authLabel = context.getString(R.string.diag_check_pairing_auth)
val authRelayActive = context.getString(R.string.diag_check_relay_active)
val authPairingProg = context.getString(R.string.diag_check_pairing_progress)
val authNotPaired = context.getString(R.string.diag_check_not_paired)
val authErr = recentError(DiagnosticCategory.Auth)
checks += when (authState) {
is AuthState.Paired ->
checks += when {
!relayConfigured ->
StatusCheck(
authLabel, CheckStatus.Unknown,
reason = context.getString(R.string.active_section_optional),
category = DiagnosticCategory.Auth,
)
authState is AuthState.Paired ->
StatusCheck(
authLabel, CheckStatus.Pass,
reason = authRelayActive,
category = DiagnosticCategory.Auth,
)
is AuthState.Pairing ->
authState is AuthState.Pairing ->
StatusCheck(
authLabel, CheckStatus.Warn,
reason = authPairingProg,
category = DiagnosticCategory.Auth,
)
is AuthState.Failed ->
authState is AuthState.Failed ->
StatusCheck(
authLabel, CheckStatus.Fail,
reason = authState.reason,
category = DiagnosticCategory.Auth,
timestampMs = authErr?.timestampMs,
)
is AuthState.Unpaired ->
else ->
StatusCheck(
authLabel, CheckStatus.Unknown,
reason = authNotPaired,
@@ -406,8 +523,8 @@ internal fun buildStatusChecks(
)
}
// 6) Relay server (optional — Unknown when not paired/configured).
val relayLabel = context.getString(R.string.diag_check_relay_server)
// 7) Relay server (optional — Unknown when not paired/configured).
val relayLabel = context.getString(R.string.active_section_optional_relay)
val relayNotConfigured = context.getString(R.string.diag_check_relay_not_configured)
val relayConnected = context.getString(R.string.diag_check_connected)
val relayReachableNotReady = context.getString(R.string.diag_check_reachable_not_ready)
@@ -442,7 +559,7 @@ internal fun buildStatusChecks(
)
}
// 7) Relay plugin version + release availability. The update route is
// 8) Relay plugin version + release availability. The update route is
// optional on older plugin versions, so a connected relay with no result is
// explicitly Unknown rather than incorrectly reported as current.
val pluginLabel = context.getString(R.string.diag_check_relay_plugin)
@@ -494,7 +611,7 @@ internal fun buildStatusChecks(
)
}
// 8) Voice readiness.
// 9) Voice readiness.
val voiceLabel = context.getString(R.string.diag_check_voice)
val voiceRelayReady = context.getString(R.string.diag_check_voice_relay)
val voiceStandardReady = context.getString(R.string.diag_check_voice_standard)
@@ -32,10 +32,9 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
* button), and pops back to wherever it came from on complete or cancel.
*
* [autoStart] lets the caller deep-link into a specific pair method. When
* set to `"scan"`, the wizard jumps straight to camera-permission-request
* → scanner on first composition. Null (default) shows the full Method
* chooser so users can pick Standard API/dashboard setup or a Relay pairing
* method.
* set to `"scan"`, the wizard jumps straight to the scanner. `"relay"`
* opens the connection-scoped Relay method chooser without exposing the
* new-server flow. Null shows the full connection chooser.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -45,6 +44,7 @@ fun PairScreen(
onCancel: () -> Unit,
onManageSignIn: (() -> Unit)? = null,
autoStart: String? = null,
setupReady: Boolean = true,
/**
* Optional offline "Try the demo" entry, forwarded to [ConnectionWizard].
* Wired by [RelayApp] only for the bare Connect entry (no placeholder
@@ -65,7 +65,14 @@ fun PairScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.pair_connect_to_hermes)) },
title = {
Text(
stringResource(
if (autoStart == "relay") R.string.detail_pair_relay
else R.string.pair_connect_to_hermes,
),
)
},
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(
@@ -93,6 +100,7 @@ fun PairScreen(
onManageSignIn = onManageSignIn,
showSkip = false,
autoStart = autoStart,
setupReady = setupReady,
onTryDemo = onTryDemo,
)
}
@@ -64,6 +64,7 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.ui.components.SessionTtlPickerDialog
import com.hermesandroid.relay.ui.components.TransportSecurityBadge
import com.hermesandroid.relay.ui.components.TransportSecuritySize
@@ -872,8 +873,7 @@ private fun EndpointsSubList(
for (candidate in endpoints) {
val isActive = activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
activeEndpoint.api.port == candidate.api.port
activeEndpoint.routeAuthority() == candidate.routeAuthority()
Row(
modifier = Modifier
.fillMaxWidth()
@@ -899,7 +899,7 @@ private fun EndpointsSubList(
},
)
Text(
text = "${candidate.api.host}:${candidate.api.port}",
text = candidate.routeAuthority().orEmpty(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
@@ -91,15 +91,19 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.ui.components.AgentAvatarFace
import com.hermesandroid.relay.ui.components.AgentInfoSheet
import com.hermesandroid.relay.ui.components.LocalAgentIconPath
import com.hermesandroid.relay.ui.components.ProfileInspectorCard
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.gradientBorder
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
/**
* Root Settings destination. After the 2026-04-11 split, Settings is a
@@ -176,6 +180,7 @@ fun SettingsScreen(
// ring-accent, and subtitle.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
@@ -183,27 +188,45 @@ fun SettingsScreen(
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
val apiServerHealth by connectionViewModel.apiServerHealth.collectAsState()
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
val devOptionsUnlocked by FeatureFlags.devOptionsUnlocked(context)
.collectAsState(initial = FeatureFlags.isDevBuild)
val relayPaired = authState is AuthState.Paired
val dashboardStatus = activeConnection?.dashboardLastStatus
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val chatRuntimeStatus = resolveChatRuntimeStatus(
gateway = when (gatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
GatewayAvailability.Unknown -> ChatTransportReadiness.Connecting
GatewayAvailability.SignInRequired,
GatewayAvailability.Unreachable,
GatewayAvailability.Unsupported -> ChatTransportReadiness.Unavailable
},
apiSse = when {
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
apiServerReachable -> ChatTransportReadiness.Ready
apiServerHealth == ConnectionViewModel.HealthStatus.Probing -> ChatTransportReadiness.Connecting
apiServerHealth == ConnectionViewModel.HealthStatus.Unreachable -> ChatTransportReadiness.Unavailable
else -> ChatTransportReadiness.Connecting
},
)
// Status pills are exception-only: a pill appears only when the surface
// needs attention (missing / checking / offline / sign-in). When it's
// healthy the pill is null so the card + agent summary stay clean.
val apiPill: SettingsStatusPillModel? = when {
activeConnection?.apiServerUrl.isNullOrBlank() -> SettingsStatusPillModel(
label = stringResource(R.string.settings_api_missing),
tone = SettingsStatusTone.Warning,
)
apiServerHealth == ConnectionViewModel.HealthStatus.Probing -> SettingsStatusPillModel(
label = stringResource(R.string.settings_api_checking),
// Chat is transport-level: Gateway is primary and API is only fallback.
// A healthy transport suppresses warnings from the other optional surface.
val chatPill: SettingsStatusPillModel? = when {
chatRuntimeStatus is ChatRuntimeStatus.Connected -> null
chatRuntimeStatus == ChatRuntimeStatus.Connecting -> null
gatewayAvailability == GatewayAvailability.SignInRequired -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_sign_in),
tone = SettingsStatusTone.Info,
)
apiServerReachable -> null
apiServerHealth == ConnectionViewModel.HealthStatus.Unreachable -> SettingsStatusPillModel(
label = stringResource(R.string.settings_api_offline),
activeConnection?.resolvedDashboardUrl.isNullOrBlank() -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_missing),
tone = SettingsStatusTone.Warning,
)
gatewayAvailability == GatewayAvailability.Unreachable ||
gatewayAvailability == GatewayAvailability.Unsupported -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_offline),
tone = SettingsStatusTone.Warning,
)
else -> null
@@ -213,38 +236,18 @@ fun SettingsScreen(
label = stringResource(R.string.settings_dashboard_missing),
tone = SettingsStatusTone.Warning,
)
dashboardStatus == null -> null
!dashboardStatus.reachable -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_offline),
tone = SettingsStatusTone.Warning,
)
dashboardSignInRequired -> SettingsStatusPillModel(
gatewayAvailability == GatewayAvailability.Ready -> null
gatewayAvailability == GatewayAvailability.SignInRequired -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_sign_in),
tone = SettingsStatusTone.Info,
)
dashboardStatus.authenticated == true -> null
gatewayAvailability == GatewayAvailability.Unreachable ||
gatewayAvailability == GatewayAvailability.Unsupported -> SettingsStatusPillModel(
label = stringResource(R.string.settings_dashboard_offline),
tone = SettingsStatusTone.Warning,
)
else -> null
}
val relayPill: SettingsStatusPillModel? = when (relayUiState) {
RelayUiState.Connected -> null
RelayUiState.Connecting -> SettingsStatusPillModel(
label = stringResource(R.string.settings_relay_reconnecting),
tone = SettingsStatusTone.Info,
)
RelayUiState.Stale -> SettingsStatusPillModel(
label = stringResource(R.string.settings_relay_stale),
tone = SettingsStatusTone.Warning,
)
RelayUiState.Expired -> SettingsStatusPillModel(
label = stringResource(R.string.settings_pairing_expired),
tone = SettingsStatusTone.Warning,
)
RelayUiState.Disconnected -> SettingsStatusPillModel(
label = if (relayPaired) stringResource(R.string.settings_relay_offline) else stringResource(R.string.settings_requires_pairing),
tone = SettingsStatusTone.Warning,
)
RelayUiState.NotConfigured -> null
}
// The Power tools below all ride the relay plugin. Rather than stamp an
// identical badge on every card (noise, not signal), the dependency is
// surfaced ONCE on the section header as a single plugin-state badge.
@@ -323,10 +326,6 @@ fun SettingsScreen(
// + one-line `connection · model · personality` subtitle).
// Tapping opens AgentInfoSheet inline so users can change
// Connection / Profile / Personality without leaving Settings.
val effectiveProfile = AgentDisplay.effectiveDisplayProfile(
selectedProfile = selectedProfile,
profiles = agentProfiles,
)
ActiveAgentCard(
agentName = AgentDisplay.agentName(
profile = effectiveProfile,
@@ -342,7 +341,7 @@ fun SettingsScreen(
defaultPersonality = defaultPersonality,
),
isCustomized = selectedProfile != null || selectedPersonality != "default",
statusPills = listOfNotNull(apiPill, dashboardPill, relayPill),
statusPills = listOfNotNull(chatPill),
onClick = { showAgentSheet = true },
isDarkTheme = isDarkTheme,
)
@@ -354,8 +353,7 @@ fun SettingsScreen(
// `default` profile — the relay always advertises one, and
// it IS the effective agent. Still falls back to disabled
// when no profiles have loaded yet (unpaired / pre-auth).
val inspectorTarget = selectedProfile
?: agentProfiles.firstOrNull { it.name == "default" }
val inspectorTarget = effectiveProfile
?: agentProfiles.firstOrNull()
ProfileInspectorCard(
activeProfile = inspectorTarget,
@@ -438,7 +436,7 @@ fun SettingsScreen(
icon = Icons.AutoMirrored.Filled.Chat,
title = stringResource(R.string.settings_chat),
subtitle = stringResource(R.string.settings_chat_desc),
badge = apiPill,
badge = chatPill,
onClick = onNavigateToChatSettings,
isDarkTheme = isDarkTheme,
)
@@ -139,6 +139,7 @@ fun VoiceSettingsScreen(
voiceViewModel: VoiceViewModel,
voiceClient: RelayVoiceClient?,
selectedProfile: Profile? = null,
displayProfile: Profile? = selectedProfile,
standardVoiceAvailability: StandardVoiceAvailability = StandardVoiceAvailability.Unknown,
/**
* Non-null endpoint display label (e.g. "Tailscale") when the sign-in
@@ -349,7 +350,7 @@ fun VoiceSettingsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
VoiceProfileSummaryCard(
selectedProfile = selectedProfile,
displayProfile = displayProfile,
currentEngine = currentEngine,
output = configState.voiceOutputConfig,
realtime = configState.realtimeConfig,
@@ -362,7 +363,7 @@ fun VoiceSettingsScreen(
relayVoiceReady = relayVoiceReady,
currentEngine = currentEngine,
configState = configState,
selectedProfile = selectedProfile,
displayProfile = displayProfile,
)
VoiceModePresetCard(
@@ -443,7 +444,7 @@ fun VoiceSettingsScreen(
currentEngine = currentEngine,
relayVoiceReady = relayVoiceReady,
configState = configState,
selectedProfile = selectedProfile,
displayProfile = displayProfile,
voiceViewModel = voiceViewModel,
)
}
@@ -460,7 +461,7 @@ private fun VoiceScopeBanner(
relayVoiceReady: Boolean,
currentEngine: VoiceEngineMode,
configState: VoiceConfigUiState,
selectedProfile: Profile?,
displayProfile: Profile?,
) {
Card(
modifier = Modifier.fillMaxWidth(),
@@ -519,7 +520,7 @@ private fun VoiceScopeBanner(
} else {
ProviderRow(
label = stringResource(R.string.voice_settings_label_profile),
value = voiceProfileLabel(profileRaw, selectedProfile),
value = voiceProfileLabel(profileRaw, displayProfile),
)
ProviderRow(
label = stringResource(R.string.voice_settings_label_scope),
@@ -2421,7 +2422,7 @@ private fun TestCurrentEngineCard(
currentEngine: VoiceEngineMode,
relayVoiceReady: Boolean,
configState: VoiceConfigUiState,
selectedProfile: Profile?,
displayProfile: Profile?,
voiceViewModel: VoiceViewModel,
) {
val context = LocalContext.current
@@ -2461,8 +2462,8 @@ private fun TestCurrentEngineCard(
ProviderRow(
label = stringResource(R.string.voice_settings_label_profile),
value = configState.voiceOutputConfig?.let { config ->
voiceProfileLabel(config.profile, selectedProfile)
} ?: voiceProfileLabel(null, selectedProfile),
voiceProfileLabel(config.profile, displayProfile)
} ?: voiceProfileLabel(null, displayProfile),
)
ProviderRow(
label = stringResource(R.string.voice_settings_label_voice),
@@ -3222,7 +3223,7 @@ private fun voiceOutputSummary(
@Composable
private fun VoiceProfileSummaryCard(
selectedProfile: Profile?,
displayProfile: Profile?,
currentEngine: VoiceEngineMode,
output: VoiceOutputConfig?,
realtime: RealtimeVoiceConfig?,
@@ -3230,7 +3231,7 @@ private fun VoiceProfileSummaryCard(
realtimeVoice: String,
) {
val (title, subtitle) = voiceOutputSummary(
profile = selectedProfile,
profile = displayProfile,
currentEngine = currentEngine,
output = output,
realtime = realtime,
@@ -0,0 +1,52 @@
package com.hermesandroid.relay.viewmodel
/** Runtime readiness for one independently optional chat transport. */
enum class ChatTransportReadiness {
NotConfigured,
Connecting,
Ready,
Unavailable,
}
enum class ChatTransportPath {
Gateway,
ApiSse,
}
/**
* Transport-neutral UI state for chat connectivity. Relay is intentionally
* absent: it is an optional bridge surface and cannot make chat unhealthy.
*/
sealed interface ChatRuntimeStatus {
data class Connected(
val transport: ChatTransportPath,
val fallback: Boolean,
) : ChatRuntimeStatus
data object Connecting : ChatRuntimeStatus
data object Unavailable : ChatRuntimeStatus
}
/**
* Resolve chat health in product priority order:
* Gateway primary, API/SSE fallback, pending connection, then unavailable.
*/
fun resolveChatRuntimeStatus(
gateway: ChatTransportReadiness,
apiSse: ChatTransportReadiness,
): ChatRuntimeStatus = when {
gateway == ChatTransportReadiness.Ready -> ChatRuntimeStatus.Connected(
transport = ChatTransportPath.Gateway,
fallback = false,
)
apiSse == ChatTransportReadiness.Ready -> ChatRuntimeStatus.Connected(
transport = ChatTransportPath.ApiSse,
fallback = true,
)
gateway == ChatTransportReadiness.Connecting ||
apiSse == ChatTransportReadiness.Connecting -> ChatRuntimeStatus.Connecting
else -> ChatRuntimeStatus.Unavailable
}
@@ -120,6 +120,21 @@ data class ContextWindowUsage(
get() = if (maxTokens > 0) (usedTokens.toFloat() / maxTokens).coerceIn(0f, 1f) else 0f
}
/**
* A successful Sessions SSE turn still needs the server-authoritative transcript
* because that transport does not stream every persisted message boundary.
* Gateway turns already deliver the assistant, reasoning, and tool lifecycle
* directly; reloading their full transcript only republishes a healthy live turn.
* A successful socket rejoin is the exception because events emitted during the
* gap cannot be replayed.
*/
internal fun shouldReloadHistoryAfterSuccessfulTurn(
actualTransport: String,
gatewayReconcileRequired: Boolean,
): Boolean =
actualTransport == "sessions" ||
(actualTransport == "gateway" && gatewayReconcileRequired)
class ChatViewModel : ViewModel() {
private var apiClient: HermesApiClient? = null
@@ -132,6 +147,9 @@ class ChatViewModel : ViewModel() {
*/
private var activeStream: ActiveTurnHandle? = null
/** Token bursts awaiting their next UI-sized publication window. */
private var activeStreamDeltas: StreamDeltaCoalescer? = null
/**
* True when [activeStream] is a GATEWAY turn (vs an SSE EventSource). A
* gateway turn runs on the gateway client, which survives a same-connection
@@ -436,6 +454,14 @@ class ChatViewModel : ViewModel() {
/** Personality name → system prompt. Used to send the right prompt when switching. */
private var personalityPrompts: Map<String, String> = emptyMap()
/** Dashboard `/api/config` fallback used when no API client is configured. */
private var dashboardConfigLoader: (suspend () -> Result<JsonObject>?)? = null
fun setDashboardConfigLoader(loader: suspend () -> Result<JsonObject>?) {
dashboardConfigLoader = loader
fetchPersonalities()
}
/** Flat model ids from `GET /v1/models` (SSE fallback source; gateway uses [modelProviders]). */
private val _availableModels = MutableStateFlow<List<String>>(emptyList())
val availableModels: StateFlow<List<String>> = _availableModels.asStateFlow()
@@ -1254,6 +1280,9 @@ class ChatViewModel : ViewModel() {
onTurnComplete = {
if (acceptsEvent()) handler.onTurnComplete(messageId)
},
// Server-initiated turns already take the bounded durable-history
// reconcile below on every completion.
onReconcileRequired = { },
onComplete = {
val canWriteTranscript = acceptsEvent()
val expectedText = handler.messages.value
@@ -2099,7 +2128,7 @@ class ChatViewModel : ViewModel() {
*/
val boundHandler: ChatHandler? get() = chatHandler
fun initialize(apiClient: HermesApiClient, chatHandler: ChatHandler) {
fun initialize(apiClient: HermesApiClient?, chatHandler: ChatHandler) {
this.apiClient = apiClient
if (this.chatHandler !== chatHandler) {
checkpointStatusJob?.cancel()
@@ -2248,13 +2277,17 @@ class ChatViewModel : ViewModel() {
}
}
fun updateApiClient(client: HermesApiClient) {
fun updateApiClient(client: HermesApiClient?) {
// A route handoff / reconnect rebuilds the HTTP API client. An SSE turn
// is bound to the OLD client, so it must be cancelled. A GATEWAY turn
// is NOT — it runs on the gateway client (which survives a
// same-connection route blip and reconnects its own socket, keeping the
// live session), so cancelling here would needlessly kill a recoverable
// turn. Leave it running; it completes on the gateway client.
if (!activeStreamIsGateway) {
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
}
val droppedSseCheckpoint = if (!activeStreamIsGateway) buildTurnCheckpoint() else null
if (!activeStreamIsGateway) {
activeStream?.cancel()
@@ -2293,6 +2326,8 @@ class ChatViewModel : ViewModel() {
historyLoadGeneration.incrementAndGet()
sessionRefreshGeneration.incrementAndGet()
intentionallyCancelled = true
activeStreamDeltas?.discard()
activeStreamDeltas = null
activeStream?.cancel()
activeStream = null
cancelAnswerRecovery(settleUi = false)
@@ -2331,7 +2366,6 @@ class ChatViewModel : ViewModel() {
}
fun switchProfileContext(contextKey: String, sessionId: String?) {
val client = apiClient
val handler = chatHandler ?: return
val isInitialContextBinding = activeProfileContextKey == null
handler.activeAgentName = currentAgentDisplayName()
@@ -2395,9 +2429,8 @@ class ChatViewModel : ViewModel() {
onSessionChanged?.invoke(sessionId)
}
if (sessionId == null || client == null) {
// Fresh draft (or no client to load from) — there's nothing to hold
// for, so clear the previous transcript now and show the empty state.
if (sessionId == null) {
// Fresh draft — there is no transcript to load.
handler.clearMessages()
_isLoadingHistory.value = false
_initialChatSettled.value = true
@@ -2452,9 +2485,15 @@ class ChatViewModel : ViewModel() {
}
private fun fetchPersonalities() {
val client = apiClient ?: return
viewModelScope.launch {
val config = client.getPersonalities()
val config = if (apiClient != null) {
apiClient?.getPersonalities()
} else {
dashboardConfigLoader
?.invoke()
?.getOrNull()
?.let(::parseDashboardPersonalityConfig)
} ?: return@launch
_personalityNames.value = config.names
_defaultPersonality.value = config.defaultName
personalityPrompts = config.prompts
@@ -2499,6 +2538,20 @@ class ChatViewModel : ViewModel() {
fetchModels()
}
/** Clear server-owned catalogs before a different connection starts loading. */
fun resetConnectionCatalogs() {
_availableSkills.value = emptyList()
_personalityNames.value = emptyList()
_defaultPersonality.value = ""
personalityPrompts = emptyMap()
_availableModels.value = emptyList()
_serverCommands.value = emptyList()
_modelProviders.value = emptyList()
_gatewayCurrentModel.value = ""
_gatewayCurrentProvider.value = ""
_serverModelName.value = ""
}
// --- Session management ---
private val _isLoadingSessions = MutableStateFlow(false)
@@ -2577,7 +2630,6 @@ class ChatViewModel : ViewModel() {
}
fun createNewChat() {
val client = apiClient ?: return
val handler = chatHandler ?: return
// Gateway turns continue as detached siblings; SSE remains exclusive.
@@ -2613,6 +2665,8 @@ class ChatViewModel : ViewModel() {
return
}
val client = apiClient ?: return
viewModelScope.launch {
val selectedProfile = selectedProfileProvider()
val useIsolatedProfileApi = selectedProfile?.hasIsolatedApi == true
@@ -2731,8 +2785,8 @@ class ChatViewModel : ViewModel() {
}
fun switchSession(sessionId: String) {
apiClient ?: return
val handler = chatHandler ?: return
if (streamingEndpoint != "gateway" && apiClient == null) return
// Keep a Gateway sibling alive and detach its callbacks. SSE remains a
// single exclusive stream and is interrupted on navigation.
@@ -2788,8 +2842,9 @@ class ChatViewModel : ViewModel() {
}
fun deleteSession(sessionId: String) {
val client = apiClient ?: return
val handler = chatHandler ?: return
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
// Save reference before removing (for rollback on failure)
val removedSession = handler.sessions.value.find { it.sessionId == sessionId }
@@ -2815,7 +2870,7 @@ class ChatViewModel : ViewModel() {
val success = if (streamingEndpoint == "gateway") {
profileSessionDeleter?.invoke(sessionId) ?: false
} else {
client.deleteSession(sessionId)
client?.deleteSession(sessionId) == true
}
if (success) {
// Re-fetch so a server that still has the row can't leave it
@@ -2833,8 +2888,9 @@ class ChatViewModel : ViewModel() {
}
fun renameSession(sessionId: String, newTitle: String) {
val client = apiClient ?: return
val handler = chatHandler ?: return
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
val previousTitle = handler.sessions.value.find { it.sessionId == sessionId }?.title
// Optimistic rename
@@ -2859,7 +2915,7 @@ class ChatViewModel : ViewModel() {
)
}
} else {
client.renameSession(sessionId, newTitle)
client?.renameSession(sessionId, newTitle)
}
}
}
@@ -2896,8 +2952,10 @@ class ChatViewModel : ViewModel() {
return
}
val client = apiClient ?: return
val handler = chatHandler ?: return
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
if (streamingEndpoint == "gateway" && gatewayClient == null && client == null) return
// Server slash commands (gateway transport only) execute via
// slash.exec / command.dispatch instead of becoming a prompt.
@@ -2955,7 +3013,9 @@ class ChatViewModel : ViewModel() {
// Turn ended while the steer RPC was in flight —
// send it as a normal next-turn prompt instead.
val client = apiClient
if (client != null) sendMessageInternal(client, handler, text)
if (gatewayClient != null || client != null) {
sendMessageInternal(client, handler, text)
}
}
}
}
@@ -3339,7 +3399,8 @@ class ChatViewModel : ViewModel() {
* message among role==USER messages (excluding phone-local traces the
* server never saw), truncates the local list from it, and dispatches a
* gateway turn carrying `truncate_before_user_ordinal`. Local/server
* divergence self-heals via the post-turn history reload.
* divergence self-heals through the gateway's authoritative truncate and
* live turn events; recovery paths still perform a full history reconcile.
*
* @return false when the edit could not be dispatched (turn in flight,
* non-gateway endpoint, missing client, ordinal failure) — the caller
@@ -3347,7 +3408,6 @@ class ChatViewModel : ViewModel() {
*/
fun regenerateFromMessage(userMessageId: String, newText: String): Boolean {
if (newText.isBlank()) return false
val client = apiClient ?: return false
val handler = chatHandler ?: return false
if (streamingEndpoint != "gateway" || gatewayClient == null) return false
if (activeStream != null) return false
@@ -3367,7 +3427,7 @@ class ChatViewModel : ViewModel() {
if (ordinal < 0) return false
handler.truncateMessagesFrom(userMessageId)
pendingTruncateOrdinal = ordinal
sendMessageInternal(client, handler, newText)
sendMessageInternal(apiClient, handler, newText)
return true
}
@@ -3827,6 +3887,8 @@ class ChatViewModel : ViewModel() {
val gateway = gatewayClient
val canBackground = streamingEndpoint == "gateway" &&
activeStreamIsGateway && activeStream != null && gateway != null
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
val checkpoint = if (canBackground) buildTurnCheckpoint() else null
if (canBackground && gateway.backgroundActiveTurn()) {
if (checkpoint != null) {
@@ -4036,6 +4098,8 @@ class ChatViewModel : ViewModel() {
scheduleCheckpointWrite(immediate = true)
}
},
// Recovered turns already reload their authoritative history below.
onReconcileRequired = { },
onComplete = {
if (owns()) {
finalizeTurnSideEffects(handler, messageId)
@@ -4383,14 +4447,16 @@ class ChatViewModel : ViewModel() {
}
private fun drainQueue() {
val client = apiClient ?: return
val handler = chatHandler ?: return
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
if (streamingEndpoint == "gateway" && gatewayClient == null && client == null) return
val next = _queuedMessages.value.firstOrNull() ?: return
_queuedMessages.update { it.drop(1) }
sendMessageInternal(client, handler, next)
}
private fun sendMessageInternal(client: HermesApiClient, handler: ChatHandler, text: String) {
private fun sendMessageInternal(client: HermesApiClient?, handler: ChatHandler, text: String) {
AppAnalytics.onMessageSent()
val interfaceContextPrompt = nextInterfaceContextPrompt
nextInterfaceContextPrompt = null
@@ -4495,6 +4561,10 @@ class ChatViewModel : ViewModel() {
interfaceContextPrompt,
)
} else {
if (client == null) {
handler.onStreamError("API fallback is not configured for this connection.")
return
}
viewModelScope.launch {
val selectedProfile = selectedProfileProvider()
val useIsolatedProfileApi = selectedProfile?.hasIsolatedApi == true
@@ -5359,7 +5429,7 @@ class ChatViewModel : ViewModel() {
}
private fun startStream(
client: HermesApiClient,
client: HermesApiClient?,
handler: ChatHandler,
sessionId: String,
message: String,
@@ -5394,6 +5464,8 @@ class ChatViewModel : ViewModel() {
// finalizes the previous turn's leftover streaming placeholder so it
// can't pulse forever next to this turn's fresh one.
cancelAnswerRecovery()
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
// A new turn is starting: clear any leftover cancellation flag so a
// stale `true` from a PRIOR cancelled turn (the flag is sticky — a
@@ -5414,6 +5486,7 @@ class ChatViewModel : ViewModel() {
// stream answer recovery (issue #166) on "sessions": the other
// endpoints keep their existing error behavior.
var dispatchedSseEndpoint: String? = null
var gatewayHistoryReconcileRequired = false
val assistantTimestamp = System.currentTimeMillis()
beginTurnCheckpoint(
@@ -5440,13 +5513,26 @@ class ChatViewModel : ViewModel() {
// interfaceContextPrompt today, so it marks this turn as spoken.
// Tag the reply with a "Voice" chip (parity with "Realtime
// Agent"); ChatHandler.loadMessageHistory preserves it across
// the post-turn history reload.
// history and recovery reconciliation.
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
)
)
val streamDeltas = StreamDeltaCoalescer(
scope = viewModelScope,
onTextDelta = { delta -> handler.onTextDelta(currentMessageId, delta) },
onThinkingDelta = { delta -> handler.onThinkingDelta(currentMessageId, delta) },
)
activeStreamDeltas = streamDeltas
fun flushAndReleaseStreamDeltas() {
streamDeltas.flushNow()
if (activeStreamDeltas === streamDeltas) activeStreamDeltas = null
}
// Shared callbacks for both endpoints
val onMessageStartedCb = { serverMsgId: String ->
streamDeltas.flushNow()
// Replace the placeholder's ID so subsequent deltas/tool calls attach
// to it instead of creating a duplicate orphan bubble with streaming dots.
// Only replaces empty+streaming messages (the placeholder), not completed turns.
@@ -5459,34 +5545,41 @@ class ChatViewModel : ViewModel() {
firstTokenNotified = true
AppAnalytics.onFirstTokenReceived()
}
handler.onTextDelta(currentMessageId, delta)
streamDeltas.appendText(delta)
}
val onThinkingDeltaCb = { delta: String ->
handler.onThinkingDelta(currentMessageId, delta)
streamDeltas.appendThinking(delta)
}
val onToolCallStartCb = { toolCallId: String, toolName: String ->
streamDeltas.flushNow()
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
scheduleCheckpointWrite(immediate = true)
}
val onToolCallDoneCb = { toolCallId: String, resultPreview: String? ->
streamDeltas.flushNow()
handler.onToolCallComplete(currentMessageId, toolCallId, resultPreview)
scheduleCheckpointWrite(immediate = true)
}
val onToolCallFailedCb = { toolCallId: String, errorMsg: String? ->
streamDeltas.flushNow()
handler.onToolCallFailed(currentMessageId, toolCallId, errorMsg)
scheduleCheckpointWrite(immediate = true)
}
// Turn complete — one assistant message finished, but the run may continue
val onTurnCompleteCb = {
streamDeltas.flushNow()
handler.onTurnComplete(currentMessageId)
scheduleCheckpointWrite(immediate = true)
}
val onCompleteCb = {
flushAndReleaseStreamDeltas()
// Double-finalize guard: if a straggler completion arrives while
// the answer-recovery poller is running, the normal completion
// wins — stop the poller before finalizing so the turn can't
// finish twice.
cancelAnswerRecovery(settleUi = false)
val completedTransport = dispatchedSseEndpoint
?: if (activeStreamIsGateway) "gateway" else streamingEndpoint
finalizeTurnSideEffects(handler, currentMessageId)
AppAnalytics.onStreamComplete(lastInputTokens, lastOutputTokens)
@@ -5504,15 +5597,13 @@ class ChatViewModel : ViewModel() {
refreshReasoningSettings()
}
// Sessions endpoint doesn't emit structured tool events during streaming —
// tool calls are only available as JSON on the stored messages. Reload the
// server-authoritative history to get proper message boundaries + tool_calls.
// Gateway turns reconcile the same way: live tool events are gated by the
// server's display.tool_progress config, so a turn that ran tools silently
// (config off, or events lost in a mid-turn rejoin gap) still gets its tool
// cards + persisted reasoning right after the turn — not on the next app
// restart. By message.complete the server has persisted the turn, so the
// REST read is authoritative.
// Sessions SSE does not stream every persisted message boundary, so it
// still needs the server-authoritative transcript after success. A healthy
// Gateway turn is already authoritative in memory through its structured
// assistant/reasoning/tool events; reloading the full transcript here would
// republish the entire visible list and cause a completion flash. A Gateway
// socket rejoin explicitly flags this completion for the same profile-aware
// history reconcile because events emitted during the gap may be missing.
val sid = handler.currentSessionId.value
// A turn that ended in an error (gateway ❌ lifecycle → "Error" badge)
// has NO assistant message persisted server-side, so reconciling the
@@ -5523,9 +5614,13 @@ class ChatViewModel : ViewModel() {
val turnErrored = handler.messages.value
.lastOrNull { it.id == currentMessageId }
?.badges?.contains("Error") == true
if (sid != null && (streamingEndpoint == "sessions" || streamingEndpoint == "gateway")) {
if (sid != null && (completedTransport == "sessions" || completedTransport == "gateway")) {
viewModelScope.launch {
if (!turnErrored) {
if (!turnErrored && shouldReloadHistoryAfterSuccessfulTurn(
completedTransport,
gatewayHistoryReconcileRequired,
)
) {
// Profile-aware read: a gateway turn on a non-default profile
// persists into THAT profile's own state.db, so the bare
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
@@ -5587,6 +5682,7 @@ class ChatViewModel : ViewModel() {
}
}
val onErrorCb = { errorMsg: String ->
flushAndReleaseStreamDeltas()
val errorSessionId = handler.currentSessionId.value
if (intentionallyCancelled) {
intentionallyCancelled = false
@@ -5765,12 +5861,16 @@ class ChatViewModel : ViewModel() {
// SSE dispatch shared by the three HTTP endpoints AND the gateway
// branch's per-turn fallback (gateway unreachable / not the resolved
// transport). Warns once per dispatch about any attachment it can't carry.
fun dispatchSse(endpoint: String): ActiveTurnHandle {
fun dispatchSse(endpoint: String): ActiveTurnHandle? {
val sseClient = client ?: run {
onErrorCb("Gateway unavailable and no API fallback is configured.")
return null
}
dispatchedSseEndpoint = endpoint
updateTurnCheckpointTransport(endpoint)
warnIfAttachmentsDropped(endpoint)
return when (endpoint) {
"runs" -> client.sendRunStream(
"runs" -> sseClient.sendRunStream(
message = message,
systemMessage = systemMsg,
attachments = attachments,
@@ -5793,7 +5893,7 @@ class ChatViewModel : ViewModel() {
modelOverride = modelOverride,
profileName = profileName,
).asTurnHandle()
"completions" -> client.sendChatCompletionsStream(
"completions" -> sseClient.sendChatCompletionsStream(
message = message,
systemMessage = systemMsg,
attachments = attachments,
@@ -5812,7 +5912,7 @@ class ChatViewModel : ViewModel() {
modelOverride = modelOverride,
profileName = profileName,
).asTurnHandle()
else -> client.sendChatStream(
else -> sseClient.sendChatStream(
sessionId = sessionId,
message = message,
systemMessage = systemMsg,
@@ -5869,12 +5969,14 @@ class ChatViewModel : ViewModel() {
// traces persist server-side, so this happens at most once per batch.
val sseDrainEndpoint = resolveSseFallback(handler)
val forceSseForTraceDrain =
client != null &&
voiceIntentMessages != null &&
streamingEndpoint == "gateway" &&
profileName == null &&
sseDrainEndpoint == "sessions"
val effectiveEndpoint =
if ((interfaceContextPrompt != null || forceSseForTraceDrain) &&
if (client != null &&
(interfaceContextPrompt != null || forceSseForTraceDrain) &&
streamingEndpoint == "gateway"
) {
sseDrainEndpoint
@@ -5943,18 +6045,24 @@ class ChatViewModel : ViewModel() {
onToolCallDone = onToolCallDoneCb,
onToolCallFailed = onToolCallFailedCb,
onToolOutputRisk = { risk ->
streamDeltas.flushNow()
handler.onToolOutputRisk(currentMessageId, risk)
scheduleCheckpointWrite(immediate = true)
},
onTurnComplete = onTurnCompleteCb,
onReconcileRequired = {
gatewayHistoryReconcileRequired = true
},
onComplete = onCompleteCb,
onUsage = onUsageCb,
onError = onErrorCb,
onToolGenerating = { name ->
streamDeltas.flushNow()
handler.onToolGenerating(currentMessageId, name)
scheduleCheckpointWrite(immediate = true)
},
onSubagentEvent = { event ->
streamDeltas.flushNow()
handler.onSubagentEvent(currentMessageId, event)
scheduleCheckpointWrite(immediate = true)
},
@@ -6076,6 +6184,8 @@ class ChatViewModel : ViewModel() {
// false: the Stopped-badge block below finalizes the placeholder
// itself (completing it here first would hide it from findLast).
cancelAnswerRecovery(settleUi = false)
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
activeStream?.cancel()
activeStream = null
_queuedMessages.value = emptyList()
@@ -6610,6 +6720,8 @@ class ChatViewModel : ViewModel() {
}
override fun onCleared() {
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
flushTurnCheckpointForTeardown()
gatewayClient?.setUnsolicitedTurnProvider(null)
gatewayClient?.setColdPrewarmSessionReadyListener(null)
@@ -6762,6 +6874,40 @@ internal fun parseCommandsCatalog(catalog: JsonObject): List<SlashCommand> {
return out
}
/** Dashboard `/api/config` response -> the same personality catalog as API mode. */
internal fun parseDashboardPersonalityConfig(
root: JsonObject,
): HermesApiClient.PersonalityConfig {
// Dashboard builds have returned both the API-compatible `{config:{...}}`
// envelope and the config tree directly; accept either without guessing.
val config = root["config"] as? JsonObject ?: root
val personalities = ((config["agent"] as? JsonObject)?.get("personalities") as? JsonObject)
?.entries
?.mapNotNull { (name, value) ->
val prompt = (value as? JsonPrimitive)?.contentOrNull ?: return@mapNotNull null
name to prompt
}
?.toMap()
.orEmpty()
val display = config["display"] as? JsonObject
val configuredPersonality = display?.stringValue("personality")
val defaultName = listOf(
configuredPersonality?.takeUnless { it.equals("default", ignoreCase = true) },
display?.stringValue("agent_name"),
display?.stringValue("assistant_name"),
display?.stringValue("display_name"),
display?.stringValue("name"),
display?.stringValue("skin"),
configuredPersonality,
).firstOrNull { !it.isNullOrBlank() }.orEmpty()
return HermesApiClient.PersonalityConfig(
names = personalities.keys.toList(),
prompts = personalities,
defaultName = defaultName,
modelName = root.stringValue("model") ?: config.stringValue("model").orEmpty(),
)
}
private fun isUnsupportedMobileCommand(name: String, pair: JsonArray): Boolean {
val normalized = name.removePrefix("/").substringBefore(' ').lowercase()
if (mobileBlockedSlashNotice(normalized) != null) return true
@@ -59,18 +59,16 @@ import kotlinx.coroutines.withTimeoutOrNull
* 8. Update URL flows via [setApiServerUrl] and [setRelayUrl] so
* [HermesApiClient] and [RelayHttpClient] pick up the new endpoints.
* [persistUrls] writes both values to the app DataStore in one pass.
* 9. Rebuild the API client via [rebuildApiClient] so subsequent chat
* calls hit the new API server with the new API key.
* 10. Await the new AuthManager's first non-[AuthState.Unpaired] /
* 9. Persist the new active-connection id so Dashboard/Gateway probes resolve
* against the target connection rather than the outgoing one.
* 10. Rebuild the API client (or clear it for Dashboard-only) and probe the
* target Dashboard/Gateway surface.
* 11. Await the new AuthManager's first non-[AuthState.Unpaired] /
* non-[AuthState.Loading] emission (or a short timeout) before deciding
* whether to kick the WSS handshake. Connecting too early fires an
* auth envelope with no pair context, which tick the relay's rate
* limiter — exactly the trap [AuthManager.hasPairContext] was
* introduced to avoid.
* 11. Persist the new active-connection id via
* [ConnectionStore.setActiveConnection] last so an early failure leaves
* the previous active connection in place.
*
* The whole sequence runs under [switchMutex] so rapid-fire switches from
* the UI queue cleanly instead of interleaving partial teardowns.
*/
@@ -269,11 +267,22 @@ class ConnectionSwitchCoordinator(
runCatching { persistUrls(target.apiServerUrl, targetRelayUrl) }
.onFailure { Log.w(TAG, "persistUrls failed: ${it.message}") }
// 9 — rebuild the API client against the new URL + key.
// 9 — publish the target context BEFORE rebuilding/probing. The
// Dashboard URL and cookie store are resolved from activeConnection;
// probing first would accidentally re-probe the outgoing connection
// and leave a Dashboard-only target stuck at Gateway.Unknown.
runCatching { connectionStore.setActiveConnection(connectionId) }
.onFailure {
Log.w(TAG, "connectionStore.setActiveConnection failed: ${it.message}")
return@withLock
}
// 10 — rebuild the API client against the new URL + key (or clear
// it and probe Dashboard/Gateway when this is Dashboard-only).
runCatching { rebuildApiClient() }
.onFailure { Log.w(TAG, "rebuildApiClient failed: ${it.message}") }
// 10 — wait briefly for the new AuthManager to hydrate its
// 11 — wait briefly for the new AuthManager to hydrate its
// stored token (if any) so hasPairContext returns the right
// answer. The authState flow seeds to Unpaired and flips to
// Paired asynchronously; without this await we'd almost
@@ -309,24 +318,16 @@ class ConnectionSwitchCoordinator(
)
}
if (newAuth.hasPairContext) {
if (newAuth.hasPairContext && targetRelayUrl.isNotBlank()) {
runCatching { connectionManager.connect(targetRelayUrl) }
.onFailure { Log.w(TAG, "connectionManager.connect failed: ${it.message}") }
} else {
Log.i(
TAG,
"switchConnection: new connection has no pair context — skipping WSS connect " +
"(user will pair from the Connection screen)",
"switchConnection: Relay is not ready for this connection — " +
"skipping WSS connect",
)
}
// 11 — persist last so an earlier failure leaves the previous
// active pointer in place. setActiveConnection updates the
// connectionStore.activeConnectionId StateFlow synchronously
// after the DataStore write lands, so the UI reacts without
// waiting for a recomposition pass.
runCatching { connectionStore.setActiveConnection(connectionId) }
.onFailure { Log.w(TAG, "connectionStore.setActiveConnection failed: ${it.message}") }
}
}
@@ -30,7 +30,9 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.MediaSettingsRepository
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.data.ConnectionSecurity
import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.ConnectionValidation
@@ -117,6 +119,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.put
private data class RelayUiInputs(
@@ -126,6 +129,14 @@ private data class RelayUiInputs(
val configured: Boolean,
)
private data class ConnectionHealthInputs(
val connection: Connection?,
val relayRow: RelayRowState,
val apiHealth: ConnectionViewModel.HealthStatus,
val relayHealth: ConnectionViewModel.HealthStatus,
val network: ConnectivityObserver.Status,
)
/**
* Why the standard (no-plugin) voice route is or isn't usable right now.
* Drives the mic gate, the Auto route ordering, and the Voice Settings
@@ -165,6 +176,46 @@ enum class ChatConnectState {
NeedsConnection,
}
/** Runtime chat readiness independent of which upstream transport is primary. */
internal fun isChatTransportReady(
apiClientPresent: Boolean,
apiReachable: Boolean,
gatewayAvailability: GatewayAvailability,
): Boolean =
gatewayAvailability == GatewayAvailability.Ready ||
(apiClientPresent && apiReachable)
internal fun hasConfiguredHermesConnection(connection: Connection?): Boolean =
connection?.capabilities?.anySurfaceConfigured == true
/**
* Add Relay transport metadata to the connection's existing standard routes
* without adopting the Relay QR's API/Dashboard identity.
*/
internal fun mergeRelayTransportIntoStandardRoutes(
standardRoutes: List<EndpointCandidate>,
relayRoutes: List<EndpointCandidate>,
): List<EndpointCandidate> = standardRoutes.map { standard ->
val relay = relayRoutes
.firstOrNull { it.role.equals(standard.role, ignoreCase = true) }
?.relay
?: return@map standard
standard.copy(relay = relay)
}
/** Pure persistence policy used by Relay-only QR apply and its regression tests. */
internal fun preserveStandardConnectionWhileApplyingRelay(
current: Connection,
relayUrl: String,
relayRoutes: List<EndpointCandidate>,
): Connection = current.copy(
relayUrl = relayUrl,
routeCandidates = mergeRelayTransportIntoStandardRoutes(
standardRoutes = current.routeCandidates,
relayRoutes = relayRoutes,
),
)
class ConnectionViewModel(application: Application) : AndroidViewModel(application) {
private val ctx: Context get() = getApplication()
@@ -326,6 +377,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val connections: StateFlow<List<Connection>> = connectionStore.connections
val activeConnection: StateFlow<Connection?> = connectionStore.activeConnection
val activeConnectionId: StateFlow<String?> = connectionStore.activeConnectionId
val startupConnectionId: StateFlow<String?> = connectionStore.startupConnectionId
// AuthManager owns the CertPinStore; ConnectionManager takes a snapshot
// of the store's current pins on every connect so re-pair wipes land.
@@ -590,14 +642,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private fun normalizeStandardRouteCandidates(
candidates: List<EndpointCandidate>,
): List<EndpointCandidate> {
return candidates.mapNotNull { candidate ->
val relayUrl = candidate.relay.url.takeIf { it.isNotBlank() }
?: Connection.deriveDefaultRelayUrl(candidate.api.url)
?: return@mapNotNull null
return candidates.map { candidate ->
val relayUrl = candidate.relay?.url?.takeIf { it.isNotBlank() }
?: return@map candidate
val transportHint = when {
relayUrl.startsWith("wss://", ignoreCase = true) -> "wss"
relayUrl.startsWith("ws://", ignoreCase = true) -> "ws"
else -> candidate.relay.transportHint
else -> candidate.relay?.transportHint
}
candidate.copy(
relay = RelayEndpoint(
@@ -813,9 +864,24 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun activeDashboardCookieStore(): DashboardCookieStore? =
upstreamTransport.activeDashboardCookieStore()
// Chat is ready when a chat-routed API client exists and the base server is reachable.
val chatReady: StateFlow<Boolean> = combine(_chatApiClient, _apiServerReachable) { client, reachable ->
client != null && reachable
/** Authenticated Dashboard config for dashboard-primary feature catalogs. */
suspend fun loadActiveDashboardConfig(): Result<JsonObject>? {
val connectionId = connectionStore.activeConnectionId.value ?: return null
val dashboardUrl = activeDashboardUrl() ?: return null
return upstreamTransport.dashboardClientFor(connectionId, dashboardUrl).getConfig()
}
// Gateway/Dashboard is the standard path; API remains an optional fallback.
val chatReady: StateFlow<Boolean> = combine(
_chatApiClient,
_apiServerReachable,
upstreamTransport.gatewayAvailability,
) { client, apiReachable, gateway ->
isChatTransportReady(
apiClientPresent = client != null,
apiReachable = apiReachable,
gatewayAvailability = gateway,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/**
@@ -933,7 +999,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
endpoint != null &&
Connection.isAutoManagedDashboardUrl(connection.dashboardUrl, connection.apiServerUrl) ->
endpoint.dashboard?.url
?: Connection.deriveDefaultDashboardUrl(endpoint.api.url).orEmpty()
?: endpoint.api?.url?.let(Connection::deriveDefaultDashboardUrl).orEmpty()
else -> connection.resolvedDashboardUrl
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
@@ -1164,6 +1230,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
*/
val effectiveSessionProfileName: StateFlow<String?>
get() = profileController.effectiveSessionProfileName
val effectiveDisplayProfile: StateFlow<Profile?>
get() = profileController.effectiveDisplayProfile
fun refreshDashboardProfiles() = profileController.refreshDashboardProfiles()
@@ -1319,20 +1387,28 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val networkStatus: StateFlow<ConnectivityObserver.Status> = connectivityObserver.observe()
.stateIn(viewModelScope, SharingStarted.Eagerly, ConnectivityObserver.Status.Available)
private val connectionHealthStatus: StateFlow<ConnectionStatusSnapshot?> = combine(
private val connectionHealthInputs = combine(
activeConnection,
relayRowState,
apiServerHealth,
relayServerHealth,
networkStatus,
) { activeConnection, relayRow, apiHealth, relayHealth, network ->
ConnectionHealthInputs(activeConnection, relayRow, apiHealth, relayHealth, network)
}
private val connectionHealthStatus: StateFlow<ConnectionStatusSnapshot?> = combine(
connectionHealthInputs,
gatewayAvailability,
) { inputs, gateway ->
buildGlobalConnectionStatus(
handoff = null,
activeConnection = activeConnection,
relayRow = relayRow,
apiHealth = apiHealth,
relayHealth = relayHealth,
network = network,
activeConnection = inputs.connection,
relayRow = inputs.relayRow,
apiHealth = inputs.apiHealth,
relayHealth = inputs.relayHealth,
network = inputs.network,
gatewayAvailability = gateway,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
@@ -2107,6 +2183,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun switchConnection(connectionId: String): Job =
connectionSwitchCoordinator.switchConnection(connectionId)
/** `null` keeps the recommended restore-last-used startup behavior. */
fun setStartupConnection(connectionId: String?) {
viewModelScope.launch {
connectionStore.setStartupConnection(connectionId)
}
}
/**
* Multi-connection: RelayApp calls this at composition time with a
* callback that tears down [ChatViewModel]'s in-flight stream. The
@@ -2199,10 +2282,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
apiHealth: HealthStatus,
relayHealth: HealthStatus,
network: ConnectivityObserver.Status,
gatewayAvailability: GatewayAvailability,
): ConnectionStatusSnapshot? {
handoff?.let { return it.asConnectionStatusSnapshot() }
if (activeConnection == null || activeConnection.apiServerUrl.isBlank()) {
if (!hasConfiguredHermesConnection(activeConnection)) {
return ConnectionStatusSnapshot(
title = ctx.getString(R.string.conn_status_no_hermes),
actionLabel = ctx.getString(R.string.conn_label_connect),
@@ -2240,14 +2324,55 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
route = route,
)
val dashboardConfigured = activeConnection?.capabilities?.dashboardGatewayConfigured == true
val apiConfigured = activeConnection?.capabilities?.apiServerConfigured == true
if (
dashboardConfigured &&
gatewayAvailability == GatewayAvailability.SignInRequired &&
(!apiConfigured || apiHealth != HealthStatus.Reachable)
) {
return ConnectionStatusSnapshot(
title = ctx.getString(R.string.cw_dashboard_sign_in_required),
actionLabel = ctx.getString(R.string.conn_label_connections),
tone = ConnectionStatusTone.Warning,
entries = listOf(
ConnectionHandoffTraceEntry(
label = ctx.getString(R.string.cw_dashboard),
detail = ctx.getString(R.string.cw_sign_in_hint),
),
),
)
}
if (
dashboardConfigured &&
gatewayAvailability == GatewayAvailability.Unreachable &&
(!apiConfigured || apiHealth != HealthStatus.Reachable)
) {
return ConnectionStatusSnapshot(
title = ctx.getString(R.string.cw_dashboard_not_reachable),
actionLabel = ctx.getString(R.string.conn_label_connections),
tone = ConnectionStatusTone.Warning,
entries = listOf(
ConnectionHandoffTraceEntry(
label = ctx.getString(R.string.cw_dashboard),
detail = ctx.getString(R.string.conn_detail_chat_unavailable),
),
),
)
}
return when {
apiHealth == HealthStatus.Unreachable -> {
apiConfigured &&
apiHealth == HealthStatus.Unreachable &&
gatewayAvailability != GatewayAvailability.Ready -> {
// Diagnose, don't just report: for a single-route connection
// the most likely cause is "phone left the server's network
// and there's no remote route to roam to" — say so and point
// at the fix. Multi-route connections already roam; tell the
// user every route was tried instead.
val routeCount = activeConnection.routeCandidates.size
val routeCount = activeConnection?.routeCandidates.orEmpty().size
val routesEntry = if (routeCount <= 1) {
ConnectionHandoffTraceEntry(
label = ctx.getString(R.string.conn_label_routes),
@@ -2651,9 +2776,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
/**
* Remove a placeholder [Connection] created by [beginAddConnection]
* when the user cancels before a successful pair. No-op when the
* connection has been paired (pairedAt != null) or already has a
* non-empty URL — i.e. the pair partially succeeded and the record
* is now real. Also no-op when [connectionId] isn't found.
* connection has been paired (pairedAt != null) or its placeholder label
* has already been promoted. A Dashboard-only connection can legitimately
* remain unpaired, so pairedAt alone must never classify it as disposable.
* Also no-op when [connectionId] isn't found.
*
* Called from the Pair route's onCancel handler. Safe to call on
* every cancel without checking state; the internal guard handles
@@ -2663,6 +2789,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val existing = connectionStore.connections.value.firstOrNull { it.id == connectionId }
?: return
if (existing.pairedAt != null) return
// Dashboard-only connections intentionally have no Relay pairing, so
// pairedAt == null no longer identifies a disposable add-flow row.
// Only the explicit label written by beginAddConnection is safe to
// treat as a placeholder. This still removes partially-applied Relay
// setup rows because their label is promoted only after auth succeeds.
if (existing.label != PLACEHOLDER_LABEL) return
// An unpaired connection with a populated API URL means the
// scan hit applyPairingPayload (which writes the URL) but the
// handshake didn't complete to Paired. We still remove it —
@@ -4133,6 +4265,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun applyPairingPayload(
payload: com.hermesandroid.relay.ui.components.HermesPairingPayload,
ttlSeconds: Long,
preserveStandardConfig: Boolean = false,
) {
android.util.Log.i(
"ConnectionVM",
@@ -4155,23 +4288,51 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
authManager.setPendingGrants(relay.grants)
}
authManager.setPendingTtlSeconds(ttlSeconds)
// ADR 24 — stage the endpoint-candidate list so AuthManager persists
// it under the paired device's id once `auth.ok` lands. `endpoints`
// is always non-null + non-empty coming out of `parseHermesPairingQr`
// (v1/v2 QRs get a synthesized priority-0 candidate); we pass
// through whatever v3 emitted verbatim.
authManager.setPendingEndpoints(payload.endpoints)
val existingStandardRoutes = if (preserveStandardConfig) {
activeConnection.value?.routeCandidates.orEmpty()
} else {
emptyList()
}
val endpointsToPersist = if (preserveStandardConfig) {
mergeRelayTransportIntoStandardRoutes(
standardRoutes = existingStandardRoutes,
relayRoutes = payload.endpoints.orEmpty(),
)
} else {
payload.endpoints.orEmpty()
}
// General pairing stages the QR candidates for auth.ok persistence.
// Relay-only pairing deliberately leaves AuthManager's pending
// standard endpoints untouched; the merged connection routes are
// pre-persisted below without replacing an in-flight standard setup.
if (!preserveStandardConfig) {
authManager.setPendingEndpoints(payload.endpoints)
}
viewModelScope.launch {
// API side — always present in any QR.
updateApiServerUrl(payload.serverUrl)
if (payload.key.isNotBlank()) {
updateApiKey(payload.key)
// A connection-scoped Relay add must not replace the already-saved
// Dashboard/API identity. New-connection and general QR flows keep
// the historical behavior of applying the QR's standard surface.
if (!preserveStandardConfig) {
updateApiServerUrl(payload.serverUrl)
if (payload.key.isNotBlank()) {
updateApiKey(payload.key)
}
}
// Relay side — only when the QR carried a relay block.
payload.relay?.let { relay ->
updateRelayUrl(relay.url)
if (preserveStandardConfig) {
// updateRelayUrl() rebuilds standard candidates from the
// current API URL. Relay-only pairing must not do that.
_relayUrl.value = relay.url.trim()
_relayServerHealth.value = HealthStatus.Probing
getApplication<Application>().relayDataStore.edit { preferences ->
preferences[KEY_RELAY_URL] = relay.url.trim()
}
} else {
updateRelayUrl(relay.url)
}
if (relay.url.startsWith("ws://")) {
setInsecureMode(true)
}
@@ -4181,7 +4342,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// no-LAN pair, waiting until auth.ok means the socket tries the
// LAN relay URL from the QR, never receives auth.ok, and therefore
// never stores the Tailscale fallback it needed to connect.
payload.endpoints?.takeIf { it.isNotEmpty() }?.let { endpoints ->
endpointsToPersist.takeIf { it.isNotEmpty() }?.let { endpoints ->
try {
val ctx = getApplication<Application>()
val deviceId = authManager.getOrCreateDeviceId()
@@ -4235,7 +4396,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// (priority-0) if the payload has endpoints but none
// match exactly.
val matched = payload.endpoints?.firstOrNull {
it.relay.url == relayUrl
it.relay?.url == relayUrl
} ?: payload.endpoints?.firstOrNull()
val autoReason = when (matched?.role?.lowercase()) {
"lan" -> "lan_only"
@@ -4272,37 +4433,48 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
.firstOrNull { it.id == activeId }
if (current != null) {
val newRelayUrl = payload.relay?.url ?: current.relayUrl
val payloadDashboardUrl = payload.dashboardUrl
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
val newDashboardUrl = payloadDashboardUrl
?: if (
Connection.isAutoManagedDashboardUrl(current.dashboardUrl, current.apiServerUrl)
) {
Connection.deriveDefaultDashboardUrl(payload.serverUrl)
} else {
current.dashboardUrl
}
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
current.relayUrl != newRelayUrl ||
current.dashboardUrl != newDashboardUrl ||
current.routeCandidates != payload.endpoints.orEmpty()
if (needsUpdate) {
connectionStore.updateConnection(
current.copy(
apiServerUrl = payload.serverUrl,
relayUrl = newRelayUrl,
dashboardUrl = newDashboardUrl,
routeCandidates = payload.endpoints.orEmpty(),
preferredRouteRole = current.preferredRouteRole
?.takeIf { preferred ->
payload.endpoints.orEmpty().any {
it.role.equals(preferred, ignoreCase = true)
}
},
)
if (preserveStandardConfig) {
val preserved = preserveStandardConnectionWhileApplyingRelay(
current = current,
relayUrl = newRelayUrl,
relayRoutes = payload.endpoints.orEmpty(),
)
if (preserved != current) {
connectionStore.updateConnection(preserved)
}
} else {
val payloadDashboardUrl = payload.dashboardUrl
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
val newDashboardUrl = payloadDashboardUrl
?: if (
Connection.isAutoManagedDashboardUrl(current.dashboardUrl, current.apiServerUrl)
) {
Connection.deriveDefaultDashboardUrl(payload.serverUrl)
} else {
current.dashboardUrl
}
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
current.relayUrl != newRelayUrl ||
current.dashboardUrl != newDashboardUrl ||
current.routeCandidates != payload.endpoints.orEmpty()
if (needsUpdate) {
connectionStore.updateConnection(
current.copy(
apiServerUrl = payload.serverUrl,
relayUrl = newRelayUrl,
dashboardUrl = newDashboardUrl,
routeCandidates = payload.endpoints.orEmpty(),
preferredRouteRole = current.preferredRouteRole
?.takeIf { preferred ->
payload.endpoints.orEmpty().any {
it.role.equals(preferred, ignoreCase = true)
}
},
)
)
}
}
}
}
@@ -4316,7 +4488,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
"applyPairingPayload: disconnecting old relay + connecting to ${relay.url}"
)
disconnectRelay()
connectRelay(relay.url)
if (preserveStandardConfig) {
// connectRelay(url) also rebuilds standard candidates.
connectRelayInternal(relay.url)
} else {
connectRelay(relay.url)
}
} ?: android.util.Log.w(
"ConnectionVM",
"applyPairingPayload: NO relay block in QR — relay/session will NOT pair"
@@ -4397,6 +4574,135 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val remoteRouteConfigured: Boolean = false,
)
/** Dashboard-first setup probe used by the normal connection wizard. */
data class DashboardSetupResult(
val ok: Boolean,
val dashboardUrl: String,
val message: String,
val signInRequired: Boolean = false,
val authenticated: Boolean = false,
val voiceAvailability: StandardVoiceAvailability = StandardVoiceAvailability.Unknown,
)
/**
* Inspect an upstream Hermes Dashboard/Gateway without persisting it.
* API and Relay are deliberately absent: they are optional surfaces and
* belong to the wizard's Advanced path.
*/
fun probeHermesDashboard(
address: String,
onResult: (DashboardSetupResult) -> Unit,
) {
val dashboardUrl = Connection.normalizeApiUrlInput(
address,
defaultPort = Connection.DEFAULT_DASHBOARD_PORT,
)
if (dashboardUrl.isBlank()) {
onResult(
DashboardSetupResult(
ok = false,
dashboardUrl = "",
message = "Enter a Hermes address",
),
)
return
}
viewModelScope.launch {
val client = upstreamTransport.dashboardClientForActive(dashboardUrl)
try {
val statusResult = client.getStatus()
val status = statusResult.getOrNull()
if (status == null) {
onResult(
DashboardSetupResult(
ok = false,
dashboardUrl = dashboardUrl,
message = statusResult.exceptionOrNull()?.message
?: "Hermes was not found at this address",
),
)
return@launch
}
val session = if (status.authRequired) {
client.currentSession().getOrNull()
} else {
null
}
val authenticated = !status.authRequired || session?.authenticated == true
val voice = when {
!authenticated -> StandardVoiceAvailability.SignInRequired
client.audioRoutesPresent() -> StandardVoiceAvailability.Ready
else -> StandardVoiceAvailability.Unsupported
}
onResult(
DashboardSetupResult(
ok = true,
dashboardUrl = dashboardUrl,
message = status.message ?: "Hermes is ready",
signInRequired = status.authRequired && !authenticated,
authenticated = authenticated,
voiceAvailability = voice,
),
)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
onResult(
DashboardSetupResult(
ok = false,
dashboardUrl = dashboardUrl,
message = e.message ?: "Hermes was not found at this address",
),
)
} finally {
client.shutdown()
}
}
}
/** Persist a Dashboard/Gateway-only standard Hermes connection. */
fun saveDashboardConnection(
dashboardUrl: String,
discoveredHostname: String? = null,
onComplete: (Result<Unit>) -> Unit,
) {
val normalized = Connection.normalizeApiUrlInput(
dashboardUrl,
defaultPort = Connection.DEFAULT_DASHBOARD_PORT,
)
if (normalized.isBlank()) {
onComplete(Result.failure(IllegalArgumentException("Hermes address is required")))
return
}
viewModelScope.launch {
runCatching {
ensureActiveConnectionForSetup()
val activeId = connectionStore.activeConnectionId.value
?: error("No active connection")
val current = connectionStore.connections.value
.firstOrNull { it.id == activeId }
?: error("Active connection is missing")
val primaryHost = Connection.extractDefaultLabel(normalized)
val nextLabel = if (current.label == PLACEHOLDER_LABEL || current.label.isBlank()) {
discoveredHostname?.trim()?.takeIf { it.isNotBlank() } ?: primaryHost
} else {
Connection.chooseDiscoveredLabel(
currentLabel = current.label,
primaryHost = primaryHost,
discoveredHostname = discoveredHostname,
)
}
connectionStore.updateConnection(
current.copy(
label = nextLabel,
dashboardUrl = normalized,
),
)
probeStandardVoice()
}.also(onComplete)
}
}
fun recordDashboardStatus(
status: DashboardStatus?,
session: DashboardAuthSession? = null,
@@ -5116,7 +5422,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
* Add or replace an extra fallback route on the active connection — the
* standard path's manual equivalent of a v3 pairing QR's `endpoints`
* array. The primary route (priority 0) mirrors the connection's main
* API URL and is edited through the URL fields / wizard, never here.
* Dashboard/Gateway route and is edited through the connection detail,
* never here.
*
* Legacy candidate sources (per-device PairingPreferences from old QR
* pairings, or a bare single-URL config) are seeded onto the connection
@@ -5143,13 +5450,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
onResult("No active connection")
return@launch
}
// Accept bare hosts/IPs — http:// is assumed (see
// [Connection.normalizeApiUrlInput]); the port defaults to 8642
// downstream in [Connection.endpointCandidateFromApiUrl].
val trimmedUrl = Connection.normalizeApiUrlInput(apiUrl)
// Accept bare hosts/IPs — http:// is assumed and the standard
// Dashboard/Gateway port defaults to 9119.
val trimmedUrl = Connection.normalizeDashboardUrlInput(apiUrl)
val existing = seedRouteCandidates(current)
if (existing.isEmpty()) {
onResult("Set the connection's API server URL first")
onResult("Set the connection's Dashboard/Gateway URL first")
return@launch
}
val withoutOriginal = if (original != null) {
@@ -5157,23 +5463,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
} else {
existing
}
val candidate = Connection.endpointCandidateFromApiUrl(
val candidate = Connection.endpointCandidateFromDashboardUrl(
role = role.trim().ifBlank { Connection.inferRouteRole(trimmedUrl) },
priority = original?.priority
?: ((withoutOriginal.maxOfOrNull { it.priority } ?: 0) + 1),
apiServerUrl = trimmedUrl,
relayUrl = Connection.deriveDefaultRelayUrl(trimmedUrl).orEmpty(),
dashboardUrl = trimmedUrl,
)
if (candidate == null) {
onResult(
"Enter the API server URL — e.g. 100.64.0.1 or " +
"http://host:8642 (http/https only; port defaults to 8642)",
"Enter the Dashboard/Gateway host — e.g. 100.64.0.1 or " +
"http://host:9119 (http/https only; port defaults to 9119)",
)
return@launch
}
val collision = withoutOriginal.firstOrNull {
it.api.host.equals(candidate.api.host, ignoreCase = true) &&
it.api.port == candidate.api.port
it.routeAuthority() == candidate.routeAuthority()
}
if (collision != null) {
onResult(
@@ -5255,17 +5559,22 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
PairingPreferences.getDeviceEndpoints(getApplication(), deviceId).first()
}.getOrDefault(emptyList())
if (fromPairing.isNotEmpty()) return fromPairing
val apiUrl = current.apiServerUrl.takeIf { it.isNotBlank() } ?: return emptyList()
return Connection.buildRouteCandidates(
apiServerUrl = apiUrl,
relayUrl = current.relayUrl,
val dashboardUrl = current.resolvedDashboardUrl.takeIf { it.isNotBlank() }
?: return emptyList()
return listOfNotNull(
Connection.endpointCandidateFromDashboardUrl(
role = Connection.inferRouteRole(dashboardUrl),
priority = 0,
dashboardUrl = dashboardUrl,
apiServerUrl = current.apiServerUrl.takeIf { it.isNotBlank() },
relayUrl = current.relayUrl.takeIf { it.isNotBlank() },
),
)
}
private fun EndpointCandidate.sameRouteAs(other: EndpointCandidate): Boolean =
role.equals(other.role, ignoreCase = true) &&
api.host.equals(other.api.host, ignoreCase = true) &&
api.port == other.api.port
routeAuthority() == other.routeAuthority()
/**
* Sticky route policy — the Routes card's "Prefer this route". Persists
@@ -5411,7 +5720,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
* is unavailable.
*/
suspend fun lookupEndpointPin(candidate: com.hermesandroid.relay.data.EndpointCandidate): String? {
val hostPort = "${candidate.api.host.lowercase()}:${candidate.api.port}"
val hostPort = candidate.routeAuthority() ?: return null
val pins = PairingPreferences.getTofuPins(getApplication())
return pins[hostPort]
}
@@ -0,0 +1,169 @@
package com.hermesandroid.relay.viewmodel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** Frame-paced publication cadence for streaming text. */
internal const val STREAM_DELTA_FRAME_MS = 16L
private const val STREAM_DELTA_MIN_CHARS_PER_FRAME = 8
private const val STREAM_DELTA_MAX_CHARS_PER_FRAME = 48
private const val STREAM_DELTA_TARGET_DRAIN_FRAMES = 8
private enum class StreamDeltaKind {
TEXT,
THINKING,
}
private data class PendingStreamDelta(
val kind: StreamDeltaKind,
val content: StringBuilder,
)
/**
* Main-thread-confined stream-delta frame pacer.
*
* Providers often deliver many token events in one scheduler burst, followed
* by a network gap. Publishing that burst as one Compose state update makes
* text appear in large steps even when rendering itself is fast. This buffer
* drains a bounded slice every display-sized interval instead. The slice grows
* with backlog, keeping presentation close to real time without returning to
* hundreds of full-message republishes per second.
*
* Adjacent deltas of the same kind retain their bytes and their ordering
* relative to thinking/text transitions. Lifecycle boundaries call [flushNow]
* so no buffered content can arrive after a tool event, completion,
* cancellation, or error has settled the message.
*/
internal class StreamDeltaCoalescer(
private val scope: CoroutineScope,
private val onTextDelta: (String) -> Unit,
private val onThinkingDelta: (String) -> Unit,
private val frameMs: Long = STREAM_DELTA_FRAME_MS,
) {
private val pending = mutableListOf<PendingStreamDelta>()
private var flushJob: Job? = null
init {
require(frameMs >= 0L) { "frameMs must be non-negative" }
}
fun appendText(delta: String) {
enqueue(StreamDeltaKind.TEXT, delta)
}
fun appendThinking(delta: String) {
enqueue(StreamDeltaKind.THINKING, delta)
}
fun flushNow() {
flushJob?.cancel()
flushJob = null
flushPending()
}
fun discard() {
flushJob?.cancel()
flushJob = null
pending.clear()
}
private fun enqueue(kind: StreamDeltaKind, delta: String) {
if (delta.isEmpty()) return
val tail = pending.lastOrNull()
if (tail?.kind == kind) {
tail.content.append(delta)
} else {
pending += PendingStreamDelta(kind, StringBuilder(delta))
}
scheduleFrame()
}
private fun scheduleFrame() {
if (flushJob != null || pending.isEmpty()) return
flushJob = scope.launch {
delay(frameMs)
flushJob = null
publishFrame()
scheduleFrame()
}
}
private fun publishFrame() {
if (pending.isEmpty()) return
var remainingBudget = streamDeltaFrameBudget(
pending.sumOf { it.content.length },
)
val frame = mutableListOf<Pair<StreamDeltaKind, String>>()
while (remainingBudget > 0 && pending.isNotEmpty()) {
val head = pending.first()
val requestedLength = minOf(remainingBudget, head.content.length)
val safeLength = head.content.codePointSafePrefixLength(requestedLength)
if (safeLength == 0) break
val content = head.content.substring(0, safeLength)
head.content.delete(0, safeLength)
remainingBudget -= safeLength
if (head.content.isEmpty()) pending.removeAt(0)
val previous = frame.lastOrNull()
if (previous?.first == head.kind) {
frame[frame.lastIndex] = head.kind to (previous.second + content)
} else {
frame += head.kind to content
}
}
publish(frame)
}
private fun flushPending() {
if (pending.isEmpty()) return
// Copy before invoking callbacks so a callback that indirectly queues
// more work starts a fresh window instead of mutating this drain.
val batch = pending.map { it.kind to it.content.toString() }
pending.clear()
publish(batch)
}
private fun publish(batch: List<Pair<StreamDeltaKind, String>>) {
batch.forEach { (kind, content) ->
when (kind) {
StreamDeltaKind.TEXT -> onTextDelta(content)
StreamDeltaKind.THINKING -> onThinkingDelta(content)
}
}
}
}
internal fun streamDeltaFrameBudget(pendingChars: Int): Int {
if (pendingChars <= 0) return 0
val adaptiveBudget =
(pendingChars + STREAM_DELTA_TARGET_DRAIN_FRAMES - 1) /
STREAM_DELTA_TARGET_DRAIN_FRAMES
return adaptiveBudget
.coerceIn(STREAM_DELTA_MIN_CHARS_PER_FRAME, STREAM_DELTA_MAX_CHARS_PER_FRAME)
.coerceAtMost(pendingChars)
}
private fun StringBuilder.codePointSafePrefixLength(requestedLength: Int): Int {
if (requestedLength <= 0) return 0
if (requestedLength >= length) return length
return if (
Character.isHighSurrogate(this[requestedLength - 1]) &&
Character.isLowSurrogate(this[requestedLength])
) {
requestedLength - 1
} else {
requestedLength
}
}
@@ -132,6 +132,19 @@ class ProfileController(
AgentDisplay.effectiveSessionProfileName(selected?.name, serverDefault?.active)
}.stateIn(scope, SharingStarted.Eagerly, null)
/** Display identity resolved through the same sticky server default used by session routing. */
val effectiveDisplayProfile: StateFlow<Profile?> = combine(
selectedProfile,
agentProfiles,
serverDefaultProfileScope,
) { selected, profiles, serverDefault ->
AgentDisplay.effectiveDisplayProfile(
selectedProfile = selected,
profiles = profiles,
serverDefaultProfileName = serverDefault?.active,
)
}.stateIn(scope, SharingStarted.Eagerly, null)
/**
* True once the active connection's persisted profile selection has SETTLED
* — i.e. profile-scoped reads (session drawer, transcript restore, voice
Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

File diff suppressed because it is too large Load Diff
+166 -15
View File
@@ -14,14 +14,16 @@
<string name="onboarding_next">下一步</string>
<string name="onboarding_connect">连接</string>
<string name="onboarding_welcome_title">Hermes-Relay Android 版</string>
<string name="onboarding_welcome_description">在手机上和 Hermes 对话,并管理你的仪表盘。</string>
<string name="onboarding_get_started">开始使用</string>
<string name="onboarding_step_count">第 %1$d 步,共 %2$d 步</string>
<string name="onboarding_welcome_title">Hermes,\n尽在掌中</string>
<string name="onboarding_welcome_description">与你自己的 Hermes 聊天、管理\n并使用语音。</string>
<string name="onboarding_welcome_badge">欢迎</string>
<string name="onboarding_hermes_logo">Hermes 标志</string>
<string name="onboarding_chat_manage_label">聊天与管理</string>
<string name="onboarding_chat_manage_description">连接正在运行的 Hermes 仪表盘和 API。无需安装 Relay,也无需配对。</string>
<string name="onboarding_power_tools_label">高级工具</string>
<string name="onboarding_power_tools_description">需要终端、Bridge、Relay 会话或频道授权时,再添加 Hermes-Relay。</string>
<string name="onboarding_chat_manage_label">仪表盘优先</string>
<string name="onboarding_chat_manage_description">无需 Relay</string>
<string name="onboarding_power_tools_label">高级工具稍后添加</string>
<string name="onboarding_power_tools_description">需要终端或 Bridge 时\n再配对 Relay</string>
<string name="onboarding_setup_guide_hint">设置指南里提供了复制即用的命令,方便你在电脑或服务器上启动 Hermes。</string>
<string name="onboarding_setup_guide">安装指南</string>
<string name="onboarding_hermes_docs">Hermes 文档</string>
@@ -274,6 +276,39 @@
<!-- 连接向导 —— Connect 页核心文案 -->
<string name="cw_connect_to_hermes">连接 Hermes</string>
<string name="cw_nearby_description">我们将在此网络中查找 Hermes。仪表板和网关提供聊天、管理和语音的标准连接。</string>
<string name="cw_before_connecting">连接之前</string>
<string name="cw_connect_step_server_title">在电脑上启动 Hermes</string>
<string name="cw_connect_step_server_body">在 Hermes 电脑上启动仪表板。首次通过局域网访问时,请使用下方的设置指南,让手机能够访问并启用登录。</string>
<string name="cw_connect_step_network_title">让服务器可访问</string>
<string name="cw_connect_step_network_body">使用同一个 Wi-Fi,或将两台设备都连接到 Tailscale。</string>
<string name="cw_connect_step_phone_title">从此手机连接</string>
<string name="cw_connect_step_phone_body">在下方搜索或输入仪表板地址,例如 192.168.1.10:9119。如有提示请登录;无需 API 密钥。</string>
<string name="cw_nearby_searching">正在搜索附近设备…</string>
<string name="cw_nearby_searching_hint">请确保手机与 Hermes 服务器位于同一网络。</string>
<string name="cw_nearby_heading">附近的 Hermes</string>
<string name="cw_nearby_empty">未找到 Hermes 服务器</string>
<string name="cw_nearby_failed">无法搜索此网络</string>
<string name="cw_nearby_empty_hint">请确认 Hermes 正在运行,然后重新搜索或输入其地址。</string>
<string name="cw_nearby_search_again">重新搜索</string>
<string name="cw_nearby_enter_address">改为输入地址</string>
<string name="cw_other_connection_methods">其他连接方式</string>
<string name="cw_manual_hermes_title">输入 Hermes 地址</string>
<string name="cw_manual_hermes_description">请输入你在浏览器中打开的仪表板地址。如果此手机无法打开,请启动 hermes dashboard 并检查 Wi-Fi 或 Tailscale。</string>
<string name="cw_hermes_address">Hermes 地址</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 或 hermes.example.com</string>
<string name="cw_hermes_address_hint">无需 API 密钥。未指定端口时使用仪表板端口 9119。</string>
<string name="cw_find_hermes">查找 Hermes</string>
<string name="cw_hermes_found">已找到 Hermes</string>
<string name="cw_ready_to_connect">可以连接</string>
<string name="cw_ready">就绪</string>
<string name="cw_available_after_signin">登录后可用</string>
<string name="cw_unavailable_server">此服务器不支持</string>
<string name="cw_could_not_verify">无法验证</string>
<string name="cw_choose_another">选择其他服务器</string>
<string name="cw_sign_in_to_hermes">登录 Hermes</string>
<string name="cw_semantics_hermes_available">Hermes 可用</string>
<string name="cw_semantics_capability">%1$s:%2$s</string>
<string name="cw_connect_description">先在主机上启动 Hermes API 或仪表盘,再在此连接。Relay 配对是可选的,仅在需要终端、Bridge、Relay 会话或频道授权时才使用。</string>
<string name="cw_try_demo">体验演示</string>
<string name="cw_try_demo_subtitle">离线探索——无需服务器。</string>
@@ -320,6 +355,11 @@
<string name="cw_scan_message">正在扫描本局域网寻找 Hermes 仪表盘/API…</string>
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥对聊天是可选的。</string>
<string name="cw_pair_relay_section">配对 Relay(可选)</string>
<string name="cw_pair_relay_for">为 %1$s 配对 Relay</string>
<string name="cw_pair_relay_scoped_desc">为这个已保存的 Hermes 连接添加可选的 Relay 扩展。这不会添加或替换服务器。</string>
<string name="cw_current_connection">当前连接</string>
<string name="cw_current_hermes_connection">当前 HERMES 连接</string>
<string name="cw_existing_connection_unchanged">Chat、Manage、Voice 和已保存的路由均保持不变。</string>
<string name="cw_pair_relay_section_desc">已经在运行 Relay 插件?在此配对以启用终端、Bridge 和频道授权。</string>
<string name="cw_pair_relay_url_label">Relay 地址</string>
<string name="cw_pair_relay_code_label">配对码</string>
@@ -441,7 +481,7 @@
<string name="endpoints_public">公网</string>
<string name="endpoints_custom">自定义</string>
<string name="endpoints_route_name">路由名称</string>
<string name="endpoints_api_url_host">API 服务器地址或主机名</string>
<string name="endpoints_api_url_host">Dashboard/Gateway 地址或主机名</string>
<string name="endpoints_saving">保存中…</string>
<string name="endpoints_save">保存</string>
<string name="endpoints_close">关闭</string>
@@ -580,7 +620,7 @@
<string name="settings_hermes_management">Hermes 管理</string>
<string name="settings_hermes_management_desc">仪表盘功能:技能、定时任务、MCP、配置文件、模型</string>
<string name="settings_chat">聊天</string>
<string name="settings_chat_desc">API 聊天行为、端点、工具显示、消息长度</string>
<string name="settings_chat_desc">聊天行为、Gateway、API 回退、工具显示、消息长度</string>
<string name="settings_voice_mode">语音模式</string>
<string name="settings_voice_mode_desc">仪表盘语音、实时 Relay 选项、提供商</string>
<string name="settings_threads">话题</string>
@@ -737,6 +777,8 @@
<string name="conn_voice_relay">Relay</string>
<string name="conn_voice_optional">可选</string>
<string name="conn_api_label">API</string>
<string name="conn_chat_label">聊天</string>
<string name="conn_manage_label">管理</string>
<string name="conn_dashboard_label">仪表盘</string>
<string name="conn_voice_label">语音</string>
<string name="conn_relay_label">Relay</string>
@@ -776,6 +818,10 @@
<string name="detail_inactive_body">此连接未激活。切换到它以查看实时状态并管理路由、高级设置和 Relay 会话。</string>
<string name="detail_switch_to">切换到此连接</string>
<string name="detail_rename_title">重命名连接</string>
<string name="detail_dashboard_primary">Dashboard 为主要连接方式</string>
<string name="detail_core_ready">Hermes 核心已就绪</string>
<string name="detail_core_configured">Hermes 核心已配置</string>
<string name="detail_overview_summary">Chat、Manage 和 Voice 使用标准 Hermes。Relay 是为高级设备功能提供的可选扩展。</string>
<string name="detail_tab_overview">概览</string>
<string name="detail_tab_routes">路由</string>
<string name="detail_tab_advanced">高级</string>
@@ -1054,6 +1100,9 @@
<string name="appearance_language_desc">选择 Hermes-Relay 使用的语言。跟随系统会使用设备的语言设置。</string>
<string name="appearance_language_system">跟随系统</string>
<string name="appearance_language_english">English</string>
<string name="appearance_language_german">Deutsch</string>
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="appearance_appearance">外观</string>
@@ -2026,7 +2075,7 @@
<string name="active_section_allow_plain_connections">允许明文连接</string>
<string name="active_section_api_hermes_voice_reachable">API 可达——Hermes 语音已配置</string>
<string name="active_section_api_key_already_set">API 密钥已设置</string>
<string name="active_section_api_key_needed_hint">如果 API 服务器需要密钥,请在此输入。</string>
<string name="active_section_api_key_needed_hint">使用在 Hermes 服务器上创建的 API_SERVER_KEY。此密钥并非由应用提供。</string>
<string name="active_section_api_key_not_configured">API 密钥未配置</string>
<string name="active_section_api_key_optional">API 密钥(可选)</string>
<string name="active_section_api_key_stored_hint">密钥已安全存储。</string>
@@ -2136,6 +2185,7 @@
<string name="conn_info_api_server_title">API 服务器</string>
<string name="conn_info_approvals_off">审批已关闭</string>
<string name="conn_info_auth">认证</string>
<string name="conn_info_relay_auth">Relay 认证</string>
<string name="conn_info_avg_ttft">平均 TTFT</string>
<string name="conn_info_channel_grants">频道授权</string>
<string name="conn_info_checking">检查中…</string>
@@ -2175,6 +2225,7 @@
<string name="conn_info_hermes">Hermes</string>
<string name="conn_info_hostname_hermes">%1$s(Hermes)</string>
<string name="conn_info_hostname_paired">%1$s(已配对)</string>
<string name="conn_info_hostname_relay_paired">%1$s · Relay 已配对</string>
<string name="conn_info_idle_suffix">· 空闲</string>
<string name="conn_info_insecure_mode_allowed">允许不安全模式</string>
<string name="conn_info_inspect_profile">查看 %1$s</string>
@@ -2507,7 +2558,10 @@
<!-- QrPairingScanner -->
<string name="qr_scanner_title">扫描 Hermes QR</string>
<string name="qr_scanner_instruction">扫描 Hermes 设置二维码</string>
<string name="qr_scanner_subtext">询问 Hermes:"生成一个包含我的 API URL 和 API 密钥的二维码。"</string>
<string name="qr_scanner_subtext">扫描 Hermes 设置二维码或 Relay 配对二维码。标准 Dashboard 连接不需要 API 密钥。</string>
<string name="qr_scanner_relay_title">扫描 Relay 二维码</string>
<string name="qr_scanner_relay_instruction">扫描 Relay 配对二维码</string>
<string name="qr_scanner_relay_subtext">在 Hermes 服务器上打开 Relay 配对二维码。现有 Dashboard 连接和路由将保持不变。</string>
<string name="qr_scanner_camera_error">无法在此设备上启动相机。</string>
<string name="qr_scanner_fallback_message">您可以在没有相机的情况下配对。</string>
<string name="qr_scanner_pair_manual">手动配对</string>
@@ -2910,12 +2964,12 @@
<string name="diag_check_voice_relay">语音(Relay)</string>
<string name="diag_check_voice_standard">语音(标准)</string>
<string name="endpoints_pin_title">固定端点</string>
<string name="endpoints_route_editor_desc">编辑路由</string>
<string name="endpoints_route_editor_desc">添加此手机在该网络上应使用的 Dashboard/Gateway 地址。</string>
<string name="endpoints_route_name_placeholder">路由名称</string>
<string name="endpoints_url_host_placeholder">主机</string>
<string name="endpoints_url_supporting_blank">无支持的 URL</string>
<string name="endpoints_url_supporting_enter">输入 URL</string>
<string name="endpoints_url_supporting_preview">预览</string>
<string name="endpoints_url_host_placeholder">100.x.y.z 或 host.ts.net</string>
<string name="endpoints_url_supporting_blank">输入服务器的 Dashboard 地址</string>
<string name="endpoints_url_supporting_enter">使用 http:// 或 https:// Dashboard 地址</string>
<string name="endpoints_url_supporting_preview">将测试:%1$s</string>
<string name="image_viewer_error">保存图片时出错</string>
<string name="image_viewer_failed">保存图片失败</string>
<string name="image_viewer_failed_template">保存图片失败:%s</string>
@@ -3071,4 +3125,101 @@
<string name="tool_output_risk_a11y">,%1$s 输出风险</string>
<string name="tool_output_risk_findings">输出风险发现</string>
<string name="tool_output_risk_redacted">敏感片段已在上游被隐去。</string>
<plurals name="conn_server_count">
<item quantity="other">%1$d 台服务器</item>
</plurals>
<string name="conn_switch">切换</string>
<string name="conn_switching">正在切换…</string>
<string name="conn_switched">使用中</string>
<string name="conn_connecting_to">正在连接到 %1$s…</string>
<string name="conn_last_used_now">刚刚使用过</string>
<string name="conn_last_used_format">上次使用:%1$s</string>
<string name="conn_just_now">刚刚</string>
<string name="conn_dashboard_only_route">仅 Dashboard</string>
<string name="conn_no_routes">未配置路由</string>
<string name="conn_startup_title">应用启动时</string>
<string name="conn_startup_last_used">上次使用</string>
<string name="conn_startup_recommended">推荐</string>
<string name="conn_startup_choose">选择启动连接</string>
<string name="active_section_primary_dashboard">主 Dashboard</string>
<string name="active_section_dashboard_reachable_signed_in">可访问 · 已登录</string>
<string name="active_section_dashboard_reachable">可访问</string>
<string name="active_section_dashboard_not_checked">尚未检查</string>
<string name="active_section_dashboard_unreachable">无法访问</string>
<string name="active_section_no_fallback_routes">尚无备用路由</string>
<string name="active_section_no_fallback_routes_desc">可添加 API 路由,用于直接聊天回退和网络切换。</string>
<string name="active_section_add_api_fallback">添加备用路由</string>
<string name="active_section_security_authentication">身份验证</string>
<string name="active_section_dashboard_session">Dashboard 会话</string>
<string name="active_section_credential_storage">凭据存储</string>
<string name="active_section_encrypted_storage">加密存储</string>
<string name="active_section_no_relay_credential">无 Relay 凭据</string>
<string name="active_section_sign_out_dashboard">退出 Dashboard</string>
<string name="active_section_credentials_encrypted">凭据始终在此设备上加密存储。</string>
<string name="active_section_reachable_badge">可访问</string>
<string name="active_section_unchecked_badge">未检查</string>
<string name="active_section_edit">编辑</string>
<string name="active_section_fallback_routes">备用路由</string>
<string name="active_section_route_selection">路由选择</string>
<string name="active_section_automatic">自动</string>
<string name="active_section_api_access">API 访问</string>
<string name="active_section_core_hermes">Hermes 核心</string>
<string name="active_section_optional_relay">可选 Relay</string>
<string name="active_section_extend_connection">扩展此连接</string>
<string name="active_section_relay_connected_features">Relay 扩展</string>
<string name="active_section_relay_optional_summary">添加 Terminal、Bridge、设备工具、Relay 会话和安全远程路由。</string>
<string name="active_section_view_relay_details">查看 Relay 详情</string>
<string name="active_section_core_unchanged">当前 Hermes 连接保持不变。</string>
<string name="active_section_api_optional_direct">可选,用于直接聊天和集成</string>
<string name="active_section_configure_test">配置并测试</string>
<string name="active_section_relay_optional_bridge">可选的 Bridge 功能和远程访问</string>
<string name="active_section_configure_relay">配置 Relay</string>
<string name="active_section_pair_device_using_code">使用服务器生成的代码配对此设备。</string>
<string name="active_section_enter_pairing_code">输入配对码</string>
<string name="active_section_done">完成</string>
<string name="active_section_connection_behavior">连接行为</string>
<string name="active_section_manual_pairing">手动配对</string>
<string name="active_section_paired">已配对</string>
<string name="active_section_not_paired">未配对</string>
<string name="active_section_transport">传输方式</string>
<string name="active_section_hardware_backed">硬件级保护</string>
<string name="active_section_relay_session">Relay 会话</string>
<string name="active_section_access">访问权限</string>
<string name="active_section_paired_devices">已配对设备</string>
<string name="active_section_device_count">%1$d 台设备</string>
<string name="active_section_session_activity">会话活动</string>
<string name="active_section_last_checked_just_now">刚刚检查过</string>
<string name="active_section_protected">受保护</string>
<string name="active_section_not_encrypted">未加密</string>
<string name="active_section_no_security_issues">未检测到安全问题</string>
<string name="active_section_unencrypted_transport">此连接使用未加密的传输方式</string>
<string name="active_section_actions">操作</string>
<string name="active_section_revoke_relay">撤销 Relay 配对</string>
<string name="active_section_choose_how_phone_reaches_named">此手机如何连接到 %1$s</string>
<string name="active_section_dashboard_home_lan">Dashboard · 家庭局域网</string>
<string name="cw_preparing_connection">正在准备连接…</string>
<string name="cw_preparing_connection_hint">正在设置安全的本地存储,片刻即可完成。</string>
<string name="cw_relay_entry_title">Hermes Relay</string>
<string name="cw_relay_entry_desc">配对 Relay 以添加 Terminal、Bridge、设备工具、Relay 会话和权限。</string>
<string name="cw_relay_pair_qr">配对 Hermes Relay</string>
<string name="cw_relay_pair_qr_desc">扫描 Relay 设置二维码</string>
<string name="cw_relay_enter_code">输入配对码</string>
<string name="cw_dashboard_connected_title">Hermes 已连接</string>
<string name="cw_dashboard_connected_body">Dashboard 和 Gateway 已就绪。你可以开始聊天或打开 Manage。</string>
<string name="cw_continue">继续</string>
<string name="cw_timeline_discovered">已发现 Hermes</string>
<string name="cw_timeline_discovered_detail">已验证 Dashboard 标识和状态端点</string>
<string name="cw_timeline_access">Dashboard 访问</string>
<string name="cw_timeline_access_ready">无需额外登录</string>
<string name="cw_timeline_authenticated">身份验证已确认</string>
<string name="cw_timeline_ready">连接已就绪</string>
<string name="cw_timeline_ready_detail">Chat、Manage 和 Voice 可以使用此 Dashboard</string>
<string name="active_section_optional_api_fallback">可选的直接 API 回退</string>
<string name="active_section_api_not_required">此连接使用 Hermes Dashboard 时不需要配置。</string>
<string name="active_section_where_api_key">从哪里获取此密钥?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY 需要在 Hermes 服务器上创建,并非由此应用提供。仅在启用可选 API 服务器时进行配置,并在此输入相同的值。</string>
<string name="active_section_scan_relay_qr">扫描 Relay 二维码</string>
<string name="active_section_other_relay_methods">其他配对方式</string>
<string name="endpoints_tailscale_setup_hint">两台设备都必须连接 Tailscale,并且 Hermes Dashboard 必须可通过端口 9119 访问。</string>
<string name="endpoints_setup_help">Tailscale 设置帮助</string>
</resources>
File diff suppressed because it is too large Load Diff
+167 -15
View File
@@ -12,14 +12,16 @@
<string name="onboarding_back">Atrás</string>
<string name="onboarding_next">Siguiente</string>
<string name="onboarding_connect">Conectar</string>
<string name="onboarding_welcome_title">Hermes-Relay para Android</string>
<string name="onboarding_welcome_description">Chatea con Hermes y administra tu tablero desde tu teléfono.</string>
<string name="onboarding_get_started">Comenzar</string>
<string name="onboarding_step_count">Paso %1$d de %2$d</string>
<string name="onboarding_welcome_title">Hermes,\nen tu bolsillo</string>
<string name="onboarding_welcome_description">Chatea, administra y usa la voz\ncon tu propio Hermes.</string>
<string name="onboarding_welcome_badge">Bienvenido</string>
<string name="onboarding_hermes_logo">Logotipo de Hermes</string>
<string name="onboarding_chat_manage_label">Chatear y administrar</string>
<string name="onboarding_chat_manage_description">Conéctese a su tablero Hermes en ejecución y a API. No se requiere instalación ni emparejamiento de Relay.</string>
<string name="onboarding_power_tools_label">Herramientas avanzadas</string>
<string name="onboarding_power_tools_description">Agregue Hermes-Relay para sesiones de Terminal, Bridge, relay y concesiones de canales.</string>
<string name="onboarding_chat_manage_label">Primero el panel</string>
<string name="onboarding_chat_manage_description">No requiere Relay</string>
<string name="onboarding_power_tools_label">Herramientas después</string>
<string name="onboarding_power_tools_description">Empareja Relay para usar\nTerminal o Bridge</string>
<string name="onboarding_setup_guide_hint">La guía de configuración tiene comandos copy/paste cuando necesita iniciar Hermes en una computadora o servidor.</string>
<string name="onboarding_setup_guide">Guía de configuración</string>
<string name="onboarding_hermes_docs">Documentos Hermes</string>
@@ -236,6 +238,39 @@
<string name="error_classify_retry">Reintentar</string>
<string name="error_classify_open_settings">Abrir configuración</string>
<string name="cw_connect_to_hermes">Conéctese a Hermes</string>
<string name="cw_nearby_description">Buscaremos Hermes en esta red. El panel y la puerta de enlace proporcionan la conexión estándar para Chat, Administrar y Voz.</string>
<string name="cw_before_connecting">Antes de conectar</string>
<string name="cw_connect_step_server_title">Inicia Hermes en tu ordenador</string>
<string name="cw_connect_step_server_body">Inicia el panel en el ordenador con Hermes. Para el primer acceso por LAN, usa la guía inferior para hacerlo accesible desde el teléfono y habilitar el inicio de sesión.</string>
<string name="cw_connect_step_network_title">Haz que el servidor sea accesible</string>
<string name="cw_connect_step_network_body">Usa la misma red Wi-Fi o conecta ambos dispositivos a Tailscale.</string>
<string name="cw_connect_step_phone_title">Conecta desde este teléfono</string>
<string name="cw_connect_step_phone_body">Busca abajo o introduce la dirección del panel, como 192.168.1.10:9119. Inicia sesión si se solicita; no se necesita una clave de API.</string>
<string name="cw_nearby_searching">Buscando cerca…</string>
<string name="cw_nearby_searching_hint">Mantenga el teléfono en la misma red que el servidor de Hermes.</string>
<string name="cw_nearby_heading">Hermes cercanos</string>
<string name="cw_nearby_empty">No se encontraron servidores de Hermes</string>
<string name="cw_nearby_failed">No se pudo buscar en esta red</string>
<string name="cw_nearby_empty_hint">Compruebe que Hermes esté en ejecución y vuelva a buscar o introduzca su dirección.</string>
<string name="cw_nearby_search_again">Buscar de nuevo</string>
<string name="cw_nearby_enter_address">Introducir la dirección</string>
<string name="cw_other_connection_methods">Otros métodos de conexión</string>
<string name="cw_manual_hermes_title">Introduzca la dirección de Hermes</string>
<string name="cw_manual_hermes_description">Introduce la dirección del panel que abres en el navegador. Si no abre en este teléfono, inicia hermes dashboard y comprueba Wi-Fi o Tailscale.</string>
<string name="cw_hermes_address">Dirección de Hermes</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 o hermes.example.com</string>
<string name="cw_hermes_address_hint">No se necesita una clave de API. Se usa el puerto 9119 si no incluyes uno.</string>
<string name="cw_find_hermes">Buscar Hermes</string>
<string name="cw_hermes_found">Hermes encontrado</string>
<string name="cw_ready_to_connect">Listo para conectar</string>
<string name="cw_ready">Listo</string>
<string name="cw_available_after_signin">Disponible después de iniciar sesión</string>
<string name="cw_unavailable_server">No disponible en este servidor</string>
<string name="cw_could_not_verify">No se pudo verificar</string>
<string name="cw_choose_another">Elegir otro</string>
<string name="cw_sign_in_to_hermes">Iniciar sesión en Hermes</string>
<string name="cw_semantics_hermes_available">Hermes disponible</string>
<string name="cw_semantics_capability">%1$s: %2$s</string>
<string name="cw_connect_description">Inicie Hermes API/dashboard en su host, luego conecte esta aplicación. El emparejamiento de Relay es opcional y solo es necesario para sesiones de Terminal, Bridge, relay y concesiones de canales.</string>
<string name="cw_try_demo">Pruebe la demostración</string>
<string name="cw_try_demo_subtitle">Explora sin conexión: no se necesita servidor.</string>
@@ -397,7 +432,7 @@
<string name="endpoints_public">Público</string>
<string name="endpoints_custom">Costumbre</string>
<string name="endpoints_route_name">Nombre de la ruta</string>
<string name="endpoints_api_url_host">URL o host del servidor API</string>
<string name="endpoints_api_url_host">URL o host de Dashboard/Gateway</string>
<string name="endpoints_saving">Ahorro…</string>
<string name="endpoints_save">Guardar</string>
<string name="endpoints_close">Cerrar</string>
@@ -512,7 +547,7 @@
<string name="settings_hermes_management">Gestión Hermes</string>
<string name="settings_hermes_management_desc">Funciones del panel: habilidades, cron, MCP, perfiles, modelos</string>
<string name="settings_chat">Charlar</string>
<string name="settings_chat_desc">Comportamiento del chat API, puntos finales, visualización de herramientas, longitud del mensaje</string>
<string name="settings_chat_desc">Comportamiento del chat, Gateway, respaldo de API, visualización de herramientas y longitud del mensaje</string>
<string name="settings_voice_mode">Modo de voz</string>
<string name="settings_voice_mode_desc">Panel de voz, opciones relay en tiempo real, proveedores</string>
<string name="settings_threads">Threads</string>
@@ -665,6 +700,8 @@
<string name="conn_voice_relay">Relay</string>
<string name="conn_voice_optional">Opcional</string>
<string name="conn_api_label">API</string>
<string name="conn_chat_label">Chat</string>
<string name="conn_manage_label">Administrar</string>
<string name="conn_dashboard_label">Panel</string>
<string name="conn_voice_label">Voz</string>
<string name="conn_relay_label">Relay</string>
@@ -1851,7 +1888,7 @@
<string name="active_section_allow_plain_connections">Permitir conexiones simples</string>
<string name="active_section_api_hermes_voice_reachable">API accesible: la voz Hermes está configurada</string>
<string name="active_section_api_key_already_set">La clave API ya está configurada</string>
<string name="active_section_api_key_needed_hint">Ingrese una clave si el servidor API la requiere.</string>
<string name="active_section_api_key_needed_hint">Use el API_SERVER_KEY creado en su servidor Hermes. La aplicación no proporciona esta clave.</string>
<string name="active_section_api_key_not_configured">Clave API no configurada</string>
<string name="active_section_api_key_optional">Clave API (opcional)</string>
<string name="active_section_api_key_stored_hint">La clave se almacena de forma segura.</string>
@@ -1959,6 +1996,7 @@
<string name="conn_info_api_server_title">Servidor API</string>
<string name="conn_info_approvals_off">Las aprobaciones están desactivadas</string>
<string name="conn_info_auth">autenticación</string>
<string name="conn_info_relay_auth">Autenticación de Relay</string>
<string name="conn_info_avg_ttft">Promedio TTFT</string>
<string name="conn_info_channel_grants">Subvenciones de canal</string>
<string name="conn_info_checking">De cheques…</string>
@@ -1992,6 +2030,7 @@
<string name="conn_info_hermes">Hermes</string>
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
<string name="conn_info_hostname_paired">%1$s (emparejado)</string>
<string name="conn_info_hostname_relay_paired">%1$s · Relay emparejado</string>
<string name="conn_info_idle_suffix"> · Inactivo</string>
<string name="conn_info_insecure_mode_allowed">Modo inseguro permitido</string>
<string name="conn_info_inspect_profile">Inspeccionar %1$s</string>
@@ -2274,7 +2313,10 @@
<string name="crash_toast_copied">Informe de fallo copiado</string>
<string name="qr_scanner_title">Escanear Hermes QR</string>
<string name="qr_scanner_instruction">Escanear una configuración Hermes QR</string>
<string name="qr_scanner_subtext">Pregúntele a Hermes: "Genere un código QR con mi URL API y mi clave API".</string>
<string name="qr_scanner_subtext">Escanee un QR de configuración de Hermes o de vinculación con Relay. Las conexiones estándar mediante Dashboard no necesitan una clave API.</string>
<string name="qr_scanner_relay_title">Escanear QR de Relay</string>
<string name="qr_scanner_relay_instruction">Escanee un QR de vinculación con Relay</string>
<string name="qr_scanner_relay_subtext">Abra el QR de vinculación con Relay en su servidor Hermes. La conexión de Dashboard y las rutas existentes no cambiarán.</string>
<string name="qr_scanner_camera_error">No se puede iniciar la cámara en este dispositivo.</string>
<string name="qr_scanner_fallback_message">Puedes emparejar sin la cámara.</string>
<string name="qr_scanner_pair_manual">Emparejar manualmente</string>
@@ -2648,12 +2690,12 @@
<string name="diag_check_voice_relay">Voz (Relay)</string>
<string name="diag_check_voice_standard">Voz (Estándar)</string>
<string name="endpoints_pin_title">Puntos finales fijados</string>
<string name="endpoints_route_editor_desc">Editar ruta</string>
<string name="endpoints_route_editor_desc">Añada la dirección de Dashboard/Gateway que debe usar este teléfono en esa red.</string>
<string name="endpoints_route_name_placeholder">Nombre de la ruta</string>
<string name="endpoints_url_host_placeholder">Anfitrión</string>
<string name="endpoints_url_supporting_blank">No hay URL compatibles</string>
<string name="endpoints_url_supporting_enter">Introduce una URL</string>
<string name="endpoints_url_supporting_preview">Avance</string>
<string name="endpoints_url_host_placeholder">100.x.y.z o host.ts.net</string>
<string name="endpoints_url_supporting_blank">Introduzca la dirección de Dashboard del servidor</string>
<string name="endpoints_url_supporting_enter">Use una dirección de Dashboard http:// o https://</string>
<string name="endpoints_url_supporting_preview">Se probará: %1$s</string>
<string name="image_viewer_error">Error al guardar la imagen</string>
<string name="image_viewer_failed">No se pudo guardar la imagen</string>
<string name="image_viewer_failed_template">No se pudo guardar la imagen: %s</string>
@@ -2812,6 +2854,9 @@
<string name="appearance_language_desc">Elige el idioma de Hermes-Relay. La opción predeterminada del sistema sigue la configuración del dispositivo.</string>
<string name="appearance_language_system">Predeterminado del sistema</string>
<string name="appearance_language_english">English</string>
<string name="appearance_language_german">Deutsch</string>
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="chat_profile_history_unavailable">No se pudo acceder al historial de conversaciones del perfil activo. Vuelve a conectarte e inténtalo de nuevo.</string>
@@ -2821,4 +2866,111 @@
<string name="conn_info_profile_offline_desc">Sin conexión; no se puede acceder a este perfil en este momento</string>
<string name="conn_info_profile_online">En línea</string>
<string name="conn_info_profile_online_desc">En línea; la puerta de enlace y los canales de mensajería están en funcionamiento</string>
<string name="cw_pair_relay_for">Vincular Relay con %1$s</string>
<string name="cw_pair_relay_scoped_desc">Añade la extensión opcional Relay a esta conexión Hermes guardada. Esto no añade ni sustituye ningún servidor.</string>
<string name="cw_current_connection">Conexión actual</string>
<string name="cw_current_hermes_connection">CONEXIÓN HERMES ACTUAL</string>
<string name="cw_existing_connection_unchanged">Chat, Manage, Voice y tus rutas guardadas permanecen sin cambios.</string>
<string name="detail_dashboard_primary">Dashboard principal</string>
<string name="detail_core_ready">Núcleo de Hermes listo</string>
<string name="detail_core_configured">Núcleo de Hermes configurado</string>
<string name="detail_overview_summary">Chat, Manage y Voice usan Hermes estándar. Relay es una extensión opcional para funciones avanzadas del dispositivo.</string>
<string name="conn_startup_title">Al iniciar la aplicación</string>
<string name="conn_startup_last_used">Última usada</string>
<string name="conn_startup_recommended">Recomendado</string>
<string name="conn_startup_choose">Elegir conexión de inicio</string>
<plurals name="conn_server_count">
<item quantity="one">%1$d servidor</item>
<item quantity="other">%1$d servidores</item>
</plurals>
<string name="conn_switch">Cambiar</string>
<string name="conn_switching">Cambiando…</string>
<string name="conn_switched">Activa</string>
<string name="conn_connecting_to">Conectando con %1$s…</string>
<string name="conn_last_used_now">Usada por última vez ahora mismo</string>
<string name="conn_last_used_format">Usada por última vez %1$s</string>
<string name="conn_just_now">ahora mismo</string>
<string name="conn_dashboard_only_route">Solo Dashboard</string>
<string name="conn_no_routes">No hay rutas configuradas</string>
<string name="active_section_primary_dashboard">Dashboard principal</string>
<string name="active_section_dashboard_reachable_signed_in">Accesible · Sesión iniciada</string>
<string name="active_section_dashboard_reachable">Accesible</string>
<string name="active_section_dashboard_not_checked">Aún no comprobado</string>
<string name="active_section_dashboard_unreachable">No accesible</string>
<string name="active_section_no_fallback_routes">Aún no hay rutas alternativas</string>
<string name="active_section_no_fallback_routes_desc">Añade una ruta API opcional para chat directo alternativo y cambios de red.</string>
<string name="active_section_add_api_fallback">Añadir ruta alternativa</string>
<string name="active_section_security_authentication">Autenticación</string>
<string name="active_section_dashboard_session">Sesión del Dashboard</string>
<string name="active_section_credential_storage">Almacenamiento de credenciales</string>
<string name="active_section_encrypted_storage">Almacenamiento cifrado</string>
<string name="active_section_no_relay_credential">Sin credencial de Relay</string>
<string name="active_section_sign_out_dashboard">Cerrar sesión del Dashboard</string>
<string name="active_section_credentials_encrypted">Las credenciales permanecen cifradas en este dispositivo.</string>
<string name="active_section_reachable_badge">ACCESIBLE</string>
<string name="active_section_unchecked_badge">SIN COMPROBAR</string>
<string name="active_section_edit">Editar</string>
<string name="active_section_fallback_routes">Rutas alternativas</string>
<string name="active_section_route_selection">Selección de ruta</string>
<string name="active_section_automatic">Automática</string>
<string name="active_section_api_access">Acceso a la API</string>
<string name="active_section_core_hermes">Núcleo de Hermes</string>
<string name="active_section_optional_relay">Relay opcional</string>
<string name="active_section_extend_connection">Amplía esta conexión</string>
<string name="active_section_relay_connected_features">Extensiones de Relay</string>
<string name="active_section_relay_optional_summary">Añade Terminal, Bridge, herramientas del dispositivo, sesiones de Relay y rutas remotas seguras.</string>
<string name="active_section_view_relay_details">Ver detalles de Relay</string>
<string name="active_section_core_unchanged">Tu conexión Hermes actual permanece sin cambios.</string>
<string name="active_section_api_optional_direct">Opcional para chat directo e integraciones</string>
<string name="active_section_configure_test">Configurar y probar</string>
<string name="active_section_relay_optional_bridge">Funciones opcionales de Bridge y acceso remoto</string>
<string name="active_section_configure_relay">Configurar Relay</string>
<string name="active_section_pair_device_using_code">Vincula este dispositivo con un código del servidor.</string>
<string name="active_section_enter_pairing_code">Introducir código de vinculación</string>
<string name="active_section_done">Listo</string>
<string name="active_section_connection_behavior">Comportamiento de la conexión</string>
<string name="active_section_manual_pairing">Vinculación manual</string>
<string name="active_section_paired">Vinculado</string>
<string name="active_section_not_paired">No vinculado</string>
<string name="active_section_transport">Transporte</string>
<string name="active_section_hardware_backed">Protegido por hardware</string>
<string name="active_section_relay_session">Sesión de Relay</string>
<string name="active_section_access">Acceso</string>
<string name="active_section_paired_devices">Dispositivos vinculados</string>
<string name="active_section_device_count">%1$d dispositivos</string>
<string name="active_section_session_activity">Actividad de la sesión</string>
<string name="active_section_last_checked_just_now">Comprobado ahora mismo</string>
<string name="active_section_protected">Protegida</string>
<string name="active_section_not_encrypted">Sin cifrar</string>
<string name="active_section_no_security_issues">No se detectaron problemas de seguridad</string>
<string name="active_section_unencrypted_transport">Esta conexión usa un transporte sin cifrar</string>
<string name="active_section_actions">Acciones</string>
<string name="active_section_revoke_relay">Revocar vinculación de Relay</string>
<string name="cw_preparing_connection">Preparando la conexión…</string>
<string name="cw_preparing_connection_hint">Configurando el almacenamiento local seguro. Solo tardará un momento.</string>
<string name="cw_relay_entry_title">Hermes Relay</string>
<string name="cw_relay_entry_desc">Vincula Relay para añadir Terminal, Bridge, herramientas del dispositivo, sesiones de Relay y permisos.</string>
<string name="cw_relay_pair_qr">Vincular Hermes Relay</string>
<string name="cw_relay_pair_qr_desc">Escanea un QR de configuración de Relay</string>
<string name="cw_relay_enter_code">Introducir código de vinculación</string>
<string name="cw_dashboard_connected_title">Hermes está conectado</string>
<string name="cw_dashboard_connected_body">Dashboard y Gateway están listos. Puedes empezar a chatear o abrir Manage.</string>
<string name="cw_continue">Continuar</string>
<string name="cw_timeline_discovered">Hermes encontrado</string>
<string name="cw_timeline_discovered_detail">Identidad del Dashboard y endpoint de estado verificados</string>
<string name="cw_timeline_access">Acceso al Dashboard</string>
<string name="cw_timeline_access_ready">No se requiere otro inicio de sesión</string>
<string name="cw_timeline_authenticated">Autenticación verificada</string>
<string name="cw_timeline_ready">Conexión lista</string>
<string name="cw_timeline_ready_detail">Chat, Manage y Voice pueden usar este Dashboard</string>
<string name="active_section_optional_api_fallback">Alternativa directa opcional mediante API</string>
<string name="active_section_api_not_required">No es necesaria cuando esta conexión usa Hermes Dashboard.</string>
<string name="active_section_where_api_key">¿Dónde obtengo esta clave?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY se crea en su servidor Hermes; esta aplicación no la proporciona. Configúrela solo al habilitar el servidor API opcional e introduzca aquí el mismo valor.</string>
<string name="active_section_choose_how_phone_reaches_named">Cómo accede este teléfono a %1$s</string>
<string name="active_section_dashboard_home_lan">Dashboard · Red LAN doméstica</string>
<string name="active_section_scan_relay_qr">Escanear QR de Relay</string>
<string name="active_section_other_relay_methods">Otros métodos de vinculación</string>
<string name="endpoints_tailscale_setup_hint">Tailscale debe estar conectado en ambos dispositivos y Hermes Dashboard debe ser accesible por el puerto 9119.</string>
<string name="endpoints_setup_help">Ayuda para configurar Tailscale</string>
</resources>
File diff suppressed because it is too large Load Diff
+172 -20
View File
@@ -13,15 +13,17 @@
<string name="onboarding_back">Back</string>
<string name="onboarding_next">Next</string>
<string name="onboarding_connect">Connect</string>
<string name="onboarding_get_started">Get started</string>
<string name="onboarding_step_count">Step %1$d of %2$d</string>
<string name="onboarding_welcome_title">Hermes-Relay for Android</string>
<string name="onboarding_welcome_description">Chat with Hermes and manage your dashboard from your phone.</string>
<string name="onboarding_welcome_title">Hermes,\nin your pocket</string>
<string name="onboarding_welcome_description">Chat, manage, and use voice\nwith your own Hermes.</string>
<string name="onboarding_welcome_badge">Welcome</string>
<string name="onboarding_hermes_logo">Hermes logo</string>
<string name="onboarding_chat_manage_label">Chat &amp; Manage</string>
<string name="onboarding_chat_manage_description">Connect to your running Hermes dashboard and API. No Relay install or pairing required.</string>
<string name="onboarding_power_tools_label">Power tools</string>
<string name="onboarding_power_tools_description">Add Hermes-Relay for Terminal, Bridge, relay sessions, and channel grants.</string>
<string name="onboarding_chat_manage_label">Dashboard first</string>
<string name="onboarding_chat_manage_description">No Relay required</string>
<string name="onboarding_power_tools_label">Power tools later</string>
<string name="onboarding_power_tools_description">Pair Relay when you need\nTerminal or Bridge</string>
<string name="onboarding_setup_guide_hint">The setup guide has copy/paste commands when you need to start Hermes on a computer or server.</string>
<string name="onboarding_setup_guide">Setup Guide</string>
<string name="onboarding_hermes_docs">Hermes Docs</string>
@@ -274,12 +276,62 @@
<!-- Connection wizard — Connect page essentials -->
<string name="cw_connect_to_hermes">Connect to Hermes</string>
<string name="cw_connect_description">Start the Hermes API/dashboard on your host, then connect this app. Relay pairing is optional and only needed for Terminal, Bridge, relay sessions, and channel grants.</string>
<string name="cw_nearby_description">We’ll look for Hermes on this network. Dashboard and Gateway provide the standard Chat, Manage, and Voice connection.</string>
<string name="cw_before_connecting">Before you connect</string>
<string name="cw_connect_step_server_title">Start Hermes on your computer</string>
<string name="cw_connect_step_server_body">Start the Dashboard on the Hermes computer. For first-time LAN access, use Setup Guide below to make it phone-reachable and enable sign-in.</string>
<string name="cw_connect_step_network_title">Make the server reachable</string>
<string name="cw_connect_step_network_body">Use the same Wi-Fi, or connect both devices to Tailscale.</string>
<string name="cw_connect_step_phone_title">Connect from this phone</string>
<string name="cw_connect_step_phone_body">Search below or enter the Dashboard address, such as 192.168.1.10:9119. Sign in if prompted—no API key is needed.</string>
<string name="cw_nearby_searching">Searching nearby…</string>
<string name="cw_nearby_searching_hint">Keep your phone on the same network as your Hermes server.</string>
<string name="cw_nearby_heading">Hermes nearby</string>
<string name="cw_nearby_empty">No Hermes servers found</string>
<string name="cw_nearby_failed">Couldn’t search this network</string>
<string name="cw_nearby_empty_hint">Check that Hermes is running, then search again or enter its address.</string>
<string name="cw_nearby_search_again">Search again</string>
<string name="cw_nearby_enter_address">Enter address or custom port</string>
<string name="cw_preparing_connection">Preparing connection…</string>
<string name="cw_preparing_connection_hint">Setting up secure local storage. This should only take a moment.</string>
<string name="cw_other_connection_methods">Advanced connection options</string>
<string name="cw_relay_entry_title">Hermes Relay</string>
<string name="cw_relay_entry_desc">Pair Relay to add Terminal, Bridge, device tools, Relay sessions, and grants.</string>
<string name="cw_relay_pair_qr">Pair Hermes Relay</string>
<string name="cw_relay_pair_qr_desc">Scan a Relay setup QR</string>
<string name="cw_relay_enter_code">Enter a Relay pairing code</string>
<string name="cw_manual_hermes_title">Enter your Hermes address</string>
<string name="cw_manual_hermes_description">Enter the Dashboard address you open in a browser. If it does not open on this phone, start hermes dashboard and check Wi-Fi or Tailscale.</string>
<string name="cw_hermes_address">Hermes address</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 or hermes.example.com</string>
<string name="cw_hermes_address_hint">No API key is needed. Dashboard port 9119 is used when no port is included.</string>
<string name="cw_find_hermes">Find Hermes</string>
<string name="cw_hermes_found">Hermes found</string>
<string name="cw_ready_to_connect">Ready to connect</string>
<string name="cw_ready">Ready</string>
<string name="cw_available_after_signin">Available after sign-in</string>
<string name="cw_unavailable_server">Unavailable on this server</string>
<string name="cw_could_not_verify">Couldn’t verify</string>
<string name="cw_choose_another">Choose another</string>
<string name="cw_sign_in_to_hermes">Sign in to Hermes</string>
<string name="cw_dashboard_connected_title">Hermes is connected</string>
<string name="cw_dashboard_connected_body">Dashboard and Gateway are ready. You can start chatting or open Manage.</string>
<string name="cw_continue">Continue</string>
<string name="cw_timeline_discovered">Hermes discovered</string>
<string name="cw_timeline_discovered_detail">Dashboard identity and status endpoint verified</string>
<string name="cw_timeline_access">Dashboard access</string>
<string name="cw_timeline_access_ready">No additional sign-in required</string>
<string name="cw_timeline_authenticated">Authentication verified</string>
<string name="cw_timeline_ready">Connection ready</string>
<string name="cw_timeline_ready_detail">Chat, Manage, and Voice can use this Dashboard</string>
<string name="cw_semantics_hermes_available">Hermes available</string>
<string name="cw_semantics_capability">%1$s: %2$s</string>
<string name="cw_connect_description">Use these options for API-only compatibility, custom setup, or alternate Relay pairing.</string>
<string name="cw_try_demo">Try the demo</string>
<string name="cw_try_demo_subtitle">Explore offline — no server needed.</string>
<string name="cw_setup_guide">Setup Guide</string>
<string name="cw_hermes_api">Hermes API</string>
<string name="cw_advanced_relay_pairing">Advanced: Relay pairing</string>
<string name="cw_advanced_relay_pairing">Relay pairing options</string>
<string name="cw_advanced_relay_pairing_desc">Terminal, Bridge, Relay sessions, and grants require the Relay plugin.</string>
<string name="cw_relay_docs">Relay docs</string>
<string name="cw_skip_for_now">Skip for now — set up later in Settings</string>
@@ -305,12 +357,12 @@
<string name="cw_cancel">Cancel</string>
<!-- Method tiles -->
<string name="cw_method_hermes_title">Hermes</string>
<string name="cw_method_hermes_subtitle">API/dashboard setup for Chat, Manage, Skills, Cron, MCP, Profiles, Models, and Settings</string>
<string name="cw_method_hermes_title">API-only connection</string>
<string name="cw_method_hermes_subtitle">Compatibility and fallback when Dashboard/Gateway is unavailable</string>
<string name="cw_method_scan_title">Scan setup QR</string>
<string name="cw_method_scan_subtitle">Scan a QR with API URL/key for Hermes; Relay QR details require the Relay plugin</string>
<string name="cw_method_pair_code_title">Pair Relay by code</string>
<string name="cw_method_pair_code_subtitle">Power-user path for Terminal, Bridge, Relay sessions, and grants</string>
<string name="cw_method_pair_code_subtitle">Pair with a code created by your Relay server</string>
<string name="cw_method_show_code_title">Show Relay code</string>
<string name="cw_method_show_code_subtitle">No camera or QR? Register this phone\'s code on the host</string>
@@ -320,6 +372,11 @@
<string name="cw_scan_message">Scanning this LAN for Hermes dashboard/API…</string>
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is optional for Chat.</string>
<string name="cw_pair_relay_section">Pair Relay (optional)</string>
<string name="cw_pair_relay_for">Pair Relay with %1$s</string>
<string name="cw_pair_relay_scoped_desc">Add the optional Relay extension to this saved Hermes connection. This does not add or replace a server.</string>
<string name="cw_current_connection">Current connection</string>
<string name="cw_current_hermes_connection">CURRENT HERMES CONNECTION</string>
<string name="cw_existing_connection_unchanged">Chat, Manage, Voice, and your saved routes stay unchanged.</string>
<string name="cw_pair_relay_section_desc">Already running the Relay plugin? Pair here to enable Terminal, Bridge, and channel grants.</string>
<string name="cw_pair_relay_url_label">Relay URL</string>
<string name="cw_pair_relay_code_label">Pairing code</string>
@@ -441,7 +498,7 @@
<string name="endpoints_public">Public</string>
<string name="endpoints_custom">Custom</string>
<string name="endpoints_route_name">Route name</string>
<string name="endpoints_api_url_host">API server URL or host</string>
<string name="endpoints_api_url_host">Dashboard/Gateway URL or host</string>
<string name="endpoints_saving">Saving…</string>
<string name="endpoints_save">Save</string>
<string name="endpoints_close">Close</string>
@@ -580,7 +637,7 @@
<string name="settings_hermes_management">Hermes management</string>
<string name="settings_hermes_management_desc">Dashboard features: skills, cron, MCP, profiles, models</string>
<string name="settings_chat">Chat</string>
<string name="settings_chat_desc">API chat behavior, endpoints, tool display, message length</string>
<string name="settings_chat_desc">Chat behavior, Gateway, API fallback, tool display, message length</string>
<string name="settings_voice_mode">Voice mode</string>
<string name="settings_voice_mode_desc">Dashboard voice, realtime relay options, providers</string>
<string name="settings_threads">Threads</string>
@@ -737,6 +794,8 @@
<string name="conn_voice_relay">Relay</string>
<string name="conn_voice_optional">Optional</string>
<string name="conn_api_label">API</string>
<string name="conn_chat_label">Chat</string>
<string name="conn_manage_label">Manage</string>
<string name="conn_dashboard_label">Dashboard</string>
<string name="conn_voice_label">Voice</string>
<string name="conn_relay_label">Relay</string>
@@ -761,6 +820,10 @@
<string name="detail_more_actions">More actions</string>
<string name="detail_rename">Rename</string>
<string name="detail_pair_relay">Pair Relay</string>
<string name="detail_dashboard_primary">Dashboard primary</string>
<string name="detail_core_ready">Core Hermes ready</string>
<string name="detail_core_configured">Core Hermes configured</string>
<string name="detail_overview_summary">Chat, Manage, and Voice use standard Hermes. Relay is an optional extension for advanced device features.</string>
<string name="detail_repair">Re-pair</string>
<string name="detail_revoke">Revoke</string>
<string name="detail_remove">Remove</string>
@@ -783,6 +846,83 @@
<string name="detail_tab_routes">Routes</string>
<string name="detail_tab_advanced">Advanced</string>
<string name="detail_tab_security">Security</string>
<string name="conn_startup_title">On app start</string>
<string name="conn_startup_last_used">Last used</string>
<string name="conn_startup_recommended">Recommended</string>
<string name="conn_startup_choose">Choose startup connection</string>
<plurals name="conn_server_count">
<item quantity="one">%1$d server</item>
<item quantity="other">%1$d servers</item>
</plurals>
<string name="conn_switch">Switch</string>
<string name="conn_switching">Switching…</string>
<string name="conn_switched">Active</string>
<string name="conn_connecting_to">Connecting to %1$s…</string>
<string name="conn_last_used_now">Last used just now</string>
<string name="conn_last_used_format">Last used %1$s</string>
<string name="conn_just_now">just now</string>
<string name="conn_dashboard_only_route">Dashboard only</string>
<string name="conn_no_routes">No routes configured</string>
<string name="active_section_primary_dashboard">Primary Dashboard</string>
<string name="active_section_dashboard_reachable_signed_in">Reachable · Signed in</string>
<string name="active_section_dashboard_reachable">Reachable</string>
<string name="active_section_dashboard_not_checked">Not checked yet</string>
<string name="active_section_dashboard_unreachable">Not reachable</string>
<string name="active_section_no_fallback_routes">No fallback routes yet</string>
<string name="active_section_no_fallback_routes_desc">Add a LAN, Tailscale, or public address to keep this connection available when networks change.</string>
<string name="active_section_add_api_fallback">Add fallback route</string>
<string name="active_section_security_authentication">Authentication</string>
<string name="active_section_dashboard_session">Dashboard session</string>
<string name="active_section_credential_storage">Credential storage</string>
<string name="active_section_encrypted_storage">Encrypted storage</string>
<string name="active_section_no_relay_credential">No Relay credential</string>
<string name="active_section_sign_out_dashboard">Sign out of Dashboard</string>
<string name="active_section_credentials_encrypted">Credentials remain encrypted on this device.</string>
<string name="active_section_reachable_badge">REACHABLE</string>
<string name="active_section_unchecked_badge">UNCHECKED</string>
<string name="active_section_edit">Edit</string>
<string name="active_section_fallback_routes">Fallback routes</string>
<string name="active_section_route_selection">Route selection</string>
<string name="active_section_automatic">Automatic</string>
<string name="active_section_api_access">API access</string>
<string name="active_section_optional_api_fallback">Optional direct API fallback</string>
<string name="active_section_api_not_required">Not required when this connection uses the Hermes Dashboard.</string>
<string name="active_section_where_api_key">Where do I get this?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY is created on your Hermes server; it is not supplied by this app. Configure it only when you enable the optional API server, then enter the same value here.</string>
<string name="active_section_core_hermes">Core Hermes</string>
<string name="active_section_optional_relay">Optional Relay</string>
<string name="active_section_extend_connection">Extend this connection</string>
<string name="active_section_relay_connected_features">Relay extensions</string>
<string name="active_section_relay_optional_summary">Add Terminal, Bridge, device tools, Relay sessions, and secure remote routes.</string>
<string name="active_section_view_relay_details">View Relay details</string>
<string name="active_section_core_unchanged">Your current Hermes connection stays unchanged.</string>
<string name="active_section_api_optional_direct">Optional for direct chat and integrations</string>
<string name="active_section_configure_test">Configure &amp; test</string>
<string name="active_section_relay_optional_bridge">Optional bridge features and remote access</string>
<string name="active_section_configure_relay">Configure Relay</string>
<string name="active_section_scan_relay_qr">Scan Relay QR</string>
<string name="active_section_other_relay_methods">Other pairing methods</string>
<string name="active_section_pair_device_using_code">Pair this device using a code from the server.</string>
<string name="active_section_enter_pairing_code">Enter pairing code</string>
<string name="active_section_done">Done</string>
<string name="active_section_connection_behavior">Connection behavior</string>
<string name="active_section_manual_pairing">Manual pairing</string>
<string name="active_section_paired">Paired</string>
<string name="active_section_not_paired">Not paired</string>
<string name="active_section_transport">Transport</string>
<string name="active_section_hardware_backed">Hardware-backed</string>
<string name="active_section_relay_session">Relay session</string>
<string name="active_section_access">Access</string>
<string name="active_section_paired_devices">Paired devices</string>
<string name="active_section_device_count">%1$d devices</string>
<string name="active_section_session_activity">Session activity</string>
<string name="active_section_last_checked_just_now">Last checked just now</string>
<string name="active_section_protected">Protected</string>
<string name="active_section_not_encrypted">Not encrypted</string>
<string name="active_section_no_security_issues">No security issues detected</string>
<string name="active_section_unencrypted_transport">This connection uses an unencrypted transport</string>
<string name="active_section_actions">Actions</string>
<string name="active_section_revoke_relay">Revoke Relay pairing</string>
<!-- P0: SessionDrawer -->
<string name="drawer_filter_by_source">Filter by source</string>
@@ -1057,6 +1197,9 @@
<string name="appearance_language_desc">Choose the language used by Hermes-Relay. System default follows your device setting.</string>
<string name="appearance_language_system">System default</string>
<string name="appearance_language_english">English</string>
<string name="appearance_language_german">Deutsch</string>
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
<string name="appearance_language_japanese">日本語</string>
<string name="appearance_language_simplified_chinese">简体中文</string>
<string name="appearance_language_spanish">Español</string>
<string name="appearance_appearance">Appearance</string>
@@ -2029,7 +2172,7 @@
<string name="active_section_allow_plain_connections">Allow plain connections</string>
<string name="active_section_api_hermes_voice_reachable">API reachable — Hermes voice is configured</string>
<string name="active_section_api_key_already_set">API key is already set</string>
<string name="active_section_api_key_needed_hint">Enter a key if the API server requires one.</string>
<string name="active_section_api_key_needed_hint">Use the API_SERVER_KEY created on your Hermes server. The app does not issue this key.</string>
<string name="active_section_api_key_not_configured">API key not configured</string>
<string name="active_section_api_key_optional">API key (optional)</string>
<string name="active_section_api_key_stored_hint">Key is stored securely.</string>
@@ -2045,6 +2188,8 @@
<string name="active_section_checking">Checking…</string>
<string name="active_section_checking_routes">Checking routes…</string>
<string name="active_section_choose_how_phone_reaches">Choose how this phone reaches the server</string>
<string name="active_section_choose_how_phone_reaches_named">How this phone reaches %1$s</string>
<string name="active_section_dashboard_home_lan">Dashboard · Home LAN</string>
<string name="active_section_command_copied">Command copied</string>
<string name="active_section_configured">Configured</string>
<string name="active_section_connect">Connect</string>
@@ -2139,6 +2284,7 @@
<string name="conn_info_api_server_title">API Server</string>
<string name="conn_info_approvals_off">Approvals are OFF</string>
<string name="conn_info_auth">Auth</string>
<string name="conn_info_relay_auth">Relay auth</string>
<string name="conn_info_avg_ttft">Avg. TTFT</string>
<string name="conn_info_channel_grants">Channel grants</string>
<string name="conn_info_checking">Checking…</string>
@@ -2178,6 +2324,7 @@
<string name="conn_info_hermes">Hermes</string>
<string name="conn_info_hostname_hermes">%1$s (Hermes)</string>
<string name="conn_info_hostname_paired">%1$s (paired)</string>
<string name="conn_info_hostname_relay_paired">%1$s · Relay paired</string>
<string name="conn_info_idle_suffix"> · Idle</string>
<string name="conn_info_insecure_mode_allowed">Insecure mode allowed</string>
<string name="conn_info_inspect_profile">Inspect %1$s</string>
@@ -2508,7 +2655,10 @@
<!-- QrPairingScanner -->
<string name="qr_scanner_title">Scan Hermes QR</string>
<string name="qr_scanner_instruction">Scan a Hermes setup QR</string>
<string name="qr_scanner_subtext">Ask Hermes: &quot;Generate a QR code with my API URL and API key.&quot;</string>
<string name="qr_scanner_subtext">Scan a Hermes setup or Relay pairing QR. Standard Dashboard connections do not require an API key.</string>
<string name="qr_scanner_relay_title">Scan Relay QR</string>
<string name="qr_scanner_relay_instruction">Scan a Relay pairing QR</string>
<string name="qr_scanner_relay_subtext">Open the Relay pairing QR on your Hermes server. Your existing Dashboard connection and routes will stay unchanged.</string>
<string name="qr_scanner_camera_error">Cannot start the camera on this device.</string>
<string name="qr_scanner_fallback_message">You can pair without the camera.</string>
<string name="qr_scanner_pair_manual">Pair manually</string>
@@ -2975,12 +3125,14 @@
<string name="diag_check_voice_relay">Voice (Relay)</string>
<string name="diag_check_voice_standard">Voice (Standard)</string>
<string name="endpoints_pin_title">Pinned endpoints</string>
<string name="endpoints_route_editor_desc">Edit route</string>
<string name="endpoints_route_editor_desc">Add the Dashboard/Gateway address this phone should use on that network.</string>
<string name="endpoints_route_name_placeholder">Route name</string>
<string name="endpoints_url_host_placeholder">Host</string>
<string name="endpoints_url_supporting_blank">No supporting URLs</string>
<string name="endpoints_url_supporting_enter">Enter a URL</string>
<string name="endpoints_url_supporting_preview">Preview</string>
<string name="endpoints_url_host_placeholder">100.x.y.z or host.ts.net</string>
<string name="endpoints_url_supporting_blank">Enter the server’s Dashboard address</string>
<string name="endpoints_url_supporting_enter">Use an http:// or https:// Dashboard address</string>
<string name="endpoints_url_supporting_preview">Will test: %1$s</string>
<string name="endpoints_tailscale_setup_hint">Tailscale must be connected on both devices, and Hermes Dashboard must be reachable on port 9119.</string>
<string name="endpoints_setup_help">Tailscale setup help</string>
<string name="image_viewer_error">Error saving image</string>
<string name="image_viewer_failed">Failed to save image</string>
<string name="image_viewer_failed_template">Failed to save image: %s</string>
+4 -1
View File
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="zh-Hans" />
<locale android:name="de" />
<locale android:name="es" />
<locale android:name="ja" />
<locale android:name="pt-BR" />
<locale android:name="zh-Hans" />
</locale-config>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Hermes Dev</string>
<string name="a11y_service_label">Hermes-Bridge Dev</string>
<string name="notification_companion_label">assistente de notificações do Hermes Dev</string>
<string name="a11y_description_sideload">O Hermes Bridge concede ao agente acesso completo de leitura e gravação no celular para controle sem usar as mãos por voz e visão. Todas as ações são registradas no Registro de atividades, e as ações destrutivas exigem sua confirmação.</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sideload flavor strings.
`a11y_description_sideload` is the full-surface description users see when
enabling Hermes Bridge on the direct-install track. Unlike the Google Play
flavor we can be explicit about voice + vision + full device control here:
the sideload user is assumed to be a power user who installed an APK by
hand, not a Play Store customer.
Do NOT reuse these strings in the googlePlay flavor — Play reviewers may
flag any mention of "full read/write access" or "hands-free control" as
outside the declared use case.
-->
<resources>
<string name="app_name">Hermes Dev</string>
<string name="a11y_service_label">Hermes-Bridge Dev</string>
<string name="notification_companion_label">Hermes Dev-Benachrichtigungsassistent</string>
<string name="a11y_description_sideload">Hermes Bridge gewährt dem Agenten vollständigen Lese- und Schreibzugriff auf das Smartphone zur freihändigen Steuerung per Sprache und Bilderkennung. Alle Aktionen werden im Aktivitätsprotokoll erfasst; destruktive Aktionen erfordern deine Bestätigung.</string>
</resources>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sideload flavor strings.
`a11y_description_sideload` is the full-surface description users see when
enabling Hermes Bridge on the direct-install track. Unlike the Google Play
flavor we can be explicit about voice + vision + full device control here:
the sideload user is assumed to be a power user who installed an APK by
hand, not a Play Store customer.
Do NOT reuse these strings in the googlePlay flavor — Play reviewers may
flag any mention of "full read/write access" or "hands-free control" as
outside the declared use case.
-->
<resources>
<string name="app_name">Hermes Dev</string>
<string name="a11y_service_label">Hermes-Bridge Dev</string>
<string name="notification_companion_label">Hermes Dev 通知コンパニオン</string>
<string name="a11y_description_sideload">Hermes Bridge は、エージェントに電話機への完全な読み取り/書き込みアクセス権を与え、音声と視覚によるハンズフリー制御を可能にします。すべてのアクションはアクティビティ ログに記録され、破壊的なアクションにはユーザーの確認が必要です。</string>
</resources>
@@ -52,6 +52,36 @@ class AgentDisplayTest {
assertEquals(defaultProfile, effective)
}
@Test
fun effectiveDisplayProfile_resolvesPinnedServerDefaultToNamedProfile() {
val pinned = Profile(
name = "pinned",
model = "gpt-pinned",
description = "Pinned profile",
)
val effective = AgentDisplay.effectiveDisplayProfile(
selectedProfile = null,
profiles = listOf(defaultProfile, pinned, mizu),
serverDefaultProfileName = "pinned",
)
assertEquals(pinned, effective)
}
@Test
fun effectiveDisplayProfile_keepsExplicitSelectionAheadOfPinnedDefault() {
val pinned = Profile(name = "pinned", model = "gpt-pinned")
val effective = AgentDisplay.effectiveDisplayProfile(
selectedProfile = mizu,
profiles = listOf(defaultProfile, pinned, mizu),
serverDefaultProfileName = "pinned",
)
assertEquals(mizu, effective)
}
@Test
fun agentName_usesProfileNameNotVerboseDescription() {
// The name slot shows the NAME, even when a (verbose) description exists.
@@ -26,6 +26,10 @@ class AppLanguageTest {
@Test
fun addedLocaleTagsResolveToTheirPickerOptions() {
assertEquals(AppLanguage.GERMAN, AppLanguage.fromLanguageTags("de-DE"))
assertEquals(AppLanguage.BRAZILIAN_PORTUGUESE, AppLanguage.fromLanguageTags("pt-BR"))
assertEquals(AppLanguage.BRAZILIAN_PORTUGUESE, AppLanguage.fromLanguageTags("pt-PT"))
assertEquals(AppLanguage.JAPANESE, AppLanguage.fromLanguageTags("ja-JP"))
assertEquals(AppLanguage.SPANISH, AppLanguage.fromLanguageTags("es-MX"))
}
@@ -33,6 +37,9 @@ class AppLanguageTest {
fun languageOptionsProduceExpectedLocaleLists() {
assertTrue(AppLanguage.SYSTEM_DEFAULT.toLocaleList().isEmpty)
assertEquals("en", AppLanguage.ENGLISH.toLocaleList().toLanguageTags())
assertEquals("de", AppLanguage.GERMAN.toLocaleList().toLanguageTags())
assertEquals("pt-BR", AppLanguage.BRAZILIAN_PORTUGUESE.toLocaleList().toLanguageTags())
assertEquals("ja", AppLanguage.JAPANESE.toLocaleList().toLanguageTags())
assertEquals("zh-Hans", AppLanguage.SIMPLIFIED_CHINESE.languageTag)
assertEquals("es", AppLanguage.SPANISH.toLocaleList().toLanguageTags())
assertEquals(
@@ -0,0 +1,82 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionCapabilitiesTest {
@Test
fun dashboardOnlyConnection_exposesStandardHermesCapabilities() {
val connection = connection(
dashboardUrl = "https://hermes.example.com",
apiServerUrl = "",
relayUrl = "",
)
assertEquals("https://hermes.example.com", connection.primaryEndpointUrl)
assertEquals("hermes.example.com", connection.primaryHost)
assertTrue(connection.capabilities.dashboardGatewayConfigured)
assertTrue(connection.capabilities.gatewayChatAvailable)
assertTrue(connection.capabilities.manageAvailable)
assertTrue(connection.capabilities.standardVoiceAvailable)
assertTrue(connection.capabilities.chatConfigured)
assertFalse(connection.capabilities.apiChatFallbackAvailable)
assertFalse(connection.capabilities.relayFeaturesAvailable)
}
@Test
fun legacyApiRecord_retainsDerivedDashboardAndAllConfiguredSurfaces() {
val connection = connection(
dashboardUrl = null,
apiServerUrl = "http://192.168.1.25:8642",
relayUrl = "ws://192.168.1.25:8767",
)
assertEquals("http://192.168.1.25:9119", connection.primaryEndpointUrl)
assertTrue(connection.capabilities.dashboardGatewayConfigured)
assertTrue(connection.capabilities.apiServerConfigured)
assertTrue(connection.capabilities.relayConfigured)
}
@Test
fun apiOnlyConnection_canChatWithoutRelay() {
val connection = connection(
dashboardUrl = "",
apiServerUrl = "https://api.example.com",
relayUrl = "",
)
// A blank dashboard retains the legacy conventional derivation when
// an API endpoint is present.
assertTrue(connection.capabilities.dashboardGatewayConfigured)
assertTrue(connection.capabilities.apiChatFallbackAvailable)
assertFalse(connection.capabilities.relayFeaturesAvailable)
}
@Test
fun dashboardFirstLabel_usesDashboardWhenApiIsAbsent() {
assertEquals(
"hermes.example.com",
Connection.extractDefaultLabel(
dashboardUrl = "https://hermes.example.com:9119",
apiServerUrl = "",
relayUrl = "",
),
)
}
private fun connection(
dashboardUrl: String?,
apiServerUrl: String,
relayUrl: String,
) = Connection(
id = "id-a",
label = "Hermes",
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
tokenStoreKey = "hermes_auth_id-a",
dashboardUrl = dashboardUrl,
)
}
@@ -88,11 +88,39 @@ class ConnectionDashboardFieldsTest {
assertEquals(2, routes.size)
assertEquals("lan", routes[0].role)
assertEquals("192.168.1.25", routes[0].api.host)
assertEquals("ws://192.168.1.25:8767", routes[0].relay.url)
assertEquals("192.168.1.25", routes[0].api?.host)
assertEquals("ws://192.168.1.25:8767", routes[0].relay?.url)
assertEquals("tailscale", routes[1].role)
assertEquals("hermes.tail1234.ts.net", routes[1].api.host)
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay.url)
assertEquals("hermes.tail1234.ts.net", routes[1].api?.host)
assertEquals("wss://hermes.tail1234.ts.net:8767", routes[1].relay?.url)
}
@Test
fun dashboardRouteBuilder_acceptsBareTailscaleHostWithoutOptionalSurfaces() {
val route = Connection.endpointCandidateFromDashboardUrl(
role = "",
priority = 1,
dashboardUrl = "100.75.1.2",
)
assertEquals("tailscale", route?.role)
assertEquals("http://100.75.1.2:9119", route?.dashboard?.url)
assertNull(route?.api)
assertNull(route?.relay)
}
@Test
fun dashboardRouteBuilder_preservesOptionalApiAndRelayWhenConfigured() {
val route = Connection.endpointCandidateFromDashboardUrl(
role = "tailscale",
priority = 1,
dashboardUrl = "hermes.tail1234.ts.net",
apiServerUrl = "https://hermes.tail1234.ts.net:8642",
relayUrl = "wss://hermes.tail1234.ts.net:8767",
)
assertEquals("https://hermes.tail1234.ts.net:8642", route?.api?.url)
assertEquals("wss://hermes.tail1234.ts.net:8767", route?.relay?.url)
}
@Test
@@ -102,6 +130,30 @@ class ConnectionDashboardFieldsTest {
assertEquals("public", Connection.inferRouteRole("https://hermes.example.com:8642"))
}
@Test
fun discoveredLabel_prefersHostnameForAnUncustomizedIpLabel() {
assertEquals(
"hermes-box.local",
Connection.chooseDiscoveredLabel(
currentLabel = "192.168.1.25",
primaryHost = "192.168.1.25",
discoveredHostname = "hermes-box.local",
),
)
}
@Test
fun discoveredLabel_preservesAUserLabel() {
assertEquals(
"Home Hermes",
Connection.chooseDiscoveredLabel(
currentLabel = "Home Hermes",
primaryHost = "192.168.1.25",
discoveredHostname = "hermes-box.local",
),
)
}
private fun sampleConnection(
dashboardUrl: String? = null,
): Connection = Connection(
@@ -43,7 +43,7 @@ class ConnectionRouteCandidateMergeTest {
assertEquals(2, merged.size)
assertEquals("lan", merged[0].role)
assertEquals("tailscale", merged[1].role)
assertEquals("100.64.0.7", merged[1].api.host)
assertEquals("100.64.0.7", merged[1].api?.host)
}
@Test
@@ -66,7 +66,7 @@ class ConnectionRouteCandidateMergeTest {
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
assertEquals(payloadRelay, merged.first { it.role == "tailscale" }.relay.url)
assertEquals(payloadRelay, merged.first { it.role == "tailscale" }.relay?.url)
}
@Test
@@ -90,9 +90,9 @@ class ConnectionRouteCandidateMergeTest {
val merged = Connection.mergeRouteCandidates(rebuilt, existing)
assertEquals(2, merged.size)
val tailscale = merged.first { it.api.host == "100.64.0.7" }
val tailscale = merged.first { it.api?.host == "100.64.0.7" }
assertEquals("tailscale", tailscale.role)
assertEquals("ws://100.64.0.7:8767", tailscale.relay.url)
assertEquals("ws://100.64.0.7:8767", tailscale.relay?.url)
}
@Test
@@ -111,10 +111,10 @@ class ConnectionRouteCandidateMergeTest {
assertEquals(2, merged.size)
assertTrue(
"old priority-0 host must be replaced by the edited URL",
merged.none { it.api.host == "192.168.1.50" },
merged.none { it.api?.host == "192.168.1.50" },
)
assertEquals("10.0.0.99", merged.first { it.priority == 0 }.api.host)
assertEquals("100.64.0.7", merged.first { it.priority == 1 }.api.host)
assertEquals("10.0.0.99", merged.first { it.priority == 0 }.api?.host)
assertEquals("100.64.0.7", merged.first { it.priority == 1 }.api?.host)
}
@Test
@@ -0,0 +1,59 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ConnectionStoreStartupTest {
@Test
fun pinnedStartupOverridesLastUsedOnlyDuringHydration() = runTest {
val dataStore = InMemoryPreferencesDataStore()
val store = ConnectionStore(dataStore, backgroundScope)
store.isHydrated.first { it }
val a = sampleConnection("connection-a", "A")
val b = sampleConnection("connection-b", "B")
store.addConnection(a)
store.addConnection(b)
store.setActiveConnection(b.id)
store.setStartupConnection(a.id)
assertEquals(b.id, store.activeConnectionId.value)
assertEquals(a.id, store.startupConnectionId.value)
val reloaded = ConnectionStore(dataStore, backgroundScope)
reloaded.isHydrated.first { it }
assertEquals(a.id, reloaded.activeConnectionId.value)
assertEquals(a.id, reloaded.startupConnectionId.value)
reloaded.setStartupConnection(null)
assertNull(reloaded.startupConnectionId.value)
}
private fun sampleConnection(id: String, label: String) = Connection(
id = id,
label = label,
apiServerUrl = "http://localhost:8642",
relayUrl = "ws://localhost:8767",
tokenStoreKey = Connection.buildTokenStoreKey(id),
)
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
val next = transform(state.value)
state.value = next
return next
}
}
}
@@ -0,0 +1,150 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class ConnectionValidationTest {
@Test
fun dashboardOnlyConnection_isValid() {
assertNull(
ConnectionValidation.validateConnectionEndpoints(
dashboardUrl = "https://hermes.example.com",
apiServerUrl = "",
relayUrl = "",
),
)
}
@Test
fun optionalApiAndRelay_acceptBlankValues() {
assertNull(ConnectionValidation.validateOptionalApiServerUrl(""))
assertNull(ConnectionValidation.validateOptionalRelayUrl(" "))
}
@Test
fun connectionWithoutAnyEndpoint_isRejected() {
assertEquals(
"Configure at least one Hermes endpoint",
ConnectionValidation.validateConnectionEndpoints(null, "", ""),
)
}
@Test
fun malformedConfiguredOptionalEndpoint_isRejected() {
assertNotNull(
ConnectionValidation.validateConnectionEndpoints(
dashboardUrl = "https://hermes.example.com",
apiServerUrl = "ws://wrong.example.com",
relayUrl = "",
),
)
}
@Test
fun duplicateDetection_usesDashboardIdentityForDashboardOnlyConnections() {
val existing = Connection(
id = "existing",
label = "Hermes",
apiServerUrl = "",
relayUrl = "",
tokenStoreKey = "hermes_auth_existing",
dashboardUrl = "https://hermes.example.com/",
)
val duplicate = ConnectionValidation.findDuplicate(
connections = listOf(existing),
apiServerUrl = "",
relayUrl = "",
dashboardUrl = "https://HERMES.example.com",
)
assertEquals(existing, duplicate)
}
@Test
fun duplicateDetection_preservesLegacyApiAndRelayMatch() {
val existing = Connection(
id = "existing",
label = "Hermes",
apiServerUrl = "http://localhost:8642",
relayUrl = "ws://localhost:8767",
tokenStoreKey = "hermes_auth_existing",
)
assertEquals(
existing,
ConnectionValidation.findDuplicate(
connections = listOf(existing),
apiServerUrl = "HTTP://LOCALHOST:8642/",
relayUrl = "WS://LOCALHOST:8767/",
),
)
}
@Test
fun duplicateDetection_matchesLegacyApiDerivedDashboard() {
val existing = Connection(
id = "existing",
label = "Hermes",
apiServerUrl = "http://hermes.local:8642",
relayUrl = "",
tokenStoreKey = "hermes_auth_existing",
)
assertEquals(
existing,
ConnectionValidation.findDuplicate(
connections = listOf(existing),
apiServerUrl = "",
relayUrl = "",
dashboardUrl = "http://HERMES.local:9119/",
),
)
}
@Test
fun duplicateDetection_excludesActiveConnection() {
val existing = Connection(
id = "existing",
label = "Hermes",
apiServerUrl = "",
relayUrl = "",
tokenStoreKey = "hermes_auth_existing",
dashboardUrl = "https://hermes.example.com",
)
assertNull(
ConnectionValidation.findDuplicate(
connections = listOf(existing),
apiServerUrl = "",
relayUrl = "",
dashboardUrl = "https://hermes.example.com",
excludeId = existing.id,
),
)
}
@Test
fun duplicateDetection_keepsDifferentDashboardPortsDistinct() {
val existing = Connection(
id = "existing",
label = "Hermes",
apiServerUrl = "",
relayUrl = "",
tokenStoreKey = "hermes_auth_existing",
dashboardUrl = "https://hermes.example.com:9119",
)
assertNull(
ConnectionValidation.findDuplicate(
connections = listOf(existing),
apiServerUrl = "",
relayUrl = "",
dashboardUrl = "https://hermes.example.com:9120",
),
)
}
}
@@ -270,7 +270,7 @@ class DataManagerTest {
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
assertNotNull(result)
assertEquals(5, result.version)
assertEquals(6, result.version)
assertNull(result.serverUrl)
assertNull(result.apiServerUrl)
assertNull(result.relayUrl)
@@ -301,9 +301,9 @@ class DataManagerTest {
// --- Format version handling ---
@Test
fun backup_defaultVersion_isFive() {
fun backup_defaultVersion_isSix() {
val backup = DataManager.AppBackup()
assertEquals(5, backup.version)
assertEquals(6, backup.version)
}
@Test
@@ -396,7 +396,7 @@ class DataManagerTest {
assertEquals(connections, restored.connections)
assertEquals("tailscale", restored.connections[1].preferredRouteRole)
assertEquals("hermes.ts.net", restored.connections[1].routeCandidates[1].api.host)
assertEquals("hermes.ts.net", restored.connections[1].routeCandidates[1].api?.host)
}
@Test
@@ -409,6 +409,33 @@ class DataManagerTest {
assertTrue(restored.connections.isEmpty())
}
@Test
fun backup_dashboardOnlyConnection_roundTrip() {
val connection = Connection(
id = "dashboard-only",
label = "Hermes",
apiServerUrl = "",
relayUrl = "",
tokenStoreKey = "hermes_auth_dashboard",
dashboardUrl = "https://hermes.example.com",
)
val backup = DataManager.AppBackup(
connections = listOf(connection),
activeConnectionId = connection.id,
startupConnectionId = connection.id,
)
val restored = json.decodeFromString<DataManager.AppBackup>(json.encodeToString(backup))
assertEquals(connection, restored.connections.single())
assertEquals("dashboard-only", restored.activeConnectionId)
assertEquals("dashboard-only", restored.startupConnectionId)
assertEquals("https://hermes.example.com", restored.connections.single().primaryEndpointUrl)
assertTrue(restored.connections.single().capabilities.gatewayChatAvailable)
assertFalse(restored.connections.single().capabilities.apiChatFallbackAvailable)
assertFalse(restored.connections.single().capabilities.relayFeaturesAvailable)
}
@Test
fun importSettings_v2_dropsLegacyStringProfiles() {
// v2 stored profiles as List<String>, which is structurally
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint
import kotlinx.coroutines.test.runTest
@@ -348,6 +349,52 @@ class EndpointResolverTest {
assertTrue(!outcome!!.reachable)
}
@Test
fun dashboardOnlyCandidate_probesGatewayStatusWithoutApiOrRelay() = runTest {
reachableServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) {
"/api/status" -> MockResponse().setResponseCode(200)
else -> MockResponse().setResponseCode(404)
}
}
val dashboardOnly = EndpointCandidate(
role = "tailscale",
dashboard = DashboardEndpoint(reachableServer.url("/").toString().trimEnd('/')),
)
val winner = EndpointResolver(fastClient, clock = { clockMillis.get() })
.resolve(listOf(dashboardOnly))
assertEquals(dashboardOnly, winner)
val request = reachableServer.takeRequest(1, TimeUnit.SECONDS)
assertNotNull(request)
assertEquals("GET", request!!.method)
assertEquals("/api/status", request.path)
}
@Test
fun explicitDashboard_isProbeTarget_whenOptionalApiAndRelayArePresent() = runTest {
reachableServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) {
"/api/status" -> MockResponse().setResponseCode(200)
"/health" -> MockResponse().setResponseCode(500)
else -> MockResponse().setResponseCode(404)
}
}
val candidate = EndpointCandidate(
role = "tailscale",
dashboard = DashboardEndpoint(reachableServer.url("/").toString().trimEnd('/')),
api = ApiEndpoint(reachableServer.hostName, reachableServer.port),
relay = RelayEndpoint("ws://${reachableServer.hostName}:${reachableServer.port}"),
)
val winner = EndpointResolver(fastClient, clock = { clockMillis.get() })
.resolve(listOf(candidate))
assertEquals(candidate, winner)
assertEquals("/api/status", reachableServer.takeRequest(1, TimeUnit.SECONDS)?.path)
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
@@ -0,0 +1,26 @@
package com.hermesandroid.relay.network.shared
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HermesLanDiscoveryTest {
@Test
fun normalizeResolvedHostname_acceptsARealHostname() {
assertEquals(
"hermes-box.local",
HermesLanDiscovery.normalizeResolvedHostname(
address = "192.168.1.25",
resolved = "hermes-box.local.",
),
)
}
@Test
fun normalizeResolvedHostname_rejectsNumericAndUnresolvedResults() {
assertNull(HermesLanDiscovery.normalizeResolvedHostname("192.168.1.25", "192.168.1.25"))
assertNull(HermesLanDiscovery.normalizeResolvedHostname("192.168.1.25", ""))
assertNull(HermesLanDiscovery.normalizeResolvedHostname("192.168.1.25", "localhost"))
}
}
@@ -1046,6 +1046,11 @@ class ChatHandlerTest {
// Server ids adopted onto the live rows.
assertEquals("srv-1", user.id)
assertEquals("srv-2", assistant.id)
// Compose identity stays on the live rows. A same-count post-turn
// reload must not remove/reinsert the two bubbles just because their
// authoritative ids arrived.
assertEquals("uuid-user", user.uiKey)
assertEquals("uuid-assistant", assistant.uiKey)
// State carried by id, in place.
assertEquals(1, user.attachments.size)
assertEquals("outb64", user.attachments[0].content)
@@ -1056,6 +1061,39 @@ class ChatHandlerTest {
assertFalse(assistant.isStreaming)
}
@Test
fun loadMessageHistory_listRebuildPreservesMatchedTailUiKey() {
// Some persisted turns rebuild one live streaming bubble into several
// server rows (for example, restored message boundaries/tool output).
// The reconciled tail must retain its UI identity even while new rows
// are inserted around it, otherwise LazyColumn loses the viewport
// anchor on a long answer.
handler.addPlaceholderMessage(
ChatMessage(
id = "uuid-live-tail",
role = MessageRole.ASSISTANT,
content = "final chunk",
timestamp = 3L,
isStreaming = false,
)
)
handler.loadMessageHistory(
listOf(
MessageItem(id = "srv-user", role = "user", content = JsonPrimitive("question"), timestamp = 1.0),
MessageItem(id = "srv-prefix", role = "assistant", content = JsonPrimitive("earlier chunk"), timestamp = 2.0),
MessageItem(id = "srv-tail", role = "assistant", content = JsonPrimitive("final chunk"), timestamp = 3.0),
)
)
val messages = handler.messages.value
assertEquals(3, messages.size)
assertEquals("uuid-live-tail", messages.single { it.id == "srv-tail" }.uiKey)
assertEquals("srv-user", messages.single { it.id == "srv-user" }.uiKey)
assertEquals("srv-prefix", messages.single { it.id == "srv-prefix" }.uiKey)
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
}
@Test
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
// Once the first reload adopts the server id, subsequent reloads match by
@@ -382,6 +382,7 @@ class GatewayChatClientTest {
val toolGenerating = ConcurrentLinkedQueue<String>()
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
val usages = ConcurrentLinkedQueue<UsageInfo>()
val reconcileRequests = AtomicInteger(0)
val completeLatch = CountDownLatch(1)
val preflightFailures = ConcurrentLinkedQueue<String>()
@@ -394,6 +395,7 @@ class GatewayChatClientTest {
onToolCallDone = { id, result -> toolDone += id to result },
onToolCallFailed = { _, _ -> },
onTurnComplete = { },
onReconcileRequired = { reconcileRequests.incrementAndGet() },
onComplete = { completeLatch.countDown() },
onUsage = { it?.let(usages::add) },
onError = { errors += it; completeLatch.countDown() },
@@ -503,6 +505,7 @@ class GatewayChatClientTest {
assertEquals(listOf("Hi!"), r.textDeltas.toList())
assertEquals(listOf("20260612_120000_abc123"), r.sessionIds.toList())
assertEquals(5, r.usages.firstOrNull()?.resolvedInputTokens)
assertEquals(0, r.reconcileRequests.get())
assertTrue(r.errors.isEmpty())
assertTrue(r.preflightFailures.isEmpty())
}
@@ -978,6 +981,7 @@ class GatewayChatClientTest {
assertTrue("turn never completed after rejoin", r.completeLatch.await(10, TimeUnit.SECONDS))
assertEquals(listOf("after rejoin"), r.textDeltas.toList())
assertEquals(1, r.reconcileRequests.get())
assertTrue("rejoined turn must not error, got ${r.errors}", r.errors.isEmpty())
// The fix's core invariant: a mid-turn rejoin must NEVER session.resume.
assertTrue(
@@ -32,6 +32,7 @@ class GatewayEventMapperTest {
val sessionIds = mutableListOf<String>()
var starts = 0
var turnCompletes = 0
var reconcileRequests = 0
var completes = 0
var usage: UsageInfo? = null
var usageCalls = 0
@@ -47,6 +48,7 @@ class GatewayEventMapperTest {
onToolCallFailed = { id, err -> toolFails += id to err },
onToolOutputRisk = { toolOutputRisks += it },
onTurnComplete = { turnCompletes++ },
onReconcileRequired = { reconcileRequests++ },
onComplete = { completes++ },
onUsage = { usage = it; usageCalls++ },
onError = { errors += it },
@@ -0,0 +1,90 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
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 RelayAppStatusTest {
@Test
fun `dashboard-only connection counts as configured startup chat`() {
assertTrue(hasConfiguredStartupChat(connection(dashboardUrl = "https://host.ts.net:9119")))
assertFalse(hasConfiguredStartupChat(connection(relayUrl = "wss://host.ts.net:8767")))
}
@Test
fun `gateway readiness owns chat status when optional surfaces are unavailable`() {
val status = resolveAppChatRuntimeStatus(
connection = connection(
dashboardUrl = "https://host.ts.net:9119",
apiServerUrl = "https://host.ts.net:8642",
relayUrl = "wss://host.ts.net:8767",
),
gatewayAvailability = GatewayAvailability.Ready,
apiHealth = ConnectionViewModel.HealthStatus.Unreachable,
)
assertEquals(
ChatRuntimeStatus.Connected(ChatTransportPath.Gateway, fallback = false),
status,
)
}
@Test
fun `configured gateway probe appears connecting without Relay involvement`() {
val status = resolveAppChatRuntimeStatus(
connection = connection(dashboardUrl = "https://host.ts.net:9119"),
gatewayAvailability = GatewayAvailability.Unknown,
apiHealth = ConnectionViewModel.HealthStatus.Unknown,
)
assertEquals(ChatRuntimeStatus.Connecting, status)
}
@Test
fun `gateway footer derives Dashboard route without an API endpoint`() {
val connection = connection(dashboardUrl = "https://host.ts.net:9119")
val unrelatedApiRoute = EndpointCandidate(
role = "lan",
api = ApiEndpoint("192.168.1.20", 8642),
relay = RelayEndpoint("ws://192.168.1.20:8767"),
)
val route = resolveFooterRouteCandidate(
runtimeStatus = ChatRuntimeStatus.Connected(
transport = ChatTransportPath.Gateway,
fallback = false,
),
activeEndpoint = unrelatedApiRoute,
connection = connection,
effectiveDashboardUrl = connection.resolvedDashboardUrl,
)
assertEquals("tailscale", route?.role)
assertEquals("https://host.ts.net:9119", route?.dashboard?.url)
assertNull(route?.api)
}
private fun connection(
dashboardUrl: String? = null,
apiServerUrl: String = "",
relayUrl: String = "",
) = Connection(
id = "connection",
label = "Hermes",
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
tokenStoreKey = "hermes_auth_connection",
dashboardUrl = dashboardUrl,
)
}
@@ -50,11 +50,11 @@ class HermesPairingPayloadTest {
val ep = endpoints[0]
assertEquals("lan", ep.role)
assertEquals(0, ep.priority)
assertEquals("192.168.1.100", ep.api.host)
assertEquals(8642, ep.api.port)
assertFalse(ep.api.tls)
assertEquals("http://192.168.1.100:8642", ep.api.url)
assertEquals("ws://192.168.1.100:8767", ep.relay.url)
assertEquals("192.168.1.100", ep.api?.host)
assertEquals(8642, ep.api?.port)
assertFalse(ep.api?.tls ?: true)
assertEquals("http://192.168.1.100:8642", ep.api?.url)
assertEquals("ws://192.168.1.100:8767", ep.relay?.url)
}
@Test
@@ -104,7 +104,7 @@ class HermesPairingPayloadTest {
assertNotNull(endpoints)
assertEquals(1, endpoints!!.size)
assertEquals("tailscale", endpoints[0].role)
assertTrue(endpoints[0].api.tls)
assertTrue(endpoints[0].api?.tls == true)
}
@Test
@@ -159,11 +159,11 @@ class HermesPairingPayloadTest {
assertEquals(1, endpoints!!.size)
assertEquals("lan", endpoints[0].role)
assertEquals(0, endpoints[0].priority)
assertEquals("ws://192.168.1.50:8767", endpoints[0].relay.url)
assertEquals("ws://192.168.1.50:8767", endpoints[0].relay?.url)
// The per-endpoint RelayEndpoint carries ONLY url + transport_hint.
// `code`, `ttl_seconds`, `grants` stay on the top-level RelayPairing
// — that split is a load-bearing ADR 24 decision.
assertEquals("ws", endpoints[0].relay.transportHint)
assertEquals("ws", endpoints[0].relay?.transportHint)
}
@Test
@@ -213,24 +213,24 @@ class HermesPairingPayloadTest {
// Priority-0 LAN entry — unchanged from the wire.
assertEquals("lan", endpoints[0].role)
assertEquals(0, endpoints[0].priority)
assertEquals("192.168.1.100", endpoints[0].api.host)
assertFalse(endpoints[0].api.tls)
assertEquals("ws://192.168.1.100:8767", endpoints[0].relay.url)
assertEquals("ws", endpoints[0].relay.transportHint)
assertEquals("192.168.1.100", endpoints[0].api?.host)
assertFalse(endpoints[0].api?.tls ?: true)
assertEquals("ws://192.168.1.100:8767", endpoints[0].relay?.url)
assertEquals("ws", endpoints[0].relay?.transportHint)
// Priority-1 Tailscale — tls + wss must round-trip.
assertEquals("tailscale", endpoints[1].role)
assertEquals(1, endpoints[1].priority)
assertEquals("hermes.tail-scale.ts.net", endpoints[1].api.host)
assertTrue(endpoints[1].api.tls)
assertEquals("wss", endpoints[1].relay.transportHint)
assertEquals("https://hermes.tail-scale.ts.net:8642", endpoints[1].api.url)
assertEquals("hermes.tail-scale.ts.net", endpoints[1].api?.host)
assertTrue(endpoints[1].api?.tls == true)
assertEquals("wss", endpoints[1].relay?.transportHint)
assertEquals("https://hermes.tail-scale.ts.net:8642", endpoints[1].api?.url)
// Priority-2 public — 443 + path in relay URL preserved.
assertEquals("public", endpoints[2].role)
assertEquals(2, endpoints[2].priority)
assertEquals(443, endpoints[2].api.port)
assertEquals("wss://hermes.example.com/relay", endpoints[2].relay.url)
assertEquals(443, endpoints[2].api?.port)
assertEquals("wss://hermes.example.com/relay", endpoints[2].relay?.url)
}
@Test
@@ -293,7 +293,7 @@ class HermesPairingPayloadTest {
assertTrue(ep.isKnownRole())
assertEquals("LAN", ep.displayLabel())
// transportHint defaulted to null because the wire didn't carry it.
assertNull(ep.relay.transportHint)
assertNull(ep.relay?.transportHint)
}
@Test
@@ -350,4 +350,20 @@ class HermesPairingPayloadTest {
assertNull(parseHermesPairingQr("not-json-at-all"))
assertNull(parseHermesPairingQr(""))
}
@Test
fun scopedRelayPairing_requiresUsableWebSocketUrlAndCode() {
fun payload(relay: RelayPairing?) = HermesPairingPayload(
host = "192.168.1.10",
relay = relay,
)
assertFalse(payload(null).hasUsableRelayPairing())
assertFalse(payload(RelayPairing()).hasUsableRelayPairing())
assertFalse(payload(RelayPairing(url = "wss://relay.example.com", code = "")).hasUsableRelayPairing())
assertFalse(payload(RelayPairing(url = "https://relay.example.com", code = "ABC123")).hasUsableRelayPairing())
assertFalse(payload(RelayPairing(url = "wss:///relay", code = "ABC123")).hasUsableRelayPairing())
assertTrue(payload(RelayPairing(url = "ws://192.168.1.10:8767", code = "ABC123")).hasUsableRelayPairing())
assertTrue(payload(RelayPairing(url = "wss://relay.example.com/relay", code = "ABC123")).hasUsableRelayPairing())
}
}

Some files were not shown because too many files have changed in this diff Show More