Compare commits

...
Author SHA1 Message Date
Bailey Dixon 86a0bebc0d fix(ci): avoid untrusted upstream checkout 2026-08-25 20:21:22 -04:00
Bailey Dixon 04d9421c74 release(android): android-v1.13.2 2026-08-25 20:10:35 -04:00
Bailey Dixon 14401aa3c3 Merge pull request #441 from Codename-11/fix/android-session-activity-fallback
fix(android): keep unknown session activity neutral

(cherry picked from commit d5cce4e390)
2026-08-25 20:02:24 -04:00
Bailey Dixon 99897274c6 Merge pull request #440 from Codename-11/chore/automate-hotfix-backmerge
feat(ci): automate conflict-free release backmerges

(cherry picked from commit 96a9e8077e)
2026-08-25 20:02:20 -04:00
Bailey Dixon 20c5b690a8 Merge pull request #439 from Codename-11/fix/android-supervised-return-blank
fix(android): stabilize supervised parent relock

(cherry picked from commit 4317da85fd)
2026-08-25 20:02:16 -04:00
Bailey Dixon dc86c043bc Merge pull request #419 from Codename-11/feature/android-supervised-mode
feat(android): add supervised mode

(cherry picked from commit 5580c9d9bb)
2026-08-25 20:02:11 -04:00
Bailey Dixon c9a5c767c6 Merge pull request #437 from Codename-11/fix/android-session-activity-hotfix
fix(android): release authoritative session activity
2026-08-25 14:33:40 -04:00
Bailey Dixon 524e319f95 release(android): android-v1.13.1 2026-08-25 13:04:28 -04:00
Bailey Dixon 647d1f9aea fix(android): make session activity authoritative 2026-08-25 12:56:22 -04:00
Bailey Dixon 08545ed32d Merge pull request #427 from Codename-11/dev
chore(release): Android 1.13.0, Plugin 1.10.0, and CLI beta.5
2026-08-24 23:42:42 -04:00
Bailey Dixon e791c6410b Merge pull request #430 from Codename-11/release/coordinated-2026-08-25
fix(server): bound provider usage profile errors
2026-08-24 23:22:31 -04:00
Bailey Dixon 8c8c3975f2 fix(server): bound provider usage profile errors 2026-08-24 23:21:24 -04:00
Bailey Dixon 41601d67ab Merge pull request #429 from Codename-11/main
chore: reconcile main release ancestry into dev
2026-08-24 23:00:47 -04:00
Bailey Dixon 366b424615 Merge pull request #428 from Codename-11/release/coordinated-2026-08-25
test(desktop): refresh release screenshot fingerprint
2026-08-24 22:51:32 -04:00
Bailey Dixon 5cd9baaaab test(desktop): refresh release screenshot fingerprint 2026-08-24 22:50:40 -04:00
Bailey Dixon 8acba9b353 Merge pull request #426 from Codename-11/release/coordinated-2026-08-25
chore(release): prepare Android 1.13.0, Plugin 1.10.0, and CLI beta.5
2026-08-24 22:25:36 -04:00
Bailey Dixon 26a612f088 chore(release): prepare Android 1.13.0, Plugin 1.10.0, and CLI beta.5 2026-08-24 22:15:06 -04:00
Bailey Dixon 6dd6ce2d13 Merge pull request #425 from Codename-11/fix/android-session-busy-auto-settle
fix(android): settle orphaned Gateway composer state
2026-08-24 21:36:23 -04:00
Bailey Dixon b60c5d9eeb Merge remote-tracking branch 'origin/dev' into codex/pr-425-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 21:27:03 -04:00
Bailey Dixon 9e201e54d7 Merge pull request #393 from Codename-11/feature/provider-usage
feat: add provider-aware usage and limits (salvages #384)
2026-08-24 21:13:20 -04:00
Bailey Dixon 8bb503eb6d fix(android): use appearance shape for usage card 2026-08-24 21:03:18 -04:00
Bailey Dixon c223dc690d Merge remote-tracking branch 'origin/dev' into codex/pr-393-integration
# Conflicts:
#	CHANGELOG.md
#	app/src/main/res/values-b+pt+BR/strings.xml
#	app/src/main/res/values-b+zh+Hans/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-ru/strings.xml
#	docs/decisions.md
#	docs/localization-status.json
2026-08-24 20:51:41 -04:00
Bailey Dixon 44e3bb75cd Merge pull request #421 from Codename-11/fix/pre-release-install-site
feat: align pre-release install and onboarding surfaces
2026-08-24 20:45:36 -04:00
Bailey Dixon 4834fcbdf5 fix(android): settle orphaned gateway composer state 2026-08-24 20:43:53 -04:00
Bailey Dixon 1cec79517e Merge remote-tracking branch 'origin/dev' into codex/pr-421-integration
# Conflicts:
#	CHANGELOG.md
#	docs/localization-status.json
2026-08-24 20:28:51 -04:00
Bailey Dixon 957be876a0 Merge pull request #423 from Codename-11/fix/android-session-busy-state
fix(android): clear stale chat busy state
2026-08-24 20:26:12 -04:00
Bailey Dixon 6b32c7aeef Merge remote-tracking branch 'origin/dev' into codex/pr-423-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 20:25:44 -04:00
Bailey Dixon 29706e1548 Merge pull request #422 from Codename-11/feature/android-bot-mode
feat(android): add multi-gateway bot mode
2026-08-24 20:24:58 -04:00
Bailey Dixon 6579b621ff Merge pull request #420 from Codename-11/fix/android-power-audit-377
fix(android): animate visible idle Sphere efficiently
2026-08-24 20:23:29 -04:00
Bailey Dixon befe8399ab Merge remote-tracking branch 'origin/dev' into codex/pr-420-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 20:13:41 -04:00
Bailey Dixon c10b87b94c Merge pull request #398 from JackHunzicker/fix/gateway-history-attachments
fix: retry Windows media paths and honor HERMES_HOME
2026-08-24 20:12:20 -04:00
Bailey Dixon 5e9d8840ae fix(android): clear stale chat busy state 2026-08-24 19:17:22 -04:00
Bailey Dixon accf464911 test(desktop): refresh screenshots after dev merge 2026-08-24 17:24:38 -04:00
Bailey Dixon b26c2cc2a1 merge: refresh pre-release install site with dev
# Conflicts:
#	CHANGELOG.md
#	docs/localization-status.json
2026-08-24 17:04:42 -04:00
Bailey Dixon 28e0c34227 feat(site): align onboarding across product surfaces 2026-08-24 17:02:31 -04:00
Bailey Dixon 35e95da6a7 test(desktop): add deterministic UI screenshots 2026-08-24 17:01:57 -04:00
Bailey Dixon 484bfdc5dc fix(desktop): harden release and update plumbing 2026-08-24 17:01:27 -04:00
Bailey Dixon c7c24b2874 merge: refresh Android idle sphere fix with dev 2026-08-24 16:28:31 -04:00
Bailey Dixon 3e8e0728db merge: refresh PR #398 with current dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
2026-08-24 15:58:11 -04:00
Bailey Dixon 1658439d05 fix(android): animate visible idle sphere efficiently 2026-08-24 11:57:26 -04:00
Bailey Dixon ef1abdae3f Merge pull request #414 from Codename-11/chore/promote-hr-candidate-reporter
chore(ci): promote HR Candidate reporter wording
2026-08-24 11:11:28 -04:00
Bailey Dixon eece12a815 chore(ci): promote HR Candidate reporter wording 2026-08-24 11:09:02 -04:00
Bailey Dixon 0cdea3ad33 Merge pull request #412 from Codename-11/fix/promote-review-reporter-ignore-skipped
fix(ci): promote skipped-run filter to main
2026-08-24 10:14:43 -04:00
Bailey Dixon a8ca61297d fix(ci): promote skipped-run filter to main 2026-08-24 10:12:04 -04:00
Bailey Dixon 5762cdf8af Merge pull request #410 from Codename-11/fix/promote-review-reporter-permission
fix(ci): promote reporter comment permission to main
2026-08-24 10:00:26 -04:00
Bailey Dixon dff633c902 fix(ci): promote reporter comment permission to main 2026-08-24 09:58:00 -04:00
Bailey Dixon 45d8a73609 Merge pull request #408 from Codename-11/chore/promote-review-bundle-reporter
chore(ci): promote review candidate reporter to main
2026-08-24 09:53:25 -04:00
Bailey Dixon 390a4dd8d8 chore(ci): promote review candidate reporter to main 2026-08-24 09:51:08 -04:00
Jack 90ab705a88 docs: record attachment and relay config fixes 2026-08-23 18:47:51 -05:00
Jack 054aab1c09 fix(server): honor HERMES_HOME for relay config 2026-08-23 18:47:51 -05:00
Jack bae1762951 fix(android): retry Windows media paths by path 2026-08-23 18:47:50 -05:00
Bailey Dixon da7ea8ffe0 feat: clarify Relay usage capabilities
Mark enhanced provider responses explicitly and explain in Settings which usage features require the matching Relay plugin.
2026-08-21 14:05:45 -04:00
Bailey Dixon 5c5c55d982 feat: support credential-aware provider usage
Resolve active Codex pool credentials from live Dashboard sessions, retain a secret-free standalone Relay fallback, and expose structured Nous balances.

Polish the Android Usage & limits surface with non-blocking skeletons, refresh controls, provider-specific landing visibility, and localized balance/status presentation.
2026-08-21 11:58:58 -04:00
Bailey Dixon 0208098687 feat: generalize provider usage settings 2026-08-21 10:11:03 -04:00
ophirhan 34fc4c4693 feat(android): show OpenCode Go subscription usage in Settings
Add an inline Settings card that displays the OpenCode Go subscription quota across its 5-hour (rolling), weekly, and monthly windows as progress bars with dollars used, the window cap, and a resets-in countdown.

The phone never sees the OpenCode Go API key. The relay host proxies GET /usage/opencode (bearer-authenticated to a paired session), reading OPENCODE_GO_API_KEY from the host .env and returning {usage, limits}. Hosts without OpenCode Go configured return 404, which the client renders as a quiet "not available" state instead of an error.

Verified: relay route + auth, upstream data shape, and 5 client unit tests; lint clean.
(cherry picked from commit 48251d0a36)
2026-08-21 08:56:25 -04:00
238 changed files with 14766 additions and 1715 deletions
+3
View File
@@ -3,8 +3,11 @@
function classifyCiPaths(paths) {
const forceAll = paths.some((path) => [
'.github/workflows/ci-required.yml',
'.github/workflows/release-backmerge.yml',
'.github/scripts/classify-ci-paths.cjs',
'.github/scripts/classify-ci-paths.test.cjs',
'scripts/plan_release_backmerge.py',
'scripts/tests/plan_release_backmerge_test.py',
].includes(path));
const exact = (values) => paths.some((path) => values.includes(path));
const under = (prefixes) => paths.some((path) => prefixes.some((prefix) => path.startsWith(prefix)));
@@ -30,5 +30,13 @@ assert.deepEqual(classifyCiPaths(['.github/workflows/ci-required.yml']), {
contract: true,
docs: true,
});
assert.deepEqual(classifyCiPaths(['.github/workflows/release-backmerge.yml']), {
android: true,
desktop: true,
plugin: true,
dashboard: true,
contract: true,
docs: true,
});
console.log('CI path classification tests passed.');
+40 -18
View File
@@ -11,6 +11,9 @@
name: CI — Upstream Contract
permissions:
contents: read
on:
workflow_call:
push:
@@ -40,45 +43,64 @@ jobs:
steps:
- name: Checkout hermes-relay
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Resolve upstream ref
id: ref
env:
REQUESTED_REF: ${{ github.event.inputs.upstream_ref }}
run: |
set -euo pipefail
# PR/push runs use a known-good NousResearch/hermes-agent commit so
# normal CI is stable. The weekly schedule below intentionally tracks
# main as the upstream-drift siren.
DEFAULT_REF="ef4b897a1843cd32c4f141f55db60f0f0602cc98"
if [ "${{ github.event_name }}" = "schedule" ]; then
REF="main" # weekly drift siren
elif [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
REF="${{ github.event.inputs.upstream_ref }}" # manual override
elif [ -n "$REQUESTED_REF" ]; then
REF="$REQUESTED_REF" # manual override
else
REF="$DEFAULT_REF"
fi
# The ref is passed to git below, so reject option-like or malformed
# values before it reaches that boundary. Full commit IDs and normal
# branch/tag names remain supported for manual contract checks.
if [[ "$REF" == -* ]] ||
! git check-ref-format --allow-onelevel "$REF" >/dev/null; then
echo "FAIL: invalid upstream branch or tag name." >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
echo "Checking standard-path route contract against upstream ref: $REF"
- name: Checkout vanilla upstream (no plugin, no bootstrap)
uses: actions/checkout@v7
with:
repository: NousResearch/hermes-agent
ref: ${{ steps.ref.outputs.ref }}
path: _upstream
fetch-depth: 1
- name: Extract trusted upstream contract sources
env:
UPSTREAM_REF: ${{ steps.ref.outputs.ref }}
run: |
set -euo pipefail
UPSTREAM_GIT="$RUNNER_TEMP/hermes-agent-contract.git"
git init --bare "$UPSTREAM_GIT"
git -C "$UPSTREAM_GIT" remote add origin \
"https://github.com/NousResearch/hermes-agent.git"
git -C "$UPSTREAM_GIT" fetch --no-tags --depth=1 origin -- "$UPSTREAM_REF"
UPSTREAM_COMMIT="$(git -C "$UPSTREAM_GIT" rev-parse 'FETCH_HEAD^{commit}')"
mkdir -p _upstream/gateway/platforms _upstream/hermes_cli
git -C "$UPSTREAM_GIT" show \
"$UPSTREAM_COMMIT:gateway/platforms/api_server.py" \
> _upstream/gateway/platforms/api_server.py
git -C "$UPSTREAM_GIT" show \
"$UPSTREAM_COMMIT:hermes_cli/web_server.py" \
> _upstream/hermes_cli/web_server.py
echo "Extracted contract sources from upstream commit: $UPSTREAM_COMMIT"
- name: Set up Python 3.11
uses: actions/setup-python@v7
with:
python-version: "3.11"
- name: Assert upstream checkout is vanilla (no relay bootstrap/plugin)
run: |
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
echo "FAIL: upstream checkout contains a relay bootstrap — not vanilla."; exit 1
fi
echo "OK: upstream checkout carries no relay plugin/bootstrap."
- name: Run route-surface contract
run: python scripts/check-upstream-route-contract.py "_upstream"
+53 -2
View File
@@ -10,6 +10,16 @@ on:
pull_request:
branches: [main, dev]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
inputs:
base_sha:
description: "Exact base commit for a trusted release-backmerge candidate"
required: true
type: string
head_sha:
description: "Exact candidate commit to check"
required: true
type: string
permissions:
contents: read
@@ -31,22 +41,63 @@ jobs:
contract: ${{ steps.filter.outputs.contract }}
docs: ${{ steps.filter.outputs.docs }}
steps:
- name: Checkout repository
- name: Checkout pull request merge
if: github.event_name == 'pull_request'
uses: actions/checkout@v7
with:
fetch-depth: 2
- name: Checkout exact dispatched candidate
if: github.event_name == 'workflow_dispatch'
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.head_sha }}
- name: Test path classifier
run: node .github/scripts/classify-ci-paths.test.cjs
- name: Classify changed files
id: filter
uses: actions/github-script@v8
env:
DISPATCH_BASE_SHA: ${{ inputs.base_sha }}
DISPATCH_HEAD_SHA: ${{ inputs.head_sha }}
with:
script: |
let diffArgs;
if (context.eventName === 'workflow_dispatch') {
const base = process.env.DISPATCH_BASE_SHA || '';
const head = process.env.DISPATCH_HEAD_SHA || '';
const shaPattern = /^[0-9a-f]{40}$/;
if (!shaPattern.test(base) || !shaPattern.test(head)) {
core.setFailed('Exact-tree dispatch requires full 40-character base/head SHAs.');
return;
}
const { stdout: checkedOut } = await exec.getExecOutput(
'git',
['rev-parse', 'HEAD'],
);
if (checkedOut.trim() !== head) {
core.setFailed(`Checked out ${checkedOut.trim()}, expected ${head}.`);
return;
}
const ancestry = await exec.exec(
'git',
['merge-base', '--is-ancestor', base, head],
{ ignoreReturnCode: true },
);
if (ancestry !== 0) {
core.setFailed(`Candidate ${head} does not descend from base ${base}.`);
return;
}
diffArgs = ['diff', '--name-only', base, head];
} else {
diffArgs = ['diff', '--name-only', 'HEAD^1', 'HEAD^2'];
}
const { stdout } = await exec.getExecOutput(
'git',
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
diffArgs,
);
const paths = stdout.split(/\r?\n/).filter(Boolean);
const { classifyCiPaths } = require(
+19
View File
@@ -365,3 +365,22 @@ jobs:
find app/build/outputs/apk -name '*.apk' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
find app/build/outputs/bundle -name '*.aab' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
echo '```' >> "$GITHUB_STEP_SUMMARY"
request-backmerge:
name: Request stable release backmerge
needs: [validate, release]
if: needs.validate.outputs.prerelease != 'true'
permissions:
actions: write
contents: read
runs-on: ubuntu-latest
steps:
- name: Dispatch fail-closed release reconciliation
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: android-v${{ needs.validate.outputs.version }}
run: |
gh workflow run release-backmerge.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f release_tag="$RELEASE_TAG"
+267
View File
@@ -0,0 +1,267 @@
# Reconcile a completed stable hotfix into dev without adding a ceremonial PR
# merge commit. Normal dev -> main releases are detected and intentionally no-op.
# A conflicted merge, failed exact-tree CI, stale dev ref, or denied branch update
# stops without mutating dev and falls back to the normal reconciliation PR path.
name: Release Backmerge
on:
workflow_dispatch:
inputs:
release_tag:
description: "Published stable tag to reconcile (android-v*, server-v*, or desktop-v*)"
required: true
type: string
permissions:
contents: read
concurrency:
group: release-backmerge-dev
cancel-in-progress: false
jobs:
prepare:
name: Prepare exact backmerge candidate
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
outcome: ${{ steps.prepare.outputs.outcome }}
base_dev_sha: ${{ steps.prepare.outputs.base_dev_sha }}
candidate_branch: ${{ steps.prepare.outputs.candidate_branch }}
candidate_sha: ${{ steps.prepare.outputs.candidate_sha }}
release_commit: ${{ steps.prepare.outputs.release_commit }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: main
- name: Validate release and prepare merge commit
id: prepare
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
shell: bash
run: |
set -euo pipefail
if [[ ! "$RELEASE_TAG" =~ ^(android|server|desktop)-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Release Backmerge accepts stable SemVer production tags only; got $RELEASE_TAG"
exit 1
fi
git fetch origin \
"+refs/heads/main:refs/remotes/origin/main" \
"+refs/heads/dev:refs/remotes/origin/dev" \
"+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
release_commit="$(git rev-parse "${RELEASE_TAG}^{commit}")"
base_dev_sha="$(git rev-parse origin/dev)"
echo "release_commit=$release_commit" >> "$GITHUB_OUTPUT"
echo "base_dev_sha=$base_dev_sha" >> "$GITHUB_OUTPUT"
if ! git merge-base --is-ancestor "$release_commit" origin/main; then
echo "::error::$RELEASE_TAG ($release_commit) is not contained in origin/main"
exit 1
fi
read -r is_draft is_prerelease < <(
gh release view "$RELEASE_TAG" --json isDraft,isPrerelease \
--jq '[.isDraft, .isPrerelease] | @tsv'
)
if [ "$is_draft" != "false" ] || [ "$is_prerelease" != "false" ]; then
echo "::error::$RELEASE_TAG is not a published stable GitHub release"
exit 1
fi
plan="$(
python3 scripts/plan_release_backmerge.py \
--release-commit "$release_commit" \
--dev-commit "$base_dev_sha"
)"
case "$plan" in
already-contained)
echo "outcome=noop" >> "$GITHUB_OUTPUT"
echo "## Release backmerge not needed" >> "$GITHUB_STEP_SUMMARY"
echo "\`$RELEASE_TAG\` is already contained in \`dev\`." >> "$GITHUB_STEP_SUMMARY"
exit 0
;;
normal-release)
echo "outcome=noop" >> "$GITHUB_OUTPUT"
echo "## Normal release: no backmerge" >> "$GITHUB_STEP_SUMMARY"
echo "The released merge's integration parent is already contained in \`dev\`." >> "$GITHUB_STEP_SUMMARY"
exit 0
;;
hotfix) ;;
*)
echo "::error::Unknown release-backmerge plan: $plan"
exit 1
;;
esac
candidate_branch="chore/release-backmerge/${RELEASE_TAG}-${GITHUB_RUN_ID}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch --detach "$base_dev_sha"
set +e
git merge --no-ff -m "chore: back-merge ${RELEASE_TAG}" "$release_commit"
merge_status=$?
set -e
if [ "$merge_status" -ne 0 ]; then
conflicts="$(git diff --name-only --diff-filter=U | paste -sd ', ' -)"
echo "outcome=conflict" >> "$GITHUB_OUTPUT"
echo "::error::Automatic backmerge conflicts: ${conflicts:-unknown}. Open a reconciliation PR."
echo "## Manual reconciliation PR required" >> "$GITHUB_STEP_SUMMARY"
echo "\`$RELEASE_TAG\` conflicts with current \`dev\`: ${conflicts:-unknown}." >> "$GITHUB_STEP_SUMMARY"
git merge --abort || true
exit 1
fi
candidate_sha="$(git rev-parse HEAD)"
first_parent="$(git rev-parse HEAD^1)"
second_parent="$(git rev-parse HEAD^2)"
if [ "$first_parent" != "$base_dev_sha" ] || [ "$second_parent" != "$release_commit" ]; then
echo "::error::Candidate parents do not match dev + release commit"
exit 1
fi
git push origin "$candidate_sha:refs/heads/$candidate_branch"
echo "outcome=candidate" >> "$GITHUB_OUTPUT"
echo "candidate_branch=$candidate_branch" >> "$GITHUB_OUTPUT"
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
echo "## Backmerge candidate prepared" >> "$GITHUB_STEP_SUMMARY"
echo "- Release: \`$RELEASE_TAG\` (\`$release_commit\`)" >> "$GITHUB_STEP_SUMMARY"
echo "- Dev base: \`$base_dev_sha\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Candidate: \`$candidate_sha\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Temporary ref: \`$candidate_branch\`" >> "$GITHUB_STEP_SUMMARY"
gate:
name: Run exact-tree required checks
needs: prepare
if: needs.prepare.outputs.outcome == 'candidate'
permissions:
actions: write
contents: read
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
check_run_id: ${{ steps.gate.outputs.check_run_id }}
steps:
- name: Dispatch and await Required checks
id: gate
env:
GH_TOKEN: ${{ github.token }}
BASE_DEV_SHA: ${{ needs.prepare.outputs.base_dev_sha }}
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
shell: bash
run: |
set -euo pipefail
gh workflow run ci-required.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "$CANDIDATE_BRANCH" \
-f base_sha="$BASE_DEV_SHA" \
-f head_sha="$CANDIDATE_SHA"
check_run_id=""
for _ in {1..20}; do
check_run_id="$(
gh run list \
--repo "$GITHUB_REPOSITORY" \
--workflow ci-required.yml \
--branch "$CANDIDATE_BRANCH" \
--event workflow_dispatch \
--limit 20 \
--json databaseId,headSha \
--jq ".[] | select(.headSha == \"$CANDIDATE_SHA\") | .databaseId" \
| head -n 1
)"
if [ -n "$check_run_id" ]; then
break
fi
sleep 3
done
if [ -z "$check_run_id" ]; then
echo "::error::Required checks dispatch was not observed for $CANDIDATE_SHA"
exit 1
fi
echo "check_run_id=$check_run_id" >> "$GITHUB_OUTPUT"
gh run watch "$check_run_id" --repo "$GITHUB_REPOSITORY" --exit-status
promote:
name: Compare-and-swap dev
needs: [prepare, gate]
if: needs.prepare.outputs.outcome == 'candidate' && needs.gate.result == 'success'
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: main
- name: Fast-forward dev to the tested candidate
env:
BASE_DEV_SHA: ${{ needs.prepare.outputs.base_dev_sha }}
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
RELEASE_COMMIT: ${{ needs.prepare.outputs.release_commit }}
shell: bash
run: |
set -euo pipefail
git fetch origin --no-tags \
"+refs/heads/dev:refs/remotes/origin/dev" \
"+refs/heads/$CANDIDATE_BRANCH:refs/remotes/origin/$CANDIDATE_BRANCH"
current_dev="$(git rev-parse origin/dev)"
remote_candidate="$(git rev-parse "origin/$CANDIDATE_BRANCH")"
if [ "$current_dev" != "$BASE_DEV_SHA" ]; then
echo "::error::dev moved from $BASE_DEV_SHA to $current_dev; rerun or open a reconciliation PR"
exit 1
fi
if [ "$remote_candidate" != "$CANDIDATE_SHA" ]; then
echo "::error::Candidate ref moved from $CANDIDATE_SHA to $remote_candidate"
exit 1
fi
if [ "$(git rev-parse "$CANDIDATE_SHA^1")" != "$BASE_DEV_SHA" ] || \
[ "$(git rev-parse "$CANDIDATE_SHA^2")" != "$RELEASE_COMMIT" ]; then
echo "::error::Candidate ancestry changed after verification"
exit 1
fi
# The explicit lease is the atomic stale-base guard. The update is a
# fast-forward from BASE_DEV_SHA; no unrelated history can be replaced.
git push \
--force-with-lease="refs/heads/dev:$BASE_DEV_SHA" \
origin "$CANDIDATE_SHA:refs/heads/dev"
git push origin --delete "$CANDIDATE_BRANCH" || \
echo "::warning::Could not remove temporary branch $CANDIDATE_BRANCH"
echo "## Release backmerge complete" >> "$GITHUB_STEP_SUMMARY"
echo "Fast-forwarded \`dev\` from \`$BASE_DEV_SHA\` to tested merge \`$CANDIDATE_SHA\`." >> "$GITHUB_STEP_SUMMARY"
fallback:
name: Report PR fallback
needs: [prepare, gate, promote]
if: always() && needs.prepare.outputs.outcome == 'candidate' && needs.promote.result != 'success'
runs-on: ubuntu-latest
steps:
- name: Preserve safe fallback instructions
env:
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
CHECK_RUN_ID: ${{ needs.gate.outputs.check_run_id }}
run: |
echo "## Automatic backmerge stopped" >> "$GITHUB_STEP_SUMMARY"
echo "\`dev\` was not updated. Open or refresh a reconciliation PR after addressing the failed/stale gate." >> "$GITHUB_STEP_SUMMARY"
echo "- Candidate ref: \`${CANDIDATE_BRANCH:-not-created}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Candidate SHA: \`${CANDIDATE_SHA:-n/a}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Required-check run: \`${CHECK_RUN_ID:-n/a}\`" >> "$GITHUB_STEP_SUMMARY"
+92 -4
View File
@@ -117,6 +117,9 @@ jobs:
- name: Build Linux x64
run: npm run build:bin:linux
- name: Build Linux arm64
run: npm run build:bin:linux-arm
- name: Build macOS x64
run: npm run build:bin:mac-x64
@@ -138,19 +141,27 @@ jobs:
- name: Smoke-test Linux binary
run: |
set -e
set -euo pipefail
chmod +x dist/bin/hermes-relay-linux-x64
for cmd in --version --help doctor; do
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1 || true)
set +e
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1)
exit_code=$?
if [ -z "$out" ] || [ ${#out} -lt 10 ]; then
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' produced no output (exit=$exit_code)"
set -e
if [ "$exit_code" -ne 0 ] || [ -z "$out" ] || [ ${#out} -lt 10 ]; then
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' failed or produced no output (exit=$exit_code)"
echo "Raw output was: [$out]"
exit 1
fi
echo " smoke OK: $cmd -> $(echo "$out" | head -1)"
done
- name: Verify Linux arm64 artifact architecture
run: |
set -euo pipefail
file dist/bin/hermes-relay-linux-arm64 | tee /tmp/hermes-relay-linux-arm64.file
grep -Eq 'ELF 64-bit.*(ARM aarch64|ARM64)' /tmp/hermes-relay-linux-arm64.file
- name: Upload CLI release assets
uses: actions/upload-artifact@v4
with:
@@ -158,6 +169,7 @@ jobs:
path: |
desktop/dist/bin/hermes-relay-win-x64.exe
desktop/dist/bin/hermes-relay-linux-x64
desktop/dist/bin/hermes-relay-linux-arm64
desktop/dist/bin/hermes-relay-darwin-x64
desktop/dist/bin/hermes-relay-darwin-arm64
retention-days: 7
@@ -196,6 +208,60 @@ jobs:
throw "Windows CLI smoke left $(@($leftovers).Count) process(es) behind"
}
smoke-macos-cli-release-asset:
name: Smoke exact macOS CLI release asset
runs-on: macos-latest
needs:
- validate-release
- build-cli-binaries
steps:
- uses: actions/download-artifact@v8
with:
name: cli-binaries
path: release-assets
- name: Launch native release asset and inspect both architectures
env:
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
run: |
set -euo pipefail
case "$(uname -m)" in
x86_64) native_asset=hermes-relay-darwin-x64 ;;
arm64) native_asset=hermes-relay-darwin-arm64 ;;
*) echo "Unsupported macOS runner architecture: $(uname -m)" >&2; exit 1 ;;
esac
chmod +x "release-assets/$native_asset"
version_output=$("release-assets/$native_asset" --version)
test "$version_output" = "hermes-relay $EXPECTED_DESKTOP_VERSION"
"release-assets/$native_asset" --help | grep -Fq 'Usage:'
file release-assets/hermes-relay-darwin-x64 | grep -Fq 'x86_64'
file release-assets/hermes-relay-darwin-arm64 | grep -Eq '(arm64|arm64e)'
smoke-linux-arm64-cli-release-asset:
name: Smoke exact Linux arm64 CLI release asset
runs-on: ubuntu-24.04-arm
needs:
- validate-release
- build-cli-binaries
steps:
- uses: actions/download-artifact@v8
with:
name: cli-binaries
path: release-assets
- name: Launch native arm64 release asset
env:
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
run: |
set -euo pipefail
asset=release-assets/hermes-relay-linux-arm64
test "$(uname -m)" = "aarch64"
chmod +x "$asset"
version_output=$("$asset" --version)
test "$version_output" = "hermes-relay $EXPECTED_DESKTOP_VERSION"
"$asset" --help | grep -Fq 'Usage:'
file "$asset" | grep -Eq 'ELF 64-bit.*(ARM aarch64|ARM64)'
build-windows-tray-installer:
name: Build Windows tray installer
runs-on: windows-latest
@@ -419,6 +485,8 @@ jobs:
needs:
- build-cli-binaries
- smoke-windows-cli-release-asset
- smoke-macos-cli-release-asset
- smoke-linux-arm64-cli-release-asset
- build-windows-tray-installer
steps:
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
@@ -466,7 +534,27 @@ jobs:
files: |
release-assets/cli-binaries/hermes-relay-win-x64.exe
release-assets/cli-binaries/hermes-relay-linux-x64
release-assets/cli-binaries/hermes-relay-linux-arm64
release-assets/cli-binaries/hermes-relay-darwin-x64
release-assets/cli-binaries/hermes-relay-darwin-arm64
release-assets/cli-windows-installer/hermes-relay-windows-x64-setup.exe
release-assets/SHA256SUMS.txt
request-backmerge:
name: Request stable release backmerge
needs: [validate-release, publish-release]
if: ${{ !contains(needs.validate-release.outputs.version, '-') }}
permissions:
actions: write
contents: read
runs-on: ubuntu-latest
steps:
- name: Dispatch fail-closed release reconciliation
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: desktop-v${{ needs.validate-release.outputs.version }}
run: |
gh workflow run release-backmerge.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f release_tag="$RELEASE_TAG"
+19
View File
@@ -138,3 +138,22 @@ jobs:
dist/*.whl
dist/*.tar.gz
dist/SHA256SUMS.txt
request-backmerge:
name: Request stable release backmerge
needs: [validate, package]
if: ${{ !contains(needs.validate.outputs.version, '-') }}
permissions:
actions: write
contents: read
runs-on: ubuntu-latest
steps:
- name: Dispatch fail-closed release reconciliation
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: server-v${{ needs.validate.outputs.version }}
run: |
gh workflow run release-backmerge.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f release_tag="$RELEASE_TAG"
+1
View File
@@ -95,3 +95,4 @@ keystore.properties
desktop/tray/ui/vendor/
# Generated from assets/screenshots/02_chat.png before docs dev/build.
/user-docs/public/chat-demo.png
/user-docs/public/product/desktop-ui/
+9 -1
View File
@@ -28,7 +28,7 @@ not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
| 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 |
| Back-merge target | `dev`; stable hotfixes reconcile automatically when the exact tested merge is conflict-free, otherwise through a PR |
Feature completion means merged and verified on `dev`; it does not mean
released. A release train is separate work owned by a Forge release
@@ -37,6 +37,14 @@ 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.
A normal `dev` → `main` release needs no back-merge: the released integration
parent is already in `dev`. A production-tag hotfix is different. After its
stable release succeeds, `Release Backmerge` prepares a `dev`-first merge
commit, runs the same path-aware required checks on that exact SHA, verifies
that `dev` has not moved, and fast-forwards `dev`. Conflicts, failed checks,
stale refs, or denied branch updates fail closed and require a reconciliation
PR; never resolve those cases by choosing a side automatically.
### Local integration discipline
- Fetch `origin/dev` before creating a task branch or worktree; do not base new
+56 -4
View File
@@ -6,19 +6,72 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [Android 1.13.2] - 2026-08-25
### Added
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
### Fixed
- **Android session rows stay neutral when optional live activity is unavailable or still loading.** Directory refreshes no longer restore a persistent Checking state, and full-row activity borders are reserved for actual Starting or Working turns.
- **Returning from parent settings keeps Supervised Chat rendered.** Parent access now relocks without rebuilding the active navigation graph, and full Settings keeps a prominent shortcut back to Supervised Mode controls.
## [Android 1.13.1] - 2026-08-25
### Fixed
- **Android session activity now follows live Hermes runtime truth.** Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work no longer come from the Dashboard's five-minute recency hint, and only complete, unambiguously resolved live snapshots clear stale state.
## [Android 1.13.0] - 2026-08-25
### Added
- **Provider usage and limits are available from top-level Settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden presentation modes. Provider credentials remain on the Hermes host.
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
### Changed
- **Release and candidate names use one public product hierarchy.** Future releases use `Hermes-Relay Android`, `Hermes-Relay Plugin`, or `Hermes-Relay CLI+UI` display names, while isolated Android review and release-candidate installs use `HR Candidate`, without changing immutable tags, package identities, updater contracts, or artifact filenames.
- **Review candidates are an explicit PR opt-in with one trusted handoff comment.** Maintainers can apply `review-candidate` for exact-head Android and Relay bundles; a separate reporter updates the PR with the artifact, expiry, source SHA, and bounded review instructions without executing fork code with write permission.
- **Unlabeled PR updates no longer receive false candidate-failure comments.** The trusted reporter ignores skipped review-bundle workflow shells before reading artifacts or writing to a PR.
- **Android releases and review candidates use clear public product names.** Stable builds use `Hermes-Relay Android`, while isolated review installs use `HR Candidate` without changing package identities or update contracts.
- **Review candidates are explicit and source-pinned.** Maintainers can opt a PR into a matched Android and Relay bundle with checksums, expiry, source SHA, and bounded review instructions.
### Fixed
- **Unlabeled PR updates no longer receive false candidate-failure comments.** The trusted reporter ignores skipped review-bundle workflow shells before reading artifacts or writing to a PR.
- **Android chats no longer retain a stale busy composer.** A completed Gateway bubble settles automatically when its exact session has no live or detached turn, new-chat navigation clears stale visible ownership, and Stop remains an immediate escape hatch. (#416, #418)
- **README and Google Play onboarding now match the Dashboard-first product path.** Public setup copy names the two separate Dashboard QR actions, treats the API server as an advanced fallback, explains the encouraged Hermes-Relay extension without implying Play includes Device Control, and ships one current deterministic Android screenshot set.
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
- **Android retries Windows-hosted `MEDIA:` attachments through Relay's by-path route.** A document deferred on cellular no longer treats `C:\...` as an opaque media token and reports it as expired.
## [Plugin 1.10.0] - 2026-08-25
### Added
- **Relay provides normalized provider usage without exposing credentials.** The authenticated Dashboard route resolves the active Codex pool entry, structured Nous balances, and OpenCode Go windows on the Hermes host; explicitly enabled paired clients receive the same provider-neutral schema.
### Changed
- **Plugin releases use the `Hermes-Relay Plugin` public name.** The display name is aligned with Android and CLI+UI while the `server-v*` compatibility tag remains unchanged.
### Fixed
- **Relay profile discovery follows `HERMES_HOME` by default.** Custom Hermes installations surface their real default profile and persist Relay sessions beside the active config while retaining the explicit `RELAY_HERMES_CONFIG` override.
## [0.4.0-beta.5] - 2026-08-25
### Added
- **Desktop releases now include a Linux ARM64 CLI artifact.** The one-line installer, updater, checksums, release publication, architecture validation, and platform documentation all recognize the same `linux-arm64` binary.
- **The public site now shows the real Windows CLI UI and guides each surface through first use.** Deterministic public-safe screenshots cover connection, host access, activity, computer control, and updates.
### Changed
- **Desktop releases use the `Hermes-Relay CLI+UI` public name.** The beta keeps its existing `desktop-v*` tag and updater contract.
### Fixed
- **Desktop install and update discovery remains reliable in a multi-surface release repository.** Every resolver paginates GitHub releases before choosing the SemVer maximum, Windows cooperative updates clean their released backup, unsigned preview installers retain the normal SmartScreen warning, and release smoke tests preserve real exit codes.
- **Desktop daemon connections recover instead of exiting after an interrupted Relay socket.** Healthy daemons retry through Relay restarts and repeated failed reconnect attempts, oversized desktop-tool results fail within a bounded response instead of closing the shared WebSocket, and terminal failures leave an accurate stopped status for the tray.
- **Desktop computer control follows Hermes' current CUA Driver contract.** CUA Driver 0.20 and newer are accepted when their manifest, daemon/MCP arguments, required tools, and canonical path remain compatible, and Windows sessions use the manifest-declared direct standard-mode runtime instead of a potentially stale machine-wide daemon. Current 0.21 installations no longer fall back solely because of an obsolete upper version pin or daemon contract.
@@ -74,7 +127,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Android screen-on idle no longer continuously redraws the ASCII sphere.** Idle holds a stable frame while thinking, streaming, and voice states retain full-rate motion; inactive voice waveforms and closed session drawers also stop their frame loops.
- **Android capture and audio effects release power-sensitive resources at their actual lifecycle boundaries.** Screen capture attaches its MediaProjection surface only for a requested frame, unattended Bridge wake locks release when the command finishes, and barge-in AEC/noise suppression attach to the microphone capture session instead of playback.
- **Experimental wake-word listening reuses its PCM normalization buffer.** Continuous opt-in listening no longer allocates a new float frame for every inference call.
## [1.10.0] - 2026-08-18
### Added
+22 -5
View File
@@ -1,17 +1,33 @@
# Hermes-Relay CLI+UI v__VERSION__
**Release Date:** 2026-08-15
**Release Date:** 2026-08-25
This patch keeps the Windows management UI usable when the Relay daemon is stopped or its status cannot be read.
This beta makes the Desktop connector resilient through Relay interruptions,
aligns Windows computer control with current CUA Driver releases, adds a native
Linux ARM64 build, and hardens installation and update discovery.
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64; the management UI is Windows-only.
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64; the management UI is Windows-only.
## What's changed
### Added
- **Linux ARM64 is a first-class release target.** The one-line installer,
updater, checksums, and release artifacts now cover both Linux x64 and arm64.
- **The public site shows the real Windows CLI UI.** Deterministic screenshots
cover connections, host access, activity, computer control, and updates.
### Changed
- **Public naming is aligned.** Releases use `Hermes-Relay CLI+UI` while the
beta keeps its existing `desktop-v*` tag and updater contract.
### Fixed
- **Stopped daemons no longer block the management UI.** Missing, stale, malformed, or temporarily unavailable daemon status falls back to an explicit stopped state while hosts, settings, activity, CLI details, diagnostics, and daemon controls continue loading normally.
- **Starting the daemon restores live status without reopening the UI.** A valid running status continues through the same bounded, single-flight snapshot path introduced in beta.3.
- **The daemon reconnects instead of exiting after an interrupted Relay socket.** Relay restarts and repeated transient replacement failures stay on bounded automatic backoff, and terminal failures persist an accurate stopped reason for the UI.
- **Oversized desktop-tool output no longer closes the shared connection.** PowerShell output and every serialized desktop response stay inside the Relay WebSocket budget.
- **Current CUA Driver releases remain compatible by contract.** Driver 0.20 and newer are accepted when their manifest and required tools match Hermes, and Windows uses the manifest-declared direct standard-mode runtime instead of a stale machine-wide daemon.
- **Install and update discovery paginates the multi-surface release history.** Desktop releases remain discoverable after more Android and Server releases, Windows cooperative updates clean their released backup, and unsigned installers retain the normal SmartScreen warning.
## Install
@@ -42,6 +58,7 @@ hermes-relay --version
hermes-relay hosts list --json
hermes-relay daemon start
hermes-relay daemon status --json
hermes-relay computer-use status --json
```
On Windows, click the Hermes-Relay CLI UI notification-area icon to open the management popup directly above it.
+12
View File
@@ -62,6 +62,18 @@ automotive device verified foreground preservation, AssistStructure and screensh
delivery, immediate listening, contextual response, and one-shot consumption;
broader firmware certification remains tracked in `TODO.md`.
## 2026-08-23 — Windows attachment retry and Hermes-home resolution
Android now recognizes Windows absolute paths during manual inbound-media retry.
Cellular-deferred `MEDIA:C:\...` documents use Relay's authenticated
`/media/by-path` route instead of being sent to the opaque-token route and
misreported as expired. A Robolectric/MockWebServer regression covers a spaced
Markdown filename and asserts the exact route and decoded path query.
Relay configuration now derives its default `config.yaml` and session-persistence
paths from `HERMES_HOME` when present. `RELAY_HERMES_CONFIG` remains the explicit
override. Focused Python tests cover both resolution paths.
## 2026-08-23 — GitHub Discussions community surface
GitHub Discussions is enabled as the repository's lightweight community surface.
+10 -5
View File
@@ -1,21 +1,26 @@
# Hermes-Relay Plugin v__VERSION__
**Release Date:** August 21, 2026
**Release Date:** August 25, 2026
## Summary
This release makes delayed phone delivery and active Bridge access easier to understand. Relay now identifies messages flushed after reconnect, emits one completion signal for the backlog, and reports permanent, timed, and unlimited phone capabilities through status surfaces.
This release adds a provider-neutral account-usage surface for Android and Dashboard clients. Relay resolves Codex credential pools, structured Nous balances, and OpenCode Go windows on the Hermes host without returning provider credentials.
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
## Added
- **Reconnect backlog context.** Messages flushed from the bounded offline queue carry an explicit delayed-delivery marker, followed by one ordered completion event with the delivered count.
- **Granular phone capability status.** Relay status and `android_phone_status` report permanent, timed, and unlimited Bridge capabilities alongside existing Android permissions and safety state.
- **Provider-neutral usage snapshots.** Authenticated Dashboard clients can resolve the exact active Codex pool entry, Nous balances, and OpenCode Go account windows through one normalized schema.
- **Bounded paired-client fallback.** Operators may explicitly enable the Relay usage route for paired standalone clients while credentials remain host-side.
## Changed
- **Phone surfacing semantics are explicit.** Default delivery persists to Threads and notifies, Inbox delivery remains silent, and Session delivery targets an available active conversation before falling back to a notification.
- **Usage capabilities are explicit.** Responses identify Relay-enhanced credential pools, structured balances, and provider adapters instead of implying unsupported upstream data.
- **Public product naming is aligned.** Releases use `Hermes-Relay Plugin` while retaining the `server-v*` tag and installation contract.
## Fixed
- **Custom Hermes homes resolve correctly.** Relay profile discovery and session persistence follow `HERMES_HOME` by default while preserving the explicit `RELAY_HERMES_CONFIG` override.
## Install / update
+50 -50
View File
@@ -17,7 +17,7 @@
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Android-8.0%2B-3DDC84.svg?logo=android&logoColor=white" alt="Android 8.0+"></a>
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
<a href="https://github.com/Codename-11/hermes-relay/releases"><img src="https://img.shields.io/github/v/release/Codename-11/hermes-relay?filter=android-v*&label=release&color=8B5CF6" alt="Latest release"></a>
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-alpha-orange.svg" alt="CLI (alpha)"></a>
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-beta-756cff.svg" alt="CLI (beta)"></a>
</p>
<p align="center">
@@ -36,12 +36,12 @@
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
- **📱 Android app** — streaming chat, hands-free voice, native plugin pages, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. Add a floating Petdex companion or optionally make Hermes your Android assistant; sideload builds can also let the agent read and act on your screen.
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
- **⌨️ Hermes-Relay CLI** *(beta)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, voice, Petdex, and ordinary installed-plugin pages need **no Relay plugin**. Add the optional Relay only when you want terminal, phone control, agent-created page drafts, or the CLI's tools. **Pair once from either surface; both work.**
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough for the upstream standard path: chat, management, voice, Petdex, and ordinary installed-plugin pages. The Hermes-Relay plugin is optional for that base but encouraged for the complete current experience: Terminal/TUI, notifications, media, desktop tools, enhanced voice, Relay sessions, page drafts, and optional Device Control. Hermes-Relay prefers compatible upstream surfaces as they become available instead of keeping duplicate extension paths. **Connect Hermes first, then grant Hermes-Relay separately; the same one-time invite contract pairs Android or the Desktop CLI.**
<p align="center">
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — Vanilla Hermes (Chat, Manage, Voice) runs with no plugin; the optional Relay plugin adds Terminal, Bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build." width="900">
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — upstream Hermes owns Chat, Manage, and standard Voice; the encouraged Relay extension fills current gaps for Terminal, notifications, media, enhanced voice, sessions, desktop tools, and optional Device Control." width="900">
</p>
## Quick Start (Android)
@@ -50,7 +50,7 @@ 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.
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, sessions, and Manage work with standard Hermes; pairing the Hermes-Relay plugin adds 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://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://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
@@ -71,27 +71,21 @@ an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/gui
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.
For plugin-enabled setups, optional **Hermes Secure Link** presents Relay, API,
and Dashboard routes through one pairing-pinned TLS origin. It protects traffic
to the paired endpoint while each service keeps its own authentication; it does
not provide reachability or independently identify the physical host. You still
use LAN routing, Tailscale or another VPN, or an operator-managed public route
to reach the listener. Secure Link is off by default and requires a fresh QR
pairing after it is enabled. See the
[remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/).
**Hermes Reach** is an experimental, advanced outbound-broker route. It remains
available for development and self-hosted evaluation, but it is disabled by
default, ordered after supported routes, and not recommended for normal remote
access. Use Tailscale for the easiest supported remote setup, or a public TLS
domain / Direct Secure Link when you want to own the complete network path.
Start on a trusted LAN. For away-from-home access, Tailscale is the recommended
path. Secure Link, public TLS, and experimental routing options are covered in
the [remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/).
### 3 · Connect and talk
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.
For a plugin-enabled host, open the Web Dashboard's **Relay** page, click
**Connect mobile app**, and scan that tokenless QR from Android **Connect → Scan
Hermes setup QR**. It contains only the Dashboard address and configures the
upstream Chat, sessions, Manage, sign-in, and standard voice connection.
Without the Dashboard plugin, use **Find Hermes on LAN** or enter the Dashboard
address manually (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.
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
@@ -106,49 +100,47 @@ The wizard probes everything and finishes with a capability card:
| **Manage** | Models, keys, skills, and profiles are available from the phone |
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
| **API fallback** | Optional API route available/unavailable |
| **Relay** | Optional extensions — fine to leave unpaired |
| **Relay** | Recommended extensions paired/unpaired; never blocks the upstream path |
One dashboard sign-in unlocks Chat, Manage, sessions, and standard voice. That's
the whole Vanilla Hermes setup.
> **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
### 4 · Recommended: pair Relay for the complete experience
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, the realtime voice engine, or approval-gated agent-created plugin-page drafts:
Install Relay for Terminal/TUI, notifications, media handoff, desktop tools,
enhanced voice, Relay sessions, approval-gated page drafts, and optional Device
Control:
```bash
hermes plugins install Codename-11/hermes-relay/plugin --enable
hermes relay doctor
hermes relay start --no-ssl
hermes pair
```
Use the legacy installer instead if you also want the systemd user service,
shell shims, and the full clone/update workflow:
Use `--no-ssl` only on a trusted LAN or VPN. Use the
[remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/) before
exposing any Hermes surface beyond that network.
Refresh or restart the Dashboard/Gateway, open **Relay → Pair new device**, and
scan the one-time QR from Android **Settings → Connections → Pair Hermes Relay**.
Leave mode on **Auto** for the recommended route discovery. The same dialog
shows a copyable invite for Desktop CLI clients:
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
hermes-relay pair --pair-qr "hermes-relay://pair?payload=…" --grant-tools
```
Installed Hermes plugins can expose bounded, host-rendered pages to Android
through the authenticated Dashboard without running plugin code on the phone.
Relay 1.5.0 additionally supports approval-gated agent-created page drafts. The
plugin-manager install owns the plugin code, dashboard tab, CLI commands, and
agent tools. `hermes relay compat status/install/remove` manages only the
optional legacy API compatibility hook when an older Hermes build needs it. Scan
the QR from the phone's Connections screen — or use
`hermes pair --register-code ABCD12` with the manual code from Android
**Settings → Connections → Advanced**.
As alternatives, `hermes pair` renders the same Android QR and pasteable invite
in a terminal, while URL + six-character code and `--register-code` remain
manual fallbacks when QR or clipboard transfer is unavailable.
- **Plugin-manager uninstall:** `hermes relay compat remove --all` if you installed the optional hook, then `hermes plugins remove hermes-relay`.
- **Legacy installer update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
- **Legacy installer uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — removes the service, shims, clone, external skill path, editable package, and compat hook. It never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a **Relay** tab (paired devices, bridge activity, media tokens) appears in the web UI.
**Next:** [Android + Hermes-Relay Quick Start](https://hermes-relay.dev/docs/guide/quick-start) ·
[Desktop CLI pairing](https://hermes-relay.dev/docs/desktop/pairing) ·
[server, TLS, legacy install, and uninstall reference](https://hermes-relay.dev/docs/reference/relay-server)
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 Dashboard/Gateway enabled · Python 3.11+ on the server. The API server and Relay are optional.
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ when installing the Hermes-Relay plugin. The API fallback is optional; the Hermes-Relay plugin is encouraged for the complete experience.
## Screenshots
@@ -193,16 +185,16 @@ tracked independently so community corrections remain easy to contribute.
- **Hands-free voice** — talk on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice and an opt-in provider-native Realtime Agent with background task handoff.
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
- **Multi-Connection + profiles** — pair multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay a profile's model + `SOUL.md` per chat.
- **Phone control (bridge)** — with Relay paired, the agent reads the screen and acts: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros. Guarded by per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
- **Device Control (Sideload + Hermes-Relay required)** — the agent can read the screen and act: tap, type, swipe, scroll, screenshots, clipboard, media keys, and batched macros. This is not included in the Google Play build. It is guarded by a per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
- **Notification companion** — opt-in access so the agent can triage, summarize, and route incoming notifications.
- **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://hermes-relay.dev/docs/guide/release-tracks).
## Hands on any machine — the Hermes-Relay CLI&nbsp;<sub>(alpha)</sub>
## Hands on any machine — the Hermes-Relay CLI&nbsp;<sub>(beta)</sub>
> **Alpha.** Self-contained CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64 — no Node required. Windows also has an optional compact management tray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
> **Beta.** Self-contained CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64 — no Node required. Windows also has an optional compact management tray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
@@ -220,6 +212,14 @@ It pairs against the **same relay and credential store** as the Android app —
On Windows, the default installer adds the optional compact **Hermes-Relay CLI UI** tray popup for host selection and pairing, connection and daemon state, per-host Ask/Trusted/Full Access, local grant dialogs, authorized-client revocation, activity, settings, and emergency stop. It is a management surface only—chat, TUI, plugins, voice, and agent sessions remain CLI/upstream concerns.
<table>
<tr>
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/overview.png" alt="Hermes-Relay CLI UI connected overview" width="100%"><br><sub><b>Connection &amp; activity</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/host-access.png" alt="Hermes-Relay CLI UI host access presets" width="100%"><br><sub><b>Per-host access</b></sub></td>
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/settings.png" alt="Hermes-Relay CLI UI computer control and updates" width="100%"><br><sub><b>Control &amp; maintenance</b></sub></td>
</tr>
</table>
Structured Windows computer control prefers a compatible local CUA Driver
runtime for window-targeted background actions and virtual per-session agent
cursors. It remains behind Hermes host policy, grants, targeting, audit, and
@@ -348,7 +348,7 @@ hermes-relay/
<br>
End users should install via the [one-liner](#4--optional-install-relay-for-power-tools) above. For local development:
End users should follow the [recommended Hermes-Relay setup](#4--recommended-pair-relay-for-the-complete-experience) above. For local development:
```bash
hermes relay start --no-ssl # if you installed the plugin
+32 -10
View File
@@ -200,6 +200,11 @@ never create a staging branch. Stable production tags are cut only from the new
10. Build and publish that surface's artifacts, roll out or deploy from the
immutable tag, and verify the release and live environment.
Do not back-merge a normal release. The `main` release merge already has the
released `dev` tip as its integration parent, so merging it back only adds
history noise. The release-backmerge workflow detects this topology and exits
successfully without changing `dev`.
### Branch names
| Prefix | When | Example |
@@ -258,7 +263,9 @@ 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.
blocked. This is the normal contribution target. The release-backmerge
workflow is the sole exception: its automation identity may compare-and-swap
`dev` to an exact checked merge commit after a stable hotfix release.
- **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
@@ -922,8 +929,23 @@ When production has a bug, use the same invariant for every surface:
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.
7. Let the stable release workflow dispatch `Release Backmerge`. A
conflict-free candidate runs the same path-aware `Required checks` against
its exact SHA, then compare-and-swaps `dev` only if the base ref is unchanged.
Conflicts, failed checks, stale refs, or a denied update require a normal
reconciliation PR.
`Release Backmerge` accepts only published stable `android-v*`, `server-v*`, or
`desktop-v*` SemVer tags contained in `main`. It exits without mutation for a
normal release whose integration parent is already in `dev`. For a selective
hotfix, it pushes a temporary merge ref, dispatches `Required checks` with full
base/head SHAs, and updates `dev` with an explicit force-with-lease only after
that exact candidate passes. The lease is a compare-and-swap guard, not
permission to rewrite history: the candidate's first parent must be the
unchanged `dev` tip and its second parent the released commit. The repository
ruleset must allow this workflow's automation identity to perform that one
checked branch update; if it does not, the workflow fails closed and the
reconciliation uses a PR.
For an Android app hotfix:
@@ -938,21 +960,21 @@ For an Android app hotfix:
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. 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.
8. Verify the automated release backmerge completed. If it stopped, open a
reconciliation PR so `dev` picks up the hotfix and versionCode bump. Without
reconciliation, `dev`'s `appVersionCode` lags behind `main` and the next app
release bump collides.
For a Plugin hotfix, branch from the affected `server-v*` tag, apply
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
`main`, tag `server-v<next-version>`, verify the package/deployment, and merge
`main` back to `dev`. Do not touch
`main`, tag `server-v<next-version>`, verify the package/deployment, and verify
the automated release backmerge. Do not touch
`gradle/libs.versions.toml` unless an Android app release is also shipping.
For a CLI+UI 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`.
then verify the automated release backmerge or use the PR fallback.
## Troubleshooting
+12 -10
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.12.1
# Hermes-Relay Android v1.13.2
**Release Date:** August 22, 2026
**Release Date:** August 25, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.12.1-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.13.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,18 +12,20 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch makes Android sharing and recovery dependable. Shared links, text, images, and files open as complete reviewable drafts; connection renewal no longer stalls; offline and history failures are visible; and secure-storage recovery appears in Diagnostics.
This release adds a parent-configured Supervised Mode and improves its return from full settings. It also keeps session rows neutral until live activity is confirmed.
## Added
- Use a profile-pinned Supervised Mode with parent-controlled attachments, Standard voice, generated media, history, actions, and technical details. Device authentication protects full settings; this remains a client-side restricted view rather than a server-enforced account boundary.
## Fixed
- Open shared links, text, images, files, and mixed or multi-item shares as a fresh reviewable draft without sending automatically.
- Keep Add and Renew connection setup on the correct connection-scoped authentication store, with bounded Retry or Cancel recovery instead of an indefinite preparation screen.
- Surface unavailable chat routes and profile-history failures clearly instead of silently dropping Send or presenting missing history as an empty conversation.
- Report Android Keystore fallback, encrypted-store recovery, and temporary credential storage in Diagnostics without exposing credentials.
- Keep session rows neutral while optional live activity is unavailable or still loading, and reserve full-row activity borders for actual Starting or Working turns.
- Keep Supervised Chat rendered when parent access relocks after visiting full settings.
## Install / Verify
- App version: **1.12.1** (versionCode **48**).
- App version: **1.13.2** (versionCode **51**).
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
- The optional Relay plugin is not required for standard Android chat, sharing, session continuity, or Gateway recovery.
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
+26
View File
@@ -6,6 +6,32 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Certify Android session activity across lifecycle and profile boundaries
The contract fixture now covers every upstream live status, complete-snapshot
disappearance, client-side ownership of duplicate durable ids across profiles,
and older Gateways without `session.active_list`. Before calling the status
model device-certified:
- Exercise working, quiet tool-heavy work, each pending-input surface, normal
completion, Stop, reconnect, app restart, and process recreation against
current vanilla upstream.
- Verify All Profiles with duplicate session ids across two profiles and two
saved connections; no late snapshot or old socket generation may mark the
wrong row live.
- Confirm failed/unsupported refresh becomes Unavailable, restart revalidation
remains Checking, ambiguous or partially
resolved process-wide snapshots infer no absence, a complete empty snapshot
settles every unambiguously owned scope, and REST `is_active=true` never
renders as Working.
- Run a background process that outlives its parent turn and verify Background
work remains separate from the conversation's Idle state.
- Pursue an upstream `session.active_list` profile field/filter or an aggregate
activity route with explicit profile ownership so multi-profile clients do
not need to resolve process-wide rows from durable keys.
---
## Bot Mode follow-ups after multi-gateway aggregation
Android Bot Mode now has an all-gateway roster, typed `(connectionId, profile)`
@@ -1,62 +1,59 @@
Hermes-Relay is the native Android client for the Hermes agent platform. Point it at your own Hermes instance and chat with your agent, talk to it hands-free, and manage models, keys, skills, and profiles from anywhere.
Hermes-Relay is the native Android companion for the Hermes agent you run. Chat, talk hands-free, continue sessions, and manage models, keys, skills, profiles, and automations from your phone.
It is not a hosted AI service. It is a companion app for the Hermes agent you run, and it talks only to the instances you configure.
It is not a hosted AI service. Your Hermes agent stays on infrastructure you control, and the app talks only to instances you configure.
QUICK START
1. Run hermes-agent with its API server and dashboard enabled on your computer or home server.
2. Install Hermes-Relay and enter your server address, for example http://192.168.1.100:8642.
3. The setup wizard checks what your server supports and shows a readiness card, then you are ready to chat.
1. Start the Hermes Dashboard/Gateway on your computer or home server with hermes dashboard.
2. Install Hermes-Relay from Google Play.
3. For the recommended full setup, install the Hermes-Relay plugin on the host and refresh the Web Dashboard. A Relay page will appear.
4. Scan Connect mobile app from Android Connect. Then scan Pair new device from Android Settings > Connections.
A plain Hermes install is enough. Chat, management, and voice work with no plugin or extra service.
The QR codes are separate on purpose. Connect mobile app adds the standard Dashboard/Gateway connection. Pair new device grants a time-limited Hermes-Relay session for the additional capabilities you approve.
Standard Hermes without the plugin is supported. Choose Find Hermes on LAN or enter the Dashboard address you open in a browser, normally http://<host>:9119. Pair the Hermes-Relay plugin later when you want the full experience.
HOW IT WORKS
Chat streams directly from your Hermes API Server or dashboard gateway in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
Chat, sessions, Manage, sign-in, and standard voice use the unmodified Hermes Dashboard/Gateway. The separate Hermes API server is an optional fallback for advanced or headless setups; it is not required for the normal Android connection.
GOOGLE PLAY BUILD
The encouraged Hermes-Relay plugin adds Terminal/TUI, notifications, media handoff, enhanced voice, Relay sessions, desktop-tool handoff, and time-limited per-feature grants. When upstream Hermes provides a compatible capability, Hermes-Relay prefers it instead of duplicating it.
The Google Play build ships Hermes Bridge Core only. It has no AccessibilityService Device Control: it cannot read your screen, tap, type, swipe, screenshot, send SMS, place calls, or access contacts or location. Device Control is reserved for sideload builds distributed outside Google Play.
GOOGLE PLAY AND SIDELOAD
The Google Play build includes Chat, voice, sessions, Manage, profiles, notifications, media, and Terminal/TUI when the Hermes-Relay plugin is paired.
Google Play does not include Android Device Control. It cannot read the phone screen, tap, type, swipe, take device screenshots, send SMS, place calls, or access contacts or location.
Device Control is available only in the signed Sideload build on this project's GitHub Releases. It requires the Sideload app, a paired Hermes-Relay plugin, explicit Android accessibility permission, and the app's safety controls.
FEATURES
- Streaming Chat: real-time responses with reasoning, markdown, tool-call visibility, attachments, mid-turn steering, edit-and-resend, and a searchable command palette.
- Manage Your Agent: use your Hermes dashboard from your phone to switch models, manage provider keys, edit profiles, and browse, install, and update skills.
- Voice Mode: talk hands-free using your server's speech providers. Relay-paired setups add per-profile voices and an experimental realtime engine.
- Works Away From Home: add LAN, Tailscale, or public routes and the app chooses the best available path on connect.
- Sessions: create, switch, rename, and delete chats. Message history loads on demand.
- Multiple Servers and Profiles: connect to more than one server and switch in a tap; overlay an agent profile or personality per conversation.
- Relay Power Tools: optional QR pairing for remote terminal, relay-session management, media handoff, and per-feature grants.
- Notification Companion: optionally forward notification metadata to your paired relay so your assistant can summarize it. Toggle it anytime in system settings.
- Stats for Nerds: local-only counters for response timing, token usage, cost, and stream health.
- Material You: Material 3 dynamic color, light/dark/system themes, and haptics.
- Streaming Chat with reasoning, markdown, tool progress, attachments, mid-turn steering, edit-and-resend, and searchable commands.
- Manage models and provider keys, edit profiles, and browse, install, or update skills through the Hermes Dashboard.
- Hands-free voice through your server's speech providers. Hermes-Relay pairing adds per-profile voices and an experimental realtime engine.
- Create, switch, search, rename, pin, archive, and continue sessions.
- Connect multiple Hermes servers and switch in one tap; add LAN, Tailscale, or public routes.
- Pair the Hermes-Relay plugin for Terminal/TUI, notifications, media, enhanced voice, Relay sessions, and per-feature grants.
- Inspect connection readiness, routes, response timing, token usage, and stream health without exposing credentials.
SECURITY AND PRIVACY
- API keys and relay tokens are stored in encrypted Android storage.
- HTTPS is enforced for remote connections; cleartext is limited to localhost or LAN setups.
- Dashboard sessions and Hermes-Relay tokens use encrypted Android storage.
- Cleartext is limited to trusted local-network setups. Use a VPN or HTTPS remotely.
- No telemetry, ads, tracking, or third-party analytics SDKs.
- Notification access and the microphone are optional and user-controlled.
- All app traffic goes only to servers you configure.
- Notification and microphone access are optional and user-controlled.
- App traffic goes only to servers you configure.
REQUIREMENTS
- Android 8.0 or later.
- A running Hermes agent for chat, management, and voice.
- Optional Hermes relay service for power tools such as terminal, notifications, and media.
- Network access to your server by local network, VPN, or internet.
- A reachable Hermes Dashboard/Gateway.
- The Hermes-Relay plugin is encouraged for the complete experience but never blocks standard Hermes.
- Network access through a local network, VPN, or operator-managed internet route.
OPEN SOURCE
Hermes-Relay is MIT licensed. Source, docs, and issue tracking are on GitHub.
Hermes-Relay is MIT licensed. Source, setup guides, downloads, and issue tracking are on GitHub.
This app is a community project and is not affiliated with or endorsed by NousResearch.
This community project is not affiliated with or endorsed by NousResearch.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 168 KiB

@@ -1 +1 @@
Your Hermes AI agent, in your pocket - chat, voice, and control.
Your Hermes agent on Android — chat, voice, sessions, and Manage.
@@ -1 +1 @@
Shared links, text, images, and files now open as complete reviewable drafts without sending automatically. Add and Renew connection setup no longer stalls. Offline chat and profile-history failures surface clear recovery guidance instead of doing nothing or showing empty history. Diagnostics now reports secure-storage fallback and recovery without exposing credentials.
Supervised Mode adds a parent-configured, profile-pinned chat view with device-authenticated settings. Parents can limit attachments, Standard voice, generated media, history, actions, and technical details. Session rows stay neutral while live activity is unavailable, and returning from parent settings no longer blanks Supervised Chat.
@@ -1 +1 @@
共享链接、文本、图片和文件现在会作为完整、可检查的草稿打开,不会自动发送。添加或续订连接时不再卡在准备阶段。离线聊天和配置文件历史记录失败会显示明确的恢复提示,而不是无响应或显示空历史记录。诊断现在会报告安全存储降级与恢复,且不会暴露凭据。
新增监督模式:家长可配置并固定到指定配置文件,设置受设备身份验证保护。家长可限制附件、标准语音、生成媒体、历史记录、操作和技术详情。实时活动不可用时会话行保持中性显示,从家长设置返回时监督聊天也不再空白。
+63
View File
@@ -1,5 +1,68 @@
{
"versions": [
{
"version": "1.13.2",
"title": "Supervised Mode and clearer activity",
"date": "2026-08-25",
"sections": [
{
"header": "Use a supervised chat",
"bullets": [
"Configure a profile-pinned restricted chat with parent-controlled attachments, voice, media, history, actions, and technical details.",
"Protect full settings with device authentication and keep Supervised Chat visible when parent access relocks."
]
},
{
"header": "Show only confirmed activity",
"bullets": [
"Keep session rows neutral while optional live activity is unavailable or still loading.",
"Show full-row activity borders only during actual Starting or Working turns."
]
}
]
},
{
"version": "1.13.1",
"title": "Accurate session activity",
"date": "2026-08-25",
"sections": [
{
"header": "Follow live Hermes state",
"bullets": [
"Show Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work from live runtime state instead of a recent-activity estimate.",
"Keep stale activity visible until a complete, unambiguous snapshot safely clears it."
]
}
]
},
{
"version": "1.13.0",
"title": "Bots, usage, and reliable chat",
"date": "2026-08-25",
"sections": [
{
"header": "Talk across saved gateways",
"bullets": [
"Use Bot Mode as one messenger-style workspace for bots and read-only groups across saved Hermes gateways.",
"Keep every Bot Chat bound to its exact gateway and profile without changing the foreground connection."
]
},
{
"header": "Understand account limits",
"bullets": [
"Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage and limits screen.",
"Choose Summary, Expanded, or Hidden presentation while provider credentials remain on the Hermes host."
]
},
{
"header": "Keep chat and voice in context",
"bullets": [
"Settle orphaned Gateway busy state automatically while preserving another session's active or detached turn.",
"Include bounded visible text and an available screenshot in the first compatible Assistant voice turn."
]
}
]
},
{
"version": "1.12.1",
"title": "Sharing and recovery that work",
+4 -5
View File
@@ -1,6 +1,5 @@
v1.12.1 - Sharing and recovery that work
v1.13.2 - Supervised Mode and clearer activity
* Open shared links, text, images, and files as a reviewable draft without auto-sending.
* Add or renew a connection without getting stuck during secure setup.
* See clear recovery guidance when chat or profile history is unavailable.
* Find secret-free secure-storage fallback and recovery evidence in Diagnostics.
* Configure a profile-pinned Supervised Mode with device-authenticated parent settings.
* Keep Supervised Chat visible when parent access relocks after full settings.
* Keep uncertain session activity neutral until live work is confirmed.
@@ -11,6 +11,7 @@ import com.hermesandroid.relay.data.replaceHermesReachCredential
import com.hermesandroid.relay.data.sameBrokerAuthority
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.isSafeProfileUiMeta
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.models.Envelope
@@ -18,6 +19,8 @@ import com.hermesandroid.relay.network.shared.InvalidCredentialException
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -53,6 +56,39 @@ sealed class AuthState {
data class Failed(val reason: String) : AuthState()
}
internal fun relaySupervisedModePayload(policy: SupervisedModePolicy): JsonObject {
if (!policy.isActive) return buildJsonObject { put("active", false) }
val capabilities = buildList {
add("text_chat")
if (policy.capabilities.newChat) add("new_chat")
if (policy.capabilities.cancelResponse) add("cancel")
if (policy.capabilities.steerResponse) add("steer")
if (policy.capabilities.attachments) add("attachments")
if (policy.capabilities.voice) add("voice")
if (policy.capabilities.generatedImages) add("generated_images")
if (policy.capabilities.shareGeneratedImages) add("share_images")
if (policy.capabilities.copyResponses) add("copy")
if (policy.capabilities.retryResponse) add("retry")
if (policy.capabilities.quoteReplies) add("quote_reply")
if (policy.visibility.resolved().showTimestamps) add("timestamps")
}.take(12)
return buildJsonObject {
put("active", true)
put("profile_label", policy.pinnedProfileName.orEmpty().take(80))
put("capabilities", JsonArray(capabilities.map(::JsonPrimitive)))
}
}
internal fun relaySupervisedModeUpdateEnvelope(
policy: SupervisedModePolicy,
): Envelope = Envelope(
channel = "system",
type = "supervised.update",
payload = buildJsonObject {
put("supervised_mode", relaySupervisedModePayload(policy))
},
)
@Serializable
data class ConnectionAuthSecrets(
val sessionToken: String? = null,
@@ -120,6 +156,60 @@ class AuthManager(
private val eagerHydrate: Boolean = true,
) : ChannelMultiplexer.ChannelHandler {
@Volatile
private var supervisedMode: SupervisedModePolicy = SupervisedModePolicy()
@Volatile
private var supervisedMetadataReconnectFallback: (() -> Unit)? = null
private var pendingSupervisedUpdateId: String? = null
private var supervisedUpdateFallbackJob: Job? = null
/**
* Update the public client-mode tag sent on Relay auth. This does not grant
* authority: Relay labels enforcement_owner=android_client and the Android
* policy remains the enforcing surface.
*/
fun updateSupervisedMode(policy: SupervisedModePolicy) {
if (supervisedMode == policy) return
supervisedMode = policy
if (_authState.value is AuthState.Paired) sendSupervisedModeUpdate()
}
/**
* Install the narrow compatibility path used when an older Relay ignores
* `system/supervised.update`. Reopening the authenticated socket causes
* the current policy to travel through the legacy `system/auth` payload.
*/
fun setSupervisedMetadataReconnectFallback(callback: () -> Unit) {
supervisedMetadataReconnectFallback = callback
}
private fun sendSupervisedModeUpdate() {
val envelope = relaySupervisedModeUpdateEnvelope(supervisedMode)
pendingSupervisedUpdateId = envelope.id
supervisedUpdateFallbackJob?.cancel()
multiplexer.send(envelope)
supervisedUpdateFallbackJob = scope.launch {
delay(SUPERVISED_UPDATE_ACK_TIMEOUT_MS)
if (pendingSupervisedUpdateId == envelope.id) {
pendingSupervisedUpdateId = null
Log.i(TAG, "supervised.update unsupported or unacknowledged; refreshing Relay socket")
supervisedMetadataReconnectFallback?.invoke()
}
}
}
private fun settleSupervisedModeUpdate(envelope: Envelope, unsupported: Boolean) {
if (envelope.id != pendingSupervisedUpdateId) return
pendingSupervisedUpdateId = null
supervisedUpdateFallbackJob?.cancel()
supervisedUpdateFallbackJob = null
if (unsupported) {
Log.i(TAG, "supervised.update rejected; refreshing Relay socket for compatibility")
supervisedMetadataReconnectFallback?.invoke()
}
}
companion object {
private const val TAG = "AuthManager"
private const val KEY_SESSION_TOKEN = "session_token"
@@ -134,6 +224,7 @@ class AuthManager(
// migration has run, so we never rebuild the legacy keyset to re-check.
private const val KEY_LEGACY_MIGRATED = "legacy_migrated"
private const val PAIRING_CODE_LENGTH = 6
private const val SUPERVISED_UPDATE_ACK_TIMEOUT_MS = 2_000L
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
/**
@@ -835,6 +926,10 @@ class AuthManager(
put("device_form_factor", "phone")
}
private fun JsonObjectBuilder.putSupervisedMode() {
put("supervised_mode", relaySupervisedModePayload(supervisedMode))
}
private fun relayDeviceName(): String {
val configured = runCatching {
Settings.Global.getString(context.contentResolver, "device_name")
@@ -890,6 +985,7 @@ class AuthManager(
put("device_id", deviceId)
putRelayDeviceIdentity()
putRelayClientSupports()
putSupervisedMode()
}
}
else -> {
@@ -906,6 +1002,7 @@ class AuthManager(
put("device_id", deviceId)
putRelayDeviceIdentity()
putRelayClientSupports()
putSupervisedMode()
pendingTtlSeconds?.let { put("ttl_seconds", it) }
pendingGrants?.let { grants ->
val obj = buildJsonObject {
@@ -985,6 +1082,8 @@ class AuthManager(
when (envelope.type) {
"auth.ok" -> handleAuthOk(envelope)
"auth.fail" -> handleAuthFail(envelope)
"supervised.updated" -> settleSupervisedModeUpdate(envelope, unsupported = false)
"error" -> settleSupervisedModeUpdate(envelope, unsupported = true)
// `profiles.updated` push — sent by the v0.7.1+ relay on
// the "pairing" channel whenever its in-memory profile
// snapshot changes (file-watcher, SIGHUP, or a manual
@@ -1129,6 +1228,11 @@ class AuthManager(
get() = _authState.value is AuthState.Paired
private fun handleAuthOk(envelope: Envelope) {
// A successful auth always carries the latest client report, including
// after the compatibility reconnect used for older Relay versions.
pendingSupervisedUpdateId = null
supervisedUpdateFallbackJob?.cancel()
supervisedUpdateFallbackJob = null
scope.launch {
try {
val payload = envelope.payload
@@ -451,7 +451,8 @@ data class ChatSession(
val outputTokens: Int = 0,
val actualCostUsd: Double? = null,
val estimatedCostUsd: Double? = null,
val isActive: Boolean = false,
/** Upstream REST five-minute recency hint; never evidence that a turn is running. */
val recentlyActive: Boolean = false,
val updatedAt: Long = 0L,
val startedAt: Long = 0L,
val lastActivityAt: Long = 0L,
@@ -0,0 +1,63 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
enum class ProviderUsageLandingMode(val storedValue: String) {
Summary("summary"),
Expanded("expanded"),
Hidden("hidden"),
;
companion object {
fun fromStoredValue(value: String?): ProviderUsageLandingMode =
entries.firstOrNull { it.storedValue == value } ?: Summary
}
}
data class ProviderUsagePreferences(
val landingMode: ProviderUsageLandingMode = ProviderUsageLandingMode.Summary,
val visibleProviders: Set<String> = DEFAULT_VISIBLE_PROVIDERS,
) {
companion object {
val DEFAULT_VISIBLE_PROVIDERS = setOf("openai-codex", "nous", "opencode-go")
}
}
class ProviderUsagePreferencesRepository(private val dataStore: DataStore<Preferences>) {
constructor(context: Context) : this(context.relayDataStore)
companion object {
internal val KEY_LANDING_MODE = stringPreferencesKey("provider_usage_landing_mode")
internal val KEY_VISIBLE_PROVIDERS = stringSetPreferencesKey("provider_usage_visible_providers")
}
val preferences: Flow<ProviderUsagePreferences> = dataStore.data
.map { prefs ->
ProviderUsagePreferences(
landingMode = ProviderUsageLandingMode.fromStoredValue(prefs[KEY_LANDING_MODE]),
visibleProviders = prefs[KEY_VISIBLE_PROVIDERS]
?: ProviderUsagePreferences.DEFAULT_VISIBLE_PROVIDERS,
)
}
.distinctUntilChanged()
suspend fun setLandingMode(mode: ProviderUsageLandingMode) {
dataStore.edit { it[KEY_LANDING_MODE] = mode.storedValue }
}
suspend fun setProviderVisible(providerId: String, visible: Boolean) {
dataStore.edit { prefs ->
val current = prefs[KEY_VISIBLE_PROVIDERS]
?: ProviderUsagePreferences.DEFAULT_VISIBLE_PROVIDERS
prefs[KEY_VISIBLE_PROVIDERS] = if (visible) current + providerId else current - providerId
}
}
}
@@ -0,0 +1,552 @@
package com.hermesandroid.relay.data
import java.util.Locale
/** Stable ownership boundary for live activity. Runtime ids are aliases, never owners. */
@ConsistentCopyVisibility
data class SessionActivityOwner private constructor(
val connectionId: String,
val profile: String,
val storedSessionId: String,
) {
companion object {
fun of(connectionId: String, profile: String, storedSessionId: String) =
SessionActivityOwner(
connectionId = connectionId.trim(),
profile = profile.trim().lowercase(Locale.ROOT),
storedSessionId = storedSessionId.trim(),
).also {
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
require(it.profile.isNotEmpty()) { "profile must not be blank" }
require(it.storedSessionId.isNotEmpty()) { "storedSessionId must not be blank" }
}
}
}
@ConsistentCopyVisibility
data class SessionActivityScope private constructor(
val connectionId: String,
val profile: String,
) {
companion object {
fun of(connectionId: String, profile: String) = SessionActivityScope(
connectionId = connectionId.trim(),
profile = profile.trim().lowercase(Locale.ROOT),
).also {
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
require(it.profile.isNotEmpty()) { "profile must not be blank" }
}
}
}
enum class SessionActivityPhase {
Starting,
Working,
NeedsInput,
BackgroundWork,
Idle,
}
enum class SessionActivityFreshness {
Confirmed,
Revalidating,
Unavailable,
}
enum class SessionActivityEvidenceSource {
Directory,
LocalSend,
ActiveList,
SessionEvent,
PendingInput,
Terminal,
Checkpoint,
Process,
}
data class SessionActivityEvidence(
val source: SessionActivityEvidenceSource,
val generation: Long,
val observedAtMillis: Long,
)
data class SessionActivityRecord(
val owner: SessionActivityOwner,
/** Authoritative turn state before exact pending-input and background-process overlays. */
val turnPhase: SessionActivityPhase,
val freshness: SessionActivityFreshness,
val evidence: SessionActivityEvidence,
val runtimeId: String? = null,
val pendingInputs: Map<String, Long?> = emptyMap(),
val backgroundProcessIds: Set<String> = emptySet(),
) {
fun phase(nowMillis: Long = Long.MIN_VALUE): SessionActivityPhase {
val hasPendingInput = pendingInputs.any { (_, expiresAt) -> expiresAt == null || expiresAt > nowMillis }
return when {
hasPendingInput -> SessionActivityPhase.NeedsInput
turnPhase != SessionActivityPhase.Idle -> turnPhase
backgroundProcessIds.isNotEmpty() -> SessionActivityPhase.BackgroundWork
else -> SessionActivityPhase.Idle
}
}
/** Presentation projection that never labels missing optional runtime data as session state. */
fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
SessionActivityFreshness.Revalidating -> null
SessionActivityFreshness.Unavailable -> null
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
SessionActivityPhase.Starting -> SessionActivityState.Starting
SessionActivityPhase.Working -> SessionActivityState.Working
SessionActivityPhase.NeedsInput -> SessionActivityState.NeedsInput
SessionActivityPhase.BackgroundWork -> SessionActivityState.BackgroundWork
SessionActivityPhase.Idle -> null
}
}
}
enum class SessionLiveStatus {
Starting,
Working,
Waiting,
Idle,
}
data class SessionLiveRuntime(
/** Null when transport data cannot be resolved uniquely to a stored session owner. */
val owner: SessionActivityOwner?,
val runtimeId: String,
val status: SessionLiveStatus,
)
sealed interface SessionActivityUpdate {
val generation: Long
val observedAtMillis: Long
data class BeginGeneration(
val scope: SessionActivityScope,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class ObserveOwner(
val owner: SessionActivityOwner,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class LocalSend(
val owner: SessionActivityOwner,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class LiveState(
val owner: SessionActivityOwner,
val runtimeId: String?,
val status: SessionLiveStatus,
val source: SessionActivityEvidenceSource = SessionActivityEvidenceSource.SessionEvent,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class RuntimeState(
val scope: SessionActivityScope,
val runtimeId: String,
val status: SessionLiveStatus,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class ActiveList(
val scope: SessionActivityScope,
val runtimes: List<SessionLiveRuntime>,
/** True only when every upstream row was safely attributable for this scope. */
val isCompleteForScope: Boolean,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class StatusUnavailable(
val scope: SessionActivityScope,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class Terminal(
val owner: SessionActivityOwner,
val runtimeId: String? = null,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class RestoreCheckpoint(
val owner: SessionActivityOwner,
val runtimeId: String?,
val phase: SessionActivityPhase,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class PendingInputOpened(
val owner: SessionActivityOwner,
val requestId: String,
val expiresAtMillis: Long? = null,
val confirmed: Boolean = true,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class PendingInputClosed(
val owner: SessionActivityOwner,
val requestId: String,
val confirmed: Boolean = true,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class ProcessState(
val owner: SessionActivityOwner,
val processId: String,
val running: Boolean,
override val generation: Long,
override val observedAtMillis: Long,
) : SessionActivityUpdate
data class Tick(
val nowMillis: Long,
override val generation: Long = Long.MAX_VALUE,
override val observedAtMillis: Long = nowMillis,
) : SessionActivityUpdate
}
/**
* Pure reducer for session activity. Every update is generation-gated per connection/profile.
* An unsuccessful/unsupported refresh never manufactures an idle result.
*/
data class SessionActivityRegistry(
val records: Map<SessionActivityOwner, SessionActivityRecord> = emptyMap(),
private val runtimeAliases: Map<RuntimeAlias, SessionActivityOwner> = emptyMap(),
private val generations: Map<SessionActivityScope, Long> = emptyMap(),
) {
fun record(owner: SessionActivityOwner): SessionActivityRecord? = records[owner]
fun ownerForRuntime(scope: SessionActivityScope, runtimeId: String): SessionActivityOwner? =
runtimeAliases[RuntimeAlias(scope, runtimeId.trim(), generations[scope] ?: return null)]
fun presentationStates(nowMillis: Long = Long.MIN_VALUE): Map<SessionActivityOwner, SessionActivityState> =
records.mapNotNull { (owner, record) -> record.presentationState(nowMillis)?.let { owner to it } }.toMap()
fun reduce(update: SessionActivityUpdate): SessionActivityRegistry {
if (update is SessionActivityUpdate.Tick) return expirePendingInputs(update.nowMillis)
val scope = update.scope()
val currentGeneration = generations[scope]
if (currentGeneration != null && update.generation < currentGeneration) return this
var state = this
if (currentGeneration == null || update.generation > currentGeneration) {
state = state.beginGeneration(scope, update.generation)
}
return when (update) {
is SessionActivityUpdate.BeginGeneration -> state
is SessionActivityUpdate.ObserveOwner -> state.observeOwner(update)
is SessionActivityUpdate.LocalSend -> state.putTurn(
update.owner, null, SessionActivityPhase.Starting, SessionActivityFreshness.Confirmed,
SessionActivityEvidenceSource.LocalSend, update.generation, update.observedAtMillis,
)
is SessionActivityUpdate.LiveState -> state.putLiveState(update)
is SessionActivityUpdate.RuntimeState -> {
val owner = state.ownerForRuntime(update.scope, update.runtimeId) ?: return state
state.putTurn(
owner, update.runtimeId, update.status.phase(), SessionActivityFreshness.Confirmed,
SessionActivityEvidenceSource.SessionEvent, update.generation, update.observedAtMillis,
)
}
is SessionActivityUpdate.ActiveList -> state.applyActiveList(update)
is SessionActivityUpdate.StatusUnavailable -> state.markUnavailable(update.scope)
is SessionActivityUpdate.Terminal -> state.settleTerminal(update)
is SessionActivityUpdate.RestoreCheckpoint -> state.restoreCheckpoint(update)
is SessionActivityUpdate.PendingInputOpened -> state.updatePendingInput(
update.owner, update.requestId, update.expiresAtMillis, true,
update.confirmed, update.generation, update.observedAtMillis,
)
is SessionActivityUpdate.PendingInputClosed -> state.updatePendingInput(
update.owner, update.requestId, null, false,
update.confirmed, update.generation, update.observedAtMillis,
)
is SessionActivityUpdate.ProcessState -> state.updateProcess(update)
is SessionActivityUpdate.Tick -> state
}
}
private fun beginGeneration(scope: SessionActivityScope, generation: Long): SessionActivityRegistry {
val refreshedRecords = records.mapValues { (owner, record) ->
if (owner.scope() == scope) {
record.copy(freshness = SessionActivityFreshness.Revalidating)
} else record
}
return copy(
records = refreshedRecords,
runtimeAliases = runtimeAliases.filterKeys { it.scope != scope },
generations = generations + (scope to generation),
)
}
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
val existing = records[update.owner]
// Directory rows establish ownership only. They are not live evidence and must not
// turn an unsupported/failed active-list probe back into a permanent Checking row.
if (existing != null) return this
val observed = SessionActivityRecord(
owner = update.owner,
turnPhase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Revalidating,
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.Directory,
update.generation,
update.observedAtMillis,
),
)
return copy(records = records + (update.owner to observed))
}
private fun putLiveState(update: SessionActivityUpdate.LiveState): SessionActivityRegistry = putTurn(
owner = update.owner,
runtimeId = update.runtimeId,
phase = update.status.phase(),
freshness = SessionActivityFreshness.Confirmed,
source = update.source,
generation = update.generation,
observedAtMillis = update.observedAtMillis,
)
private fun putTurn(
owner: SessionActivityOwner,
runtimeId: String?,
phase: SessionActivityPhase,
freshness: SessionActivityFreshness,
source: SessionActivityEvidenceSource,
generation: Long,
observedAtMillis: Long,
): SessionActivityRegistry {
val previous = records[owner]
val record = SessionActivityRecord(
owner = owner,
turnPhase = phase,
freshness = freshness,
evidence = SessionActivityEvidence(source, generation, observedAtMillis),
runtimeId = runtimeId ?: previous?.runtimeId,
pendingInputs = previous?.pendingInputs.orEmpty(),
backgroundProcessIds = previous?.backgroundProcessIds.orEmpty(),
)
val alias = runtimeId?.trim()?.takeIf { it.isNotEmpty() }
return copy(
records = records + (owner to record),
runtimeAliases = if (alias == null) runtimeAliases else {
runtimeAliases + (RuntimeAlias(owner.scope(), alias, generation) to owner)
},
)
}
private fun applyActiveList(update: SessionActivityUpdate.ActiveList): SessionActivityRegistry {
require(update.runtimes.all { it.owner == null || it.owner.scope() == update.scope }) {
"Active-list rows must belong to the snapshot scope"
}
var state = copy(runtimeAliases = runtimeAliases.filterKeys { it.scope != update.scope })
val resolvedRuntimes = update.runtimes.filter { it.owner != null }
val observedOwners = resolvedRuntimes.mapTo(mutableSetOf()) { requireNotNull(it.owner) }
resolvedRuntimes.forEach { runtime ->
val resolvedOwner = requireNotNull(runtime.owner)
state = state.putTurn(
resolvedOwner, runtime.runtimeId, runtime.status.phase(), SessionActivityFreshness.Confirmed,
SessionActivityEvidenceSource.ActiveList, update.generation, update.observedAtMillis,
)
if (runtime.status == SessionLiveStatus.Idle) {
val idleRecord = requireNotNull(state.records[resolvedOwner]).copy(pendingInputs = emptyMap())
state = state.copy(records = state.records + (resolvedOwner to idleRecord))
}
}
val snapshotCanSettle = update.isCompleteForScope && resolvedRuntimes.size == update.runtimes.size
if (!snapshotCanSettle) return state
val settled = state.records.mapValues { (owner, record) ->
if (
owner.scope() == update.scope && owner !in observedOwners &&
record.shouldSettleWhenAbsent()
) {
record.copy(
turnPhase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Confirmed,
runtimeId = null,
pendingInputs = emptyMap(),
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.ActiveList,
update.generation,
update.observedAtMillis,
),
)
} else record
}
return state.copy(records = settled)
}
private fun markUnavailable(scope: SessionActivityScope): SessionActivityRegistry = copy(
records = records.mapValues { (owner, record) ->
if (
owner.scope() == scope && record.evidence.source in setOf(
SessionActivityEvidenceSource.ActiveList,
SessionActivityEvidenceSource.Directory,
SessionActivityEvidenceSource.Checkpoint,
)
) {
record.copy(freshness = SessionActivityFreshness.Unavailable)
} else record
},
)
private fun settleTerminal(update: SessionActivityUpdate.Terminal): SessionActivityRegistry {
val settled = putTurn(
update.owner,
runtimeId = null,
phase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Confirmed,
source = SessionActivityEvidenceSource.Terminal,
generation = update.generation,
observedAtMillis = update.observedAtMillis,
)
val record = requireNotNull(settled.records[update.owner]).copy(
runtimeId = null,
pendingInputs = emptyMap(),
)
return settled.copy(
records = settled.records + (update.owner to record),
runtimeAliases = settled.runtimeAliases.filterNot { (alias, owner) ->
alias.scope == update.owner.scope() && owner == update.owner &&
(update.runtimeId == null || alias.runtimeId == update.runtimeId)
},
)
}
private fun restoreCheckpoint(update: SessionActivityUpdate.RestoreCheckpoint): SessionActivityRegistry {
val existing = records[update.owner]
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
return putTurn(
update.owner, update.runtimeId, update.phase, SessionActivityFreshness.Revalidating,
SessionActivityEvidenceSource.Checkpoint, update.generation, update.observedAtMillis,
)
}
private fun updatePendingInput(
owner: SessionActivityOwner,
requestId: String,
expiresAtMillis: Long?,
opened: Boolean,
confirmed: Boolean,
generation: Long,
observedAtMillis: Long,
): SessionActivityRegistry {
val previous = records[owner] ?: SessionActivityRecord(
owner = owner,
turnPhase = SessionActivityPhase.Idle,
freshness = if (confirmed) {
SessionActivityFreshness.Confirmed
} else {
SessionActivityFreshness.Revalidating
},
evidence = SessionActivityEvidence(
if (confirmed) {
SessionActivityEvidenceSource.PendingInput
} else {
SessionActivityEvidenceSource.Checkpoint
},
generation,
observedAtMillis,
),
)
val pending = if (opened) {
previous.pendingInputs + (requestId to expiresAtMillis)
} else {
previous.pendingInputs - requestId
}
return copy(records = records + (owner to previous.copy(
pendingInputs = pending,
freshness = if (confirmed) SessionActivityFreshness.Confirmed else previous.freshness,
evidence = if (confirmed) {
SessionActivityEvidence(
SessionActivityEvidenceSource.PendingInput,
generation,
observedAtMillis,
)
} else previous.evidence,
)))
}
private fun updateProcess(update: SessionActivityUpdate.ProcessState): SessionActivityRegistry {
val previous = records[update.owner] ?: SessionActivityRecord(
owner = update.owner,
turnPhase = SessionActivityPhase.Idle,
freshness = SessionActivityFreshness.Confirmed,
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.Process,
update.generation,
update.observedAtMillis,
),
)
val processes = if (update.running) {
previous.backgroundProcessIds + update.processId
} else {
previous.backgroundProcessIds - update.processId
}
return copy(records = records + (update.owner to previous.copy(
backgroundProcessIds = processes,
evidence = SessionActivityEvidence(
SessionActivityEvidenceSource.Process,
update.generation,
update.observedAtMillis,
),
)))
}
private fun expirePendingInputs(nowMillis: Long): SessionActivityRegistry = copy(
records = records.mapValues { (_, record) ->
record.copy(pendingInputs = record.pendingInputs.filterValues { it == null || it > nowMillis })
},
)
private fun SessionActivityRecord.shouldSettleWhenAbsent(): Boolean =
runtimeId != null || evidence.source in setOf(
SessionActivityEvidenceSource.ActiveList,
SessionActivityEvidenceSource.Checkpoint,
SessionActivityEvidenceSource.Directory,
)
private fun SessionActivityUpdate.scope(): SessionActivityScope = when (this) {
is SessionActivityUpdate.BeginGeneration -> scope
is SessionActivityUpdate.ObserveOwner -> owner.scope()
is SessionActivityUpdate.RuntimeState -> scope
is SessionActivityUpdate.ActiveList -> scope
is SessionActivityUpdate.StatusUnavailable -> scope
is SessionActivityUpdate.Terminal -> owner.scope()
is SessionActivityUpdate.LocalSend -> owner.scope()
is SessionActivityUpdate.LiveState -> owner.scope()
is SessionActivityUpdate.RestoreCheckpoint -> owner.scope()
is SessionActivityUpdate.PendingInputOpened -> owner.scope()
is SessionActivityUpdate.PendingInputClosed -> owner.scope()
is SessionActivityUpdate.ProcessState -> owner.scope()
is SessionActivityUpdate.Tick -> error("Tick has no scope")
}
private fun SessionActivityOwner.scope() = SessionActivityScope.of(connectionId, profile)
private fun SessionLiveStatus.phase(): SessionActivityPhase = when (this) {
SessionLiveStatus.Starting -> SessionActivityPhase.Starting
SessionLiveStatus.Working -> SessionActivityPhase.Working
SessionLiveStatus.Waiting -> SessionActivityPhase.NeedsInput
SessionLiveStatus.Idle -> SessionActivityPhase.Idle
}
data class RuntimeAlias(
val scope: SessionActivityScope,
val runtimeId: String,
val generation: Long,
)
}
@@ -2,6 +2,10 @@ package com.hermesandroid.relay.data
/** Live activity surfaced beside a session without conflating it with selection. */
enum class SessionActivityState {
Starting,
Working,
NeedsInput,
BackgroundWork,
Checking,
Unavailable,
}
@@ -0,0 +1,234 @@
package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import com.hermesandroid.relay.ui.theme.AppThemes
/**
* Parent-configured restrictions for the official Android client.
*
* This policy deliberately describes a client presentation mode, not a server
* authorization boundary. The pinned profile is expected to have already been
* configured with the appropriate server-side tool and content restrictions.
*/
@Serializable
data class SupervisedModePolicy(
val enabled: Boolean = false,
val pinnedProfileName: String? = null,
val capabilities: SupervisedCapabilities = SupervisedCapabilities(),
val appearance: SupervisedAppearance = SupervisedAppearance(),
val visibility: SupervisedVisibility = SupervisedVisibility(),
val parentAccess: SupervisedParentAccess = SupervisedParentAccess(),
) {
/** A saved policy is usable only when it names a concrete Hermes profile. */
val isConfigured: Boolean
get() = !pinnedProfileName.isNullOrBlank()
/** Consumers should use this instead of treating [enabled] alone as sufficient. */
val isActive: Boolean
get() = enabled && isConfigured
internal fun normalized(): SupervisedModePolicy = copy(
pinnedProfileName = pinnedProfileName?.trim()?.takeIf { it.isNotEmpty() },
capabilities = capabilities.normalized(),
appearance = appearance.normalized(),
parentAccess = parentAccess.normalized(),
)
}
/** Actions and content types the supervised chat surface may expose. */
@Serializable
data class SupervisedCapabilities(
val attachments: Boolean = false,
val voice: Boolean = false,
val generatedImages: Boolean = true,
val conversationHistory: Boolean = false,
val newChat: Boolean = true,
val cancelResponse: Boolean = true,
val steerResponse: Boolean = true,
val retryResponse: Boolean = true,
val copyResponses: Boolean = true,
val quoteReplies: Boolean = true,
val editAndResend: Boolean = false,
val shareGeneratedImages: Boolean = false,
val sessionActions: SupervisedSessionActions = SupervisedSessionActions(),
val attachmentMaxCount: Int = DEFAULT_ATTACHMENT_MAX_COUNT,
val attachmentMaxFileMb: Int = DEFAULT_ATTACHMENT_MAX_FILE_MB,
val attachmentCategories: Set<SupervisedAttachmentCategory> = setOf(
SupervisedAttachmentCategory.Images,
),
) {
internal fun normalized(): SupervisedCapabilities = copy(
attachmentMaxCount = attachmentMaxCount.coerceIn(1, MAX_ATTACHMENT_COUNT),
attachmentMaxFileMb = attachmentMaxFileMb.coerceIn(1, MAX_ATTACHMENT_FILE_MB),
attachmentCategories = attachmentCategories.ifEmpty {
setOf(SupervisedAttachmentCategory.Images)
},
)
companion object {
const val DEFAULT_ATTACHMENT_MAX_COUNT = 4
const val DEFAULT_ATTACHMENT_MAX_FILE_MB = 10
const val MAX_ATTACHMENT_COUNT = 10
const val MAX_ATTACHMENT_FILE_MB = 100
}
}
/** Appearance applied only while the supervised root is locked. */
@Serializable
data class SupervisedAppearance(
val appThemeId: String = AppThemes.DEFAULT_ID,
val themePreference: String = "auto",
val showPet: Boolean = false,
val allowProfileIconChanges: Boolean = false,
val allowBackgroundChanges: Boolean = false,
) {
internal fun normalized(): SupervisedAppearance = copy(
appThemeId = AppThemes.byId(appThemeId).id,
themePreference = themePreference.takeIf { it in VALID_THEME_PREFERENCES } ?: "auto",
)
private companion object {
val VALID_THEME_PREFERENCES = setOf("auto", "light", "dark")
}
}
/** Mutable operations available from a supervised conversation-history row. */
@Serializable
data class SupervisedSessionActions(
val pin: Boolean = false,
val rename: Boolean = false,
val archive: Boolean = false,
val delete: Boolean = false,
val shareTranscript: Boolean = false,
) {
val enabledCount: Int
get() = listOf(pin, rename, archive, delete, shareTranscript).count { it }
val allEnabled: Boolean
get() = enabledCount == TOTAL
val noneEnabled: Boolean
get() = enabledCount == 0
fun withAll(enabled: Boolean): SupervisedSessionActions = SupervisedSessionActions(
pin = enabled,
rename = enabled,
archive = enabled,
delete = enabled,
shareTranscript = enabled,
)
companion object {
const val TOTAL = 5
}
}
enum class SupervisedSessionAction {
Pin,
Rename,
Archive,
Delete,
ShareTranscript,
}
fun SupervisedModePolicy.allowsSessionAction(action: SupervisedSessionAction): Boolean {
if (!enabled) return true
if (!capabilities.conversationHistory) return false
return when (action) {
SupervisedSessionAction.Pin -> capabilities.sessionActions.pin
SupervisedSessionAction.Rename -> capabilities.sessionActions.rename
SupervisedSessionAction.Archive -> capabilities.sessionActions.archive
SupervisedSessionAction.Delete -> capabilities.sessionActions.delete
SupervisedSessionAction.ShareTranscript -> capabilities.sessionActions.shareTranscript
}
}
@Serializable
enum class SupervisedAttachmentCategory {
@SerialName("images")
Images,
@SerialName("documents")
Documents,
@SerialName("audio")
Audio,
@SerialName("video")
Video,
}
/**
* Controls which metadata and conversation affordances are rendered.
*
* [Simple] is the quiet default. [Transparent] is a useful preset for older or
* technical users, while [Custom] tells the UI to honor every stored toggle.
*/
@Serializable
data class SupervisedVisibility(
val preset: SupervisedVisibilityPreset = SupervisedVisibilityPreset.Simple,
val showAgentIdentity: Boolean = true,
val showModelName: Boolean = false,
val showProfileName: Boolean = false,
val showConnectionStatus: Boolean = true,
val showTechnicalRoute: Boolean = false,
val showTimestamps: Boolean = true,
val showToolNames: Boolean = false,
val showToolDetails: Boolean = false,
val showWorkingStatus: Boolean = true,
val showReasoning: Boolean = false,
val showUsage: Boolean = false,
) {
/** Resolve presets to the concrete flags consumed by chat presentation. */
fun resolved(): SupervisedVisibility = when (preset) {
SupervisedVisibilityPreset.Simple -> SIMPLE
SupervisedVisibilityPreset.Transparent -> TRANSPARENT
SupervisedVisibilityPreset.Custom -> this
}
companion object {
val SIMPLE = SupervisedVisibility(preset = SupervisedVisibilityPreset.Simple)
val TRANSPARENT = SupervisedVisibility(
preset = SupervisedVisibilityPreset.Transparent,
showModelName = true,
showProfileName = true,
showTechnicalRoute = true,
showToolNames = true,
showUsage = true,
)
}
}
@Serializable
enum class SupervisedVisibilityPreset {
@SerialName("simple")
Simple,
@SerialName("transparent")
Transparent,
@SerialName("custom")
Custom,
}
/** Device-authentication and automatic relock behavior for parent access. */
@Serializable
data class SupervisedParentAccess(
/** Reserved for forward-compatible persistence; normalization never permits an auth bypass. */
val requireDeviceAuthentication: Boolean = true,
val relockOnBackground: Boolean = true,
val timeoutMinutes: Int = DEFAULT_TIMEOUT_MINUTES,
) {
internal fun normalized(): SupervisedParentAccess = copy(
requireDeviceAuthentication = true,
timeoutMinutes = timeoutMinutes.coerceIn(MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES),
)
companion object {
const val DEFAULT_TIMEOUT_MINUTES = 5
const val MIN_TIMEOUT_MINUTES = 1
const val MAX_TIMEOUT_MINUTES = 60
}
}
@@ -0,0 +1,106 @@
package com.hermesandroid.relay.data
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
/** Persists one independent [SupervisedModePolicy] per Hermes connection. */
class SupervisedModeStore private constructor(
private val dataStore: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.relayDataStore)
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
private val serializer = MapSerializer(String.serializer(), SupervisedModePolicy.serializer())
fun policyFlow(connectionId: String): Flow<SupervisedModePolicy> =
dataStore.data.map { preferences ->
val decoded = decode(preferences[KEY_POLICIES])
if (decoded.corrupt) {
// A malformed persisted policy must never silently reopen the
// unrestricted app. Enabled + unconfigured renders the
// supervised recovery surface until an authenticated user
// repairs or clears the policy.
SupervisedModePolicy(enabled = true)
} else {
decoded.policies[connectionId]?.normalized() ?: SupervisedModePolicy()
}
}
suspend fun setPolicy(connectionId: String, policy: SupervisedModePolicy) {
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
dataStore.edit { preferences ->
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
policies[connectionId] = policy.normalized()
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
}
}
suspend fun updatePolicy(
connectionId: String,
transform: (SupervisedModePolicy) -> SupervisedModePolicy,
) {
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
dataStore.edit { preferences ->
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
val current = policies[connectionId]?.normalized() ?: SupervisedModePolicy()
policies[connectionId] = transform(current).normalized()
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
}
}
suspend fun setEnabled(connectionId: String, enabled: Boolean) {
updatePolicy(connectionId) { it.copy(enabled = enabled) }
}
suspend fun clear(connectionId: String) {
dataStore.edit { preferences ->
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
policies.remove(connectionId)
if (policies.isEmpty()) {
preferences.remove(KEY_POLICIES)
} else {
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
}
}
}
/** Clear supervised policies without disturbing unrelated app settings. */
suspend fun clearAll() {
dataStore.edit { preferences -> preferences.remove(KEY_POLICIES) }
}
private fun decode(raw: String?): DecodeResult {
if (raw.isNullOrBlank()) return DecodeResult(emptyMap(), corrupt = false)
return try {
DecodeResult(json.decodeFromString(serializer, raw), corrupt = false)
} catch (error: Exception) {
Log.w(TAG, "Unable to decode supervised-mode policies; failing closed", error)
DecodeResult(emptyMap(), corrupt = true)
}
}
private data class DecodeResult(
val policies: Map<String, SupervisedModePolicy>,
val corrupt: Boolean,
)
internal companion object {
private const val TAG = "SupervisedModeStore"
private val KEY_POLICIES = stringPreferencesKey("supervised_mode_policies_v1")
fun forTesting(dataStore: DataStore<Preferences>): SupervisedModeStore =
SupervisedModeStore(dataStore)
}
}
@@ -153,7 +153,7 @@ class ChannelMultiplexer {
)
send(pong)
}
"auth.ok", "auth.fail" -> {
"auth.ok", "auth.fail", "supervised.updated", "error" -> {
// Delegate to system handler if registered
handlers["system"]?.onMessage(envelope)
}
@@ -442,6 +442,35 @@ class ConnectionManager(
return true
}
/**
* Reopen the current authenticated Relay socket without discarding pair
* state. Used only as a compatibility fallback when an older Relay does
* not acknowledge a post-auth metadata update; the replacement socket's
* normal `system/auth` frame carries the latest metadata.
*/
fun reconnectForAuthenticatedMetadataUpdate(): Boolean {
val targetUrl = serverUrl?.takeIf { it.isNotBlank() } ?: return false
if (isRelayRateLimitBackoffActive(
rateLimitBackoffUntilMs,
SystemClock.elapsedRealtime(),
)
) {
Log.i(TAG, "metadata reconnect: preserving active rate-limit backoff")
return false
}
val previousSocket = webSocket
if (previousSocket == null) {
connect(targetUrl)
} else {
doConnect(
targetUrl,
previousSocketToClose = previousSocket,
replaceReason = "Relay metadata compatibility refresh",
)
}
return true
}
/**
* Same as [connect] but bypasses the resolver — used by the network-
* change callback when we've already picked a winner and just want to
@@ -9,6 +9,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
@@ -1444,4 +1445,86 @@ class RelayHttpClient(
val value = header?.trim()?.lowercase() ?: return false
return value == "1" || value == "true"
}
/** Provider-neutral compatibility fetch for gateways without `account.usage`. */
suspend fun fetchProviderUsage(
profile: String? = null,
sessionId: String? = null,
): Result<ProviderUsageResponse?> =
withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
return@withContext Result.success(null)
}
val sessionToken = sessionTokenProvider()
if (sessionToken.isNullOrBlank()) {
return@withContext Result.success(null)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val url = "$httpBase/usage/providers".toHttpUrlOrNull()
?.newBuilder()
?.apply {
profile?.trim()?.takeIf { it.isNotEmpty() }?.let {
addQueryParameter("profile", it)
}
sessionId?.trim()?.takeIf { it.isNotEmpty() }?.let {
addQueryParameter("session_id", it)
}
}
?.build()
?: return@withContext Result.failure(
IllegalArgumentException("Invalid relay URL: $httpBase")
)
val request = Request.Builder()
.url(url)
.get()
.header("Authorization", "Bearer $sessionToken")
.header("Accept", "application/json")
.build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (response.code == 404) {
// Older or operator-disabled hosts simply do not expose
// account usage. This is capability absence, not an error.
return@withContext Result.success(null)
}
if (!response.isSuccessful) {
val reason = when (response.code) {
401, 403 -> "Unauthorized — re-pair with the relay"
502 -> "Provider usage upstream error (HTTP ${response.code})"
in 500..599 -> "Relay error (HTTP ${response.code})"
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
}
return@withContext Result.failure(IOException(reason))
}
val body = response.body?.string().orEmpty()
if (body.isBlank()) {
return@withContext Result.failure(IOException("Empty response body"))
}
val parsed = runCatching {
sessionsJson.decodeFromString(
ProviderUsageResponse.serializer(),
body,
)
}.getOrElse {
Log.w(TAG, "fetchProviderUsage parse error: ${it.message}")
return@withContext Result.failure(IOException("Unrecognized usage payload"))
}
Result.success(parsed)
}
} catch (e: IOException) {
Log.w(TAG, "fetchProviderUsage failed: ${e.message}")
Result.failure(IOException("Relay unreachable: ${e.message ?: "IO error"}"))
} catch (e: Exception) {
Log.w(TAG, "fetchProviderUsage unexpected error: ${e.message}")
Result.failure(e)
}
}
}
@@ -48,6 +48,9 @@ interface VoiceAudioClient {
val effectiveRoute: VoiceAudioRoute
get() = route
/** Temporary client-policy override; the shared router honors it before user prefs. */
fun setRouteOverride(route: VoiceAudioRoute?) = Unit
suspend fun transcribe(audioFile: File): Result<String>
suspend fun synthesize(text: String): Result<File>
@@ -82,8 +85,15 @@ class AutoVoiceAudioClient(
private val standardReadyProvider: () -> Boolean,
private val relayReadyProvider: () -> Boolean,
) : VoiceAudioClient {
@Volatile
private var routeOverride: VoiceAudioRoute? = null
override fun setRouteOverride(route: VoiceAudioRoute?) {
routeOverride = route
}
override val route: VoiceAudioRoute
get() = routeProvider()
get() = routeOverride ?: routeProvider()
/**
* Resolve the configured preference to the backend a call would land on:
@@ -92,7 +102,7 @@ class AutoVoiceAudioClient(
* decide whether standard-only limitations (global TTS) currently apply.
*/
override val effectiveRoute: VoiceAudioRoute
get() = when (routeProvider()) {
get() = when (route) {
VoiceAudioRoute.Standard -> VoiceAudioRoute.Standard
VoiceAudioRoute.Relay -> VoiceAudioRoute.Relay
VoiceAudioRoute.Auto ->
@@ -114,7 +124,7 @@ class AutoVoiceAudioClient(
private suspend fun <T> runWithSelectedRoute(
block: suspend (VoiceAudioClient) -> Result<T>,
): Result<T> {
return when (routeProvider()) {
return when (route) {
VoiceAudioRoute.Standard -> {
if (!standardReadyProvider()) {
Result.failure(
@@ -2075,7 +2075,7 @@ class ChatHandler {
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
recentlyActive = item.isActive,
updatedAt = activityAtMs,
startedAt = startedAtMs,
lastActivityAt = lastActivityAtMs,
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.shutdownOffMainThread
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
import com.hermesandroid.relay.network.upstream.models.SessionItem
@@ -448,6 +449,25 @@ class DashboardApiClient(
*/
suspend fun getConfig(): Result<JsonObject> = getJsonObject("/api/config")
suspend fun getProviderUsage(
profile: String? = null,
sessionId: String? = null,
): Result<ProviderUsageResponse?> {
val query = buildList {
profile?.trim()?.takeIf { it.isNotEmpty() }?.let {
add("profile=${queryValue(it)}")
}
sessionId?.trim()?.takeIf { it.isNotEmpty() }?.let {
add("session_id=${queryValue(it)}")
}
}
val suffix = query.joinToString(prefix = if (query.isEmpty()) "" else "?", separator = "&")
return getJsonObject("/api/plugins/hermes-relay/provider-usage$suffix")
.mapCatching { root ->
json.decodeFromJsonElement(ProviderUsageResponse.serializer(), root)
}
}
/**
* The config SCHEMA: `{fields: {<dot.path>: {type, description, category,
* options?}}, category_order: [...]}`. Describes how to render each field;
@@ -52,6 +52,7 @@ import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
@@ -278,6 +279,12 @@ class GatewayChatClient(
private val _processCapability = MutableStateFlow(GatewayProcessCapability.Unknown)
val processCapability: StateFlow<GatewayProcessCapability> = _processCapability.asStateFlow()
/** Per-socket capability for upstream's process-wide live-session snapshot. */
private val _activeSessionCapability =
MutableStateFlow(GatewayActiveSessionCapability.Unknown)
val activeSessionCapability: StateFlow<GatewayActiveSessionCapability> =
_activeSessionCapability.asStateFlow()
/**
* Active personality the gateway is applying, as a config value ("none" when
* the overlay is cleared, otherwise the personality name). Tracks the
@@ -797,10 +804,30 @@ class GatewayChatClient(
*/
fun hasActiveTurn(): Boolean = activeTurn?.ended == false || backgroundTurns.isNotEmpty()
/** True only when [storedId] still owns a foreground or deliberately detached turn. */
fun hasActiveTurnForSession(storedId: String): Boolean =
(activeTurn?.ended == false && storedSessionId == storedId) ||
backgroundTurns.values.any { it.storedSessionId == storedId }
/** Live id to persist beside a durable stored id while a turn is active. */
fun currentLiveSessionId(storedId: String): String? =
liveSessionId?.takeIf { storedSessionId == storedId }
/**
* Exact durable/profile owner already held by this client for [runtimeId].
* Unlike `session.active_list`, this mapping is safe for multiplexed profiles
* because Android recorded it when the runtime was created/resumed/detached.
*/
fun knownSessionOwner(runtimeId: String): GatewayKnownSessionOwner? {
if (runtimeId == liveSessionId) {
val storedId = storedSessionId ?: return null
return GatewayKnownSessionOwner(storedId, liveSessionProfile)
}
return backgroundTurns[runtimeId]?.let { owner ->
GatewayKnownSessionOwner(owner.storedSessionId, owner.profile)
}
}
/**
* Point this client at a new dashboard route (e.g. LAN→Tailscale after a
* sustained network change). If a turn is in flight, the current socket is
@@ -1426,6 +1453,21 @@ class GatewayChatClient(
.onSuccess { commandsCatalogCache = it }
}
/**
* Provider-neutral account limits owned by upstream Hermes. Current hosts
* may not expose this additive method yet; callers should treat JSON-RPC
* method-not-found as capability absence and use the optional Relay
* compatibility surface when paired.
*/
suspend fun providerUsage(): Result<JsonObject> {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
return rpc("account.usage", JsonObject(emptyMap()))
}
/**
* Create a schedule through upstream's authenticated `cron.manage` RPC.
* No Relay scheduler or compatibility endpoint is involved.
@@ -2045,6 +2087,47 @@ class GatewayChatClient(
)
}
/**
* Fetch authoritative in-memory execution states from current upstream
* Hermes. `session.active_list` is process-wide: it accepts only an optional
* current runtime id and does not profile-filter its rows. Accordingly this
* transport returns rows unscoped and never derives activity from REST
* `is_active` or stamps the selected profile onto a row.
*/
suspend fun listActiveSessions(): GatewayActiveSessionsResult {
if (_activeSessionCapability.value == GatewayActiveSessionCapability.Unsupported) {
return GatewayActiveSessionsResult.Unsupported
}
try {
connectMutex.withLock { ensureConnected() }
} catch (error: Exception) {
return GatewayActiveSessionsResult.TransientFailure(error)
}
val result = rpc(
"session.active_list",
buildJsonObject {
liveSessionId?.let { put("current_session_id", it) }
},
)
val error = result.exceptionOrNull()
if (error.isMethodNotFound()) {
_activeSessionCapability.value = GatewayActiveSessionCapability.Unsupported
return GatewayActiveSessionsResult.Unsupported
}
if (error != null) {
return GatewayActiveSessionsResult.TransientFailure(error)
}
_activeSessionCapability.value = GatewayActiveSessionCapability.Supported
return try {
val payload = result.getOrThrow()
val rows = payload["sessions"] as? JsonArray
?: throw GatewayRpcException("session.active_list returned no sessions array")
GatewayActiveSessionsResult.Success(rows.map(::parseGatewayActiveSession))
} catch (parseError: Exception) {
GatewayActiveSessionsResult.TransientFailure(parseError)
}
}
/** Stop one process owned by the current live gateway session. */
suspend fun killProcess(processId: String): Result<Unit> {
if (processId.isBlank()) {
@@ -2483,6 +2566,7 @@ class GatewayChatClient(
private suspend fun connectOnce() {
val connectStart = System.nanoTime()
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.MintingTicket
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
@@ -2729,6 +2813,28 @@ class GatewayChatClient(
)
}
private fun parseGatewayActiveSession(
element: kotlinx.serialization.json.JsonElement,
): GatewayActiveSession {
val row = element as? JsonObject
?: throw GatewayRpcException("session.active_list returned a non-object row")
val runtimeId = row.stringField("id")?.takeIf(String::isNotBlank)
?: throw GatewayRpcException("session.active_list row returned no runtime id")
val storedId = row.stringField("session_key")?.takeIf(String::isNotBlank)
?: throw GatewayRpcException("session.active_list row returned no session key")
val status = GatewayActiveSessionStatus.fromWire(row.stringField("status"))
?: throw GatewayRpcException("session.active_list row returned an unknown status")
val lastActive = (row["last_active"] as? JsonPrimitive)?.doubleOrNull
?: throw GatewayRpcException("session.active_list row returned no last_active")
return GatewayActiveSession(
runtimeSessionId = runtimeId,
storedSessionId = storedId,
status = status,
lastActiveEpochSeconds = lastActive,
profile = row.stringField("profile")?.trim()?.takeIf(String::isNotEmpty),
)
}
private fun markProcessUnsupportedIfNeeded(error: Throwable?) {
if (error.isMethodNotFound()) {
_processCapability.value = GatewayProcessCapability.Unsupported
@@ -3202,6 +3308,7 @@ class GatewayChatClient(
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
pendingRpcs.values.forEach {
@@ -3387,6 +3494,7 @@ class GatewayChatClient(
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
}
@@ -320,6 +320,69 @@ enum class GatewayProcessCapability {
Unsupported,
}
/** Authoritative execution state reported by upstream `session.active_list`. */
enum class GatewayActiveSessionStatus(val wireValue: String) {
Idle("idle"),
Starting("starting"),
Working("working"),
Waiting("waiting");
companion object {
fun fromWire(value: String?): GatewayActiveSessionStatus? = when (value?.trim()?.lowercase()) {
"idle" -> Idle
"starting" -> Starting
"working" -> Working
"waiting" -> Waiting
else -> null
}
}
}
/**
* One in-memory runtime returned by upstream `session.active_list`.
*
* The RPC is process-wide in current upstream Hermes. Its rows do not normally
* identify their profile, so [profile] stays null unless a future gateway
* explicitly sends one. Callers must resolve [storedSessionId] against their
* own profile-scoped session registry and fail closed when ownership is
* ambiguous; the transport never synthesizes profile attribution.
*/
data class GatewayActiveSession(
/** Per-process runtime id used by live Gateway events and session RPCs. */
val runtimeSessionId: String,
/** Durable history id (`session_key`) used by the REST/session database. */
val storedSessionId: String,
val status: GatewayActiveSessionStatus,
/** Unix epoch seconds from upstream's in-memory runtime record. */
val lastActiveEpochSeconds: Double,
/** Future-compatible only; null for the current upstream contract. */
val profile: String? = null,
)
/** Exact owner already known by this client for a foreground or detached runtime. */
data class GatewayKnownSessionOwner(
val storedSessionId: String,
val profile: String?,
)
/** Whether the current Gateway socket exposes `session.active_list`. */
enum class GatewayActiveSessionCapability {
Unknown,
Supported,
Unsupported,
}
/**
* Result of one process-wide live-session snapshot request. Unsupported is
* intentionally distinct from transport/protocol failure so callers can use
* another source only for older gateways, while failures remain Unknown.
*/
sealed interface GatewayActiveSessionsResult {
data class Success(val sessions: List<GatewayActiveSession>) : GatewayActiveSessionsResult
data object Unsupported : GatewayActiveSessionsResult
data class TransientFailure(val error: Throwable) : GatewayActiveSessionsResult
}
/**
* Connection-level background-process events. These are deliberately separate
* from [GatewayTurnCallbacks]: output and completion notifications can arrive
@@ -250,6 +250,7 @@ data class SessionItem(
@SerialName("output_tokens") val outputTokens: Int? = null,
@SerialName("actual_cost_usd") val actualCostUsd: Double? = null,
@SerialName("estimated_cost_usd") val estimatedCostUsd: Double? = null,
/** REST recency heuristic from upstream; not live Gateway execution state. */
@SerialName("is_active") val isActive: Boolean = false,
@SerialName("has_model_config")
@Serializable(with = FlexibleBooleanSerializer::class)
@@ -0,0 +1,89 @@
package com.hermesandroid.relay.network.usage
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ProviderUsageResponse(
@SerialName("schema_version") val schemaVersion: Int = 1,
@SerialName("fetched_at") val fetchedAt: String? = null,
val capabilities: Set<String> = emptySet(),
val providers: List<ProviderUsageProvider> = emptyList(),
) {
val relayEnhanced: Boolean
get() = capabilities.containsAll(RELAY_ENHANCED_CAPABILITIES)
companion object {
val RELAY_ENHANCED_CAPABILITIES = setOf(
"credential_pools",
"structured_balances",
"opencode_go",
)
}
}
@Serializable
data class ProviderUsageProvider(
val id: String,
@SerialName("display_name") val displayName: String,
val status: String,
val source: String? = null,
@SerialName("fetched_at") val fetchedAt: String? = null,
val plan: String? = null,
val windows: List<ProviderUsageWindow> = emptyList(),
val details: List<String> = emptyList(),
val balances: List<ProviderUsageBalance> = emptyList(),
@SerialName("renews_at") val renewsAt: String? = null,
@SerialName("action_url") val actionUrl: String? = null,
val credentials: List<ProviderUsageCredential> = emptyList(),
@SerialName("active_credential_id") val activeCredentialId: String? = null,
@SerialName("active_credential_state") val activeCredentialState: String = "unknown",
@SerialName("active_observed_at") val activeObservedAt: String? = null,
val message: String? = null,
) {
val available: Boolean get() = status == STATUS_AVAILABLE
companion object {
const val STATUS_AVAILABLE = "available"
const val STATUS_NOT_CONFIGURED = "not_configured"
const val STATUS_UNAVAILABLE = "unavailable"
}
}
@Serializable
data class ProviderUsageBalance(
val id: String,
val label: String,
val amount: Double,
val currency: String = "USD",
)
@Serializable
data class ProviderUsageCredential(
val id: String,
val label: String,
val active: Boolean = false,
val status: String,
@SerialName("pool_status") val poolStatus: String? = null,
@SerialName("last_status_at") val lastStatusAt: String? = null,
@SerialName("reset_at") val resetAt: String? = null,
val plan: String? = null,
val windows: List<ProviderUsageWindow> = emptyList(),
val details: List<String> = emptyList(),
val message: String? = null,
) {
companion object {
const val STATUS_AVAILABLE = "available"
const val STATUS_AT_LIMIT = "at_limit"
const val STATUS_UNAVAILABLE = "unavailable"
}
}
@Serializable
data class ProviderUsageWindow(
val id: String,
val label: String,
@SerialName("used_percent") val usedPercent: Double? = null,
@SerialName("reset_at") val resetAt: String? = null,
val detail: String? = null,
)
@@ -0,0 +1,46 @@
package com.hermesandroid.relay.network.usage
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
/** Relay-enhanced usage with an upstream fallback for hosts without Relay support. */
class ProviderUsageRepository(
private val gatewayClientProvider: () -> GatewayChatClient?,
private val dashboardClientProvider: () -> DashboardApiClient? = { null },
private val relayHttpClient: RelayHttpClient,
private val profileProvider: () -> String? = { null },
private val sessionProvider: () -> String? = { null },
) {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
explicitNulls = false
}
suspend fun fetch(): Result<ProviderUsageResponse?> {
val profile = profileProvider()
val session = sessionProvider()
val dashboard = dashboardClientProvider()
if (dashboard != null) {
val enhanced = dashboard.getProviderUsage(profile, session)
if (enhanced.isSuccess && enhanced.getOrNull() != null) return enhanced
}
val relay = relayHttpClient.fetchProviderUsage(
profile = profile,
sessionId = session,
)
if (relay.isSuccess && relay.getOrNull() != null) return relay
val gateway = gatewayClientProvider()
if (gateway != null) {
val upstream = gateway.providerUsage()
.mapCatching { json.decodeFromJsonElement<ProviderUsageResponse>(it) }
if (upstream.isSuccess) return upstream
}
return relay
}
}
@@ -54,6 +54,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -133,6 +134,8 @@ import com.hermesandroid.relay.data.CandidateBuild
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.FeatureFlags
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.SupervisedModeStore
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.capabilities
import com.hermesandroid.relay.data.displayLabel
@@ -144,6 +147,7 @@ import com.hermesandroid.relay.util.HumanError
import kotlinx.coroutines.delay
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
import com.hermesandroid.relay.ui.screens.AboutScreen
import com.hermesandroid.relay.ui.screens.AdvancedSettingsScreen
import com.hermesandroid.relay.ui.screens.AnalyticsScreen
import com.hermesandroid.relay.ui.screens.AppearanceSettingsScreen
import com.hermesandroid.relay.ui.screens.CustomThemeScreen
@@ -170,6 +174,9 @@ import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
import com.hermesandroid.relay.ui.screens.SettingsScreen
import com.hermesandroid.relay.ui.screens.SupervisedControlsScreen
import com.hermesandroid.relay.ui.screens.SupervisedAppearanceSettingsScreen
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
import com.hermesandroid.relay.ui.screens.PluginsScreen
import com.hermesandroid.relay.ui.screens.PluginPageScreen
import com.hermesandroid.relay.ui.screens.TerminalScreen
@@ -530,6 +537,18 @@ sealed class Screen(
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
// above for the surviving route.)
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
data object AdvancedSettings : Screen("settings/advanced", "Advanced", Icons.Filled.Settings)
data object SupervisedAppearanceSettings : Screen(
"settings/supervised/appearance",
"Appearance",
Icons.Filled.Settings,
)
data object SupervisedControls : Screen(
"settings/supervised",
"Supervised mode",
Icons.Filled.Settings,
)
data object ProviderUsage : Screen("settings/usage", "Usage & limits", Icons.Filled.Settings)
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
data object CustomTheme : Screen("settings/appearance/custom-theme", "Custom", Icons.Filled.Settings)
@@ -587,6 +606,24 @@ sealed class Screen(
}
}
@Composable
private fun SupervisedStartupLoadingScreen() {
HermesRelayTheme(themePreference = "dark") {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
Text(
text = "Loading protected settings…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
fun RelayApp() {
val applicationContext = LocalContext.current.applicationContext
@@ -601,7 +638,10 @@ fun RelayApp() {
LaunchedEffect(processRuntime) {
processRuntime.ensureInitialized()
}
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) return
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) {
SupervisedStartupLoadingScreen()
return
}
val voiceClient: RelayVoiceClient = processRuntime.relayVoiceClient
val voicePreferences = processRuntime.voicePreferences
@@ -698,6 +738,72 @@ fun RelayApp() {
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
val connectionStoreHydrated by
connectionViewModel.connectionStore.isHydrated.collectAsState()
val supervisedModeStore = remember(applicationContext) {
SupervisedModeStore(applicationContext)
}
val supervisedPolicyState = produceState<Pair<String?, SupervisedModePolicy>?>(
initialValue = null,
key1 = activeConnectionId,
key2 = supervisedModeStore,
) {
val connectionId = activeConnectionId
if (connectionId == null) {
value = null to SupervisedModePolicy()
} else {
supervisedModeStore.policyFlow(connectionId).collect { policy ->
value = connectionId to policy
}
}
}
val ownedSupervisedPolicyState = supervisedPolicyState.value
?.takeIf { (ownerConnectionId, _) -> ownerConnectionId == activeConnectionId }
// Fail closed across process restoration. activeConnectionId starts as
// null while ConnectionStore reads DataStore, so null alone cannot prove
// this is a fresh install with no supervised policy to restore.
if (!isRelayNavigationHydrated(
connectionStoreHydrated = connectionStoreHydrated,
activeConnectionId = activeConnectionId,
supervisedPolicyHydrated = ownedSupervisedPolicyState != null,
)
) {
SupervisedStartupLoadingScreen()
return
}
val supervisedPolicy = ownedSupervisedPolicyState?.second ?: SupervisedModePolicy()
val supervisedPinnedProfile = supervisedPolicy.pinnedProfileName?.let { name ->
agentProfiles.firstOrNull { it.name.equals(name, ignoreCase = true) }
}
val supervisedProfileConfirmed = !supervisedPolicy.enabled || (
profileSelectionSettled &&
supervisedPinnedProfile != null &&
selectedProfile?.name.equals(supervisedPinnedProfile.name, ignoreCase = true)
)
val chatSupervisedPolicy = if (supervisedPolicy.enabled && !supervisedProfileConfirmed) {
supervisedPolicy.copy(pinnedProfileName = null)
} else supervisedPolicy
var parentAccessUnlocked by remember(activeConnectionId) { mutableStateOf(false) }
LaunchedEffect(
activeConnectionId,
supervisedPolicy,
agentProfiles,
selectedProfile,
profileSelectionSettled,
) {
chatViewModel.updateSupervisedModePolicy(chatSupervisedPolicy)
connectionViewModel.authManager.updateSupervisedMode(chatSupervisedPolicy)
if (!supervisedPolicy.enabled) {
parentAccessUnlocked = false
return@LaunchedEffect
}
val pinned = supervisedPinnedProfile ?: return@LaunchedEffect
if (!selectedProfile?.name.equals(pinned.name, ignoreCase = true)) {
connectionViewModel.selectProfile(pinned)
chatViewModel.activateGatewayProfile(pinned)
}
}
val connections by connectionViewModel.connections.collectAsState()
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
@@ -782,6 +888,22 @@ fun RelayApp() {
val appearanceAccent by connectionViewModel.appearanceAccent.collectAsState()
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val parentAccessForCurrentRoute = parentAccessUnlocked &&
!shouldRelockParentAccess(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessUnlocked,
route = currentRoute,
)
val resolvedTheme = resolveSupervisedTheme(
policy = supervisedPolicy,
parentAccessUnlocked = parentAccessForCurrentRoute,
globalAppThemeId = appThemeId,
globalThemePreference = themePreference,
)
val supervisedAppearanceLocked = supervisedPolicy.enabled && !parentAccessForCurrentRoute
// Resolve the active sphere skin (built-in / adaptive / user-loaded) and
// publish it + the full available set so every MorphingSphere picks it up
@@ -798,10 +920,10 @@ fun RelayApp() {
value = SphereRegistry.builtIns +
withContext(Dispatchers.IO) { SphereSkinLoader.loadUserSkins(sphereContext) }
}
val activeSphereSkin = remember(sphereSkinId, appThemeId, availableSphereSkins) {
val activeSphereSkin = remember(sphereSkinId, resolvedTheme.appThemeId, availableSphereSkins) {
SphereRegistry.resolve(
selectedId = sphereSkinId,
themeDefaultSkinId = AppThemes.byId(appThemeId).defaultSphereSkinId,
themeDefaultSkinId = AppThemes.byId(resolvedTheme.appThemeId).defaultSphereSkinId,
available = availableSphereSkins,
)
}
@@ -936,37 +1058,19 @@ fun RelayApp() {
),
)
HermesRelayTheme(
appThemeId = appThemeId,
themePreference = themePreference,
appThemeId = resolvedTheme.appThemeId,
themePreference = resolvedTheme.themePreference,
fontScale = fontScale,
appFontId = appFontId,
accentHex = appearanceAccent,
accentHex = appearanceAccent.takeIf { resolvedTheme.useGlobalCustomTheme },
shapeId = appearanceShape,
customTheme = activeCustomTheme,
customTheme = activeCustomTheme.takeIf { resolvedTheme.useGlobalCustomTheme },
) {
// Surface a crash report from a previous session, if any. Renders a
// platform Dialog (own window) so tree position is z-order-agnostic;
// it just needs to be inside the theme for Material colors.
CrashReportGate()
val navController = rememberNavController()
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
// Collect navigation requests posted by external launchers (e.g., the
// BridgeForegroundService notification's "Settings" action). The
// service sets EXTRA_NAV_ROUTE on its launch intent → MainActivity's
// onCreate / onNewIntent reads it and pumps it onto NavRouteRequest →
// we forward each emission to the NavController. Single observer at
// the app root so every screen benefits.
LaunchedEffect(navController) {
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
navController.navigate(route) {
launchSingleTop = true
}
}
}
// === END PHASE3-safety-rails-followup ===
// Wire the proactive "session" surfacing once: a message with
// surfacing="session" is injected into the active chat conversation.
// ChatViewModel isn't available where ConnectionViewModel builds the
@@ -1037,8 +1141,74 @@ fun RelayApp() {
// restart cleanly lands back in setup.
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
// The unlock remains useful while moving between parent-only settings,
// but never follows an enrolled device user back into supervised chat.
// Cross-layer requests (notifications, services, deep links) use the
// route-scoped unlock. As soon as Chat is current, the parent grant is
// ineffective even before the state-clearing effect runs.
LaunchedEffect(
navController,
supervisedPolicy.enabled,
parentAccessForCurrentRoute,
) {
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
if (
supervisedPolicy.enabled &&
!isSupervisedRouteAllowed(route, parentAccessForCurrentRoute)
) return@collect
navController.navigate(route) {
launchSingleTop = true
}
}
}
LaunchedEffect(
supervisedPolicy.enabled,
parentAccessForCurrentRoute,
currentRoute,
) {
val redirect = shouldRedirectSupervisedRoute(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessForCurrentRoute,
currentRoute = currentRoute,
)
if (redirect) {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(navController.graph.findStartDestination().id) { inclusive = false }
launchSingleTop = true
}
}
}
LaunchedEffect(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute) {
if (shouldRelockParentAccess(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute)) {
// Route-scoped authority is already false on Chat. Let Navigation
// finish committing the new destination before clearing the raw
// parent grant, otherwise the same-frame root recomposition can
// leave a themed but contentless surface.
withFrameNanos { }
withFrameNanos { }
parentAccessUnlocked = false
}
}
LaunchedEffect(parentAccessUnlocked, supervisedPolicy.parentAccess.timeoutMinutes) {
if (parentAccessUnlocked) {
delay(supervisedPolicy.parentAccess.timeoutMinutes * 60_000L)
parentAccessUnlocked = false
}
}
DisposableEffect(lifecycleOwner, supervisedPolicy.enabled, parentAccessUnlocked) {
val relockObserver = LifecycleEventObserver { _, event ->
if (
event == Lifecycle.Event.ON_PAUSE &&
supervisedPolicy.enabled &&
parentAccessUnlocked &&
supervisedPolicy.parentAccess.relockOnBackground
) {
parentAccessUnlocked = false
}
}
lifecycleOwner.lifecycle.addObserver(relockObserver)
onDispose { lifecycleOwner.lifecycle.removeObserver(relockObserver) }
}
val suppressGlobalChrome = shouldSuppressGlobalChrome(
onboardingCompleted = onboardingCompleted,
isDemoMode = isDemoMode,
@@ -1717,6 +1887,8 @@ fun RelayApp() {
!suppressGlobalChrome &&
!isKeyboardVisible &&
!showStartupSphere &&
(!supervisedPolicy.enabled ||
supervisedPolicy.visibility.resolved().showTechnicalRoute) &&
shouldShowConnectionFooter(voiceUiState.voiceMode, voicePresentationMode)
) {
val footerRoute = resolveFooterRouteCandidate(
@@ -1791,12 +1963,16 @@ fun RelayApp() {
.fillMaxSize()
.padding(innerPadding),
) {
val routeContentAllowed = isSupervisedRouteContentAllowed(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessForCurrentRoute,
currentRoute = currentRoute,
)
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
modifier = Modifier.fillMaxSize(),
) {
composable(Screen.Onboarding.route) {
// The wizard inside OnboardingScreen now owns credential
@@ -1883,15 +2059,43 @@ fun RelayApp() {
// sheet.
val openAgentSheetArg = backStackEntry.arguments
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
val requestedSessionId = backStackEntry.arguments
val rawRequestedSessionId = backStackEntry.arguments
?.getString(Screen.Chat.ARG_SESSION_ID)
?.takeIf { it.isNotBlank() }
val requestedProfileRoute = backStackEntry.arguments
val rawRequestedProfileRoute = backStackEntry.arguments
?.getString(Screen.Chat.ARG_PROFILE)
?.takeIf { it.isNotBlank() }
val requestedProactiveChatId = backStackEntry.arguments
val rawRequestedProactiveChatId = backStackEntry.arguments
?.getString(Screen.Chat.ARG_PROACTIVE_CHAT_ID)
?.takeIf { it.isNotBlank() }
// Nav/deep-link arguments are not ownership evidence. The
// supervised drawer uses profile-scoped session rows
// directly; external args stay discarded until an
// owner-aware source can explicitly prove the binding.
val sanitizedRouteArgs = sanitizeSupervisedChatRouteArgs(
policy = supervisedPolicy,
args = SupervisedChatRouteArgs(
sessionId = rawRequestedSessionId,
profile = rawRequestedProfileRoute,
proactiveChatId = rawRequestedProactiveChatId,
),
pinnedProfileOwnershipProven = false,
)
val requestedSessionId = sanitizedRouteArgs.sessionId
val requestedProfileRoute = sanitizedRouteArgs.profile
val requestedProactiveChatId = sanitizedRouteArgs.proactiveChatId
LaunchedEffect(
supervisedPolicy.enabled,
rawRequestedSessionId,
rawRequestedProfileRoute,
rawRequestedProactiveChatId,
) {
if (supervisedPolicy.enabled) {
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROACTIVE_CHAT_ID, null)
}
}
val proactiveInboxEntries by connectionViewModel.inboxMessages.collectAsState()
val phoneThreadChatIds by connectionViewModel.phoneThreadChatIds.collectAsState()
LaunchedEffect(
@@ -2054,6 +2258,7 @@ fun RelayApp() {
launchSingleTop = true
}
},
supervisedPolicy = chatSupervisedPolicy,
onNavigateToBotMode = {
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
},
@@ -2374,6 +2579,25 @@ fun RelayApp() {
SettingsScreen(
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
supervisedPolicy = supervisedPolicy,
parentAccessUnlocked = parentAccessForCurrentRoute,
onRequestParentAccess = { parentAccessUnlocked = true },
onUpdateSupervisedPolicy = { policy ->
activeConnectionId?.let { connectionId ->
connectionSwitchScope.launch {
supervisedModeStore.setPolicy(connectionId, policy)
}
}
},
onNavigateToAdvancedSettings = {
navController.navigate(Screen.AdvancedSettings.route)
},
onNavigateToSupervisedAppearance = {
navController.navigate(Screen.SupervisedAppearanceSettings.route)
},
onNavigateToSupervisedControls = {
navController.navigate(Screen.SupervisedControls.route)
},
onBack = { navController.popBackStack() },
// (The `onNavigateToChatWithAgentSheet` callback that
// used to live here was removed 2026-04-21. Tapping
@@ -2387,6 +2611,9 @@ fun RelayApp() {
onNavigateToManage = {
navController.navigate(Screen.Manage.route)
},
onNavigateToProviderUsage = {
navController.navigate(Screen.ProviderUsage.route)
},
onNavigateToPlugins = {
navController.navigate(Screen.Plugins.route)
},
@@ -2445,6 +2672,69 @@ fun RelayApp() {
},
)
}
composable(Screen.AdvancedSettings.route) {
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
AdvancedSettingsScreen(
supervisedPolicy = supervisedPolicy,
onNavigateToSupervisedControls = {
navController.navigate(Screen.SupervisedControls.route)
},
onBack = { navController.popBackStack() },
)
}
}
composable(Screen.SupervisedAppearanceSettings.route) {
if (!supervisedPolicy.enabled && !parentAccessForCurrentRoute) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
SupervisedAppearanceSettingsScreen(
connectionViewModel = connectionViewModel,
policy = supervisedPolicy,
onPolicyChange = { policy ->
activeConnectionId?.let { connectionId ->
connectionSwitchScope.launch {
supervisedModeStore.setPolicy(connectionId, policy)
}
}
},
onBack = { navController.popBackStack() },
)
}
}
composable(Screen.SupervisedControls.route) {
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
SupervisedControlsScreen(
connectionViewModel = connectionViewModel,
policy = supervisedPolicy,
profiles = agentProfiles.filterNot { it.isDefault },
onPolicyChange = { policy ->
activeConnectionId?.let { connectionId ->
connectionSwitchScope.launch {
supervisedModeStore.setPolicy(connectionId, policy)
}
}
},
onBack = { navController.popBackStack() },
onReturnToSupervisedView = {
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
popUpTo(Screen.Chat.route) { inclusive = false }
launchSingleTop = true
}
},
)
}
}
composable(Screen.ProviderUsage.route) {
UsageLimitsScreen(
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
onBack = { navController.popBackStack() },
)
}
composable(Screen.Plugins.route) {
PluginsScreen(
viewModel = pluginsViewModel,
@@ -2915,7 +3205,8 @@ fun RelayApp() {
composable(Screen.About.route) {
AboutScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessForCurrentRoute,
)
}
composable(
@@ -3018,6 +3309,12 @@ fun RelayApp() {
)
}
}
if (!routeContentAllowed) {
// Keep the graph mounted so the redirect can complete, but
// cover restored parent-only content with an opaque fail-closed surface.
SupervisedStartupLoadingScreen()
}
}
} // end bridge-return wrapper column
} // end CompositionLocalProvider
}
@@ -3030,6 +3327,7 @@ fun RelayApp() {
val petSurfaceOwner = petSurfaceOwnerForRoute(currentRoute)
val petActivity = petCompanionCoordinator.activityFor(petSurfaceOwner)
val showFloatingPet = activeFloatingPet != null &&
shouldShowPetInSupervisedMode(supervisedPolicy, parentAccessForCurrentRoute) &&
floatingPetAllowedOnRoute(currentRoute) &&
!petActivity.hidden &&
!suppressGlobalChrome &&
@@ -3060,6 +3358,7 @@ fun RelayApp() {
),
animationEnabled = animationEnabled,
appForeground = appIsForeground,
interactive = !supervisedAppearanceLocked,
route = roamingRoute,
visitRequest = petCompanionCoordinator.pendingVisitRequest,
onVisitRequestConsumed = petCompanionCoordinator::clearVisitRequest,
@@ -0,0 +1,34 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.SupervisedModePolicy
internal data class ResolvedSupervisedTheme(
val appThemeId: String,
val themePreference: String,
val useGlobalCustomTheme: Boolean,
)
/** Keep the supervised palette isolated from the parent's ordinary app theme. */
internal fun resolveSupervisedTheme(
policy: SupervisedModePolicy,
parentAccessUnlocked: Boolean,
globalAppThemeId: String,
globalThemePreference: String,
): ResolvedSupervisedTheme = if (policy.enabled && !parentAccessUnlocked) {
ResolvedSupervisedTheme(
appThemeId = policy.appearance.appThemeId,
themePreference = policy.appearance.themePreference,
useGlobalCustomTheme = false,
)
} else {
ResolvedSupervisedTheme(
appThemeId = globalAppThemeId,
themePreference = globalThemePreference,
useGlobalCustomTheme = true,
)
}
internal fun shouldShowPetInSupervisedMode(
policy: SupervisedModePolicy,
parentAccessUnlocked: Boolean,
): Boolean = !policy.enabled || parentAccessUnlocked || policy.appearance.showPet
@@ -0,0 +1,107 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.SupervisedModePolicy
/** Allowlist applied to external, deep-link, and programmatic navigation. */
internal fun isSupervisedRouteAllowed(route: String?, parentAccessUnlocked: Boolean): Boolean {
if (parentAccessUnlocked) return true
val normalized = route?.substringBefore('?') ?: return false
return normalized == "chat" ||
normalized == Screen.Settings.route ||
normalized == Screen.SupervisedAppearanceSettings.route
}
/** Do not inspect or mutate a NavController until its first destination exists. */
internal fun shouldRedirectSupervisedRoute(
supervisedEnabled: Boolean,
parentAccessUnlocked: Boolean,
currentRoute: String?,
): Boolean = currentRoute != null &&
supervisedEnabled &&
!isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
/** A null route is Navigation's pre-graph bootstrap state, not a forbidden destination. */
internal fun isSupervisedRouteContentAllowed(
supervisedEnabled: Boolean,
parentAccessUnlocked: Boolean,
currentRoute: String?,
): Boolean = currentRoute == null ||
!supervisedEnabled ||
isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
/**
* Cold-start gate for the app navigation graph.
*
* A null active connection is also the seed value used while [ConnectionStore]
* is reading DataStore. Callers must therefore wait for the store's explicit
* hydration signal before treating null as "no connection" and composing the
* unrestricted onboarding/settings graph.
*/
internal fun isRelayNavigationHydrated(
connectionStoreHydrated: Boolean,
activeConnectionId: String?,
supervisedPolicyHydrated: Boolean,
): Boolean = connectionStoreHydrated &&
(activeConnectionId == null || supervisedPolicyHydrated)
/** A parent unlock never follows the user back into the supervised chat root. */
internal fun shouldRelockParentAccess(
supervisedEnabled: Boolean,
parentAccessUnlocked: Boolean,
route: String?,
): Boolean = supervisedEnabled &&
parentAccessUnlocked &&
route?.substringBefore('?') == "chat"
/**
* External chat route arguments are untrusted. A session may be restored only
* after an owner-aware source has proved that it belongs to the pinned profile.
*/
internal fun mayRestoreSupervisedSessionRoute(
policy: SupervisedModePolicy,
requestedSessionId: String?,
requestedProfile: String?,
pinnedProfileOwnershipProven: Boolean,
): Boolean = policy.isActive &&
policy.capabilities.conversationHistory &&
pinnedProfileOwnershipProven &&
!requestedSessionId.isNullOrBlank() &&
!requestedProfile.isNullOrBlank() &&
requestedProfile.equals(policy.pinnedProfileName, ignoreCase = true)
internal data class SupervisedChatRouteArgs(
val sessionId: String? = null,
val profile: String? = null,
val proactiveChatId: String? = null,
)
/** Strip external chat targeting before any destination effect can dispatch it. */
internal fun sanitizeSupervisedChatRouteArgs(
policy: SupervisedModePolicy,
args: SupervisedChatRouteArgs,
pinnedProfileOwnershipProven: Boolean,
): SupervisedChatRouteArgs {
if (!policy.enabled) return args
val allowSession = mayRestoreSupervisedSessionRoute(
policy = policy,
requestedSessionId = args.sessionId,
requestedProfile = args.profile,
pinnedProfileOwnershipProven = pinnedProfileOwnershipProven,
)
return if (allowSession) {
args.copy(proactiveChatId = null)
} else {
SupervisedChatRouteArgs()
}
}
/** A disabled policy may become active only after an enrolled credential succeeds. */
internal fun mayEnableSupervisedMode(
policy: SupervisedModePolicy,
deviceSecure: Boolean,
deviceCredentialConfirmed: Boolean,
): Boolean = !policy.enabled &&
policy.isConfigured &&
deviceSecure &&
deviceCredentialConfirmed
@@ -125,6 +125,7 @@ fun AttachmentGallery(
if (attachments.size < 2) return
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
val scope = rememberCoroutineScope()
val blurMode = LocalMediaBlurMode.current
val revealed = remember { mutableStateMapOf<String, Boolean>() }
@@ -189,7 +190,7 @@ fun AttachmentGallery(
)
}
if (!blurred) {
if (!blurred && exportAllowed) {
SaveOverlayButton(
onClick = {
scope.launch { saveAttachment(context, attachment) }
@@ -201,7 +202,7 @@ fun AttachmentGallery(
}
AttachmentActionsMenu(
expanded = menuExpanded,
expanded = menuExpanded && exportAllowed,
onDismiss = { menuExpanded = false },
context = context,
scope = scope,
@@ -312,6 +312,8 @@ fun AttachmentViewer(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
@@ -405,6 +407,7 @@ fun AttachmentViewer(
title = title,
busy = busy,
actionsEnabled = !blurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
@@ -448,6 +451,7 @@ internal fun AttachmentGalleryViewer(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
@@ -584,6 +588,7 @@ internal fun AttachmentGalleryViewer(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
@@ -613,6 +618,7 @@ private fun MediaViewerToolbar(
title: String,
busy: Boolean,
actionsEnabled: Boolean = true,
exportAllowed: Boolean = true,
onShare: () -> Unit,
onSave: () -> Unit,
onOpenExternal: () -> Unit,
@@ -653,11 +659,13 @@ private fun MediaViewerToolbar(
) {
Icon(Icons.Filled.OpenInNew, contentDescription = stringResource(R.string.attachment_open_externally_a11y))
}
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
}
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
if (exportAllowed) {
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
}
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
}
}
}
}
@@ -32,6 +32,7 @@ fun ChatFailurePanel(
onDetails: () -> Unit,
onRetry: () -> Unit,
onDismiss: () -> Unit,
showDetails: Boolean = true,
modifier: Modifier = Modifier,
) {
Surface(
@@ -72,8 +73,10 @@ fun ChatFailurePanel(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDetails) {
Text(stringResource(R.string.chat_failure_details))
if (showDetails) {
TextButton(onClick = onDetails) {
Text(stringResource(R.string.chat_failure_details))
}
}
if (failure.recoverable) {
TextButton(onClick = onRetry) {
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -42,6 +43,9 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.util.MediaSaver
import kotlinx.coroutines.launch
/** Whether the current conversation policy permits copying image bytes out of the app. */
val LocalImageExportAllowed = staticCompositionLocalOf { true }
/**
* What the [ChatImageViewer] displays and how it obtains bytes for Save/Share.
*
@@ -104,6 +108,7 @@ fun ChatImageViewer(
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
@@ -156,60 +161,72 @@ fun ChatImageViewer(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
val cdClose = stringResource(R.string.cd_close_viewer)
val errorMsg = context.getString(R.string.image_viewer_error)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
busy = false
if (bytes == null) {
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
MediaSaver.share(context, uri, source.mime)
}
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
if (bytes == null) {
if (exportAllowed) {
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
busy = false
toast(context, errorMsg)
return@launch
if (bytes == null) {
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
if (bytes == null) {
busy = false
toast(context, savedFmt.format(result.location))
toast(context, errorMsg)
return@launch
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, failedFmt.format(result.message))
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
toast(context, savedFmt.format(result.location))
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, failedFmt.format(result.message))
}
}
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = cdSave)
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = cdSave)
}
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = cdClose)
@@ -483,6 +483,7 @@ fun FloatingPetCompanion(
compact: Boolean,
animationEnabled: Boolean,
appForeground: Boolean,
interactive: Boolean = true,
route: String?,
visitRequest: PetVisitRequest?,
onVisitRequestConsumed: (String) -> Unit,
@@ -2318,13 +2319,14 @@ fun FloatingPetCompanion(
}
.pointerInput(
pet.id,
interactive,
safeBounds,
roamingRails,
settledHabitat,
positioned,
surfaceScrolling,
) {
if (!floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
if (!interactive || !floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
return@pointerInput
}
detectDragGesturesAfterLongPress(
@@ -2399,16 +2401,16 @@ fun FloatingPetCompanion(
)
}
.clickable(
enabled = floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
enabled = interactive && floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
) {
tapReactionNonce += 1
setMenuExpanded(true)
}
.semantics(mergeDescendants = true) {
role = Role.Button
if (interactive) role = Role.Button
contentDescription = companionDescription
stateDescription = stateLabel
customActions = buildList {
customActions = if (interactive) buildList {
add(CustomAccessibilityAction(moveStartLabel) {
onPlacementChanged(placement.copy(edge = PetLogicalEdge.Start)); true
})
@@ -2438,7 +2440,7 @@ fun FloatingPetCompanion(
add(CustomAccessibilityAction(resetLabel) { onResetPlacement(); true })
add(CustomAccessibilityAction(appearanceLabel) { onOpenAppearance(); true })
add(CustomAccessibilityAction(hideLabel) { onHide(); true })
}
} else emptyList()
},
contentAlignment = Alignment.Center,
) {
@@ -2482,7 +2484,7 @@ fun FloatingPetCompanion(
}
DropdownMenu(
expanded = menuExpanded,
expanded = interactive && menuExpanded,
onDismissRequest = { setMenuExpanded(false) },
) {
DropdownMenuItem(
@@ -270,6 +270,7 @@ private fun ImageRender(
maxWidth: Dp
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
val scope = rememberCoroutineScope()
// Decode OFF the main thread — a large inbound image would otherwise block
// composition. Null while decoding (placeholder); decodeFailed → file card.
@@ -356,14 +357,14 @@ private fun ImageRender(
}
// One-tap save overlay — hidden while the blur cover is up so it
// doesn't sit over the "tap to reveal" prompt.
if (!blurred) {
if (!blurred && exportAllowed) {
SaveOverlayButton(
onClick = { scope.launch { saveAttachment(context, attachment) } },
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
)
}
AttachmentActionsMenu(
expanded = menuExpanded,
expanded = menuExpanded && exportAllowed,
onDismiss = { menuExpanded = false },
context = context,
scope = scope,
@@ -380,6 +381,8 @@ private fun FileCardRender(
maxWidth: Dp
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
val scope = rememberCoroutineScope()
val (emoji, typeLabel) = emojiAndLabelFor(attachment.renderMode, attachment.contentType)
var menuExpanded by remember { mutableStateOf(false) }
@@ -463,21 +466,23 @@ private fun FileCardRender(
}
}
// Visible one-tap save affordance (B2).
IconButton(
onClick = { scope.launch { saveAttachment(context, attachment) } },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = stringResource(R.string.inbound_attach_cd_save),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
if (exportAllowed) {
IconButton(
onClick = { scope.launch { saveAttachment(context, attachment) } },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = stringResource(R.string.inbound_attach_cd_save),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
AttachmentActionsMenu(
expanded = menuExpanded,
expanded = menuExpanded && exportAllowed,
onDismiss = { menuExpanded = false },
context = context,
scope = scope,
@@ -94,6 +94,14 @@ import java.util.Date
internal const val CHAT_PET_IDENTITY_OBSTACLE_PREFIX = "chat-message-identity:"
private val MESSAGE_REACTIONS = listOf("❤️", "👍", "👎", "😂", "‼️", "❓")
internal fun assistantImageContent(
content: String,
showImages: Boolean,
): Pair<String, List<ChatInlineImage>> {
val (body, images) = extractChatInlineImages(content)
return body to if (showImages) images else emptyList()
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
@@ -101,6 +109,13 @@ fun MessageBubble(
modifier: Modifier = Modifier,
maxBubbleWidth: Dp = 300.dp,
showThinking: Boolean = true,
showAgentIdentity: Boolean = true,
showTimestamps: Boolean = true,
showWorkingStatus: Boolean = true,
showUsage: Boolean = true,
showTechnicalBadges: Boolean = true,
showAssistantImages: Boolean = true,
allowAssistantImageExport: Boolean = true,
isFirstInGroup: Boolean = true,
isLastInGroup: Boolean = true,
onCopyMessage: (String) -> Unit = {},
@@ -238,18 +253,24 @@ fun MessageBubble(
// content so they render as real images (remote URLs via Coil) or a
// graceful inline notice — not the blank element the markdown renderer
// emits for an image link. User/system bubbles keep their raw content.
val (markdownBody, inlineImages) = remember(visibleMessageContent, isUser, isSystem) {
val (markdownBody, inlineImages) = remember(
visibleMessageContent,
isUser,
isSystem,
showAssistantImages,
) {
if (isUser || isSystem) {
visibleMessageContent to emptyList()
} else {
extractChatInlineImages(visibleMessageContent)
assistantImageContent(visibleMessageContent, showAssistantImages)
}
}
val showImageGeneration = shouldShowImageGenerationPlaceholder(
val showImageGeneration = showAssistantImages && showWorkingStatus && shouldShowImageGenerationPlaceholder(
toolCalls = message.toolCalls,
isStreaming = message.isStreaming,
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
)
val actionContent = if (!isUser && !isSystem) markdownBody else visibleMessageContent
val streamingStatusLabel = if (
!isUser &&
!isSystem &&
@@ -297,7 +318,10 @@ fun MessageBubble(
val blurRepo = remember(context) { MediaSettingsRepository(context.applicationContext) }
val blurMode by blurRepo.blurMode.collectAsState(initial = BlurMode.FLAGGED)
CompositionLocalProvider(LocalMediaBlurMode provides blurMode) {
CompositionLocalProvider(
LocalMediaBlurMode provides blurMode,
LocalImageExportAllowed provides allowAssistantImageExport,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = alignment,
@@ -305,7 +329,7 @@ fun MessageBubble(
// Keep sender identity in the first-message label rather than a
// persistent leading column. Long responses and every follow-up in the
// group therefore retain the full bubble-width allowance.
if (!isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
if (showAgentIdentity && !isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
@@ -336,7 +360,7 @@ fun MessageBubble(
}
}
if (!isUser && !isSystem && message.badges.isNotEmpty()) {
if (showTechnicalBadges && !isUser && !isSystem && message.badges.isNotEmpty()) {
Row(
modifier = Modifier
.widthIn(max = maxBubbleWidth)
@@ -415,7 +439,7 @@ fun MessageBubble(
// is rendered directly in the conversation
// lane below, without an opaque bubble. Cards and attachments still own
// a normal bubble even when response prose has not arrived yet.
streamingStatusLabel?.let { streamingStatus ->
streamingStatusLabel?.takeIf { showWorkingStatus }?.let { streamingStatus ->
StandaloneStreamingStatus(
status = streamingStatus,
accessibilityDescription = a11yDescription,
@@ -508,7 +532,7 @@ fun MessageBubble(
text = { Text(stringResource(R.string.msg_bubble_copy)) },
onClick = {
showMessageActions = false
onCopyMessage(visibleMessageContent)
onCopyMessage(actionContent)
},
)
if (onQuoteMessage != null) {
@@ -516,7 +540,7 @@ fun MessageBubble(
text = { Text(stringResource(R.string.msg_bubble_quote)) },
onClick = {
showMessageActions = false
onQuoteMessage(message.copy(content = visibleMessageContent))
onQuoteMessage(message.copy(content = actionContent))
},
)
}
@@ -531,7 +555,7 @@ fun MessageBubble(
},
onClick = {
showMessageActions = false
onSpeakMessage?.invoke(visibleMessageContent)
onSpeakMessage?.invoke(actionContent)
},
)
}
@@ -601,7 +625,7 @@ fun MessageBubble(
) {
showMessageActions = true
} else {
onCopyMessage(visibleMessageContent)
onCopyMessage(actionContent)
}
}
)
@@ -798,7 +822,7 @@ fun MessageBubble(
}
}
val hasTokenUsage = !isUser &&
val hasTokenUsage = showUsage && !isUser &&
(message.inputTokens != null || message.outputTokens != null)
// Timestamp — only on the LAST bubble of a same-author run so a
@@ -808,13 +832,13 @@ fun MessageBubble(
// This row is reserved from the first streaming frame. Completion
// can reveal both timestamp and token usage without adding a new
// footer line or changing the bubble's measured height.
if (isLastInGroup) {
if (isLastInGroup && (showTimestamps || hasTokenUsage)) {
Spacer(modifier = Modifier.height(2.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (showTimestamps) Text(
text = timeFormat.format(Date(message.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
@@ -883,15 +907,15 @@ fun MessageBubble(
showEdit = showEditAction,
onCopy = {
showInlineActions = false
onCopyMessage(visibleMessageContent)
onCopyMessage(actionContent)
},
onQuote = {
showInlineActions = false
onQuoteMessage?.invoke(message.copy(content = visibleMessageContent))
onQuoteMessage?.invoke(message.copy(content = actionContent))
},
onSpeak = {
showInlineActions = false
onSpeakMessage?.invoke(visibleMessageContent)
onSpeakMessage?.invoke(actionContent)
},
onStopSpeaking = {
showInlineActions = false
@@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
@@ -25,6 +26,8 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.LocalBrand
import kotlinx.coroutines.delay
import kotlin.math.sin
/**
* ASCII morphing sphere — the visual embodiment of the AI agent.
@@ -53,6 +56,32 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
private const val SPHERE_TIME_UNITS_PER_SEC = 1f
private const val SPHERE_TWO_PI = 6.2832f
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
private const val SPHERE_IDLE_BREATH_RADIANS_PER_SEC = 0.72f
private const val SPHERE_IDLE_BREATH_SCALE = 0.012f
private const val SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS = 184L
internal enum class SphereMotionMode {
Still,
AmbientLayer,
Procedural,
}
internal fun sphereMotionMode(
state: SphereState,
voiceMode: Boolean,
motionVisible: Boolean,
fixedTime: Float?,
fixedColorPhase: Float?,
): SphereMotionMode {
if (!motionVisible || fixedTime != null || fixedColorPhase != null) {
return SphereMotionMode.Still
}
return if (state == SphereState.Idle && !voiceMode) {
SphereMotionMode.AmbientLayer
} else {
SphereMotionMode.Procedural
}
}
@Composable
fun MorphingSphere(
@@ -64,7 +93,8 @@ fun MorphingSphere(
voiceMode: Boolean = false,
skin: SphereSkin = LocalSphereSkin.current,
fixedTime: Float? = null,
fixedColorPhase: Float? = null
fixedColorPhase: Float? = null,
motionVisible: Boolean = true,
) {
val brand = LocalBrand.current
// Gate reactive inputs on what the skin declares it honors — this is the
@@ -104,16 +134,21 @@ fun MorphingSphere(
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
// Continuous motion runs only for active agent/voice states. Idle is a
// stable frame: the 58x34 text grid is expensive enough that even a
// throttled cosmetic drift dominated measured screen-on CPU. Active states
// retain full display-rate motion and dt-based timing.
// Active states retain the full procedural animation. Visible Idle uses a
// lightweight graphics-layer breath: redrawing the 58x34 glyph grid just
// for ambient drift was the measured screen-on hotspot, while transforming
// its cached layer preserves the intended living Sphere at far lower cost.
val animatedTime = remember { mutableFloatStateOf(0f) }
val animatedColorPhase = remember { mutableFloatStateOf(0f) }
val fullFrameRate = state != SphereState.Idle || effVoiceMode
val driveAnimation = (fixedTime == null || fixedColorPhase == null) && fullFrameRate
if (driveAnimation) {
LaunchedEffect(fullFrameRate) {
val motionMode = sphereMotionMode(
state = state,
voiceMode = effVoiceMode,
motionVisible = motionVisible,
fixedTime = fixedTime,
fixedColorPhase = fixedColorPhase,
)
if (motionMode == SphereMotionMode.Procedural) {
LaunchedEffect(motionMode) {
var lastNanos = withFrameNanos { it }
while (true) {
val now = withFrameNanos { it }
@@ -127,6 +162,25 @@ fun MorphingSphere(
}
}
}
val idleBreathPhase = remember { mutableFloatStateOf(0f) }
LaunchedEffect(motionMode) {
if (motionMode != SphereMotionMode.AmbientLayer) {
idleBreathPhase.floatValue = 0f
return@LaunchedEffect
}
var lastNanos = withFrameNanos { it }
while (true) {
val now = withFrameNanos { it }
val dtSec = (now - lastNanos).coerceAtLeast(0L) / 1_000_000_000f
lastNanos = now
idleBreathPhase.floatValue =
(idleBreathPhase.floatValue + dtSec * SPHERE_IDLE_BREATH_RADIANS_PER_SEC) %
SPHERE_TWO_PI
// The frame wait plus this delay caps the gentle layer-only pulse
// near 5fps while active procedural states retain display-rate motion.
delay(SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS)
}
}
val time = fixedTime ?: animatedTime.floatValue
val colorPhase = fixedColorPhase ?: animatedColorPhase.floatValue
@@ -138,7 +192,18 @@ fun MorphingSphere(
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
val glyphStrings = remember { HashMap<Char, String>(32) }
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
Canvas(
modifier = modifier
.fillMaxSize()
.graphicsLayer {
if (motionMode == SphereMotionMode.AmbientLayer) {
val scale = 1f + sin(idleBreathPhase.floatValue) * SPHERE_IDLE_BREATH_SCALE
scaleX = scale
scaleY = scale
}
}
.clipToBounds(),
) {
val canvasW = size.width
val canvasH = size.height
val cellW = canvasW / cols
@@ -5,6 +5,7 @@ import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.SweepGradient
import androidx.annotation.StringRes
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
@@ -81,6 +82,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -105,6 +109,7 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SupervisedSessionActions
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
@@ -116,6 +121,7 @@ import com.hermesandroid.relay.ui.theme.resolveProfileAccent
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
internal enum class SessionDrawerFilter {
All,
@@ -203,6 +209,8 @@ fun SessionDrawerContent(
animationEnabled: Boolean = true,
autoTitlesSupported: Boolean = true,
archiveSupported: Boolean = true,
supervisedSessionActions: SupervisedSessionActions? = null,
newChatEnabled: Boolean = true,
onRefresh: (() -> Unit)? = null,
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
onOpenBotMode: (() -> Unit)? = null,
@@ -277,9 +285,16 @@ fun SessionDrawerContent(
}
val scopedRows = (sessions + provisionalSessions).map { ProfileSessionRow(activeProfileName, it) }
val sourceRows = if (showAllProfiles) allProfileSessions else scopedRows
val scopedActivityStates = scopedSessionActivityStates(
rows = sourceRows,
activityStates = activityStates,
allowBareSessionIds = !showAllProfiles,
)
val sourceSessions = sourceRows.map { it.session }
val showThreads = threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) }
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, archiveSupported)
val showThreads = supervisedSessionActions == null &&
(threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) })
val effectiveArchiveSupported = archiveSupported && supervisedSessionActions?.archive != false
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, effectiveArchiveSupported)
// External gateway sources present (discord/telegram/cron/…) for the source
// filter dropdown. Own chats (tui/api_server) + phone Threads aren't listed.
val presentSources = sourceSessions
@@ -319,8 +334,13 @@ fun SessionDrawerContent(
sessionWorkLabels(session).any { it.contains(needle, ignoreCase = true) }
}
.toList()
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, activityStates)
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, activityStates)
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, scopedActivityStates)
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, scopedActivityStates)
val drawerNowMillis = rememberDrawerClock(
isEnabled = isOpen && (
viewOptions.showUpdated || viewOptions.grouping == SessionDrawerGrouping.Project
),
)
val drawerTitle = if (showAllProfiles) {
stringResource(R.string.drawer_all_profiles)
} else {
@@ -390,7 +410,7 @@ fun SessionDrawerContent(
)
// Source filter — show/hide gateway sources (default hides the
// noisy cron+webhook). Only when external sources are present.
if (onToggleSourceHidden != null && presentSources.isNotEmpty()) {
if (supervisedSessionActions == null && onToggleSourceHidden != null && presentSources.isNotEmpty()) {
Box {
IconButton(
onClick = { sourceFilterOpen = true },
@@ -451,7 +471,7 @@ fun SessionDrawerContent(
// Threads affordance — a clean thread-spool that toggles the Threads
// filter. Shown only when the Threads capability is active (or a Thread is
// already present), so an ordinary no-relay drawer is visually unchanged.
if (showThreads) {
if (supervisedSessionActions == null && showThreads) {
IconButton(
onClick = {
filter = if (filter == SessionDrawerFilter.Threads) {
@@ -522,7 +542,8 @@ fun SessionDrawerContent(
onNewChat()
}
},
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
enabled = newChatEnabled,
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
@@ -587,8 +608,10 @@ fun SessionDrawerContent(
}
SessionDrawerFilter.entries
.filter { item ->
(item != SessionDrawerFilter.Threads || showThreads) &&
(item != SessionDrawerFilter.Archive || archiveSupported)
(item != SessionDrawerFilter.Threads ||
(supervisedSessionActions == null && showThreads)) &&
(item != SessionDrawerFilter.Archive ||
effectiveArchiveSupported)
}
.forEach { item ->
FilterChip(
@@ -620,17 +643,19 @@ fun SessionDrawerContent(
)
}
}
TextButton(
onClick = { customizeOpen = true },
modifier = Modifier.align(Alignment.Start),
) {
Icon(
Icons.Filled.FilterList,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.drawer_customize_sessions))
if (supervisedSessionActions == null) {
TextButton(
onClick = { customizeOpen = true },
modifier = Modifier.align(Alignment.Start),
) {
Icon(
Icons.Filled.FilterList,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(R.string.drawer_customize_sessions))
}
}
// "+ New Thread" — Discord-style user-created thread, shown when the
// Threads filter is active. The first message opens the conversation.
@@ -728,6 +753,7 @@ fun SessionDrawerContent(
ProjectGroupHeader(
label = label,
rows = group.rows,
nowMillis = drawerNowMillis,
expanded = expanded,
onToggle = {
expandedProjectGroups = if (expanded) {
@@ -750,9 +776,7 @@ fun SessionDrawerContent(
if (expanded) items(group.rows, key = ::sessionRowKey) { row ->
val session = row.session
val provisional = session.sessionId.startsWith(PROVISIONAL_THREAD_PREFIX)
val activityState = activityStates[sessionRowKey(row)]
?: activityStates[session.sessionId]
?: if (session.isActive) SessionActivityState.Working else null
val activityState = scopedActivityStates[sessionRowKey(row)]
SessionItem(
modifier = if (isProjectGroup) Modifier.padding(start = 42.dp) else Modifier,
session = session,
@@ -764,13 +788,21 @@ fun SessionDrawerContent(
showUpdated = viewOptions.showUpdated,
showTokens = viewOptions.showTokens,
showCost = viewOptions.showCost,
actionsEnabled = !provisional,
nowMillis = drawerNowMillis,
actionsEnabled = !provisional && (
supervisedSessionActions == null ||
supervisedSessionActions.pin ||
supervisedSessionActions.rename ||
supervisedSessionActions.delete ||
(supervisedSessionActions.archive && archiveSupported)
),
isActive = !showAllProfiles && session.sessionId == currentSessionId,
activityState = activityState,
animationEnabled = animationEnabled && isOpen,
pinned = session.pinned,
archived = session.archived,
archiveSupported = archiveSupported,
supervisedSessionActions = supervisedSessionActions,
onClick = {
if (showAllProfiles) {
onSelectProfileSession?.invoke(row.profile, session.sessionId)
@@ -1217,7 +1249,11 @@ private fun SessionDrawerOrdering.label(): String = when (this) {
private fun SessionDrawerStatus.label(): String = when (this) {
SessionDrawerStatus.NeedsInput -> "Needs input"
SessionDrawerStatus.Starting -> "Starting"
SessionDrawerStatus.Working -> "Working"
SessionDrawerStatus.BackgroundWork -> "Background work"
SessionDrawerStatus.Checking -> "Checking"
SessionDrawerStatus.Unavailable -> "Unavailable"
SessionDrawerStatus.Idle -> "Idle"
}
@@ -1242,6 +1278,7 @@ private fun compactMetric(value: Double): String = when {
private fun ProjectGroupHeader(
label: String,
rows: List<ProfileSessionRow>,
nowMillis: Long,
expanded: Boolean,
onToggle: () -> Unit,
) {
@@ -1290,7 +1327,7 @@ private fun ProjectGroupHeader(
append(context.resources.getQuantityString(R.plurals.drawer_project_session_count, rows.size, rows.size))
if (latestActivity > 0L) {
append(" · ")
append(formatTimestamp(latestActivity, locale, context))
append(formatTimestamp(latestActivity, locale, context, nowMillis))
}
},
style = MaterialTheme.typography.bodySmall,
@@ -1319,6 +1356,7 @@ private fun SessionItem(
showUpdated: Boolean,
showTokens: Boolean,
showCost: Boolean,
nowMillis: Long,
actionsEnabled: Boolean,
isActive: Boolean,
activityState: SessionActivityState?,
@@ -1326,6 +1364,7 @@ private fun SessionItem(
pinned: Boolean,
archived: Boolean,
archiveSupported: Boolean,
supervisedSessionActions: SupervisedSessionActions?,
onClick: () -> Unit,
onTogglePinned: () -> Unit,
onToggleArchived: () -> Unit,
@@ -1337,11 +1376,7 @@ private fun SessionItem(
val locale = LocalLocale.current.platformLocale
val context = LocalContext.current
val untitledLabel = stringResource(R.string.drawer_untitled)
val activityLabel = when (activityState) {
SessionActivityState.Working -> stringResource(R.string.drawer_activity_working)
SessionActivityState.NeedsInput -> stringResource(R.string.drawer_activity_needs_input)
null -> null
}
val activityLabel = activityState?.let { stringResource(sessionActivityLabelResource(it)) }
val motion = rememberAccessibleMotionState()
val backgroundColor = if (isActive) {
MaterialTheme.colorScheme.secondaryContainer
@@ -1441,7 +1476,7 @@ private fun SessionItem(
sourceBadge(session.source)?.let { badge ->
SourceChip(badge)
}
if (showUpdated) sessionTimestampText(session, locale, context)?.let { timestamp ->
if (showUpdated) sessionTimestampText(session, locale, context, nowMillis)?.let { timestamp ->
Text(
text = timestamp,
style = MaterialTheme.typography.bodySmall,
@@ -1493,7 +1528,7 @@ private fun SessionItem(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
) {
DropdownMenuItem(
if (supervisedSessionActions?.pin != false) DropdownMenuItem(
text = {
Text(
if (pinned) {
@@ -1519,7 +1554,7 @@ private fun SessionItem(
onTogglePinned()
},
)
DropdownMenuItem(
if (supervisedSessionActions == null) DropdownMenuItem(
text = { Text(stringResource(R.string.chat_copy_session_id)) },
leadingIcon = {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
@@ -1529,7 +1564,7 @@ private fun SessionItem(
onCopySessionId()
},
)
DropdownMenuItem(
if (supervisedSessionActions?.rename != false) DropdownMenuItem(
text = { Text(stringResource(R.string.drawer_rename)) },
leadingIcon = {
Icon(Icons.Filled.Edit, contentDescription = null)
@@ -1539,7 +1574,7 @@ private fun SessionItem(
onRename()
},
)
if (archiveSupported) {
if (archiveSupported && supervisedSessionActions?.archive != false) {
DropdownMenuItem(
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
leadingIcon = {
@@ -1559,7 +1594,7 @@ private fun SessionItem(
},
)
}
DropdownMenuItem(
if (supervisedSessionActions?.delete != false) DropdownMenuItem(
text = {
Text(
text = stringResource(R.string.drawer_delete),
@@ -1583,6 +1618,16 @@ private fun SessionItem(
}
}
@StringRes
internal fun sessionActivityLabelResource(state: SessionActivityState): Int = when (state) {
SessionActivityState.Starting -> R.string.drawer_activity_starting
SessionActivityState.Working -> R.string.drawer_activity_working
SessionActivityState.NeedsInput -> R.string.drawer_activity_needs_input
SessionActivityState.BackgroundWork -> R.string.drawer_activity_background_work
SessionActivityState.Checking -> R.string.drawer_activity_checking
SessionActivityState.Unavailable -> R.string.drawer_activity_unavailable
}
@Composable
private fun SessionWorkBadgeChip(badge: SessionWorkBadge) {
val icon: ImageVector = when (badge.kind) {
@@ -1687,18 +1732,29 @@ private fun ProfileBadge(
@Composable
private fun SessionActivityIndicator(state: SessionActivityState, label: String) {
val color = when (state) {
SessionActivityState.Starting,
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
SessionActivityState.BackgroundWork,
SessionActivityState.Checking,
SessionActivityState.Unavailable,
-> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(RoundedCornerShape(50))
.background(color),
modifier = if (state == SessionActivityState.BackgroundWork) {
Modifier
.size(7.dp)
.border(1.dp, color, CircleShape)
} else {
Modifier
.size(7.dp)
.clip(CircleShape)
.background(color)
},
)
Text(
text = label,
@@ -1714,12 +1770,9 @@ private fun Modifier.sessionActivityBorder(
state: SessionActivityState?,
animated: Boolean,
): Modifier {
if (state == null) return this
val color = when (state) {
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
}
val shouldRotate = animated && state == SessionActivityState.Working
if (!sessionActivityShowsRowBorder(state)) return this
val color = RelayRefresh.Relay
val shouldRotate = animated
val phase = if (shouldRotate) {
val transition = rememberInfiniteTransition(label = "session-activity")
transition.animateFloat(
@@ -1825,20 +1878,45 @@ private fun Modifier.sessionActivityBorder(
}
}
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
@Composable
private fun rememberDrawerClock(isEnabled: Boolean): Long {
var nowMillis by remember { mutableStateOf(System.currentTimeMillis()) }
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(isEnabled, lifecycleOwner) {
if (!isEnabled) return@LaunchedEffect
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) {
nowMillis = System.currentTimeMillis()
delay(MINUTE_MILLIS - (nowMillis % MINUTE_MILLIS))
}
}
}
return nowMillis
}
internal fun sessionTimestampText(
session: ChatSession,
locale: Locale,
context: Context,
nowMillis: Long = System.currentTimeMillis(),
): String? {
val timestamp = session.activityTimestamp
if (timestamp <= 0L) return null
val hasDistinctActivity =
session.lastActivityAt > 0L &&
session.startTimestamp > 0L &&
session.lastActivityAt != session.startTimestamp
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_active) else context.getString(R.string.drawer_timestamp_started)
return "$prefix ${formatTimestamp(timestamp, locale, context)}"
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_updated) else context.getString(R.string.drawer_timestamp_started)
return "$prefix ${formatTimestamp(timestamp, locale, context, nowMillis)}"
}
private fun formatTimestamp(millis: Long, locale: Locale, context: Context): String {
val now = System.currentTimeMillis()
val diff = now - millis
private fun formatTimestamp(
millis: Long,
locale: Locale,
context: Context,
nowMillis: Long = System.currentTimeMillis(),
): String {
val diff = nowMillis - millis
return when {
diff < 60_000 -> context.getString(R.string.drawer_just_now)
diff < 3_600_000 -> "${diff / 60_000}m ago"
@@ -1846,3 +1924,5 @@ private fun formatTimestamp(millis: Long, locale: Locale, context: Context): Str
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
}
}
private const val MINUTE_MILLIS = 60_000L
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import java.util.Locale
@@ -23,7 +24,11 @@ internal enum class SessionDrawerOrdering {
internal enum class SessionDrawerStatus {
NeedsInput,
Starting,
Working,
BackgroundWork,
Checking,
Unavailable,
Idle,
}
@@ -55,7 +60,7 @@ internal data class SessionDrawerGroup(
)
internal fun sessionRowKey(row: ProfileSessionRow): String =
"${row.profile.lowercase(Locale.ROOT)}:${row.session.sessionId}"
"${AgentDisplay.profileSessionKey(row.profile).lowercase(Locale.ROOT)}:${row.session.sessionId}"
internal fun sessionProjectLabel(session: ChatSession): String {
val raw = (session.gitRepoRoot ?: session.workingDirectory)
@@ -69,12 +74,38 @@ internal fun sessionProjectLabel(session: ChatSession): String {
internal fun sessionDrawerStatus(
row: ProfileSessionRow,
activityStates: Map<String, SessionActivityState>,
): SessionDrawerStatus = when (
activityStates[sessionRowKey(row)] ?: activityStates[row.session.sessionId]
) {
): SessionDrawerStatus = when (activityStates[sessionRowKey(row)]) {
SessionActivityState.NeedsInput -> SessionDrawerStatus.NeedsInput
SessionActivityState.Starting -> SessionDrawerStatus.Starting
SessionActivityState.Working -> SessionDrawerStatus.Working
null -> if (row.session.isActive) SessionDrawerStatus.Working else SessionDrawerStatus.Idle
SessionActivityState.BackgroundWork -> SessionDrawerStatus.BackgroundWork
SessionActivityState.Checking -> SessionDrawerStatus.Checking
SessionActivityState.Unavailable -> SessionDrawerStatus.Unavailable
null -> SessionDrawerStatus.Idle
}
/** Desktop-style row emphasis is reserved for an actual foreground turn. */
internal fun sessionActivityShowsRowBorder(state: SessionActivityState?): Boolean =
state == SessionActivityState.Starting || state == SessionActivityState.Working
/**
* Normalizes live activity to the drawer's profile-scoped row identity.
*
* A selected-profile drawer may accept the legacy bare session id because every row belongs
* to that one explicit profile. All Profiles must use composite keys exclusively: session ids
* are only unique inside their owning profile.
*/
internal fun scopedSessionActivityStates(
rows: List<ProfileSessionRow>,
activityStates: Map<String, SessionActivityState>,
allowBareSessionIds: Boolean,
): Map<String, SessionActivityState> = buildMap {
rows.forEach { row ->
val rowKey = sessionRowKey(row)
val state = activityStates[rowKey]
?: activityStates[row.session.sessionId].takeIf { allowBareSessionIds }
state?.let { put(rowKey, it) }
}
}
internal fun sessionDrawerPrState(session: ChatSession): SessionDrawerPrState = when {
@@ -98,8 +129,12 @@ internal fun filterAndSortSessionRows(
.toList()
val statusRank = mapOf(
SessionDrawerStatus.NeedsInput to 0,
SessionDrawerStatus.Working to 1,
SessionDrawerStatus.Idle to 2,
SessionDrawerStatus.Starting to 1,
SessionDrawerStatus.Working to 2,
SessionDrawerStatus.BackgroundWork to 3,
SessionDrawerStatus.Checking to 4,
SessionDrawerStatus.Unavailable to 5,
SessionDrawerStatus.Idle to 6,
)
val comparator = when (options.ordering) {
SessionDrawerOrdering.Updated -> compareByDescending<ProfileSessionRow> { it.session.activityTimestamp }
@@ -149,7 +184,11 @@ internal fun groupSessionRows(
private val SessionDrawerStatus.displayLabel: String
get() = when (this) {
SessionDrawerStatus.NeedsInput -> "Needs input"
SessionDrawerStatus.Starting -> "Starting"
SessionDrawerStatus.Working -> "Working"
SessionDrawerStatus.BackgroundWork -> "Background work"
SessionDrawerStatus.Checking -> "Checking"
SessionDrawerStatus.Unavailable -> "Unavailable"
SessionDrawerStatus.Idle -> "Idle"
}
@@ -1,9 +1,12 @@
package com.hermesandroid.relay.ui.components.avatar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.SphereReactivity
import com.hermesandroid.relay.util.AppForegroundTracker
/**
* Default ambient visualization — the ASCII [MorphingSphere].
@@ -34,6 +37,7 @@ object SphereAvatar : AgentAvatar {
@Composable
override fun Render(state: AvatarRenderState, modifier: Modifier) {
val appForeground by AppForegroundTracker.isForeground.collectAsState()
MorphingSphere(
modifier = modifier,
state = state.state,
@@ -46,6 +50,7 @@ object SphereAvatar : AgentAvatar {
// call did with fixedTime/fixedColorPhase = 0f.
fixedTime = if (state.paused) 0f else null,
fixedColorPhase = if (state.paused) 0f else null,
motionVisible = appForeground,
)
}
}
@@ -87,6 +87,8 @@ fun AboutScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
onUnlockDeveloperOptions: () -> Unit = {},
/** Supervised clients may read About without gaining a settings mutation backdoor. */
allowDeveloperUnlock: Boolean = true,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -218,7 +220,7 @@ fun AboutScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
.clickable(enabled = allowDeveloperUnlock) {
if (devOptionsUnlocked) return@clickable
val now = System.currentTimeMillis()
if (now - lastTapTime > 2000) {
@@ -0,0 +1,96 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
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.Security
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.ui.theme.LocalBrand
/** Optional and specialized features kept off the primary Settings surface. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdvancedSettingsScreen(
supervisedPolicy: SupervisedModePolicy,
onNavigateToSupervisedControls: () -> Unit,
onBack: () -> Unit,
) {
val isDarkTheme = LocalBrand.current.isDark
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
},
title = { Text(stringResource(R.string.settings_advanced)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.settings_advanced_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SettingsCategoryRow(
icon = Icons.Filled.Security,
title = stringResource(R.string.settings_supervised_mode),
subtitle = when {
supervisedPolicy.isActive -> stringResource(
R.string.settings_supervised_on_profile,
supervisedPolicy.pinnedProfileName.orEmpty(),
)
supervisedPolicy.isConfigured -> stringResource(
R.string.settings_supervised_ready_profile,
supervisedPolicy.pinnedProfileName.orEmpty(),
)
else -> stringResource(R.string.settings_supervised_desc)
},
badge = supervisedPolicy.takeIf { it.isActive }?.let {
SettingsStatusPillModel(
label = stringResource(R.string.settings_supervised_on),
tone = SettingsStatusTone.Good,
)
},
onClick = onNavigateToSupervisedControls,
isDarkTheme = isDarkTheme,
petPerchKey = null,
)
}
}
}
@@ -1487,26 +1487,27 @@ private fun AppearanceSummaryRow(
/** Representative, theme-live chat sample so presets are judged in context. */
@Composable
private fun AppearanceLivePreview(
internal fun AppearanceLivePreview(
palette: BrandPalette,
shapeScale: AppearanceShapeScale,
restricted: Boolean = false,
) {
CompositionLocalProvider(
LocalBrand provides palette,
LocalAppearanceShapeScale provides shapeScale,
) {
MaterialTheme(colorScheme = palette.toColorScheme(), shapes = shapeScale.asMaterialShapes()) {
AppearanceLivePreviewContent()
AppearanceLivePreviewContent(restricted = restricted)
}
}
}
@Composable
private fun AppearanceLivePreviewContent() {
private fun AppearanceLivePreviewContent(restricted: Boolean) {
val backgroundEnabled = LocalBackgroundVisualizationEnabled.current
val backgroundAvatar = LocalAgentAvatar.current
Card(
modifier = Modifier.fillMaxWidth().height(294.dp),
modifier = Modifier.fillMaxWidth().height(if (restricted) 258.dp else 294.dp),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
@@ -1648,14 +1649,16 @@ private fun AppearanceLivePreviewContent() {
style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
Text(
text = stringResource(R.string.appearance_preview_tool_meta),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = LocalBrand.current.green,
modifier = Modifier.padding(start = 5.dp),
)
if (!restricted) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
Text(
text = stringResource(R.string.appearance_preview_tool_meta),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = LocalBrand.current.green,
modifier = Modifier.padding(start = 5.dp),
)
}
}
}
Box(modifier = Modifier.padding(start = 6.dp).size(38.dp), contentAlignment = Alignment.Center) {
@@ -1678,10 +1681,12 @@ private fun AppearanceLivePreviewContent() {
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Filled.Add, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
if (!restricted) {
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
}
Text(
stringResource(R.string.appearance_preview_message_placeholder),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
@@ -1691,7 +1696,7 @@ private fun AppearanceLivePreviewContent() {
Icon(Icons.Filled.GraphicEq, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
}
}
Surface(
if (!restricted) Surface(
modifier = Modifier.align(Alignment.CenterHorizontally),
shape = appearanceRoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -1785,7 +1790,7 @@ private fun FontOptionRow(
* are added.
*/
@Composable
private fun ThemeSwatchChip(
internal fun ThemeSwatchChip(
appTheme: AppTheme,
selected: Boolean,
onClick: () -> Unit,
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
@@ -147,6 +148,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
@@ -180,6 +182,10 @@ import com.hermesandroid.relay.data.PhysicalKeyboardEnterBehavior
import com.hermesandroid.relay.data.ProfilePresentationPolicy
import com.hermesandroid.relay.data.ProactiveInboxEntry
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.SupervisedSessionAction
import com.hermesandroid.relay.data.allowsSessionAction
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
import com.hermesandroid.relay.ui.components.AgentInfoSheet
@@ -297,21 +303,6 @@ private const val CHAT_AUTOCOMPLETE_PET_OBSTACLE = "chat-autocomplete-obstacle"
private const val CHAT_RECENT_PROMPTS_PET_OBSTACLE = "chat-recent-prompts-obstacle"
private val CHAT_PET_ROUTES = setOf("chat")
internal fun resolveSessionActivityStates(
background: Map<String, SessionActivityState>,
currentSessionId: String?,
isStreaming: Boolean,
needsInput: Boolean,
): Map<String, SessionActivityState> = background.toMutableMap().apply {
currentSessionId?.let { sessionId ->
when {
needsInput -> put(sessionId, SessionActivityState.NeedsInput)
isStreaming -> put(sessionId, SessionActivityState.Working)
else -> remove(sessionId)
}
}
}
internal fun resolveChatHeaderSubtitle(
isStreaming: Boolean,
statusText: String,
@@ -726,8 +717,40 @@ fun ChatScreen(
// existing test/preview call sites keep compiling.
onNavigateToVoiceSettings: () -> Unit = {},
onNavigateToProfileInspector: (String) -> Unit = {},
supervisedPolicy: SupervisedModePolicy = SupervisedModePolicy(),
onNavigateToBotMode: () -> Unit = {},
) {
val supervised = supervisedPolicy.enabled
val supervisedVisibility = supervisedPolicy.visibility.resolved()
LaunchedEffect(supervisedPolicy) {
voiceViewModel.updateSupervisedModePolicy(supervisedPolicy)
}
if (supervised && !supervisedPolicy.isActive) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Supervised chat unavailable") },
actions = {
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
},
)
},
) { padding ->
Box(
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
"The supervised profile is unavailable. Parent access is required to update this connection.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
return
}
val voiceUiState by voiceViewModel.uiState.collectAsState()
val responseSpeechActive by voiceViewModel.responseSpeechActive.collectAsState()
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
@@ -750,7 +773,6 @@ fun ChatScreen(
LaunchedEffect(voiceUiState.voiceMode) {
if (!voiceUiState.voiceMode) voicePresentationOverride = null
}
// Route classified chat errors (media cache, streaming failures, …) to
// the app-wide snackbar. Same pattern every VM-bound screen uses.
val snackbarHost = LocalSnackbarHost.current
@@ -812,7 +834,22 @@ fun ChatScreen(
}
val messages by chatViewModel.messages.collectAsState()
val rawMessages by chatViewModel.messages.collectAsState()
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
if (!supervised) rawMessages
else rawMessages.map { message ->
if (message.role == MessageRole.ASSISTANT) {
message.copy(
attachments = if (supervisedPolicy.capabilities.generatedImages) {
message.attachments.filter { it.isImage }
} else {
emptyList()
},
cards = emptyList(),
)
} else message
}
}
val messageReactionsSupported by chatViewModel.messageReactionsSupported.collectAsState()
val newestReactableMessageKeys = remember(messages) {
setOfNotNull(
@@ -839,10 +876,17 @@ fun ChatScreen(
// Stable voice can use the standard Hermes dashboard audio routes or the
// optional Relay voice routes. Gate the mic on either route being usable;
// availability picks the actionable toast when neither is.
val voiceReady by connectionViewModel.voiceReady.collectAsState()
val connectionVoiceReady by connectionViewModel.voiceReady.collectAsState()
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val voiceReady = if (supervised) {
supervisedPolicy.capabilities.voice &&
standardVoiceAvailability ==
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Ready
} else {
connectionVoiceReady
}
val chatSpeakResponseActionsEnabled =
shouldOfferChatSpeakAction(voiceReady, voiceUiState.state)
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val standardVoiceSignInRouteHint by
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
@@ -871,15 +915,9 @@ fun ChatScreen(
mutableStateOf(false)
}
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
val sessionActivityStates = remember(
backgroundSessionActivityStates,
currentSessionId,
isStreaming,
pendingAsk,
) {
resolveSessionActivityStates(
background = backgroundSessionActivityStates,
currentSessionId = currentSessionId,
val sessionActivityStates = backgroundSessionActivityStates
LaunchedEffect(currentSessionId, isStreaming, pendingAsk) {
chatViewModel.updateCurrentSessionActivity(
isStreaming = isStreaming,
needsInput = pendingAsk != null,
)
@@ -907,6 +945,54 @@ fun ChatScreen(
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
var allProfileSessionsLoading by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
suspend fun refreshAllProfileSessions(showError: Boolean) {
if (isProfileLocked || allProfileSessionsLoading) return
allProfileSessionsLoading = true
val result = connectionViewModel.listAllProfileSessions()
result?.fold(
onSuccess = { items ->
allProfileSessions = items.mapNotNull { item ->
val owner = item.profile?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
ProfileSessionRow(
profile = owner,
session = com.hermesandroid.relay.data.ChatSession(
sessionId = item.id,
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
recentlyActive = item.isActive,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
pinned = item.pinned,
archived = item.archived,
workingDirectory = item.cwd,
gitBranch = item.gitBranch,
gitRepoRoot = item.gitRepoRoot,
pullRequestNumber = item.pullRequest?.number,
pullRequestUrl = item.pullRequest?.url,
pullRequestState = item.pullRequest?.state,
pullRequestDraft = item.pullRequest?.draft == true,
),
)
}
chatViewModel.updateSessionActivityDirectory(
rows = allProfileSessions.map { it.profile to it.session.sessionId },
)
},
onFailure = { error ->
if (showError) snackbarHostState.showSnackbar(
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
)
},
)
allProfileSessionsLoading = false
}
val conversationBinding by chatViewModel.conversationBinding.collectAsState()
val explicitBindingProfileName = conversationBinding.profileName
.takeIf { conversationBinding.hasExplicitOwner }
@@ -964,8 +1050,15 @@ fun ChatScreen(
?: sessionModelState.pickerModel?.let { model ->
modelProviders.singleOrNull { model in it.models }?.slug
}
val showThinking by connectionViewModel.showThinking.collectAsState()
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
val configuredShowThinking by connectionViewModel.showThinking.collectAsState()
val configuredToolDisplay by connectionViewModel.toolDisplay.collectAsState()
val showThinking = configuredShowThinking &&
(!supervised || supervisedVisibility.showReasoning)
val toolDisplay = if (!supervised) configuredToolDisplay else when {
supervisedVisibility.showToolDetails -> "detailed"
supervisedVisibility.showToolNames -> "compact"
else -> "off"
}
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
val keepComposerFocusedOnSend by
@@ -984,7 +1077,10 @@ fun ChatScreen(
// marker so the user knows approvals are off without opening the agent drawer.
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
val pendingAttachments by chatViewModel.pendingAttachments.collectAsState()
val maxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
val configuredMaxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
val maxAttachmentMb = if (supervised) {
minOf(configuredMaxAttachmentMb, supervisedPolicy.capabilities.attachmentMaxFileMb)
} else configuredMaxAttachmentMb
val charLimit by connectionViewModel.maxMessageLength.collectAsState()
// === Gateway desktop-parity state ===
@@ -993,6 +1089,9 @@ fun ChatScreen(
val contextWindow by chatViewModel.contextWindow.collectAsState()
// Injected-context audit sheet (opened by tapping the context meter).
var showContextSheet by remember { mutableStateOf(false) }
LaunchedEffect(supervised) {
if (supervised) showContextSheet = false
}
val steerableTurn by chatViewModel.steerableTurn.collectAsState()
val steerNotice by chatViewModel.steerNotice.collectAsState()
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
@@ -1306,6 +1405,13 @@ fun ChatScreen(
val listState = rememberLazyListState()
val userScrolledAwayState = remember(currentSessionId) { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
LaunchedEffect(chatViewModel, drawerState) {
chatViewModel.sessionDirectoryRefreshRequests.collect {
if (drawerState.isOpen || allProfileSessions.isNotEmpty()) {
refreshAllProfileSessions(showError = false)
}
}
}
PetInteractionLayer(
owner = "chat-interaction-layer",
active = shouldHideChatPet(
@@ -1393,7 +1499,6 @@ fun ChatScreen(
}
val clipboard = LocalClipboard.current
val haptic = LocalHapticFeedback.current
val snackbarHostState = remember { SnackbarHostState() }
val handleCardAction: (String, String, HermesCardAction) -> Unit =
remember(chatViewModel, context) {
{ messageId, cardKey, action ->
@@ -1988,9 +2093,9 @@ fun ChatScreen(
}
}
}
val showAutocomplete by remember(filteredCommands, inputText) {
val showAutocomplete by remember(filteredCommands, inputText, supervised) {
derivedStateOf {
inputText.startsWith("/") && filteredCommands.isNotEmpty()
!supervised && inputText.startsWith("/") && filteredCommands.isNotEmpty()
}
}
@@ -2022,6 +2127,7 @@ fun ChatScreen(
// shows up without a manual reload. Cheap dashboard read; the optimistic
// row for the active session is preserved by ChatHandler.updateSessions.
LaunchedEffect(drawerState.isOpen) {
chatViewModel.setSessionActivityDrawerOpen(drawerState.isOpen)
if (drawerState.isOpen && chatReady) {
chatViewModel.refreshSessions()
}
@@ -2262,7 +2368,7 @@ fun ChatScreen(
}
}
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
val profileShelfAvailable = ProfilePresentationPolicy.shouldShowShelf(
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
profiles = agentProfiles,
presentation = profilePresentation,
selectedKey = selectedProfileKey,
@@ -2289,7 +2395,7 @@ fun ChatScreen(
// Material routes scrim taps through the drawer's gesture handler.
// Keep it enabled so tapping outside always dismisses the drawer; the
// voice overlay already owns input while voice mode is visible.
gesturesEnabled = true,
gesturesEnabled = !supervised || supervisedPolicy.capabilities.conversationHistory,
drawerContent = {
val drawerProfileName = explicitBindingProfileName ?: effectiveProfile?.name
val drawerTitle = if (drawerProfileName != null) {
@@ -2333,7 +2439,9 @@ fun ChatScreen(
}
SessionDrawerContent(
sessions = sessions,
sessions = if (
supervised && !supervisedPolicy.capabilities.conversationHistory
) emptyList() else sessions,
currentSessionId = currentSessionId,
scopeTitle = drawerTitle,
scopeSubtitle = drawerSubtitle,
@@ -2344,14 +2452,19 @@ fun ChatScreen(
animationEnabled = animationEnabled,
autoTitlesSupported = serverAutoTitles,
archiveSupported = sessionArchivingSupported,
supervisedSessionActions = supervisedPolicy.capabilities.sessionActions
.takeIf { supervised },
newChatEnabled = !supervised || supervisedPolicy.capabilities.newChat,
onRefresh = { chatViewModel.refreshSessions() },
onOpenBotMode = {
scope.launch { drawerState.close() }
onNavigateToBotMode()
},
onNewChat = {
chatViewModel.createNewChat()
scope.launch { drawerState.close() }
if (!supervised || supervisedPolicy.capabilities.newChat) {
chatViewModel.createNewChat()
scope.launch { drawerState.close() }
}
},
onNewDefaultChat = {
if (isProfileLocked) return@SessionDrawerContent
@@ -2377,6 +2490,9 @@ fun ChatScreen(
scope.launch { drawerState.close() }
},
onDeleteSession = { sessionId ->
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Delete)) {
return@SessionDrawerContent
}
val connectionId = activeConnection?.id
val profileId = explicitBindingProfileName ?: selectedProfile?.name
chatViewModel.deleteSession(sessionId) {
@@ -2390,10 +2506,21 @@ fun ChatScreen(
}
},
onRenameSession = { sessionId, title ->
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Rename)) {
return@SessionDrawerContent
}
chatViewModel.renameSession(sessionId, title)
},
onSetSessionPinned = chatViewModel::setSessionPinned,
onSetSessionArchived = chatViewModel::setSessionArchived,
onSetSessionPinned = { sessionId, pinned ->
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Pin)) {
chatViewModel.setSessionPinned(sessionId, pinned)
}
},
onSetSessionArchived = { sessionId, archived ->
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Archive)) {
chatViewModel.setSessionArchived(sessionId, archived)
}
},
onCopySessionId = { sessionId ->
scope.launch {
clipboard.setClipEntry(
@@ -2423,7 +2550,7 @@ fun ChatScreen(
onToggleSourceHidden = { source, hidden ->
connectionViewModel.setSourceHidden(source, hidden)
},
allProfilesSupported = !isProfileLocked &&
allProfilesSupported = !supervised && !isProfileLocked &&
!activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
allProfileSessions = allProfileSessions,
allProfileSessionsLoading = allProfileSessionsLoading,
@@ -2431,48 +2558,7 @@ fun ChatScreen(
onProfileColorChange = connectionViewModel::setProfileColor,
onRefreshAllProfiles = {
if (!isProfileLocked && !allProfileSessionsLoading) scope.launch {
allProfileSessionsLoading = true
val result = connectionViewModel.listAllProfileSessions()
result?.fold(
onSuccess = { items ->
allProfileSessions = items.mapNotNull { item ->
val owner = item.profile?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
ProfileSessionRow(
profile = owner,
session = com.hermesandroid.relay.data.ChatSession(
sessionId = item.id,
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
pinned = item.pinned,
archived = item.archived,
workingDirectory = item.cwd,
gitBranch = item.gitBranch,
gitRepoRoot = item.gitRepoRoot,
pullRequestNumber = item.pullRequest?.number,
pullRequestUrl = item.pullRequest?.url,
pullRequestState = item.pullRequest?.state,
pullRequestDraft = item.pullRequest?.draft == true,
),
)
}
},
onFailure = { error ->
snackbarHostState.showSnackbar(
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
)
},
)
allProfileSessionsLoading = false
refreshAllProfileSessions(showError = true)
}
},
onSelectProfileSession = { profileName, sessionId ->
@@ -2585,8 +2671,14 @@ fun ChatScreen(
// Top bar — messaging app style with avatar, name, model subtitle
TopAppBar(
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
}
} else if (supervisedPolicy.capabilities.newChat) {
IconButton(onClick = { chatViewModel.createNewChat() }) {
Icon(Icons.Filled.Edit, contentDescription = "New chat")
}
}
},
title = {
@@ -2618,8 +2710,10 @@ fun ChatScreen(
// style subtitle status.
var everConnected by remember { mutableStateOf(false) }
if (headerChatReady) everConnected = true
val showStreamingState = isStreaming &&
(!supervised || supervisedVisibility.showWorkingStatus)
val statusText = when {
headerChatReady -> if (isStreaming) {
headerChatReady -> if (showStreamingState) {
stringResource(R.string.chat_streaming)
} else {
stringResource(R.string.chat_connected_label)
@@ -2669,6 +2763,14 @@ fun ChatScreen(
// personality label.
val subtitleText = if (!headerChatReady) {
statusText
} else if (supervised) {
buildList {
if (supervisedVisibility.showProfileName) {
conversationProfile?.name?.takeIf { it.isNotBlank() }?.let(::add)
}
if (supervisedVisibility.showModelName && !modelName.isNullOrBlank()) add(modelName)
if (isEmpty() && supervisedVisibility.showConnectionStatus) add(statusText)
}.joinToString(" · ")
} else {
resolveChatHeaderSubtitle(
isStreaming = isStreaming,
@@ -2687,7 +2789,7 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.clickable {
.clickable(enabled = !supervised) {
if (profileShelfAvailable) {
showProfileShelf = !showProfileShelf
} else {
@@ -2711,7 +2813,7 @@ fun ChatScreen(
// Avatar — a plain 40dp circle whose letter swaps to the
// active agent (profile or personality). No overlay ring:
// the letter itself is the indicator.
Box(modifier = Modifier.size(40.dp)) {
if (!supervised || supervisedVisibility.showAgentIdentity) Box(modifier = Modifier.size(40.dp)) {
Surface(
modifier = Modifier.size(40.dp),
shape = CircleShape,
@@ -2761,14 +2863,16 @@ fun ChatScreen(
}
}
}
ConnectionStatusBadge(
isConnected = headerChatReady,
isConnecting = isConnecting,
modifier = Modifier
.size(10.dp)
.align(Alignment.BottomEnd),
size = 10.dp
)
if (!supervised || supervisedVisibility.showConnectionStatus) {
ConnectionStatusBadge(
isConnected = headerChatReady,
isConnecting = isConnecting,
modifier = Modifier
.size(10.dp)
.align(Alignment.BottomEnd),
size = 10.dp,
)
}
}
// Name + single-line subtitle.
@@ -2809,7 +2913,13 @@ fun ChatScreen(
} else {
Column {
Text(
text = if (agentDisplayName.isNotBlank()) agentDisplayName else stringResource(R.string.chat_agent_default),
text = if (supervised && !supervisedVisibility.showAgentIdentity) {
stringResource(R.string.screen_chat_label)
} else if (agentDisplayName.isNotBlank()) {
agentDisplayName
} else {
stringResource(R.string.chat_agent_default)
},
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
@@ -2846,7 +2956,7 @@ fun ChatScreen(
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
if (isStreaming && animationEnabled) {
if (showStreamingState && animationEnabled) {
StreamingDots(
color = subtitleColor,
modifier = Modifier.clearAndSetSemantics { },
@@ -2867,7 +2977,7 @@ fun ChatScreen(
// full explanation (global mode / --yolo / per-session)
// lives. Keeps the risk visible without eating subtitle
// width on every turn.
if (yoloEnabled == true) {
if (!supervised && yoloEnabled == true) {
RelayChromeIconButton(
icon = Icons.Filled.Bolt,
contentDescription = stringResource(R.string.cd_approvals_off),
@@ -2885,12 +2995,14 @@ fun ChatScreen(
// tappable → Connections, so the affordance moved with the
// info. Dropping it here declutters the actions row and frees
// width for the title subtitle.)
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = stringResource(R.string.cd_terminal),
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
if (!supervised) {
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = stringResource(R.string.cd_terminal),
onClick = onNavigateToTerminal,
modifier = Modifier.padding(end = 4.dp),
)
}
RelayChromeIconButton(
icon = Icons.Filled.Tune,
contentDescription = stringResource(R.string.cd_settings),
@@ -2903,7 +3015,11 @@ fun ChatScreen(
// Settings — which is what was squeezing the title subtitle.
// Session identity is useful before the first message; sharing only appears
// once the conversation has content.
if (messages.isNotEmpty() || !currentSessionId.isNullOrBlank()) {
if (
(!supervised && (messages.isNotEmpty() || !currentSessionId.isNullOrBlank())) ||
(supervised && messages.isNotEmpty() &&
supervisedPolicy.allowsSessionAction(SupervisedSessionAction.ShareTranscript))
) {
var showOverflowMenu by remember { mutableStateOf(false) }
Box {
RelayChromeIconButton(
@@ -2916,7 +3032,7 @@ fun ChatScreen(
expanded = showOverflowMenu,
onDismissRequest = { showOverflowMenu = false },
) {
currentSessionId?.takeIf { it.isNotBlank() }?.let { sessionId ->
currentSessionId?.takeIf { !supervised && it.isNotBlank() }?.let { sessionId ->
DropdownMenuItem(
text = { Text(copySessionIdLabel) },
leadingIcon = {
@@ -2941,7 +3057,7 @@ fun ChatScreen(
},
)
}
if (messages.isNotEmpty()) {
if (!supervised && messages.isNotEmpty()) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_search_conversation)) },
leadingIcon = {
@@ -2965,6 +3081,22 @@ fun ChatScreen(
shareConversation(context, messages)
},
)
} else if (
messages.isNotEmpty() &&
supervisedPolicy.allowsSessionAction(
SupervisedSessionAction.ShareTranscript,
)
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_share_conversation)) },
leadingIcon = {
Icon(Icons.Filled.Share, contentDescription = null)
},
onClick = {
showOverflowMenu = false
shareConversation(context, messages)
},
)
}
}
}
@@ -3006,13 +3138,15 @@ fun ChatScreen(
// and the mode strip — slim bar + `NN% · used/max` token readout,
// color-graded by fullness. Composes to nothing until the server
// reports a context_max for the session.
ContextMeterBar(
usedFraction = contextUsage,
usedTokens = contextWindow?.usedTokens,
maxTokens = contextWindow?.maxTokens,
onClick = { showContextSheet = true },
)
if (showContextSheet) {
if (!supervised || supervisedVisibility.showUsage) {
ContextMeterBar(
usedFraction = contextUsage,
usedTokens = contextWindow?.usedTokens,
maxTokens = contextWindow?.maxTokens,
onClick = if (supervised) null else ({ showContextSheet = true }),
)
}
if (!supervised && showContextSheet) {
// Live audit of the exact extra context the agent will be
// injected with on the next turn (transparency / auditability).
InjectedContextSheet(
@@ -3073,7 +3207,31 @@ fun ChatScreen(
},
label = "chatEmptyStatePhaseTransition",
) { targetConnectState ->
if (targetConnectState == ChatConnectState.Connecting) {
if (supervised && targetConnectState != ChatConnectState.Ready) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (supervisedVisibility.showConnectionStatus) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (targetConnectState == ChatConnectState.Connecting) {
CircularProgressIndicator()
}
Text(
text = if (targetConnectState == ChatConnectState.Connecting) {
stringResource(R.string.chat_connecting_dots)
} else {
stringResource(R.string.chat_disconnected_label)
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else if (targetConnectState == ChatConnectState.Connecting) {
ChatColdStartLoadingState(
animationEnabled = animationEnabled,
streamingIntensity = streamingIntensity,
@@ -3113,7 +3271,10 @@ fun ChatScreen(
Spacer(modifier = Modifier.weight(0.15f))
// ASCII sphere (constrained to square aspect)
if (LocalBackgroundVisualizationEnabled.current) {
if (
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -3141,7 +3302,10 @@ fun ChatScreen(
// thread itself (not just the header) -
// the desktop's intro.
ChatConnectState.Ready ->
if (effectiveProfile != null) {
if (
effectiveProfile != null &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
} else {
stringResource(R.string.chat_start_conversation)
@@ -3159,7 +3323,11 @@ fun ChatScreen(
val profileBlurb = effectiveProfile?.description
?.trim()
?.takeIf { it.isNotBlank() && !it.equals(agentDisplayName, ignoreCase = true) }
if (targetConnectState == ChatConnectState.Ready && profileBlurb != null) {
if (
targetConnectState == ChatConnectState.Ready &&
profileBlurb != null &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = profileBlurb,
@@ -3276,6 +3444,7 @@ fun ChatScreen(
// Ambient avatar behind messages
if (
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity) &&
animationBehindChat &&
!ambientMode
) {
@@ -3297,8 +3466,13 @@ fun ChatScreen(
// /media/by-path route when a relay session is paired,
// instead of degrading to the "image is on the server"
// notice. Null when no relay (standard no-plugin) → notice.
val relayServerImageResolver = remember(chatViewModel) {
RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
val relayServerImageResolver = remember(
chatViewModel,
supervised,
supervisedPolicy.capabilities.generatedImages,
) {
if (supervised && !supervisedPolicy.capabilities.generatedImages) null
else RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
}
val thinkingIndicatorConfig = remember(
thinkingIndicatorStyle,
@@ -3353,6 +3527,7 @@ fun ChatScreen(
items(messages.size, key = { messages[it].uiKey }) { index ->
val message = messages[index]
val processNotification = message.hermesProcessNotificationOrNull()
?.takeIf { !supervised || supervisedVisibility.showToolNames }
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
// no attachments). Attachments keep the bubble alive for inbound media;
@@ -3377,7 +3552,10 @@ fun ChatScreen(
messages[index + 1].timestamp - message.timestamp > GROUP_GAP_MS
// Date separator
if (index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp)) {
if (
(!supervised || supervisedVisibility.showTimestamps) &&
(index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp))
) {
DateSeparator(timestamp = message.timestamp)
}
@@ -3389,7 +3567,9 @@ fun ChatScreen(
message.attachments.isNotEmpty() ||
message.cards.isNotEmpty()
message.backgroundTask?.let { task ->
message.backgroundTask
?.takeIf { !supervised || supervisedVisibility.showWorkingStatus }
?.let { task ->
val taskModifier = Modifier.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
@@ -3447,6 +3627,14 @@ fun ChatScreen(
},
maxBubbleWidth = maxBubbleWidth,
showThinking = showThinking,
showAgentIdentity = !supervised || supervisedVisibility.showAgentIdentity,
showTimestamps = !supervised || supervisedVisibility.showTimestamps,
showWorkingStatus = !supervised || supervisedVisibility.showWorkingStatus,
showUsage = !supervised || supervisedVisibility.showUsage,
showTechnicalBadges = !supervised || supervisedVisibility.showTechnicalRoute,
showAssistantImages = !supervised || supervisedPolicy.capabilities.generatedImages,
allowAssistantImageExport = !supervised ||
supervisedPolicy.capabilities.shareGeneratedImages,
isFirstInGroup = isFirstInGroup,
isLastInGroup = isLastInGroup,
recoveringAnswer = recoveringAnswer,
@@ -3459,9 +3647,9 @@ fun ChatScreen(
onAttachmentManualFetch = { msgId, idx ->
chatViewModel.manualFetchAttachment(msgId, idx)
},
onCardAction = handleCardAction,
onCardInput = handleCardInput,
onSessionReference = { reference ->
onCardAction = if (supervised) ({ _, _, _ -> }) else handleCardAction,
onCardInput = if (supervised) ({ _, _, _ -> }) else handleCardInput,
onSessionReference = if (supervised) null else { reference ->
val target = agentProfiles.firstOrNull {
it.name.equals(reference.profile, ignoreCase = true)
}
@@ -3479,6 +3667,7 @@ fun ChatScreen(
}
},
onReact = if (
!supervised &&
isGatewayTransport &&
messageReactionsSupported &&
!message.isStreaming &&
@@ -3492,6 +3681,7 @@ fun ChatScreen(
null
},
onEditMessage = if (
(!supervised || supervisedPolicy.capabilities.editAndResend) &&
isGatewayTransport &&
!isStreaming &&
message.role == MessageRole.USER &&
@@ -3510,10 +3700,14 @@ fun ChatScreen(
null
},
animationEnabled = animationEnabled,
onQuoteMessage = { quoted ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
quotedMessage = quoted
},
onQuoteMessage = if (
!supervised || supervisedPolicy.capabilities.quoteReplies
) {
{ quoted ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
quotedMessage = quoted
}
} else null,
onNavigateToMessage = { messageId ->
val targetIndex = messages.indexOfFirst { it.id == messageId }
if (targetIndex >= 0) {
@@ -3524,7 +3718,10 @@ fun ChatScreen(
scope.launch { listState.animateScrollToItem(targetIndex + 1) }
}
},
onSpeakMessage = if (chatSpeakResponseActionsEnabled) {
onSpeakMessage = if (
chatSpeakResponseActionsEnabled &&
(!supervised || supervisedPolicy.capabilities.voice)
) {
{ text -> voiceViewModel.speakResponse(text) }
} else {
null
@@ -3535,6 +3732,9 @@ fun ChatScreen(
null
},
onCopyMessage = { text ->
if (supervised && !supervisedPolicy.capabilities.copyResponses) {
return@MessageBubble
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
// The new Clipboard API is suspend-based, so the
// setClipEntry call has to live inside a coroutine.
@@ -4003,7 +4203,8 @@ fun ChatScreen(
// Gateway redirect is text-only. Attachment-bearing follow-ups must
// retain their files in the session-owned queue instead of showing
// a correction action that cannot carry them.
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty()
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty() &&
(!supervised || supervisedPolicy.capabilities.steerResponse)
val trailing = when {
!isStreaming && hasContent -> ChatInputTrailing.SEND
!isStreaming -> ChatInputTrailing.VOICE
@@ -4136,7 +4337,7 @@ fun ChatScreen(
}
}
}
val modelControl = modelPickerOptions.takeIf { it.isNotEmpty() }?.let {
val modelControl = modelPickerOptions.takeIf { !supervised && it.isNotEmpty() }?.let {
ChatInputPickerControl(
value = compactModelChipLabel(currentModelForInput, modelDefaultLabel),
contentDescription = stringResource(R.string.cd_select_model),
@@ -4187,6 +4388,7 @@ fun ChatScreen(
// is definitively unreachable (SSE-only) — the agent sheet carries the
// disabled-with-reason version there.
val effortControl = if (
!supervised &&
chatGatewayAvailability != GatewayAvailability.Unreachable &&
effortAvailability.supported != false &&
effortPickerOptions.isNotEmpty()
@@ -4203,7 +4405,13 @@ fun ChatScreen(
}
visibleChatFailure?.let { failure ->
val failureRouteLabel = when (failure.route) {
val displayFailure = if (!supervised) failure else failure.copy(
model = failure.model.takeIf { supervisedVisibility.showModelName },
provider = failure.provider.takeIf { supervisedVisibility.showTechnicalRoute },
)
val failureRouteLabel = if (
supervised && !supervisedVisibility.showTechnicalRoute
) "" else when (failure.route) {
ChatFailureRoute.GATEWAY ->
stringResource(R.string.chat_failure_route_gateway)
ChatFailureRoute.API_FALLBACK ->
@@ -4211,23 +4419,28 @@ fun ChatScreen(
null -> ""
}
ChatFailurePanel(
failure = failure,
failure = displayFailure,
routeLabel = failureRouteLabel,
onDetails = { showChatFailureDetails = true },
onRetry = { chatViewModel.retryLastMessage() },
onRetry = {
if (!supervised || supervisedPolicy.capabilities.retryResponse) {
chatViewModel.retryLastMessage()
}
},
onDismiss = chatViewModel::dismissChatFailure,
showDetails = !supervised || supervisedVisibility.showTechnicalRoute,
)
if (showChatFailureDetails) {
ChatFailureDetailsDialog(
failure = failure,
failure = displayFailure,
routeLabel = failureRouteLabel,
onCopy = {
val details = buildString {
append(failureRouteLabel)
failure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
failure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
displayFailure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
displayFailure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
append("\n\n")
append(failure.rawError)
append(displayFailure.rawError)
}
scope.launch {
clipboard.setClipEntry(
@@ -4309,6 +4522,9 @@ fun ChatScreen(
)
},
onStop = {
if (supervised && !supervisedPolicy.capabilities.cancelResponse) {
return@ChatInputBar
}
chatViewModel.cancelStream()
// Firm haptic (LongPress — TextHandleMove was near-
// imperceptible) plus a "Stopped" badge stamped on the turn
@@ -4324,14 +4540,44 @@ fun ChatScreen(
}
},
onAttachPhotos = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
val allowed = !supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Images in
supervisedPolicy.capabilities.attachmentCategories &&
pendingAttachments.size < supervisedPolicy.capabilities.attachmentMaxCount
)
if (allowed) {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
},
onAttachFiles = { filePickerLauncher.launch(arrayOf("*/*")) },
onAttachCamera = requestCameraCapture,
onPasteImage = pasteImageFromClipboard,
onLongPressAttach = { showCommandPalette = true },
onAttachFiles = {
if (!supervised || supervisedPolicy.capabilities.attachments) {
val mimeTypes = if (!supervised) arrayOf("*/*") else buildList {
val categories = supervisedPolicy.capabilities.attachmentCategories
if (SupervisedAttachmentCategory.Images in categories) add("image/*")
if (SupervisedAttachmentCategory.Audio in categories) add("audio/*")
if (SupervisedAttachmentCategory.Video in categories) add("video/*")
if (SupervisedAttachmentCategory.Documents in categories) {
add("text/*")
add("application/pdf")
}
}.toTypedArray()
if (mimeTypes.isNotEmpty()) filePickerLauncher.launch(mimeTypes)
}
},
onAttachCamera = if (!supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Images in
supervisedPolicy.capabilities.attachmentCategories
)) requestCameraCapture else ({ }),
onPasteImage = if (!supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Images in
supervisedPolicy.capabilities.attachmentCategories
)) pasteImageFromClipboard else ({ }),
onLongPressAttach = { if (!supervised) showCommandPalette = true },
charLimit = charLimit,
caption = turnStatus ?: inputCaption,
voiceReady = voiceReady,
@@ -4343,8 +4589,13 @@ fun ChatScreen(
submitEnabled = pendingAttachments.none {
it.state == com.hermesandroid.relay.data.AttachmentState.LOADING
},
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS
.takeIf { convertLargePastesToAttachments },
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS.takeIf {
convertLargePastesToAttachments && (!supervised || (
supervisedPolicy.capabilities.attachments &&
SupervisedAttachmentCategory.Documents in
supervisedPolicy.capabilities.attachmentCategories
))
},
onLargePaste = { pastedText ->
val owner = activeComposerDraftKey ?: composerDraftKey
val sizeBytes = pastedText.toByteArray(Charsets.UTF_8).size.toLong()
@@ -4662,7 +4913,7 @@ fun ChatScreen(
}
// Command palette bottom sheet
if (showCommandPalette) {
if (showCommandPalette && !supervised) {
CommandPalette(
commands = allCommands,
onSelect = { cmd ->
@@ -4690,7 +4941,7 @@ fun ChatScreen(
// personality, connection summary). Replaces the old AlertDialog and the
// two top-bar chips (ProfilePicker + PersonalityPicker). Tap target is
// the title Row in the TopAppBar above.
if (showAgentInfo) {
if (showAgentInfo && !supervised) {
AgentInfoSheet(
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
@@ -116,7 +116,10 @@ fun ConnectionsSettingsScreen(
val configured by connectionViewModel.relayConfigured.collectAsState()
configured
} else {
false
// Preview/screenshot hosts do not construct a ConnectionViewModel.
// Fall back to the persisted pairing metadata so their active card is
// honest instead of showing a connected Relay as "Optional".
connections.firstOrNull { it.id == activeConnectionId }?.hasConfiguredRelay() == true
}
val startupConnectionId: String? = if (connectionViewModel != null) {
val startupId by connectionViewModel.startupConnectionId.collectAsState()
@@ -566,7 +569,7 @@ private fun ConnectionSurfaceSummary(
val dashboardSignInRequired =
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive) {
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive && activeConnectionViewModel != null) {
resolveChatRuntimeStatus(
gateway = when (gatewayAvailability) {
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.NewReleases
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -70,6 +71,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -96,11 +98,18 @@ 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.data.ProviderUsageLandingMode
import com.hermesandroid.relay.data.ProviderUsagePreferences
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
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.components.RelaySkeletonLine
import com.hermesandroid.relay.ui.components.pet.LocalPetCompanionCoordinator
import com.hermesandroid.relay.ui.components.pet.petObstacleSurface
import com.hermesandroid.relay.ui.components.pet.petPerchSurface
@@ -114,6 +123,7 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.RelayUiState
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.delay
private const val SETTINGS_PET_SURFACE_ROUTE = "settings"
private val SETTINGS_PET_SURFACE_ROUTES = setOf(SETTINGS_PET_SURFACE_ROUTE)
@@ -148,6 +158,14 @@ fun SettingsScreen(
connectionViewModel: ConnectionViewModel,
/** Header back affordance — Settings is a pushed destination, not a tab. */
onBack: (() -> Unit)? = null,
supervisedPolicy: SupervisedModePolicy? = null,
parentAccessUnlocked: Boolean = false,
/** Called only after the restricted surface completes device authentication. */
onRequestParentAccess: () -> Unit = {},
onUpdateSupervisedPolicy: (SupervisedModePolicy) -> Unit = {},
onNavigateToAdvancedSettings: () -> Unit = {},
onNavigateToSupervisedAppearance: () -> Unit = {},
onNavigateToSupervisedControls: () -> Unit = {},
// Needed by the Active Agent summary card at the top of the screen — it
// reads the current personality pick so the subtitle can render
// `connection · model · personality` without re-reading ChatViewModel
@@ -170,6 +188,7 @@ fun SettingsScreen(
// expandable sections, so there's nothing left to link to twice.
onNavigateToConnections: () -> Unit,
onNavigateToManage: () -> Unit,
onNavigateToProviderUsage: () -> Unit,
onNavigateToPlugins: () -> Unit,
onNavigateToChatSettings: () -> Unit,
onNavigateToTerminal: () -> Unit,
@@ -196,13 +215,76 @@ fun SettingsScreen(
// discoverable before a pair-and-pick happens.
onNavigateToProfileInspector: (profileName: String) -> Unit,
) {
// Keep the restricted root when an enabled policy becomes temporarily
// unusable (for example, its profile was renamed). Parent authentication,
// not a configuration error, is what unlocks the full settings surface.
if (supervisedPolicy?.enabled == true && !parentAccessUnlocked) {
SupervisedSettingsScreen(
connectionViewModel = connectionViewModel,
policy = supervisedPolicy,
onPolicyChange = onUpdateSupervisedPolicy,
onBack = onBack,
onNavigateToAppearance = onNavigateToSupervisedAppearance,
onParentAccessGranted = onRequestParentAccess,
)
return
}
val context = LocalContext.current
val isDarkTheme = LocalBrand.current.isDark
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val providerUsagePreferencesRepository = remember(context) {
ProviderUsagePreferencesRepository(context)
}
val providerUsagePreferences by providerUsagePreferencesRepository.preferences.collectAsState(
initial = ProviderUsagePreferences(),
)
val providerUsageRepository = remember(connectionViewModel) {
ProviderUsageRepository(
gatewayClientProvider = connectionViewModel::activeGatewayChatClient,
dashboardClientProvider = {
connectionViewModel.activeDashboardUrl()?.let(
connectionViewModel::dashboardClientForActive,
)
},
relayHttpClient = connectionViewModel.relayHttpClient,
profileProvider = { connectionViewModel.selectedProfile.value?.name },
sessionProvider = { chatViewModel.currentSessionId.value },
)
}
var providerUsageResponse by remember { mutableStateOf<ProviderUsageResponse?>(null) }
var providerUsageLoaded by remember { mutableStateOf(false) }
var providerUsageRefreshing by remember { mutableStateOf(false) }
var providerUsageRefreshKey by remember { mutableIntStateOf(0) }
LaunchedEffect(
activeConnection?.id,
selectedProfile?.name,
currentSessionId,
providerUsagePreferences.landingMode,
providerUsageRefreshKey,
) {
if (providerUsagePreferences.landingMode == ProviderUsageLandingMode.Hidden) {
providerUsageResponse = null
providerUsageLoaded = true
} else {
if (providerUsageResponse == null) providerUsageLoaded = false
providerUsageRefreshing = providerUsageResponse != null
providerUsageRepository.fetch().getOrNull()?.let { providerUsageResponse = it }
providerUsageLoaded = true
providerUsageRefreshing = false
}
}
LaunchedEffect(providerUsagePreferences.landingMode) {
while (providerUsagePreferences.landingMode != ProviderUsageLandingMode.Hidden) {
delay(300_000)
providerUsageRefreshKey++
}
}
// Active Agent card inputs — personality + profile drive the title,
// 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()
@@ -374,6 +456,20 @@ fun SettingsScreen(
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (supervisedPolicy?.enabled == true && parentAccessUnlocked) {
SettingsCategoryRow(
icon = Icons.Filled.Security,
title = "Supervised mode",
subtitle = "On · ${supervisedPolicy.pinnedProfileName.orEmpty()}",
badge = SettingsStatusPillModel(
label = "On",
tone = SettingsStatusTone.Good,
),
onClick = onNavigateToSupervisedControls,
isDarkTheme = isDarkTheme,
)
}
// ── Active Agent summary ───────────────────────────────────
// Mirrors the ChatScreen TopAppBar title block (avatar + name
// + one-line `connection · model · personality` subtitle).
@@ -439,6 +535,7 @@ fun SettingsScreen(
modifier = Modifier.settingsPetSurface("settings-card:profile-lock"),
)
// ── Quick Controls ─────────────────────────────────────────
// The switches flipped most often, pinned to the top-level Settings
// landing instead of buried in a sub-screen. Persistent connection is
@@ -477,6 +574,16 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
ProviderUsageLandingCard(
response = providerUsageResponse,
loaded = providerUsageLoaded,
refreshing = providerUsageRefreshing,
preferences = providerUsagePreferences,
onDisplay = onNavigateToProviderUsage,
onRefresh = { providerUsageRefreshKey++ },
isDarkTheme = isDarkTheme,
)
SettingsSectionHeader(stringResource(R.string.settings_hermes))
SettingsCategoryRow(
@@ -598,6 +705,24 @@ fun SettingsScreen(
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Security,
title = stringResource(R.string.settings_advanced),
subtitle = when {
supervisedPolicy?.isActive == true -> "On · ${supervisedPolicy.pinnedProfileName}"
supervisedPolicy?.isConfigured == true -> "Ready · ${supervisedPolicy.pinnedProfileName}"
else -> stringResource(R.string.settings_advanced_desc)
},
badge = supervisedPolicy?.takeIf { it.isActive }?.let {
SettingsStatusPillModel(
label = "On",
tone = SettingsStatusTone.Good,
)
},
onClick = onNavigateToAdvancedSettings,
isDarkTheme = isDarkTheme,
)
SettingsCategoryRow(
icon = Icons.Filled.Analytics,
title = stringResource(R.string.settings_analytics),
@@ -1212,12 +1337,12 @@ private fun ProfileLockOptionRow(
}
}
private data class SettingsStatusPillModel(
internal data class SettingsStatusPillModel(
val label: String,
val tone: SettingsStatusTone = SettingsStatusTone.Neutral,
)
private enum class SettingsStatusTone {
internal enum class SettingsStatusTone {
Neutral,
Good,
Info,
@@ -1257,6 +1382,135 @@ private fun SettingsStatusPill(pill: SettingsStatusPillModel) {
}
}
@Composable
private fun ProviderUsageLandingCard(
response: ProviderUsageResponse?,
loaded: Boolean,
refreshing: Boolean,
preferences: ProviderUsagePreferences,
onDisplay: () -> Unit,
onRefresh: () -> Unit,
isDarkTheme: Boolean,
) {
val providers = response?.providers
?.filter { it.available && it.id in preferences.visibleProviders }
.orEmpty()
Card(
modifier = Modifier
.settingsPetSurface("settings-card:provider-usage")
.fillMaxWidth()
.gradientBorder(
shape = appearanceRoundedCornerShape(12.dp),
isDarkTheme = isDarkTheme,
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Analytics,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.provider_usage_title),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(
when (response?.relayEnhanced) {
true -> R.string.provider_usage_settings_desc_relay
false -> R.string.provider_usage_settings_desc_basic
null -> R.string.provider_usage_settings_desc
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onRefresh, enabled = !refreshing) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.provider_usage_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onDisplay) {
Text(stringResource(R.string.provider_usage_customize))
}
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
when {
preferences.landingMode == ProviderUsageLandingMode.Hidden -> {
Text(
text = stringResource(R.string.provider_usage_hidden_hint),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
!loaded -> {
ProviderUsageSkeleton(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
providers.isEmpty() -> {
Text(
text = stringResource(R.string.provider_usage_not_available_compact),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> providers.forEachIndexed { index, provider ->
if (index > 0) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
ProviderUsageContent(
provider = provider,
detailed = preferences.landingMode == ProviderUsageLandingMode.Expanded,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
}
}
}
@Composable
private fun ProviderUsageSkeleton(modifier: Modifier = Modifier) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
RelaySkeletonLine(width = 112.dp, height = 16.dp)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
RelaySkeletonLine(width = 86.dp)
RelaySkeletonLine(width = 58.dp)
}
RelaySkeletonLine(width = 260.dp, height = 6.dp)
RelaySkeletonLine(width = 92.dp, height = 10.dp)
}
}
@Composable
private fun SettingsSectionHeader(
label: String,
@@ -1286,18 +1540,22 @@ private fun SettingsSectionHeader(
* mega-SettingsScreen.
*/
@Composable
private fun SettingsCategoryRow(
internal fun SettingsCategoryRow(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
isDarkTheme: Boolean,
badge: SettingsStatusPillModel? = null,
petPerchKey: String = title,
petPerchKey: String? = title,
) {
val surfaceModifier = if (petPerchKey != null) {
Modifier.settingsPetSurface("settings-category:$petPerchKey")
} else {
Modifier
}
Card(
modifier = Modifier
.settingsPetSurface("settings-category:$petPerchKey")
modifier = surfaceModifier
.fillMaxWidth()
.gradientBorder(
shape = appearanceRoundedCornerShape(12.dp),
@@ -0,0 +1,700 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.res.stringResource
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ProviderUsageLandingMode
import com.hermesandroid.relay.data.ProviderUsagePreferences
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
import com.hermesandroid.relay.network.usage.ProviderUsageProvider
import com.hermesandroid.relay.network.usage.ProviderUsageCredential
import com.hermesandroid.relay.network.usage.ProviderUsageBalance
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
import com.hermesandroid.relay.network.usage.ProviderUsageWindow
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.ui.components.RelaySkeletonLine
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private sealed interface UsageLoadState {
data object Loading : UsageLoadState
data object Unsupported : UsageLoadState
data class Loaded(val response: ProviderUsageResponse) : UsageLoadState
data object Error : UsageLoadState
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UsageLimitsScreen(
connectionViewModel: ConnectionViewModel,
chatViewModel: ChatViewModel,
onBack: () -> Unit,
) {
val context = androidx.compose.ui.platform.LocalContext.current
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val preferencesRepository = remember(context) { ProviderUsagePreferencesRepository(context) }
val preferences by preferencesRepository.preferences.collectAsState(
initial = ProviderUsagePreferences(),
)
val repository = remember(connectionViewModel) {
ProviderUsageRepository(
gatewayClientProvider = connectionViewModel::activeGatewayChatClient,
dashboardClientProvider = {
connectionViewModel.activeDashboardUrl()?.let(
connectionViewModel::dashboardClientForActive,
)
},
relayHttpClient = connectionViewModel.relayHttpClient,
profileProvider = { connectionViewModel.selectedProfile.value?.name },
sessionProvider = { chatViewModel.currentSessionId.value },
)
}
var refreshKey by remember { mutableIntStateOf(0) }
var state by remember { mutableStateOf<UsageLoadState>(UsageLoadState.Loading) }
var refreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(activeConnection?.id, selectedProfile?.name, currentSessionId, refreshKey) {
val hadContent = state is UsageLoadState.Loaded
if (!hadContent) state = UsageLoadState.Loading else refreshing = true
val next = repository.fetch().fold(
onSuccess = { result ->
result?.let(UsageLoadState::Loaded) ?: UsageLoadState.Unsupported
},
onFailure = { UsageLoadState.Error },
)
if (!hadContent || next is UsageLoadState.Loaded) state = next
refreshing = false
}
LaunchedEffect(Unit) {
while (true) {
delay(300_000)
refreshKey++
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.provider_usage_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.provider_usage_back),
tint = MaterialTheme.colorScheme.primary,
)
}
},
actions = {
IconButton(onClick = { refreshKey++ }, enabled = !refreshing) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.provider_usage_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = activeConnection?.label ?: stringResource(R.string.settings_no_connection),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.provider_usage_intro),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
when (val current = state) {
UsageLoadState.Loading -> ProviderUsageLoading()
UsageLoadState.Unsupported -> ProviderUsageMessage(
text = stringResource(R.string.provider_usage_not_available),
)
UsageLoadState.Error -> ProviderUsageError(onRetry = { refreshKey++ })
is UsageLoadState.Loaded -> {
ProviderUsageCapabilityNotice(relayEnhanced = current.response.relayEnhanced)
val providers = current.response.providers
if (providers.none { it.available }) {
ProviderUsageMessage(
text = stringResource(R.string.provider_usage_none_configured),
)
}
providers.forEach { provider ->
ProviderUsageCard(
provider = provider,
detailed = true,
)
}
}
}
ProviderUsageDisplaySettings(
preferences = preferences,
providers = (state as? UsageLoadState.Loaded)?.response?.providers.orEmpty(),
onModeChanged = { mode ->
scope.launch { preferencesRepository.setLandingMode(mode) }
},
onProviderVisibilityChanged = { providerId, visible ->
scope.launch {
preferencesRepository.setProviderVisible(providerId, visible)
}
},
)
}
}
}
@Composable
private fun ProviderUsageCapabilityNotice(relayEnhanced: Boolean) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = stringResource(
if (relayEnhanced) R.string.provider_usage_capability_relay_title
else R.string.provider_usage_capability_basic_title,
),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(
if (relayEnhanced) R.string.provider_usage_capability_relay_body
else R.string.provider_usage_capability_basic_body,
),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@Composable
private fun ProviderUsageLoading() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(2) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
RelaySkeletonLine(width = 112.dp, height = 18.dp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
RelaySkeletonLine(width = 92.dp)
RelaySkeletonLine(width = 62.dp)
}
RelaySkeletonLine(width = 280.dp, height = 6.dp)
RelaySkeletonLine(width = 98.dp, height = 10.dp)
}
}
}
}
}
@Composable
private fun ProviderUsageMessage(text: String) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Text(
text = text,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ProviderUsageError(onRetry: () -> Unit) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.provider_usage_error),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onRetry) {
Text(stringResource(R.string.provider_usage_retry))
}
}
}
}
@Composable
fun ProviderUsageCard(
provider: ProviderUsageProvider,
detailed: Boolean,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
ProviderUsageContent(
provider = provider,
detailed = detailed,
modifier = Modifier.padding(16.dp),
)
}
}
@Composable
fun ProviderUsageContent(
provider: ProviderUsageProvider,
detailed: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = provider.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
provider.plan?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (!provider.available) {
Text(
text = providerUnavailableText(provider),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
if (provider.balances.isNotEmpty()) {
ProviderBalanceUsage(provider, detailed)
} else if (provider.credentials.isNotEmpty()) {
val shownCredentials = if (detailed) {
provider.credentials
} else {
provider.credentials.filter { it.active }.take(1)
}
if (shownCredentials.isEmpty()) {
Text(
text = stringResource(R.string.provider_usage_active_unknown),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
shownCredentials.forEach { credential ->
ProviderCredentialUsage(credential, detailed)
}
}
} else {
val windows = if (detailed) provider.windows else provider.windows.take(1)
windows.forEach { ProviderUsageWindowRow(it) }
}
if (detailed && provider.credentials.isEmpty() && provider.balances.isEmpty()) {
provider.details.forEach { detail ->
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ProviderBalanceUsage(
provider: ProviderUsageProvider,
detailed: Boolean,
) {
val uriHandler = LocalUriHandler.current
val total = provider.balances.firstOrNull { it.id == "total" }
?: provider.balances.first()
val supporting = provider.balances.filterNot { it.id == total.id }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = formatBalance(total),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = total.label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (detailed) {
supporting.forEach { balance ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = balance.label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = formatBalance(balance),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
}
}
formatRenewal(provider.renewsAt)?.let { renewal ->
Text(
text = stringResource(R.string.provider_usage_renews_on, renewal),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (detailed && !provider.actionUrl.isNullOrBlank()) {
TextButton(onClick = { uriHandler.openUri(provider.actionUrl) }) {
Text(stringResource(R.string.provider_usage_manage_credits))
}
}
if (detailed) {
provider.details.forEach { detail ->
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun ProviderCredentialUsage(
credential: ProviderUsageCredential,
detailed: Boolean,
) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = credential.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (credential.active) FontWeight.SemiBold else FontWeight.Normal,
)
Text(
text = when {
credential.active && credential.status == ProviderUsageCredential.STATUS_AVAILABLE ->
stringResource(R.string.provider_usage_active_available)
credential.active && credential.status == ProviderUsageCredential.STATUS_AT_LIMIT ->
stringResource(R.string.provider_usage_active_at_limit)
credential.active -> stringResource(R.string.provider_usage_active)
credential.status == ProviderUsageCredential.STATUS_AVAILABLE ->
stringResource(R.string.provider_usage_available)
credential.status == ProviderUsageCredential.STATUS_AT_LIMIT ->
stringResource(R.string.provider_usage_at_limit)
else -> stringResource(R.string.provider_usage_unavailable_status)
},
style = MaterialTheme.typography.labelSmall,
color = when (credential.status) {
ProviderUsageCredential.STATUS_AT_LIMIT -> MaterialTheme.colorScheme.error
ProviderUsageCredential.STATUS_AVAILABLE -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
val windows = if (detailed) credential.windows else credential.windows.take(1)
windows.forEach { ProviderUsageWindowRow(it) }
if (detailed) {
credential.details.forEach { detail ->
Text(
text = detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ProviderUsageWindowRow(window: ProviderUsageWindow) {
var now by remember { mutableStateOf(Instant.now()) }
LaunchedEffect(window.resetAt) {
while (window.resetAt != null) {
delay(60_000)
now = Instant.now()
}
}
val percent = window.usedPercent?.coerceIn(0.0, 100.0)
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(window.label, style = MaterialTheme.typography.labelLarge)
Text(
text = percent?.let { stringResource(R.string.provider_usage_percent, it.toInt()) }
?: window.detail.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (percent != null) {
LinearProgressIndicator(
progress = { (percent / 100.0).toFloat() },
modifier = Modifier.fillMaxWidth().height(6.dp),
color = when {
percent >= 90 -> MaterialTheme.colorScheme.error
percent >= 75 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
},
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
)
}
formatReset(window.resetAt, now)?.let { reset ->
Text(
text = stringResource(R.string.provider_usage_resets, reset),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (percent != null && !window.detail.isNullOrBlank()) {
Text(
text = window.detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ProviderUsageDisplaySettings(
preferences: ProviderUsagePreferences,
providers: List<ProviderUsageProvider>,
onModeChanged: (ProviderUsageLandingMode) -> Unit,
onProviderVisibilityChanged: (String, Boolean) -> Unit,
) {
Text(
text = stringResource(R.string.provider_usage_display_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.provider_usage_display_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val modes = ProviderUsageLandingMode.entries
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
modes.forEachIndexed { index, mode ->
SegmentedButton(
selected = preferences.landingMode == mode,
onClick = { onModeChanged(mode) },
shape = SegmentedButtonDefaults.itemShape(index, modes.size),
) {
Text(
when (mode) {
ProviderUsageLandingMode.Summary -> stringResource(R.string.provider_usage_mode_summary)
ProviderUsageLandingMode.Expanded -> stringResource(R.string.provider_usage_mode_expanded)
ProviderUsageLandingMode.Hidden -> stringResource(R.string.provider_usage_mode_hidden)
}
)
}
}
}
Text(
text = stringResource(R.string.provider_usage_providers_title),
style = MaterialTheme.typography.labelLarge,
)
Text(
text = stringResource(R.string.provider_usage_providers_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val rows = if (providers.isEmpty()) {
listOf(
"openai-codex" to "Codex",
"nous" to "Nous",
"opencode-go" to "OpenCode Go",
)
} else {
providers.map { it.id to it.displayName }
}
rows.forEach { (id, label) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyLarge)
Switch(
checked = id in preferences.visibleProviders,
onCheckedChange = { onProviderVisibilityChanged(id, it) },
)
}
}
}
}
}
@Composable
private fun providerUnavailableText(provider: ProviderUsageProvider): String =
if (provider.status == ProviderUsageProvider.STATUS_NOT_CONFIGURED) {
stringResource(R.string.provider_usage_provider_not_configured)
} else {
stringResource(R.string.provider_usage_provider_unavailable)
}
private fun formatReset(raw: String?, now: Instant): String? = runCatching {
val reset = Instant.parse(raw ?: return null)
val duration = Duration.between(now, reset)
if (duration.isNegative || duration.isZero) return "now"
val days = duration.toDays()
val hours = duration.toHours() % 24
val minutes = duration.toMinutes() % 60
when {
days > 0 -> "${days}d ${hours}h"
hours > 0 -> "${hours}h ${minutes}m"
else -> "${minutes}m"
}
}.getOrNull()
private fun formatBalance(balance: ProviderUsageBalance): String = runCatching {
NumberFormat.getCurrencyInstance().apply {
currency = Currency.getInstance(balance.currency)
}.format(balance.amount)
}.getOrElse { "${balance.amount} ${balance.currency}" }
private fun formatRenewal(raw: String?): String? = runCatching {
val instant = Instant.parse(raw ?: return null)
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
.format(instant)
}.getOrNull()
File diff suppressed because it is too large Load Diff
@@ -2749,6 +2749,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private fun installAuthManager(am: AuthManager) {
am.setActiveEndpointProvider { connectionManager.activeRelayEndpoint.value }
am.setSupervisedMetadataReconnectFallback {
connectionManager.reconnectForAuthenticatedMetadataUpdate()
}
authManager = am
// Push into the flow so the flatMapLatest chains on authState /
// pairingCode / currentPairedSession repoint to the new manager.
@@ -3591,6 +3594,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// ConnectionStore's EncryptedSharedPrefs.
profileController.profileSelectionStore.clear(connectionId)
profileController.profileLockStore.clear(connectionId)
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
.clear(connectionId)
profileController.profilePresentationStore.clear(connectionId)
profileController.profileSessionStore.clearConnection(connectionId)
profileController.profileDisplayAliasStore.clearConnection(connectionId)
@@ -3630,6 +3635,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
init {
authManager.setSupervisedMetadataReconnectFallback {
connectionManager.reconnectForAuthenticatedMetadataUpdate()
}
// Wire multiplexer to connection manager (for relay/bridge/terminal)
multiplexer.setSendCallback { envelope ->
connectionManager.send(envelope)
@@ -4096,6 +4104,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
connectionStore.removeConnection(duplicate.id)
profileController.profileSelectionStore.clear(duplicate.id)
profileController.profileLockStore.clear(duplicate.id)
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
.clear(duplicate.id)
profileController.profilePresentationStore.clear(duplicate.id)
profileController.profileSessionStore.clearConnection(duplicate.id)
}
@@ -7190,6 +7200,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
check(dataManager.resetAppData()) { "App data store reset failed" }
profileController.profileSelectionStore.clearAll()
profileController.profileLockStore.clearAll()
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
.clearAll()
profileController.profilePresentationStore.clearAll()
profileController.profileSessionStore.clearAll()
_apiServerUrl.value = ""
@@ -110,6 +110,10 @@ internal class GatewayProcessController(
resetForSession(sessionId, scopeKey)
}
/** True only when the published process snapshot belongs to this exact owner. */
fun ownsSnapshot(sessionId: String, scopeKey: String?): Boolean =
selectedSessionId == sessionId && selectedScopeKey == scopeKey && readySessionId == sessionId
/**
* Admit process RPCs for [sessionId] after the gateway has created/resumed
* that chat's live session. Stale ready callbacks are ignored.
@@ -0,0 +1,25 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.SupervisedModePolicy
/**
* Fail-closed dispatch policy for Android Supervised Mode.
*
* This intentionally runs before demo handling, route selection, slash.exec,
* command.dispatch, steering, and queueing. Kotlin's default trim recognizes
* Unicode whitespace, preventing an indented slash command from bypassing the
* client restriction.
*/
internal fun supervisedMessageBlockReason(
policy: SupervisedModePolicy,
text: String,
): String? {
if (!policy.enabled) return null
if (!policy.isConfigured) {
return "Supervised mode is unavailable until the parent selects a profile."
}
if (text.trimStart().startsWith('/')) {
return "Slash commands are unavailable in supervised mode."
}
return null
}
@@ -21,6 +21,8 @@ import com.hermesandroid.relay.data.DEFAULT_VOICE_STOP_PHRASES
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.data.VoiceEngineMode
import com.hermesandroid.relay.data.VoiceIntentTrace
@@ -279,6 +281,23 @@ internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
*/
enum class InteractionMode { TapToTalk, HoldToTalk, Continuous }
internal fun isVoiceCommandAllowed(
action: VoiceCommandAction,
policy: SupervisedModePolicy,
): Boolean {
if (!policy.enabled) return true
val capabilities: SupervisedCapabilities = policy.capabilities
return when (action) {
VoiceCommandAction.StartNewChat -> capabilities.newChat
VoiceCommandAction.StopResponse,
VoiceCommandAction.CancelBackgroundTask -> capabilities.cancelResponse
VoiceCommandAction.EndVoiceChat,
VoiceCommandAction.PauseContinuousListening,
VoiceCommandAction.ResumeContinuousListening,
VoiceCommandAction.RepeatBackgroundAnswer -> capabilities.voice
}
}
internal fun InteractionMode.storageValue(): String = when (this) {
InteractionMode.TapToTalk -> "tap"
InteractionMode.HoldToTalk -> "hold"
@@ -723,6 +742,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private var voicePreferences: VoicePreferencesRepository? = null
private var voicePreferencesJob: Job? = null
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
private var voiceStopPhrases: List<String> = DEFAULT_VOICE_STOP_PHRASES
private var finalAnswerOnly: Boolean = false
private var realtimeTraceDetails: Boolean = false
@@ -1380,6 +1400,28 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
}
/** Apply the active Android client policy at the voice coordinator boundary. */
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
supervisedModePolicy = policy
val supervised = policy.enabled
voiceAudioClient?.setRouteOverride(if (supervised) VoiceAudioRoute.Standard else null)
if (supervised) {
if (voiceEngineMode == VoiceEngineMode.RealtimeAgent) closeRealtimeSession()
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput
_voiceStats.update {
it.copy(
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput.storageValue,
)
}
if (!policy.capabilities.voice && _uiState.value.voiceMode) exitVoiceMode()
} else {
val prefs = voicePreferences ?: return
viewModelScope.launch {
prefs.settings.firstOrNull()?.let { applyVoiceSettingsSnapshot(it) }
}
}
}
private fun persistInteractionMode(mode: InteractionMode) {
val prefs = voicePreferences ?: return
viewModelScope.launch {
@@ -1485,7 +1527,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
) {
closeRealtimeSession()
}
voiceEngineMode = nextEngineMode
voiceEngineMode = if (supervisedModePolicy.enabled) {
VoiceEngineMode.HermesVoiceOutput
} else {
nextEngineMode
}
voiceStopPhrases = settings.stopPhrases
finalAnswerOnly = settings.finalAnswerOnly
realtimeTraceDetails = settings.realtimeTraceDetails
@@ -1506,7 +1552,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
it.copy(
vadThresholdMs = settings.silenceThresholdMs,
interactionMode = settings.interactionMode,
voiceEngineMode = settings.engineMode,
voiceEngineMode = voiceEngineMode.storageValue,
realtimeModel = settings.realtimeModel,
realtimeVoice = settings.realtimeVoice,
)
@@ -1540,6 +1586,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
activationId: String? = null,
expectScreenContext: Boolean = false,
) {
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.voice) return
val freshEntry = !_uiState.value.voiceMode
val orphanedRun = _uiState.value
.takeIf { freshEntry }
@@ -2430,6 +2477,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
if (!canSpeakSettledResponse(state, providerRealtimeAgentTurnActive.get())) {
return false
}
if (
supervisedModePolicy.enabled &&
voiceAudioClient?.effectiveRoute != VoiceAudioRoute.Standard
) return false
val spoken = sanitizeForTts(text)
if (spoken.isBlank()) return false
@@ -3007,6 +3058,17 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
return null
}
if (!isVoiceCommandAllowed(action, supervisedModePolicy)) {
_uiState.update {
it.copy(
state = VoiceState.Idle,
outputAudioActive = false,
responseText = "That voice action is disabled by Parent controls.",
)
}
return action
}
Log.i(TAG, "Hands-free voice command action=$action source=${if (fromRealtime) "realtime" else "stt"}")
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
@@ -3076,12 +3138,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
setError("Voice pipeline not initialized")
return
}
if (
supervisedModePolicy.enabled &&
audioClient.effectiveRoute != VoiceAudioRoute.Standard
) {
setError("Supervised voice requires the Standard Hermes voice route")
return
}
currentTurnPcm = inputPcm
currentTurnPcmSampleRate = inputSampleRate
resetBrokeredToolSpeechState()
resetRealtimeSpeechCoalescer()
resetTtsTurnStats()
val engineModeForTurn = voiceEngineMode
val engineModeForTurn = if (supervisedModePolicy.enabled) {
VoiceEngineMode.HermesVoiceOutput
} else {
voiceEngineMode
}
Log.i(
TAG,
"Processing voice input engine=${engineModeForTurn.storageValue} " +
@@ -3230,7 +3303,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// ever mis-edited. If/when a `BuildFlavor.bridgeTier3` compile-
// time constant exists we should still short-circuit here for
// clarity, but today the factory already does the right thing.
val bridgeHandler = voiceBridgeIntentHandler
val bridgeHandler = voiceBridgeIntentHandler.takeUnless { supervisedModePolicy.enabled }
// === PHASE3-voice-cancel-midcountdown ===
// Voice-in-voice cancel: if a destructive action is currently
@@ -4906,7 +4979,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
private fun shouldPreferRealtimeVoice(): Boolean =
voiceOutputAvailable != false &&
!supervisedModePolicy.enabled &&
voiceOutputAvailable != false &&
realtimePcmPlayer != null &&
voiceClient != null &&
// Use the RESOLVED route: AutoVoiceAudioClient.effectiveRoute maps
+53 -2
View File
@@ -657,6 +657,14 @@
<string name="settings_analytics_desc">Estatísticas de uso, TTFT, tokens e integridade</string>
<string name="settings_diagnostics">Diagnóstico</string>
<string name="settings_diagnostics_desc">Verificações de status e atividade recente da API, do relay, da sessão e da voz</string>
<string name="settings_advanced">Avançado</string>
<string name="settings_advanced_desc">Modo supervisionado e outros recursos opcionais</string>
<string name="settings_advanced_intro">Recursos opcionais e especializados ficam aqui para manter a tela principal de Configurações organizada.</string>
<string name="settings_supervised_mode">Modo supervisionado</string>
<string name="settings_supervised_desc">Escolha um perfil e os recursos de chat permitidos</string>
<string name="settings_supervised_on">Ativado</string>
<string name="settings_supervised_on_profile">Ativado · %1$s</string>
<string name="settings_supervised_ready_profile">Pronto · %1$s</string>
<string name="settings_developer_options">Opções do desenvolvedor</string>
<string name="settings_developer_options_desc">Flags de recursos, gerenciamento de dados e opções experimentais</string>
<string name="settings_whats_new">Novidades</string>
@@ -876,6 +884,10 @@
<string name="drawer_search_sessions">Pesquisar sessões</string>
<string name="drawer_activity_working">Em andamento</string>
<string name="drawer_activity_needs_input">Precisa de resposta</string>
<string name="drawer_activity_starting">Iniciando</string>
<string name="drawer_activity_background_work">Trabalho em segundo plano</string>
<string name="drawer_activity_checking">Verificando</string>
<string name="drawer_activity_unavailable">Indisponível</string>
<string name="drawer_new_thread">Nova Thread</string>
<string name="drawer_chats_not_named">Os chats não recebem nomes automáticos nesta conexão — use ⋮ → Renomear.</string>
<string name="drawer_loading_sessions">Carregando sessões…</string>
@@ -905,7 +917,7 @@
<string name="drawer_filter_pinned">Fixadas</string>
<string name="drawer_filter_archive">Arquivo</string>
<string name="drawer_filter_sessions">Sessões</string>
<string name="drawer_timestamp_active">Ativa</string>
<string name="drawer_timestamp_updated">Atualizada</string>
<string name="drawer_timestamp_started">Iniciada</string>
<string name="drawer_just_now">Agora mesmo</string>
<!-- P1: BridgeScreen -->
@@ -3881,7 +3893,7 @@
<string name="appearance_preview_voice">Voz</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 tokens · 137,2 mil</string>
<string name="appearance_preview_message_placeholder">Mensagem…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / perfil: padrão</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / perfil: padrão</string>
<string name="appearance_preview_live_note">Esta prévia é atualizada imediatamente com a predefinição, o modo, a fonte e a aparência da Sphere.</string>
<string name="appearance_customize_theme">Personalizar %1$s</string>
<string name="appearance_accent_preset">Cor de destaque predefinida</string>
@@ -4136,6 +4148,45 @@
<string name="bridge_timed_unlimited_warning">Sem limite de inatividade. O acesso continua após inatividade e reconexão até ser encerrado, a chave mestra ser desligada ou a política mudar. Ideal para um dispositivo dedicado.</string>
<string name="bss_screen_access_off_desc">O acesso à tela está desligado. Novo acesso finito usa %1$d minutos ocioso por padrão.</string>
<string name="bss_screen_access_unlimited_desc">Pelo menos um recurso de tela permanece ativo até ser desligado explicitamente.</string>
<string name="provider_usage_title">Uso e limites</string>
<string name="provider_usage_back">Voltar</string>
<string name="provider_usage_refresh">Atualizar uso</string>
<string name="provider_usage_intro">Limites de conta dos provedores configurados nesta conexão do Hermes.</string>
<string name="provider_usage_not_available">Esta conexão Hermes não expõe o uso dos provedores. Atualize o Hermes ou instale/atualize o plugin Relay.</string>
<string name="provider_usage_none_configured">Nenhum provedor visível tem dados de uso da conta disponíveis.</string>
<string name="provider_usage_loading">Carregando uso dos provedores…</string>
<string name="provider_usage_error">Não foi possível carregar o uso dos provedores.</string>
<string name="provider_usage_retry">Tentar novamente</string>
<string name="provider_usage_percent">%1$d%% usado</string>
<string name="provider_usage_resets">Redefine em %1$s</string>
<string name="provider_usage_display_title">Exibição nas Configurações</string>
<string name="provider_usage_display_desc">Escolha como o uso da conta aparece na tela principal de Configurações.</string>
<string name="provider_usage_mode_summary">Resumo</string>
<string name="provider_usage_mode_expanded">Expandido</string>
<string name="provider_usage_mode_hidden">Oculto</string>
<string name="provider_usage_providers_title">Mostrar nas Configurações principais</string>
<string name="provider_usage_providers_desc">Escolha quais cartões de provedores aparecem nas Configurações principais. Todos continuam visíveis aqui.</string>
<string name="provider_usage_settings_desc">Uso da conta e limites dos provedores</string>
<string name="provider_usage_settings_desc_relay">Uso e limites ampliados pelo plugin Relay</string>
<string name="provider_usage_settings_desc_basic">Uso básico do Hermes · Relay adiciona pools e mais</string>
<string name="provider_usage_customize">Exibição</string>
<string name="provider_usage_hidden_hint">Os cartões de uso estão ocultos nas Configurações.</string>
<string name="provider_usage_not_available_compact">O uso dos provedores está indisponível. Atualize o Hermes ou o plugin Relay.</string>
<string name="provider_usage_provider_not_configured">Não configurado neste host</string>
<string name="provider_usage_provider_unavailable">Uso temporariamente indisponível</string>
<string name="provider_usage_active_unknown">Esta sessão ainda não tem uma credencial ativa.</string>
<string name="provider_usage_active_available">Ativa · Disponível</string>
<string name="provider_usage_active_at_limit">Ativa · Limite atingido</string>
<string name="provider_usage_active">Ativa</string>
<string name="provider_usage_available">Disponível</string>
<string name="provider_usage_at_limit">Limite atingido</string>
<string name="provider_usage_unavailable_status">Indisponível</string>
<string name="provider_usage_renews_on">Renova em %1$s</string>
<string name="provider_usage_manage_credits">Gerenciar créditos</string>
<string name="provider_usage_capability_relay_title">Ampliado pelo plugin Relay</string>
<string name="provider_usage_capability_relay_body">Pools de credenciais, saldos estruturados da Nous e OpenCode Go são fornecidos pelo plugin Relay.</string>
<string name="provider_usage_capability_basic_title">Uso básico do Hermes</string>
<string name="provider_usage_capability_basic_body">Instale ou atualize o plugin Relay para pools de credenciais, saldos estruturados da Nous e OpenCode Go.</string>
<string name="custom_theme_title">Personalizado</string>
<string name="custom_theme_entry_summary">Crie e salve seus próprios temas</string>
<string name="custom_theme_your_presets">Seus temas</string>
+53 -2
View File
@@ -698,6 +698,14 @@
<string name="settings_analytics_desc">使用统计、TTFT、token、健康状态</string>
<string name="settings_diagnostics">诊断</string>
<string name="settings_diagnostics_desc">状态检查,以及最近的 API、Relay、会话和语音活动</string>
<string name="settings_advanced">高级</string>
<string name="settings_advanced_desc">受监督模式和其他可选功能</string>
<string name="settings_advanced_intro">可选和专用功能集中在此,以保持主设置界面简洁。</string>
<string name="settings_supervised_mode">受监督模式</string>
<string name="settings_supervised_desc">选择配置文件和允许的聊天功能</string>
<string name="settings_supervised_on">已开启</string>
<string name="settings_supervised_on_profile">已开启 · %1$s</string>
<string name="settings_supervised_ready_profile">已就绪 · %1$s</string>
<string name="settings_developer_options">开发者选项</string>
<string name="settings_developer_options_desc">功能标志、数据管理、实验性</string>
<string name="settings_whats_new">新功能</string>
@@ -922,6 +930,10 @@
<string name="drawer_search_sessions">搜索会话</string>
<string name="drawer_activity_working">正在处理</string>
<string name="drawer_activity_needs_input">需要输入</string>
<string name="drawer_activity_starting">正在启动</string>
<string name="drawer_activity_background_work">后台工作</string>
<string name="drawer_activity_checking">正在检查</string>
<string name="drawer_activity_unavailable">不可用</string>
<string name="drawer_new_thread">新话题</string>
<string name="drawer_chats_not_named">此连接上的对话不会自动命名——使用 ⋮ → 重命名。</string>
<string name="drawer_loading_sessions">正在加载会话…</string>
@@ -951,7 +963,7 @@
<string name="drawer_filter_pinned">已固定</string>
<string name="drawer_filter_archive">归档</string>
<string name="drawer_filter_sessions">会话</string>
<string name="drawer_timestamp_active">活跃</string>
<string name="drawer_timestamp_updated">更新</string>
<string name="drawer_timestamp_started">已开始</string>
<string name="drawer_just_now">刚刚</string>
@@ -3969,7 +3981,7 @@
<string name="appearance_preview_voice">语音</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 个 token · 137.2K</string>
<string name="appearance_preview_message_placeholder">消息…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / 配置文件:默认</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / 配置文件:默认</string>
<string name="appearance_preview_live_note">更改预设、模式、字体或 Sphere 皮肤后,此预览会立即更新。</string>
<string name="appearance_customize_theme">自定义 %1$s</string>
<string name="appearance_accent_preset">预设强调色</string>
@@ -4221,6 +4233,45 @@
<string name="bridge_timed_unlimited_warning">无空闲超时。屏幕访问在空闲和重新连接后仍保持,直到结束访问、关闭主开关或更改策略。适合专用设备。</string>
<string name="bss_screen_access_off_desc">屏幕访问已关闭。新的有限访问默认使用 %1$d 分钟空闲限制。</string>
<string name="bss_screen_access_unlimited_desc">至少一项屏幕功能会保持有效,直到明确关闭。</string>
<string name="provider_usage_title">用量和限额</string>
<string name="provider_usage_back">返回</string>
<string name="provider_usage_refresh">刷新用量</string>
<string name="provider_usage_intro">此 Hermes 连接中已配置提供商的账户限额。</string>
<string name="provider_usage_not_available">此 Hermes 连接未提供服务商用量。请更新 Hermes,或安装/更新 Relay 插件。</string>
<string name="provider_usage_none_configured">当前显示的提供商均无可用账户用量。</string>
<string name="provider_usage_loading">正在加载提供商用量…</string>
<string name="provider_usage_error">无法加载提供商用量。</string>
<string name="provider_usage_retry">重试</string>
<string name="provider_usage_percent">已使用 %1$d%%</string>
<string name="provider_usage_resets">%1$s后重置</string>
<string name="provider_usage_display_title">设置页显示</string>
<string name="provider_usage_display_desc">选择账户用量在主设置屏幕中的显示方式。</string>
<string name="provider_usage_mode_summary">摘要</string>
<string name="provider_usage_mode_expanded">展开</string>
<string name="provider_usage_mode_hidden">隐藏</string>
<string name="provider_usage_providers_title">在主设置页显示</string>
<string name="provider_usage_providers_desc">选择要在主设置页显示的提供商卡片。此处仍会显示所有提供商。</string>
<string name="provider_usage_settings_desc">账户用量和提供商限额</string>
<string name="provider_usage_settings_desc_relay">由 Relay 插件增强的用量和限额</string>
<string name="provider_usage_settings_desc_basic">Hermes 基础用量 · Relay 可增加凭据池等功能</string>
<string name="provider_usage_customize">显示</string>
<string name="provider_usage_hidden_hint">设置页已隐藏用量卡片。</string>
<string name="provider_usage_not_available_compact">服务商用量不可用。请更新 Hermes 或 Relay 插件。</string>
<string name="provider_usage_provider_not_configured">此主机未配置</string>
<string name="provider_usage_provider_unavailable">用量暂时不可用</string>
<string name="provider_usage_active_unknown">此会话尚无当前凭据。</string>
<string name="provider_usage_active_available">当前 · 可用</string>
<string name="provider_usage_active_at_limit">当前 · 已达上限</string>
<string name="provider_usage_active">当前</string>
<string name="provider_usage_available">可用</string>
<string name="provider_usage_at_limit">已达上限</string>
<string name="provider_usage_unavailable_status">不可用</string>
<string name="provider_usage_renews_on">续期日期:%1$s</string>
<string name="provider_usage_manage_credits">管理额度</string>
<string name="provider_usage_capability_relay_title">已由 Relay 插件增强</string>
<string name="provider_usage_capability_relay_body">凭据池、结构化 Nous 余额和 OpenCode Go 由 Relay 插件提供。</string>
<string name="provider_usage_capability_basic_title">Hermes 基础用量</string>
<string name="provider_usage_capability_basic_body">安装或更新 Relay 插件即可使用凭据池、结构化 Nous 余额和 OpenCode Go。</string>
<string name="custom_theme_title">自定义</string>
<string name="custom_theme_entry_summary">创建并保存自己的主题</string>
<string name="custom_theme_your_presets">你的预设</string>
+53 -2
View File
@@ -698,6 +698,14 @@
<string name="settings_analytics_desc">Nutzungsstatistiken, TTFT, Token, Status</string>
<string name="settings_diagnostics">Diagnose</string>
<string name="settings_diagnostics_desc">Statusprüfungen sowie letzte API-, Relay-, Sitzungs- und Sprachaktivitäten</string>
<string name="settings_advanced">Erweitert</string>
<string name="settings_advanced_desc">Beaufsichtigter Modus und weitere optionale Funktionen</string>
<string name="settings_advanced_intro">Optionale und spezielle Funktionen befinden sich hier, damit die Haupteinstellungen übersichtlich bleiben.</string>
<string name="settings_supervised_mode">Beaufsichtigter Modus</string>
<string name="settings_supervised_desc">Profil und erlaubte Chatfunktionen auswählen</string>
<string name="settings_supervised_on">Ein</string>
<string name="settings_supervised_on_profile">Ein · %1$s</string>
<string name="settings_supervised_ready_profile">Bereit · %1$s</string>
<string name="settings_developer_options">Entwickleroptionen</string>
<string name="settings_developer_options_desc">Funktionsschalter, Datenverwaltung, Experimente</string>
<string name="settings_whats_new">Neuigkeiten</string>
@@ -925,6 +933,10 @@
<string name="drawer_search_sessions">Sitzungen durchsuchen</string>
<string name="drawer_activity_working">Wird bearbeitet</string>
<string name="drawer_activity_needs_input">Eingabe erforderlich</string>
<string name="drawer_activity_starting">Wird gestartet</string>
<string name="drawer_activity_background_work">Hintergrundarbeit</string>
<string name="drawer_activity_checking">Wird geprüft</string>
<string name="drawer_activity_unavailable">Nicht verfügbar</string>
<string name="drawer_new_thread">Neuer Thread</string>
<string name="drawer_chats_not_named">Chats werden bei dieser Verbindung nicht automatisch benannt — verwende ⋮ → Umbenennen.</string>
<string name="drawer_loading_sessions">Sitzungen werden geladen…</string>
@@ -954,7 +966,7 @@
<string name="drawer_filter_pinned">Angeheftet</string>
<string name="drawer_filter_archive">Archiv</string>
<string name="drawer_filter_sessions">Sitzungen</string>
<string name="drawer_timestamp_active">Aktiv</string>
<string name="drawer_timestamp_updated">Aktualisiert</string>
<string name="drawer_timestamp_started">Gestartet</string>
<string name="drawer_just_now">Gerade eben</string>
@@ -4041,7 +4053,7 @@
<string name="appearance_preview_voice">Sprache</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 Token · 137,2K</string>
<string name="appearance_preview_message_placeholder">Nachricht…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / Profil: Standard</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / Profil: Standard</string>
<string name="appearance_preview_live_note">Diese Vorschau wird sofort mit Vorlage, Modus, Schrift und Sphere-Skin aktualisiert.</string>
<string name="appearance_customize_theme">%1$s anpassen</string>
<string name="appearance_accent_preset">Voreingestellte Akzentfarbe</string>
@@ -4296,6 +4308,45 @@
<string name="bridge_timed_unlimited_warning">Kein Leerlaufzeitlimit. Bildschirmzugriff bleibt bei Inaktivität und Wiederverbindung aktiv, bis er beendet, der Hauptschalter deaktiviert oder die Richtlinie geändert wird. Für ein dediziertes Gerät.</string>
<string name="bss_screen_access_off_desc">Bildschirmzugriff ist aus. Neuer begrenzter Zugriff verwendet standardmäßig %1$d Minuten Leerlauf.</string>
<string name="bss_screen_access_unlimited_desc">Mindestens eine Bildschirmfunktion bleibt bis zum ausdrücklichen Ausschalten aktiv.</string>
<string name="provider_usage_title">Nutzung &amp; Limits</string>
<string name="provider_usage_back">Zurück</string>
<string name="provider_usage_refresh">Nutzung aktualisieren</string>
<string name="provider_usage_intro">Kontolimits der Anbieter, die für diese Hermes-Verbindung konfiguriert sind.</string>
<string name="provider_usage_not_available">Diese Hermes-Verbindung stellt keine Anbieternutzung bereit. Aktualisieren Sie Hermes oder installieren/aktualisieren Sie das Relay-Plugin.</string>
<string name="provider_usage_none_configured">Für keinen sichtbaren Anbieter sind Kontonutzungsdaten verfügbar.</string>
<string name="provider_usage_loading">Anbieternutzung wird geladen…</string>
<string name="provider_usage_error">Anbieternutzung konnte nicht geladen werden.</string>
<string name="provider_usage_retry">Erneut versuchen</string>
<string name="provider_usage_percent">%1$d%% verwendet</string>
<string name="provider_usage_resets">Zurücksetzung in %1$s</string>
<string name="provider_usage_display_title">Anzeige in Einstellungen</string>
<string name="provider_usage_display_desc">Wählen Sie, wie die Kontonutzung in den Haupteinstellungen erscheint.</string>
<string name="provider_usage_mode_summary">Übersicht</string>
<string name="provider_usage_mode_expanded">Erweitert</string>
<string name="provider_usage_mode_hidden">Ausgeblendet</string>
<string name="provider_usage_providers_title">In den Haupteinstellungen anzeigen</string>
<string name="provider_usage_providers_desc">Wählen Sie, welche Anbieterkarten in den Haupteinstellungen erscheinen. Hier bleiben alle Anbieter sichtbar.</string>
<string name="provider_usage_settings_desc">Kontonutzung und Anbieterlimits</string>
<string name="provider_usage_settings_desc_relay">Durch Relay-Plugin erweiterte Nutzung und Limits</string>
<string name="provider_usage_settings_desc_basic">Hermes-Basisnutzung · Relay-Plugin ergänzt Pools und mehr</string>
<string name="provider_usage_customize">Anzeige</string>
<string name="provider_usage_hidden_hint">Nutzungskarten sind in den Einstellungen ausgeblendet.</string>
<string name="provider_usage_not_available_compact">Anbieternutzung ist nicht verfügbar. Aktualisieren Sie Hermes oder das Relay-Plugin.</string>
<string name="provider_usage_provider_not_configured">Auf diesem Host nicht konfiguriert</string>
<string name="provider_usage_provider_unavailable">Nutzung ist vorübergehend nicht verfügbar</string>
<string name="provider_usage_active_unknown">Für diese Sitzung gibt es noch keine aktiven Anmeldedaten.</string>
<string name="provider_usage_active_available">Aktiv · Verfügbar</string>
<string name="provider_usage_active_at_limit">Aktiv · Limit erreicht</string>
<string name="provider_usage_active">Aktiv</string>
<string name="provider_usage_available">Verfügbar</string>
<string name="provider_usage_at_limit">Limit erreicht</string>
<string name="provider_usage_unavailable_status">Nicht verfügbar</string>
<string name="provider_usage_renews_on">Verlängert sich am %1$s</string>
<string name="provider_usage_manage_credits">Guthaben verwalten</string>
<string name="provider_usage_capability_relay_title">Durch Relay-Plugin erweitert</string>
<string name="provider_usage_capability_relay_body">Anmeldedaten-Pools, strukturierte Nous-Guthaben und OpenCode Go werden vom Relay-Plugin bereitgestellt.</string>
<string name="provider_usage_capability_basic_title">Basisnutzung von Hermes</string>
<string name="provider_usage_capability_basic_body">Installieren oder aktualisieren Sie das Relay-Plugin für Anmeldedaten-Pools, strukturierte Nous-Guthaben und OpenCode Go.</string>
<string name="custom_theme_title">Benutzerdefiniert</string>
<string name="custom_theme_entry_summary">Eigene Themes erstellen und speichern</string>
<string name="custom_theme_your_presets">Deine Presets</string>
+53 -2
View File
@@ -625,6 +625,14 @@
<string name="settings_analytics_desc">Estadísticas de uso, TTFT, tokens, salud</string>
<string name="settings_diagnostics">Diagnóstico</string>
<string name="settings_diagnostics_desc">Verificaciones de estado, además de actividad reciente de API, relay, sesión y voz</string>
<string name="settings_advanced">Avanzado</string>
<string name="settings_advanced_desc">Modo supervisado y otras funciones opcionales</string>
<string name="settings_advanced_intro">Las funciones opcionales y especializadas están aquí para mantener despejada la pantalla principal de Ajustes.</string>
<string name="settings_supervised_mode">Modo supervisado</string>
<string name="settings_supervised_desc">Elige un perfil y las funciones de chat permitidas</string>
<string name="settings_supervised_on">Activado</string>
<string name="settings_supervised_on_profile">Activado · %1$s</string>
<string name="settings_supervised_ready_profile">Listo · %1$s</string>
<string name="settings_developer_options">Opciones de desarrollador</string>
<string name="settings_developer_options_desc">Indicadores de funciones, gestión de datos, experimental.</string>
<string name="settings_whats_new">Novedades</string>
@@ -840,6 +848,10 @@
<string name="drawer_search_sessions">Buscar sesiones</string>
<string name="drawer_activity_working">En curso</string>
<string name="drawer_activity_needs_input">Requiere intervención</string>
<string name="drawer_activity_starting">Iniciando</string>
<string name="drawer_activity_background_work">Trabajo en segundo plano</string>
<string name="drawer_activity_checking">Comprobando</string>
<string name="drawer_activity_unavailable">No disponible</string>
<string name="drawer_new_thread">Nuevo hilo</string>
<string name="drawer_chats_not_named">Los chats no tienen nombre automático en esta conexión. Utiliza «→Renombrar».</string>
<string name="drawer_loading_sessions">Cargando sesiones…</string>
@@ -869,7 +881,7 @@
<string name="drawer_filter_pinned">Fijado</string>
<string name="drawer_filter_archive">Archivo</string>
<string name="drawer_filter_sessions">Sesiones</string>
<string name="drawer_timestamp_active">Activo</string>
<string name="drawer_timestamp_updated">Actualizado</string>
<string name="drawer_timestamp_started">Comenzó</string>
<string name="drawer_just_now">En este momento</string>
<string name="bridge_back">Atrás</string>
@@ -3726,7 +3738,7 @@
<string name="appearance_preview_voice">Voz</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 tokens · 137,2K</string>
<string name="appearance_preview_message_placeholder">Mensaje…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / perfil: predeterminado</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / perfil: predeterminado</string>
<string name="appearance_preview_live_note">Esta vista previa se actualiza al instante con el ajuste, modo, fuente y aspecto de Sphere.</string>
<string name="appearance_customize_theme">Personalizar %1$s</string>
<string name="appearance_accent_preset">Color de acento predefinido</string>
@@ -3981,6 +3993,45 @@
<string name="bridge_timed_unlimited_warning">Sin límite de inactividad. El acceso continúa tras inactividad y reconexión hasta finalizarlo, desactivar el interruptor maestro o cambiar la política. Ideal para un dispositivo dedicado.</string>
<string name="bss_screen_access_off_desc">El acceso a pantalla está desactivado. El acceso finito nuevo usa %1$d minutos de inactividad por defecto.</string>
<string name="bss_screen_access_unlimited_desc">Al menos una capacidad de pantalla permanece activa hasta desactivarla explícitamente.</string>
<string name="provider_usage_title">Uso y límites</string>
<string name="provider_usage_back">Atrás</string>
<string name="provider_usage_refresh">Actualizar uso</string>
<string name="provider_usage_intro">Límites de cuenta de los proveedores configurados en esta conexión de Hermes.</string>
<string name="provider_usage_not_available">Esta conexión de Hermes no expone el uso de proveedores. Actualiza Hermes o instala/actualiza el complemento Relay.</string>
<string name="provider_usage_none_configured">Ningún proveedor visible tiene datos de uso de cuenta disponibles.</string>
<string name="provider_usage_loading">Cargando uso de proveedores…</string>
<string name="provider_usage_error">No se pudo cargar el uso de proveedores.</string>
<string name="provider_usage_retry">Reintentar</string>
<string name="provider_usage_percent">%1$d%% usado</string>
<string name="provider_usage_resets">Se restablece en %1$s</string>
<string name="provider_usage_display_title">Visualización en Ajustes</string>
<string name="provider_usage_display_desc">Elige cómo aparece el uso de cuenta en la pantalla principal de Ajustes.</string>
<string name="provider_usage_mode_summary">Resumen</string>
<string name="provider_usage_mode_expanded">Ampliado</string>
<string name="provider_usage_mode_hidden">Oculto</string>
<string name="provider_usage_providers_title">Mostrar en Ajustes principales</string>
<string name="provider_usage_providers_desc">Elige qué tarjetas de proveedores aparecen en Ajustes principales. Aquí siempre se muestran todos.</string>
<string name="provider_usage_settings_desc">Uso de cuenta y límites de proveedores</string>
<string name="provider_usage_settings_desc_relay">Uso y límites ampliados por el complemento Relay</string>
<string name="provider_usage_settings_desc_basic">Uso básico de Hermes · Relay añade grupos y más</string>
<string name="provider_usage_customize">Visualización</string>
<string name="provider_usage_hidden_hint">Las tarjetas de uso están ocultas en Ajustes.</string>
<string name="provider_usage_not_available_compact">El uso de proveedores no está disponible. Actualiza Hermes o el complemento Relay.</string>
<string name="provider_usage_provider_not_configured">No configurado en este host</string>
<string name="provider_usage_provider_unavailable">El uso no está disponible temporalmente</string>
<string name="provider_usage_active_unknown">Esta sesión aún no tiene una credencial activa.</string>
<string name="provider_usage_active_available">Activa · Disponible</string>
<string name="provider_usage_active_at_limit">Activa · Límite alcanzado</string>
<string name="provider_usage_active">Activa</string>
<string name="provider_usage_available">Disponible</string>
<string name="provider_usage_at_limit">Límite alcanzado</string>
<string name="provider_usage_unavailable_status">No disponible</string>
<string name="provider_usage_renews_on">Se renueva el %1$s</string>
<string name="provider_usage_manage_credits">Gestionar créditos</string>
<string name="provider_usage_capability_relay_title">Ampliado por el complemento Relay</string>
<string name="provider_usage_capability_relay_body">Los grupos de credenciales, los saldos estructurados de Nous y OpenCode Go los proporciona el complemento Relay.</string>
<string name="provider_usage_capability_basic_title">Uso básico de Hermes</string>
<string name="provider_usage_capability_basic_body">Instala o actualiza el complemento Relay para obtener grupos de credenciales, saldos estructurados de Nous y OpenCode Go.</string>
<string name="custom_theme_title">Personalizado</string>
<string name="custom_theme_entry_summary">Crea y guarda tus propios temas</string>
<string name="custom_theme_your_presets">Tus preajustes</string>
+53 -2
View File
@@ -698,6 +698,14 @@
<string name="settings_analytics_desc">使用状況統計、TTFT、トークン、ヘルス</string>
<string name="settings_diagnostics">診断</string>
<string name="settings_diagnostics_desc">ステータス チェック、および最近の API、Relay、セッション、および音声アクティビティ</string>
<string name="settings_advanced">詳細設定</string>
<string name="settings_advanced_desc">監督モードとその他のオプション機能</string>
<string name="settings_advanced_intro">メインの設定画面をシンプルに保つため、オプション機能と専門機能はここにまとめられています。</string>
<string name="settings_supervised_mode">監督モード</string>
<string name="settings_supervised_desc">プロファイルと許可するチャット機能を選択</string>
<string name="settings_supervised_on">オン</string>
<string name="settings_supervised_on_profile">オン · %1$s</string>
<string name="settings_supervised_ready_profile">準備完了 · %1$s</string>
<string name="settings_developer_options">開発者向けオプション</string>
<string name="settings_developer_options_desc">機能フラグ、データ管理、実験的</string>
<string name="settings_whats_new">新着情報</string>
@@ -938,6 +946,10 @@
<string name="drawer_search_sessions">セッションを検索</string>
<string name="drawer_activity_working">処理中</string>
<string name="drawer_activity_needs_input">入力が必要</string>
<string name="drawer_activity_starting">開始中</string>
<string name="drawer_activity_background_work">バックグラウンド処理</string>
<string name="drawer_activity_checking">確認中</string>
<string name="drawer_activity_unavailable">利用不可</string>
<string name="drawer_new_thread">新しいスレッド</string>
<string name="drawer_chats_not_named">この接続ではチャットの名前は自動的に付けられません。「⋮」→「名前の変更」を使用してください。</string>
<string name="drawer_loading_sessions">セッションを読み込み中…</string>
@@ -967,7 +979,7 @@
<string name="drawer_filter_pinned">固定された</string>
<string name="drawer_filter_archive">アーカイブ</string>
<string name="drawer_filter_sessions">セッション</string>
<string name="drawer_timestamp_active">アクティブ</string>
<string name="drawer_timestamp_updated">更新</string>
<string name="drawer_timestamp_started">開始しました</string>
<string name="drawer_just_now">ちょうど今</string>
@@ -4040,7 +4052,7 @@
<string name="appearance_preview_voice">音声</string>
<string name="appearance_preview_tool_meta">tool_executor · 206トークン · 137.2K</string>
<string name="appearance_preview_message_placeholder">メッセージ…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / プロファイル: デフォルト</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / プロファイル: デフォルト</string>
<string name="appearance_preview_live_note">このプレビューには、プリセット、モード、フォント、Sphereスキンの変更がすぐに反映されます。</string>
<string name="appearance_customize_theme">%1$sをカスタマイズ</string>
<string name="appearance_accent_preset">プリセットのアクセント</string>
@@ -4294,6 +4306,45 @@
<string name="bridge_timed_unlimited_warning">アイドルタイムアウトはありません。終了、マスター無効化、またはポリシー変更まで、非操作時や再接続後も画面アクセスが続きます。専用端末向けです。</string>
<string name="bss_screen_access_off_desc">画面アクセスはオフです。新しい有限アクセスの既定アイドル制限は %1$d 分です。</string>
<string name="bss_screen_access_unlimited_desc">少なくとも 1 つの画面機能が明示的にオフにするまで有効です。</string>
<string name="provider_usage_title">使用量と上限</string>
<string name="provider_usage_back">戻る</string>
<string name="provider_usage_refresh">使用量を更新</string>
<string name="provider_usage_intro">この Hermes 接続に設定されたプロバイダーのアカウント上限です。</string>
<string name="provider_usage_not_available">この Hermes 接続はプロバイダー使用量を公開していません。Hermes を更新するか、Relay プラグインをインストール/更新してください。</string>
<string name="provider_usage_none_configured">表示中のプロバイダーに利用可能なアカウント使用量がありません。</string>
<string name="provider_usage_loading">プロバイダー使用量を読み込み中…</string>
<string name="provider_usage_error">プロバイダー使用量を読み込めませんでした。</string>
<string name="provider_usage_retry">再試行</string>
<string name="provider_usage_percent">%1$d%% 使用済み</string>
<string name="provider_usage_resets">%1$s後にリセット</string>
<string name="provider_usage_display_title">設定での表示</string>
<string name="provider_usage_display_desc">メインの設定画面にアカウント使用量を表示する方法を選びます。</string>
<string name="provider_usage_mode_summary">概要</string>
<string name="provider_usage_mode_expanded">展開</string>
<string name="provider_usage_mode_hidden">非表示</string>
<string name="provider_usage_providers_title">メイン設定に表示</string>
<string name="provider_usage_providers_desc">メイン設定に表示するプロバイダーカードを選びます。ここではすべて表示されます。</string>
<string name="provider_usage_settings_desc">アカウント使用量とプロバイダー上限</string>
<string name="provider_usage_settings_desc_relay">Relay プラグインで拡張された使用量と上限</string>
<string name="provider_usage_settings_desc_basic">Hermes の基本使用量 · Relay でプールなどを追加</string>
<string name="provider_usage_customize">表示</string>
<string name="provider_usage_hidden_hint">設定では使用量カードが非表示です。</string>
<string name="provider_usage_not_available_compact">プロバイダー使用量を利用できません。Hermes または Relay プラグインを更新してください。</string>
<string name="provider_usage_provider_not_configured">このホストでは未設定です</string>
<string name="provider_usage_provider_unavailable">使用量は一時的に利用できません</string>
<string name="provider_usage_active_unknown">このセッションにはまだ使用中の認証情報がありません。</string>
<string name="provider_usage_active_available">使用中 · 利用可能</string>
<string name="provider_usage_active_at_limit">使用中 · 上限到達</string>
<string name="provider_usage_active">使用中</string>
<string name="provider_usage_available">利用可能</string>
<string name="provider_usage_at_limit">上限到達</string>
<string name="provider_usage_unavailable_status">利用不可</string>
<string name="provider_usage_renews_on">%1$s に更新</string>
<string name="provider_usage_manage_credits">クレジットを管理</string>
<string name="provider_usage_capability_relay_title">Relay プラグインで拡張</string>
<string name="provider_usage_capability_relay_body">認証情報プール、構造化された Nous 残高、OpenCode Go は Relay プラグインによって提供されます。</string>
<string name="provider_usage_capability_basic_title">Hermes の基本使用量</string>
<string name="provider_usage_capability_basic_body">認証情報プール、構造化された Nous 残高、OpenCode Go を利用するには Relay プラグインをインストールまたは更新してください。</string>
<string name="custom_theme_title">カスタム</string>
<string name="custom_theme_entry_summary">独自のテーマを作成して保存します</string>
<string name="custom_theme_your_presets">保存したテーマ</string>
+53 -2
View File
@@ -668,6 +668,14 @@
<string name="settings_analytics_desc">Статистика использования, TTFT, токены, состояние</string>
<string name="settings_diagnostics">Диагностика</string>
<string name="settings_diagnostics_desc">Проверка состояния, а также недавняя активность API, Relay, сессий и голосовых данных</string>
<string name="settings_advanced">Дополнительно</string>
<string name="settings_advanced_desc">Режим с контролем и другие дополнительные функции</string>
<string name="settings_advanced_intro">Дополнительные и специальные функции собраны здесь, чтобы не перегружать главный экран настроек.</string>
<string name="settings_supervised_mode">Режим с контролем</string>
<string name="settings_supervised_desc">Выберите профиль и разрешённые функции чата</string>
<string name="settings_supervised_on">Вкл.</string>
<string name="settings_supervised_on_profile">Вкл. · %1$s</string>
<string name="settings_supervised_ready_profile">Готово · %1$s</string>
<string name="settings_developer_options">Настройки разработчика</string>
<string name="settings_developer_options_desc">Флаги функций, управление данными, экспериментальные</string>
<string name="settings_whats_new">Что нового</string>
@@ -948,6 +956,10 @@
<string name="drawer_search_sessions">Поиск сессий</string>
<string name="drawer_activity_working">Выполняется</string>
<string name="drawer_activity_needs_input">Требуется ввод</string>
<string name="drawer_activity_starting">Запуск</string>
<string name="drawer_activity_background_work">Фоновая работа</string>
<string name="drawer_activity_checking">Проверка</string>
<string name="drawer_activity_unavailable">Недоступно</string>
<string name="drawer_new_thread">Новая ветка</string>
<string name="drawer_chats_not_named">Чаты не автоматически именуются на этом соединении — используйте ⋮ → Переименовать.</string>
<string name="drawer_loading_sessions">Загрузка сессий…</string>
@@ -977,7 +989,7 @@
<string name="drawer_filter_pinned">Закрепленные</string>
<string name="drawer_filter_archive">Архив</string>
<string name="drawer_filter_sessions">Сессии</string>
<string name="drawer_timestamp_active">Активен</string>
<string name="drawer_timestamp_updated">Обновлено</string>
<string name="drawer_timestamp_started">Начат</string>
<string name="drawer_just_now">Только что</string>
<string name="bridge_back">Назад</string>
@@ -3762,7 +3774,7 @@
<string name="appearance_preview_voice">Голос</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 токенов · 137,2 тыс.</string>
<string name="appearance_preview_message_placeholder">Сообщение…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / профиль: по умолчанию</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / профиль: по умолчанию</string>
<string name="appearance_preview_live_note">Предпросмотр сразу обновляется при изменении шаблона, режима, шрифта и оформления Sphere.</string>
<string name="appearance_customize_theme">Настроить %1$s</string>
<string name="appearance_accent_preset">Предустановленный акцент</string>
@@ -4023,6 +4035,45 @@
<string name="bridge_timed_unlimited_warning">Без тайм-аута. Доступ сохраняется при бездействии и переподключении, пока не завершен, не выключен главный переключатель или не изменена политика. Для выделенного устройства.</string>
<string name="bss_screen_access_off_desc">Доступ к экрану выключен. Новый ограниченный доступ по умолчанию использует %1$d минут бездействия.</string>
<string name="bss_screen_access_unlimited_desc">Хотя бы одна экранная возможность активна до явного отключения.</string>
<string name="provider_usage_title">Использование и лимиты</string>
<string name="provider_usage_back">Назад</string>
<string name="provider_usage_refresh">Обновить использование</string>
<string name="provider_usage_intro">Лимиты учётных записей поставщиков, настроенных для этого подключения Hermes.</string>
<string name="provider_usage_not_available">Это подключение Hermes не предоставляет данные поставщиков. Обновите Hermes или установите/обновите плагин Relay.</string>
<string name="provider_usage_none_configured">Ни у одного видимого поставщика нет доступных данных об использовании.</string>
<string name="provider_usage_loading">Загрузка данных поставщиков…</string>
<string name="provider_usage_error">Не удалось загрузить данные поставщиков.</string>
<string name="provider_usage_retry">Повторить</string>
<string name="provider_usage_percent">Использовано %1$d%%</string>
<string name="provider_usage_resets">Сброс через %1$s</string>
<string name="provider_usage_display_title">Отображение в настройках</string>
<string name="provider_usage_display_desc">Выберите, как использование учётной записи отображается на главном экране настроек.</string>
<string name="provider_usage_mode_summary">Сводка</string>
<string name="provider_usage_mode_expanded">Развёрнуто</string>
<string name="provider_usage_mode_hidden">Скрыто</string>
<string name="provider_usage_providers_title">Показывать в основных настройках</string>
<string name="provider_usage_providers_desc">Выберите карточки поставщиков для главного экрана настроек. Здесь всегда видны все поставщики.</string>
<string name="provider_usage_settings_desc">Использование учётной записи и лимиты поставщиков</string>
<string name="provider_usage_settings_desc_relay">Расширенные данные и лимиты от плагина Relay</string>
<string name="provider_usage_settings_desc_basic">Базовые данные Hermes · Relay добавляет пулы и другое</string>
<string name="provider_usage_customize">Отображение</string>
<string name="provider_usage_hidden_hint">Карточки использования скрыты в настройках.</string>
<string name="provider_usage_not_available_compact">Данные поставщиков недоступны. Обновите Hermes или плагин Relay.</string>
<string name="provider_usage_provider_not_configured">Не настроено на этом хосте</string>
<string name="provider_usage_provider_unavailable">Данные временно недоступны</string>
<string name="provider_usage_active_unknown">Для этого сеанса ещё нет активных учётных данных.</string>
<string name="provider_usage_active_available">Активно · Доступно</string>
<string name="provider_usage_active_at_limit">Активно · Лимит исчерпан</string>
<string name="provider_usage_active">Активно</string>
<string name="provider_usage_available">Доступно</string>
<string name="provider_usage_at_limit">Лимит исчерпан</string>
<string name="provider_usage_unavailable_status">Недоступно</string>
<string name="provider_usage_renews_on">Продление: %1$s</string>
<string name="provider_usage_manage_credits">Управление кредитами</string>
<string name="provider_usage_capability_relay_title">Расширено плагином Relay</string>
<string name="provider_usage_capability_relay_body">Пулы учётных данных, структурированные балансы Nous и OpenCode Go предоставляются плагином Relay.</string>
<string name="provider_usage_capability_basic_title">Базовые данные Hermes</string>
<string name="provider_usage_capability_basic_body">Установите или обновите плагин Relay для пулов учётных данных, структурированных балансов Nous и OpenCode Go.</string>
<string name="custom_theme_title">Своя тема</string>
<string name="custom_theme_entry_summary">Создавайте и сохраняйте собственные темы</string>
<string name="custom_theme_your_presets">Ваши темы</string>
+53 -2
View File
@@ -736,6 +736,14 @@
<string name="settings_analytics_desc">Usage stats, TTFT, tokens, health</string>
<string name="settings_diagnostics">Diagnostics</string>
<string name="settings_diagnostics_desc">Status checks, plus recent API, relay, session, and voice activity</string>
<string name="settings_advanced">Advanced</string>
<string name="settings_advanced_desc">Supervised mode and other optional features</string>
<string name="settings_advanced_intro">Optional and specialized features live here to keep the main Settings screen focused.</string>
<string name="settings_supervised_mode">Supervised mode</string>
<string name="settings_supervised_desc">Choose a profile and approved chat features</string>
<string name="settings_supervised_on">On</string>
<string name="settings_supervised_on_profile">On · %1$s</string>
<string name="settings_supervised_ready_profile">Ready · %1$s</string>
<string name="settings_developer_options">Developer options</string>
<string name="settings_developer_options_desc">Feature flags, data management, experimental</string>
<string name="settings_whats_new">What\'s New</string>
@@ -1040,6 +1048,10 @@
<string name="drawer_search_sessions">Search sessions</string>
<string name="drawer_activity_working">Working</string>
<string name="drawer_activity_needs_input">Needs input</string>
<string name="drawer_activity_starting">Starting</string>
<string name="drawer_activity_background_work">Background work</string>
<string name="drawer_activity_checking">Checking</string>
<string name="drawer_activity_unavailable">Unavailable</string>
<string name="drawer_new_thread">New Thread</string>
<string name="drawer_chats_not_named">Chats aren\'t auto-named on this connection — use ⋮ → Rename.</string>
<string name="drawer_loading_sessions">Loading sessions…</string>
@@ -1069,7 +1081,7 @@
<string name="drawer_filter_pinned">Pinned</string>
<string name="drawer_filter_archive">Archive</string>
<string name="drawer_filter_sessions">Sessions</string>
<string name="drawer_timestamp_active">Active</string>
<string name="drawer_timestamp_updated">Updated</string>
<string name="drawer_timestamp_started">Started</string>
<string name="drawer_just_now">Just now</string>
@@ -1306,7 +1318,7 @@
<string name="appearance_preview_voice">Voice</string>
<string name="appearance_preview_tool_meta">tool_executor · 206 tokens · 137.2K</string>
<string name="appearance_preview_message_placeholder">Message…</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.5 / profile: default</string>
<string name="appearance_preview_gateway">Gateway · LAN · gpt-5.6-sol / profile: default</string>
<string name="appearance_preview_live_note">This preview updates immediately with your preset, mode, font, and sphere skin.</string>
<string name="appearance_back">Back</string>
<string name="appearance_remove_pet_title">Remove pet?</string>
@@ -4339,4 +4351,43 @@
<string name="bridge_timed_allow">Allow access</string>
<string name="bridge_timed_end_now">End now</string>
<string name="bridge_timed_ended_snackbar">Screen access ended. Permanent grants are still available.</string>
<string name="provider_usage_title">Usage &amp; limits</string>
<string name="provider_usage_back">Back</string>
<string name="provider_usage_refresh">Refresh usage</string>
<string name="provider_usage_intro">Account limits from providers configured on this Hermes connection.</string>
<string name="provider_usage_not_available">This Hermes connection does not expose provider usage. Update Hermes or install/update the Relay plugin to enable it.</string>
<string name="provider_usage_none_configured">No visible provider has account usage available.</string>
<string name="provider_usage_loading">Loading provider usage…</string>
<string name="provider_usage_error">Couldn\'t load provider usage.</string>
<string name="provider_usage_retry">Retry</string>
<string name="provider_usage_percent">%1$d%% used</string>
<string name="provider_usage_resets">Resets in %1$s</string>
<string name="provider_usage_display_title">Settings display</string>
<string name="provider_usage_display_desc">Choose how account usage appears on the main Settings screen.</string>
<string name="provider_usage_mode_summary">Summary</string>
<string name="provider_usage_mode_expanded">Expanded</string>
<string name="provider_usage_mode_hidden">Hidden</string>
<string name="provider_usage_providers_title">Show on main Settings</string>
<string name="provider_usage_providers_desc">Choose which provider cards appear on the main Settings page. All providers remain visible here.</string>
<string name="provider_usage_settings_desc">Account usage and provider limits</string>
<string name="provider_usage_settings_desc_relay">Relay plugin enhanced usage and limits</string>
<string name="provider_usage_settings_desc_basic">Basic Hermes usage · Relay plugin adds pools and more</string>
<string name="provider_usage_customize">Display</string>
<string name="provider_usage_hidden_hint">Usage cards are hidden on Settings.</string>
<string name="provider_usage_not_available_compact">Provider usage is unavailable. Update Hermes or install/update the Relay plugin.</string>
<string name="provider_usage_provider_not_configured">Not configured on this host</string>
<string name="provider_usage_provider_unavailable">Usage is temporarily unavailable</string>
<string name="provider_usage_active_unknown">No active credential yet for this session.</string>
<string name="provider_usage_active_available">Active · Available</string>
<string name="provider_usage_active_at_limit">Active · At limit</string>
<string name="provider_usage_active">Active</string>
<string name="provider_usage_available">Available</string>
<string name="provider_usage_at_limit">At limit</string>
<string name="provider_usage_unavailable_status">Unavailable</string>
<string name="provider_usage_renews_on">Renews %1$s</string>
<string name="provider_usage_manage_credits">Manage credits</string>
<string name="provider_usage_capability_relay_title">Relay plugin enhanced</string>
<string name="provider_usage_capability_relay_body">Credential pools, structured Nous balances, and OpenCode Go are provided by the Relay plugin.</string>
<string name="provider_usage_capability_basic_title">Basic usage from Hermes</string>
<string name="provider_usage_capability_basic_body">Install or update the Relay plugin for credential pools, structured Nous balances, and OpenCode Go.</string>
</resources>
@@ -0,0 +1,55 @@
package com.hermesandroid.relay.auth
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedModeAuthPayloadTest {
@Test fun `active policy reports only public capability ids`() {
val payload = relaySupervisedModePayload(
SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(attachments = true, voice = true),
),
)
assertTrue(payload.getValue("active").jsonPrimitive.boolean)
assertEquals("willow", payload.getValue("profile_label").jsonPrimitive.content)
val capabilities = payload.getValue("capabilities").jsonArray.map { it.jsonPrimitive.content }
assertTrue("text_chat" in capabilities)
assertTrue("attachments" in capabilities)
assertTrue("voice" in capabilities)
assertFalse(capabilities.any { it.contains("model") || it.contains("tool") })
}
@Test fun `inactive update explicitly clears Relay tag`() {
val payload = relaySupervisedModePayload(SupervisedModePolicy())
assertFalse(payload.getValue("active").jsonPrimitive.boolean)
assertEquals(setOf("active"), payload.keys)
}
@Test fun `live update uses typed correlated system envelope`() {
val envelope = relaySupervisedModeUpdateEnvelope(
SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(voice = true),
),
)
assertEquals("system", envelope.channel)
assertEquals("supervised.update", envelope.type)
assertTrue(envelope.id.isNotBlank())
val mode = envelope.payload.getValue("supervised_mode")
.jsonObject
assertTrue(mode.getValue("active").jsonPrimitive.boolean)
assertEquals("willow", mode.getValue("profile_label").jsonPrimitive.content)
}
}
@@ -0,0 +1,69 @@
package com.hermesandroid.relay.data
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class ProviderUsagePreferencesTest {
@get:Rule
val tempFolder = TemporaryFolder()
private lateinit var file: File
private lateinit var scope: CoroutineScope
private lateinit var repository: ProviderUsagePreferencesRepository
@Before
fun setUp() {
file = tempFolder.newFile("provider_usage.preferences_pb").also { it.delete() }
scope = CoroutineScope(Dispatchers.IO + Job())
repository = ProviderUsagePreferencesRepository(
PreferenceDataStoreFactory.create(scope = scope, produceFile = { file }),
)
}
@After
fun tearDown() {
scope.cancel()
}
@Test
fun defaultsToSummaryWithSupportedProvidersVisible() = runTest {
val preferences = repository.preferences.first()
assertEquals(ProviderUsageLandingMode.Summary, preferences.landingMode)
assertEquals(
setOf("openai-codex", "nous", "opencode-go"),
preferences.visibleProviders,
)
}
@Test
fun persistsDisplayMode() = runTest {
repository.setLandingMode(ProviderUsageLandingMode.Expanded)
val preferences = repository.preferences.first()
assertEquals(ProviderUsageLandingMode.Expanded, preferences.landingMode)
}
@Test
fun persistsIndependentProviderVisibility() = runTest {
repository.setProviderVisible("nous", false)
val preferences = repository.preferences.first()
assertFalse("nous" in preferences.visibleProviders)
assertTrue("openai-codex" in preferences.visibleProviders)
assertTrue("opencode-go" in preferences.visibleProviders)
}
}
@@ -0,0 +1,466 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionActivityRegistryTest {
private val owner = owner("connection-a", "Default", "session-a")
private val scope = SessionActivityScope.of("connection-a", "default")
@Test
fun `directory owner stays neutral until status confirms live activity`() {
val checking = SessionActivityRegistry().reduce(
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
)
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Revalidating, checking.record(owner)?.freshness)
assertNull(checking.record(owner)?.presentationState())
val unavailable = checking.reduce(
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
)
assertEquals(SessionActivityFreshness.Unavailable, unavailable.record(owner)?.freshness)
assertNull(unavailable.record(owner)?.presentationState())
val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, confirmedIdle.record(owner)?.freshness)
assertNull(confirmedIdle.record(owner)?.presentationState())
}
@Test
fun `directory refresh cannot restore checking after active status is unavailable`() {
val state = SessionActivityRegistry()
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1))
.reduce(SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2))
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 3))
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `directory observation cannot downgrade confirmed live evidence`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 20))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
}
@Test
fun `owner normalizes profile without collapsing connection ownership`() {
assertEquals(owner, owner("connection-a", " DEFAULT ", "session-a"))
val otherConnection = owner("connection-b", "default", "session-a")
assertEquals(2, setOf(owner, otherConnection).size)
}
@Test
fun `exact pending input outranks live working and answer restores it`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase(nowMillis = 10))
state = state.reduce(closeInput(owner, "request-a", generation = 1))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 10))
}
@Test
fun `expired pending input no longer overrides live state`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1, expiresAt = 50))
.reduce(SessionActivityUpdate.Tick(nowMillis = 50))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 50))
}
@Test
fun `checkpoint is revalidating until successful snapshot settles it`() {
var state = SessionActivityRegistry().reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 1,
),
)
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
state = state.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
}
@Test
fun `successful snapshot absence settles only the same profile`() {
val otherProfile = owner("connection-a", "work", "session-a")
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(live(otherProfile, "runtime-a", SessionLiveStatus.Working, generation = 1))
state = state.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(otherProfile)?.phase())
}
@Test
fun `same ids cannot alias across profiles or connections`() {
val profileB = owner("connection-a", "work", "session-a")
val connectionB = owner("connection-b", "default", "session-a")
var state = SessionActivityRegistry()
listOf(owner, profileB, connectionB).forEach {
state = state.reduce(live(it, "runtime-shared", SessionLiveStatus.Working, generation = 2))
}
assertEquals(owner, state.ownerForRuntime(scope, "runtime-shared"))
assertEquals(
profileB,
state.ownerForRuntime(SessionActivityScope.of("connection-a", "work"), "runtime-shared"),
)
assertEquals(
connectionB,
state.ownerForRuntime(SessionActivityScope.of("connection-b", "default"), "runtime-shared"),
)
}
@Test
fun `unscoped active row cannot mark duplicate stored ids as working`() {
val profileB = owner("connection-a", "work", "session-a")
var state = SessionActivityRegistry()
.reduce(live(owner, "old-a", SessionLiveStatus.Working, generation = 1))
.reduce(live(profileB, "old-b", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
activeList(
scope,
1,
false,
SessionLiveRuntime(
owner = null,
runtimeId = "ambiguous-runtime",
status = SessionLiveStatus.Working,
),
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(profileB)?.phase())
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
}
@Test
fun `partial snapshot applies resolved row without settling absent owner`() {
val absentOwner = owner("connection-a", "default", "session-b")
var state = SessionActivityRegistry()
.reduce(live(absentOwner, "runtime-b", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
activeList(
scope,
1,
false,
SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Working),
SessionLiveRuntime(null, "ambiguous-runtime", SessionLiveStatus.Working),
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(absentOwner)?.phase())
assertEquals(owner, state.ownerForRuntime(scope, "runtime-a"))
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
}
@Test
fun `new generation rejects late terminal event and invalidates old alias`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-old", SessionLiveStatus.Working, generation = 3))
.reduce(SessionActivityUpdate.BeginGeneration(scope, generation = 4, observedAtMillis = 20))
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
assertNull(state.ownerForRuntime(scope, "runtime-old"))
state = state.reduce(live(owner, "runtime-old", SessionLiveStatus.Idle, generation = 3))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
}
@Test
fun `failed or unsupported status refresh preserves evidence but presents a neutral row`() {
val state = SessionActivityRegistry()
.reduce(
SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = "runtime-a",
status = SessionLiveStatus.Working,
source = SessionActivityEvidenceSource.ActiveList,
generation = 1,
observedAtMillis = 10,
),
)
.reduce(
SessionActivityUpdate.StatusUnavailable(
scope = scope,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `active-list failure does not override exact live session event`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(
SessionActivityUpdate.StatusUnavailable(
scope = scope,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
assertEquals(SessionActivityState.Working, state.record(owner)?.presentationState())
}
@Test
fun `presentation keeps starting and background distinct while revalidation stays neutral`() {
val starting = SessionActivityRegistry().reduce(
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
)
assertEquals(SessionActivityState.Starting, starting.record(owner)?.presentationState())
val background = starting
.reduce(process(owner, "process-a", running = true, generation = 1))
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
assertEquals(SessionActivityState.BackgroundWork, background.record(owner)?.presentationState())
val checking = starting.reduce(
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
)
assertNull(checking.record(owner)?.presentationState())
}
@Test
fun `terminal turn with running process projects background work separately`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(process(owner, "process-a", running = true, generation = 1))
.reduce(terminal(owner, "runtime-a", generation = 1))
assertEquals(SessionActivityPhase.BackgroundWork, state.record(owner)?.phase())
state = state.reduce(process(owner, "process-a", running = false, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
}
@Test
fun `terminal settles pending input and removes its runtime alias`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
state = state.reduce(terminal(owner, "runtime-a", generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertNull(state.ownerForRuntime(scope, "runtime-a"))
}
@Test
fun `authoritative live state is not overwritten by restored checkpoint`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
.reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 30,
),
)
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
}
@Test
fun `restored needs-input checkpoint stays neutral until live confirmation`() {
val state = SessionActivityRegistry().reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.NeedsInput,
generation = 1,
observedAtMillis = 10,
),
)
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `synthetic checkpoint input does not confirm restored working state`() {
var state = SessionActivityRegistry()
.reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 10,
),
)
.reduce(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:session-a",
confirmed = false,
generation = 1,
observedAtMillis = 11,
),
)
assertNull(state.record(owner)?.presentationState())
state = state.reduce(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:session-a",
confirmed = true,
generation = 1,
observedAtMillis = 12,
),
)
assertEquals(SessionActivityState.NeedsInput, state.record(owner)?.presentationState())
}
@Test
fun `authoritative idle active-list row clears stale pending input`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
.reduce(
activeList(
scope,
generation = 1,
runtimes = arrayOf(SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Idle)),
),
)
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertTrue(state.record(owner)?.pendingInputs.orEmpty().isEmpty())
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `runtime-only update requires alias in current scoped generation`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
SessionActivityUpdate.RuntimeState(
scope = scope,
runtimeId = "runtime-a",
status = SessionLiveStatus.Waiting,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
state = state.reduce(SessionActivityUpdate.BeginGeneration(scope, 2, 30))
.reduce(
SessionActivityUpdate.RuntimeState(
scope = scope,
runtimeId = "runtime-a",
status = SessionLiveStatus.Idle,
generation = 2,
observedAtMillis = 40,
),
)
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
}
private fun owner(connection: String, profile: String, session: String) =
SessionActivityOwner.of(connection, profile, session)
private fun live(
owner: SessionActivityOwner,
runtime: String,
status: SessionLiveStatus,
generation: Long,
) = SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = runtime,
status = status,
generation = generation,
observedAtMillis = 10,
)
private fun activeList(
scope: SessionActivityScope,
generation: Long,
complete: Boolean = true,
vararg runtimes: SessionLiveRuntime,
) = SessionActivityUpdate.ActiveList(
scope = scope,
runtimes = runtimes.toList(),
isCompleteForScope = complete,
generation = generation,
observedAtMillis = 20,
)
private fun openInput(
owner: SessionActivityOwner,
request: String,
generation: Long,
expiresAt: Long? = null,
) = SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = request,
expiresAtMillis = expiresAt,
generation = generation,
observedAtMillis = 10,
)
private fun closeInput(owner: SessionActivityOwner, request: String, generation: Long) =
SessionActivityUpdate.PendingInputClosed(
owner = owner,
requestId = request,
generation = generation,
observedAtMillis = 20,
)
private fun process(owner: SessionActivityOwner, id: String, running: Boolean, generation: Long) =
SessionActivityUpdate.ProcessState(
owner = owner,
processId = id,
running = running,
generation = generation,
observedAtMillis = 10,
)
private fun terminal(owner: SessionActivityOwner, runtime: String, generation: Long) =
SessionActivityUpdate.Terminal(
owner = owner,
runtimeId = runtime,
generation = generation,
observedAtMillis = 20,
)
}
@@ -0,0 +1,221 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.mutablePreferencesOf
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.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedModeStoreTest {
@Test
fun freshConnectionUsesRestrictiveDefaults() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
val policy = store.policyFlow("connection-a").first()
assertFalse(policy.enabled)
assertFalse(policy.isConfigured)
assertFalse(policy.isActive)
assertFalse(policy.capabilities.attachments)
assertFalse(policy.capabilities.voice)
assertFalse(policy.visibility.resolved().showModelName)
assertFalse(policy.visibility.resolved().showTechnicalRoute)
assertTrue(policy.parentAccess.requireDeviceAuthentication)
assertEquals(5, policy.parentAccess.timeoutMinutes)
}
@Test
fun policyRoundTripsWithCapabilitiesLimitsAndVisibility() = runTest {
val dataStore = InMemorySupervisedPreferencesDataStore()
val store = SupervisedModeStore.forTesting(dataStore)
val saved = SupervisedModePolicy(
enabled = true,
pinnedProfileName = " willow ",
capabilities = SupervisedCapabilities(
attachments = true,
voice = true,
attachmentMaxCount = 6,
attachmentMaxFileMb = 20,
attachmentCategories = setOf(
SupervisedAttachmentCategory.Images,
SupervisedAttachmentCategory.Documents,
),
sessionActions = SupervisedSessionActions(
pin = true,
rename = true,
shareTranscript = true,
),
),
appearance = SupervisedAppearance(
appThemeId = "rose",
themePreference = "dark",
showPet = true,
allowProfileIconChanges = true,
allowBackgroundChanges = true,
),
visibility = SupervisedVisibility(
preset = SupervisedVisibilityPreset.Custom,
showAgentIdentity = true,
showModelName = true,
showToolNames = true,
),
)
store.setPolicy("connection-a", saved)
val restored = SupervisedModeStore.forTesting(dataStore).policyFlow("connection-a").first()
assertTrue(restored.isActive)
assertEquals("willow", restored.pinnedProfileName)
assertEquals(6, restored.capabilities.attachmentMaxCount)
assertEquals(20, restored.capabilities.attachmentMaxFileMb)
assertEquals(saved.capabilities.attachmentCategories, restored.capabilities.attachmentCategories)
assertEquals(saved.capabilities.sessionActions, restored.capabilities.sessionActions)
assertEquals("rose", restored.appearance.appThemeId)
assertEquals("dark", restored.appearance.themePreference)
assertTrue(restored.appearance.showPet)
assertTrue(restored.appearance.allowProfileIconChanges)
assertTrue(restored.appearance.allowBackgroundChanges)
assertEquals(SupervisedVisibilityPreset.Custom, restored.visibility.preset)
assertTrue(restored.visibility.showModelName)
assertTrue(restored.visibility.showToolNames)
}
@Test
fun connectionsAreIsolatedAndClearRemovesOnlyTarget() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
store.setPolicy("connection-b", SupervisedModePolicy(true, "juniper"))
store.clear("connection-a")
assertFalse(store.policyFlow("connection-a").first().enabled)
assertEquals("juniper", store.policyFlow("connection-b").first().pinnedProfileName)
}
@Test
fun updateAndSetEnabledPreserveOtherPolicyFields() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
store.setPolicy(
"connection-a",
SupervisedModePolicy(
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(voice = true),
),
)
store.setEnabled("connection-a", true)
store.updatePolicy("connection-a") {
it.copy(visibility = it.visibility.copy(preset = SupervisedVisibilityPreset.Transparent))
}
val policy = store.policyFlow("connection-a").first()
assertTrue(policy.isActive)
assertTrue(policy.capabilities.voice)
assertEquals(SupervisedVisibilityPreset.Transparent, policy.visibility.preset)
}
@Test
fun invalidLimitsAreNormalizedAndEmptyCategoriesFallBackToImages() = runTest {
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
store.setPolicy(
"connection-a",
SupervisedModePolicy(
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(
attachmentMaxCount = Int.MAX_VALUE,
attachmentMaxFileMb = -1,
attachmentCategories = emptySet(),
),
parentAccess = SupervisedParentAccess(
requireDeviceAuthentication = false,
timeoutMinutes = 0,
),
appearance = SupervisedAppearance(
appThemeId = "missing-theme",
themePreference = "sepia",
),
),
)
val policy = store.policyFlow("connection-a").first()
assertEquals(SupervisedCapabilities.MAX_ATTACHMENT_COUNT, policy.capabilities.attachmentMaxCount)
assertEquals(1, policy.capabilities.attachmentMaxFileMb)
assertEquals(setOf(SupervisedAttachmentCategory.Images), policy.capabilities.attachmentCategories)
assertTrue(policy.parentAccess.requireDeviceAuthentication)
assertEquals(SupervisedParentAccess.MIN_TIMEOUT_MINUTES, policy.parentAccess.timeoutMinutes)
assertEquals("hermes-relay", policy.appearance.appThemeId)
assertEquals("auto", policy.appearance.themePreference)
}
@Test
fun simplePresetResolvesToSafeValuesEvenIfStoredFlagsDiffer() {
val visibility = SupervisedVisibility(
preset = SupervisedVisibilityPreset.Simple,
showModelName = true,
showTechnicalRoute = true,
showReasoning = true,
).resolved()
assertFalse(visibility.showModelName)
assertFalse(visibility.showTechnicalRoute)
assertFalse(visibility.showReasoning)
assertTrue(visibility.showAgentIdentity)
assertTrue(visibility.showConnectionStatus)
}
@Test
fun malformedPersistedPolicyFailsClosed() = runTest {
val policyKey = androidx.datastore.preferences.core.stringPreferencesKey(
"supervised_mode_policies_v1",
)
val dataStore = InMemorySupervisedPreferencesDataStore(
mutablePreferencesOf(policyKey to "{not-valid-json"),
)
val policy = SupervisedModeStore.forTesting(dataStore)
.policyFlow("connection-a")
.first()
assertTrue(policy.enabled)
assertFalse(policy.isConfigured)
assertFalse(policy.isActive)
}
@Test
fun clearAllDoesNotClearUnrelatedPreferences() = runTest {
val unrelatedKey = androidx.datastore.preferences.core.stringPreferencesKey("unrelated")
val dataStore = InMemorySupervisedPreferencesDataStore(
mutablePreferencesOf(unrelatedKey to "kept"),
)
val store = SupervisedModeStore.forTesting(dataStore)
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
store.clearAll()
assertFalse(store.policyFlow("connection-a").first().enabled)
assertEquals("kept", dataStore.data.first()[unrelatedKey])
}
}
private class InMemorySupervisedPreferencesDataStore(
initial: Preferences = emptyPreferences(),
) : DataStore<Preferences> {
private val state = MutableStateFlow(initial)
override val data: Flow<Preferences> = state
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences {
val updated = transform(state.value)
state.value = updated
return updated
}
}
@@ -0,0 +1,41 @@
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 SupervisedSessionPolicyTest {
@Test fun `session action summary derives none mixed and all`() {
val none = SupervisedSessionActions()
val mixed = none.copy(rename = true, delete = true)
val all = none.withAll(true)
assertTrue(none.noneEnabled)
assertEquals(2, mixed.enabledCount)
assertFalse(mixed.noneEnabled)
assertFalse(mixed.allEnabled)
assertTrue(all.allEnabled)
assertEquals(SupervisedSessionActions.TOTAL, all.enabledCount)
}
@Test fun `supervised history and granular flag are both required`() {
val base = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(
conversationHistory = true,
sessionActions = SupervisedSessionActions(rename = true),
),
)
assertTrue(base.allowsSessionAction(SupervisedSessionAction.Rename))
assertFalse(base.allowsSessionAction(SupervisedSessionAction.Delete))
assertFalse(
base.copy(
capabilities = base.capabilities.copy(conversationHistory = false),
).allowsSessionAction(SupervisedSessionAction.Rename),
)
assertTrue(SupervisedModePolicy().allowsSessionAction(SupervisedSessionAction.Delete))
}
}
@@ -0,0 +1,33 @@
package com.hermesandroid.relay.network.relay
import com.hermesandroid.relay.network.relay.models.Envelope
import org.junit.Assert.assertEquals
import org.junit.Test
class ChannelMultiplexerSupervisedUpdateTest {
@Test fun `supervised update acknowledgement reaches system auth handler`() {
val multiplexer = ChannelMultiplexer()
val received = mutableListOf<Envelope>()
multiplexer.registerHandler("system") { received += it }
val acknowledgement = Envelope(
channel = "system",
type = "supervised.updated",
id = "update-1",
)
multiplexer.route(acknowledgement)
assertEquals(listOf(acknowledgement), received)
}
@Test fun `correlated system error reaches system auth handler`() {
val multiplexer = ChannelMultiplexer()
val received = mutableListOf<Envelope>()
multiplexer.registerHandler("system") { received += it }
val error = Envelope(channel = "system", type = "error", id = "update-2")
multiplexer.route(error)
assertEquals(listOf(error), received)
}
}
@@ -0,0 +1,99 @@
package com.hermesandroid.relay.network.relay
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class RelayHttpClientProviderUsageTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun parsesProviderNeutralPayloadAndAuthenticates() = runTest {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""
{
"schema_version": 2,
"capabilities": ["credential_pools", "structured_balances", "opencode_go"],
"providers": [
{
"id": "openai-codex",
"display_name": "Codex",
"status": "available",
"plan": "Plus",
"active_credential_id": "abc123",
"active_credential_state": "known",
"credentials": [{
"id": "abc123",
"label": "Work",
"active": true,
"status": "available",
"windows": []
}],
"windows": [{
"id": "session",
"label": "Session",
"used_percent": 42.5,
"reset_at": "2026-08-22T00:00:00Z"
}]
}
]
}
""".trimIndent(),
),
)
val response = client(token = "paired-token")
.fetchProviderUsage(profile = "victor", sessionId = "session-42")
.getOrThrow()!!
val request = server.takeRequest()
assertEquals("/usage/providers?profile=victor&session_id=session-42", request.path)
assertEquals("Bearer paired-token", request.getHeader("Authorization"))
assertEquals("Codex", response.providers.single().displayName)
assertEquals(42.5, response.providers.single().windows.single().usedPercent!!, 0.001)
assertEquals("Work", response.providers.single().credentials.single().label)
assertTrue(response.providers.single().credentials.single().active)
assertTrue(response.relayEnhanced)
}
@Test
fun unsupportedHostIsNullSuccess() = runTest {
server.enqueue(MockResponse().setResponseCode(404))
val response = client(token = "paired-token").fetchProviderUsage()
assertTrue(response.isSuccess)
assertNull(response.getOrNull())
}
@Test
fun unpairedIsUnsupportedAndDoesNotHitServer() = runTest {
val response = client(token = null).fetchProviderUsage()
assertTrue(response.isSuccess)
assertNull(response.getOrNull())
assertEquals(0, server.requestCount)
}
private fun client(token: String?) = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { server.url("/").toString() },
sessionTokenProvider = { token },
)
}
@@ -0,0 +1,37 @@
package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.VoiceAudioRoute
import java.io.File
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class AutoVoiceAudioClientSupervisionTest {
@Test
fun `route override forces standard even when auto prefers ready relay`() = runTest {
val standard = FakeVoiceClient(VoiceAudioRoute.Standard, "standard")
val relay = FakeVoiceClient(VoiceAudioRoute.Relay, "relay")
val router = AutoVoiceAudioClient(
standardClient = standard,
relayClient = relay,
routeProvider = { VoiceAudioRoute.Auto },
standardReadyProvider = { true },
relayReadyProvider = { true },
)
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
router.setRouteOverride(VoiceAudioRoute.Standard)
assertEquals(VoiceAudioRoute.Standard, router.effectiveRoute)
assertEquals("standard", router.transcribe(File("voice.wav")).getOrThrow())
router.setRouteOverride(null)
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
}
private class FakeVoiceClient(
override val route: VoiceAudioRoute,
private val transcript: String,
) : VoiceAudioClient {
override suspend fun transcribe(audioFile: File): Result<String> = Result.success(transcript)
override suspend fun synthesize(text: String): Result<File> = Result.success(File("voice.mp3"))
}
}
@@ -126,6 +126,44 @@ class DashboardApiClientTest {
assertEquals(listOf("default", "worker"), status.gateways.single().servedProfiles)
}
@Test
fun getProviderUsage_carriesSessionAndParsesCredentialPool() = runTest {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""
{
"schema_version": 2,
"capabilities": ["credential_pools", "structured_balances", "opencode_go"],
"providers": [{
"id": "openai-codex",
"display_name": "Codex",
"status": "available",
"active_credential_state": "known",
"credentials": [{
"id": "abc123",
"label": "bailey",
"active": true,
"status": "available"
}]
}]
}
""".trimIndent(),
),
)
val usage = DashboardApiClient(baseUrl = server.url("/").toString())
.getProviderUsage(profile = "victor", sessionId = "session/42")
.getOrThrow()!!
val request = server.takeRequest().requestUrl!!
assertEquals("/api/plugins/hermes-relay/provider-usage", request.encodedPath)
assertEquals("victor", request.queryParameter("profile"))
assertEquals("session/42", request.queryParameter("session_id"))
assertEquals("bailey", usage.providers.single().credentials.single().label)
assertTrue(usage.providers.single().credentials.single().active)
assertTrue(usage.relayEnhanced)
}
@Test
fun getModelOptions_alwaysRequestsUnconfiguredProviders() = runTest {
// HRUI-022: newer upstream hides unconfigured provider skeleton rows
@@ -195,6 +195,11 @@ class GatewayClientHarness(
put("sessions", JsonArray(emptyList()))
}
@Volatile
var activeSessionListPayload: JsonObject = buildJsonObject {
put("sessions", JsonArray(emptyList()))
}
@Volatile
var profileCreatePayload: JsonObject = buildJsonObject {
put("ok", true)
@@ -316,6 +321,7 @@ class GatewayClientHarness(
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
)
"session.list" -> sessionListPayload
"session.active_list" -> activeSessionListPayload
"session.title" -> buildJsonObject { put("ok", true) }
"prompt.submit" -> promptSubmitPayload
"session.interrupt" -> buildJsonObject { put("ok", true) }
@@ -1514,6 +1520,112 @@ class GatewayChatClientTest {
assertTrue((refreshParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
}
@Test
fun `active session list parses every authoritative upstream state`() = runBlocking {
harness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray {
listOf("idle", "starting", "working", "waiting").forEachIndexed { index, status ->
add(buildJsonObject {
put("id", "runtime-$index")
put("session_key", "stored-$index")
put("status", status)
put("last_active", 1_774_000_000.25 + index)
})
}
})
}
val result = client.listActiveSessions()
val rows = (result as GatewayActiveSessionsResult.Success).sessions
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
assertEquals(
listOf(
GatewayActiveSessionStatus.Idle,
GatewayActiveSessionStatus.Starting,
GatewayActiveSessionStatus.Working,
GatewayActiveSessionStatus.Waiting,
),
rows.map(GatewayActiveSession::status),
)
assertEquals("runtime-2", rows[2].runtimeSessionId)
assertEquals("stored-2", rows[2].storedSessionId)
assertEquals(1_774_000_002.25, rows[2].lastActiveEpochSeconds, 0.0)
assertTrue(rows.all { it.profile == null })
}
@Test
fun `active session list treats empty successful snapshot as authoritative`() = runBlocking {
val result = client.listActiveSessions()
assertEquals(emptyList<GatewayActiveSession>(), (result as GatewayActiveSessionsResult.Success).sessions)
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
}
@Test
fun `active session list stays process wide and never synthesizes fixed profile`() = runBlocking {
val routeHarness = GatewayClientHarness()
routeHarness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray {
add(buildJsonObject {
put("id", "runtime-operator")
put("session_key", "stored-shared")
put("status", "working")
put("last_active", 1_774_000_000.0)
})
})
}
val routeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val routeClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = routeHarness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
fixedSessionProfile = "operator",
okHttpClient = OkHttpClient(),
callbackDispatcher = { it() },
scope = routeScope,
)
try {
val result = routeClient.listActiveSessions() as GatewayActiveSessionsResult.Success
val params = routeHarness.awaitRpc("session.active_list")
val requests = List(2) { routeHarness.server.takeRequest(5, TimeUnit.SECONDS) }
assertFalse(params.containsKey("profile"))
assertNull(result.sessions.single().profile)
assertTrue(requests.filterNotNull().any { it.path?.contains("profile=operator") == true })
} finally {
routeClient.shutdown()
routeScope.cancel()
routeHarness.shutdown()
}
}
@Test
fun `active session method not found is explicit and sticky for current socket`() = runBlocking {
harness.methodNotFound += "session.active_list"
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
assertEquals(GatewayActiveSessionCapability.Unsupported, client.activeSessionCapability.value)
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
}
@Test
fun `active session transient error stays distinct from unsupported`() = runBlocking {
harness.rpcErrors["session.active_list"] = 5036 to "could not enumerate active sessions"
val failed = client.listActiveSessions()
assertTrue(failed is GatewayActiveSessionsResult.TransientFailure)
assertEquals(GatewayActiveSessionCapability.Unknown, client.activeSessionCapability.value)
harness.rpcErrors.remove("session.active_list")
assertTrue(client.listActiveSessions() is GatewayActiveSessionsResult.Success)
assertEquals(2, harness.rpcLog.count { it.first == "session.active_list" })
}
@Test
fun `process list uses live session id and parses typed snapshot`() = runBlocking {
assertTrue(client.prewarmAwait("stored-session"))
@@ -4027,6 +4139,10 @@ class GatewayChatClientTest {
harness.awaitRpc("prompt.submit")
assertTrue(client.backgroundActiveTurn())
assertEquals(
GatewayKnownSessionOwner("20260612_120000_abc123", "coder"),
client.knownSessionOwner("live-1"),
)
client.clearSession()
client.sessionProfileProvider = { "writer" }
@@ -0,0 +1,26 @@
package com.hermesandroid.relay.network.usage
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ProviderUsageModelsTest {
@Test
fun completeRelayCapabilitySetIsEnhanced() {
assertTrue(
ProviderUsageResponse(
capabilities = ProviderUsageResponse.RELAY_ENHANCED_CAPABILITIES,
).relayEnhanced,
)
}
@Test
fun missingOrPartialCapabilitiesRemainBasic() {
assertFalse(ProviderUsageResponse().relayEnhanced)
assertFalse(
ProviderUsageResponse(
capabilities = setOf("credential_pools", "structured_balances"),
).relayEnhanced,
)
}
}
@@ -61,12 +61,14 @@ import com.hermesandroid.relay.data.AppearancePreferences
import com.hermesandroid.relay.data.DashboardConnectionStatus
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.PetBehaviorPreferences
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.ui.components.ChatInputBar
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
import com.hermesandroid.relay.ui.components.ChatInputTrailing
import com.hermesandroid.relay.ui.components.ContextMeterBar
import com.hermesandroid.relay.ui.components.ConversationVoiceDock
import com.hermesandroid.relay.ui.components.ConnectionStatusBadge
import com.hermesandroid.relay.ui.components.MessageBubble
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
@@ -137,9 +139,14 @@ class StoreScreenshotTest {
@get:Rule
val compose = createComposeRule()
private fun capture(name: String, themeId: String = "hermes-relay", body: @Composable () -> Unit) {
private fun capture(
name: String,
themeId: String = "hermes-relay",
themePreference: String = "dark",
body: @Composable () -> Unit,
) {
compose.setContent {
HermesRelayTheme(appThemeId = themeId, themePreference = "dark") {
HermesRelayTheme(appThemeId = themeId, themePreference = themePreference) {
// Adaptive is the app's real default skin (resolve("auto") -> Adaptive);
// it recolors to the active theme. The preview/test fallback is Classic,
// which mismatches the app and reads poorly on light themes.
@@ -183,6 +190,10 @@ class StoreScreenshotTest {
}
@Test fun s11_voice_conversation() {
compose.mainClock.autoAdvance = false
// RelayRefresh is a process-wide compatibility façade. Seed it before
// the first composition so this animation-pinned scene cannot inherit
// the palette left by a light-theme gallery test that ran earlier.
RelayRefresh.activePalette = AppThemes.byId("hermes-relay").paletteFor(dark = true)
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
CompositionLocalProvider(LocalSphereSkin provides SphereRegistry.Adaptive) {
@@ -246,6 +257,14 @@ class StoreScreenshotTest {
}
@Test fun s06_manage() = capture("06_manage", "hermes-relay") { ManageScene() }
@Test fun s04_sessions() = capture("04_sessions", "hermes-relay") { SessionsScene() }
@Test fun s12_session_activity_states() = capture("12_session_activity_states", "hermes-relay") {
SessionActivityStatesScene()
}
@Test fun s12_session_activity_states_light() = capture(
"12_session_activity_states_light",
"nous-blue",
themePreference = "light",
) { SessionActivityStatesScene() }
@Test fun s07_connections() = capture("07_connections", "hermes-relay") { ConnectionsScene() }
// Real Appearance screen scrolled to the new Font picker — proves the
// bundled Inter/Nunito faces load as visibly distinct previews (vs System).
@@ -280,7 +299,9 @@ class StoreScreenshotTest {
// The earlier frame scrolled past the pet controls and showed only the
// sphere-skin grid. Frame the independently selected floating companion,
// its real PetAvatar preview, Petdex/import actions, and tuning controls.
compose.onNodeWithText("Browse Petdex").performScrollTo()
compose.onNodeWithText(
"Scales the pet art, touch target, and safe routing footprint together. Larger pets may skip narrow perches.",
).performScrollTo()
compose.onRoot().captureRoboImage("build/store-shots/08_appearance.png")
}
@@ -361,8 +382,7 @@ private fun BlendChatScene() = StoreCockpit(contextUsage = 0.06f) {
}
@Composable
private fun BlendThread() {
val thread = MockChat.blendThread
private fun BlendThread(thread: List<ChatMessage> = MockChat.blendThread) {
Column(
Modifier.fillMaxSize().padding(start = 18.dp, top = 14.dp, end = 18.dp),
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.Bottom)
@@ -405,18 +425,40 @@ private fun StoreCockpit(
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(Modifier.size(34.dp), shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)) {
Image(painterResource(R.drawable.splash_icon), contentDescription = null, modifier = Modifier.padding(3.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Keep the Hermes logo as the public default agent identity,
// while matching ChatScreen's current live-status treatment.
Box(Modifier.size(40.dp)) {
Surface(
modifier = Modifier.size(40.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
) {
Image(
painter = painterResource(R.drawable.splash_icon),
contentDescription = null,
modifier = Modifier.padding(3.dp),
)
}
ConnectionStatusBadge(
isConnected = true,
isConnecting = false,
modifier = Modifier.size(10.dp).align(Alignment.BottomEnd),
size = 10.dp,
)
}
Column(Modifier.padding(start = 10.dp)) {
Column {
Text("Hermes", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("gpt-5.6-sol", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}
},
actions = {
RelayChromeIconButton(Icons.Filled.Bolt, "Approvals off", onClick = {}, tint = RelayRefresh.Amber, borderColor = RelayRefresh.Amber.copy(alpha = 0.5f), modifier = Modifier.padding(end = 4.dp))
// The production bolt appears only when approval bypass is
// active. The public marketing fixture uses the safe default.
RelayChromeIconButton(Icons.Filled.Code, "Terminal", onClick = {}, modifier = Modifier.padding(end = 4.dp))
RelayChromeIconButton(Icons.Filled.Tune, "Settings", onClick = {}, modifier = Modifier.padding(end = 4.dp))
RelayChromeIconButton(Icons.Filled.MoreVert, "More", onClick = {}, modifier = Modifier.padding(end = 4.dp))
@@ -534,7 +576,10 @@ private fun VoiceConversationScene() = StoreCockpit(
contextUsage = 0.05f,
conversationVoiceState = marketingVoiceUiState,
) {
BlendThread()
// The voice dock occupies more vertical space than the standard composer.
// Use its shorter one-line lead-in so the frame starts near the context
// meter while the final user reply remains fully visible.
BlendThread(MockChat.voiceBlendThread)
}
/** The real sphere renderer pinned to one frame for pixel-identical marketing output. */
@@ -687,9 +732,18 @@ private object MockChat {
),
)
// A grouped thread for the "Blend" capture: user → two-message assistant
// group (avatar once, code block in the first) → user follow-up.
// A grouped thread for the "Blend" capture. The short opening exchange
// intentionally fills the production conversation viewport so the first
// turn begins directly below the context meter instead of leaving a large,
// misleading empty band in the canonical marketing frame.
val blendThread = listOf(
ChatMessage(
id = "ba0",
role = MessageRole.ASSISTANT,
content = "I’ll keep cancellation and offline behavior intact. Show me the retry path.",
timestamp = 0L,
agentName = "Hermes",
),
ChatMessage(
id = "bu1",
role = MessageRole.USER,
@@ -728,6 +782,15 @@ private object MockChat {
timestamp = 0L,
),
)
val voiceBlendThread = listOf(
ChatMessage(
id = "voice-lead",
role = MessageRole.USER,
content = "Can you review this?",
timestamp = 0L,
),
) + blendThread.drop(1)
}
// ════════════════════════════════════════════════════════════════════════════
@@ -756,6 +819,41 @@ private fun SessionsScene() {
}
}
@Composable
private fun SessionActivityStatesScene() {
val states = SessionActivityState.entries
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim)) {
SessionDrawerContent(
sessions = states.mapIndexed { index, state ->
ChatSession(
sessionId = "activity-$index",
title = when (state) {
SessionActivityState.Starting -> "Launching the agent"
SessionActivityState.Working -> "Reviewing the release"
SessionActivityState.NeedsInput -> "Approval required"
SessionActivityState.BackgroundWork -> "Build still running"
SessionActivityState.Checking -> "Reconnecting to Hermes"
SessionActivityState.Unavailable -> "Offline session"
},
model = "gpt-5.6-sol",
)
},
currentSessionId = null,
activeProfileName = "default",
scopeTitle = "Hermes",
scopeSubtitle = "Live session status",
activityStates = states.mapIndexed { index, state ->
"default:activity-$index" to state
}.toMap(),
animationEnabled = false,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
@Composable
private fun ConnectionsScene() = ConnectionsSettingsScreen(
connections = marketingConnections,
@@ -0,0 +1,57 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.SupervisedAppearance
import com.hermesandroid.relay.data.SupervisedModePolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedAppearancePolicyTest {
private val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
appearance = SupervisedAppearance(
appThemeId = "rose",
themePreference = "dark",
showPet = false,
),
)
@Test fun `locked supervised root uses only its own theme`() {
val resolved = resolveSupervisedTheme(policy, false, "midnight", "light")
assertEquals("rose", resolved.appThemeId)
assertEquals("dark", resolved.themePreference)
assertFalse(resolved.useGlobalCustomTheme)
}
@Test fun `parent access restores ordinary app theme`() {
val resolved = resolveSupervisedTheme(policy, true, "midnight", "light")
assertEquals("midnight", resolved.appThemeId)
assertEquals("light", resolved.themePreference)
assertTrue(resolved.useGlobalCustomTheme)
}
@Test fun `pet visibility follows supervised policy only while locked`() {
assertFalse(shouldShowPetInSupervisedMode(policy, false))
assertTrue(shouldShowPetInSupervisedMode(policy, true))
assertTrue(
shouldShowPetInSupervisedMode(
policy.copy(appearance = policy.appearance.copy(showPet = true)),
false,
),
)
}
@Test fun `enabled recovery policy stays on restricted appearance defaults`() {
val recovery = SupervisedModePolicy(enabled = true)
val resolved = resolveSupervisedTheme(recovery, false, "rose", "dark")
assertEquals("hermes-relay", resolved.appThemeId)
assertEquals("auto", resolved.themePreference)
assertFalse(resolved.useGlobalCustomTheme)
assertFalse(shouldShowPetInSupervisedMode(recovery, false))
}
}
@@ -0,0 +1,121 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.SupervisedCapabilities
import com.hermesandroid.relay.data.SupervisedModePolicy
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupervisedNavigationPolicyTest {
@Test fun `locked surface permits only approved destinations`() {
assertTrue(isSupervisedRouteAllowed("chat?sessionId=owned", false))
assertTrue(isSupervisedRouteAllowed("settings", false))
assertTrue(isSupervisedRouteAllowed(Screen.SupervisedAppearanceSettings.route, false))
// The full Appearance destination includes profile/avatar/pet controls.
// The supervised Settings root owns its own allowlisted theme controls.
assertFalse(isSupervisedRouteAllowed("settings/appearance", false))
assertFalse(isSupervisedRouteAllowed("settings/about", false))
assertFalse(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, false))
assertFalse(isSupervisedRouteAllowed("manage", false))
assertFalse(isSupervisedRouteAllowed("settings/developer", false))
assertFalse(isSupervisedRouteAllowed("settings/supervised", false))
assertFalse(isSupervisedRouteAllowed(null, false))
}
@Test fun `parent unlock permits full navigation`() {
assertTrue(isSupervisedRouteAllowed("manage", true))
assertTrue(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, true))
}
@Test fun `supervised redirect waits until the navigation graph has a route`() {
assertFalse(shouldRedirectSupervisedRoute(true, false, null))
assertTrue(isSupervisedRouteContentAllowed(true, false, null))
assertFalse(shouldRedirectSupervisedRoute(true, false, Screen.Chat.route))
assertTrue(isSupervisedRouteContentAllowed(true, false, Screen.Chat.route))
assertTrue(shouldRedirectSupervisedRoute(true, false, Screen.AdvancedSettings.route))
assertFalse(isSupervisedRouteContentAllowed(true, false, Screen.AdvancedSettings.route))
assertFalse(shouldRedirectSupervisedRoute(true, true, Screen.AdvancedSettings.route))
assertTrue(isSupervisedRouteContentAllowed(true, true, Screen.AdvancedSettings.route))
}
@Test fun `navigation waits for connection store before trusting null active id`() {
assertFalse(isRelayNavigationHydrated(false, null, false))
assertTrue(isRelayNavigationHydrated(true, null, false))
assertFalse(isRelayNavigationHydrated(true, "home", false))
assertTrue(isRelayNavigationHydrated(true, "home", true))
}
@Test fun `parent access relocks as soon as chat becomes current`() {
assertTrue(shouldRelockParentAccess(true, true, "chat?sessionId=ignored"))
assertFalse(shouldRelockParentAccess(true, true, "settings/supervised"))
assertFalse(shouldRelockParentAccess(false, true, "chat"))
assertFalse(shouldRelockParentAccess(true, false, "chat"))
}
@Test fun `supervised route session requires history pinned profile and trusted ownership proof`() {
val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(conversationHistory = true),
)
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "willow", false))
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "parent", true))
assertTrue(mayRestoreSupervisedSessionRoute(policy, "session-1", "WILLOW", true))
assertFalse(
mayRestoreSupervisedSessionRoute(
policy.copy(capabilities = policy.capabilities.copy(conversationHistory = false)),
"session-1",
"willow",
true,
),
)
}
@Test fun `supervised external route discards session profile and proactive targets`() {
val policy = SupervisedModePolicy(
enabled = true,
pinnedProfileName = "willow",
capabilities = SupervisedCapabilities(conversationHistory = true),
)
val external = SupervisedChatRouteArgs(
sessionId = "parent-session",
profile = "willow",
proactiveChatId = "phone",
)
assertTrue(
sanitizeSupervisedChatRouteArgs(policy, external, false) ==
SupervisedChatRouteArgs(),
)
assertTrue(
sanitizeSupervisedChatRouteArgs(policy, external, true) ==
external.copy(proactiveChatId = null),
)
assertTrue(
sanitizeSupervisedChatRouteArgs(SupervisedModePolicy(), external, false) == external,
)
}
@Test fun `first enable requires configured policy secure screen and successful device credential`() {
val configured = SupervisedModePolicy(pinnedProfileName = "willow")
assertFalse(
mayEnableSupervisedMode(
configured,
deviceSecure = false,
deviceCredentialConfirmed = true,
),
)
assertFalse(
mayEnableSupervisedMode(
configured,
deviceSecure = true,
deviceCredentialConfirmed = false,
),
)
assertFalse(mayEnableSupervisedMode(SupervisedModePolicy(), true, true))
assertTrue(mayEnableSupervisedMode(configured, true, true))
assertFalse(mayEnableSupervisedMode(configured.copy(enabled = true), true, true))
}
}
@@ -0,0 +1,44 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Test
class MorphingSphereMotionPolicyTest {
@Test
fun `visible idle sphere uses lightweight ambient motion`() {
assertEquals(
SphereMotionMode.AmbientLayer,
sphereMotionMode(
state = SphereState.Idle,
voiceMode = false,
motionVisible = true,
fixedTime = null,
fixedColorPhase = null,
),
)
}
@Test
fun `hidden or paused idle sphere is still`() {
assertEquals(
SphereMotionMode.Still,
sphereMotionMode(SphereState.Idle, false, false, null, null),
)
assertEquals(
SphereMotionMode.Still,
sphereMotionMode(SphereState.Idle, false, true, 0f, 0f),
)
}
@Test
fun `visible active and voice states keep procedural motion`() {
assertEquals(
SphereMotionMode.Procedural,
sphereMotionMode(SphereState.Thinking, false, true, null, null),
)
assertEquals(
SphereMotionMode.Procedural,
sphereMotionMode(SphereState.Idle, true, true, null, null),
)
}
}
@@ -1,8 +1,11 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionDrawerPolicyTest {
@@ -19,6 +22,138 @@ class SessionDrawerPolicyTest {
assertEquals("alpha:same", sessionRowKey(alpha))
assertEquals("beta:same", sessionRowKey(beta))
assertEquals("default:same", sessionRowKey(row("default", "same")))
}
@Test
fun `rest recent activity alone does not mark a session working`() {
val recentlyActive = row("default", "recent", recentlyActive = true)
assertEquals(
SessionDrawerStatus.Idle,
sessionDrawerStatus(recentlyActive, activityStates = emptyMap()),
)
}
@Test
fun `duplicate session ids cannot leak activity across profiles`() {
val alpha = row("alpha", "same")
val beta = row("beta", "same")
val scoped = scopedSessionActivityStates(
rows = listOf(alpha, beta),
activityStates = mapOf(sessionRowKey(alpha) to SessionActivityState.Working),
allowBareSessionIds = false,
)
assertEquals(SessionDrawerStatus.Working, sessionDrawerStatus(alpha, scoped))
assertEquals(SessionDrawerStatus.Idle, sessionDrawerStatus(beta, scoped))
assertFalse(sessionRowKey(beta) in scoped)
}
@Test
fun `all profiles ignores ambiguous bare session activity`() {
val alpha = row("alpha", "same")
val beta = row("beta", "same")
val scoped = scopedSessionActivityStates(
rows = listOf(alpha, beta),
activityStates = mapOf("same" to SessionActivityState.Working),
allowBareSessionIds = false,
)
assertEquals(emptyMap<String, SessionActivityState>(), scoped)
}
@Test
fun `selected profile may scope legacy bare session activity`() {
val row = row("work", "session")
val scoped = scopedSessionActivityStates(
rows = listOf(row),
activityStates = mapOf("session" to SessionActivityState.NeedsInput),
allowBareSessionIds = true,
)
assertEquals(
mapOf(sessionRowKey(row) to SessionActivityState.NeedsInput),
scoped,
)
}
@Test
fun `status filter and grouping use the same authoritative state`() {
val restOnly = row("default", "rest-only", recentlyActive = true)
val working = row("default", "working")
val states = mapOf(sessionRowKey(working) to SessionActivityState.Working)
val filtered = filterAndSortSessionRows(
rows = listOf(restOnly, working),
options = SessionDrawerViewOptions(statuses = setOf(SessionDrawerStatus.Working)),
activityStates = states,
)
val grouped = groupSessionRows(
rows = listOf(restOnly, working),
grouping = SessionDrawerGrouping.Status,
activityStates = states,
)
assertEquals(listOf("working"), filtered.map { it.session.sessionId })
assertEquals(listOf("Idle", "Working"), grouped.mapNotNull { it.label })
}
@Test
fun `expanded live phases retain distinct drawer statuses and labels`() {
val phases = listOf(
SessionActivityState.NeedsInput to (SessionDrawerStatus.NeedsInput to "Needs input"),
SessionActivityState.Starting to (SessionDrawerStatus.Starting to "Starting"),
SessionActivityState.Working to (SessionDrawerStatus.Working to "Working"),
SessionActivityState.BackgroundWork to (SessionDrawerStatus.BackgroundWork to "Background work"),
SessionActivityState.Checking to (SessionDrawerStatus.Checking to "Checking"),
SessionActivityState.Unavailable to (SessionDrawerStatus.Unavailable to "Unavailable"),
)
val rows = phases.mapIndexed { index, _ -> row("default", "session-$index") }
val states = rows.zip(phases).associate { (row, phase) -> sessionRowKey(row) to phase.first }
assertEquals(
phases.map { it.second.first },
rows.map { sessionDrawerStatus(it, states) },
)
assertEquals(
phases.map { it.second.second },
groupSessionRows(rows, SessionDrawerGrouping.Status, states).mapNotNull { it.label },
)
assertEquals(
listOf(
R.string.drawer_activity_needs_input,
R.string.drawer_activity_starting,
R.string.drawer_activity_working,
R.string.drawer_activity_background_work,
R.string.drawer_activity_checking,
R.string.drawer_activity_unavailable,
),
phases.map { sessionActivityLabelResource(it.first) },
)
phases.forEachIndexed { index, phase ->
assertEquals(
listOf("session-$index"),
filterAndSortSessionRows(
rows = rows,
options = SessionDrawerViewOptions(statuses = setOf(phase.second.first)),
activityStates = states,
).map { it.session.sessionId },
)
}
}
@Test
fun `full row border is limited to foreground live work`() {
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Starting))
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Working))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.NeedsInput))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.BackgroundWork))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Checking))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Unavailable))
assertFalse(sessionActivityShowsRowBorder(null))
}
@Test
@@ -113,6 +248,7 @@ class SessionDrawerPolicyTest {
outputTokens: Int = 0,
cost: Double? = null,
updatedAt: Long = 0L,
recentlyActive: Boolean = false,
) = ProfileSessionRow(
profile = profile,
session = ChatSession(
@@ -126,6 +262,7 @@ class SessionDrawerPolicyTest {
outputTokens = outputTokens,
actualCostUsd = cost,
lastActivityAt = updatedAt,
recentlyActive = recentlyActive,
),
)
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
import org.junit.Rule
import org.junit.Test
@@ -359,6 +360,66 @@ class SessionDrawerTest {
compose.onNodeWithText("Filters").assertIsDisplayed()
}
@Test
fun `drawer renders every authoritative activity phase distinctly`() {
val states = SessionActivityState.entries
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = states.mapIndexed { index, _ ->
ChatSession("session-$index", "Session $index", null)
},
currentSessionId = null,
activeProfileName = "default",
activityStates = states.mapIndexed { index, state ->
"default:session-$index" to state
}.toMap(),
animationEnabled = false,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
listOf(
"Starting",
"Working",
"Needs input",
"Background work",
"Checking",
"Unavailable",
).forEach { label ->
compose.onNodeWithText(label).performScrollTo().assertIsDisplayed()
}
}
@Test
fun `rest recency alone renders no working badge`() {
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = listOf(
ChatSession(
"recent",
"Recently updated",
null,
recentlyActive = true,
),
),
currentSessionId = null,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("Working").assertDoesNotExist()
}
@Test
fun `all profiles customization can override a profile identity color`() {
var changed: Pair<String, String?>? = null
@@ -0,0 +1,57 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.data.ChatSession
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class SessionDrawerTimestampTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun `distinct activity is labeled updated`() {
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
assertEquals(
"Updated Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
}
@Test
fun `relative timestamp changes when the drawer clock advances`() {
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
assertEquals(
"Updated Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
assertEquals(
"Updated 2m ago",
sessionTimestampText(session, Locale.US, context, nowMillis = 240_000L),
)
}
@Test
fun `session without later activity keeps started label`() {
val session = session(startedAt = 120_000L, lastActivityAt = 120_000L)
assertEquals(
"Started Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
}
private fun session(startedAt: Long, lastActivityAt: Long) = ChatSession(
sessionId = "session",
title = "Session",
model = null,
startedAt = startedAt,
lastActivityAt = lastActivityAt,
)
}

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