Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68f8b58a0b | ||
|
|
481ad48c57 | ||
|
|
74be630e05 | ||
|
|
a61d27a92d | ||
|
|
22e4d24817 | ||
|
|
7edaa2df14 | ||
|
|
165feaa0d6 | ||
|
|
8c516c3c8d | ||
|
|
40bb0a4ef8 | ||
|
|
d96898a6aa | ||
|
|
b4e595e320 | ||
|
|
31c41fb2ff | ||
|
|
ab0f7b726a | ||
|
|
f72904ab53 | ||
|
|
4fc5f668de | ||
|
|
cedc340091 | ||
|
|
97cb30c927 | ||
|
|
ed41be3390 | ||
|
|
08816cfe63 | ||
|
|
01a0cde589 | ||
|
|
ed6742afe4 | ||
|
|
a6264df910 | ||
|
|
64e2e2eca6 | ||
|
|
1ccaf2c4f1 | ||
|
|
7686bb41e7 | ||
|
|
a940b4b8ea | ||
|
|
46afdeab59 | ||
|
|
d4a8aad050 | ||
|
|
0a6e95ae74 | ||
|
|
a6fc53e5cf | ||
|
|
c013daacda | ||
|
|
f5b1d377a4 | ||
|
|
bb1e406f3f | ||
|
|
10213ca8ed | ||
|
|
cbfccd8ccf | ||
|
|
c3c98caa31 | ||
|
|
ed60abd57c | ||
|
|
cab0d90530 | ||
|
|
d977600f9d | ||
|
|
c902c00101 | ||
|
|
aa6b48a068 | ||
|
|
50297d1496 | ||
|
|
d80f36a087 | ||
|
|
b6117c2d41 | ||
|
|
b0ee6935fe | ||
|
|
33538fde0c | ||
|
|
603919c8ff | ||
|
|
52df3adbf6 | ||
|
|
3eab11c639 | ||
|
|
2673f228bb | ||
|
|
53b8f6a418 | ||
|
|
87cd9e7b9d | ||
|
|
51a020bd22 | ||
|
|
34ff4d0629 | ||
|
|
c452c25148 | ||
|
|
0db5c02722 | ||
|
|
4630695c17 | ||
|
|
f4ee440015 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*.
|
||||
|
||||
@@ -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 <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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -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 当前固定的活跃配置文件。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+741
-172
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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+385
-137
@@ -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)
|
||||
}
|
||||
|
||||
+55
-101
@@ -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
|
||||
|
||||
+21
-20
@@ -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
@@ -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
@@ -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
@@ -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 & 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 & 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: "Generate a QR code with my API URL and API key."</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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
+7
-7
@@ -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,
|
||||
)
|
||||
}
|
||||
+35
-19
@@ -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
Reference in New Issue
Block a user