Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d80b3db329 | ||
|
|
834763a213 | ||
|
|
8ef6b794b2 | ||
|
|
17d8a2374a | ||
|
|
274ae11160 | ||
|
|
9cebdb67e6 | ||
|
|
ee300b3b15 | ||
|
|
2b75577fcf | ||
|
|
ff2f94922b | ||
|
|
292cc94eb4 | ||
|
|
621081b633 | ||
|
|
da8e822b66 | ||
|
|
0015a913f1 | ||
|
|
0f47e464f3 | ||
|
|
2411cf19b3 | ||
|
|
51b4edd52a | ||
|
|
d3c4e90696 | ||
|
|
cd7cad03ec | ||
|
|
bea3021ef0 | ||
|
|
00a9a7efcb | ||
|
|
7a97b71844 | ||
|
|
ae51ae3ab1 | ||
|
|
1bdcab8fc0 | ||
|
|
0c77d010fa | ||
|
|
dc18209c3c | ||
|
|
69347adb34 | ||
|
|
43a809e41a | ||
|
|
a5cc0104bf | ||
|
|
86a0bebc0d | ||
|
|
04d9421c74 | ||
|
|
14401aa3c3 | ||
|
|
99897274c6 | ||
|
|
20c5b690a8 | ||
|
|
dc86c043bc | ||
|
|
d5cce4e390 | ||
|
|
ae9b22a9e6 | ||
|
|
96a9e8077e | ||
|
|
a97e6a2b14 | ||
|
|
4317da85fd | ||
|
|
45fde0ad9a | ||
|
|
94565e9d6d | ||
|
|
56c2e6fa07 | ||
|
|
28629f3d93 | ||
|
|
c9a5c767c6 | ||
|
|
0d1faf47a0 | ||
|
|
00288a2b3b | ||
|
|
8f52feffba | ||
|
|
524e319f95 | ||
|
|
647d1f9aea | ||
|
|
ee29e49361 | ||
|
|
e2073b7692 | ||
|
|
70b6d8ee5a | ||
|
|
1f5e50ccd7 | ||
|
|
5580c9d9bb | ||
|
|
2ebdf55501 | ||
|
|
ad107ea205 | ||
|
|
f5aeb27e5a | ||
|
|
fdaeb121d5 | ||
|
|
06c0df6304 | ||
|
|
71a2b3a7fb | ||
|
|
08545ed32d | ||
|
|
e791c6410b | ||
|
|
8c8c3975f2 | ||
|
|
41601d67ab | ||
|
|
366b424615 | ||
|
|
5cd9baaaab | ||
|
|
8acba9b353 | ||
|
|
26a612f088 | ||
|
|
65e48084cb | ||
|
|
1074ecc24f | ||
|
|
6dd6ce2d13 | ||
|
|
f2a23e32aa | ||
|
|
b60c5d9eeb | ||
|
|
9e201e54d7 | ||
|
|
630cc6d316 | ||
|
|
8bb503eb6d | ||
|
|
c223dc690d | ||
|
|
e16205d82a | ||
|
|
44e3bb75cd | ||
|
|
4834fcbdf5 | ||
|
|
1cec79517e | ||
|
|
676c37e5ca | ||
|
|
957be876a0 | ||
|
|
6b32c7aeef | ||
|
|
29706e1548 | ||
|
|
6579b621ff | ||
|
|
9b6fed9bdd | ||
|
|
4d90eef3d8 | ||
|
|
befe8399ab | ||
|
|
c10b87b94c | ||
|
|
478323893a | ||
|
|
5e9d8840ae | ||
|
|
accf464911 | ||
|
|
b26c2cc2a1 | ||
|
|
28e0c34227 | ||
|
|
35e95da6a7 | ||
|
|
484bfdc5dc | ||
|
|
fcddeeb810 | ||
|
|
49002b7141 | ||
|
|
326eb47df3 | ||
|
|
c7c24b2874 | ||
|
|
3e8e0728db | ||
|
|
1658439d05 | ||
|
|
ef1abdae3f | ||
|
|
eece12a815 | ||
|
|
0cdea3ad33 | ||
|
|
a8ca61297d | ||
|
|
5762cdf8af | ||
|
|
dff633c902 | ||
|
|
45d8a73609 | ||
|
|
390a4dd8d8 | ||
|
|
90ab705a88 | ||
|
|
054aab1c09 | ||
|
|
bae1762951 | ||
|
|
da7ea8ffe0 | ||
|
|
5c5c55d982 | ||
|
|
0208098687 | ||
|
|
34fc4c4693 |
@@ -0,0 +1,47 @@
|
||||
## Summary
|
||||
|
||||
<!-- Describe the user/developer outcome and why this change is needed. Link related issues, e.g. Closes #123. -->
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- List the focused implementation changes. Avoid repeating the commit log. -->
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
<!-- List exact commands and results. Say explicitly when a check was not run or when device/UI proof remains outstanding. -->
|
||||
|
||||
-
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- UI change: include screenshots/video plus device, viewport, or emulator details. Otherwise write: No visual change. -->
|
||||
|
||||
## Compatibility / risk
|
||||
|
||||
<!-- Note Standard Hermes/upstream compatibility, migrations/state changes, security/privacy impact, rollout/rollback, or write N/A. -->
|
||||
|
||||
## Lineage / contributor credit
|
||||
|
||||
<!-- Preserve prior contributor work when replacing, salvaging, or rebuilding another PR. -->
|
||||
|
||||
- Source PR(s): N/A
|
||||
- Attribution preserved by: N/A
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Check an item when satisfied or when its N/A rationale is stated above. -->
|
||||
|
||||
- [ ] Target branch is `dev`, unless this is a `dev` → `main` release PR or a focused production-tag hotfix PR to `main`
|
||||
- [ ] Scope is focused and related issues/PRs are linked
|
||||
- [ ] Android changes: lint and focused tests ran, or rationale is listed above
|
||||
- [ ] Translation changes: locale validation/review ran, or N/A is listed above
|
||||
- [ ] Server/plugin changes: focused tests ran, or N/A/rationale is listed above
|
||||
- [ ] Desktop changes: build/tests ran, or N/A/rationale is listed above
|
||||
- [ ] Docs/site changes: build or link/route checks ran, or N/A/rationale is listed above
|
||||
- [ ] UI changes were tested on a relevant device/emulator/desktop surface, or the missing proof is stated above
|
||||
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [ ] `CHANGELOG.md` is updated for user-visible changes, or N/A is listed above
|
||||
- [ ] Public writing hygiene checked: no secrets, private infrastructure, personal names, or AI/process narration
|
||||
- [ ] Salvaged/replacement work links source PRs and preserves contributor authorship, or N/A is listed above
|
||||
@@ -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)));
|
||||
@@ -13,7 +16,8 @@ function classifyCiPaths(paths) {
|
||||
android: forceAll || under(['app/', 'relay-core/', 'relay-ui/', 'ui-preview/', 'quest/', 'gradle/']) || exact([
|
||||
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
|
||||
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
|
||||
'scripts/check-android-collection-apis.py', '.github/workflows/ci-android.yml',
|
||||
'scripts/check-android-collection-apis.py', 'scripts/check-android-release-notes.py',
|
||||
'scripts/tests/check_android_release_notes_test.py', '.github/workflows/ci-android.yml',
|
||||
'.github/workflows/play-preflight-android.yml',
|
||||
'.github/workflows/approve-release-android.yml',
|
||||
'.github/workflows/release-android.yml',
|
||||
|
||||
@@ -15,6 +15,7 @@ const none = {
|
||||
assert.deepEqual(classifyCiPaths(['README.md']), none);
|
||||
assert.deepEqual(classifyCiPaths(['desktop/src/cli.ts']), { ...none, desktop: true });
|
||||
assert.deepEqual(classifyCiPaths(['relay-core/src/main/kotlin/Wire.kt']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['scripts/check-android-release-notes.py']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['plugin/relay/server.py']), { ...none, plugin: true });
|
||||
assert.deepEqual(classifyCiPaths(['plugin/dashboard/src/App.tsx']), { ...none, dashboard: true });
|
||||
assert.deepEqual(classifyCiPaths(['user-docs/index.md']), { ...none, docs: true });
|
||||
@@ -30,5 +31,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.');
|
||||
|
||||
@@ -33,6 +33,8 @@ on:
|
||||
- "scripts/check-android-locales.py"
|
||||
- "scripts/android-locale-harness.py"
|
||||
- "scripts/check-android-collection-apis.py"
|
||||
- "scripts/check-android-release-notes.py"
|
||||
- "scripts/tests/check_android_release_notes_test.py"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
- ".github/workflows/play-preflight-android.yml"
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
@@ -73,6 +75,11 @@ jobs:
|
||||
- name: Reject unsafe Android collection APIs
|
||||
run: python3 scripts/check-android-collection-apis.py
|
||||
|
||||
- name: Validate Android release notes
|
||||
run: |
|
||||
python3 scripts/check-android-release-notes.py
|
||||
python3 -m unittest scripts.tests.check_android_release_notes_test
|
||||
|
||||
- name: Run Android lint
|
||||
run: ./gradlew lint --console=plain
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -38,6 +38,10 @@ jobs:
|
||||
working-directory: plugin/dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Test dashboard source
|
||||
working-directory: plugin/dashboard
|
||||
run: npm test
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
@@ -54,7 +58,11 @@ jobs:
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx requests
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
run: >-
|
||||
python -m unittest
|
||||
plugin.dashboard.test_plugin_api
|
||||
plugin.dashboard.test_git_api
|
||||
plugin.dashboard.test_mobile_plugin_api
|
||||
|
||||
- name: Verify dashboard bundle outputs
|
||||
run: |
|
||||
|
||||
@@ -106,4 +106,8 @@ jobs:
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py \
|
||||
plugin/tests/test_profile_discovery.py \
|
||||
plugin/tests/test_profiles_updated_broadcast.py
|
||||
plugin/tests/test_profiles_updated_broadcast.py \
|
||||
plugin/tests/test_git_state.py \
|
||||
plugin/tests/test_git_state_write.py \
|
||||
plugin/tests/test_git_state_extras.py \
|
||||
plugin/tests/test_mobile_plugin_store.py
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -81,10 +81,10 @@ jobs:
|
||||
- name: Validate release metadata and source compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-android-release-notes.py
|
||||
python3 scripts/check-privacy-policy.py --live
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
|
||||
|
||||
- name: Decode release keystore
|
||||
env:
|
||||
|
||||
@@ -150,6 +150,7 @@ jobs:
|
||||
- name: Validate release metadata and Android API compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-android-release-notes.py
|
||||
python3 scripts/check-privacy-policy.py
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
@@ -365,3 +366,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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -94,6 +102,40 @@ a staging branch.
|
||||
- Prioritize findings that warrant holding the merge. State the impacted path
|
||||
and the concrete failure mode.
|
||||
|
||||
## Automated public issue triage
|
||||
|
||||
New public issues may receive one clearly labeled **Hermes-Relay automated
|
||||
triage** reply. That first response may classify the report with existing
|
||||
type/area labels, point to related issues or current code/docs, ask for safe
|
||||
sanitized diagnostics, and flag the thread for maintainer review.
|
||||
|
||||
GitHub attributes that reply to the repository-scoped
|
||||
`hermes-relay-triage[bot]` App, never to a maintainer's personal account.
|
||||
|
||||
The automated lane may assign only the fixed maintainer account `Codename-11`
|
||||
as follow-up ownership; that assignment does not imply acceptance, priority,
|
||||
implementation, or a release commitment. It never closes, milestones,
|
||||
prioritizes, promises a fix/release/timeline, chooses another assignee, or
|
||||
continues replying after its first response. A related issue is not
|
||||
automatically a duplicate. Human maintainer comments and decisions remain
|
||||
authoritative; read the complete live thread before acting on an issue.
|
||||
|
||||
## Automated public PR intake
|
||||
|
||||
New external-contributor, non-draft pull requests may receive one clearly
|
||||
labeled **Hermes-Relay automated PR intake** reply from
|
||||
`hermes-relay-triage[bot]`. Owner-authored `Codename-11` PRs and bot PRs are
|
||||
dropped before model dispatch. For eligible PRs, the intake compares the live PR
|
||||
metadata/body and changed-path list with trusted `origin/dev` policy and
|
||||
`.github/pull_request_template.md` without checking out or executing contributor
|
||||
code. It may add genuine area labels plus `documentation`, `ci`, or
|
||||
`needs-maintainer-review` and point out missing intake evidence.
|
||||
|
||||
The automated lane never approves, requests changes, merges, closes, assigns,
|
||||
requests reviewers, milestones, prioritizes, pushes commits, edits PR text,
|
||||
reruns workflows, applies `review-candidate`, or claims code correctness. Human
|
||||
maintainer review and CI remain authoritative.
|
||||
|
||||
## Public-repo writing hygiene
|
||||
|
||||
Everything committed is public. In CHANGELOG, DEVLOG, README, docs, and release
|
||||
|
||||
@@ -8,17 +8,88 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Added
|
||||
|
||||
- **Android presents Relay Git as a first-class native workspace.** A compact optional Chat rail opens repository status, line totals, filters, diffs, branches, staging, commits, and remotes; the full workspace remains available from Settings when Chat controls are hidden.
|
||||
- **Hermes-Relay Plugin provides a bounded Git workspace API for authenticated Dashboard clients.** Configured repository roots, path validation, tracked line totals, scoped write grants, and explicit confirmation protect repository reads and mutations.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Android What's New leads with one curated release highlight without interrupting startup.** A timed post-update toast can be swiped or closed, previews additional feature/fix counts when a release has meaningful secondary items, expands into the centered highlight view on request, and keeps the full technical history available. Each release can present one plain-language summary, up to three primary benefits, and up to two quieter improvements, while release checks keep the structured entry, fallback, Play copy, and public release records aligned.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Opening Android no longer claims or interrupts a turn already running in Hermes Desktop/TUI.** Passive foreground and session browsing now use read-only Gateway status plus profile-scoped history; live-session resume remains reserved for explicit Android actions and exact Android-owned recovery.
|
||||
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Android Chat no longer includes the hidden clean-focus presentation.** The long-press gesture, overlapping instructional pill, reduced composer, and alternate fading transcript were removed so Chat keeps one complete interaction model. Voice Focus remains available.
|
||||
|
||||
## [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 +145,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -104,6 +104,24 @@ The legacy `relay_server/` directory is a thin compatibility shim around `plugin
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, signed APK artifacts) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 36 |
|
||||
|
||||
## Issues and automated triage
|
||||
|
||||
New issues may receive one first response headed **Hermes-Relay automated
|
||||
triage**. It reads the live report against current code, documentation, related
|
||||
issues, and public release state; it may add existing type/area labels and ask
|
||||
for a focused, safe diagnostic such as the app version, interaction mode, or a
|
||||
sanitized log excerpt.
|
||||
|
||||
GitHub displays the response as authored by `hermes-relay-triage[bot]`, a
|
||||
repository-scoped App rather than a maintainer's personal account.
|
||||
|
||||
That reply is an acknowledgement and initial analysis, not a maintainer
|
||||
decision. The automated path may assign `Codename-11` as the fixed owner for
|
||||
follow-up, but assignment does not mean acceptance, priority, implementation,
|
||||
or a release commitment. It does not close issues, choose another assignee, set
|
||||
milestones or priority, promise a fix or release, or continue the conversation.
|
||||
A maintainer will follow up on the thread.
|
||||
|
||||
## Running the Relay Locally
|
||||
|
||||
Only needed if you're working on the bridge, voice, notifications, or media features. Chat alone doesn't need the relay.
|
||||
@@ -145,6 +163,23 @@ documentation fixes.
|
||||
`main` is release history, not the normal contribution target; it receives
|
||||
approved release PRs from `dev` and focused hotfix PRs based on production tags.
|
||||
|
||||
Pull requests use [the repository template](.github/pull_request_template.md).
|
||||
Keep the body grounded: describe the outcome and focused changes, list exact
|
||||
verification, include visual evidence when applicable, state compatibility or
|
||||
risk, and preserve contributor lineage when replacing or salvaging prior work.
|
||||
Check an item when it is satisfied or when its N/A rationale is written in the
|
||||
body; do not use checked boxes as a substitute for evidence.
|
||||
|
||||
New external-contributor, non-draft pull requests may receive one
|
||||
**Hermes-Relay automated PR intake** reply from `hermes-relay-triage[bot]`.
|
||||
Owner-authored `Codename-11` PRs and bot PRs skip this lane. For eligible PRs,
|
||||
the bot checks the live body, base branch, changed-path areas, template
|
||||
completeness, stated verification, visual proof, and lineage without checking
|
||||
out or executing contributor code. It may add bounded area/intake labels and
|
||||
identify missing evidence, but it does not review code correctness, approve,
|
||||
request changes, merge, close, assign, request reviewers, push commits, edit the
|
||||
PR, rerun workflows, or select review bundles.
|
||||
|
||||
`origin/dev` is the canonical integration ref. Keep local `dev` as a clean,
|
||||
fast-forward-only mirror and create each task in its own branch/worktree from the
|
||||
current `origin/dev`. Do not accumulate unpublished commits on local `dev`. If a
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-08-27 — Fixed issue ownership and bounded PR intake
|
||||
|
||||
The automated issue first-response lane now assigns only `Codename-11` when the
|
||||
maintainer is absent. The assignee is fixed in the write wrapper rather than
|
||||
chosen by webhook or model output, and represents follow-up ownership only—not
|
||||
acceptance, priority, implementation, or a release promise.
|
||||
|
||||
A separate pull-request route now handles external-contributor, non-bot,
|
||||
non-draft `opened`, `reopened`, and `ready_for_review` events. Owner-authored
|
||||
`Codename-11` PRs are dropped before model dispatch. Eligible PRs are read from
|
||||
live metadata plus trusted `dev` policy/template without executing contributor
|
||||
code; the route applies only bounded area/intake labels and posts one
|
||||
App-authored intake reply. It cannot approve, request changes, merge, close,
|
||||
assign, request reviewers, push, edit PR text, rerun CI, select
|
||||
`review-candidate`, or claim correctness. The external-contributor production
|
||||
canary retained its exact head/base/open state and proved marker idempotency.
|
||||
|
||||
## 2026-08-26 — Canonical pull request intake contract
|
||||
|
||||
The repository now supplies one pull request template derived from the structure
|
||||
already used by successful Android, plugin, desktop, docs, release, and salvage
|
||||
PRs. It asks for a concise summary, focused changes, exact verification, visual
|
||||
evidence when applicable, compatibility/risk notes, contributor lineage, and the
|
||||
existing cross-surface checklist.
|
||||
|
||||
Checkboxes require evidence or an explicit N/A rationale. The template does not
|
||||
turn every surface into a mandatory test lane, does not replace CI or maintainer
|
||||
review, and keeps normal work targeting `dev` while preserving the documented
|
||||
release/hotfix exceptions.
|
||||
|
||||
## 2026-08-26 — Bounded public issue triage contract
|
||||
|
||||
New GitHub issues may receive one clearly identified Hermes-Relay automated
|
||||
triage response grounded in the live report, current code and documentation,
|
||||
related open/closed issues, and verified public release state. The lane may add
|
||||
existing type/area labels, request focused sanitized diagnostics, and flag a
|
||||
thread for maintainer review.
|
||||
|
||||
The automated path is intentionally not an issue-lifecycle owner: it cannot
|
||||
close, assign, milestone, prioritize, promise a fix/release/timeline, or continue
|
||||
replying after the first response. Replies identify themselves as automated and
|
||||
explicitly hand the remaining decision to a maintainer. Contributor guidance and
|
||||
agent instructions carry the same boundary.
|
||||
|
||||
Public labels and comments are attributed to the repository-scoped
|
||||
`hermes-relay-triage[bot]` GitHub App rather than a maintainer's personal
|
||||
account. The App identity does not expand the bounded action contract.
|
||||
|
||||
## 2026-08-24 — Single dev integration authority
|
||||
|
||||
`origin/dev` is the sole integration authority. Primary local `dev` checkouts are
|
||||
@@ -62,6 +110,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,6 +159,11 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/screenshots/supplemental/15_git_workspace.png" alt="Native Git workspace showing repository changes, an inline diff, and staging controls" width="260"><br>
|
||||
<sub><b>Native Git workspace</b> — optional Hermes-Relay plugin</sub>
|
||||
</p>
|
||||
|
||||
### Simplified Chinese
|
||||
|
||||
<table>
|
||||
@@ -193,16 +190,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 <sub>(alpha)</sub>
|
||||
## Hands on any machine — the Hermes-Relay CLI <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 +217,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 & 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 & 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 +353,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
|
||||
|
||||
@@ -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
|
||||
@@ -523,10 +530,16 @@ the new app version and a higher `appVersionCode`.
|
||||
run Android's retrace tool with the matching flavor mapping:
|
||||
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
|
||||
use the mapping bundled into the uploaded AAB through Play Console.
|
||||
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
|
||||
shown in the settings/about screen. Update with the version number
|
||||
and a brief feature summary. Gets stale silently if forgotten
|
||||
(v0.4.0 shipped with 0.1.0 content until caught post-release).
|
||||
- `app/src/main/assets/changelog.json` — curated source for the in-app
|
||||
**What's New** dialog and Android release history. Prepend the newest entry
|
||||
with one explicit `highlight` (`title`, plain-language `summary`, and 1–3
|
||||
user-benefit bullets), up to two quieter `improvements`, Android-only
|
||||
`playNotes`, a `toastDigest` with counts and 0–2 short previews for noteworthy
|
||||
items beyond the hero, and the existing technical `sections` used by older clients.
|
||||
Do not derive the highlight mechanically from `CHANGELOG.md`; choosing the
|
||||
release's main reason to care is an editorial release-prep decision.
|
||||
- `app/src/main/assets/whats_new.txt` — legacy in-app fallback generated from
|
||||
the newest structured entry. Do not edit it independently.
|
||||
- `app/src/googlePlay/play/release-notes/en-US/default.txt` — the Play
|
||||
Console **"What's new"** text, which gradle-play-publisher reads at
|
||||
upload to fill the Production-draft release notes. This is **separate**
|
||||
@@ -534,19 +547,60 @@ the new app version and a higher `appVersionCode`.
|
||||
this file is missing or stale, the Play draft ships with empty/wrong
|
||||
notes (shipped empty in v1.1.0 until caught post-release). Keep it
|
||||
**≤500 chars per language**, user-facing, Android-only.
|
||||
- `docs/play-store-listing.md` — Play Store listing copy. Update
|
||||
the version reference and the "Release Notes" section that gets
|
||||
pasted into the Play Console "What's new" field. Keep the Play
|
||||
"What's new" within **500 characters** and framed around the
|
||||
release's themes, not a feature dump. Compare its **Foreground service
|
||||
- `docs/play-store-listing.md` — Play Store listing copy. Its release-note
|
||||
block and the Gradle Play Publisher note are generated from `playNotes`.
|
||||
After editing the newest structured entry, run
|
||||
`python scripts/check-android-release-notes.py --write`, then run it again
|
||||
without `--write` to validate the 1–3 / 0–2 editorial limits, current Android
|
||||
version, GitHub-release/changelog headings, derived files, and Play's
|
||||
**500-character** limit. Frame Play copy around the release's themes, not a
|
||||
feature dump. Compare its **Foreground service
|
||||
permissions** section with the merged `googlePlayRelease` manifest and
|
||||
complete Play Console declarations for every declared service type before
|
||||
approval; the Publisher API can upload a draft and still reject promotion
|
||||
when an App content declaration is missing.
|
||||
|
||||
#### Generate release copy from the verified changes
|
||||
|
||||
When release copy is generated with an agent, this section is the canonical
|
||||
authoring contract; do not maintain a separate prompt file.
|
||||
|
||||
1. Read the exact Android version/SHA, the Android-only entries selected from
|
||||
`[Unreleased]`, the implemented behavior, and any compatibility or security
|
||||
boundary that users must understand. Do not generate from commit titles or
|
||||
a mixed-surface changelog block alone.
|
||||
2. Before editing release files, show a temporary coverage ledger in the task
|
||||
output. Map every selected source change to exactly one placement:
|
||||
`hero`, `secondary`, or `full-only`. Include the change kind (`feature`,
|
||||
`change`, or `fix`) and a short reason. The ledger is review evidence, not a
|
||||
committed public artifact; no selected source item may disappear silently.
|
||||
3. Choose exactly one `hero`: the strongest user-facing reason to care about
|
||||
the release. Its summary is one plain-language outcome, and its 1–3 bullets
|
||||
are distinct user benefits rather than implementation steps or filler.
|
||||
4. Use `secondary` for other important user-visible features and fixes. The
|
||||
`toastDigest` counts only these items, excluding the hero. Preview the
|
||||
strongest 1–2 secondary items in short phrases. If there are no legitimate
|
||||
secondary items, set both counts to `0` and `preview` to `[]`; the app hides
|
||||
the footer. Never invent an item to satisfy the layout.
|
||||
5. Use `full-only` for technically relevant details that belong in
|
||||
`RELEASE_NOTES.md` or `CHANGELOG.md` but would make the collapsed update card
|
||||
noisy. Preserve user-relevant trust and compatibility limits; omit branches,
|
||||
worktrees, CI mechanics, debugging history, and private/operator context.
|
||||
6. Write each surface for its audience:
|
||||
- `RELEASE_NOTES.md`: concise Summary plus Added/Changed/Fixed; keep the
|
||||
deterministic Download and Install/Verify scaffolding intact.
|
||||
- `CHANGELOG.md`: complete, crisp public history for the released surface.
|
||||
- `changelog.json`: curated hero, optional improvements, digest, Play copy,
|
||||
and compatibility `sections` for older clients.
|
||||
- `playNotes`: Android-only themes within the rendered 500-character limit.
|
||||
7. Before presenting the draft, check that wording begins with user outcomes,
|
||||
avoids unexplained implementation terminology, uses exact public product
|
||||
names, makes no unverified device claim, and passes the public-distribution
|
||||
scrub below.
|
||||
|
||||
#### Scrub for public distribution
|
||||
|
||||
This is a **public repo** and these four files are user-facing. Before
|
||||
This is a **public repo** and these release-note files are user-facing. Before
|
||||
promoting the `[Unreleased]` block and writing the notes, scrub the
|
||||
versioned CHANGELOG block and all three release-notes artifacts for
|
||||
wording that shouldn't ship publicly. The CHANGELOG accumulates in a
|
||||
@@ -632,7 +686,9 @@ git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md \
|
||||
app/src/main/assets/whats_new.txt docs/play-store-listing.md
|
||||
app/src/main/assets/changelog.json app/src/main/assets/whats_new.txt \
|
||||
app/src/googlePlay/play/release-notes/en-US/default.txt \
|
||||
docs/play-store-listing.md
|
||||
git commit -m "release(android): android-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
@@ -922,8 +978,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 +1009,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,6 +6,35 @@ 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.
|
||||
- On a physical phone, open and repeatedly foreground Android while the same
|
||||
session is working in official Desktop/TUI; verify Android sends no live
|
||||
attach/interrupt RPC, the producer completes, and final history appears.
|
||||
- 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)`
|
||||
@@ -730,8 +759,10 @@ cancels). Ranked next increments, in value-per-complexity order:
|
||||
|
||||
Plan: `docs/plans/2026-07-06-open-issue-resolution.md` (13 open issues triaged;
|
||||
fix-state claims verified against tags with `git merge-base --is-ancestor`).
|
||||
**Automation never posts to GitHub** — every comment/close/label below is an
|
||||
owner action, deliberately queued here:
|
||||
This historical batch remains owner-controlled. The bounded new-issue triage
|
||||
lane may post one clearly identified first response and basic type/area labels,
|
||||
but it does not execute backlog actions. Every comment, close, relabel, and
|
||||
milestone below remains an owner action deliberately queued here:
|
||||
|
||||
- [ ] **#131** — close: fixed by `3573ba8` (PR #136), shipped android-v1.2.5
|
||||
(reporter was on 1.2.3). Optionally re-check Play vitals for the
|
||||
@@ -1426,5 +1457,5 @@ Follow-ups:
|
||||
- **On-device import/delete smoke.** Import `/sdcard/Download/lucy.zip` via Add a pet → confirm Lucy appears, selects, and animates all states; then remove it and confirm the avatar falls back to the Sphere.
|
||||
- **Pet behavior model — richer state association (spec'd 2026-06-19, `docs/pet-spec.md` "Agent states & pet behavior").** Shipped: the honesty clamp (declared reactivity ∩ `PET_RENDERER_CAPABILITIES`), the friendly `writing` alias, the `**working`/tool-use overlay** (pet-local sub-state from `toolCallBurst`; opt-in `working` clip drives both the swap and the Tools badge), the **one-shot reaction layer** (`greet`/`wake` on appear, `done`/`celebrate` on turn-finish — opt-in, play-once-then-revert, transition-derived; `ONE_SHOT_MAX_MS` backstop), and `**intensity` modulation** (opt-in `reactive.intensity` → live playback speedup ≤1.6× via `rememberUpdatedState`; un-clamps the Activity badge). Voice · Tools · Activity reactivity is now complete. Remaining:
|
||||
- `**attention` one-shot (only deferred behavior).** A reaction on notification arrival — needs a host event the avatar doesn't yet receive (unlike `greet`/`done`, which ride state transitions). Would plumb a notification edge into `AvatarRenderState` (or a side channel) + a `PetOneShot.Attention`. Low priority: the avatar is rarely on-screen when notifications land (backgrounded) — see the value analysis; revisit only if the avatar becomes an always-on surface (persistent overlay / Quest port).
|
||||
- **On-device verification (working + one-shots + intensity).** Best seen in clean mode (`AgentTextFlow` feeds `toolCallBurst` + `streamingIntensity` + state transitions). Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Confirm each decoded clip swap holds the previous complete visual until the new state is ready.
|
||||
- **On-device verification (working + one-shots + intensity).** Use the normal Chat background visualization, which receives `toolCallBurst`, `streamingIntensity`, and state transitions. Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Confirm each decoded clip swap holds the previous complete visual until the new state is ready.
|
||||
- **Undecodable-but-present image appears valid (audit 2026-06-19).** A file that exists but isn't a decodable image passes the loader's `isFile` check, so the pet shows in the picker but renders blank. Documented as a caveat; consider a cheap header sniff at load time if false-valid pets become a support issue.
|
||||
|
||||
@@ -239,6 +239,60 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
assertEquals(0, fixture.requestsTo("/v1/chat/completions"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopOwnedTurn_remainsReadOnlyAcrossAndroidForegroundLifecycle() {
|
||||
viewModel.setChatVisible(false)
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
gatewayScope.cancel()
|
||||
|
||||
val controlMethods = setOf(
|
||||
"session.resume",
|
||||
"session.activate",
|
||||
"session.interrupt",
|
||||
"prompt.submit",
|
||||
)
|
||||
val baseline = controlMethods.associateWith(fixture::rpcCount)
|
||||
val baselineActiveList = fixture.rpcCount("session.active_list")
|
||||
fixture.activeSessionStatus = "working"
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val okHttp = OkHttpClient()
|
||||
gatewayClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = okHttp,
|
||||
),
|
||||
okHttpClient = okHttp,
|
||||
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
|
||||
scope = gatewayScope,
|
||||
reconnectJitterUnit = { 0.0 },
|
||||
)
|
||||
viewModel.setChatTurnCheckpointStore(null)
|
||||
viewModel.updateGatewayClient(gatewayClient)
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
viewModel.setChatVisible(false)
|
||||
viewModel.setChatVisible(true)
|
||||
fixture.awaitRpcCount("session.active_list", baselineActiveList + 1)
|
||||
|
||||
controlMethods.forEach { method ->
|
||||
assertEquals(
|
||||
"passive lifecycle sent $method",
|
||||
baseline.getValue(method),
|
||||
fixture.rpcCount(method),
|
||||
)
|
||||
}
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
assertEquals(
|
||||
"observer teardown interrupted the Desktop turn",
|
||||
baseline.getValue("session.interrupt"),
|
||||
fixture.rpcCount("session.interrupt"),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STORED_SESSION_ID = "20260821_120000_fixture"
|
||||
const val LIVE_SESSION_ID = "fixture-live-1"
|
||||
@@ -263,6 +317,9 @@ internal class AndroidGatewayContractFixture {
|
||||
@Volatile
|
||||
var recoveryRunning = false
|
||||
|
||||
@Volatile
|
||||
var activeSessionStatus: String? = null
|
||||
|
||||
private val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
sockets.add(webSocket)
|
||||
@@ -282,6 +339,18 @@ internal class AndroidGatewayContractFixture {
|
||||
"session.activate" -> sessionSnapshot(
|
||||
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "fixture-live-1",
|
||||
)
|
||||
"session.active_list" -> buildJsonObject {
|
||||
put("sessions", kotlinx.serialization.json.buildJsonArray {
|
||||
activeSessionStatus?.let { status ->
|
||||
add(buildJsonObject {
|
||||
put("id", LIVE_SESSION_ID)
|
||||
put("session_key", STORED_SESSION_ID)
|
||||
put("status", status)
|
||||
put("last_active", 1.0)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
"prompt.submit", "session.interrupt" -> buildJsonObject { put("ok", true) }
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
@@ -345,6 +414,15 @@ internal class AndroidGatewayContractFixture {
|
||||
error("Gateway RPC $method not observed; saw ${rpcLog.map { it.first }}")
|
||||
}
|
||||
|
||||
fun awaitRpcCount(method: String, count: Int) {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (rpcCount(method) >= count) return
|
||||
Thread.sleep(20)
|
||||
}
|
||||
error("Gateway RPC $method count $count not observed; saw ${rpcLog.map { it.first }}")
|
||||
}
|
||||
|
||||
fun requestsTo(path: String): Int = requestPaths.count { it.startsWith(path) }
|
||||
|
||||
fun rpcCount(method: String): Int = rpcLog.count { it.first == method }
|
||||
@@ -353,4 +431,9 @@ internal class AndroidGatewayContractFixture {
|
||||
allSockets.forEach { socket -> runCatching { socket.close(1001, "teardown") } }
|
||||
runCatching { server.shutdown() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STORED_SESSION_ID = "20260821_120000_fixture"
|
||||
const val LIVE_SESSION_ID = "fixture-live-1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
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,3 @@
|
||||
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.
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
|
||||
Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.
|
||||
|
||||
@@ -1 +1 @@
|
||||
共享链接、文本、图片和文件现在会作为完整、可检查的草稿打开,不会自动发送。添加或续订连接时不再卡在准备阶段。离线聊天和配置文件历史记录失败会显示明确的恢复提示,而不是无响应或显示空历史记录。诊断现在会报告安全存储降级与恢复,且不会暴露凭据。
|
||||
新增监督模式:家长可配置并固定到指定配置文件,设置受设备身份验证保护。家长可限制附件、标准语音、生成媒体、历史记录、操作和技术详情。实时活动不可用时会话行保持中性显示,从家长设置返回时监督聊天也不再空白。
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.2",
|
||||
"title": "Supervised Mode and clearer activity",
|
||||
"date": "2026-08-25",
|
||||
"highlight": {
|
||||
"title": "Supervised Mode",
|
||||
"summary": "Create a simpler, profile-focused chat and choose which features remain available.",
|
||||
"bullets": [
|
||||
"Protect parent settings with your phone's device authentication.",
|
||||
"Choose access to attachments, Standard voice, generated media, history, actions, and technical details.",
|
||||
"Keep Supervised Chat open when returning from parent settings."
|
||||
]
|
||||
},
|
||||
"improvements": [
|
||||
"Activity indicators now appear only while Hermes is genuinely working."
|
||||
],
|
||||
"toastDigest": {
|
||||
"additionalFeatureCount": 0,
|
||||
"fixCount": 2,
|
||||
"preview": [
|
||||
"Accurate activity",
|
||||
"safer return"
|
||||
]
|
||||
},
|
||||
"playNotes": "Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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.
|
||||
Supervised Mode
|
||||
* Protect parent settings with your phone's device authentication.
|
||||
* Choose access to attachments, Standard voice, generated media, history, actions, and technical details.
|
||||
* Keep Supervised Chat open when returning from parent settings.
|
||||
|
||||
Also improved
|
||||
* Activity indicators now appear only while Hermes is genuinely working.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,8 @@ class ChatInputPreferencesRepository(
|
||||
stringPreferencesKey("physical_keyboard_enter_behavior")
|
||||
internal val KEY_CONVERT_LARGE_PASTES =
|
||||
booleanPreferencesKey("convert_large_pastes_to_attachments")
|
||||
internal val KEY_SHOW_GIT_WORKSPACE_IN_CHAT =
|
||||
booleanPreferencesKey("show_git_workspace_in_chat")
|
||||
}
|
||||
|
||||
val physicalKeyboardEnterBehavior: Flow<PhysicalKeyboardEnterBehavior> = dataStore.data
|
||||
@@ -47,6 +49,10 @@ class ChatInputPreferencesRepository(
|
||||
.map { preferences -> preferences[KEY_CONVERT_LARGE_PASTES] ?: true }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val showGitWorkspaceInChat: Flow<Boolean> = dataStore.data
|
||||
.map { preferences -> preferences[KEY_SHOW_GIT_WORKSPACE_IN_CHAT] ?: true }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setPhysicalKeyboardEnterBehavior(behavior: PhysicalKeyboardEnterBehavior) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[KEY_PHYSICAL_KEYBOARD_ENTER] = behavior.storedValue
|
||||
@@ -58,4 +64,10 @@ class ChatInputPreferencesRepository(
|
||||
preferences[KEY_CONVERT_LARGE_PASTES] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setShowGitWorkspaceInChat(enabled: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[KEY_SHOW_GIT_WORKSPACE_IN_CHAT] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,163 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.plugins.runtime.ScopedPluginApiClient
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
// Read + write client for the Hermes-Relay Git State endpoints.
|
||||
// All requests are confined to the ``hermes-relay`` plugin namespace and the
|
||||
// ``git/*`` sub-path via ScopedPluginApiClient, which rejects traversal and
|
||||
// encodes query values.
|
||||
|
||||
private fun pathsArray(paths: List<String>) = buildJsonArray { paths.forEach { add(JsonPrimitive(it)) } }
|
||||
|
||||
class GitStateApiClient(
|
||||
dashboard: DashboardApiClient,
|
||||
) {
|
||||
private val scoped = ScopedPluginApiClient("hermes-relay", dashboard)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun repos(): Result<List<GitRepo>> = scoped
|
||||
.get("git/repos")
|
||||
.mapCatching { element ->
|
||||
json.decodeFromJsonElement<ReposResponse>(element).repos
|
||||
}
|
||||
|
||||
suspend fun status(repo: String): Result<GitStatus> = scoped
|
||||
.get("git/status", mapOf("repo" to repo))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitStatus>(element) }
|
||||
|
||||
suspend fun branches(repo: String): Result<List<GitBranch>> = scoped
|
||||
.get("git/branches", mapOf("repo" to repo))
|
||||
.mapCatching { element ->
|
||||
json.decodeFromJsonElement<BranchesResponse>(element).branches
|
||||
}
|
||||
|
||||
suspend fun diff(repo: String, path: String, kind: String): Result<GitDiff> = scoped
|
||||
.get("git/diff", mapOf("repo" to repo, "path" to path, "kind" to kind))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitDiff>(element) }
|
||||
|
||||
suspend fun file(repo: String, path: String): Result<GitFile> = scoped
|
||||
.get("git/file", mapOf("repo" to repo, "path" to path))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitFile>(element) }
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────
|
||||
// Every write requires the plugin.api.write grant, which the app enforces
|
||||
// (see GitStateViewModel: a POST is never sent without the grant). The
|
||||
// server additionally enforces per-use confirmation strings for destructive
|
||||
// ops (discard/push/dirty-checkout) — the caller passes the echoed token.
|
||||
|
||||
suspend fun stage(repo: String, paths: List<String>): Result<GitMutationResult> =
|
||||
scoped.post("git/stage", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun unstage(repo: String, paths: List<String>): Result<GitMutationResult> =
|
||||
scoped.post("git/unstage", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun discard(
|
||||
repo: String,
|
||||
paths: List<String>,
|
||||
confirmation: String,
|
||||
deleteUntracked: Boolean = false,
|
||||
): Result<GitMutationResult> = scoped.post("git/discard", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
put("confirmation", confirmation)
|
||||
put("delete_untracked", deleteUntracked)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun commit(repo: String, message: String): Result<GitMutationResult> =
|
||||
scoped.post("git/commit", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("message", message)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun commitSelected(
|
||||
repo: String,
|
||||
message: String,
|
||||
paths: List<String>,
|
||||
): Result<GitMutationResult> = scoped.post("git/commit_selected", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("message", message)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun fetch(repo: String, remote: String = "origin"): Result<GitMutationResult> =
|
||||
scoped.post("git/fetch", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun pull(repo: String, remote: String = "origin", branch: String = ""): Result<GitMutationResult> =
|
||||
scoped.post("git/pull", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
put("branch", branch)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun push(
|
||||
repo: String,
|
||||
confirmation: String,
|
||||
remote: String = "origin",
|
||||
branch: String = "",
|
||||
): Result<GitMutationResult> = scoped.post("git/push", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
put("branch", branch)
|
||||
put("confirmation", confirmation)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun checkout(
|
||||
repo: String,
|
||||
ref: String,
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
): Result<GitMutationResult> = scoped.post("git/checkout", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("ref", ref)
|
||||
if (confirmation != null) put("confirmation", confirmation)
|
||||
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
|
||||
put("track", track)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a commit-message suggestion from the staged diff. */
|
||||
suspend fun commitMessage(repo: String): Result<GitCommitMessage> =
|
||||
scoped.post("git/commit_message", buildJsonObject {
|
||||
put("repo", repo)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
|
||||
|
||||
/** Generate a commit-message suggestion from the given paths' staged diff. */
|
||||
suspend fun commitMessageSelected(
|
||||
repo: String,
|
||||
paths: List<String>,
|
||||
): Result<GitCommitMessage> = scoped.post("git/commit_message_selected", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
|
||||
|
||||
/** Checkout that auto-stashes a dirty tree first. */
|
||||
suspend fun stashCheckout(
|
||||
repo: String,
|
||||
ref: String,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
): Result<GitStashCheckoutResult> = scoped.post("git/stash_checkout", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("ref", ref)
|
||||
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
|
||||
put("track", track)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitStashCheckoutResult>(it) }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** A repository discovered by the plugin's /git/repos endpoint. */
|
||||
@Serializable
|
||||
data class GitRepo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val root: String,
|
||||
@SerialName("current_branch") val currentBranch: String? = null,
|
||||
val dirty: Boolean = false,
|
||||
)
|
||||
|
||||
/** Working-tree status from /git/status. */
|
||||
@Serializable
|
||||
data class GitStatus(
|
||||
val counts: GitStatusCounts = GitStatusCounts(),
|
||||
val staged: List<GitStatusEntry> = emptyList(),
|
||||
val modified: List<GitStatusEntry> = emptyList(),
|
||||
val untracked: List<GitStatusEntry> = emptyList(),
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitStatusCounts(
|
||||
val staged: Int = 0,
|
||||
val modified: Int = 0,
|
||||
val untracked: Int = 0,
|
||||
/** Unique changed paths. -1 means an older plugin did not provide it. */
|
||||
val changes: Int = -1,
|
||||
val additions: Int = 0,
|
||||
val deletions: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitStatusEntry(
|
||||
val path: String,
|
||||
val additions: Int? = null,
|
||||
val deletions: Int? = null,
|
||||
)
|
||||
|
||||
/** A branch from /git/branches. */
|
||||
@Serializable
|
||||
data class GitBranch(
|
||||
val name: String,
|
||||
val upstream: String? = null,
|
||||
val ahead: Int = 0,
|
||||
val behind: Int = 0,
|
||||
@SerialName("is_current") val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
/** A per-file diff from /git/diff. */
|
||||
@Serializable
|
||||
data class GitDiff(
|
||||
val path: String,
|
||||
val kind: String,
|
||||
val diff: String,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** A tracked-file read from /git/file. */
|
||||
@Serializable
|
||||
data class GitFile(
|
||||
val path: String,
|
||||
val content: String,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Wrapper for /git/repos response. */
|
||||
@Serializable
|
||||
internal data class ReposResponse(
|
||||
val repos: List<GitRepo> = emptyList(),
|
||||
val notice: String? = null,
|
||||
)
|
||||
|
||||
/** Wrapper for /git/branches response. */
|
||||
@Serializable
|
||||
internal data class BranchesResponse(
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
)
|
||||
|
||||
/** A mutation response: fresh HEAD oid + working-tree status (+ branches). */
|
||||
@Serializable
|
||||
data class GitMutationResult(
|
||||
val head: String = "",
|
||||
val status: GitStatus = GitStatus(),
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
)
|
||||
|
||||
/** A /git/commit_message suggestion: generated message + optional notice. */
|
||||
@Serializable
|
||||
data class GitCommitMessage(
|
||||
val message: String = "",
|
||||
val notice: String = "",
|
||||
)
|
||||
|
||||
/** A /git/stash_checkout result: standard mutation shape + stash flag/message. */
|
||||
@Serializable
|
||||
data class GitStashCheckoutResult(
|
||||
val head: String = "",
|
||||
val status: GitStatus = GitStatus(),
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
val stashed: Boolean = false,
|
||||
@SerialName("stash_message") val stashMessage: String = "",
|
||||
)
|
||||
@@ -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
|
||||
@@ -879,6 +906,30 @@ class GatewayChatClient(
|
||||
scope.launch { prewarmAwait(storedSessionId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish only the shared Gateway socket for read-only observation.
|
||||
*
|
||||
* `session.resume` and `session.activate` attach a live runtime to this
|
||||
* transport. Opening Chat, foreground restoration, and selecting a saved
|
||||
* transcript must not claim a turn that another Desktop/TUI client owns,
|
||||
* so those paths use this socket-only warmup and observe through REST
|
||||
* history plus `session.active_list` instead.
|
||||
*/
|
||||
fun observe(onReady: (() -> Unit)? = null) {
|
||||
scope.launch {
|
||||
if (observeAwait() && onReady != null) callbackDispatcher(onReady)
|
||||
}
|
||||
}
|
||||
|
||||
/** Suspending [observe]; returns true once the read-only socket is ready. */
|
||||
suspend fun observeAwait(): Boolean = try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Gateway observation warmup skipped: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspending [prewarm]: establishes the socket and (when [storedSessionId]
|
||||
* is non-null) resumes the existing session, returning only once that work
|
||||
@@ -1426,6 +1477,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 +2111,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 +2590,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 +2837,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 +3332,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 +3518,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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.ui.components.ChatGitWorkspaceSummary
|
||||
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
|
||||
|
||||
internal fun selectGitRepoForWorkspace(
|
||||
repos: List<GitRepo>,
|
||||
selectedRepoId: String?,
|
||||
sessionRepoRoot: String?,
|
||||
sessionWorkingDirectory: String?,
|
||||
): GitRepo? {
|
||||
if (repos.isEmpty()) return null
|
||||
|
||||
fun normalized(path: String): String =
|
||||
path.trim().replace('\\', '/').trimEnd('/')
|
||||
|
||||
val exactRoot = sessionRepoRoot?.let(::normalized).orEmpty()
|
||||
val workingDirectory = sessionWorkingDirectory?.let(::normalized).orEmpty()
|
||||
val matched = exactRoot.takeIf { it.isNotBlank() }?.let { root ->
|
||||
repos.firstOrNull { normalized(it.root).equals(root, ignoreCase = true) }
|
||||
} ?: workingDirectory.takeIf { it.isNotBlank() }?.let { cwd ->
|
||||
repos.filter { repo ->
|
||||
val root = normalized(repo.root)
|
||||
cwd.equals(root, ignoreCase = true) ||
|
||||
cwd.startsWith("$root/", ignoreCase = true)
|
||||
}.maxByOrNull { normalized(it.root).length }
|
||||
}
|
||||
if (matched != null) return matched
|
||||
if (repos.any { it.id == selectedRepoId }) return null
|
||||
return repos.singleOrNull()
|
||||
}
|
||||
|
||||
internal fun buildChatGitWorkspaceSummary(
|
||||
repo: GitRepo?,
|
||||
detail: GitRepoDetailState,
|
||||
): ChatGitWorkspaceSummary? {
|
||||
val ready = detail as? GitRepoDetailState.Ready ?: return null
|
||||
repo ?: return null
|
||||
val status = ready.status
|
||||
val changedPaths = buildSet {
|
||||
status.staged.forEach { add(it.path) }
|
||||
status.modified.forEach { add(it.path) }
|
||||
status.untracked.forEach { add(it.path) }
|
||||
}
|
||||
val branch = ready.branches.firstOrNull { it.isCurrent }?.name
|
||||
?: repo.currentBranch.orEmpty()
|
||||
if (branch.isBlank()) return null
|
||||
return ChatGitWorkspaceSummary(
|
||||
branch = branch,
|
||||
changeCount = status.counts.changes.takeIf { it >= 0 } ?: changedPaths.size,
|
||||
additions = status.counts.additions,
|
||||
deletions = status.counts.deletions,
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -54,6 +55,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
|
||||
@@ -66,6 +68,8 @@ import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
@@ -125,6 +129,7 @@ import com.hermesandroid.relay.ui.components.HostResourcePressureBanner
|
||||
import com.hermesandroid.relay.ui.components.rememberUpdateAvailability
|
||||
import com.hermesandroid.relay.ui.components.resolveChatTransportStatus
|
||||
import com.hermesandroid.relay.ui.components.WhatsNewDialog
|
||||
import com.hermesandroid.relay.ui.components.WhatsNewToast
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.BridgePreferencesRepository
|
||||
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
|
||||
@@ -133,6 +138,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 +151,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
|
||||
@@ -160,6 +168,7 @@ import com.hermesandroid.relay.ui.screens.BotModeScreen
|
||||
// === END PHASE3-safety-rails ===
|
||||
import com.hermesandroid.relay.ui.screens.ChatScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChangelogScreen
|
||||
import com.hermesandroid.relay.ui.screens.DashboardManagementScreen
|
||||
import com.hermesandroid.relay.ui.screens.DashboardSignInScreen
|
||||
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
|
||||
@@ -170,8 +179,14 @@ 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.GitStateScreen
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.viewmodel.GitStateUiState
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProactiveSettingsScreen
|
||||
@@ -191,7 +206,9 @@ import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.plugins.runtime.PLUGIN_API_WRITE_CAPABILITY
|
||||
import com.hermesandroid.relay.viewmodel.PluginsViewModel
|
||||
import com.hermesandroid.relay.viewmodel.PluginsHubState
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
@@ -439,6 +456,7 @@ sealed class Screen(
|
||||
}
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
data object Plugins : Screen("plugins", "Plugins", Icons.Filled.Extension)
|
||||
data object GitState : Screen("git_state", "Git", Icons.Filled.Code)
|
||||
data object PluginPage : Screen(
|
||||
"plugins/{pluginId}/pages/{pageId}",
|
||||
"Plugin",
|
||||
@@ -530,6 +548,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 +617,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
|
||||
@@ -595,13 +643,17 @@ fun RelayApp() {
|
||||
val chatViewModel: ChatViewModel = processRuntime.chatViewModel
|
||||
val terminalViewModel: TerminalViewModel = viewModel()
|
||||
val pluginsViewModel: PluginsViewModel = viewModel()
|
||||
val gitStateViewModel: GitStateViewModel = viewModel()
|
||||
val voiceViewModel: VoiceViewModel = processRuntime.voiceViewModel
|
||||
val runtimeInitializationState by processRuntime.initializationState.collectAsState()
|
||||
|
||||
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 +750,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()
|
||||
@@ -744,6 +862,11 @@ fun RelayApp() {
|
||||
val serverCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val gitOwnerKey = activeConnectionId?.takeIf { it.isNotBlank() }?.let { connectionId ->
|
||||
effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { dashboardUrl ->
|
||||
"$connectionId\u0000${effectiveSessionProfileName.orEmpty()}\u0000$dashboardUrl"
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
activeConnectionId,
|
||||
effectiveDashboardUrl,
|
||||
@@ -758,12 +881,98 @@ fun RelayApp() {
|
||||
sessionId = currentChatSessionId,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(gitOwnerKey) {
|
||||
val dashboard = effectiveDashboardUrl
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { connectionViewModel.dashboardClientForActive(it) }
|
||||
gitStateViewModel.configure(dashboard, gitOwnerKey)
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
// Mirror the plugin.api.write grant into the Git view model so write
|
||||
// mutations are refused client-side until the user grants write access
|
||||
// (matches the plug-in's grant gating in PluginsViewModel).
|
||||
val pluginsHubState by pluginsViewModel.hubState.collectAsState()
|
||||
LaunchedEffect(pluginsHubState, gitOwnerKey) {
|
||||
val ready = pluginsHubState as? PluginsHubState.Ready
|
||||
val granted = ready
|
||||
?.takeIf { it.ownerKey == gitOwnerKey }
|
||||
?.plugins
|
||||
?.firstOrNull { it.catalog.id == "hermes-relay" }
|
||||
?.preferences
|
||||
?.grants
|
||||
?.contains(PLUGIN_API_WRITE_CAPABILITY) == true
|
||||
gitStateViewModel.setWriteGrant(gitOwnerKey, granted)
|
||||
}
|
||||
|
||||
val gitReposState by gitStateViewModel.repos.collectAsState()
|
||||
val gitDetailState by gitStateViewModel.detail.collectAsState()
|
||||
val selectedGitRepoId by gitStateViewModel.selectedRepoId.collectAsState()
|
||||
val chatSessions by chatViewModel.sessions.collectAsState()
|
||||
val activeChatSession = remember(chatSessions, currentChatSessionId) {
|
||||
chatSessions.firstOrNull { it.sessionId == currentChatSessionId }
|
||||
}
|
||||
|
||||
// Bind Git to the active coding session when upstream supplies its exact
|
||||
// workspace metadata. CWD fallback only matches a path-segment descendant;
|
||||
// an ambiguous multi-repo catalog stays unselected until the user chooses.
|
||||
LaunchedEffect(gitReposState, activeChatSession, selectedGitRepoId) {
|
||||
val repos = (gitReposState as? GitStateUiState.Ready)?.repos.orEmpty()
|
||||
val target = selectGitRepoForWorkspace(
|
||||
repos = repos,
|
||||
selectedRepoId = selectedGitRepoId,
|
||||
sessionRepoRoot = activeChatSession?.gitRepoRoot,
|
||||
sessionWorkingDirectory = activeChatSession?.workingDirectory,
|
||||
)
|
||||
if (target != null && target.id != selectedGitRepoId) {
|
||||
gitStateViewModel.selectRepo(target.id)
|
||||
}
|
||||
}
|
||||
|
||||
val gitWorkspaceAvailable = gitReposState is GitStateUiState.Ready
|
||||
val gitWorkspaceSummary = remember(
|
||||
gitReposState,
|
||||
gitDetailState,
|
||||
selectedGitRepoId,
|
||||
) {
|
||||
val repo = (gitReposState as? GitStateUiState.Ready)
|
||||
?.repos
|
||||
?.firstOrNull { it.id == selectedGitRepoId }
|
||||
buildChatGitWorkspaceSummary(repo, gitDetailState)
|
||||
}
|
||||
|
||||
// Post-update What's New starts as a non-blocking toast and expands only
|
||||
// when requested; manual Settings/About entry points retain the full view.
|
||||
val showWhatsNew by connectionViewModel.showWhatsNew.collectAsState()
|
||||
var showWhatsNewExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var showWhatsNewHistory by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
if (showWhatsNew) {
|
||||
WhatsNewDialog(onDismiss = { connectionViewModel.dismissWhatsNew() })
|
||||
if (showWhatsNew && !showWhatsNewExpanded) {
|
||||
WhatsNewToast(
|
||||
onDismiss = { connectionViewModel.dismissWhatsNew() },
|
||||
onExpand = {
|
||||
connectionViewModel.dismissWhatsNew()
|
||||
showWhatsNewExpanded = true
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showWhatsNewExpanded) {
|
||||
WhatsNewDialog(
|
||||
onDismiss = { showWhatsNewExpanded = false },
|
||||
onViewHistory = {
|
||||
showWhatsNewExpanded = false
|
||||
showWhatsNewHistory = true
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showWhatsNewHistory) {
|
||||
Dialog(
|
||||
onDismissRequest = { showWhatsNewHistory = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
ChangelogScreen(onClose = { showWhatsNewHistory = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark version as seen on first launch (when there's no previous version)
|
||||
@@ -782,6 +991,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 +1023,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 +1161,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 +1244,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 +1990,8 @@ fun RelayApp() {
|
||||
!suppressGlobalChrome &&
|
||||
!isKeyboardVisible &&
|
||||
!showStartupSphere &&
|
||||
(!supervisedPolicy.enabled ||
|
||||
supervisedPolicy.visibility.resolved().showTechnicalRoute) &&
|
||||
shouldShowConnectionFooter(voiceUiState.voiceMode, voicePresentationMode)
|
||||
) {
|
||||
val footerRoute = resolveFooterRouteCandidate(
|
||||
@@ -1791,12 +2066,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 +2162,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,9 +2361,15 @@ fun RelayApp() {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
supervisedPolicy = chatSupervisedPolicy,
|
||||
onNavigateToBotMode = {
|
||||
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
|
||||
},
|
||||
gitWorkspaceAvailable = gitWorkspaceAvailable,
|
||||
gitWorkspaceSummary = gitWorkspaceSummary,
|
||||
onNavigateToGitWorkspace = {
|
||||
navController.navigate(Screen.GitState.route) { launchSingleTop = true }
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.BotMode.route) {
|
||||
@@ -2374,6 +2687,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,9 +2719,15 @@ fun RelayApp() {
|
||||
onNavigateToManage = {
|
||||
navController.navigate(Screen.Manage.route)
|
||||
},
|
||||
onNavigateToProviderUsage = {
|
||||
navController.navigate(Screen.ProviderUsage.route)
|
||||
},
|
||||
onNavigateToPlugins = {
|
||||
navController.navigate(Screen.Plugins.route)
|
||||
},
|
||||
onNavigateToGitWorkspace = {
|
||||
navController.navigate(Screen.GitState.route)
|
||||
},
|
||||
onNavigateToChatSettings = {
|
||||
navController.navigate(Screen.ChatSettings.route)
|
||||
},
|
||||
@@ -2445,15 +2783,88 @@ 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,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenPage = { pluginId, pageId ->
|
||||
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
|
||||
if (pluginId == "hermes-relay" && pageId == "git") {
|
||||
navController.navigate(Screen.GitState.route)
|
||||
} else {
|
||||
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.GitState.route) {
|
||||
GitStateScreen(
|
||||
viewModel = gitStateViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.PluginPage.route,
|
||||
arguments = listOf(
|
||||
@@ -2915,7 +3326,8 @@ fun RelayApp() {
|
||||
composable(Screen.About.route) {
|
||||
AboutScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = { navController.popBackStack() },
|
||||
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessForCurrentRoute,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
@@ -3018,6 +3430,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 +3448,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 +3479,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
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/** Shared OS motion/accessibility posture for animated UI affordances. */
|
||||
internal data class AccessibleMotionState(
|
||||
/** OS animator scale is non-zero (i.e. system animations are on). */
|
||||
val osAnimations: Boolean,
|
||||
/** TalkBack-style touch exploration is active. */
|
||||
val touchExploration: Boolean,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun rememberAccessibleMotionState(): AccessibleMotionState {
|
||||
val context = LocalContext.current
|
||||
val osAnimations = remember {
|
||||
runCatching {
|
||||
Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
) != 0f
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
val accessibilityManager = remember {
|
||||
context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
|
||||
}
|
||||
var touchExploration by remember {
|
||||
mutableStateOf(accessibilityManager?.isTouchExplorationEnabled == true)
|
||||
}
|
||||
DisposableEffect(accessibilityManager) {
|
||||
val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled ->
|
||||
touchExploration = enabled
|
||||
}
|
||||
accessibilityManager?.addTouchExplorationStateChangeListener(listener)
|
||||
onDispose {
|
||||
accessibilityManager?.removeTouchExplorationStateChangeListener(listener)
|
||||
}
|
||||
}
|
||||
return AccessibleMotionState(
|
||||
osAnimations = osAnimations,
|
||||
touchExploration = touchExploration,
|
||||
)
|
||||
}
|
||||
@@ -1,665 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalBackgroundVisualizationEnabled
|
||||
import kotlinx.coroutines.delay
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
|
||||
// --- Text-flow tuning constants -------------------------------------------
|
||||
//
|
||||
// All time-based numbers stay inside the ranges WP-C1 prescribes so the
|
||||
// "clean text flowing in and fading out" reads calm rather than frantic.
|
||||
|
||||
/** Soft word-wrap width for a flow line — keeps each buffer entry to ~one
|
||||
* visual line so the bounded buffer maps cleanly to "≤6 lines". */
|
||||
private const val FLOW_MAX_CHARS = 42
|
||||
|
||||
/** Soft-wrap target only — the visible buffer is now bounded by the
|
||||
* scrollable viewport height + scroll, not a hard line count. */
|
||||
private const val FLOW_MAX_LINES = 6
|
||||
|
||||
/** Memory ceiling for the persistent line buffer. Lines past this (already
|
||||
* scrolled well above the faded top edge) are dropped silently so a very long
|
||||
* turn can't grow the list without bound. */
|
||||
private const val FLOW_BUFFER_MAX = 80
|
||||
|
||||
/** How long a settled line lingers after it stops growing, before it begins
|
||||
* fading. Inside the 2.5–4s band from the spec. */
|
||||
private const val FLOW_DWELL_MS = 3_000L
|
||||
|
||||
private const val FLOW_FADE_IN_MS = 180
|
||||
private const val FLOW_FADE_OUT_MS = 600
|
||||
|
||||
/** Buffer maintenance cadence. Cheap list bookkeeping only — it mutates
|
||||
* observed state (and so triggers recomposition) only when something
|
||||
* actually changes, so an idle clean mode does not churn the UI. */
|
||||
private const val FLOW_TICK_MS = 80L
|
||||
|
||||
/**
|
||||
* One ephemeral line in the text flow.
|
||||
*
|
||||
* [text] and [visibility] are snapshot-observed so a growing tail or a
|
||||
* fade-out re-renders just that line. [settledAt]/[hiddenAt] are plain
|
||||
* bookkeeping read only by the maintenance loop, so they intentionally do
|
||||
* NOT trigger recomposition.
|
||||
*
|
||||
* [visibility] starts `currentState = false, targetState = true`; handing
|
||||
* that to `AnimatedVisibility(visibleState = …)` plays the enter transition
|
||||
* the first time the line is composed — the idiomatic "animate on appear".
|
||||
*/
|
||||
private class FlowLine(val key: Int, initialText: String) {
|
||||
var text by mutableStateOf(initialText)
|
||||
val visibility = MutableTransitionState(false).apply { targetState = true }
|
||||
|
||||
/** Wall-clock millis at which the line stopped growing (null while it is
|
||||
* still the active streaming tail). Starts the dwell countdown. */
|
||||
var settledAt: Long? = null
|
||||
|
||||
/** Wall-clock millis at which the fade-out was requested. */
|
||||
var hiddenAt: Long? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Split [text] into short, append-only flow segments.
|
||||
*
|
||||
* Explicit newlines hard-break; long paragraphs greedily soft-wrap at word
|
||||
* boundaries to [maxChars]. Because the source content only ever grows
|
||||
* (streaming appends), every segment except the last is final the moment the
|
||||
* next word/line exists — which is exactly what lets the caller treat the
|
||||
* last segment as the "growing tail" and everything before it as settled,
|
||||
* and key each line by its stable index.
|
||||
*/
|
||||
private fun segmentFlowLines(text: String, maxChars: Int): List<String> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
val out = ArrayList<String>()
|
||||
for (rawLine in text.split('\n')) {
|
||||
val line = rawLine.trim()
|
||||
if (line.isEmpty()) continue
|
||||
val current = StringBuilder()
|
||||
for (word in line.split(' ')) {
|
||||
if (word.isEmpty()) continue
|
||||
val candidate = if (current.isEmpty()) word.length else current.length + 1 + word.length
|
||||
if (candidate > maxChars && current.isNotEmpty()) {
|
||||
out.add(current.toString())
|
||||
current.setLength(0)
|
||||
current.append(word)
|
||||
} else {
|
||||
if (current.isNotEmpty()) current.append(' ')
|
||||
current.append(word)
|
||||
}
|
||||
}
|
||||
if (current.isNotEmpty()) out.add(current.toString())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft fade on the TOP edge so lines that scroll up dissolve cleanly into the
|
||||
* background instead of hard-clipping — the "slides up and clears" look — while
|
||||
* the avatar above stays unobstructed. Renders the content into an offscreen
|
||||
* layer and masks the top [fade] dp with a transparent->opaque gradient.
|
||||
*/
|
||||
private fun Modifier.topFadeEdge(fade: Dp = 28.dp): Modifier = this
|
||||
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
val fadePx = fade.toPx().coerceAtMost(size.height)
|
||||
if (fadePx <= 0f) return@drawWithContent
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
(fadePx / size.height) to Color.Black,
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared OS motion/accessibility posture for animated chat affordances. */
|
||||
internal data class AccessibleMotionState(
|
||||
/** OS animator scale is non-zero (i.e. system animations are ON). */
|
||||
val osAnimations: Boolean,
|
||||
/** TalkBack-style touch exploration is active — faded text is unreadable
|
||||
* to it, so the text path must fall back to a static, announced mirror. */
|
||||
val touchExploration: Boolean,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun rememberAccessibleMotionState(): AccessibleMotionState {
|
||||
val context = LocalContext.current
|
||||
// ANIMATOR_DURATION_SCALE == 0 is the platform "remove animations" / many
|
||||
// OEM "reduce motion" toggles. Read once on entry; a mid-mode toggle is
|
||||
// rare and recovered by leaving + re-entering the mode.
|
||||
val osAnimations = remember {
|
||||
runCatching {
|
||||
Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
) != 0f
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
val a11y = remember {
|
||||
context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
|
||||
}
|
||||
var touchExploration by remember {
|
||||
mutableStateOf(a11y?.isTouchExplorationEnabled == true)
|
||||
}
|
||||
DisposableEffect(a11y) {
|
||||
val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled ->
|
||||
touchExploration = enabled
|
||||
}
|
||||
a11y?.addTouchExplorationStateChangeListener(listener)
|
||||
onDispose { a11y?.removeTouchExplorationStateChangeListener(listener) }
|
||||
}
|
||||
return AccessibleMotionState(
|
||||
osAnimations = osAnimations,
|
||||
touchExploration = touchExploration,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemeral, themed text flow bound to the agent's streaming reply.
|
||||
*
|
||||
* New segments materialize with `fadeIn + slideInVertically`; a settled line
|
||||
* dwells ~[FLOW_DWELL_MS], then `fadeOut`s and is **removed from the buffer**
|
||||
* (it leaves the composition tree, so it stops composing — not merely
|
||||
* alpha-0). The still-growing tail never fades; its dwell starts only once
|
||||
* [streaming] flips false. The buffer is hard-capped at [FLOW_MAX_LINES].
|
||||
*
|
||||
* Accessibility: when [motionEnabled] is false (animations disabled, OS
|
||||
* reduce-motion, or TalkBack touch exploration) the flow renders the recent
|
||||
* lines **statically** inside a polite live region — never gating the
|
||||
* conversation on animation. Even on the animated path a visually-hidden
|
||||
* polite mirror carries the readable words, since faded glyphs are
|
||||
* unreadable to assistive tech.
|
||||
*
|
||||
* @param content the last assistant message's (streaming) content.
|
||||
* @param streaming whether that message is still growing this turn.
|
||||
* @param messageId stable id of the bound message; a new id resets the buffer.
|
||||
*/
|
||||
@Composable
|
||||
fun AgentTextFlow(
|
||||
content: String,
|
||||
streaming: Boolean,
|
||||
messageId: String?,
|
||||
motionEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 21.sp,
|
||||
)
|
||||
val flowColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
// Readable, non-faded mirror of the visible tail — used as the live-region
|
||||
// text on both paths so assistive tech hears the words.
|
||||
val mirrorText = remember(content) {
|
||||
segmentFlowLines(content, FLOW_MAX_CHARS).takeLast(FLOW_MAX_LINES).joinToString(" ")
|
||||
}
|
||||
|
||||
// --- Static / reduced-motion path -------------------------------------
|
||||
if (!motionEnabled) {
|
||||
val staticLines = remember(content) {
|
||||
segmentFlowLines(content, FLOW_MAX_CHARS).takeLast(FLOW_BUFFER_MAX)
|
||||
}
|
||||
val staticScroll = rememberScrollState()
|
||||
// Pin the latest line to the bottom of the bounded viewport.
|
||||
LaunchedEffect(staticLines.size) { staticScroll.scrollTo(staticScroll.maxValue) }
|
||||
// No contentDescription — the merged child Text content IS the readable
|
||||
// content; liveRegion announces it on change. Lines persist + scroll
|
||||
// (bounded + top-faded like the animated path) — they never vanish.
|
||||
Column(
|
||||
modifier = modifier
|
||||
.semantics { liveRegion = LiveRegionMode.Polite }
|
||||
// Fade the top edge ONLY when there's content scrolled above it —
|
||||
// a message that fits shows its first line crisply (no cut-off look).
|
||||
.topFadeEdge(fade = if (staticScroll.canScrollBackward) 28.dp else 0.dp)
|
||||
.verticalScroll(staticScroll),
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
) {
|
||||
staticLines.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = flowStyle,
|
||||
color = flowColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// --- Animated path ----------------------------------------------------
|
||||
val flowLines = remember(messageId) { mutableStateListOf<FlowLine>() }
|
||||
val currentContent by rememberUpdatedState(content)
|
||||
|
||||
LaunchedEffect(messageId) {
|
||||
flowLines.clear()
|
||||
// Largest segment index ever materialized — guards against re-adding a
|
||||
// line that was dropped from the front by the memory cap.
|
||||
var maxKeyAdded = -1
|
||||
var lastText: String? = null
|
||||
while (true) {
|
||||
val text = currentContent
|
||||
// Re-diff only when the transcript changed, so an idle clean mode
|
||||
// (no streaming, no new turn) doesn't churn. We never permanently
|
||||
// exit: a new turn appended to the transcript must still slide in.
|
||||
if (text != lastText) {
|
||||
lastText = text
|
||||
val segs = segmentFlowLines(text, FLOW_MAX_CHARS)
|
||||
// Add new lines (they slide in); update a changed tail in place.
|
||||
// Lines PERSIST — older ones simply scroll up within the bounded,
|
||||
// scrollable viewport and dissolve at the top fade edge.
|
||||
segs.forEachIndexed { i, s ->
|
||||
val existing = flowLines.firstOrNull { it.key == i }
|
||||
if (existing == null) {
|
||||
if (i > maxKeyAdded) {
|
||||
flowLines.add(FlowLine(key = i, initialText = s))
|
||||
maxKeyAdded = i
|
||||
}
|
||||
} else if (existing.text != s) {
|
||||
existing.text = s
|
||||
}
|
||||
}
|
||||
// Memory guard: drop the oldest lines once well past the viewport.
|
||||
while (flowLines.size > FLOW_BUFFER_MAX) flowLines.removeAt(0)
|
||||
}
|
||||
delay(FLOW_TICK_MS)
|
||||
}
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
// Pin the latest line to the bottom as content streams in / lines slide up.
|
||||
LaunchedEffect(flowLines.size, flowLines.lastOrNull()?.text) {
|
||||
scrollState.scrollTo(scrollState.maxValue)
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
// Visually-hidden, readable, politely-announced mirror. Present even
|
||||
// with motion on, so non-touch assistive tech still receives the words
|
||||
// the faded glyphs can't convey. The Text's own content is its
|
||||
// semantics text, so liveRegion alone announces it on change.
|
||||
Text(
|
||||
text = mirrorText,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 1.dp)
|
||||
.alpha(0f)
|
||||
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
style = flowStyle,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
// Fade the top edge ONLY when content is scrolled above it, so a
|
||||
// reply that fits the viewport shows its first line crisply.
|
||||
.topFadeEdge(fade = if (scrollState.canScrollBackward) 28.dp else 0.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
) {
|
||||
flowLines.forEach { line ->
|
||||
androidx.compose.runtime.key(line.key) {
|
||||
AnimatedVisibility(
|
||||
visibleState = line.visibility,
|
||||
enter = fadeIn(tween(FLOW_FADE_IN_MS)) +
|
||||
slideInVertically(tween(FLOW_FADE_IN_MS)) { it / 6 },
|
||||
exit = fadeOut(tween(FLOW_FADE_OUT_MS)),
|
||||
) {
|
||||
Text(
|
||||
text = line.text,
|
||||
style = flowStyle,
|
||||
color = flowColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// The visible glyphs fade; the mirror above owns
|
||||
// accessibility, so keep AT off these duplicates.
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clearAndSetSemantics {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin single-line composer for clean mode.
|
||||
*
|
||||
* Deliberately stripped: no model/effort pills, no attachments, no slash
|
||||
* palette — just a pill field plus a send affordance, calling [onSend] with
|
||||
* the same [com.hermesandroid.relay.viewmodel.ChatViewModel.sendMessage]
|
||||
* contract the full composer uses. Internal text state is UI-local.
|
||||
*/
|
||||
@Composable
|
||||
private fun CleanModeComposer(
|
||||
enabled: Boolean,
|
||||
onSend: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
val canSend = enabled && text.isNotBlank()
|
||||
val submit = {
|
||||
val trimmed = text.trim()
|
||||
if (enabled && trimmed.isNotEmpty()) {
|
||||
onSend(trimmed)
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 18.dp, end = 6.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 40.dp)
|
||||
.padding(vertical = 8.dp),
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { submit() }),
|
||||
decorationBox = { inner ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (text.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_text_placeholder),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = RelayRefresh.Dim,
|
||||
)
|
||||
}
|
||||
inner()
|
||||
}
|
||||
},
|
||||
)
|
||||
IconButton(
|
||||
onClick = submit,
|
||||
enabled = canSend,
|
||||
modifier = Modifier.size(44.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(R.string.agent_text_send_cd),
|
||||
tint = if (canSend) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean text-flow chat mode — a full-screen, minimalist third presentation of
|
||||
* the agent surface (alongside normal chat and the voice overlay).
|
||||
*
|
||||
* Centered morphing sphere, a calm themed text flow ([AgentTextFlow]) instead
|
||||
* of a persistent transcript, and a thin composer. Mirrors the voice overlay's
|
||||
* centered-sphere + bottom-content skeleton (`VoiceModeOverlay.kt:262-280`).
|
||||
*
|
||||
* Exit is an **explicit control** (top-corner dismiss + system back) — never
|
||||
* any-tap, because the in-mode composer needs taps. All mode state lives in
|
||||
* the caller as plain UI-local state; this is a presentation over the same
|
||||
* conversation, not new ViewModel state.
|
||||
*
|
||||
* Honors the ambient-visualization preference independently from motion.
|
||||
* When visible, [animationEnabled] and OS reduce-motion pause the sphere on a
|
||||
* static frame; TalkBack keeps the text readable and announced.
|
||||
*/
|
||||
@Composable
|
||||
fun CleanChatMode(
|
||||
messages: List<ChatMessage>,
|
||||
isStreaming: Boolean,
|
||||
sphereState: SphereState,
|
||||
streamingIntensity: Float,
|
||||
toolCallBurst: Float,
|
||||
animationEnabled: Boolean,
|
||||
enabled: Boolean,
|
||||
onSend: (String) -> Unit,
|
||||
onExit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val motion = rememberAccessibleMotionState()
|
||||
val backgroundVisualizationEnabled = LocalBackgroundVisualizationEnabled.current
|
||||
val sphereAnimated = animationEnabled && motion.osAnimations
|
||||
// Faded text is unreadable to touch exploration, so the text path goes
|
||||
// static (readable + announced) whenever TalkBack is exploring.
|
||||
val textMotionEnabled = sphereAnimated && !motion.touchExploration
|
||||
|
||||
val lastAssistant = remember(messages) {
|
||||
messages.lastOrNull { it.role == MessageRole.ASSISTANT }
|
||||
}
|
||||
// Clean mode shows the recent CONVERSATION (not just the last reply) as one
|
||||
// faded, scrollable flow, so scrolling up brings history into view. The flow
|
||||
// is append-only across turns; user turns get a subtle "›" so the
|
||||
// back-and-forth stays legible. How far back it retains is bounded by the
|
||||
// flow's line buffer (FLOW_BUFFER_MAX).
|
||||
val flowContent = remember(messages) {
|
||||
messages
|
||||
.filter { it.role == MessageRole.USER || it.role == MessageRole.ASSISTANT }
|
||||
.joinToString("\n\n") { msg ->
|
||||
val body = msg.content.trim()
|
||||
if (msg.role == MessageRole.USER) "› $body" else body
|
||||
}
|
||||
}
|
||||
// Stable per-conversation key so the flow buffer accumulates across turns and
|
||||
// resets only on a new conversation (the oldest message's id changes).
|
||||
val conversationKey = messages.firstOrNull()?.id
|
||||
val flowStreaming = lastAssistant?.isStreaming == true && isStreaming
|
||||
// The sphere + text are a vertically-centered group (equal spacers above and
|
||||
// below). The sphere is a fixed size so the group grows via the TEXT: a short
|
||||
// reply sits centered, and as the reply lengthens the centered group gets
|
||||
// taller — sliding the sphere up toward the top third while the text fills
|
||||
// down toward the composer.
|
||||
val sphereHeight = (LocalConfiguration.current.screenHeightDp * 0.34f).dp
|
||||
val maxFlowHeight = (LocalConfiguration.current.screenHeightDp * 0.5f).dp
|
||||
|
||||
BackHandler(enabled = true) { onExit() }
|
||||
|
||||
val sphereDescLabel = stringResource(R.string.agent_text_sphere_desc)
|
||||
val sphereDescription = remember(sphereState) {
|
||||
"$sphereDescLabel ${sphereState.name.lowercase()}"
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// Opaque so the chat underneath is fully hidden — this is a mode,
|
||||
// not a translucent overlay.
|
||||
.background(RelayRefresh.Background)
|
||||
// Consume any pointer event the children (composer, exit button, text
|
||||
// scroll) didn't handle, so stray taps/swipes in the empty areas don't
|
||||
// fall through to the chat + session drawer behind this mode. Children
|
||||
// run leaf-first on the same Main pass, so this only catches the gaps
|
||||
// (mirrors the voice overlay's focus-mode scrim).
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent().changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp),
|
||||
) {
|
||||
// Explicit dismiss — the only way out besides system back.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(onClick = onExit) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.agent_text_exit_clean),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Flexible top spacer — with the bottom one it vertically centers the
|
||||
// sphere + text group; as the text grows the spacers yield and the
|
||||
// sphere rises toward the top third.
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// The ambient sphere is optional. Hiding it collapses the visual's
|
||||
// slot so the conversation remains centered instead of leaving a
|
||||
// large blank region above the text.
|
||||
if (backgroundVisualizationEnabled) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(sphereHeight),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.semantics { contentDescription = sphereDescription },
|
||||
) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
state = sphereState,
|
||||
intensity = streamingIntensity,
|
||||
toolCallBurst = toolCallBurst,
|
||||
// Pin to a still frame when motion is suppressed.
|
||||
paused = !sphereAnimated,
|
||||
),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AgentTextFlow(
|
||||
content = flowContent,
|
||||
streaming = flowStreaming,
|
||||
messageId = conversationKey,
|
||||
motionEnabled = textMotionEnabled,
|
||||
// Content-sized reading area (capped ~half the screen) directly
|
||||
// below the sphere — no gap between them. Grows + scrolls with the
|
||||
// reply, which is what lifts the centered group (and the sphere).
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 560.dp)
|
||||
.heightIn(min = 96.dp, max = maxFlowHeight)
|
||||
.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
// Flexible bottom spacer — balances the top one to keep the
|
||||
// sphere + text group vertically centered.
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
CleanModeComposer(
|
||||
enabled = enabled,
|
||||
onSend = onSend,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
|
||||
/** Small, read-only Git projection supplied by the native workspace owner. */
|
||||
data class ChatGitWorkspaceSummary(
|
||||
val branch: String,
|
||||
val changeCount: Int,
|
||||
val additions: Int? = null,
|
||||
val deletions: Int? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ChatGitContextButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.AccountTree,
|
||||
contentDescription = stringResource(R.string.chat_git_open_workspace),
|
||||
onClick = onClick,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(9.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
shape = CircleShape,
|
||||
color = RelayRefresh.Green,
|
||||
border = BorderStroke(1.5.dp, RelayRefresh.Background),
|
||||
content = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatGitWorkspaceRail(
|
||||
summary: ChatGitWorkspaceSummary,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val branch = summary.branch.trim()
|
||||
val changeCount = summary.changeCount.coerceAtLeast(0)
|
||||
val changeLabel = pluralStringResource(
|
||||
R.plurals.chat_git_change_count,
|
||||
changeCount,
|
||||
changeCount,
|
||||
)
|
||||
val additions = summary.additions?.coerceAtLeast(0)
|
||||
val deletions = summary.deletions?.coerceAtLeast(0)
|
||||
val a11yLabel = buildList {
|
||||
add(stringResource(R.string.chat_git_branch, branch))
|
||||
add(changeLabel)
|
||||
additions?.let { add(stringResource(R.string.chat_git_additions, it)) }
|
||||
deletions?.let { add(stringResource(R.string.chat_git_deletions, it)) }
|
||||
add(stringResource(R.string.chat_git_open_workspace))
|
||||
}.joinToString(". ")
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(38.dp)
|
||||
.clearAndSetSemantics { contentDescription = a11yLabel },
|
||||
shape = appearanceRoundedCornerShape(12.dp),
|
||||
color = RelayRefresh.Background.copy(alpha = 0.72f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AccountTree,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Cyan,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
Text(
|
||||
text = branch,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Paper,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 92.dp),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(16.dp)
|
||||
.background(RelayRefresh.LineStrong),
|
||||
)
|
||||
Text(
|
||||
text = changeLabel,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
additions?.let {
|
||||
Text(
|
||||
text = "+$it",
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Green,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
deletions?.let {
|
||||
Text(
|
||||
text = "-$it",
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Danger,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Muted,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -54,6 +54,22 @@ 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
|
||||
|
||||
internal enum class SphereMotionMode {
|
||||
Still,
|
||||
Procedural,
|
||||
}
|
||||
|
||||
internal fun sphereMotionMode(
|
||||
motionVisible: Boolean,
|
||||
fixedTime: Float?,
|
||||
fixedColorPhase: Float?,
|
||||
): SphereMotionMode {
|
||||
if (!motionVisible || fixedTime != null || fixedColorPhase != null) {
|
||||
return SphereMotionMode.Still
|
||||
}
|
||||
return SphereMotionMode.Procedural
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MorphingSphere(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -64,7 +80,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 +121,18 @@ 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.
|
||||
// Every visible Sphere uses the same display-synced procedural loop so
|
||||
// startup, chat, and voice all feel equally smooth. Backgrounded and
|
||||
// explicitly paused/reduced-motion renderers still pin a static frame.
|
||||
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(
|
||||
motionVisible = motionVisible,
|
||||
fixedTime = fixedTime,
|
||||
fixedColorPhase = fixedColorPhase,
|
||||
)
|
||||
if (motionMode == SphereMotionMode.Procedural) {
|
||||
LaunchedEffect(motionMode) {
|
||||
var lastNanos = withFrameNanos { it }
|
||||
while (true) {
|
||||
val now = withFrameNanos { it }
|
||||
|
||||
@@ -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,7 +1,14 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -20,13 +27,20 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -37,10 +51,12 @@ import androidx.compose.ui.unit.dp
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* "What's New" sheet, shown automatically on a version bump (RelayApp) and from
|
||||
* the About screen.
|
||||
* Expanded "What's New" sheet, opened from the non-blocking post-update toast
|
||||
* or directly from the About screen.
|
||||
*
|
||||
* As of the multi-version changelog work, the single source of truth is the
|
||||
* bundled [changelog.json] asset (see [ChangelogStore]). This dialog renders the
|
||||
@@ -60,6 +76,9 @@ fun WhatsNewDialog(
|
||||
onViewHistory: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
var exiting by remember { mutableStateOf(false) }
|
||||
// Prefer the structured changelog's latest entry; fall back to the legacy
|
||||
// text asset so a missing/garbled JSON never leaves the dialog empty.
|
||||
val latest = remember { ChangelogStore.load(context).versions.firstOrNull() }
|
||||
@@ -67,20 +86,39 @@ fun WhatsNewDialog(
|
||||
latest?.toNotes() ?: parseWhatsNew(loadWhatsNew(context))
|
||||
}
|
||||
|
||||
fun leave(afterExit: () -> Unit) {
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
scope.launch {
|
||||
visible = false
|
||||
delay(180)
|
||||
afterExit()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!exiting) visible = true
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
onDismissRequest = { leave(onDismiss) },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 32.dp)
|
||||
.widthIn(max = 560.dp)
|
||||
.heightIn(max = 680.dp),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
tonalElevation = 6.dp,
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(220)) + scaleIn(tween(220), initialScale = 0.92f),
|
||||
exit = fadeOut(tween(180)) + scaleOut(tween(180), targetScale = 0.96f),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 32.dp)
|
||||
.widthIn(max = 560.dp)
|
||||
.heightIn(max = 680.dp),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -99,7 +137,7 @@ fun WhatsNewDialog(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
IconButton(onClick = { leave(onDismiss) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.changelog_close),
|
||||
@@ -146,20 +184,48 @@ fun WhatsNewDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
onViewHistory?.let { action ->
|
||||
androidx.compose.material3.OutlinedButton(
|
||||
onClick = action,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text(stringResource(R.string.whats_new_full_history)) }
|
||||
WhatsNewActions(
|
||||
onDismiss = { leave(onDismiss) },
|
||||
onViewHistory = onViewHistory?.let { action -> { leave(action) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WhatsNewActions(
|
||||
onDismiss: () -> Unit,
|
||||
onViewHistory: (() -> Unit)?,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
val stackActions = LocalDensity.current.fontScale >= 1.3f || maxWidth < 320.dp
|
||||
if (stackActions) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
onViewHistory?.let { action ->
|
||||
OutlinedButton(onClick = action, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.whats_new_full_history))
|
||||
}
|
||||
Button(onClick = onDismiss, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.whats_new_got_it))
|
||||
}
|
||||
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.whats_new_got_it))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
onViewHistory?.let { action ->
|
||||
OutlinedButton(onClick = action, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.whats_new_full_history))
|
||||
}
|
||||
}
|
||||
Button(onClick = onDismiss, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.whats_new_got_it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,15 +288,55 @@ fun ColumnScope.VersionNotesBody(groups: List<WhatsNewGroup>) {
|
||||
* followed by [VersionNotesBody]. Used by ChangelogScreen for each release.
|
||||
*/
|
||||
@Composable
|
||||
fun VersionNotesBlock(entry: ChangelogVersion) {
|
||||
fun VersionNotesBlock(
|
||||
entry: ChangelogVersion,
|
||||
showVersionLine: Boolean = true,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = entry.subtitle(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
VersionNotesBody(entry.toGroups())
|
||||
val highlight = entry.highlight
|
||||
if (highlight == null) {
|
||||
if (showVersionLine) {
|
||||
Text(
|
||||
text = entry.subtitle(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
VersionNotesBody(entry.toGroups())
|
||||
} else {
|
||||
if (showVersionLine) {
|
||||
Text(
|
||||
text = entry.versionLine(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = highlight.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
highlight.summary?.takeIf { it.isNotBlank() }?.let { summary ->
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
VersionNotesBody(listOf(WhatsNewGroup(header = null, bullets = highlight.bullets)))
|
||||
if (entry.improvements.isNotEmpty()) {
|
||||
VersionNotesBody(
|
||||
listOf(
|
||||
WhatsNewGroup(
|
||||
header = stringResource(R.string.changelog_also_improved),
|
||||
bullets = entry.improvements,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,12 +351,32 @@ data class ChangelogSection(
|
||||
val bullets: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** Editorially selected reason to care about a release. */
|
||||
@Serializable
|
||||
data class ChangelogHighlight(
|
||||
val title: String,
|
||||
val summary: String? = null,
|
||||
val bullets: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** Compact preview of noteworthy items beyond the primary highlight. */
|
||||
@Serializable
|
||||
data class ChangelogToastDigest(
|
||||
val additionalFeatureCount: Int = 0,
|
||||
val fixCount: Int = 0,
|
||||
val preview: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** A single released version's user-facing notes. */
|
||||
@Serializable
|
||||
data class ChangelogVersion(
|
||||
val version: String,
|
||||
val title: String? = null,
|
||||
val date: String? = null,
|
||||
val highlight: ChangelogHighlight? = null,
|
||||
val improvements: List<String> = emptyList(),
|
||||
val toastDigest: ChangelogToastDigest? = null,
|
||||
val playNotes: String? = null,
|
||||
val sections: List<ChangelogSection> = emptyList(),
|
||||
) {
|
||||
/** "v1.2.0 — Make it yours · 2026-06-20" (each token optional but version). */
|
||||
@@ -261,8 +387,21 @@ data class ChangelogVersion(
|
||||
return head + titlePart + datePart
|
||||
}
|
||||
|
||||
/** Compact metadata line used when a curated highlight owns the headline. */
|
||||
fun versionLine(): String {
|
||||
val datePart = date?.takeIf { it.isNotBlank() }?.let { " · $it" } ?: ""
|
||||
return "v$version$datePart"
|
||||
}
|
||||
|
||||
fun toGroups(): List<WhatsNewGroup> =
|
||||
sections.map { WhatsNewGroup(it.header?.takeIf { h -> h.isNotBlank() }, it.bullets) }
|
||||
highlight?.let { curated ->
|
||||
buildList {
|
||||
add(WhatsNewGroup(curated.title.takeIf { it.isNotBlank() }, curated.bullets))
|
||||
if (improvements.isNotEmpty()) {
|
||||
add(WhatsNewGroup("Also improved", improvements))
|
||||
}
|
||||
}
|
||||
} ?: sections.map { WhatsNewGroup(it.header?.takeIf { h -> h.isNotBlank() }, it.bullets) }
|
||||
|
||||
/** Adapt this version into the dialog's [WhatsNewNotes] shape. */
|
||||
fun toNotes(): WhatsNewNotes = WhatsNewNotes(
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.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.draw.alpha
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalAccessibilityManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val DEFAULT_AUTO_DISMISS_MILLIS = 10_000L
|
||||
private const val EXIT_ANIMATION_MILLIS = 180L
|
||||
private const val SWIPE_DISMISS_FRACTION = 0.28f
|
||||
|
||||
/**
|
||||
* Non-blocking post-update notice. It stays outside the dialog window so the
|
||||
* app remains usable, then hands off to the full [WhatsNewDialog] only when the
|
||||
* user chooses to expand it.
|
||||
*/
|
||||
@Composable
|
||||
fun WhatsNewToast(
|
||||
onDismiss: () -> Unit,
|
||||
onExpand: () -> Unit,
|
||||
autoDismissMillis: Long = DEFAULT_AUTO_DISMISS_MILLIS,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val entry = remember { ChangelogStore.load(context).versions.firstOrNull() } ?: return
|
||||
val progress = remember { Animatable(1f) }
|
||||
val horizontalOffset = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val accessibilityManager = LocalAccessibilityManager.current
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
var widthPx by remember { mutableIntStateOf(1) }
|
||||
|
||||
fun leave(afterExit: () -> Unit) {
|
||||
if (!visible) return
|
||||
scope.launch {
|
||||
visible = false
|
||||
delay(EXIT_ANIMATION_MILLIS)
|
||||
afterExit()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
visible = true
|
||||
val recommendedTimeout = accessibilityManager?.calculateRecommendedTimeoutMillis(
|
||||
originalTimeoutMillis = autoDismissMillis,
|
||||
containsIcons = true,
|
||||
containsText = true,
|
||||
containsControls = true,
|
||||
) ?: autoDismissMillis
|
||||
progress.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = recommendedTimeout.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(),
|
||||
easing = LinearEasing,
|
||||
),
|
||||
)
|
||||
leave(onDismiss)
|
||||
}
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.TopCenter,
|
||||
properties = PopupProperties(focusable = false),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(220)) + slideInVertically(tween(220)) { -it / 2 },
|
||||
exit = fadeOut(tween(EXIT_ANIMATION_MILLIS.toInt())) +
|
||||
slideOutVertically(tween(EXIT_ANIMATION_MILLIS.toInt())) { -it / 3 },
|
||||
) {
|
||||
val swipeProgress = (abs(horizontalOffset.value) / widthPx).coerceIn(0f, 1f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.widthIn(max = 560.dp)
|
||||
.fillMaxWidth()
|
||||
.offset { IntOffset(horizontalOffset.value.roundToInt(), 0) }
|
||||
.alpha(1f - (swipeProgress * 0.7f))
|
||||
.onSizeChanged { widthPx = it.width.coerceAtLeast(1) }
|
||||
.pointerInput(onDismiss) {
|
||||
var dragTarget = horizontalOffset.value
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { dragTarget = horizontalOffset.value },
|
||||
onHorizontalDrag = { change, amount ->
|
||||
change.consume()
|
||||
dragTarget += amount
|
||||
scope.launch { horizontalOffset.snapTo(dragTarget) }
|
||||
},
|
||||
onDragEnd = {
|
||||
if (abs(horizontalOffset.value) >= widthPx * SWIPE_DISMISS_FRACTION) {
|
||||
scope.launch {
|
||||
horizontalOffset.animateTo(
|
||||
targetValue = if (horizontalOffset.value < 0) -widthPx.toFloat() else widthPx.toFloat(),
|
||||
animationSpec = tween(EXIT_ANIMATION_MILLIS.toInt()),
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
scope.launch { horizontalOffset.animateTo(0f, tween(160)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
WhatsNewToastContent(
|
||||
entry = entry,
|
||||
progress = progress.value,
|
||||
onExpand = { leave(onExpand) },
|
||||
onDismiss = { leave(onDismiss) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun WhatsNewToastContent(
|
||||
entry: ChangelogVersion,
|
||||
progress: Float,
|
||||
onExpand: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = stringResource(R.string.whats_new_title)
|
||||
val highlight = entry.highlight
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shape = appearanceRoundedCornerShape(18.dp),
|
||||
tonalElevation = 6.dp,
|
||||
shadowElevation = 10.dp,
|
||||
modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 6.dp, top = 10.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable(
|
||||
role = Role.Button,
|
||||
onClickLabel = title,
|
||||
onClick = onExpand,
|
||||
)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "$title · v${entry.version}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = highlight?.title ?: entry.title.orEmpty(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
highlight?.summary?.takeIf(String::isNotBlank)?.let { summary ->
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.changelog_close),
|
||||
)
|
||||
}
|
||||
}
|
||||
entry.toastDigest?.takeIf {
|
||||
it.additionalFeatureCount > 0 || it.fixCount > 0
|
||||
}?.let { digest ->
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.32f),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
role = Role.Button,
|
||||
onClickLabel = stringResource(R.string.changelog_view_all),
|
||||
onClick = onExpand,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val counts = buildList {
|
||||
if (digest.additionalFeatureCount > 0) {
|
||||
add(
|
||||
pluralStringResource(
|
||||
R.plurals.changelog_additional_feature_count,
|
||||
digest.additionalFeatureCount,
|
||||
digest.additionalFeatureCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (digest.fixCount > 0) {
|
||||
add(
|
||||
pluralStringResource(
|
||||
R.plurals.changelog_fix_count,
|
||||
digest.fixCount,
|
||||
digest.fixCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.changelog_toast_also,
|
||||
counts.joinToString(" · "),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = digest.preview.joinToString(", ") + "…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.changelog_view_all),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.coerceIn(0f, 1f) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clearAndSetSemantics { },
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -3,13 +3,13 @@ package com.hermesandroid.relay.ui.screens
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -33,12 +34,21 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import com.hermesandroid.relay.R
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.components.ChangelogStore
|
||||
import com.hermesandroid.relay.ui.components.ChangelogVersion
|
||||
import com.hermesandroid.relay.ui.components.VersionNotesBody
|
||||
import com.hermesandroid.relay.ui.components.VersionNotesBlock
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Full release history, sourced from the bundled `changelog.json` (the same
|
||||
@@ -95,19 +105,26 @@ fun ChangelogScreen(
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
Column(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
versions.forEachIndexed { index, entry ->
|
||||
itemsIndexed(
|
||||
items = versions,
|
||||
key = { _, entry -> entry.version },
|
||||
) { index, entry ->
|
||||
// Latest version is expanded; older ones start collapsed.
|
||||
ChangelogVersionCard(
|
||||
entry = entry,
|
||||
initiallyExpanded = index == 0,
|
||||
installed = isInstalledRelease(
|
||||
releaseVersion = entry.version,
|
||||
buildVersion = BuildConfig.VERSION_NAME,
|
||||
flavor = BuildConfig.FLAVOR,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -122,21 +139,26 @@ fun ChangelogScreen(
|
||||
private fun ChangelogVersionCard(
|
||||
entry: ChangelogVersion,
|
||||
initiallyExpanded: Boolean,
|
||||
installed: Boolean,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
var expanded by remember(entry.version) { mutableStateOf(initiallyExpanded) }
|
||||
val expansionState = stringResource(
|
||||
if (expanded) R.string.changelog_state_expanded else R.string.changelog_state_collapsed,
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
shape = appearanceRoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.clickable(role = Role.Button) { expanded = !expanded }
|
||||
.semantics { stateDescription = expansionState }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -148,12 +170,20 @@ private fun ChangelogVersionCard(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
entry.date?.takeIf { it.isNotBlank() }?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
entry.date?.takeIf { it.isNotBlank() }?.let { date ->
|
||||
Text(
|
||||
text = formatReleaseDate(date),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (installed) {
|
||||
InstalledVersionBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
@@ -162,7 +192,7 @@ private fun ChangelogVersionCard(
|
||||
} else {
|
||||
Icons.Filled.ExpandMore
|
||||
},
|
||||
contentDescription = if (expanded) stringResource(R.string.changelog_collapse) else stringResource(R.string.changelog_expand),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -174,9 +204,43 @@ private fun ChangelogVersionCard(
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
VersionNotesBody(entry.toGroups())
|
||||
VersionNotesBlock(entry, showVersionLine = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstalledVersionBadge() {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
|
||||
shape = appearanceRoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.changelog_installed),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatReleaseDate(raw: String, locale: Locale = Locale.getDefault()): String =
|
||||
runCatching {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale)
|
||||
.format(LocalDate.parse(raw))
|
||||
}.getOrDefault(raw)
|
||||
|
||||
internal fun isInstalledRelease(
|
||||
releaseVersion: String,
|
||||
buildVersion: String,
|
||||
flavor: String,
|
||||
): Boolean {
|
||||
val canonicalBuildVersion = flavor
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { buildVersion.removeSuffix("-$it") }
|
||||
?: buildVersion
|
||||
return releaseVersion == canonicalBuildVersion
|
||||
}
|
||||
|
||||
@@ -391,6 +391,32 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val showGitWorkspaceInChat by
|
||||
connectionViewModel.showGitWorkspaceInChat.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_settings_show_git_workspace),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.chat_settings_show_git_workspace_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = showGitWorkspaceInChat,
|
||||
onCheckedChange = connectionViewModel::setShowGitWorkspaceInChat,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val recentPromptsEnabled by
|
||||
connectionViewModel.chatRecentPromptsEnabled.collectAsState()
|
||||
Row(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,846 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.viewmodel.GitConfirmationStrings
|
||||
import com.hermesandroid.relay.viewmodel.GitContentViewState
|
||||
import com.hermesandroid.relay.viewmodel.GitMessageGenerationState
|
||||
import com.hermesandroid.relay.viewmodel.GitMutationState
|
||||
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
|
||||
import com.hermesandroid.relay.viewmodel.GitStateUiState
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.viewmodel.GitTarget
|
||||
|
||||
private enum class FileFilter { All, Staged, Modified, Untracked }
|
||||
private enum class ContentMode { Diff, File }
|
||||
private data class DisplayFile(
|
||||
val path: String,
|
||||
val filter: FileFilter,
|
||||
val additions: Int?,
|
||||
val deletions: Int?,
|
||||
)
|
||||
|
||||
/** First-class native Git workspace backed by the optional Relay contribution. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GitStateScreen(viewModel: GitStateViewModel, onBack: () -> Unit) {
|
||||
val reposState by viewModel.repos.collectAsState()
|
||||
val detailState by viewModel.detail.collectAsState()
|
||||
val contentState by viewModel.content.collectAsState()
|
||||
val mutation by viewModel.mutation.collectAsState()
|
||||
val messageGeneration by viewModel.messageGeneration.collectAsState()
|
||||
val stashNotice by viewModel.stashNotice.collectAsState()
|
||||
val hasGrant by viewModel.writeGrant.collectAsState()
|
||||
val selectedRepoId by viewModel.selectedRepoId.collectAsState()
|
||||
|
||||
// A path can be staged and modified at the same time. Keep the category in
|
||||
// the selection identity so selecting one row never silently selects the
|
||||
// other layer or disables its correct action.
|
||||
var selection by remember { mutableStateOf(setOf<DisplayFile>()) }
|
||||
var filter by rememberSaveable { mutableStateOf(FileFilter.All) }
|
||||
var expandedPath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var contentMode by rememberSaveable { mutableStateOf(ContentMode.Diff) }
|
||||
var showRepos by rememberSaveable { mutableStateOf(false) }
|
||||
var showBranches by rememberSaveable { mutableStateOf(false) }
|
||||
var showCommit by rememberSaveable { mutableStateOf(false) }
|
||||
var showOverflow by rememberSaveable { mutableStateOf(false) }
|
||||
var pushAfter by rememberSaveable { mutableStateOf(false) }
|
||||
var pendingConfirm by remember { mutableStateOf<ConfirmationRequest?>(null) }
|
||||
|
||||
val repos = (reposState as? GitStateUiState.Ready)?.repos.orEmpty()
|
||||
val selectedRepo = repos.firstOrNull { it.id == selectedRepoId }
|
||||
val detail = detailState as? GitRepoDetailState.Ready
|
||||
val currentBranch = detail?.branches?.firstOrNull { it.isCurrent }
|
||||
val changeCount = detail?.status?.uniqueChangeCount() ?: 0
|
||||
val stageSelection: () -> Unit = {
|
||||
val unstaged = selection.filter { it.filter != FileFilter.Staged }.map { it.path }
|
||||
if (unstaged.isEmpty()) {
|
||||
viewModel.unstage(selection.map { it.path }.distinct())
|
||||
} else {
|
||||
viewModel.stage(unstaged.distinct())
|
||||
}
|
||||
selection = emptySet()
|
||||
}
|
||||
val discardSelection: () -> Unit = {
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
val discardable = selection.filter { it.filter != FileFilter.Staged }
|
||||
if (discardable.isNotEmpty()) {
|
||||
pendingConfirm = ConfirmationRequest.Discard(
|
||||
paths = discardable.map { it.path }.distinct(),
|
||||
deleteUntracked = discardable.any { it.filter == FileFilter.Untracked },
|
||||
target = target,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(repos, selectedRepoId) {
|
||||
if (repos.size == 1 && selectedRepoId == null) viewModel.selectRepo(repos.single().id)
|
||||
}
|
||||
LaunchedEffect(selectedRepoId) {
|
||||
selection = emptySet()
|
||||
expandedPath = null
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.git_state_back))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Surface(
|
||||
onClick = { showRepos = true },
|
||||
enabled = repos.isNotEmpty(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 5.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
selectedRepo?.name ?: stringResource(R.string.git_state_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (repos.isNotEmpty()) Icon(Icons.Filled.ExpandMore, "Choose repository", Modifier.size(20.dp))
|
||||
}
|
||||
selectedRepo?.let {
|
||||
Text(branchSummary(it, currentBranch), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { selectedRepo?.let { viewModel.selectRepo(it.id) } ?: viewModel.loadRepos() }) {
|
||||
Icon(Icons.Filled.Refresh, "Refresh Git workspace")
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showOverflow = true }) { Icon(Icons.Filled.MoreVert, "More Git actions") }
|
||||
DropdownMenu(expanded = showOverflow, onDismissRequest = { showOverflow = false }) {
|
||||
DropdownMenuItem(text = { Text("Choose repository") }, onClick = { showOverflow = false; showRepos = true })
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.git_state_branches)) }, enabled = detail != null, onClick = { showOverflow = false; showBranches = true })
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (detail != null) {
|
||||
Surface(shadowElevation = 8.dp, tonalElevation = 2.dp) {
|
||||
Column {
|
||||
if (selection.isNotEmpty()) {
|
||||
SelectionRail(
|
||||
count = selection.size,
|
||||
canWrite = hasGrant,
|
||||
allStaged = selection.all { it.filter == FileFilter.Staged },
|
||||
onStage = stageSelection,
|
||||
onDiscard = discardSelection,
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedButton(onClick = { showBranches = true }, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.AccountTree, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.git_state_branches))
|
||||
}
|
||||
Button(onClick = { showCommit = true }, enabled = hasGrant && detail.status.counts.staged > 0, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.AutoAwesome, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.git_state_commit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
when (val state = reposState) {
|
||||
GitStateUiState.Loading -> FullState(Modifier.padding(padding), true, "Finding repositories")
|
||||
is GitStateUiState.Unavailable -> UnavailableState(Modifier.padding(padding), state.message, viewModel::loadRepos)
|
||||
is GitStateUiState.Error -> UnavailableState(Modifier.padding(padding), state.message, viewModel::loadRepos)
|
||||
is GitStateUiState.Ready -> when {
|
||||
state.repos.isEmpty() -> FullState(Modifier.padding(padding), false, "No Git repositories found", "Add a repository to the host's configured Git roots, then refresh.")
|
||||
selectedRepo == null -> RepositoryPrompt(Modifier.padding(padding), state.repos, viewModel::selectRepo)
|
||||
else -> WorkspaceBody(
|
||||
modifier = Modifier.padding(padding),
|
||||
repo = selectedRepo,
|
||||
reposNotice = state.notice,
|
||||
detailState = detailState,
|
||||
mutation = mutation,
|
||||
stashNotice = stashNotice,
|
||||
hasGrant = hasGrant,
|
||||
filter = filter,
|
||||
onFilter = { filter = it },
|
||||
selection = selection,
|
||||
onToggleSelected = { file ->
|
||||
selection = if (file in selection) selection - file else selection + file
|
||||
},
|
||||
expandedPath = expandedPath,
|
||||
contentMode = contentMode,
|
||||
contentState = contentState,
|
||||
onOpen = { file, mode ->
|
||||
if (file.filter != FileFilter.Untracked) {
|
||||
val opening = expandedPath != file.path || contentMode != mode
|
||||
expandedPath = if (opening) file.path else null
|
||||
contentMode = mode
|
||||
if (opening) {
|
||||
if (mode == ContentMode.File) viewModel.loadFile(file.path)
|
||||
else viewModel.loadDiff(file.path, if (file.filter == FileFilter.Staged) "staged" else "unstaged")
|
||||
}
|
||||
}
|
||||
},
|
||||
onFetch = viewModel::fetch,
|
||||
onPull = viewModel::pull,
|
||||
onPush = { viewModel.currentTarget()?.let { pendingConfirm = ConfirmationRequest.Push(it) } },
|
||||
onClearMutation = viewModel::clearMutationError,
|
||||
onRetry = { viewModel.selectRepo(selectedRepo.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showRepos) RepositorySheet(repos, selectedRepo?.id, { showRepos = false }) { showRepos = false; viewModel.selectRepo(it) }
|
||||
if (showBranches && detail != null) {
|
||||
BranchSheet(
|
||||
branches = detail.branches,
|
||||
hasGrant = hasGrant,
|
||||
dirty = changeCount > 0,
|
||||
onDismiss = { showBranches = false },
|
||||
onSwitch = { ref ->
|
||||
showBranches = false
|
||||
if (changeCount > 0) viewModel.currentTarget()?.let { pendingConfirm = ConfirmationRequest.DirtyCheckout(ref, it) } else viewModel.checkout(ref)
|
||||
},
|
||||
onStashSwitch = { showBranches = false; viewModel.stashCheckout(it) },
|
||||
onCreate = { name, track -> showBranches = false; viewModel.checkout("", newBranch = name, track = track) },
|
||||
)
|
||||
}
|
||||
if (showCommit && detail != null) {
|
||||
val stagedPaths = detail.status.staged.map { it.path }
|
||||
CommitDialog(
|
||||
hasStaged = stagedPaths.isNotEmpty(),
|
||||
generating = messageGeneration is GitMessageGenerationState.Loading,
|
||||
generatedMessage = (messageGeneration as? GitMessageGenerationState.Ready)?.message.orEmpty(),
|
||||
generationNotice = (messageGeneration as? GitMessageGenerationState.Ready)?.notice,
|
||||
pushAfter = pushAfter,
|
||||
onPushAfterChange = { pushAfter = it },
|
||||
onGenerate = { viewModel.generateCommitMessage(stagedPaths) },
|
||||
onDismiss = { showCommit = false },
|
||||
onCommit = { message ->
|
||||
showCommit = false
|
||||
viewModel.commit(message) { target -> if (pushAfter) pendingConfirm = ConfirmationRequest.Push(target) }
|
||||
},
|
||||
)
|
||||
}
|
||||
pendingConfirm?.let { request ->
|
||||
ConfirmationDialog(
|
||||
request,
|
||||
onDismiss = { pendingConfirm = null },
|
||||
onDiscard = { paths, deleteUntracked, target -> pendingConfirm = null; viewModel.discard(paths, GitConfirmationStrings.DISCARD, deleteUntracked, target) },
|
||||
onPush = { target -> pendingConfirm = null; viewModel.push(GitConfirmationStrings.PUSH, expectedTarget = target) },
|
||||
onCheckout = { ref, target -> pendingConfirm = null; viewModel.checkout(ref, GitConfirmationStrings.DIRTY_CHECKOUT, expectedTarget = target) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkspaceBody(
|
||||
modifier: Modifier,
|
||||
repo: GitRepo,
|
||||
reposNotice: String?,
|
||||
detailState: GitRepoDetailState,
|
||||
mutation: GitMutationState,
|
||||
stashNotice: String?,
|
||||
hasGrant: Boolean,
|
||||
filter: FileFilter,
|
||||
onFilter: (FileFilter) -> Unit,
|
||||
selection: Set<DisplayFile>,
|
||||
onToggleSelected: (DisplayFile) -> Unit,
|
||||
expandedPath: String?,
|
||||
contentMode: ContentMode,
|
||||
contentState: GitContentViewState,
|
||||
onOpen: (DisplayFile, ContentMode) -> Unit,
|
||||
onFetch: () -> Unit,
|
||||
onPull: () -> Unit,
|
||||
onPush: () -> Unit,
|
||||
onClearMutation: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (detailState) {
|
||||
GitRepoDetailState.Idle, GitRepoDetailState.Loading -> FullState(modifier, true, "Loading workspace")
|
||||
is GitRepoDetailState.Error -> UnavailableState(modifier, detailState.message, onRetry)
|
||||
is GitRepoDetailState.Ready -> {
|
||||
val status = detailState.status
|
||||
val allFiles = buildList {
|
||||
status.staged.forEach { add(DisplayFile(it.path, FileFilter.Staged, it.additions, it.deletions)) }
|
||||
status.modified.forEach { add(DisplayFile(it.path, FileFilter.Modified, it.additions, it.deletions)) }
|
||||
status.untracked.forEach { add(DisplayFile(it.path, FileFilter.Untracked, it.additions, it.deletions)) }
|
||||
}
|
||||
val visible = allFiles.filter { filter == FileFilter.All || it.filter == filter }
|
||||
LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 18.dp)) {
|
||||
item {
|
||||
SummaryRail(repo, detailState)
|
||||
RemoteActions(hasGrant, status, onFetch, onPull, onPush)
|
||||
reposNotice?.let { NoticeCard(it) }
|
||||
if (!hasGrant) WriteGrantNotice()
|
||||
MutationBanner(mutation, onClearMutation)
|
||||
stashNotice?.let { NoticeCard(it) }
|
||||
if (status.truncated) NoticeCard(stringResource(R.string.git_state_truncated), error = true)
|
||||
FilterRow(filter, status, onFilter)
|
||||
}
|
||||
if (visible.isEmpty()) item { EmptyFilterState(filter) }
|
||||
FileFilter.entries.filter { it != FileFilter.All }.forEach { group ->
|
||||
val files = visible.filter { it.filter == group }
|
||||
if (files.isNotEmpty()) {
|
||||
item { GroupHeader(group.name, files.size) }
|
||||
items(files, key = { "${group.name}:${it.path}" }) { file ->
|
||||
FileRow(
|
||||
file = file,
|
||||
selected = file in selection,
|
||||
expanded = expandedPath == file.path,
|
||||
mode = contentMode,
|
||||
contentState = contentState,
|
||||
onToggleSelected = { onToggleSelected(file) },
|
||||
onOpen = { onOpen(file, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRail(repo: GitRepo, detail: GitRepoDetailState.Ready) {
|
||||
val count = detail.status.uniqueChangeCount()
|
||||
val current = detail.branches.firstOrNull { it.isCurrent }
|
||||
Surface(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("$count changes", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
VerticalDivider()
|
||||
Text("+${detail.status.counts.additions}", color = Color(0xFF2E7D32), style = MaterialTheme.typography.labelLarge)
|
||||
VerticalDivider()
|
||||
Text("−${detail.status.counts.deletions}", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelLarge)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
color = Color.Transparent,
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 9.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.AccountTree, null, Modifier.size(16.dp)); Spacer(Modifier.width(5.dp))
|
||||
Text(current?.name ?: repo.currentBranch ?: "Detached", style = MaterialTheme.typography.labelLarge, maxLines = 1)
|
||||
if (repo.dirty || count > 0) { Spacer(Modifier.width(7.dp)); Box(Modifier.size(7.dp).background(MaterialTheme.colorScheme.tertiary, CircleShape)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerticalDivider() {
|
||||
Box(
|
||||
Modifier.padding(horizontal = 10.dp).width(1.dp).height(22.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteActions(hasGrant: Boolean, status: GitStatus, onFetch: () -> Unit, onPull: () -> Unit, onPush: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onFetch, enabled = hasGrant && status.counts.untracked == 0, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.Refresh, null, Modifier.size(18.dp)); Spacer(Modifier.width(6.dp)); Text(stringResource(R.string.git_state_fetch))
|
||||
}
|
||||
VerticalDivider()
|
||||
TextButton(onClick = onPull, enabled = hasGrant, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.Download, null, Modifier.size(18.dp)); Spacer(Modifier.width(6.dp)); Text(stringResource(R.string.git_state_pull))
|
||||
}
|
||||
VerticalDivider()
|
||||
TextButton(onClick = onPush, enabled = hasGrant && status.counts.staged == 0, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.FileUpload, null, Modifier.size(18.dp)); Spacer(Modifier.width(6.dp)); Text(stringResource(R.string.git_state_push))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterRow(selected: FileFilter, status: GitStatus, onSelect: (FileFilter) -> Unit) {
|
||||
val counts = mapOf(
|
||||
FileFilter.All to status.uniqueChangeCount(),
|
||||
FileFilter.Staged to status.counts.staged,
|
||||
FileFilter.Modified to status.counts.modified,
|
||||
FileFilter.Untracked to status.counts.untracked,
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
FileFilter.entries.forEach { item ->
|
||||
val isSelected = selected == item
|
||||
Surface(
|
||||
onClick = { onSelect(item) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Box(Modifier.padding(horizontal = 3.dp, vertical = 9.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"${item.name} ${counts.getValue(item)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun GitStatus.uniqueChangeCount(): Int = counts.changes.takeIf { it >= 0 } ?: buildSet {
|
||||
staged.forEach { add(it.path) }
|
||||
modified.forEach { add(it.path) }
|
||||
untracked.forEach { add(it.path) }
|
||||
}.size
|
||||
|
||||
@Composable
|
||||
private fun FileRow(
|
||||
file: DisplayFile,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
mode: ContentMode,
|
||||
contentState: GitContentViewState,
|
||||
onToggleSelected: () -> Unit,
|
||||
onOpen: (ContentMode) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.then(
|
||||
if (file.filter == FileFilter.Untracked) Modifier
|
||||
else Modifier.clickable { onOpen(ContentMode.Diff) },
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onToggleSelected() },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = "Select ${file.path}"
|
||||
},
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(file.path.substringAfterLast('/'), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
file.path.substringBeforeLast('/', "").takeIf { it.isNotEmpty() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (file.filter == FileFilter.Untracked) {
|
||||
Text("Preview unavailable until staged", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
file.additions?.let { additions ->
|
||||
Text("+$additions", color = Color(0xFF2E7D32), style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
file.deletions?.let { deletions ->
|
||||
Text("−$deletions", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
StatusBadge(file.filter)
|
||||
if (file.filter != FileFilter.Untracked) {
|
||||
Icon(if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, if (expanded) "Collapse" else "Expand")
|
||||
}
|
||||
}
|
||||
if (expanded && file.filter != FileFilter.Untracked) {
|
||||
Row(Modifier.padding(start = 56.dp, end = 16.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (file.filter != FileFilter.Untracked) FilterChip(mode == ContentMode.Diff, { onOpen(ContentMode.Diff) }, label = { Text("Diff") })
|
||||
FilterChip(mode == ContentMode.File, { onOpen(ContentMode.File) }, label = { Text("File") })
|
||||
}
|
||||
InlineContent(contentState, Modifier.padding(start = 16.dp, end = 16.dp, bottom = 10.dp))
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(start = 56.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBadge(filter: FileFilter) {
|
||||
val label = when (filter) { FileFilter.Staged -> "S"; FileFilter.Modified -> "M"; FileFilter.Untracked -> "U"; else -> "" }
|
||||
val color = when (filter) { FileFilter.Staged -> MaterialTheme.colorScheme.primary; FileFilter.Modified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.secondary }
|
||||
Surface(shape = CircleShape, color = color.copy(alpha = 0.14f), modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
Text(label, color = color, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineContent(state: GitContentViewState, modifier: Modifier) {
|
||||
Surface(modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), color = MaterialTheme.colorScheme.surfaceContainerLow) {
|
||||
when (state) {
|
||||
GitContentViewState.Idle, GitContentViewState.Loading -> Box(Modifier.fillMaxWidth().height(100.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) }
|
||||
is GitContentViewState.Error -> Text(state.message, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
is GitContentViewState.Diff -> MonospaceContent(state.diff.diff.ifEmpty { stringResource(R.string.git_state_no_changes) }, state.diff.truncated)
|
||||
is GitContentViewState.File -> MonospaceContent(state.file.content, state.file.truncated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonospaceContent(text: String, truncated: Boolean) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
if (truncated) Text(stringResource(R.string.git_state_truncated), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall)
|
||||
Text(text, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, modifier = Modifier.fillMaxWidth().height(220.dp).verticalScroll(rememberScrollState()))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectionRail(count: Int, canWrite: Boolean, allStaged: Boolean, onStage: () -> Unit, onDiscard: () -> Unit) {
|
||||
val canDiscard = canWrite && !allStaged
|
||||
Surface(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
shadowElevation = 6.dp,
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("$count selected", Modifier.weight(1f), style = MaterialTheme.typography.labelLarge)
|
||||
TextButton(onClick = onStage, enabled = canWrite) {
|
||||
Text(
|
||||
if (allStaged) "Unstage" else "Stage",
|
||||
color = if (canWrite) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onDiscard, enabled = canDiscard) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_discard),
|
||||
color = if (canDiscard) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepositoryPrompt(modifier: Modifier, repos: List<GitRepo>, onSelect: (String) -> Unit) {
|
||||
Column(modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Choose a repository", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Git actions stay scoped to the repository you select.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
repos.forEach { repo -> RepositoryRow(repo, false) { onSelect(repo.id) } }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RepositorySheet(repos: List<GitRepo>, selectedId: String?, onDismiss: () -> Unit, onSelect: (String) -> Unit) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 28.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Repositories", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(8.dp))
|
||||
repos.forEach { repo -> RepositoryRow(repo, repo.id == selectedId) { onSelect(repo.id) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepositoryRow(repo: GitRepo, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(Modifier.fillMaxWidth().clickable(onClick = onClick), color = if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, shape = RoundedCornerShape(12.dp)) {
|
||||
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.AccountTree, null); Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(repo.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(repo.currentBranch ?: repo.root, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (repo.dirty) Box(Modifier.size(8.dp).background(MaterialTheme.colorScheme.tertiary, CircleShape))
|
||||
if (selected) { Spacer(Modifier.width(8.dp)); Icon(Icons.Filled.Check, "Selected") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BranchSheet(
|
||||
branches: List<GitBranch>,
|
||||
hasGrant: Boolean,
|
||||
dirty: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSwitch: (String) -> Unit,
|
||||
onStashSwitch: (String) -> Unit,
|
||||
onCreate: (String, Boolean) -> Unit,
|
||||
) {
|
||||
var newBranch by rememberSaveable { mutableStateOf("") }
|
||||
var track by rememberSaveable { mutableStateOf(false) }
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stringResource(R.string.git_state_branches), style = MaterialTheme.typography.titleLarge)
|
||||
branches.forEach { branch ->
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = if (branch.isCurrent) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(branch.name, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodyMedium)
|
||||
branch.upstream?.let { Text("$it · ahead ${branch.ahead} · behind ${branch.behind}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
if (branch.isCurrent) Text(stringResource(R.string.git_state_current), color = MaterialTheme.colorScheme.primary)
|
||||
else {
|
||||
TextButton(onClick = { onSwitch(branch.name) }, enabled = hasGrant) { Text(stringResource(R.string.git_state_switch)) }
|
||||
if (dirty) TextButton(onClick = { onStashSwitch(branch.name) }, enabled = hasGrant) { Text(stringResource(R.string.git_state_switch_stash)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(); Text("Create branch", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(newBranch, { newBranch = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.git_state_new_branch_hint)) }, singleLine = true)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(track, { track = it }, enabled = hasGrant); Text(stringResource(R.string.git_state_track_remote)) }
|
||||
Button(onClick = { onCreate(newBranch.trim(), track) }, enabled = hasGrant && newBranch.isNotBlank(), modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.git_state_create_branch)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitDialog(
|
||||
hasStaged: Boolean,
|
||||
generating: Boolean,
|
||||
generatedMessage: String,
|
||||
generationNotice: String?,
|
||||
pushAfter: Boolean,
|
||||
onPushAfterChange: (Boolean) -> Unit,
|
||||
onGenerate: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onCommit: (String) -> Unit,
|
||||
) {
|
||||
var message by rememberSaveable { mutableStateOf("") }
|
||||
LaunchedEffect(generatedMessage) { if (generatedMessage.isNotBlank()) message = generatedMessage }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_commit_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
message, { message = it }, Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.git_state_commit_message_hint)) }, minLines = 3,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onGenerate, enabled = hasStaged && !generating) {
|
||||
if (generating) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) else Icon(Icons.Filled.AutoAwesome, stringResource(R.string.git_state_generate_message))
|
||||
}
|
||||
},
|
||||
)
|
||||
generationNotice?.takeIf { it.isNotBlank() }?.let { Text(it, color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.bodySmall) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(pushAfter, onPushAfterChange); Text(stringResource(R.string.git_state_push_after_commit)) }
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onCommit(message.trim()) }, enabled = hasStaged && message.isNotBlank()) { Text(stringResource(R.string.git_state_commit_confirm)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.git_state_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
private sealed interface ConfirmationRequest {
|
||||
data class Discard(val paths: List<String>, val deleteUntracked: Boolean, val target: GitTarget) : ConfirmationRequest
|
||||
data class Push(val target: GitTarget) : ConfirmationRequest
|
||||
data class DirtyCheckout(val ref: String, val target: GitTarget) : ConfirmationRequest
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmationDialog(
|
||||
request: ConfirmationRequest,
|
||||
onDismiss: () -> Unit,
|
||||
onDiscard: (List<String>, Boolean, GitTarget) -> Unit,
|
||||
onPush: (GitTarget) -> Unit,
|
||||
onCheckout: (String, GitTarget) -> Unit,
|
||||
) {
|
||||
val title = stringResource(when (request) {
|
||||
is ConfirmationRequest.Discard -> R.string.git_state_confirm_discard_title
|
||||
is ConfirmationRequest.Push -> R.string.git_state_confirm_push_title
|
||||
is ConfirmationRequest.DirtyCheckout -> R.string.git_state_confirm_checkout_dirty_title
|
||||
})
|
||||
val text = stringResource(when (request) {
|
||||
is ConfirmationRequest.Discard -> R.string.git_state_confirm_discard_text
|
||||
is ConfirmationRequest.Push -> R.string.git_state_confirm_push_text
|
||||
is ConfirmationRequest.DirtyCheckout -> R.string.git_state_confirm_checkout_dirty_text
|
||||
})
|
||||
val confirm = stringResource(when (request) {
|
||||
is ConfirmationRequest.Discard -> R.string.git_state_confirm_discard_confirm
|
||||
is ConfirmationRequest.Push -> R.string.git_state_confirm_push_confirm
|
||||
is ConfirmationRequest.DirtyCheckout -> R.string.git_state_confirm_checkout_confirm
|
||||
})
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) }, text = { Text(text) },
|
||||
confirmButton = { TextButton(onClick = {
|
||||
when (request) {
|
||||
is ConfirmationRequest.Discard -> onDiscard(request.paths, request.deleteUntracked, request.target)
|
||||
is ConfirmationRequest.Push -> onPush(request.target)
|
||||
is ConfirmationRequest.DirtyCheckout -> onCheckout(request.ref, request.target)
|
||||
}
|
||||
}) { Text(confirm) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.git_state_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutationBanner(mutation: GitMutationState, onClear: () -> Unit) {
|
||||
when (mutation) {
|
||||
GitMutationState.Idle -> Unit
|
||||
is GitMutationState.InProgress -> NoticeCard("${mutation.label} in progress…", loading = true)
|
||||
is GitMutationState.Success -> NoticeCard("${displayLabel(mutation.label)} completed · ${mutation.head.take(8)}")
|
||||
is GitMutationState.Error -> Card(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Row(Modifier.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${displayLabel(mutation.label)} failed: ${mutation.message}",
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
IconButton(onClick = onClear) { Icon(Icons.Filled.Close, "Dismiss error") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoticeCard(message: String, error: Boolean = false, loading: Boolean = false) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = if (error) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (loading) { CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp); Spacer(Modifier.width(10.dp)) }
|
||||
Text(message, color = if (error) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun WriteGrantNotice() = NoticeCard(stringResource(R.string.git_state_write_grant_required))
|
||||
|
||||
@Composable
|
||||
private fun GroupHeader(label: String, count: Int) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold); Spacer(Modifier.width(6.dp))
|
||||
Text(count.toString(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyFilterState(filter: FileFilter) {
|
||||
Box(Modifier.fillMaxWidth().padding(48.dp), contentAlignment = Alignment.Center) {
|
||||
Text(if (filter == FileFilter.All) "Working tree clean" else "No ${filter.name.lowercase()} files", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FullState(modifier: Modifier, loading: Boolean, title: String, detail: String? = null) {
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (loading) CircularProgressIndicator(); Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
detail?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnavailableState(modifier: Modifier, message: String, onRetry: () -> Unit) {
|
||||
val missingRoute = message.contains("404") || message.contains("No such API endpoint", true)
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Icon(Icons.Filled.Close, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(32.dp))
|
||||
Text(if (missingRoute) "Git isn't available on this host yet" else "Git workspace unavailable", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
if (missingRoute) "The host advertised Git, but its API route is not currently reachable. Restart or update the Dashboard, then retry." else message.ifBlank { "The Git service did not return a usable response." },
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(onClick = onRetry) { Icon(Icons.Filled.Refresh, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Retry") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun branchSummary(repo: GitRepo, branch: GitBranch?): String {
|
||||
val name = branch?.name ?: repo.currentBranch ?: "Detached HEAD"
|
||||
val tracking = buildList {
|
||||
if ((branch?.ahead ?: 0) > 0) add("ahead ${branch?.ahead}")
|
||||
if ((branch?.behind ?: 0) > 0) add("behind ${branch?.behind}")
|
||||
}
|
||||
return if (tracking.isEmpty()) name else "$name · ${tracking.joinToString(" · ")}"
|
||||
}
|
||||
|
||||
private fun displayLabel(label: String): String = label.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Message
|
||||
import androidx.compose.material.icons.filled.Analytics
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
@@ -49,6 +50,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 +72,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 +99,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 +124,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 +159,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,7 +189,9 @@ fun SettingsScreen(
|
||||
// expandable sections, so there's nothing left to link to twice.
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToManage: () -> Unit,
|
||||
onNavigateToProviderUsage: () -> Unit,
|
||||
onNavigateToPlugins: () -> Unit,
|
||||
onNavigateToGitWorkspace: () -> Unit,
|
||||
onNavigateToChatSettings: () -> Unit,
|
||||
onNavigateToTerminal: () -> Unit,
|
||||
onNavigateToBridge: () -> Unit,
|
||||
@@ -196,13 +217,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 +458,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 +537,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 +576,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(
|
||||
@@ -496,6 +605,14 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.AccountTree,
|
||||
title = stringResource(R.string.settings_git_workspace),
|
||||
subtitle = stringResource(R.string.settings_git_workspace_desc),
|
||||
onClick = onNavigateToGitWorkspace,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
title = stringResource(R.string.settings_chat),
|
||||
@@ -598,6 +715,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 +1347,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 +1392,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 +1550,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()
|
||||
@@ -2412,6 +2412,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
val showGitWorkspaceInChat: StateFlow<Boolean> =
|
||||
chatInputPreferencesRepository.showGitWorkspaceInChat
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setShowGitWorkspaceInChat(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
chatInputPreferencesRepository.setShowGitWorkspaceInChat(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Turn-complete notification (default ON). RelayApp mirrors this into
|
||||
// ChatViewModel.notifyOnTurnComplete; ChatSettingsScreen owns the toggle
|
||||
// + the POST_NOTIFICATIONS runtime request on first enable.
|
||||
@@ -2749,6 +2759,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 +3604,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 +3645,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 +4114,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 +7210,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,522 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitDiff
|
||||
import com.hermesandroid.relay.data.GitFile
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStateApiClient
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface GitStateUiState {
|
||||
data object Loading : GitStateUiState
|
||||
data class Unavailable(val message: String) : GitStateUiState
|
||||
data class Error(val message: String) : GitStateUiState
|
||||
data class Ready(val repos: List<GitRepo>, val notice: String?) : GitStateUiState
|
||||
}
|
||||
|
||||
sealed interface GitRepoDetailState {
|
||||
data object Idle : GitRepoDetailState
|
||||
data object Loading : GitRepoDetailState
|
||||
data class Error(val message: String) : GitRepoDetailState
|
||||
data class Ready(
|
||||
val status: GitStatus,
|
||||
val branches: List<GitBranch>,
|
||||
) : GitRepoDetailState
|
||||
}
|
||||
|
||||
sealed interface GitContentViewState {
|
||||
data object Idle : GitContentViewState
|
||||
data object Loading : GitContentViewState
|
||||
data class Error(val message: String) : GitContentViewState
|
||||
data class Diff(val diff: GitDiff) : GitContentViewState
|
||||
data class File(val file: GitFile) : GitContentViewState
|
||||
}
|
||||
|
||||
/** A single in-flight or completed write mutation on the selected repo. */
|
||||
sealed interface GitMutationState {
|
||||
data object Idle : GitMutationState
|
||||
data class InProgress(val label: String) : GitMutationState
|
||||
data class Error(val label: String, val message: String) : GitMutationState
|
||||
data class Success(val label: String, val head: String) : GitMutationState
|
||||
}
|
||||
|
||||
/** A commit-message generation attempt (AI magic-wand). */
|
||||
sealed interface GitMessageGenerationState {
|
||||
data object Idle : GitMessageGenerationState
|
||||
data object Loading : GitMessageGenerationState
|
||||
data class Ready(val message: String, val notice: String) : GitMessageGenerationState
|
||||
}
|
||||
|
||||
/** Fixed per-use confirmation tokens matching the plugin's server constants. */
|
||||
object GitConfirmationStrings {
|
||||
const val DISCARD = "discard"
|
||||
const val PUSH = "push"
|
||||
const val DIRTY_CHECKOUT = "checkout-dirty"
|
||||
}
|
||||
|
||||
data class GitTarget(
|
||||
val scopeKey: String,
|
||||
val repoId: String,
|
||||
val generation: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* View model for the Git State Android surface (read + write).
|
||||
*
|
||||
* Loads the scanned repo list from the Hermes-Relay plugin and, on selection,
|
||||
* fetches working-tree status + branches. Mutations (stage/unstage/discard/
|
||||
* commit/fetch/pull/push/checkout) all require the ``plugin.api.write`` grant:
|
||||
* ``configure`` binds one connection/profile/Dashboard owner and every mutation
|
||||
* refuses (surfacing a readable message, never a POST) when that owner's grant
|
||||
* is absent. Destructive ops
|
||||
* (discard/push/dirty-checkout) additionally require a per-use confirmation
|
||||
* string the caller echoes from GitConfirmationStrings.
|
||||
*/
|
||||
class GitStateViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _repos = MutableStateFlow<GitStateUiState>(GitStateUiState.Loading)
|
||||
val repos: StateFlow<GitStateUiState> = _repos.asStateFlow()
|
||||
|
||||
private val _detail = MutableStateFlow<GitRepoDetailState>(GitRepoDetailState.Idle)
|
||||
val detail: StateFlow<GitRepoDetailState> = _detail.asStateFlow()
|
||||
|
||||
private val _content = MutableStateFlow<GitContentViewState>(GitContentViewState.Idle)
|
||||
val content: StateFlow<GitContentViewState> = _content.asStateFlow()
|
||||
|
||||
private val _mutation = MutableStateFlow<GitMutationState>(GitMutationState.Idle)
|
||||
val mutation: StateFlow<GitMutationState> = _mutation.asStateFlow()
|
||||
|
||||
private val _messageGeneration =
|
||||
MutableStateFlow<GitMessageGenerationState>(GitMessageGenerationState.Idle)
|
||||
val messageGeneration: StateFlow<GitMessageGenerationState> = _messageGeneration.asStateFlow()
|
||||
|
||||
private val _pushAfterCommit = MutableStateFlow(false)
|
||||
val pushAfterCommit: StateFlow<Boolean> = _pushAfterCommit.asStateFlow()
|
||||
|
||||
private val _stashNotice = MutableStateFlow<String?>(null)
|
||||
val stashNotice: StateFlow<String?> = _stashNotice.asStateFlow()
|
||||
|
||||
private val _writeGrant = MutableStateFlow(false)
|
||||
val writeGrant: StateFlow<Boolean> = _writeGrant.asStateFlow()
|
||||
|
||||
private val _selectedRepoId = MutableStateFlow<String?>(null)
|
||||
val selectedRepoId: StateFlow<String?> = _selectedRepoId.asStateFlow()
|
||||
|
||||
private var api: GitStateApiClient? = null
|
||||
private var reposJob: Job? = null
|
||||
private var detailJob: Job? = null
|
||||
private var contentJob: Job? = null
|
||||
private var mutationJob: Job? = null
|
||||
private var messageJob: Job? = null
|
||||
private var scopeKey: String? = null
|
||||
private var targetGeneration: Long = 0
|
||||
fun selectedRepoIdForDisplay(): String? = _selectedRepoId.value
|
||||
|
||||
fun currentTarget(): GitTarget? {
|
||||
val owner = scopeKey ?: return null
|
||||
val repo = _selectedRepoId.value ?: return null
|
||||
return GitTarget(owner, repo, targetGeneration)
|
||||
}
|
||||
|
||||
fun configure(dashboard: DashboardApiClient?, ownerKey: String?) {
|
||||
reposJob?.cancel()
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob?.cancel()
|
||||
messageJob?.cancel()
|
||||
targetGeneration += 1
|
||||
scopeKey = ownerKey
|
||||
_selectedRepoId.value = null
|
||||
_writeGrant.value = false
|
||||
_detail.value = GitRepoDetailState.Idle
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
_messageGeneration.value = GitMessageGenerationState.Idle
|
||||
_stashNotice.value = null
|
||||
api = dashboard?.let(::GitStateApiClient)
|
||||
loadRepos()
|
||||
}
|
||||
|
||||
/** Grants the plugin.api.write capability for this connection/profile. */
|
||||
fun setWriteGrant(ownerKey: String?, granted: Boolean) {
|
||||
if (ownerKey != scopeKey) return
|
||||
_writeGrant.value = granted
|
||||
}
|
||||
|
||||
fun hasWriteGrant(): Boolean = _writeGrant.value
|
||||
|
||||
fun loadRepos() {
|
||||
val client = api ?: run {
|
||||
_repos.value = GitStateUiState.Error("Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val expectedScope = scopeKey
|
||||
reposJob?.cancel()
|
||||
reposJob = viewModelScope.launch {
|
||||
_repos.value = GitStateUiState.Loading
|
||||
client.repos().fold(
|
||||
onSuccess = { list ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Ready(list, null)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (scopeKey == expectedScope) {
|
||||
val message = error.message.orEmpty()
|
||||
_repos.value = if (
|
||||
message.contains("HTTP 404", ignoreCase = true) ||
|
||||
message.contains("No such API endpoint", ignoreCase = true)
|
||||
) {
|
||||
GitStateUiState.Unavailable(
|
||||
"Git isn't available on this Hermes host yet.",
|
||||
)
|
||||
} else {
|
||||
GitStateUiState.Error(message.ifBlank { "Failed to load repositories" })
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRepo(repoId: String) {
|
||||
val client = api ?: return
|
||||
targetGeneration += 1
|
||||
_selectedRepoId.value = repoId
|
||||
val target = currentTarget() ?: return
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
detailJob = viewModelScope.launch {
|
||||
_detail.value = GitRepoDetailState.Loading
|
||||
val statusResult = client.status(repoId)
|
||||
val branchesResult = client.branches(repoId)
|
||||
if (currentTarget() != target) return@launch
|
||||
if (statusResult.isFailure) {
|
||||
_detail.value = GitRepoDetailState.Error(
|
||||
statusResult.exceptionOrNull()?.message ?: "Failed to load status",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val status: GitStatus = statusResult.getOrThrow()
|
||||
val branches: List<GitBranch> = branchesResult.getOrDefault(emptyList())
|
||||
_detail.value = GitRepoDetailState.Ready(status, branches)
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one owner/repository-bound mutation without cancelling another mutation. */
|
||||
private fun runMutation(
|
||||
label: String,
|
||||
expectedTarget: GitTarget? = null,
|
||||
onSuccess: (GitTarget) -> Unit = {},
|
||||
block: suspend (GitStateApiClient, String) -> Result<GitMutationState>,
|
||||
) {
|
||||
val client = api ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "No repository selected")
|
||||
return
|
||||
}
|
||||
if (expectedTarget != null && expectedTarget != target) {
|
||||
_mutation.value = GitMutationState.Error(label, "Repository context changed; review the action again.")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (mutationJob?.isActive == true) {
|
||||
_mutation.value = GitMutationState.Error(label, "Another Git action is still in progress.")
|
||||
return
|
||||
}
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob = viewModelScope.launch {
|
||||
_mutation.value = GitMutationState.InProgress(label)
|
||||
block(client, target.repoId).fold(
|
||||
onSuccess = {
|
||||
if (currentTarget() != target) return@fold
|
||||
_mutation.value = it
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(client, target)
|
||||
onSuccess(target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDetail(client: GitStateApiClient, target: GitTarget) {
|
||||
val statusResult = client.status(target.repoId)
|
||||
val branchesResult = client.branches(target.repoId)
|
||||
if (currentTarget() == target && statusResult.isSuccess) {
|
||||
_detail.value = GitRepoDetailState.Ready(
|
||||
statusResult.getOrDefault(GitStatus()),
|
||||
branchesResult.getOrDefault(emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read operations ────────────────────────────────────────────────────
|
||||
|
||||
fun loadDiff(path: String, kind: String) {
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.diff(target.repoId, path, kind).fold(
|
||||
onSuccess = { diff ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Diff(diff)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load diff",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFile(path: String) {
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.file(target.repoId, path).fold(
|
||||
onSuccess = { file ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.File(file)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load file",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────
|
||||
|
||||
fun stage(paths: List<String>) = runMutation("Stage") { c, r ->
|
||||
c.stage(r, paths).map { GitMutationState.Success("stage", it.head) }
|
||||
}
|
||||
|
||||
fun unstage(paths: List<String>) = runMutation("Unstage") { c, r ->
|
||||
c.unstage(r, paths).map { GitMutationState.Success("unstage", it.head) }
|
||||
}
|
||||
|
||||
fun discard(
|
||||
paths: List<String>,
|
||||
confirmation: String,
|
||||
deleteUntracked: Boolean = false,
|
||||
expectedTarget: GitTarget? = null,
|
||||
) =
|
||||
runMutation("Discard", expectedTarget = expectedTarget) { c, r ->
|
||||
c.discard(r, paths, confirmation, deleteUntracked)
|
||||
.map { GitMutationState.Success("discard", it.head) }
|
||||
}
|
||||
|
||||
fun commit(message: String, onSuccess: (GitTarget) -> Unit = {}) =
|
||||
runMutation("Commit", onSuccess = onSuccess) { c, r ->
|
||||
c.commit(r, message).map {
|
||||
GitMutationState.Success("commit", it.head)
|
||||
}
|
||||
}
|
||||
|
||||
fun commitSelected(message: String, paths: List<String>) = runMutation("Commit") { c, r ->
|
||||
c.commitSelected(r, message, paths).map {
|
||||
GitMutationState.Success("commit", it.head)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetch(remote: String = "origin") = runMutation("Fetch") { c, r ->
|
||||
c.fetch(r, remote).map { GitMutationState.Success("fetch", it.head) }
|
||||
}
|
||||
|
||||
fun pull(remote: String = "origin", branch: String = "") = runMutation("Pull") { c, r ->
|
||||
c.pull(r, remote, branch).map { GitMutationState.Success("pull", it.head) }
|
||||
}
|
||||
|
||||
fun push(
|
||||
confirmation: String,
|
||||
remote: String = "origin",
|
||||
branch: String = "",
|
||||
expectedTarget: GitTarget? = null,
|
||||
) =
|
||||
runMutation("Push", expectedTarget = expectedTarget) { c, r ->
|
||||
c.push(r, confirmation, remote, branch).map { GitMutationState.Success("push", it.head) }
|
||||
}
|
||||
|
||||
fun checkout(
|
||||
ref: String,
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
expectedTarget: GitTarget? = null,
|
||||
) = runMutation("Checkout", expectedTarget = expectedTarget) { c, r ->
|
||||
c.checkout(r, ref, confirmation, newBranch, track)
|
||||
.map { GitMutationState.Success("checkout", it.head) }
|
||||
}
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────
|
||||
|
||||
/** Toggle the push-after-commit flow (default OFF; never bypasses confirm). */
|
||||
fun setPushAfterCommit(enabled: Boolean) {
|
||||
_pushAfterCommit.value = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a commit-message suggestion from the staged diff (AI magic-wand).
|
||||
* Empty staged diff / model-unavailable degrade to a notice, never an error.
|
||||
* Uses the shared write grant gate (a POST is never sent without the grant).
|
||||
*/
|
||||
fun generateCommitMessage(paths: List<String>? = null) {
|
||||
val client = api ?: run {
|
||||
_messageGeneration.value =
|
||||
GitMessageGenerationState.Ready("", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready("", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
messageJob?.cancel()
|
||||
messageJob = viewModelScope.launch {
|
||||
_messageGeneration.value = GitMessageGenerationState.Loading
|
||||
val result = if (paths != null) {
|
||||
client.commitMessageSelected(target.repoId, paths)
|
||||
} else {
|
||||
client.commitMessage(target.repoId)
|
||||
}
|
||||
if (currentTarget() != target) return@launch
|
||||
result.fold(
|
||||
onSuccess = { msg ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(msg.message, msg.notice)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
error.message ?: "Could not generate a commit message.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout that auto-stashes a dirty tree first. No confirmation is needed
|
||||
* because a stash is recoverable. Surfaces the stash message via [stashNotice].
|
||||
*/
|
||||
fun stashCheckout(ref: String, newBranch: String = "", track: Boolean = false) {
|
||||
val client = api ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
_stashNotice.value = null
|
||||
if (mutationJob?.isActive == true) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Another Git action is still in progress.",
|
||||
)
|
||||
return
|
||||
}
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob = viewModelScope.launch {
|
||||
_mutation.value = GitMutationState.InProgress("Stash Checkout")
|
||||
client.stashCheckout(target.repoId, ref, newBranch, track).fold(
|
||||
onSuccess = { result ->
|
||||
if (currentTarget() != target) return@fold
|
||||
if (result.stashed) {
|
||||
_stashNotice.value =
|
||||
"Stashed changes on $ref as \"${result.stashMessage}\". Use \"git stash pop\" to restore them."
|
||||
}
|
||||
_mutation.value = GitMutationState.Success("stash-checkout", result.head)
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(client, target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearStashNotice() {
|
||||
_stashNotice.value = null
|
||||
}
|
||||
|
||||
/** True when the push-after-commit toggle is currently enabled. */
|
||||
fun isPushAfterCommitEnabled(): Boolean = _pushAfterCommit.value
|
||||
|
||||
/** True when the named destructive op needs a confirmation echo. */
|
||||
fun requiresConfirmation(op: String): Boolean = op in setOf("discard", "push", "dirty-checkout")
|
||||
|
||||
/** Fixed confirmation token for a destructive op (matches the server). */
|
||||
fun confirmationFor(op: String): String? = when (op) {
|
||||
"discard" -> GitConfirmationStrings.DISCARD
|
||||
"push" -> GitConfirmationStrings.PUSH
|
||||
"dirty-checkout" -> GitConfirmationStrings.DIRTY_CHECKOUT
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun clearMutationError() {
|
||||
if (_mutation.value is GitMutationState.Error) {
|
||||
_mutation.value = GitMutationState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ sealed interface PluginsHubState {
|
||||
data object Disconnected : PluginsHubState
|
||||
data object Loading : PluginsHubState
|
||||
data class Ready(
|
||||
val ownerKey: String,
|
||||
val plugins: List<PluginHubItem>,
|
||||
val preview: PluginCatalogPreview,
|
||||
val refreshing: Boolean = false,
|
||||
@@ -220,6 +221,7 @@ class PluginsViewModel(application: Application) : AndroidViewModel(application)
|
||||
_hubState.value = result.fold(
|
||||
onSuccess = { items ->
|
||||
PluginsHubState.Ready(
|
||||
ownerKey = expectedKey,
|
||||
plugins = items,
|
||||
preview = catalogPreview(items),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -136,7 +136,6 @@
|
||||
<string name="chat_editing_notice">Editando — a conversa será retrocedida</string>
|
||||
<string name="chat_mic_perm_needed">Permissão do microfone necessária</string>
|
||||
<string name="chat_mic_perm_body">Toque em Abrir Configurações e permita o acesso ao microfone para usar o modo de voz.</string>
|
||||
<string name="chat_clean_view_hint">Mantenha o chat pressionado para uma visualização limpa e focada</string>
|
||||
<string name="chat_manage_connections">Gerenciar conexões</string>
|
||||
<string name="chat_no_clipboard_image">Nenhuma imagem na área de transferência</string>
|
||||
<string name="chat_copied_to_clipboard">Copiado para a área de transferência</string>
|
||||
@@ -566,6 +565,20 @@
|
||||
<string name="changelog_no_notes">Nenhuma nota de versão disponível.</string>
|
||||
<string name="changelog_collapse">Recolher</string>
|
||||
<string name="changelog_expand">Expandir</string>
|
||||
<string name="changelog_state_expanded">Expandido</string>
|
||||
<string name="changelog_state_collapsed">Recolhido</string>
|
||||
<string name="changelog_installed">Instalado</string>
|
||||
<string name="changelog_also_improved">Também melhorado</string>
|
||||
<string name="changelog_toast_also">Também: %1$s</string>
|
||||
<string name="changelog_view_all">Ver tudo</string>
|
||||
<plurals name="changelog_additional_feature_count">
|
||||
<item quantity="one">%1$d recurso</item>
|
||||
<item quantity="other">%1$d recursos</item>
|
||||
</plurals>
|
||||
<plurals name="changelog_fix_count">
|
||||
<item quantity="one">%1$d correção</item>
|
||||
<item quantity="other">%1$d correções</item>
|
||||
</plurals>
|
||||
<!-- DiagnosticsScreen -->
|
||||
<string name="diag_title">Diagnóstico</string>
|
||||
<string name="diag_back">Voltar</string>
|
||||
@@ -657,10 +670,18 @@
|
||||
<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>
|
||||
<string name="settings_whats_new_desc">Notas de versão e changelog completo</string>
|
||||
<string name="settings_whats_new_desc">Histórico de versões do Android</string>
|
||||
<string name="settings_about">Sobre</string>
|
||||
<string name="settings_about_desc">Versão, créditos e documentação</string>
|
||||
<string name="settings_profile_lock">Bloqueio de perfil</string>
|
||||
@@ -876,6 +897,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 +930,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 -->
|
||||
@@ -2491,8 +2516,6 @@
|
||||
<string name="power_gate_explanation_expired">Sua sessão do relay não é mais aceita pelo servidor. Abra a conexão para reparar.</string>
|
||||
<string name="power_gate_explanation_unavailable">Este servidor Hermes não oferece a superfície necessária para este recurso.</string>
|
||||
<string name="power_gate_explanation_signin">Este recurso padrão do painel exige login no painel antes de ser usado.</string>
|
||||
<!-- AgentTextFlow / CleanModeComposer -->
|
||||
<string name="agent_text_placeholder">Mensagem</string>
|
||||
<!-- ExtraKeysToolbar -->
|
||||
<string name="extra_keys_esc">ESC</string>
|
||||
<string name="extra_keys_tab">TAB</string>
|
||||
@@ -2880,7 +2903,6 @@
|
||||
<!-- InsecureConnectionAckDialog -->
|
||||
<!-- SessionTtlPickerDialog -->
|
||||
<!-- PowerFeatureGate -->
|
||||
<!-- AgentTextFlow / CleanModeComposer -->
|
||||
<!-- ExtraKeysToolbar -->
|
||||
<!-- ToolProgressCard -->
|
||||
<!-- AgentIconRow -->
|
||||
@@ -2936,10 +2958,6 @@
|
||||
<string name="chat_relay_sessions_summary">Veja e revogue os dispositivos pareados com este relay.</string>
|
||||
<string name="chat_relay_sessions_title">Sessões do Relay</string>
|
||||
<string name="chat_rename_failed">Falha ao renomear</string>
|
||||
<!-- AgentTextFlow -->
|
||||
<string name="agent_text_send_cd">Enviar</string>
|
||||
<string name="agent_text_exit_clean">Sair do modo limpo</string>
|
||||
<string name="agent_text_sphere_desc">Agente</string>
|
||||
<!-- UpdateBanner format strings (with version placeholders) -->
|
||||
<string name="update_banner_available_fmt">Atualização disponível — v%1$s</string>
|
||||
<string name="update_banner_body_fmt">Você está na %1$s. Toque em Atualizar para baixar.</string>
|
||||
@@ -3846,6 +3864,48 @@
|
||||
<string name="plugins_keep">Manter</string>
|
||||
<string name="plugins_remove">Remover</string>
|
||||
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Voltar</string>
|
||||
<string name="git_state_staged">Preparados</string>
|
||||
<string name="git_state_modified">Modificados</string>
|
||||
<string name="git_state_untracked">Não rastreados</string>
|
||||
<string name="git_state_branches">Ramos</string>
|
||||
<string name="git_state_truncated">Resultados truncados — apenas as primeiras entradas são exibidas.</string>
|
||||
<string name="git_state_no_changes">(sem alterações)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Alterações de gravação exigem a permissão de alterações de plugin. Ative "Permitir alterações de plugins (plugin.api.write)" nas configurações de permissão de Plugins.</string>
|
||||
<string name="git_state_stage">Preparar</string>
|
||||
<string name="git_state_unstage">Despreparar</string>
|
||||
<string name="git_state_discard">Descartar</string>
|
||||
<string name="git_state_commit">Confirmar</string>
|
||||
<string name="git_state_commit_title">Confirmar alterações preparadas</string>
|
||||
<string name="git_state_commit_message_hint">Mensagem do commit</string>
|
||||
<string name="git_state_commit_confirm">Confirmar</string>
|
||||
<string name="git_state_cancel">Cancelar</string>
|
||||
<string name="git_state_fetch">Buscar</string>
|
||||
<string name="git_state_pull">Puxar</string>
|
||||
<string name="git_state_push">Enviar</string>
|
||||
<string name="git_state_new_branch_hint">Nome da nova ramificação</string>
|
||||
<string name="git_state_create_branch">Criar ramificação</string>
|
||||
<string name="git_state_track_remote">Rastrear ramificação remota</string>
|
||||
<string name="git_state_switch">Alternar</string>
|
||||
<string name="git_state_current">Atual</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s em andamento…</string>
|
||||
<string name="git_state_mutation_success">%1$s concluído. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s falhou: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Descartar alterações?</string>
|
||||
<string name="git_state_confirm_discard_text">As alterações locais dos arquivos selecionados serão descartadas e não poderão ser recuperadas.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Descartar</string>
|
||||
<string name="git_state_confirm_push_title">Enviar alterações?</string>
|
||||
<string name="git_state_confirm_push_text">Envia a ramificação atual para o repositório remoto. Isso publica os commits locais no repositório remoto.</string>
|
||||
<string name="git_state_confirm_push_confirm">Enviar</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Alternar ramificação com alterações locais?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">A árvore de trabalho tem alterações não confirmadas. A alternância pode carregá-las para a ramificação de destino.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Alternar</string>
|
||||
<string name="git_state_generate_message">Gerar mensagem de commit</string>
|
||||
<string name="git_state_generating_message">Gerando mensagem de commit…</string>
|
||||
<string name="git_state_push_after_commit">Enviar após o commit</string>
|
||||
<string name="git_state_switch_stash">Alternar (guardar se houver alterações)</string>
|
||||
<string name="support_bundle_review">Revisar informações de suporte</string>
|
||||
<string name="support_bundle_title">Informações de suporte</string>
|
||||
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
|
||||
@@ -3881,7 +3941,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 +4196,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>
|
||||
@@ -4172,4 +4271,13 @@
|
||||
<string name="custom_theme_saved_to">Alterações salvas em %1$s</string>
|
||||
<string name="custom_theme_modified">Alterações não salvas</string>
|
||||
<string name="custom_theme_saved_count">%1$d temas salvos</string>
|
||||
<string name="chat_settings_show_git_workspace">Mostrar o espaço de trabalho Git no chat</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">Mostra a branch e as alterações acima do campo de mensagem</string>
|
||||
<string name="chat_git_open_workspace">Abrir o espaço de trabalho Git</string>
|
||||
<string name="chat_git_branch">Branch %1$s</string>
|
||||
<string name="chat_git_additions">%1$d adições</string>
|
||||
<string name="chat_git_deletions">%1$d exclusões</string>
|
||||
<plurals name="chat_git_change_count"><item quantity="one">%1$d alteração</item><item quantity="other">%1$d alterações</item></plurals>
|
||||
<string name="settings_git_workspace">Espaço de trabalho Git</string>
|
||||
<string name="settings_git_workspace_desc">Revise alterações, branches, commits e remotos</string>
|
||||
</resources>
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
<string name="chat_editing_notice">编辑中——将回退对话</string>
|
||||
<string name="chat_mic_perm_needed">需要麦克风权限</string>
|
||||
<string name="chat_mic_perm_body">点击“打开设置”并授予麦克风权限以使用语音模式。</string>
|
||||
<string name="chat_clean_view_hint">长按聊天进入清爽专注视图</string>
|
||||
<string name="chat_manage_connections">管理连接</string>
|
||||
<string name="chat_no_clipboard_image">剪贴板中没有图片</string>
|
||||
<string name="chat_copied_to_clipboard">已复制到剪贴板</string>
|
||||
@@ -603,6 +602,18 @@
|
||||
<string name="changelog_no_notes">暂无发行说明。</string>
|
||||
<string name="changelog_collapse">收起</string>
|
||||
<string name="changelog_expand">展开</string>
|
||||
<string name="changelog_state_expanded">已展开</string>
|
||||
<string name="changelog_state_collapsed">已收起</string>
|
||||
<string name="changelog_installed">已安装</string>
|
||||
<string name="changelog_also_improved">其他改进</string>
|
||||
<string name="changelog_toast_also">另外:%1$s</string>
|
||||
<string name="changelog_view_all">查看全部</string>
|
||||
<plurals name="changelog_additional_feature_count">
|
||||
<item quantity="other">%1$d 项功能</item>
|
||||
</plurals>
|
||||
<plurals name="changelog_fix_count">
|
||||
<item quantity="other">%1$d 项修复</item>
|
||||
</plurals>
|
||||
|
||||
<!-- DiagnosticsScreen -->
|
||||
<string name="diag_title">诊断</string>
|
||||
@@ -698,10 +709,18 @@
|
||||
<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>
|
||||
<string name="settings_whats_new_desc">发行说明和完整变更日志</string>
|
||||
<string name="settings_whats_new_desc">Android 版本历史</string>
|
||||
<string name="settings_about">关于</string>
|
||||
<string name="settings_about_desc">版本、致谢、文档</string>
|
||||
<string name="settings_profile_lock">配置文件锁定</string>
|
||||
@@ -922,6 +941,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 +974,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>
|
||||
|
||||
@@ -2598,9 +2621,6 @@
|
||||
<string name="power_gate_explanation_unavailable">此 Hermes 服务器未暴露此功能所需的接口。</string>
|
||||
<string name="power_gate_explanation_signin">此标准仪表盘功能需要在登录后才能使用。</string>
|
||||
|
||||
<!-- AgentTextFlow / CleanModeComposer -->
|
||||
<string name="agent_text_placeholder">消息</string>
|
||||
|
||||
<!-- ExtraKeysToolbar -->
|
||||
<string name="extra_keys_esc">ESC</string>
|
||||
<string name="extra_keys_tab">TAB</string>
|
||||
@@ -3041,11 +3061,6 @@
|
||||
<string name="chat_rename_failed">重命名失败</string>
|
||||
<string name="chat_only_active_revoke">现在只能撤销活动连接</string>
|
||||
|
||||
<!-- AgentTextFlow -->
|
||||
<string name="agent_text_send_cd">发送</string>
|
||||
<string name="agent_text_exit_clean">退出简洁模式</string>
|
||||
<string name="agent_text_sphere_desc">代理</string>
|
||||
|
||||
|
||||
<!-- UpdateBanner (format strings with version placeholders) -->
|
||||
<string name="update_banner_available_fmt">更新可用 — v%1$s</string>
|
||||
@@ -3934,6 +3949,48 @@
|
||||
<string name="plugins_keep">保留</string>
|
||||
<string name="plugins_remove">移除</string>
|
||||
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">返回</string>
|
||||
<string name="git_state_staged">已暂存</string>
|
||||
<string name="git_state_modified">已修改</string>
|
||||
<string name="git_state_untracked">未跟踪</string>
|
||||
<string name="git_state_branches">分支</string>
|
||||
<string name="git_state_truncated">结果已截断 — 仅显示前几条。</string>
|
||||
<string name="git_state_no_changes">(无更改)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">写入更改需要插件更改权限。请在“插件”的权限设置中启用“允许插件更改 (plugin.api.write)”。</string>
|
||||
<string name="git_state_stage">暂存</string>
|
||||
<string name="git_state_unstage">取消暂存</string>
|
||||
<string name="git_state_discard">丢弃</string>
|
||||
<string name="git_state_commit">提交</string>
|
||||
<string name="git_state_commit_title">提交已暂存的更改</string>
|
||||
<string name="git_state_commit_message_hint">提交消息</string>
|
||||
<string name="git_state_commit_confirm">提交</string>
|
||||
<string name="git_state_cancel">取消</string>
|
||||
<string name="git_state_fetch">获取</string>
|
||||
<string name="git_state_pull">拉取</string>
|
||||
<string name="git_state_push">推送</string>
|
||||
<string name="git_state_new_branch_hint">新分支名称</string>
|
||||
<string name="git_state_create_branch">创建分支</string>
|
||||
<string name="git_state_track_remote">跟踪远程分支</string>
|
||||
<string name="git_state_switch">切换</string>
|
||||
<string name="git_state_current">当前</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s 正在进行…</string>
|
||||
<string name="git_state_mutation_success">%1$s 已完成。HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s 失败:%2$s</string>
|
||||
<string name="git_state_confirm_discard_title">丢弃更改?</string>
|
||||
<string name="git_state_confirm_discard_text">所选文件的本地更改将被丢弃,且无法恢复。</string>
|
||||
<string name="git_state_confirm_discard_confirm">丢弃</string>
|
||||
<string name="git_state_confirm_push_title">推送更改?</string>
|
||||
<string name="git_state_confirm_push_text">将当前分支推送到其远程仓库。这会把本地提交发送到远程仓库。</string>
|
||||
<string name="git_state_confirm_push_confirm">推送</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">切换包含本地更改的分支?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">工作区有未提交的更改。切换后,这些更改可能会被带到目标分支。</string>
|
||||
<string name="git_state_confirm_checkout_confirm">切换</string>
|
||||
<string name="git_state_generate_message">生成提交信息</string>
|
||||
<string name="git_state_generating_message">正在生成提交信息…</string>
|
||||
<string name="git_state_push_after_commit">提交后推送</string>
|
||||
<string name="git_state_switch_stash">切换(有更改时暂存)</string>
|
||||
<string name="support_bundle_review">查看支持信息</string>
|
||||
<string name="support_bundle_title">支持信息</string>
|
||||
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
|
||||
@@ -3969,7 +4026,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 +4278,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>
|
||||
@@ -4257,4 +4353,13 @@
|
||||
<string name="custom_theme_saved_to">更改已保存到 %1$s</string>
|
||||
<string name="custom_theme_modified">未保存的更改</string>
|
||||
<string name="custom_theme_saved_count">已保存 %1$d 个预设</string>
|
||||
<string name="chat_settings_show_git_workspace">在聊天中显示 Git 工作区</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">在输入框上方显示分支和更改</string>
|
||||
<string name="chat_git_open_workspace">打开 Git 工作区</string>
|
||||
<string name="chat_git_branch">分支 %1$s</string>
|
||||
<string name="chat_git_additions">新增 %1$d 行</string>
|
||||
<string name="chat_git_deletions">删除 %1$d 行</string>
|
||||
<plurals name="chat_git_change_count"><item quantity="other">%1$d 个更改</item></plurals>
|
||||
<string name="settings_git_workspace">Git 工作区</string>
|
||||
<string name="settings_git_workspace_desc">查看更改、分支、提交和远程仓库</string>
|
||||
</resources>
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
<string name="chat_editing_notice">Bearbeitung — setzt die Unterhaltung zurück</string>
|
||||
<string name="chat_mic_perm_needed">Mikrofonberechtigung erforderlich</string>
|
||||
<string name="chat_mic_perm_body">Tippe auf Einstellungen öffnen und erteile Mikrofonzugriff, um den Sprachmodus zu nutzen.</string>
|
||||
<string name="chat_clean_view_hint">Halte den Chat gedrückt für eine aufgeräumte, fokussierte Ansicht</string>
|
||||
<string name="chat_manage_connections">Verbindungen verwalten</string>
|
||||
<string name="chat_no_clipboard_image">Kein Bild in der Zwischenablage</string>
|
||||
<string name="chat_copied_to_clipboard">In die Zwischenablage kopiert</string>
|
||||
@@ -603,6 +602,20 @@
|
||||
<string name="changelog_no_notes">Keine Versionshinweise verfügbar.</string>
|
||||
<string name="changelog_collapse">Einklappen</string>
|
||||
<string name="changelog_expand">Ausklappen</string>
|
||||
<string name="changelog_state_expanded">Ausgeklappt</string>
|
||||
<string name="changelog_state_collapsed">Eingeklappt</string>
|
||||
<string name="changelog_installed">Installiert</string>
|
||||
<string name="changelog_also_improved">Außerdem verbessert</string>
|
||||
<string name="changelog_toast_also">Außerdem: %1$s</string>
|
||||
<string name="changelog_view_all">Alle anzeigen</string>
|
||||
<plurals name="changelog_additional_feature_count">
|
||||
<item quantity="one">%1$d Funktion</item>
|
||||
<item quantity="other">%1$d Funktionen</item>
|
||||
</plurals>
|
||||
<plurals name="changelog_fix_count">
|
||||
<item quantity="one">%1$d Fehlerbehebung</item>
|
||||
<item quantity="other">%1$d Fehlerbehebungen</item>
|
||||
</plurals>
|
||||
|
||||
<!-- DiagnosticsScreen -->
|
||||
<string name="diag_title">Diagnose</string>
|
||||
@@ -698,10 +711,18 @@
|
||||
<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>
|
||||
<string name="settings_whats_new_desc">Versionshinweise und vollständiges Änderungsprotokoll</string>
|
||||
<string name="settings_whats_new_desc">Android-Versionsverlauf</string>
|
||||
<string name="settings_about">Info</string>
|
||||
<string name="settings_about_desc">Version, Mitwirkende, Dokumentation</string>
|
||||
<string name="settings_profile_lock">Profilsperre</string>
|
||||
@@ -925,6 +946,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 +979,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>
|
||||
|
||||
@@ -2601,9 +2626,6 @@
|
||||
<string name="power_gate_explanation_unavailable">Dieser Hermes-Server stellt die für diese Funktion benötigte Oberfläche nicht bereit.</string>
|
||||
<string name="power_gate_explanation_signin">Diese Standard-Dashboard-Funktion erfordert vor der Nutzung eine Dashboard-Anmeldung.</string>
|
||||
|
||||
<!-- AgentTextFlow / CleanModeComposer -->
|
||||
<string name="agent_text_placeholder">Nachricht</string>
|
||||
|
||||
<!-- ExtraKeysToolbar -->
|
||||
<string name="extra_keys_esc">ESC</string>
|
||||
<string name="extra_keys_tab">TAB</string>
|
||||
@@ -3020,8 +3042,6 @@
|
||||
|
||||
<!-- PowerFeatureGate -->
|
||||
|
||||
<!-- AgentTextFlow / CleanModeComposer -->
|
||||
|
||||
<!-- ExtraKeysToolbar -->
|
||||
|
||||
<!-- ToolProgressCard -->
|
||||
@@ -3104,11 +3124,6 @@
|
||||
<string name="chat_rename_failed">Umbenennen fehlgeschlagen</string>
|
||||
|
||||
|
||||
<!-- AgentTextFlow -->
|
||||
<string name="agent_text_send_cd">Senden</string>
|
||||
<string name="agent_text_exit_clean">Aufgeräumten Modus beenden</string>
|
||||
<string name="agent_text_sphere_desc">Agent</string>
|
||||
|
||||
|
||||
<!-- UpdateBanner format strings (with version placeholders) -->
|
||||
<string name="update_banner_available_fmt">Aktualisierung verfügbar — v%1$s</string>
|
||||
@@ -4006,6 +4021,48 @@
|
||||
<string name="plugins_keep">Behalten</string>
|
||||
<string name="plugins_remove">Entfernen</string>
|
||||
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Zurück</string>
|
||||
<string name="git_state_staged">Gestaged</string>
|
||||
<string name="git_state_modified">Geändert</string>
|
||||
<string name="git_state_untracked">Unverfolgt</string>
|
||||
<string name="git_state_branches">Branches</string>
|
||||
<string name="git_state_truncated">Ergebnisse abgeschnitten – nur die ersten Einträge werden angezeigt.</string>
|
||||
<string name="git_state_no_changes">(keine Änderungen)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Für Schreibvorgänge ist die Plugin-Änderungsberechtigung erforderlich. Aktivieren Sie „Änderungen durch Plugins zulassen (plugin.api.write)“ in den Plugin-Berechtigungseinstellungen.</string>
|
||||
<string name="git_state_stage">Bereitstellen</string>
|
||||
<string name="git_state_unstage">Zurücksetzen</string>
|
||||
<string name="git_state_discard">Verwerfen</string>
|
||||
<string name="git_state_commit">Committen</string>
|
||||
<string name="git_state_commit_title">Bereitgestellte Änderungen committen</string>
|
||||
<string name="git_state_commit_message_hint">Commit-Nachricht</string>
|
||||
<string name="git_state_commit_confirm">Committen</string>
|
||||
<string name="git_state_cancel">Abbrechen</string>
|
||||
<string name="git_state_fetch">Abrufen</string>
|
||||
<string name="git_state_pull">Pullen</string>
|
||||
<string name="git_state_push">Pushen</string>
|
||||
<string name="git_state_new_branch_hint">Name des neuen Branches</string>
|
||||
<string name="git_state_create_branch">Branch erstellen</string>
|
||||
<string name="git_state_track_remote">Remote-Branch verfolgen</string>
|
||||
<string name="git_state_switch">Wechseln</string>
|
||||
<string name="git_state_current">Aktuell</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s läuft…</string>
|
||||
<string name="git_state_mutation_success">%1$s abgeschlossen. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s fehlgeschlagen: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Änderungen verwerfen?</string>
|
||||
<string name="git_state_confirm_discard_text">Lokale Änderungen an den ausgewählten Dateien werden verworfen und können nicht wiederhergestellt werden.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Verwerfen</string>
|
||||
<string name="git_state_confirm_push_title">Änderungen pushen?</string>
|
||||
<string name="git_state_confirm_push_text">Pushen Sie den aktuellen Branch zu seinem Remote. Dabei werden lokale Commits an das Remote-Repository gesendet.</string>
|
||||
<string name="git_state_confirm_push_confirm">Pushen</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Branch mit lokalen Änderungen wechseln?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">Der Arbeitsbaum enthält nicht committete Änderungen. Beim Wechseln werden sie möglicherweise auf den Zielbranch übertragen.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Wechseln</string>
|
||||
<string name="git_state_generate_message">Commit-Nachricht generieren</string>
|
||||
<string name="git_state_generating_message">Commit-Nachricht wird generiert…</string>
|
||||
<string name="git_state_push_after_commit">Nach Commit pushen</string>
|
||||
<string name="git_state_switch_stash">Wechseln (bei Änderungen stashen)</string>
|
||||
<string name="support_bundle_review">Supportinformationen prüfen</string>
|
||||
<string name="support_bundle_title">Supportinformationen</string>
|
||||
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
|
||||
@@ -4041,7 +4098,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 +4353,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 & 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>
|
||||
@@ -4332,4 +4428,13 @@
|
||||
<string name="custom_theme_saved_to">Änderungen in %1$s gespeichert</string>
|
||||
<string name="custom_theme_modified">Nicht gespeicherte Änderungen</string>
|
||||
<string name="custom_theme_saved_count">%1$d gespeicherte Presets</string>
|
||||
<string name="chat_settings_show_git_workspace">Git-Arbeitsbereich im Chat anzeigen</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">Zeigt Branch und Änderungen über dem Eingabefeld an</string>
|
||||
<string name="chat_git_open_workspace">Git-Arbeitsbereich öffnen</string>
|
||||
<string name="chat_git_branch">Branch %1$s</string>
|
||||
<string name="chat_git_additions">%1$d Hinzufügungen</string>
|
||||
<string name="chat_git_deletions">%1$d Löschungen</string>
|
||||
<plurals name="chat_git_change_count"><item quantity="one">%1$d Änderung</item><item quantity="other">%1$d Änderungen</item></plurals>
|
||||
<string name="settings_git_workspace">Git-Arbeitsbereich</string>
|
||||
<string name="settings_git_workspace_desc">Änderungen, Branches, Commits und Remotes prüfen</string>
|
||||
</resources>
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
<string name="chat_editing_notice">Editando: rebobinará la conversación</string>
|
||||
<string name="chat_mic_perm_needed">Se necesita permiso para el micrófono</string>
|
||||
<string name="chat_mic_perm_body">Toque Abrir configuración y otorgue acceso al micrófono para usar el modo de voz.</string>
|
||||
<string name="chat_clean_view_hint">Mantenga el chat para una vista clara y enfocada</string>
|
||||
<string name="chat_manage_connections">Administrar conexiones</string>
|
||||
<string name="chat_no_clipboard_image">No hay ninguna imagen en el portapapeles</string>
|
||||
<string name="chat_copied_to_clipboard">Copiado al portapapeles</string>
|
||||
@@ -538,6 +537,20 @@
|
||||
<string name="changelog_no_notes">No hay notas de versión disponibles.</string>
|
||||
<string name="changelog_collapse">Colapsar</string>
|
||||
<string name="changelog_expand">Expandir</string>
|
||||
<string name="changelog_state_expanded">Expandido</string>
|
||||
<string name="changelog_state_collapsed">Contraído</string>
|
||||
<string name="changelog_installed">Instalada</string>
|
||||
<string name="changelog_also_improved">También mejorado</string>
|
||||
<string name="changelog_toast_also">También: %1$s</string>
|
||||
<string name="changelog_view_all">Ver todo</string>
|
||||
<plurals name="changelog_additional_feature_count">
|
||||
<item quantity="one">%1$d función</item>
|
||||
<item quantity="other">%1$d funciones</item>
|
||||
</plurals>
|
||||
<plurals name="changelog_fix_count">
|
||||
<item quantity="one">%1$d corrección</item>
|
||||
<item quantity="other">%1$d correcciones</item>
|
||||
</plurals>
|
||||
<string name="diag_title">Diagnóstico</string>
|
||||
<string name="diag_back">Atrás</string>
|
||||
<string name="diag_status">Estado</string>
|
||||
@@ -625,10 +638,18 @@
|
||||
<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>
|
||||
<string name="settings_whats_new_desc">Notas de la versión y registro de cambios completo</string>
|
||||
<string name="settings_whats_new_desc">Historial de versiones de Android</string>
|
||||
<string name="settings_about">Acerca de</string>
|
||||
<string name="settings_about_desc">Versión, créditos, documentos.</string>
|
||||
<string name="settings_profile_lock">Bloqueo de perfil</string>
|
||||
@@ -840,6 +861,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 +894,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>
|
||||
@@ -2379,7 +2404,6 @@
|
||||
<string name="power_gate_explanation_expired">El servidor ya no acepta su sesión relay. Abra la conexión para reparar.</string>
|
||||
<string name="power_gate_explanation_unavailable">Este servidor Hermes no expone la superficie necesaria para esta función.</string>
|
||||
<string name="power_gate_explanation_signin">Esta función estándar del panel necesita iniciar sesión en el panel antes de poder usarse.</string>
|
||||
<string name="agent_text_placeholder">Mensaje</string>
|
||||
<string name="extra_keys_esc">ESC</string>
|
||||
<string name="extra_keys_tab">PESTAÑA</string>
|
||||
<string name="extra_keys_ctrl">CONTROL</string>
|
||||
@@ -2767,9 +2791,6 @@
|
||||
<string name="chat_relay_sessions_summary">Ver y revocar dispositivos emparejados con este relay.</string>
|
||||
<string name="chat_relay_sessions_title">Sesiones Relay</string>
|
||||
<string name="chat_rename_failed">Error al cambiar el nombre</string>
|
||||
<string name="agent_text_send_cd">Enviar</string>
|
||||
<string name="agent_text_exit_clean">Salir del modo limpio</string>
|
||||
<string name="agent_text_sphere_desc">Agente</string>
|
||||
<string name="update_banner_available_fmt">Actualización disponible: v%1$s</string>
|
||||
<string name="update_banner_body_fmt">Estás en %1$s. Toque Actualizar para descargar.</string>
|
||||
<string name="attach_viewer_cd_close">Cerrar</string>
|
||||
@@ -3691,6 +3712,48 @@
|
||||
<string name="plugins_keep">Conservar</string>
|
||||
<string name="plugins_remove">Eliminar</string>
|
||||
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Atrás</string>
|
||||
<string name="git_state_staged">Preparados</string>
|
||||
<string name="git_state_modified">Modificados</string>
|
||||
<string name="git_state_untracked">Sin seguimiento</string>
|
||||
<string name="git_state_branches">Ramas</string>
|
||||
<string name="git_state_truncated">Resultados truncados: solo se muestran las primeras entradas.</string>
|
||||
<string name="git_state_no_changes">(sin cambios)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Los cambios de escritura requieren el permiso de cambios de plugin. Activa «Permitir cambios de plugins (plugin.api.write)» en la configuración de permisos de Plugins.</string>
|
||||
<string name="git_state_stage">Preparar</string>
|
||||
<string name="git_state_unstage">Quitar de preparados</string>
|
||||
<string name="git_state_discard">Descartar</string>
|
||||
<string name="git_state_commit">Confirmar</string>
|
||||
<string name="git_state_commit_title">Confirmar cambios preparados</string>
|
||||
<string name="git_state_commit_message_hint">Mensaje de confirmación</string>
|
||||
<string name="git_state_commit_confirm">Confirmar</string>
|
||||
<string name="git_state_cancel">Cancelar</string>
|
||||
<string name="git_state_fetch">Obtener</string>
|
||||
<string name="git_state_pull">Traer</string>
|
||||
<string name="git_state_push">Enviar</string>
|
||||
<string name="git_state_new_branch_hint">Nombre de la nueva rama</string>
|
||||
<string name="git_state_create_branch">Crear rama</string>
|
||||
<string name="git_state_track_remote">Seguir rama remota</string>
|
||||
<string name="git_state_switch">Cambiar</string>
|
||||
<string name="git_state_current">Actual</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s en curso…</string>
|
||||
<string name="git_state_mutation_success">%1$s completado. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s falló: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">¿Descartar cambios?</string>
|
||||
<string name="git_state_confirm_discard_text">Los cambios locales de los archivos seleccionados se descartarán y no podrán recuperarse.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Descartar</string>
|
||||
<string name="git_state_confirm_push_title">¿Enviar cambios?</string>
|
||||
<string name="git_state_confirm_push_text">Envía la rama actual a su remoto. Esto sube los commits locales al repositorio remoto.</string>
|
||||
<string name="git_state_confirm_push_confirm">Enviar</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">¿Cambiar de rama con cambios locales?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">El árbol de trabajo tiene cambios sin confirmar. Cambiar puede trasladarlos a la rama de destino.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Cambiar</string>
|
||||
<string name="git_state_generate_message">Generar mensaje de confirmación</string>
|
||||
<string name="git_state_generating_message">Generando mensaje de confirmación…</string>
|
||||
<string name="git_state_push_after_commit">Enviar después de confirmar</string>
|
||||
<string name="git_state_switch_stash">Cambiar (guardar en stash si hay cambios)</string>
|
||||
<string name="support_bundle_review">Revisar información de soporte</string>
|
||||
<string name="support_bundle_title">Información de soporte</string>
|
||||
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
|
||||
@@ -3726,7 +3789,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 +4044,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>
|
||||
@@ -4017,4 +4119,13 @@
|
||||
<string name="custom_theme_saved_to">Cambios guardados en %1$s</string>
|
||||
<string name="custom_theme_modified">Cambios sin guardar</string>
|
||||
<string name="custom_theme_saved_count">%1$d preajustes guardados</string>
|
||||
<string name="chat_settings_show_git_workspace">Mostrar el espacio de Git en el chat</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">Muestra la rama y los cambios encima del cuadro de texto</string>
|
||||
<string name="chat_git_open_workspace">Abrir el espacio de Git</string>
|
||||
<string name="chat_git_branch">Rama %1$s</string>
|
||||
<string name="chat_git_additions">%1$d adiciones</string>
|
||||
<string name="chat_git_deletions">%1$d eliminaciones</string>
|
||||
<plurals name="chat_git_change_count"><item quantity="one">%1$d cambio</item><item quantity="other">%1$d cambios</item></plurals>
|
||||
<string name="settings_git_workspace">Espacio de Git</string>
|
||||
<string name="settings_git_workspace_desc">Revisa cambios, ramas, commits y remotos</string>
|
||||
</resources>
|
||||
|
||||