Compare commits

...
Author SHA1 Message Date
Bailey Dixon 578c074797 fix(android): surface assistant capture recovery 2026-08-29 13:37:35 -04:00
Bailey Dixon 834763a213 Merge pull request #463 from Codename-11/fix/android-remove-chat-clean-mode
fix(android): remove chat clean focus mode
2026-08-27 20:20:15 -04:00
Bailey Dixon 8ef6b794b2 Merge remote-tracking branch 'origin/dev' into fix/android-remove-chat-clean-mode 2026-08-27 20:08:01 -04:00
Bailey Dixon 17d8a2374a fix(android): remove chat clean focus mode 2026-08-27 20:07:43 -04:00
Bailey Dixon 274ae11160 Merge pull request #462 from Codename-11/docs/pr-intake-external-only
docs: limit PR intake to external contributors
2026-08-27 19:56:52 -04:00
Bailey Dixon 9cebdb67e6 docs: limit PR intake to external contributors 2026-08-27 19:54:49 -04:00
Bailey Dixon ee300b3b15 Merge pull request #460 from Codename-11/docs/triage-assignment-pr-intake
docs: document triage ownership and PR intake
2026-08-27 18:32:20 -04:00
Bailey Dixon 2b75577fcf docs: document triage ownership and PR intake 2026-08-27 18:05:11 -04:00
Bailey Dixon ff2f94922b Merge pull request #459 from Codename-11/docs/pull-request-template
docs: add canonical pull request template
2026-08-27 16:10:29 -04:00
Bailey Dixon 292cc94eb4 docs: add canonical pull request template 2026-08-26 21:47:45 -04:00
Bailey Dixon 621081b633 Merge pull request #458 from Codename-11/docs/triage-bot-attribution
docs: identify automated triage bot
2026-08-26 21:01:18 -04:00
Bailey Dixon da8e822b66 docs: identify automated triage bot 2026-08-26 20:51:32 -04:00
Bailey Dixon 0015a913f1 Merge pull request #457 from Codename-11/feature/android-whats-new-highlights
feat(android): improve What's New release highlights
2026-08-26 20:40:10 -04:00
Bailey Dixon 0f47e464f3 Merge remote-tracking branch 'origin/dev' into feature/android-whats-new-highlights 2026-08-26 20:20:26 -04:00
Bailey Dixon 2411cf19b3 Merge pull request #456 from Codename-11/chore/hermes-issue-triage-docs
docs: document bounded issue triage
2026-08-26 20:13:55 -04:00
Bailey Dixon 51b4edd52a Merge remote-tracking branch 'origin/dev' into feature/android-whats-new-highlights
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt
#	docs/localization-status.json
2026-08-26 20:02:24 -04:00
Bailey Dixon d3c4e90696 docs: document bounded issue triage 2026-08-26 19:59:01 -04:00
Bailey Dixon cd7cad03ec Merge pull request #455 from Codename-11/feature/native-git-workspace
feat(android): add first-class native Git workspace
2026-08-26 19:46:35 -04:00
Bailey Dixon bea3021ef0 Merge remote-tracking branch 'origin/dev' into feature/android-whats-new-highlights
# Conflicts:
#	CHANGELOG.md
2026-08-26 19:17:57 -04:00
Bailey Dixon 00a9a7efcb feat(android): improve What's New release highlights 2026-08-26 19:17:09 -04:00
Bailey Dixon 7a97b71844 docs: publish native git workspace references 2026-08-26 19:09:15 -04:00
Bailey Dixon ae51ae3ab1 Merge origin/dev into feature/native-git-workspace 2026-08-26 19:03:31 -04:00
Bailey Dixon 1bdcab8fc0 feat(android): add native git workspace surfaces 2026-08-26 19:03:21 -04:00
Bailey Dixon 0c77d010fa Merge pull request #454 from Codename-11/fix/android-smooth-sphere-motion
fix(android): keep visible sphere motion smooth
2026-08-26 18:51:23 -04:00
Bailey Dixon dc18209c3c fix(android): keep visible sphere motion smooth 2026-08-26 18:18:03 -04:00
Bailey Dixon 69347adb34 Merge pull request #445 from Codename-11/chore/reconcile-android-1.13.2
chore: reconcile Android 1.13.2 into dev
2026-08-25 21:36:11 -04:00
Bailey Dixon 43a809e41a chore: reconcile android-v1.13.2 into dev 2026-08-25 21:25:01 -04:00
Bailey Dixon a5cc0104bf Merge pull request #443 from Codename-11/fix/android-1.13.2-no-git
release(android): android-v1.13.2
2026-08-25 20:38:04 -04:00
Bailey Dixon 86a0bebc0d fix(ci): avoid untrusted upstream checkout 2026-08-25 20:21:22 -04:00
Bailey Dixon 04d9421c74 release(android): android-v1.13.2 2026-08-25 20:10:35 -04:00
Bailey Dixon 14401aa3c3 Merge pull request #441 from Codename-11/fix/android-session-activity-fallback
fix(android): keep unknown session activity neutral

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

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

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

(cherry picked from commit 5580c9d9bb)
2026-08-25 20:02:11 -04:00
Bailey Dixon d5cce4e390 Merge pull request #441 from Codename-11/fix/android-session-activity-fallback
fix(android): keep unknown session activity neutral
2026-08-25 19:41:41 -04:00
Bailey Dixon ae9b22a9e6 fix(android): keep unknown session activity neutral 2026-08-25 19:01:25 -04:00
Bailey Dixon 96a9e8077e Merge pull request #440 from Codename-11/chore/automate-hotfix-backmerge
feat(ci): automate conflict-free release backmerges
2026-08-25 17:12:49 -04:00
Bailey Dixon a97e6a2b14 chore: merge current dev into release backmerge automation 2026-08-25 16:53:13 -04:00
Bailey Dixon 4317da85fd Merge pull request #439 from Codename-11/fix/android-supervised-return-blank
fix(android): stabilize supervised parent relock
2026-08-25 16:24:42 -04:00
Bailey Dixon 45fde0ad9a feat(ci): automate conflict-free release backmerges 2026-08-25 16:21:14 -04:00
Bailey Dixon 94565e9d6d fix(android): stabilize supervised parent relock 2026-08-25 16:13:47 -04:00
Bailey Dixon 56c2e6fa07 Merge pull request #438 from Codename-11/chore/backmerge-android-1.13.1
chore: back-merge Android 1.13.1
2026-08-25 15:30:33 -04:00
Bailey Dixon 28629f3d93 chore: back-merge android 1.13.1 2026-08-25 15:18:32 -04:00
Bailey Dixon c9a5c767c6 Merge pull request #437 from Codename-11/fix/android-session-activity-hotfix
fix(android): release authoritative session activity
2026-08-25 14:33:40 -04:00
Bailey Dixon 0d1faf47a0 Merge pull request #435 from Codename-11/fix/git-state-audit
fix: harden Git state repository operations
2026-08-25 14:05:40 -04:00
Bailey Dixon 00288a2b3b test(git-state): cover links across platforms 2026-08-25 13:54:45 -04:00
Bailey Dixon 8f52feffba fix(git-state): harden repository operations 2026-08-25 13:51:10 -04:00
Bailey Dixon 524e319f95 release(android): android-v1.13.1 2026-08-25 13:04:28 -04:00
Bailey Dixon 647d1f9aea fix(android): make session activity authoritative 2026-08-25 12:56:22 -04:00
Bailey Dixon ee29e49361 Merge pull request #434 from Codename-11/fix/android-session-activity-truth
fix(android): make session activity authoritative
2026-08-25 12:37:31 -04:00
Bailey Dixon e2073b7692 chore: merge dev into session activity fix 2026-08-25 12:26:42 -04:00
Bailey Dixon 70b6d8ee5a Merge pull request #433 from mrvigneshvt/feat/git-state
feat: Git State — Hermex-parity repo management for host repos
2026-08-25 12:03:50 -04:00
Bailey Dixon 1f5e50ccd7 chore: merge dev into git-state 2026-08-25 11:51:44 -04:00
Bailey Dixon 5580c9d9bb Merge pull request #419 from Codename-11/feature/android-supervised-mode
feat(android): add supervised mode
2026-08-25 11:42:21 -04:00
mrvigneshvt ad107ea205 feat(git-state): add AI commit messages, stash-checkout, and push-after-commit
Commit-message generation reuses the upstream async LLM helper via the plugin's deferred-import pattern; empty staged diffs never call the model and failures degrade to an empty message plus notice. stash_checkout auto-stashes a dirty tree before switching (recoverable; stash surfaced as a notice). Push-after-commit toggle auto-starts the push confirmation flow without bypassing the confirmation token. Truncation caps consistent across all bounded endpoints. New UI strings localized across the 12-catalog parity gate.
2026-08-25 13:25:21 +00:00
mrvigneshvt f5aeb27e5a feat(git-state): add git write operations and Android write UI
POST endpoints for stage, unstage, discard, commit (selected paths supported), fetch, pull (--ff-only), push, and checkout with new-branch/track. Destructive ops enforce fixed per-use confirmation tokens (403 on missing or mismatched confirmation); the plugin.api.write grant gates writes client-side per the documented plugin contract; git failures classify into a structured taxonomy mapped to HTTP (409 dirty/conflict, 502 auth/network, 400 invalid). Android: commit dialog, branch picker with track, confirmation dialogs (standard AlertDialog pattern), grant-refusal notice, mutation progress/error rendering, 16 view-model tests. Dashboard: identically gated write controls. 56 endpoint/helper-level tests; deterministic fixtures.
2026-08-25 13:25:21 +00:00
mrvigneshvt fdaeb121d5 feat(git-state): add read-only git workspace browser (scan, status, branches, diff, file read)
Plugin endpoints under /api/plugins/hermes-relay/git/* backed by a scanned-repo allowlist with configurable base path; bounded responses with truncation flags; traversal-rejected file reads returning working-tree content with clear binary/non-UTF-8 errors; remote URLs scrubbed of userinfo; zero shell interpolation. Registers the Git mobile plugin page and a read-only dashboard tab; Android renders the surface via a dedicated Compose screen with view model and unit tests; all locale catalogs refreshed.
2026-08-25 13:25:21 +00:00
Bailey Dixon 06c0df6304 fix(android): make session activity authoritative 2026-08-25 08:51:12 -04:00
155 changed files with 13489 additions and 1388 deletions
+47
View File
@@ -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
+5 -1
View File
@@ -3,8 +3,11 @@
function classifyCiPaths(paths) {
const forceAll = paths.some((path) => [
'.github/workflows/ci-required.yml',
'.github/workflows/release-backmerge.yml',
'.github/scripts/classify-ci-paths.cjs',
'.github/scripts/classify-ci-paths.test.cjs',
'scripts/plan_release_backmerge.py',
'scripts/tests/plan_release_backmerge_test.py',
].includes(path));
const exact = (values) => paths.some((path) => values.includes(path));
const under = (prefixes) => paths.some((path) => prefixes.some((prefix) => path.startsWith(prefix)));
@@ -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.');
+7
View File
@@ -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
+40 -18
View File
@@ -11,6 +11,9 @@
name: CI — Upstream Contract
permissions:
contents: read
on:
workflow_call:
push:
@@ -40,45 +43,64 @@ jobs:
steps:
- name: Checkout hermes-relay
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Resolve upstream ref
id: ref
env:
REQUESTED_REF: ${{ github.event.inputs.upstream_ref }}
run: |
set -euo pipefail
# PR/push runs use a known-good NousResearch/hermes-agent commit so
# normal CI is stable. The weekly schedule below intentionally tracks
# main as the upstream-drift siren.
DEFAULT_REF="ef4b897a1843cd32c4f141f55db60f0f0602cc98"
if [ "${{ github.event_name }}" = "schedule" ]; then
REF="main" # weekly drift siren
elif [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
REF="${{ github.event.inputs.upstream_ref }}" # manual override
elif [ -n "$REQUESTED_REF" ]; then
REF="$REQUESTED_REF" # manual override
else
REF="$DEFAULT_REF"
fi
# The ref is passed to git below, so reject option-like or malformed
# values before it reaches that boundary. Full commit IDs and normal
# branch/tag names remain supported for manual contract checks.
if [[ "$REF" == -* ]] ||
! git check-ref-format --allow-onelevel "$REF" >/dev/null; then
echo "FAIL: invalid upstream branch or tag name." >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
echo "Checking standard-path route contract against upstream ref: $REF"
- name: Checkout vanilla upstream (no plugin, no bootstrap)
uses: actions/checkout@v7
with:
repository: NousResearch/hermes-agent
ref: ${{ steps.ref.outputs.ref }}
path: _upstream
fetch-depth: 1
- name: Extract trusted upstream contract sources
env:
UPSTREAM_REF: ${{ steps.ref.outputs.ref }}
run: |
set -euo pipefail
UPSTREAM_GIT="$RUNNER_TEMP/hermes-agent-contract.git"
git init --bare "$UPSTREAM_GIT"
git -C "$UPSTREAM_GIT" remote add origin \
"https://github.com/NousResearch/hermes-agent.git"
git -C "$UPSTREAM_GIT" fetch --no-tags --depth=1 origin -- "$UPSTREAM_REF"
UPSTREAM_COMMIT="$(git -C "$UPSTREAM_GIT" rev-parse 'FETCH_HEAD^{commit}')"
mkdir -p _upstream/gateway/platforms _upstream/hermes_cli
git -C "$UPSTREAM_GIT" show \
"$UPSTREAM_COMMIT:gateway/platforms/api_server.py" \
> _upstream/gateway/platforms/api_server.py
git -C "$UPSTREAM_GIT" show \
"$UPSTREAM_COMMIT:hermes_cli/web_server.py" \
> _upstream/hermes_cli/web_server.py
echo "Extracted contract sources from upstream commit: $UPSTREAM_COMMIT"
- name: Set up Python 3.11
uses: actions/setup-python@v7
with:
python-version: "3.11"
- name: Assert upstream checkout is vanilla (no relay bootstrap/plugin)
run: |
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
echo "FAIL: upstream checkout contains a relay bootstrap — not vanilla."; exit 1
fi
echo "OK: upstream checkout carries no relay plugin/bootstrap."
- name: Run route-surface contract
run: python scripts/check-upstream-route-contract.py "_upstream"
+9 -1
View File
@@ -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: |
+5 -1
View File
@@ -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
+53 -2
View File
@@ -10,6 +10,16 @@ on:
pull_request:
branches: [main, dev]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
inputs:
base_sha:
description: "Exact base commit for a trusted release-backmerge candidate"
required: true
type: string
head_sha:
description: "Exact candidate commit to check"
required: true
type: string
permissions:
contents: read
@@ -31,22 +41,63 @@ jobs:
contract: ${{ steps.filter.outputs.contract }}
docs: ${{ steps.filter.outputs.docs }}
steps:
- name: Checkout repository
- name: Checkout pull request merge
if: github.event_name == 'pull_request'
uses: actions/checkout@v7
with:
fetch-depth: 2
- name: Checkout exact dispatched candidate
if: github.event_name == 'workflow_dispatch'
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.head_sha }}
- name: Test path classifier
run: node .github/scripts/classify-ci-paths.test.cjs
- name: Classify changed files
id: filter
uses: actions/github-script@v8
env:
DISPATCH_BASE_SHA: ${{ inputs.base_sha }}
DISPATCH_HEAD_SHA: ${{ inputs.head_sha }}
with:
script: |
let diffArgs;
if (context.eventName === 'workflow_dispatch') {
const base = process.env.DISPATCH_BASE_SHA || '';
const head = process.env.DISPATCH_HEAD_SHA || '';
const shaPattern = /^[0-9a-f]{40}$/;
if (!shaPattern.test(base) || !shaPattern.test(head)) {
core.setFailed('Exact-tree dispatch requires full 40-character base/head SHAs.');
return;
}
const { stdout: checkedOut } = await exec.getExecOutput(
'git',
['rev-parse', 'HEAD'],
);
if (checkedOut.trim() !== head) {
core.setFailed(`Checked out ${checkedOut.trim()}, expected ${head}.`);
return;
}
const ancestry = await exec.exec(
'git',
['merge-base', '--is-ancestor', base, head],
{ ignoreReturnCode: true },
);
if (ancestry !== 0) {
core.setFailed(`Candidate ${head} does not descend from base ${base}.`);
return;
}
diffArgs = ['diff', '--name-only', base, head];
} else {
diffArgs = ['diff', '--name-only', 'HEAD^1', 'HEAD^2'];
}
const { stdout } = await exec.getExecOutput(
'git',
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
diffArgs,
);
const paths = stdout.split(/\r?\n/).filter(Boolean);
const { classifyCiPaths } = require(
+1 -1
View File
@@ -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:
+20
View File
@@ -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"
+267
View File
@@ -0,0 +1,267 @@
# Reconcile a completed stable hotfix into dev without adding a ceremonial PR
# merge commit. Normal dev -> main releases are detected and intentionally no-op.
# A conflicted merge, failed exact-tree CI, stale dev ref, or denied branch update
# stops without mutating dev and falls back to the normal reconciliation PR path.
name: Release Backmerge
on:
workflow_dispatch:
inputs:
release_tag:
description: "Published stable tag to reconcile (android-v*, server-v*, or desktop-v*)"
required: true
type: string
permissions:
contents: read
concurrency:
group: release-backmerge-dev
cancel-in-progress: false
jobs:
prepare:
name: Prepare exact backmerge candidate
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
outcome: ${{ steps.prepare.outputs.outcome }}
base_dev_sha: ${{ steps.prepare.outputs.base_dev_sha }}
candidate_branch: ${{ steps.prepare.outputs.candidate_branch }}
candidate_sha: ${{ steps.prepare.outputs.candidate_sha }}
release_commit: ${{ steps.prepare.outputs.release_commit }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: main
- name: Validate release and prepare merge commit
id: prepare
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
shell: bash
run: |
set -euo pipefail
if [[ ! "$RELEASE_TAG" =~ ^(android|server|desktop)-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Release Backmerge accepts stable SemVer production tags only; got $RELEASE_TAG"
exit 1
fi
git fetch origin \
"+refs/heads/main:refs/remotes/origin/main" \
"+refs/heads/dev:refs/remotes/origin/dev" \
"+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
release_commit="$(git rev-parse "${RELEASE_TAG}^{commit}")"
base_dev_sha="$(git rev-parse origin/dev)"
echo "release_commit=$release_commit" >> "$GITHUB_OUTPUT"
echo "base_dev_sha=$base_dev_sha" >> "$GITHUB_OUTPUT"
if ! git merge-base --is-ancestor "$release_commit" origin/main; then
echo "::error::$RELEASE_TAG ($release_commit) is not contained in origin/main"
exit 1
fi
read -r is_draft is_prerelease < <(
gh release view "$RELEASE_TAG" --json isDraft,isPrerelease \
--jq '[.isDraft, .isPrerelease] | @tsv'
)
if [ "$is_draft" != "false" ] || [ "$is_prerelease" != "false" ]; then
echo "::error::$RELEASE_TAG is not a published stable GitHub release"
exit 1
fi
plan="$(
python3 scripts/plan_release_backmerge.py \
--release-commit "$release_commit" \
--dev-commit "$base_dev_sha"
)"
case "$plan" in
already-contained)
echo "outcome=noop" >> "$GITHUB_OUTPUT"
echo "## Release backmerge not needed" >> "$GITHUB_STEP_SUMMARY"
echo "\`$RELEASE_TAG\` is already contained in \`dev\`." >> "$GITHUB_STEP_SUMMARY"
exit 0
;;
normal-release)
echo "outcome=noop" >> "$GITHUB_OUTPUT"
echo "## Normal release: no backmerge" >> "$GITHUB_STEP_SUMMARY"
echo "The released merge's integration parent is already contained in \`dev\`." >> "$GITHUB_STEP_SUMMARY"
exit 0
;;
hotfix) ;;
*)
echo "::error::Unknown release-backmerge plan: $plan"
exit 1
;;
esac
candidate_branch="chore/release-backmerge/${RELEASE_TAG}-${GITHUB_RUN_ID}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch --detach "$base_dev_sha"
set +e
git merge --no-ff -m "chore: back-merge ${RELEASE_TAG}" "$release_commit"
merge_status=$?
set -e
if [ "$merge_status" -ne 0 ]; then
conflicts="$(git diff --name-only --diff-filter=U | paste -sd ', ' -)"
echo "outcome=conflict" >> "$GITHUB_OUTPUT"
echo "::error::Automatic backmerge conflicts: ${conflicts:-unknown}. Open a reconciliation PR."
echo "## Manual reconciliation PR required" >> "$GITHUB_STEP_SUMMARY"
echo "\`$RELEASE_TAG\` conflicts with current \`dev\`: ${conflicts:-unknown}." >> "$GITHUB_STEP_SUMMARY"
git merge --abort || true
exit 1
fi
candidate_sha="$(git rev-parse HEAD)"
first_parent="$(git rev-parse HEAD^1)"
second_parent="$(git rev-parse HEAD^2)"
if [ "$first_parent" != "$base_dev_sha" ] || [ "$second_parent" != "$release_commit" ]; then
echo "::error::Candidate parents do not match dev + release commit"
exit 1
fi
git push origin "$candidate_sha:refs/heads/$candidate_branch"
echo "outcome=candidate" >> "$GITHUB_OUTPUT"
echo "candidate_branch=$candidate_branch" >> "$GITHUB_OUTPUT"
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
echo "## Backmerge candidate prepared" >> "$GITHUB_STEP_SUMMARY"
echo "- Release: \`$RELEASE_TAG\` (\`$release_commit\`)" >> "$GITHUB_STEP_SUMMARY"
echo "- Dev base: \`$base_dev_sha\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Candidate: \`$candidate_sha\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Temporary ref: \`$candidate_branch\`" >> "$GITHUB_STEP_SUMMARY"
gate:
name: Run exact-tree required checks
needs: prepare
if: needs.prepare.outputs.outcome == 'candidate'
permissions:
actions: write
contents: read
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
check_run_id: ${{ steps.gate.outputs.check_run_id }}
steps:
- name: Dispatch and await Required checks
id: gate
env:
GH_TOKEN: ${{ github.token }}
BASE_DEV_SHA: ${{ needs.prepare.outputs.base_dev_sha }}
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
shell: bash
run: |
set -euo pipefail
gh workflow run ci-required.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "$CANDIDATE_BRANCH" \
-f base_sha="$BASE_DEV_SHA" \
-f head_sha="$CANDIDATE_SHA"
check_run_id=""
for _ in {1..20}; do
check_run_id="$(
gh run list \
--repo "$GITHUB_REPOSITORY" \
--workflow ci-required.yml \
--branch "$CANDIDATE_BRANCH" \
--event workflow_dispatch \
--limit 20 \
--json databaseId,headSha \
--jq ".[] | select(.headSha == \"$CANDIDATE_SHA\") | .databaseId" \
| head -n 1
)"
if [ -n "$check_run_id" ]; then
break
fi
sleep 3
done
if [ -z "$check_run_id" ]; then
echo "::error::Required checks dispatch was not observed for $CANDIDATE_SHA"
exit 1
fi
echo "check_run_id=$check_run_id" >> "$GITHUB_OUTPUT"
gh run watch "$check_run_id" --repo "$GITHUB_REPOSITORY" --exit-status
promote:
name: Compare-and-swap dev
needs: [prepare, gate]
if: needs.prepare.outputs.outcome == 'candidate' && needs.gate.result == 'success'
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: main
- name: Fast-forward dev to the tested candidate
env:
BASE_DEV_SHA: ${{ needs.prepare.outputs.base_dev_sha }}
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
RELEASE_COMMIT: ${{ needs.prepare.outputs.release_commit }}
shell: bash
run: |
set -euo pipefail
git fetch origin --no-tags \
"+refs/heads/dev:refs/remotes/origin/dev" \
"+refs/heads/$CANDIDATE_BRANCH:refs/remotes/origin/$CANDIDATE_BRANCH"
current_dev="$(git rev-parse origin/dev)"
remote_candidate="$(git rev-parse "origin/$CANDIDATE_BRANCH")"
if [ "$current_dev" != "$BASE_DEV_SHA" ]; then
echo "::error::dev moved from $BASE_DEV_SHA to $current_dev; rerun or open a reconciliation PR"
exit 1
fi
if [ "$remote_candidate" != "$CANDIDATE_SHA" ]; then
echo "::error::Candidate ref moved from $CANDIDATE_SHA to $remote_candidate"
exit 1
fi
if [ "$(git rev-parse "$CANDIDATE_SHA^1")" != "$BASE_DEV_SHA" ] || \
[ "$(git rev-parse "$CANDIDATE_SHA^2")" != "$RELEASE_COMMIT" ]; then
echo "::error::Candidate ancestry changed after verification"
exit 1
fi
# The explicit lease is the atomic stale-base guard. The update is a
# fast-forward from BASE_DEV_SHA; no unrelated history can be replaced.
git push \
--force-with-lease="refs/heads/dev:$BASE_DEV_SHA" \
origin "$CANDIDATE_SHA:refs/heads/dev"
git push origin --delete "$CANDIDATE_BRANCH" || \
echo "::warning::Could not remove temporary branch $CANDIDATE_BRANCH"
echo "## Release backmerge complete" >> "$GITHUB_STEP_SUMMARY"
echo "Fast-forwarded \`dev\` from \`$BASE_DEV_SHA\` to tested merge \`$CANDIDATE_SHA\`." >> "$GITHUB_STEP_SUMMARY"
fallback:
name: Report PR fallback
needs: [prepare, gate, promote]
if: always() && needs.prepare.outputs.outcome == 'candidate' && needs.promote.result != 'success'
runs-on: ubuntu-latest
steps:
- name: Preserve safe fallback instructions
env:
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
CHECK_RUN_ID: ${{ needs.gate.outputs.check_run_id }}
run: |
echo "## Automatic backmerge stopped" >> "$GITHUB_STEP_SUMMARY"
echo "\`dev\` was not updated. Open or refresh a reconciliation PR after addressing the failed/stale gate." >> "$GITHUB_STEP_SUMMARY"
echo "- Candidate ref: \`${CANDIDATE_BRANCH:-not-created}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Candidate SHA: \`${CANDIDATE_SHA:-n/a}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Required-check run: \`${CHECK_RUN_ID:-n/a}\`" >> "$GITHUB_STEP_SUMMARY"
+19
View File
@@ -539,3 +539,22 @@ jobs:
release-assets/cli-binaries/hermes-relay-darwin-arm64
release-assets/cli-windows-installer/hermes-relay-windows-x64-setup.exe
release-assets/SHA256SUMS.txt
request-backmerge:
name: Request stable release backmerge
needs: [validate-release, publish-release]
if: ${{ !contains(needs.validate-release.outputs.version, '-') }}
permissions:
actions: write
contents: read
runs-on: ubuntu-latest
steps:
- name: Dispatch fail-closed release reconciliation
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: desktop-v${{ needs.validate-release.outputs.version }}
run: |
gh workflow run release-backmerge.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f release_tag="$RELEASE_TAG"
+19
View File
@@ -138,3 +138,22 @@ jobs:
dist/*.whl
dist/*.tar.gz
dist/SHA256SUMS.txt
request-backmerge:
name: Request stable release backmerge
needs: [validate, package]
if: ${{ !contains(needs.validate.outputs.version, '-') }}
permissions:
actions: write
contents: read
runs-on: ubuntu-latest
steps:
- name: Dispatch fail-closed release reconciliation
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: server-v${{ needs.validate.outputs.version }}
run: |
gh workflow run release-backmerge.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f release_tag="$RELEASE_TAG"
+43 -1
View File
@@ -28,7 +28,7 @@ not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
| Staging source | An exact tested `dev` SHA or release-candidate tag; staging is an environment, never a branch |
| Production source | Immutable `android-v*`, `server-v*`, or `desktop-v*` tags, selected by surface |
| Hotfix base | The immutable production tag for the affected surface |
| Back-merge target | `dev`; merge `main` back immediately after every hotfix |
| Back-merge target | `dev`; stable hotfixes reconcile automatically when the exact tested merge is conflict-free, otherwise through a PR |
Feature completion means merged and verified on `dev`; it does not mean
released. A release train is separate work owned by a Forge release
@@ -37,6 +37,14 @@ open the `dev` → `main` release PR, tag the resulting `main` tip, publish the
surface artifacts, deploy or roll out, and verify the live result. Never create
a staging branch.
A normal `dev` → `main` release needs no back-merge: the released integration
parent is already in `dev`. A production-tag hotfix is different. After its
stable release succeeds, `Release Backmerge` prepares a `dev`-first merge
commit, runs the same path-aware required checks on that exact SHA, verifies
that `dev` has not moved, and fast-forwards `dev`. Conflicts, failed checks,
stale refs, or denied branch updates fail closed and require a reconciliation
PR; never resolve those cases by choosing a side automatically.
### Local integration discipline
- Fetch `origin/dev` before creating a task branch or worktree; do not base new
@@ -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
+35 -1
View File
@@ -6,6 +6,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### 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
- **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.
- **Android Assistant sessions explain when no speech was captured instead of appearing stuck at Ready.** Retry feedback survives the separate system overlay process, recreated session UI requests the current turn state, and locked sessions keep transcript, response, and technical error text private.
### 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
@@ -13,7 +48,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **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.
- **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.
### Changed
+35
View File
@@ -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
+48
View File
@@ -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
+5
View File
@@ -159,6 +159,11 @@ manual fallbacks when QR or clipboard transfer is unavailable.
</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>
+92 -21
View File
@@ -200,6 +200,11 @@ never create a staging branch. Stable production tags are cut only from the new
10. Build and publish that surface's artifacts, roll out or deploy from the
immutable tag, and verify the release and live environment.
Do not back-merge a normal release. The `main` release merge already has the
released `dev` tip as its integration parent, so merging it back only adds
history noise. The release-backmerge workflow detects this topology and exits
successfully without changing `dev`.
### Branch names
| Prefix | When | Example |
@@ -258,7 +263,9 @@ The intended settings are:
- **`main`** — PRs required; `Required checks` required and current; force push
and deletion blocked. Normal work does not target this branch.
- **`dev`** — PRs and `Required checks` required; force push and deletion
blocked. This is the normal contribution target.
blocked. This is the normal contribution target. The release-backmerge
workflow is the sole exception: its automation identity may compare-and-swap
`dev` to an exact checked merge commit after a stable hotfix release.
- **Merge policy** — merge commits allowed; squash and rebase merges disabled so
the no-ff contract cannot be bypassed in the GitHub UI.
- **Default branch** — `main`, which remains the release-history branch and the
@@ -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
+8 -16
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.13.0
# Hermes-Relay Android v1.13.2
**Release Date:** August 25, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.13.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.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,28 +12,20 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This feature release adds Bot Mode across saved Hermes gateways, provider usage and limits, and bounded Assistant screen context. It also settles stale Gateway composer state, improves onboarding, and keeps idle Sphere motion efficient.
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 Bot Mode as one messenger-style workspace across saved Hermes gateways, with exact gateway/profile ownership and read-only group rooms.
- Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage & limits screen.
- Start a compatible unlocked Assistant invocation with bounded visible text and an available screenshot in the first Standard voice turn.
## Changed
- Follow the Dashboard-first setup path with current screenshots and clearer separation between standard Hermes and optional Relay extensions.
- Use clear `Hermes-Relay Android` and isolated `HR Candidate` product names without changing package identities or update behavior.
- 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
- Settle orphaned Gateway busy state automatically while preserving active or detached turns owned by another session.
- Keep the visible idle Sphere gently animated without running hidden, backgrounded, or motion-disabled loops.
- Retry Windows-hosted `MEDIA:` attachments through the Relay by-path route instead of treating drive-letter paths as expired tokens.
- 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.13.0** (versionCode **49**).
- 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 enhances provider usage, media retry, and device surfaces but remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
+37 -4
View File
@@ -6,6 +6,32 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Certify Android session activity across lifecycle and profile boundaries
The contract fixture now covers every upstream live status, complete-snapshot
disappearance, client-side ownership of duplicate durable ids across profiles,
and older Gateways without `session.active_list`. Before calling the status
model device-certified:
- Exercise working, quiet tool-heavy work, each pending-input surface, normal
completion, Stop, reconnect, app restart, and process recreation against
current vanilla upstream.
- Verify All Profiles with duplicate session ids across two profiles and two
saved connections; no late snapshot or old socket generation may mark the
wrong row live.
- Confirm failed/unsupported refresh becomes Unavailable, restart revalidation
remains Checking, ambiguous or partially
resolved process-wide snapshots infer no absence, a complete empty snapshot
settles every unambiguously owned scope, and REST `is_active=true` never
renders as Working.
- Run a background process that outlives its parent turn and verify Background
work remains separate from the conversation's Idle state.
- Pursue an upstream `session.active_list` profile field/filter or an aggregate
activity route with explicit profile ownership so multi-profile clients do
not need to resolve process-wide rows from durable keys.
---
## Bot Mode follow-ups after multi-gateway aggregation
Android Bot Mode now has an all-gateway roster, typed `(connectionId, profile)`
@@ -730,8 +756,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
@@ -1272,7 +1300,12 @@ and whether the agent is waiting on the user.
permissions; exercise compact, expanded, collapsed, and full-Voice handoff
states, background tap-through, rotation and insets, cancel/back, microphone
denial, network failure, process kill/recreation, and wake→voice→wake
resumption. Measure idle battery drain because third-party assistants do not
resumption. For background and keyguard capture, record `AudioRecord`, AppOps,
and foreground-service state: the user-installed app owns capture outside the
separate session process, so confirm whether the selected Assistant role is
sufficient on each target OS or whether activation needs an explicit,
activation-scoped microphone foreground-service lease. Measure idle battery
drain because third-party assistants do not
receive Google's dedicated low-power hotword hardware.
- **Audio quality guardrails** — normalize output volume across realtime and
@@ -1426,5 +1459,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 &amp; 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.
@@ -1 +1,3 @@
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
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 @@
Bot 模式现在可将已保存 Hermes 网关中的机器人汇集到一个消息式工作区。设置新增统一的 Codex、Nous 和 OpenCode Go 用量视图。兼容的助手启动可在首个语音回合中包含受限的可见文本和可用截图。Gateway 聊天会自动清除过期的忙碌状态,引导更清晰,空闲 Sphere 动画也更省电。
新增监督模式:家长可配置并固定到指定配置文件,设置受设备身份验证保护。家长可限制附件、标准语音、生成媒体、历史记录、操作和技术详情。实时活动不可用时会话行保持中性显示,从家长设置返回时监督聊天也不再空白。
+57
View File
@@ -1,5 +1,62 @@
{
"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",
+8 -5
View File
@@ -1,6 +1,9 @@
v1.13.0 - Bots, usage, and reliable chat
v1.13.2 - Supervised Mode and clearer activity
* Use Bot Mode across saved Hermes gateways without changing the foreground connection.
* Review Codex, Nous, and OpenCode Go usage from one provider-neutral screen.
* Include bounded visible text and an available screenshot in compatible Assistant turns.
* Keep the composer accurate when Gateway completion frames and visible bubbles settle separately.
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.
@@ -39,14 +39,38 @@ enum class AssistantSessionPhase {
Closed,
}
enum class AssistantSessionNotice {
NoSpeech,
}
data class AssistantSessionSnapshot(
val phase: AssistantSessionPhase = AssistantSessionPhase.Launching,
val transcript: String? = null,
val response: String = "",
val notice: AssistantSessionNotice? = null,
val error: String? = null,
val screenContextSupported: Boolean = false,
)
internal fun assistantSnapshotForPresentation(
snapshot: AssistantSessionSnapshot,
locked: Boolean,
): AssistantSessionSnapshot = if (locked) {
snapshot.copy(
transcript = null,
response = "",
error = null,
screenContextSupported = false,
)
} else {
snapshot
}
internal fun assistantSnapshotMatchesActivation(
expectedActivationId: String?,
receivedActivationId: String?,
): Boolean = expectedActivationId != null && expectedActivationId == receivedActivationId
object AssistantRole {
fun status(context: Context): AssistantRoleStatus {
val component = ComponentName(context, HermesVoiceInteractionService::class.java)
@@ -209,6 +233,7 @@ object AssistantSessionProtocol {
onFailure = { failure ->
publish(
application,
activation.id,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = failure.message ?: "Hermes voice could not start",
@@ -219,13 +244,19 @@ object AssistantSessionProtocol {
return true
}
fun publish(context: Context, snapshot: AssistantSessionSnapshot) {
fun publish(
context: Context,
activationId: String,
snapshot: AssistantSessionSnapshot,
) {
context.sendBroadcast(
Intent(context, AssistantSessionStateReceiver::class.java).apply {
action = ACTION_STATUS
putExtra(EXTRA_ACTIVATION_ID, activationId)
putExtra(EXTRA_PHASE, snapshot.phase.name)
putExtra(EXTRA_TRANSCRIPT, snapshot.transcript)
putExtra(EXTRA_RESPONSE, snapshot.response)
putExtra(EXTRA_NOTICE, snapshot.notice?.name)
putExtra(EXTRA_ERROR, snapshot.error)
putExtra(EXTRA_SCREEN_CONTEXT_SUPPORTED, snapshot.screenContextSupported)
}
@@ -238,10 +269,6 @@ object AssistantSessionProtocol {
}
}
fun publish(context: Context, state: VoiceUiState) {
publish(context, snapshotFromVoiceState(state))
}
internal fun snapshotFromVoiceState(state: VoiceUiState): AssistantSessionSnapshot {
val phase = when {
!state.voiceMode -> AssistantSessionPhase.Closed
@@ -256,7 +283,10 @@ object AssistantSessionProtocol {
phase = phase,
transcript = state.transcribedText?.take(MAX_SESSION_TEXT_CHARS),
response = state.responseText.take(MAX_SESSION_TEXT_CHARS),
error = state.error?.take(MAX_SESSION_ERROR_CHARS),
notice = state.assistantNotice,
error = state.error
?.takeIf { phase == AssistantSessionPhase.Error }
?.take(MAX_SESSION_ERROR_CHARS),
)
}
@@ -350,6 +380,9 @@ object AssistantSessionProtocol {
phase = phase,
transcript = intent.getStringExtra(EXTRA_TRANSCRIPT),
response = intent.getStringExtra(EXTRA_RESPONSE).orEmpty(),
notice = intent.getStringExtra(EXTRA_NOTICE)?.let { raw ->
runCatching { AssistantSessionNotice.valueOf(raw) }.getOrNull()
},
error = intent.getStringExtra(EXTRA_ERROR),
screenContextSupported = intent.getBooleanExtra(
EXTRA_SCREEN_CONTEXT_SUPPORTED,
@@ -360,24 +393,33 @@ object AssistantSessionProtocol {
private const val MAX_SESSION_TEXT_CHARS = 4_000
private const val MAX_SESSION_ERROR_CHARS = 1_000
private const val EXTRA_NOTICE = "notice"
}
object AssistantSessionState {
private val _snapshot = MutableStateFlow(AssistantSessionSnapshot())
val snapshot: StateFlow<AssistantSessionSnapshot> = _snapshot.asStateFlow()
@Volatile private var activationId: String? = null
internal fun update(snapshot: AssistantSessionSnapshot) {
internal fun update(receivedActivationId: String?, snapshot: AssistantSessionSnapshot) {
if (!assistantSnapshotMatchesActivation(activationId, receivedActivationId)) return
_snapshot.value = snapshot
}
internal fun reset() {
internal fun reset(activationId: String) {
this.activationId = activationId
_snapshot.value = AssistantSessionSnapshot()
}
}
class AssistantSessionStateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
AssistantSessionState.update(AssistantSessionProtocol.readSnapshot(intent))
AssistantSessionState.update(
receivedActivationId = intent.getStringExtra(
AssistantSessionProtocol.EXTRA_ACTIVATION_ID
),
snapshot = AssistantSessionProtocol.readSnapshot(intent),
)
}
}
@@ -423,6 +465,7 @@ class AssistantSessionLifecycleReceiver : BroadcastReceiver() {
onFailure = { failure ->
AssistantSessionProtocol.publish(
application,
id,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = failure.message ?: "Hermes voice could not start",
@@ -430,6 +473,7 @@ class AssistantSessionLifecycleReceiver : BroadcastReceiver() {
)
},
)
application.runtime.republishAssistantSnapshot(id)
return
}
if (AssistantSessionProtocol.isStartAction(intent.action)) {
@@ -3,6 +3,11 @@ package com.hermesandroid.relay.assistant
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.ColorDrawable
import android.app.KeyguardManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
import android.service.voice.VoiceInteractionSessionService
@@ -67,6 +72,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
@@ -108,6 +114,11 @@ internal fun shouldCancelVoiceWhenSessionUiEnds(
presentation: AssistantSessionPresentation,
): Boolean = presentation == AssistantSessionPresentation.Overlay
internal fun assistantPresentationLocked(
currentKeyguardLocked: Boolean?,
fallbackLocked: Boolean,
): Boolean = currentKeyguardLocked ?: fallbackLocked
private class HermesVoiceInteractionSession(
private val service: HermesVoiceInteractionSessionService,
) : VoiceInteractionSession(service) {
@@ -118,12 +129,19 @@ private class HermesVoiceInteractionSession(
private var surfaceExpanded by mutableStateOf(false)
private var activationId: String? = null
private var manualMic = false
private var keyguardLocked by mutableStateOf(false)
private var expectScreenContext: Boolean? = null
private var pendingSemantic = AssistantSemanticContext()
private var pendingScreenshot: ByteArray? = null
private var screenContextUi by mutableStateOf(AssistantScreenContextUi())
private val contextStore = assistantContextStore(service)
private var heartbeatJob: Job? = null
private var keyguardReceiverRegistered = false
private val keyguardReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
refreshKeyguardState()
}
}
init {
scope.launch {
@@ -139,6 +157,17 @@ private class HermesVoiceInteractionSession(
override fun onCreate() {
super.onCreate()
ContextCompat.registerReceiver(
service,
keyguardReceiver,
IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_USER_PRESENT)
},
ContextCompat.RECEIVER_NOT_EXPORTED,
)
keyguardReceiverRegistered = true
window.window?.apply {
setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
@@ -155,6 +184,7 @@ private class HermesVoiceInteractionSession(
PersistedHermesRelayTheme {
AssistantSessionSurface(
expanded = surfaceExpanded,
locked = keyguardLocked,
screenContext = screenContextUi,
onExpandedChange = { surfaceExpanded = it },
onCancel = { finishSession(cancelVoice = true) },
@@ -183,11 +213,22 @@ private class HermesVoiceInteractionSession(
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
if (args?.getBoolean(HermesVoiceInteractionService.EXTRA_FROM_KEYGUARD, false) == true) {
refreshKeyguardState(
fallbackLocked = args?.getBoolean(
HermesVoiceInteractionService.EXTRA_FROM_KEYGUARD,
false,
) == true,
)
if (keyguardLocked) {
window.window?.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
} else {
window.window?.clearFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
setUiEnabled(true)
val startsNewLifecycle = presentation == AssistantSessionPresentation.Inactive
@@ -195,10 +236,10 @@ private class HermesVoiceInteractionSession(
if (!startsNewLifecycle) return
surfaceExpanded = false
AssistantSessionState.reset()
screenContextUi = AssistantScreenContextUi()
activationId = args?.getString(AssistantSessionProtocol.EXTRA_ACTIVATION_ID)
?: UUID.randomUUID().toString()
AssistantSessionState.reset(activationId!!)
manualMic = args?.getBoolean(AssistantSessionProtocol.EXTRA_MANUAL_MIC, false) ?: false
expectScreenContext = args?.getBoolean(
AssistantSessionProtocol.EXTRA_EXPECT_SCREEN_CONTEXT,
@@ -300,6 +341,10 @@ private class HermesVoiceInteractionSession(
pendingSemantic = AssistantSemanticContext()
pendingScreenshot = null
screenContextUi = AssistantScreenContextUi()
if (keyguardReceiverRegistered) {
runCatching { service.unregisterReceiver(keyguardReceiver) }
keyguardReceiverRegistered = false
}
viewOwner.stop()
scope.cancel()
super.onDestroy()
@@ -319,6 +364,7 @@ private class HermesVoiceInteractionSession(
)
}.onFailure {
AssistantSessionState.update(
activationId,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = it.message ?: "Hermes could not open the voice session.",
@@ -337,6 +383,7 @@ private class HermesVoiceInteractionSession(
setUiEnabled(false)
}.onFailure {
AssistantSessionState.update(
activationId,
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
error = it.message ?: "Hermes could not open full voice.",
@@ -377,6 +424,14 @@ private class HermesVoiceInteractionSession(
}
}
private fun refreshKeyguardState(fallbackLocked: Boolean = keyguardLocked) {
keyguardLocked = assistantPresentationLocked(
currentKeyguardLocked = service.getSystemService(KeyguardManager::class.java)
?.isKeyguardLocked,
fallbackLocked = fallbackLocked,
)
}
@RequiresApi(android.os.Build.VERSION_CODES.Q)
private fun stageAssistState(state: AssistState) {
stageAssistData(state.assistStructure, state.assistContent)
@@ -463,6 +518,7 @@ private class AssistantSessionViewOwner :
@Composable
private fun AssistantSessionSurface(
expanded: Boolean,
locked: Boolean,
screenContext: AssistantScreenContextUi,
onExpandedChange: (Boolean) -> Unit,
onCancel: () -> Unit,
@@ -471,7 +527,8 @@ private fun AssistantSessionSurface(
onOpenFullVoice: () -> Unit,
onSurfaceBoundsChanged: (android.graphics.Rect) -> Unit,
) {
val snapshot by AssistantSessionState.snapshot.collectAsState()
val rawSnapshot by AssistantSessionState.snapshot.collectAsState()
val snapshot = assistantSnapshotForPresentation(rawSnapshot, locked)
val status = assistantStatus(snapshot.phase)
val transmittedScreenContext = if (snapshot.screenContextSupported) {
screenContext
@@ -650,6 +707,13 @@ private fun ExpandedAssistantSurface(
color = MaterialTheme.colorScheme.onSurface,
)
}
snapshot.notice?.let { notice ->
Text(
text = assistantNoticeText(notice),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
snapshot.error?.let { error ->
Text(
text = error,
@@ -896,5 +960,11 @@ private fun assistantStatus(phase: AssistantSessionPhase): String = when (phase)
private fun compactAssistantText(snapshot: AssistantSessionSnapshot): String =
snapshot.transcript?.takeIf { it.isNotBlank() }
?: snapshot.response.takeIf { it.isNotBlank() }
?: snapshot.notice?.let { assistantNoticeText(it) }
?: snapshot.error?.takeIf { it.isNotBlank() }
?: assistantStatus(snapshot.phase)
@Composable
private fun assistantNoticeText(notice: AssistantSessionNotice): String = when (notice) {
AssistantSessionNotice.NoSpeech -> stringResource(R.string.voice_no_speech_try_again)
}
@@ -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,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,
}
@@ -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,
@@ -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
@@ -806,6 +813,21 @@ class GatewayChatClient(
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
@@ -2065,6 +2087,47 @@ class GatewayChatClient(
)
}
/**
* Fetch authoritative in-memory execution states from current upstream
* Hermes. `session.active_list` is process-wide: it accepts only an optional
* current runtime id and does not profile-filter its rows. Accordingly this
* transport returns rows unscoped and never derives activity from REST
* `is_active` or stamps the selected profile onto a row.
*/
suspend fun listActiveSessions(): GatewayActiveSessionsResult {
if (_activeSessionCapability.value == GatewayActiveSessionCapability.Unsupported) {
return GatewayActiveSessionsResult.Unsupported
}
try {
connectMutex.withLock { ensureConnected() }
} catch (error: Exception) {
return GatewayActiveSessionsResult.TransientFailure(error)
}
val result = rpc(
"session.active_list",
buildJsonObject {
liveSessionId?.let { put("current_session_id", it) }
},
)
val error = result.exceptionOrNull()
if (error.isMethodNotFound()) {
_activeSessionCapability.value = GatewayActiveSessionCapability.Unsupported
return GatewayActiveSessionsResult.Unsupported
}
if (error != null) {
return GatewayActiveSessionsResult.TransientFailure(error)
}
_activeSessionCapability.value = GatewayActiveSessionCapability.Supported
return try {
val payload = result.getOrThrow()
val rows = payload["sessions"] as? JsonArray
?: throw GatewayRpcException("session.active_list returned no sessions array")
GatewayActiveSessionsResult.Success(rows.map(::parseGatewayActiveSession))
} catch (parseError: Exception) {
GatewayActiveSessionsResult.TransientFailure(parseError)
}
}
/** Stop one process owned by the current live gateway session. */
suspend fun killProcess(processId: String): Result<Unit> {
if (processId.isBlank()) {
@@ -2503,6 +2566,7 @@ class GatewayChatClient(
private suspend fun connectOnce() {
val connectStart = System.nanoTime()
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.MintingTicket
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
@@ -2749,6 +2813,28 @@ class GatewayChatClient(
)
}
private fun parseGatewayActiveSession(
element: kotlinx.serialization.json.JsonElement,
): GatewayActiveSession {
val row = element as? JsonObject
?: throw GatewayRpcException("session.active_list returned a non-object row")
val runtimeId = row.stringField("id")?.takeIf(String::isNotBlank)
?: throw GatewayRpcException("session.active_list row returned no runtime id")
val storedId = row.stringField("session_key")?.takeIf(String::isNotBlank)
?: throw GatewayRpcException("session.active_list row returned no session key")
val status = GatewayActiveSessionStatus.fromWire(row.stringField("status"))
?: throw GatewayRpcException("session.active_list row returned an unknown status")
val lastActive = (row["last_active"] as? JsonPrimitive)?.doubleOrNull
?: throw GatewayRpcException("session.active_list row returned no last_active")
return GatewayActiveSession(
runtimeSessionId = runtimeId,
storedSessionId = storedId,
status = status,
lastActiveEpochSeconds = lastActive,
profile = row.stringField("profile")?.trim()?.takeIf(String::isNotEmpty),
)
}
private fun markProcessUnsupportedIfNeeded(error: Throwable?) {
if (error.isMethodNotFound()) {
_processCapability.value = GatewayProcessCapability.Unsupported
@@ -3222,6 +3308,7 @@ class GatewayChatClient(
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
pendingRpcs.values.forEach {
@@ -3407,6 +3494,7 @@ class GatewayChatClient(
attachMethodForSocket = null
commandsCatalogCache = null
_processCapability.value = GatewayProcessCapability.Unknown
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
_connectionState.value = GatewayConnectionState.Idle
}
@@ -320,6 +320,69 @@ enum class GatewayProcessCapability {
Unsupported,
}
/** Authoritative execution state reported by upstream `session.active_list`. */
enum class GatewayActiveSessionStatus(val wireValue: String) {
Idle("idle"),
Starting("starting"),
Working("working"),
Waiting("waiting");
companion object {
fun fromWire(value: String?): GatewayActiveSessionStatus? = when (value?.trim()?.lowercase()) {
"idle" -> Idle
"starting" -> Starting
"working" -> Working
"waiting" -> Waiting
else -> null
}
}
}
/**
* One in-memory runtime returned by upstream `session.active_list`.
*
* The RPC is process-wide in current upstream Hermes. Its rows do not normally
* identify their profile, so [profile] stays null unless a future gateway
* explicitly sends one. Callers must resolve [storedSessionId] against their
* own profile-scoped session registry and fail closed when ownership is
* ambiguous; the transport never synthesizes profile attribution.
*/
data class GatewayActiveSession(
/** Per-process runtime id used by live Gateway events and session RPCs. */
val runtimeSessionId: String,
/** Durable history id (`session_key`) used by the REST/session database. */
val storedSessionId: String,
val status: GatewayActiveSessionStatus,
/** Unix epoch seconds from upstream's in-memory runtime record. */
val lastActiveEpochSeconds: Double,
/** Future-compatible only; null for the current upstream contract. */
val profile: String? = null,
)
/** Exact owner already known by this client for a foreground or detached runtime. */
data class GatewayKnownSessionOwner(
val storedSessionId: String,
val profile: String?,
)
/** Whether the current Gateway socket exposes `session.active_list`. */
enum class GatewayActiveSessionCapability {
Unknown,
Supported,
Unsupported,
}
/**
* Result of one process-wide live-session snapshot request. Unsupported is
* intentionally distinct from transport/protocol failure so callers can use
* another source only for older gateways, while failures remain Unknown.
*/
sealed interface GatewayActiveSessionsResult {
data class Success(val sessions: List<GatewayActiveSession>) : GatewayActiveSessionsResult
data object Unsupported : GatewayActiveSessionsResult
data class TransientFailure(val error: Throwable) : GatewayActiveSessionsResult
}
/**
* Connection-level background-process events. These are deliberately separate
* from [GatewayTurnCallbacks]: output and completion notifications can arrive
@@ -250,6 +250,7 @@ data class SessionItem(
@SerialName("output_tokens") val outputTokens: Int? = null,
@SerialName("actual_cost_usd") val actualCostUsd: Double? = null,
@SerialName("estimated_cost_usd") val estimatedCostUsd: Double? = null,
/** REST recency heuristic from upstream; not live Gateway execution state. */
@SerialName("is_active") val isActive: Boolean = false,
@SerialName("has_model_config")
@Serializable(with = FlexibleBooleanSerializer::class)
@@ -240,6 +240,25 @@ class HermesProcessRuntime internal constructor(
}
}
fun republishAssistantSnapshot(activationId: String) {
val snapshot = synchronized(activationLock) {
if (currentActivationId != activationId ||
_initializationState.value != HermesRuntimeInitializationState.Ready
) {
null
} else {
binder.assistantSnapshot.value
}
} ?: return
if (snapshot.phase != com.hermesandroid.relay.assistant.AssistantSessionPhase.Closed) {
com.hermesandroid.relay.assistant.AssistantSessionProtocol.publish(
application,
activationId,
snapshot,
)
}
}
fun recordAssistantHeartbeat(
activationId: String,
nowElapsedMs: Long = SystemClock.elapsedRealtime(),
@@ -376,7 +376,9 @@ internal class HermesRuntimeBinder(
if (!AssistantAppSessionState.active.value) return@collect
if (state.voiceMode) AssistantAppSessionState.markVoiceStarted()
if (state.voiceMode || AssistantAppSessionState.hasVoiceStarted()) {
AssistantSessionProtocol.publish(application, snapshot)
state.assistantActivationId?.let { activationId ->
AssistantSessionProtocol.publish(application, activationId, snapshot)
}
}
}
}
@@ -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
@@ -163,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
@@ -178,6 +184,9 @@ 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
@@ -197,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
@@ -445,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",
@@ -631,6 +643,7 @@ 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()
@@ -849,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,
@@ -863,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)
@@ -1165,12 +1269,12 @@ fun RelayApp() {
parentAccessForCurrentRoute,
currentRoute,
) {
if (shouldRedirectSupervisedRoute(
supervisedEnabled = supervisedPolicy.enabled,
parentAccessUnlocked = parentAccessForCurrentRoute,
currentRoute = 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
@@ -1179,6 +1283,12 @@ fun RelayApp() {
}
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
}
}
@@ -2255,6 +2365,11 @@ fun RelayApp() {
onNavigateToBotMode = {
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
},
gitWorkspaceAvailable = gitWorkspaceAvailable,
gitWorkspaceSummary = gitWorkspaceSummary,
onNavigateToGitWorkspace = {
navController.navigate(Screen.GitState.route) { launchSingleTop = true }
},
)
}
composable(Screen.BotMode.route) {
@@ -2573,7 +2688,7 @@ fun RelayApp() {
connectionViewModel = connectionViewModel,
chatViewModel = chatViewModel,
supervisedPolicy = supervisedPolicy,
parentAccessUnlocked = parentAccessUnlocked,
parentAccessUnlocked = parentAccessForCurrentRoute,
onRequestParentAccess = { parentAccessUnlocked = true },
onUpdateSupervisedPolicy = { policy ->
activeConnectionId?.let { connectionId ->
@@ -2588,6 +2703,9 @@ fun RelayApp() {
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
@@ -2607,6 +2725,9 @@ fun RelayApp() {
onNavigateToPlugins = {
navController.navigate(Screen.Plugins.route)
},
onNavigateToGitWorkspace = {
navController.navigate(Screen.GitState.route)
},
onNavigateToChatSettings = {
navController.navigate(Screen.ChatSettings.route)
},
@@ -2663,7 +2784,7 @@ fun RelayApp() {
)
}
composable(Screen.AdvancedSettings.route) {
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
AdvancedSettingsScreen(
@@ -2676,7 +2797,7 @@ fun RelayApp() {
}
}
composable(Screen.SupervisedAppearanceSettings.route) {
if (!supervisedPolicy.enabled && !parentAccessUnlocked) {
if (!supervisedPolicy.enabled && !parentAccessForCurrentRoute) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
SupervisedAppearanceSettingsScreen(
@@ -2694,7 +2815,7 @@ fun RelayApp() {
}
}
composable(Screen.SupervisedControls.route) {
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
LaunchedEffect(Unit) { navController.popBackStack() }
} else {
SupervisedControlsScreen(
@@ -2730,10 +2851,20 @@ fun RelayApp() {
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(
@@ -3196,7 +3327,7 @@ fun RelayApp() {
AboutScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessUnlocked,
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessForCurrentRoute,
)
}
composable(
@@ -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),
)
}
}
}
@@ -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),
)
}
}
}
@@ -14,7 +14,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
@@ -26,8 +25,6 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.LocalBrand
import kotlinx.coroutines.delay
import kotlin.math.sin
/**
* ASCII morphing sphere — the visual embodiment of the AI agent.
@@ -56,19 +53,13 @@ import kotlin.math.sin
private const val SPHERE_TIME_UNITS_PER_SEC = 1f
private const val SPHERE_TWO_PI = 6.2832f
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
private const val SPHERE_IDLE_BREATH_RADIANS_PER_SEC = 0.72f
private const val SPHERE_IDLE_BREATH_SCALE = 0.012f
private const val SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS = 184L
internal enum class SphereMotionMode {
Still,
AmbientLayer,
Procedural,
}
internal fun sphereMotionMode(
state: SphereState,
voiceMode: Boolean,
motionVisible: Boolean,
fixedTime: Float?,
fixedColorPhase: Float?,
@@ -76,11 +67,7 @@ internal fun sphereMotionMode(
if (!motionVisible || fixedTime != null || fixedColorPhase != null) {
return SphereMotionMode.Still
}
return if (state == SphereState.Idle && !voiceMode) {
SphereMotionMode.AmbientLayer
} else {
SphereMotionMode.Procedural
}
return SphereMotionMode.Procedural
}
@Composable
@@ -134,15 +121,12 @@ fun MorphingSphere(
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
// Active states retain the full procedural animation. Visible Idle uses a
// lightweight graphics-layer breath: redrawing the 58x34 glyph grid just
// for ambient drift was the measured screen-on hotspot, while transforming
// its cached layer preserves the intended living Sphere at far lower cost.
// 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 motionMode = sphereMotionMode(
state = state,
voiceMode = effVoiceMode,
motionVisible = motionVisible,
fixedTime = fixedTime,
fixedColorPhase = fixedColorPhase,
@@ -162,25 +146,6 @@ fun MorphingSphere(
}
}
}
val idleBreathPhase = remember { mutableFloatStateOf(0f) }
LaunchedEffect(motionMode) {
if (motionMode != SphereMotionMode.AmbientLayer) {
idleBreathPhase.floatValue = 0f
return@LaunchedEffect
}
var lastNanos = withFrameNanos { it }
while (true) {
val now = withFrameNanos { it }
val dtSec = (now - lastNanos).coerceAtLeast(0L) / 1_000_000_000f
lastNanos = now
idleBreathPhase.floatValue =
(idleBreathPhase.floatValue + dtSec * SPHERE_IDLE_BREATH_RADIANS_PER_SEC) %
SPHERE_TWO_PI
// The frame wait plus this delay caps the gentle layer-only pulse
// near 5fps while active procedural states retain display-rate motion.
delay(SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS)
}
}
val time = fixedTime ?: animatedTime.floatValue
val colorPhase = fixedColorPhase ?: animatedColorPhase.floatValue
@@ -192,18 +157,7 @@ fun MorphingSphere(
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
val glyphStrings = remember { HashMap<Char, String>(32) }
Canvas(
modifier = modifier
.fillMaxSize()
.graphicsLayer {
if (motionMode == SphereMotionMode.AmbientLayer) {
val scale = 1f + sin(idleBreathPhase.floatValue) * SPHERE_IDLE_BREATH_SCALE
scaleX = scale
scaleY = scale
}
}
.clipToBounds(),
) {
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
val canvasW = size.width
val canvasH = size.height
val cellW = canvasW / cols
@@ -5,6 +5,7 @@ import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.SweepGradient
import androidx.annotation.StringRes
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
@@ -81,6 +82,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -117,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,
@@ -280,6 +285,11 @@ 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 = supervisedSessionActions == null &&
(threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) })
@@ -324,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 {
@@ -738,6 +753,7 @@ fun SessionDrawerContent(
ProjectGroupHeader(
label = label,
rows = group.rows,
nowMillis = drawerNowMillis,
expanded = expanded,
onToggle = {
expandedProjectGroups = if (expanded) {
@@ -760,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,
@@ -774,6 +788,7 @@ fun SessionDrawerContent(
showUpdated = viewOptions.showUpdated,
showTokens = viewOptions.showTokens,
showCost = viewOptions.showCost,
nowMillis = drawerNowMillis,
actionsEnabled = !provisional && (
supervisedSessionActions == null ||
supervisedSessionActions.pin ||
@@ -1234,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"
}
@@ -1259,6 +1278,7 @@ private fun compactMetric(value: Double): String = when {
private fun ProjectGroupHeader(
label: String,
rows: List<ProfileSessionRow>,
nowMillis: Long,
expanded: Boolean,
onToggle: () -> Unit,
) {
@@ -1307,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,
@@ -1336,6 +1356,7 @@ private fun SessionItem(
showUpdated: Boolean,
showTokens: Boolean,
showCost: Boolean,
nowMillis: Long,
actionsEnabled: Boolean,
isActive: Boolean,
activityState: SessionActivityState?,
@@ -1355,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
@@ -1459,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,
@@ -1601,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) {
@@ -1705,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,
@@ -1732,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(
@@ -1843,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"
@@ -1864,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,
)
}
}
}
@@ -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
}
@@ -37,10 +37,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
@@ -193,13 +191,15 @@ import com.hermesandroid.relay.ui.components.BackgroundTaskCard
import com.hermesandroid.relay.ui.components.LocalRelayServerImageResolver
import com.hermesandroid.relay.ui.components.RelayServerImageResolver
import com.hermesandroid.relay.ui.components.ChatInputBar
import com.hermesandroid.relay.ui.components.ChatGitContextButton
import com.hermesandroid.relay.ui.components.ChatGitWorkspaceRail
import com.hermesandroid.relay.ui.components.ChatGitWorkspaceSummary
import com.hermesandroid.relay.ui.components.ChatFailureDetailsDialog
import com.hermesandroid.relay.ui.components.ChatFailurePanel
import com.hermesandroid.relay.viewmodel.ChatFailureRoute
import com.hermesandroid.relay.viewmodel.ChatFailureNotice
import com.hermesandroid.relay.viewmodel.scopedChatFailure
import com.hermesandroid.relay.ui.components.ConversationVoiceDock
import com.hermesandroid.relay.ui.components.CleanChatMode
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
import com.hermesandroid.relay.ui.components.ChatInputPickerOption
import com.hermesandroid.relay.ui.components.ChatInputTrailing
@@ -303,21 +303,6 @@ private const val CHAT_AUTOCOMPLETE_PET_OBSTACLE = "chat-autocomplete-obstacle"
private const val CHAT_RECENT_PROMPTS_PET_OBSTACLE = "chat-recent-prompts-obstacle"
private val CHAT_PET_ROUTES = setOf("chat")
internal fun resolveSessionActivityStates(
background: Map<String, SessionActivityState>,
currentSessionId: String?,
isStreaming: Boolean,
needsInput: Boolean,
): Map<String, SessionActivityState> = background.toMutableMap().apply {
currentSessionId?.let { sessionId ->
when {
needsInput -> put(sessionId, SessionActivityState.NeedsInput)
isStreaming -> put(sessionId, SessionActivityState.Working)
else -> remove(sessionId)
}
}
}
internal fun resolveChatHeaderSubtitle(
isStreaming: Boolean,
statusText: String,
@@ -734,6 +719,9 @@ fun ChatScreen(
onNavigateToProfileInspector: (String) -> Unit = {},
supervisedPolicy: SupervisedModePolicy = SupervisedModePolicy(),
onNavigateToBotMode: () -> Unit = {},
gitWorkspaceSummary: ChatGitWorkspaceSummary? = null,
gitWorkspaceAvailable: Boolean = gitWorkspaceSummary != null,
onNavigateToGitWorkspace: () -> Unit = {},
) {
val supervised = supervisedPolicy.enabled
val supervisedVisibility = supervisedPolicy.visibility.resolved()
@@ -930,15 +918,9 @@ fun ChatScreen(
mutableStateOf(false)
}
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
val sessionActivityStates = remember(
backgroundSessionActivityStates,
currentSessionId,
isStreaming,
pendingAsk,
) {
resolveSessionActivityStates(
background = backgroundSessionActivityStates,
currentSessionId = currentSessionId,
val sessionActivityStates = backgroundSessionActivityStates
LaunchedEffect(currentSessionId, isStreaming, pendingAsk) {
chatViewModel.updateCurrentSessionActivity(
isStreaming = isStreaming,
needsInput = pendingAsk != null,
)
@@ -966,6 +948,54 @@ fun ChatScreen(
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
var allProfileSessionsLoading by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
suspend fun refreshAllProfileSessions(showError: Boolean) {
if (isProfileLocked || allProfileSessionsLoading) return
allProfileSessionsLoading = true
val result = connectionViewModel.listAllProfileSessions()
result?.fold(
onSuccess = { items ->
allProfileSessions = items.mapNotNull { item ->
val owner = item.profile?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
ProfileSessionRow(
profile = owner,
session = com.hermesandroid.relay.data.ChatSession(
sessionId = item.id,
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
recentlyActive = item.isActive,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
pinned = item.pinned,
archived = item.archived,
workingDirectory = item.cwd,
gitBranch = item.gitBranch,
gitRepoRoot = item.gitRepoRoot,
pullRequestNumber = item.pullRequest?.number,
pullRequestUrl = item.pullRequest?.url,
pullRequestState = item.pullRequest?.state,
pullRequestDraft = item.pullRequest?.draft == true,
),
)
}
chatViewModel.updateSessionActivityDirectory(
rows = allProfileSessions.map { it.profile to it.session.sessionId },
)
},
onFailure = { error ->
if (showError) snackbarHostState.showSnackbar(
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
)
},
)
allProfileSessionsLoading = false
}
val conversationBinding by chatViewModel.conversationBinding.collectAsState()
val explicitBindingProfileName = conversationBinding.profileName
.takeIf { conversationBinding.hasExplicitOwner }
@@ -1040,6 +1070,13 @@ fun ChatScreen(
connectionViewModel.physicalKeyboardEnterBehavior.collectAsState()
val convertLargePastesToAttachments by
connectionViewModel.convertLargePastesToAttachments.collectAsState()
val showGitWorkspaceInChat by
connectionViewModel.showGitWorkspaceInChat.collectAsState()
val visibleGitWorkspaceSummary = gitWorkspaceSummary?.takeIf {
!supervised && showGitWorkspaceInChat && it.branch.isNotBlank()
}
val showGitWorkspaceContextEntry =
!supervised && showGitWorkspaceInChat && gitWorkspaceAvailable
val availableSkills by chatViewModel.availableSkills.collectAsState()
val queuedMessages by chatViewModel.queuedMessages.collectAsState()
@@ -1147,12 +1184,6 @@ fun ChatScreen(
}
}
}
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
// empty / new-chat view (no messages) — it teaches the long-press entry
// without nagging mid-conversation. Derived directly from the message list
// at the pill below; no one-shot timer, no auto-dismiss.
// Sphere state with debounced Thinking→Streaming (min 1.5s in Thinking)
val rawSphereState by remember {
derivedStateOf {
@@ -1378,10 +1409,16 @@ fun ChatScreen(
val listState = rememberLazyListState()
val userScrolledAwayState = remember(currentSessionId) { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
LaunchedEffect(chatViewModel, drawerState) {
chatViewModel.sessionDirectoryRefreshRequests.collect {
if (drawerState.isOpen || allProfileSessions.isNotEmpty()) {
refreshAllProfileSessions(showError = false)
}
}
}
PetInteractionLayer(
owner = "chat-interaction-layer",
active = shouldHideChatPet(
ambientMode = ambientMode,
voiceMode = voiceUiState.voiceMode,
drawerOpenOrMoving =
drawerState.currentValue != DrawerValue.Closed ||
@@ -1465,7 +1502,6 @@ fun ChatScreen(
}
val clipboard = LocalClipboard.current
val haptic = LocalHapticFeedback.current
val snackbarHostState = remember { SnackbarHostState() }
val handleCardAction: (String, String, HermesCardAction) -> Unit =
remember(chatViewModel, context) {
{ messageId, cardKey, action ->
@@ -2094,6 +2130,7 @@ fun ChatScreen(
// shows up without a manual reload. Cheap dashboard read; the optimistic
// row for the active session is preserved by ChatHandler.updateSessions.
LaunchedEffect(drawerState.isOpen) {
chatViewModel.setSessionActivityDrawerOpen(drawerState.isOpen)
if (drawerState.isOpen && chatReady) {
chatViewModel.refreshSessions()
}
@@ -2524,48 +2561,7 @@ fun ChatScreen(
onProfileColorChange = connectionViewModel::setProfileColor,
onRefreshAllProfiles = {
if (!isProfileLocked && !allProfileSessionsLoading) scope.launch {
allProfileSessionsLoading = true
val result = connectionViewModel.listAllProfileSessions()
result?.fold(
onSuccess = { items ->
allProfileSessions = items.mapNotNull { item ->
val owner = item.profile?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
ProfileSessionRow(
profile = owner,
session = com.hermesandroid.relay.data.ChatSession(
sessionId = item.id,
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
pinned = item.pinned,
archived = item.archived,
workingDirectory = item.cwd,
gitBranch = item.gitBranch,
gitRepoRoot = item.gitRepoRoot,
pullRequestNumber = item.pullRequest?.number,
pullRequestUrl = item.pullRequest?.url,
pullRequestState = item.pullRequest?.state,
pullRequestDraft = item.pullRequest?.draft == true,
),
)
}
},
onFailure = { error ->
snackbarHostState.showSnackbar(
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
)
},
)
allProfileSessionsLoading = false
refreshAllProfileSessions(showError = true)
}
},
onSelectProfileSession = { profileName, sessionId ->
@@ -3003,6 +2999,12 @@ fun ChatScreen(
// info. Dropping it here declutters the actions row and frees
// width for the title subtitle.)
if (!supervised) {
if (showGitWorkspaceContextEntry) {
ChatGitContextButton(
onClick = onNavigateToGitWorkspace,
modifier = Modifier.padding(end = 4.dp),
)
}
RelayChromeIconButton(
icon = Icons.Filled.Code,
contentDescription = stringResource(R.string.cd_terminal),
@@ -3108,11 +3110,6 @@ fun ChatScreen(
}
}
}
// Clean text-flow mode has no top-bar toggle — it's a quiet
// gesture: long-press the conversation background to enter.
// Exit is the in-mode dismiss control or system back (not
// any-tap, since the mode carries its own composer). A
// first-run hint teaches the entry.
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
@@ -3191,11 +3188,6 @@ fun ChatScreen(
}
}
// Clean text-flow mode is rendered as a full-screen overlay (a
// sibling of this Column, below) so it can own its own minimal
// composer and explicit-dismiss control. The Column always renders
// the normal chat underneath; the overlay covers it when active.
// Message list or empty state
if (messages.isEmpty() && !isStreaming && (!isLoadingHistory || isChatConnecting)) {
AnimatedContent(
@@ -3432,28 +3424,12 @@ fun ChatScreen(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
// Quiet entry to clean mode: long-press the
// conversation background. Bubbles keep their own
// long-press (copy) — they consume the gesture first,
// so only presses on empty space land here. Enters
// regardless of animationEnabled — clean mode degrades
// to static text when motion is suppressed, so the
// conversation is never gated on animation.
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
ambientMode = true
},
)
}
) {
// Ambient avatar behind messages
if (
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity) &&
animationBehindChat &&
!ambientMode
animationBehindChat
) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
@@ -4464,6 +4440,14 @@ fun ChatScreen(
}
}
visibleGitWorkspaceSummary?.let { summary ->
ChatGitWorkspaceRail(
summary = summary,
onClick = onNavigateToGitWorkspace,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
ChatInputBar(
value = inputText,
onValueChange = { inputText = it },
@@ -4790,62 +4774,6 @@ fun ChatScreen(
}
}
// Clean-mode discoverability hint — a quiet, persistent pill teaching
// the long-press entry. Shown ONLY on the empty / new-chat view; it
// yields the bottom area whenever clean or voice mode owns it.
AnimatedVisibility(
visible = shouldShowCleanViewHint(
hasMessages = messages.isNotEmpty(),
ambientMode = ambientMode,
voiceMode = voiceUiState.voiceMode,
),
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 96.dp),
) {
Text(
text = stringResource(R.string.chat_clean_view_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f))
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
// Clean text-flow mode — full-screen overlay covering the whole Box.
// Owns its own minimal composer + explicit dismiss; exit is never
// any-tap. Renders static text when motion is suppressed.
AnimatedVisibility(
visible = ambientMode,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
CleanChatMode(
messages = messages,
isStreaming = isStreaming,
sphereState = sphereState,
streamingIntensity = streamingIntensity,
toolCallBurst = toolCallBurst,
animationEnabled = animationEnabled,
enabled = chatReady,
onSend = { text ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
userScrolledAwayState.value = reduceUserScrolledAway(
current = userScrolledAwayState.value,
event = ChatFollowEvent.UserSend,
)
chatViewModel.sendMessage(text)
},
onExit = { ambientMode = false },
)
}
// Voice mode overlay — covers the whole Box when voiceUiState.voiceMode
AnimatedVisibility(
visible = voiceUiState.voiceMode,
@@ -5554,15 +5482,8 @@ private fun createCameraCaptureUri(context: android.content.Context): Uri {
)
}
internal fun shouldShowCleanViewHint(
hasMessages: Boolean,
ambientMode: Boolean,
voiceMode: Boolean,
): Boolean = !hasMessages && !ambientMode && !voiceMode
/** Chat overlays that temporarily own input and must not compete with the root pet host. */
internal fun shouldHideChatPet(
ambientMode: Boolean = false,
voiceMode: Boolean = false,
drawerOpenOrMoving: Boolean = false,
commandPaletteVisible: Boolean = false,
@@ -5571,8 +5492,7 @@ internal fun shouldHideChatPet(
contextSheetVisible: Boolean = false,
backgroundProcessesVisible: Boolean = false,
agentInfoVisible: Boolean = false,
): Boolean = ambientMode ||
voiceMode ||
): Boolean = voiceMode ||
drawerOpenOrMoving ||
commandPaletteVisible ||
modelSheetVisible ||
@@ -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(
@@ -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
@@ -165,6 +166,7 @@ fun SettingsScreen(
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
@@ -189,6 +191,7 @@ fun SettingsScreen(
onNavigateToManage: () -> Unit,
onNavigateToProviderUsage: () -> Unit,
onNavigateToPlugins: () -> Unit,
onNavigateToGitWorkspace: () -> Unit,
onNavigateToChatSettings: () -> Unit,
onNavigateToTerminal: () -> Unit,
onNavigateToBridge: () -> Unit,
@@ -455,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).
@@ -588,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),
@@ -388,6 +388,16 @@ fun SupervisedControlsScreen(
)
}
if (policy.enabled) {
SupervisedNavigationRow(
icon = Icons.Filled.Lock,
title = "Return to supervised view",
subtitle = "Lock parent access and open the pinned agent chat",
onClick = onReturnToSupervisedView,
isDarkTheme = isDarkTheme,
)
}
Text(
"This mode restricts this Android client. The selected Hermes profile remains responsible for agent tools and content policy.",
style = MaterialTheme.typography.bodySmall,
@@ -746,15 +756,6 @@ fun SupervisedControlsScreen(
}
}
if (policy.enabled) {
TextButton(
onClick = onReturnToSupervisedView,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Lock, contentDescription = null)
Text("Return to supervised view", modifier = Modifier.padding(start = 8.dp))
}
}
Spacer(Modifier.height(16.dp))
}
}
@@ -41,6 +41,14 @@ import com.hermesandroid.relay.data.ProactiveInboxEntry
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import com.hermesandroid.relay.data.RealtimeTurnTrace
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SessionActivityFreshness
import com.hermesandroid.relay.data.SessionActivityOwner
import com.hermesandroid.relay.data.SessionActivityPhase
import com.hermesandroid.relay.data.SessionActivityRegistry
import com.hermesandroid.relay.data.SessionActivityScope
import com.hermesandroid.relay.data.SessionActivityUpdate
import com.hermesandroid.relay.data.SessionLiveRuntime
import com.hermesandroid.relay.data.SessionLiveStatus
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
import com.hermesandroid.relay.data.SupervisedModePolicy
import com.hermesandroid.relay.data.SupervisedSessionAction
@@ -61,6 +69,9 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
import com.hermesandroid.relay.network.upstream.GatewayAsk
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionsResult
import com.hermesandroid.relay.network.upstream.GatewayApprovalMode
import com.hermesandroid.relay.network.upstream.GatewayApprovalModeCapability
import com.hermesandroid.relay.network.upstream.GatewayBackgroundInteractionEvent
@@ -261,6 +272,57 @@ internal fun shouldSuppressPassiveSessionError(context: String?, error: Throwabl
"unauthorized" in message || "forbidden" in message
}
internal data class ResolvedGatewayActiveSessions(
val runtimes: List<SessionLiveRuntime>,
val ambiguous: Boolean,
val ambiguousForCurrent: Boolean,
)
/** Resolve process-wide runtime rows without ever inventing a profile owner. */
internal fun resolveGatewayActiveSessions(
sessions: List<GatewayActiveSession>,
directory: Set<SessionActivityOwner>,
currentOwner: SessionActivityOwner?,
currentRuntimeId: String? = null,
knownOwnersByRuntime: Map<String, SessionActivityOwner> = emptyMap(),
): ResolvedGatewayActiveSessions {
var ambiguous = false
var ambiguousForCurrent = false
val runtimes = sessions.map { row ->
val explicitProfile = row.profile?.trim()?.takeIf(String::isNotEmpty)
?.let(AgentDisplay::profileSessionKey)
val candidates = directory.filter { owner ->
owner.storedSessionId == row.storedSessionId &&
(explicitProfile == null || owner.profile.equals(explicitProfile, ignoreCase = true))
}
val owner = when {
knownOwnersByRuntime[row.runtimeSessionId]
?.takeIf { it.storedSessionId == row.storedSessionId } != null ->
knownOwnersByRuntime.getValue(row.runtimeSessionId)
explicitProfile == null && currentOwner != null && currentRuntimeId != null &&
currentRuntimeId == row.runtimeSessionId &&
currentOwner.storedSessionId == row.storedSessionId -> currentOwner
explicitProfile != null && candidates.size == 1 -> candidates.single()
else -> null
}
if (owner == null) {
ambiguous = true
if (currentOwner?.storedSessionId == row.storedSessionId) ambiguousForCurrent = true
}
SessionLiveRuntime(
owner = owner,
runtimeId = row.runtimeSessionId,
status = when (row.status) {
GatewayActiveSessionStatus.Idle -> SessionLiveStatus.Idle
GatewayActiveSessionStatus.Starting -> SessionLiveStatus.Starting
GatewayActiveSessionStatus.Working -> SessionLiveStatus.Working
GatewayActiveSessionStatus.Waiting -> SessionLiveStatus.Waiting
},
)
}
return ResolvedGatewayActiveSessions(runtimes, ambiguous, ambiguousForCurrent)
}
sealed interface VoiceMessageSubmissionResult {
data class Submitted(val userUiKey: String) : VoiceMessageSubmissionResult
data class Rejected(val reason: String) : VoiceMessageSubmissionResult
@@ -342,27 +404,126 @@ class ChatViewModel : ViewModel() {
val backgroundSessionActivityStates: StateFlow<Map<String, SessionActivityState>> =
_backgroundSessionActivityStates.asStateFlow()
private fun publishBackgroundSessionActivity() {
val contextKey = activeProfileContextKey
_backgroundSessionActivityStates.value = if (contextKey == null) {
private val sessionActivityRegistry = MutableStateFlow(SessionActivityRegistry())
private val sessionActivityGeneration = AtomicLong(0L)
private val sessionActivityPollMutex = Mutex()
private var sessionActivityPollJob: Job? = null
private var sessionActivityDirectory: Set<SessionActivityOwner> = emptySet()
private var lastProjectedProcessIds: Set<String> = emptySet()
private var lastProjectedProcessOwner: SessionActivityOwner? = null
private var lastLocalActivityOwner: SessionActivityOwner? = null
private var lastLocalStreaming = false
private var lastSessionActivityScope: SessionActivityScope? = null
private val _sessionDirectoryRefreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val sessionDirectoryRefreshRequests: SharedFlow<Unit> =
_sessionDirectoryRefreshRequests.asSharedFlow()
private fun activityScope(contextKey: String? = activeProfileContextKey): SessionActivityScope? {
val raw = contextKey?.trim().orEmpty()
val separator = raw.lastIndexOf("::")
if (separator <= 0 || separator >= raw.lastIndex) return null
return SessionActivityScope.of(raw.substring(0, separator), raw.substring(separator + 2))
}
private fun activityOwner(
sessionId: String?,
contextKey: String? = activeProfileContextKey,
): SessionActivityOwner? {
val scope = activityScope(contextKey) ?: return null
val storedId = sessionId?.trim()?.takeIf(String::isNotEmpty) ?: return null
return SessionActivityOwner.of(scope.connectionId, scope.profile, storedId)
}
private fun reduceSessionActivity(update: SessionActivityUpdate) {
sessionActivityRegistry.update { it.reduce(update) }
publishSessionActivityProjection()
}
private fun reduceSessionActivities(updates: Iterable<SessionActivityUpdate>) {
sessionActivityRegistry.update { current ->
updates.fold(current) { state, update -> state.reduce(update) }
}
publishSessionActivityProjection()
}
private fun activateSessionActivityScope() {
val scope = activityScope() ?: return
if (scope == lastSessionActivityScope) return
clearProjectedBackgroundProcesses()
lastSessionActivityScope = scope
val generation = sessionActivityGeneration.incrementAndGet()
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
lastLocalActivityOwner = null
lastLocalStreaming = false
reduceSessionActivity(
SessionActivityUpdate.BeginGeneration(
scope = scope,
generation = generation,
observedAtMillis = System.currentTimeMillis(),
),
)
requestSessionActivityRefresh()
}
private fun publishSessionActivityProjection() {
val activeConnectionId = activityScope()?.connectionId
_backgroundSessionActivityStates.value = if (activeConnectionId == null) {
emptyMap()
} else {
backgroundTurnCheckpoints.keys
.asSequence()
.filter { it.contextKey == contextKey }
.associate { key ->
key.sessionId to if (
key in backgroundNeedsInputKeys ||
backgroundPendingInteractions.containsKey(key)
) {
SessionActivityState.NeedsInput
} else {
SessionActivityState.Working
}
sessionActivityRegistry.value.presentationStates(System.currentTimeMillis())
.filterKeys { it.connectionId == activeConnectionId }
.mapKeys { (owner, _) ->
val displayProfile = owner.profile.takeUnless {
it == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
} ?: "default"
"$displayProfile:${owner.storedSessionId}"
}
}
}
private fun publishBackgroundSessionActivity() {
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
backgroundTurnCheckpoints.forEach { (key, checkpoint) ->
val owner = activityOwner(key.sessionId, key.contextKey) ?: return@forEach
reduceSessionActivity(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = checkpoint.liveSessionId,
phase = SessionActivityPhase.Working,
generation = generation,
observedAtMillis = now,
),
)
if (key in backgroundNeedsInputKeys || backgroundPendingInteractions.containsKey(key)) {
reduceSessionActivity(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
confirmed = backgroundPendingInteractions.containsKey(key),
generation = generation,
observedAtMillis = now,
),
)
} else if (sessionActivityRegistry.value.record(owner)
?.pendingInputs
?.containsKey("checkpoint:${key.contextKey}:${key.sessionId}") == true
) {
reduceSessionActivity(
SessionActivityUpdate.PendingInputClosed(
owner = owner,
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
confirmed = false,
generation = generation,
observedAtMillis = now,
),
)
}
}
publishSessionActivityProjection()
}
private fun TurnCheckpointKey.keepAliveKey(): String = "$contextKey::$sessionId"
private fun activeTurnCheckpointKey(): TurnCheckpointKey? =
@@ -1945,10 +2106,329 @@ class ChatViewModel : ViewModel() {
gatewayProcessController.dismiss(processId)
}
/**
* Replaces the known profile/session directory used to attribute process-wide
* `session.active_list` rows. A row is projected only when ownership is exact.
*/
fun updateSessionActivityDirectory(
rows: Collection<Pair<String, String>>,
) {
val scope = activityScope() ?: return
sessionActivityDirectory = rows.mapNotNullTo(mutableSetOf()) { (profile, sessionId) ->
runCatching {
SessionActivityOwner.of(
scope.connectionId,
AgentDisplay.profileSessionKey(profile),
sessionId,
)
}.getOrNull()
}
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
reduceSessionActivities(
sessionActivityDirectory.map { owner ->
SessionActivityUpdate.ObserveOwner(owner, generation, now)
},
)
requestSessionActivityRefresh()
}
private fun updateCurrentProfileActivityDirectory(sessions: Collection<ChatSession>) {
val scope = activityScope() ?: return
sessionActivityDirectory = sessionActivityDirectory
.filterNotTo(mutableSetOf()) {
it.connectionId == scope.connectionId && it.profile == scope.profile
}
.apply {
sessions.forEach { row ->
add(SessionActivityOwner.of(scope.connectionId, scope.profile, row.sessionId))
}
}
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
reduceSessionActivities(
sessionActivityDirectory
.filter { it.connectionId == scope.connectionId && it.profile == scope.profile }
.map { owner -> SessionActivityUpdate.ObserveOwner(owner, generation, now) },
)
}
fun setSessionActivityDrawerOpen(open: Boolean) {
if (open) requestSessionActivityRefresh()
}
/** Local UI edges are immediate evidence, then the active-list poll confirms them. */
fun updateCurrentSessionActivity(isStreaming: Boolean, needsInput: Boolean) {
val owner = activityOwner(chatHandler?.currentSessionId?.value) ?: return
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
val previousAskId = "current-pending-input"
lastLocalActivityOwner?.takeIf { it != owner }?.let { previousOwner ->
if (sessionActivityRegistry.value.record(previousOwner)
?.pendingInputs
?.containsKey(previousAskId) == true
) {
reduceSessionActivity(
SessionActivityUpdate.PendingInputClosed(
previousOwner,
previousAskId,
generation = generation,
observedAtMillis = now,
),
)
}
}
val pendingWasOpen = sessionActivityRegistry.value.record(owner)
?.pendingInputs
?.containsKey(previousAskId) == true
if (needsInput || pendingWasOpen) {
reduceSessionActivity(
if (needsInput) {
SessionActivityUpdate.PendingInputOpened(
owner, previousAskId, generation = generation, observedAtMillis = now,
)
} else {
SessionActivityUpdate.PendingInputClosed(
owner, previousAskId, generation = generation, observedAtMillis = now,
)
},
)
}
val streamingEdge = lastLocalActivityOwner == owner && lastLocalStreaming != isStreaming
val alreadyStarting = sessionActivityRegistry.value.record(owner)
?.phase(now) == SessionActivityPhase.Starting
if ((isStreaming && !alreadyStarting) ||
(lastLocalActivityOwner == owner && lastLocalStreaming)
) {
reduceSessionActivity(
SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = null,
status = if (isStreaming) SessionLiveStatus.Working else SessionLiveStatus.Idle,
generation = generation,
observedAtMillis = now,
),
)
}
lastLocalActivityOwner = owner
lastLocalStreaming = isStreaming
if (streamingEdge) _sessionDirectoryRefreshRequests.tryEmit(Unit)
requestSessionActivityRefresh()
}
private fun markSessionActivityStarting(sessionId: String?) {
val owner = activityOwner(sessionId) ?: return
reduceSessionActivity(
SessionActivityUpdate.LocalSend(
owner = owner,
generation = sessionActivityGeneration.get(),
observedAtMillis = System.currentTimeMillis(),
),
)
requestSessionActivityRefresh()
}
private fun settleSessionActivity(sessionId: String?, runtimeId: String? = null) {
val owner = activityOwner(sessionId) ?: return
reduceSessionActivity(
SessionActivityUpdate.Terminal(
owner = owner,
runtimeId = runtimeId,
generation = sessionActivityGeneration.get(),
observedAtMillis = System.currentTimeMillis(),
),
)
_sessionDirectoryRefreshRequests.tryEmit(Unit)
requestSessionActivityRefresh()
}
fun requestSessionActivityRefresh() {
val client = gatewayClient ?: return
if (streamingEndpoint != "gateway" || !chatVisible) return
sessionActivityPollJob?.cancel()
sessionActivityPollJob = viewModelScope.launch {
pollSessionActivity(client)
}
}
private suspend fun pollSessionActivity(client: GatewayChatClient) {
sessionActivityPollMutex.withLock {
if (gatewayClient !== client || !chatVisible || streamingEndpoint != "gateway") return
val generation = sessionActivityGeneration.get()
val currentScope = activityScope() ?: return
val currentOwner = activityOwner(chatHandler?.currentSessionId?.value)
val directory = buildSet {
addAll(sessionActivityDirectory.filter { it.connectionId == currentScope.connectionId })
currentOwner?.let { add(it) }
chatHandler?.sessions?.value.orEmpty().forEach { row ->
add(SessionActivityOwner.of(
currentScope.connectionId,
currentScope.profile,
row.sessionId,
))
}
}
val now = System.currentTimeMillis()
when (val result = client.listActiveSessions()) {
is GatewayActiveSessionsResult.Success -> {
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
val resolved = resolveGatewayActiveSessions(
sessions = result.sessions,
directory = directory,
currentOwner = currentOwner,
currentRuntimeId = currentOwner?.let {
client.currentLiveSessionId(it.storedSessionId)
},
knownOwnersByRuntime = result.sessions.mapNotNull { row ->
client.knownSessionOwner(row.runtimeSessionId)?.let { known ->
val knownProfile = when {
!known.profile.isNullOrBlank() ->
AgentDisplay.profileSessionKey(known.profile)
currentOwner != null &&
row.runtimeSessionId == client.currentLiveSessionId(
currentOwner.storedSessionId,
) &&
known.storedSessionId == currentOwner.storedSessionId ->
currentOwner.profile
else -> directory.singleOrNull { owner ->
owner.storedSessionId == known.storedSessionId &&
owner.profile in setOf(
"default",
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
)
}?.profile ?: return@let null
}
row.runtimeSessionId to SessionActivityOwner.of(
currentScope.connectionId,
knownProfile,
known.storedSessionId,
)
}
}.toMap(),
)
val scopes = directory.mapTo(mutableSetOf()) {
SessionActivityScope.of(it.connectionId, it.profile)
}
sessionActivityRegistry.value.records.keys
.filterTo(mutableSetOf()) { it.connectionId == currentScope.connectionId }
.mapTo(scopes) { SessionActivityScope.of(it.connectionId, it.profile) }
resolved.runtimes.mapNotNullTo(scopes) { runtime ->
runtime.owner?.let { SessionActivityScope.of(it.connectionId, it.profile) }
}
if (scopes.isEmpty()) scopes += currentScope
reduceSessionActivities(
scopes.map { scope ->
SessionActivityUpdate.ActiveList(
scope = scope,
runtimes = resolved.runtimes.filter { it.owner?.let { owner ->
owner.connectionId == scope.connectionId && owner.profile == scope.profile
} == true },
isCompleteForScope = !resolved.ambiguous,
generation = generation,
observedAtMillis = now,
)
},
)
}
GatewayActiveSessionsResult.Unsupported,
is GatewayActiveSessionsResult.TransientFailure -> {
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
val scopes = directory.mapTo(mutableSetOf()) {
SessionActivityScope.of(it.connectionId, it.profile)
}.apply { add(currentScope) }
reduceSessionActivities(
scopes.map { scope ->
SessionActivityUpdate.StatusUnavailable(scope, generation, now)
},
)
}
}
projectCurrentBackgroundProcesses(generation, now)
}
if (gatewayClient !== client || !chatVisible) return
val hasConfirmedLiveWork = sessionActivityRegistry.value.records.values.any { record ->
record.freshness == SessionActivityFreshness.Confirmed &&
record.phase(System.currentTimeMillis()) != SessionActivityPhase.Idle
}
val delayMs = if (hasConfirmedLiveWork) 1_500L else 30_000L
sessionActivityPollJob = viewModelScope.launch {
delay(delayMs)
if (gatewayClient === client && chatVisible) pollSessionActivity(client)
}
}
private fun projectCurrentBackgroundProcesses(generation: Long, now: Long) {
val sessionId = chatHandler?.currentSessionId?.value ?: return
val owner = activityOwner(sessionId) ?: return
val previousOwner = lastProjectedProcessOwner
if (previousOwner != null && previousOwner != owner) {
reduceSessionActivities(
lastProjectedProcessIds.map { processId ->
SessionActivityUpdate.ProcessState(
owner = previousOwner,
processId = processId,
running = false,
generation = generation,
observedAtMillis = now,
)
},
)
lastProjectedProcessIds = emptySet()
}
lastProjectedProcessOwner = owner
if (!gatewayProcessController.ownsSnapshot(sessionId, activeProfileContextKey)) {
lastProjectedProcessIds = emptySet()
return
}
val activeIds = backgroundProcesses.value.filter { it.isRunning }.mapTo(mutableSetOf()) { it.id }
reduceSessionActivities(
(lastProjectedProcessIds + activeIds).map { processId ->
SessionActivityUpdate.ProcessState(
owner = owner,
processId = processId,
running = processId in activeIds,
generation = generation,
observedAtMillis = now,
)
},
)
lastProjectedProcessIds = activeIds
}
private fun clearProjectedBackgroundProcesses() {
val owner = lastProjectedProcessOwner
if (owner != null && lastProjectedProcessIds.isNotEmpty()) {
val generation = sessionActivityGeneration.get()
val now = System.currentTimeMillis()
reduceSessionActivities(
lastProjectedProcessIds.map { processId ->
SessionActivityUpdate.ProcessState(
owner = owner,
processId = processId,
running = false,
generation = generation,
observedAtMillis = now,
)
},
)
}
lastProjectedProcessIds = emptySet()
lastProjectedProcessOwner = null
}
fun updateGatewayClient(client: GatewayChatClient?) {
val previousClient = gatewayClient
val changed = previousClient !== client
if (changed) {
clearProjectedBackgroundProcesses()
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
sessionActivityGeneration.incrementAndGet()
sessionActivityDirectory = emptySet()
lastLocalActivityOwner = null
lastLocalStreaming = false
lastSessionActivityScope = null
gatewayVisibleReattachJob?.cancel()
gatewayVisibleReattachJob = null
previousClient?.setUnsolicitedTurnProvider(null)
@@ -1967,6 +2447,7 @@ class ChatViewModel : ViewModel() {
sessionId = chatHandler?.currentSessionId?.value,
scopeKey = activeProfileContextKey,
)
activateSessionActivityScope()
}
// Bind each gateway session.create/resume to the currently-selected
// profile (pulled live) — the upstream gateway builds the agent from it.
@@ -2114,6 +2595,7 @@ class ChatViewModel : ViewModel() {
) {
prewarmGateway()
}
if (changed && client != null) requestSessionActivityRefresh()
}
/** Remove the detached sibling's recovery snapshot after server completion. */
@@ -2134,7 +2616,20 @@ class ChatViewModel : ViewModel() {
backgroundPendingInteractions.remove(key)
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
}
reduceSessionActivities(
matching.mapNotNull { key ->
activityOwner(key.sessionId, key.contextKey)?.let { owner ->
SessionActivityUpdate.Terminal(
owner = owner,
runtimeId = completion.liveSessionId,
generation = sessionActivityGeneration.get(),
observedAtMillis = System.currentTimeMillis(),
)
}
},
)
publishBackgroundSessionActivity()
requestSessionActivityRefresh()
chatTurnCheckpointStore?.let { store ->
viewModelScope.launch {
checkpointMutex.withLock {
@@ -2580,7 +3075,13 @@ class ChatViewModel : ViewModel() {
fun setChatVisible(visible: Boolean) {
val changed = chatVisible != visible
chatVisible = visible
if (visible && changed) prewarmGateway()
if (visible && changed) {
prewarmGateway()
requestSessionActivityRefresh()
} else if (!visible) {
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
}
}
// === Gateway desktop-parity state ===
@@ -3128,6 +3629,16 @@ class ChatViewModel : ViewModel() {
gatewayStateSyncJob?.cancel()
lastSurfacedCredentialWarning = null
gatewayStateSyncJob = viewModelScope.launch {
launch {
backgroundProcesses.collect {
if (gatewayClient !== client) return@collect
projectCurrentBackgroundProcesses(
generation = sessionActivityGeneration.get(),
now = System.currentTimeMillis(),
)
requestSessionActivityRefresh()
}
}
launch {
client.serverPersonality.collect { value ->
if (gatewayClient !== client || value == null) return@collect
@@ -3835,6 +4346,7 @@ class ChatViewModel : ViewModel() {
sessionId = sessionId,
)
}
activateSessionActivityScope()
handler.activeAgentName = currentAgentDisplayName()
if (
previousBinding.contextKey == contextKey &&
@@ -4102,6 +4614,8 @@ class ChatViewModel : ViewModel() {
currentSessionProfileName() == profileName
) {
handler.updateSessions(sessions)
updateCurrentProfileActivityDirectory(handler.sessions.value)
requestSessionActivityRefresh()
}
},
onFailure = { error ->
@@ -6533,6 +7047,7 @@ class ChatViewModel : ViewModel() {
private fun finalizeTurnSideEffects(handler: ChatHandler, messageId: String) {
val completedOwner = activeQueueOwnerRunId
handler.onStreamComplete(messageId)
settleSessionActivity(handler.currentSessionId.value)
if (completedOwner != null && queuedMessageItems.any { it.ownerRunId == completedOwner }) {
completedQueueOwnerRuns += completedOwner
}
@@ -6561,6 +7076,7 @@ class ChatViewModel : ViewModel() {
private fun finalizeFailedTurnSideEffects(handler: ChatHandler, messageId: String) {
handler.onStreamComplete(messageId)
handler.markError(messageId)
settleSessionActivity(handler.currentSessionId.value)
clearTurnCheckpoint()
activeStream = null
_steerableTurn.value = false
@@ -6603,6 +7119,7 @@ class ChatViewModel : ViewModel() {
} else {
handler.clearStreamingStatus()
}
settleSessionActivity(handler.currentSessionId.value)
}
}
@@ -7106,6 +7623,9 @@ class ChatViewModel : ViewModel() {
badges = listOf("Realtime Agent"),
)
)
if (streamingEndpoint == "gateway") {
markSessionActivityStarting(handler.currentSessionId.value)
}
return assistantMessageId
}
@@ -7996,6 +8516,9 @@ class ChatViewModel : ViewModel() {
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
)
)
if (streamingEndpoint == "gateway") {
markSessionActivityStarting(handler.currentSessionId.value)
}
val streamDeltas = StreamDeltaCoalescer(
scope = viewModelScope,
@@ -8313,6 +8836,7 @@ class ChatViewModel : ViewModel() {
_steerableTurn.value = false
_steerNotice.value = null
clearTurnCheckpoint()
settleSessionActivity(errorSessionId)
} else if (
dispatchedSseEndpoint == "sessions" &&
errorSessionId != null &&
@@ -8362,6 +8886,7 @@ class ChatViewModel : ViewModel() {
),
)
clearTurnCheckpoint()
settleSessionActivity(errorSessionId)
}
} else {
AppAnalytics.onStreamError()
@@ -8402,6 +8927,7 @@ class ChatViewModel : ViewModel() {
_steerableTurn.value = false
_steerNotice.value = null
clearTurnCheckpoint()
settleSessionActivity(errorSessionId)
}
}
val onPreflightErrorCb = { error: Throwable ->
@@ -8433,6 +8959,7 @@ class ChatViewModel : ViewModel() {
_steerableTurn.value = false
_steerNotice.value = null
clearTurnCheckpoint()
settleSessionActivity(handler.currentSessionId.value)
}
// === v0.4.1 voice-intent + v0.7.x card-dispatch session sync ===
@@ -8711,6 +9238,7 @@ class ChatViewModel : ViewModel() {
),
)
handler.setSessionId(sid)
markSessionActivityStarting(sid)
updateTurnCheckpointSession(sid)
selectBackgroundProcessSession(sid)
gatewayProcessController.sessionReady(sid)
@@ -8829,6 +9357,7 @@ class ChatViewModel : ViewModel() {
// don't resurrect the turn on SSE.
intentionallyCancelled = false
activeStream = null
settleSessionActivity(handler.currentSessionId.value)
} else {
// Nothing started server-side — rerun this turn on
// the SSE fallback. Callbacks land on the main
@@ -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.
@@ -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),
)
@@ -52,6 +52,7 @@ import com.hermesandroid.relay.voice.VoiceCommandInterpreter
import com.hermesandroid.relay.voice.SpokenInterruptionLatch
import com.hermesandroid.relay.voice.voiceInterfaceContextPrompt
import com.hermesandroid.relay.assistant.assistantContextStore
import com.hermesandroid.relay.assistant.AssistantSessionNotice
import com.hermesandroid.relay.assistant.buildAssistantVoiceTurnPayload
// === PHASE3-voice-intents: voice→bridge intent routing ===
import com.hermesandroid.relay.voice.IntentResult
@@ -126,6 +127,25 @@ internal fun voiceSubmissionRetryState(state: VoiceUiState): VoiceUiState = stat
error = null,
)
internal fun voiceNoSpeechState(state: VoiceUiState): VoiceUiState = state.copy(
state = VoiceState.Idle,
amplitude = 0f,
outputAudioActive = false,
transcribedText = null,
error = null,
assistantNotice = AssistantSessionNotice.NoSpeech,
)
internal fun voiceCaptureCancellationState(
state: VoiceUiState,
notice: AssistantSessionNotice? = null,
): VoiceUiState = state.copy(
state = VoiceState.Idle,
amplitude = 0f,
outputAudioActive = false,
assistantNotice = notice,
)
internal data class AssistantContextTurnDisposition(
val retireForLaterTurns: Boolean,
val consumeOnTransportAcceptance: Boolean,
@@ -328,6 +348,10 @@ data class VoiceUiState(
val responseText: String = "",
/** Human-readable error surfaced in the overlay. */
val error: String? = null,
/** Content-free retry status safe for the system Assistant surface. */
val assistantNotice: AssistantSessionNotice? = null,
/** Stable owner for cross-process Assistant status; null for ordinary voice. */
val assistantActivationId: String? = null,
/** Currently-selected interaction mode. */
val interactionMode: InteractionMode = InteractionMode.TapToTalk,
/**
@@ -440,6 +464,7 @@ internal fun voiceSessionExitState(state: VoiceUiState): VoiceUiState =
transcribedText = null,
responseText = "",
error = null,
assistantNotice = null,
destructiveCountdown = null,
hermesConfirmation = null,
handoffStatus = null,
@@ -1630,6 +1655,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
state = VoiceState.Idle,
outputAudioActive = false,
error = null,
assistantActivationId = activationId,
hermesConfirmation = null,
backgroundRun = if (orphanedRun != null) null else it.backgroundRun,
)
@@ -2067,6 +2093,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
state = VoiceState.Listening,
outputAudioActive = false,
error = null,
assistantNotice = null,
responseText = "",
// v0.4.1 — fresh turn, drop any stale JIT permission chip
// from the previous dispatch.
@@ -2176,7 +2203,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private fun shouldDiscardVoiceCaptureBeforeStop(durationMs: Long): Boolean =
durationMs < MIN_VOICE_CAPTURE_DURATION_MS
private fun cancelListeningWithoutProcessing(title: String, detail: String? = null) {
private fun cancelListeningWithoutProcessing(
title: String,
detail: String? = null,
notice: AssistantSessionNotice? = null,
) {
responseInterruptedForVoiceCommand = false
silenceWatchdogJob?.cancel()
silenceWatchdogJob = null
@@ -2188,9 +2219,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
title = title,
detail = detail,
)
_uiState.update {
it.copy(state = VoiceState.Idle, amplitude = 0f, outputAudioActive = false)
}
_uiState.update { voiceCaptureCancellationState(it, notice) }
}
/** Reconcile microphone state after the Activity returns to foreground. */
@@ -2327,6 +2356,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
cancelListeningWithoutProcessing(
title = getApplication<Application>().getString(R.string.voice_status_no_speech),
detail = "No speech within ${IDLE_NO_SPEECH_MS / 1000}s",
notice = AssistantSessionNotice.NoSpeech,
)
return@launch
}
@@ -6497,13 +6527,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
detail = detail,
)
_uiState.update {
it.copy(
state = VoiceState.Idle,
amplitude = 0f,
outputAudioActive = false,
error = null,
transcribedText = null,
)
voiceNoSpeechState(it)
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
+71 -10
View File
@@ -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>
@@ -668,7 +681,7 @@
<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>
@@ -884,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>
@@ -913,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 -->
@@ -2499,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>
@@ -2888,7 +2903,6 @@
<!-- InsecureConnectionAckDialog -->
<!-- SessionTtlPickerDialog -->
<!-- PowerFeatureGate -->
<!-- AgentTextFlow / CleanModeComposer -->
<!-- ExtraKeysToolbar -->
<!-- ToolProgressCard -->
<!-- AgentIconRow -->
@@ -2944,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>
@@ -3854,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>
@@ -4219,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>
+69 -11
View File
@@ -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>
@@ -709,7 +720,7 @@
<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>
@@ -930,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>
@@ -959,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>
@@ -2606,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>
@@ -3049,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">更新可用 &#8212; v%1$s</string>
@@ -3942,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>
@@ -4304,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>
+71 -13
View File
@@ -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>
@@ -709,7 +722,7 @@
<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>
@@ -933,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>
@@ -962,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>
@@ -2609,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>
@@ -3028,8 +3042,6 @@
<!-- PowerFeatureGate -->
<!-- AgentTextFlow / CleanModeComposer -->
<!-- ExtraKeysToolbar -->
<!-- ToolProgressCard -->
@@ -3112,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 &#8212; v%1$s</string>
@@ -4014,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>
@@ -4379,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>
+71 -7
View File
@@ -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>
@@ -636,7 +649,7 @@
<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>
@@ -848,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>
@@ -877,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>
@@ -2387,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>
@@ -2775,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>
@@ -3699,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>
@@ -4064,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>
+69 -13
View File
@@ -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>
@@ -709,7 +720,7 @@
<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>
@@ -946,6 +957,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>
@@ -975,7 +990,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>
@@ -2620,9 +2635,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">タブ</string>
@@ -3038,8 +3050,6 @@
<!-- PowerFeatureGate -->
<!-- AgentTextFlow / CleanModeComposer -->
<!-- ExtraKeysToolbar -->
<!-- ToolProgressCard -->
@@ -3122,11 +3132,6 @@
<string name="chat_rename_failed">名前の変更に失敗しました</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">利用可能なアップデート &#8212; v%1$s</string>
@@ -4013,6 +4018,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">書き込み変更にはプラグイン変更権限が必要です。Plugins の権限設定で「プラグインの変更を許可する(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>
@@ -4377,4 +4424,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>
+75 -7
View File
@@ -144,7 +144,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>
@@ -581,6 +580,24 @@
<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="one">%1$d функция</item>
<item quantity="few">%1$d функции</item>
<item quantity="many">%1$d функций</item>
<item quantity="other">%1$d функции</item>
</plurals>
<plurals name="changelog_fix_count">
<item quantity="one">%1$d исправление</item>
<item quantity="few">%1$d исправления</item>
<item quantity="many">%1$d исправлений</item>
<item quantity="other">%1$d исправления</item>
</plurals>
<string name="diag_title">Диагностика</string>
<string name="diag_back">Назад</string>
<string name="diag_status">Статус</string>
@@ -679,7 +696,7 @@
<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>
@@ -956,6 +973,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>
@@ -985,7 +1006,7 @@
<string name="drawer_filter_pinned">Закрепленные</string>
<string name="drawer_filter_archive">Архив</string>
<string name="drawer_filter_sessions">Сессии</string>
<string name="drawer_timestamp_active">Активен</string>
<string name="drawer_timestamp_updated">Обновлено</string>
<string name="drawer_timestamp_started">Начат</string>
<string name="drawer_just_now">Только что</string>
<string name="bridge_back">Назад</string>
@@ -2598,7 +2619,6 @@
<string name="power_gate_explanation_expired">Ваша сессия Relay больше не принимается сервером. Откройте соединение для восстановления.</string>
<string name="power_gate_explanation_unavailable">Этот сервер Гермеса не предоставляет необходимую поверхность для этой функции.</string>
<string name="power_gate_explanation_signin">Эта стандартная функция панели управления требует входа в панель управления перед использованием.</string>
<string name="agent_text_placeholder">Сообщение</string>
<string name="extra_keys_esc">ESC</string>
<string name="extra_keys_tab">TAB</string>
<string name="extra_keys_ctrl">CTRL</string>
@@ -2992,9 +3012,6 @@
<string name="chat_relay_sessions_summary">Просмотр и отзыв устройств, сопряженных с этим Relay.</string>
<string name="chat_relay_sessions_title">Сессии Relay</string>
<string name="chat_rename_failed">Переименование не удалось</string>
<string name="agent_text_send_cd">Отправить</string>
<string name="agent_text_exit_clean">Выход из чистого режима</string>
<string name="agent_text_sphere_desc">Агент</string>
<string name="update_banner_available_fmt">Доступно обновление \&amp;#8212; v%1$s</string>
<string name="update_banner_body_fmt">Вы используете %1$s. Нажмите Обновить, чтобы скачать.</string>
<string name="attach_viewer_cd_close">Закрыть</string>
@@ -3735,6 +3752,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)» в настройках разрешений Plugins.</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>
@@ -4106,4 +4165,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="one">%1$d изменение</item><item quantity="few">%1$d изменения</item><item quantity="many">%1$d изменений</item><item quantity="other">%1$d изменения</item></plurals>
<string name="settings_git_workspace">Рабочая область Git</string>
<string name="settings_git_workspace_desc">Изменения, ветки, коммиты и удалённые репозитории</string>
</resources>
+77 -13
View File
@@ -154,7 +154,6 @@
<string name="chat_editing_notice">Editing — will rewind the conversation</string>
<string name="chat_mic_perm_needed">Microphone permission needed</string>
<string name="chat_mic_perm_body">Tap Open Settings and grant Microphone access to use voice mode.</string>
<string name="chat_clean_view_hint">Hold the chat for a clean, focused view</string>
<string name="chat_manage_connections">Manage connections</string>
<string name="chat_no_clipboard_image">No image on the clipboard</string>
<string name="chat_copied_to_clipboard">Copied to clipboard</string>
@@ -641,6 +640,20 @@
<string name="changelog_no_notes">No release notes available.</string>
<string name="changelog_collapse">Collapse</string>
<string name="changelog_expand">Expand</string>
<string name="changelog_state_expanded">Expanded</string>
<string name="changelog_state_collapsed">Collapsed</string>
<string name="changelog_installed">Installed</string>
<string name="changelog_also_improved">Also improved</string>
<string name="changelog_toast_also">Also: %1$s</string>
<string name="changelog_view_all">View all</string>
<plurals name="changelog_additional_feature_count">
<item quantity="one">%1$d feature</item>
<item quantity="other">%1$d features</item>
</plurals>
<plurals name="changelog_fix_count">
<item quantity="one">%1$d fix</item>
<item quantity="other">%1$d fixes</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">Diagnostics</string>
@@ -747,7 +760,7 @@
<string name="settings_developer_options">Developer options</string>
<string name="settings_developer_options_desc">Feature flags, data management, experimental</string>
<string name="settings_whats_new">What\'s New</string>
<string name="settings_whats_new_desc">Release notes and full changelog</string>
<string name="settings_whats_new_desc">Android release history</string>
<string name="settings_about">About</string>
<string name="settings_about_desc">Version, credits, docs</string>
<string name="settings_profile_lock">Profile lock</string>
@@ -791,6 +804,16 @@
<string name="chat_settings_keep_keyboard_open_desc">Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.</string>
<string name="chat_settings_large_pastes">Convert large pastes to attachments</string>
<string name="chat_settings_large_pastes_desc">Turn pastes of 5,000 or more characters into reviewable text attachments. On by default.</string>
<string name="chat_settings_show_git_workspace">Show Git workspace in Chat</string>
<string name="chat_settings_show_git_workspace_desc">Show the Git status entry and changes rail in Chat. The full Git workspace remains available when this is off.</string>
<string name="chat_git_open_workspace">Open Git workspace</string>
<string name="chat_git_branch">Git branch %1$s</string>
<string name="chat_git_additions">%1$d additions</string>
<string name="chat_git_deletions">%1$d deletions</string>
<plurals name="chat_git_change_count">
<item quantity="one">%1$d change</item>
<item quantity="other">%1$d changes</item>
</plurals>
<string name="chat_large_paste_attached">Large paste added as a text attachment</string>
<string name="chat_large_paste_too_large">Pasted text exceeds the %1$d MB attachment limit</string>
<string name="chat_settings_physical_keyboard_enter">Physical keyboard Enter key</string>
@@ -1048,6 +1071,10 @@
<string name="drawer_search_sessions">Search sessions</string>
<string name="drawer_activity_working">Working</string>
<string name="drawer_activity_needs_input">Needs input</string>
<string name="drawer_activity_starting">Starting</string>
<string name="drawer_activity_background_work">Background work</string>
<string name="drawer_activity_checking">Checking</string>
<string name="drawer_activity_unavailable">Unavailable</string>
<string name="drawer_new_thread">New Thread</string>
<string name="drawer_chats_not_named">Chats aren\'t auto-named on this connection — use ⋮ → Rename.</string>
<string name="drawer_loading_sessions">Loading sessions…</string>
@@ -1077,7 +1104,7 @@
<string name="drawer_filter_pinned">Pinned</string>
<string name="drawer_filter_archive">Archive</string>
<string name="drawer_filter_sessions">Sessions</string>
<string name="drawer_timestamp_active">Active</string>
<string name="drawer_timestamp_updated">Updated</string>
<string name="drawer_timestamp_started">Started</string>
<string name="drawer_just_now">Just now</string>
@@ -2914,9 +2941,6 @@
<string name="power_gate_explanation_unavailable">This Hermes server does not expose the surface needed for this feature.</string>
<string name="power_gate_explanation_signin">This standard dashboard feature needs dashboard sign-in before it can be used.</string>
<!-- AgentTextFlow / CleanModeComposer -->
<string name="agent_text_placeholder">Message</string>
<!-- ExtraKeysToolbar -->
<string name="extra_keys_esc">ESC</string>
<string name="extra_keys_tab">TAB</string>
@@ -3339,8 +3363,6 @@
<!-- PowerFeatureGate -->
<!-- AgentTextFlow / CleanModeComposer -->
<!-- ExtraKeysToolbar -->
<!-- ToolProgressCard -->
@@ -3423,11 +3445,6 @@
<string name="chat_rename_failed">Rename failed</string>
<!-- AgentTextFlow -->
<string name="agent_text_send_cd">Send</string>
<string name="agent_text_exit_clean">Exit clean mode</string>
<string name="agent_text_sphere_desc">Agent</string>
<!-- UpdateBanner format strings (with version placeholders) -->
<string name="update_banner_available_fmt">Update available &#8212; v%1$s</string>
@@ -4210,6 +4227,51 @@
<string name="plugins_keep">Keep</string>
<string name="plugins_remove">Remove</string>
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
<string name="git_state_title">Git</string>
<string name="git_state_back">Back</string>
<string name="git_state_staged">Staged</string>
<string name="git_state_modified">Modified</string>
<string name="git_state_untracked">Untracked</string>
<string name="git_state_branches">Branches</string>
<string name="git_state_truncated">Results truncated — showing the first entries only.</string>
<string name="git_state_no_changes">(no changes)</string>
<!-- Git write surface (Phase 2). All mutations require the plugin.api.write grant. -->
<string name="git_state_write_grant_required">Write changes require the plugin change permission. Enable “Allow plugin changes (plugin.api.write)” in the Plugins grant settings.</string>
<string name="git_state_stage">Stage</string>
<string name="git_state_unstage">Unstage</string>
<string name="git_state_discard">Discard</string>
<string name="git_state_commit">Commit</string>
<string name="git_state_commit_title">Commit staged changes</string>
<string name="git_state_commit_message_hint">Commit message</string>
<string name="git_state_commit_confirm">Commit</string>
<string name="git_state_cancel">Cancel</string>
<string name="git_state_fetch">Fetch</string>
<string name="git_state_pull">Pull</string>
<string name="git_state_push">Push</string>
<string name="git_state_new_branch_hint">New branch name</string>
<string name="git_state_create_branch">Create branch</string>
<string name="git_state_track_remote">Track remote branch</string>
<string name="git_state_switch">Switch</string>
<string name="git_state_current">Current</string>
<string name="git_state_mutation_in_progress">%1$s in progress…</string>
<string name="git_state_mutation_success">%1$s completed. HEAD %2$s</string>
<string name="git_state_mutation_failed">%1$s failed: %2$s</string>
<!-- Destructive-op confirmations (token is sent only on explicit user confirmation). -->
<string name="git_state_confirm_discard_title">Discard changes?</string>
<string name="git_state_confirm_discard_text">Local changes to the selected file(s) will be discarded and cannot be recovered.</string>
<string name="git_state_confirm_discard_confirm">Discard</string>
<string name="git_state_confirm_push_title">Push changes?</string>
<string name="git_state_confirm_push_text">Push the current branch to its remote. This sends local commits to the remote repository.</string>
<string name="git_state_confirm_push_confirm">Push</string>
<string name="git_state_confirm_checkout_dirty_title">Switch branch with local changes?</string>
<string name="git_state_confirm_checkout_dirty_text">The working tree has uncommitted changes. Switching may carry them onto the target branch.</string>
<string name="git_state_confirm_checkout_confirm">Switch</string>
<string name="git_state_generate_message">Generate commit message</string>
<string name="git_state_generating_message">Generating commit message…</string>
<string name="git_state_push_after_commit">Push after commit</string>
<string name="git_state_switch_stash">Switch (stash if dirty)</string>
<string name="support_bundle_review">Review support information</string>
<string name="support_bundle_title">Support information</string>
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
@@ -4386,4 +4448,6 @@
<string name="provider_usage_capability_relay_body">Credential pools, structured Nous balances, and OpenCode Go are provided by the Relay plugin.</string>
<string name="provider_usage_capability_basic_title">Basic usage from Hermes</string>
<string name="provider_usage_capability_basic_body">Install or update the Relay plugin for credential pools, structured Nous balances, and OpenCode Go.</string>
<string name="settings_git_workspace">Git workspace</string>
<string name="settings_git_workspace_desc">Review changes, branches, commits, and remotes</string>
</resources>
@@ -65,6 +65,122 @@ class AssistantSessionProtocolTest {
assertEquals("Microphone unavailable", error.error)
}
@Test
fun idleNoSpeech_isAVisibleRetryNotice() {
val snapshot = AssistantSessionProtocol.snapshotFromVoiceState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Idle,
assistantNotice = AssistantSessionNotice.NoSpeech,
)
)
assertEquals(AssistantSessionPhase.Idle, snapshot.phase)
assertEquals(AssistantSessionNotice.NoSpeech, snapshot.notice)
assertNull(snapshot.error)
}
@Test
fun lockedPresentation_redactsConversationButKeepsGenericNotice() {
val presented = assistantSnapshotForPresentation(
AssistantSessionSnapshot(
phase = AssistantSessionPhase.Error,
transcript = "private request",
response = "private response",
notice = AssistantSessionNotice.NoSpeech,
error = "private route detail",
),
locked = true,
)
assertNull(presented.transcript)
assertEquals("", presented.response)
assertNull(presented.error)
assertEquals(AssistantSessionNotice.NoSpeech, presented.notice)
}
@Test
fun unlockedPresentation_restoresConversationContent() {
val snapshot = AssistantSessionSnapshot(
phase = AssistantSessionPhase.Speaking,
transcript = "request",
response = "response",
)
assertEquals(snapshot, assistantSnapshotForPresentation(snapshot, locked = false))
}
@Test
fun liveKeyguardState_overridesLaunchFallbackInBothDirections() {
assertTrue(
assistantPresentationLocked(
currentKeyguardLocked = true,
fallbackLocked = false,
)
)
assertFalse(
assistantPresentationLocked(
currentKeyguardLocked = false,
fallbackLocked = true,
)
)
}
@Test
fun statusSnapshots_areFencedToTheCurrentActivation() {
assertTrue(assistantSnapshotMatchesActivation("activation-b", "activation-b"))
assertFalse(assistantSnapshotMatchesActivation("activation-b", "activation-a"))
assertFalse(assistantSnapshotMatchesActivation("activation-b", null))
assertFalse(assistantSnapshotMatchesActivation(null, "activation-b"))
}
@Test
fun voiceStateRetainsItsOwningActivationAcrossLaterRuntimeChanges() {
val activationA = VoiceUiState(
voiceMode = true,
state = VoiceState.Listening,
assistantActivationId = "activation-a",
)
val currentRuntimeActivation = "activation-b"
assertEquals("activation-a", activationA.assistantActivationId)
assertFalse(
assistantSnapshotMatchesActivation(
expectedActivationId = currentRuntimeActivation,
receivedActivationId = activationA.assistantActivationId,
)
)
}
@Test
fun terminalVoiceStateRetainsActivationForClosedPublication() {
val exited = com.hermesandroid.relay.viewmodel.voiceSessionExitState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Speaking,
assistantActivationId = "activation-a",
)
)
assertFalse(exited.voiceMode)
assertEquals("activation-a", exited.assistantActivationId)
assertEquals(
AssistantSessionPhase.Closed,
AssistantSessionProtocol.snapshotFromVoiceState(exited).phase,
)
}
@Test
fun subsequentOrdinaryVoiceEntryClearsPreviousAssistantOwner() {
val ordinaryEntry = VoiceUiState(
voiceMode = true,
state = VoiceState.Idle,
assistantActivationId = null,
)
assertNull(ordinaryEntry.assistantActivationId)
}
@Test
fun persistedSessionMarker_expiresAfterBoundedRecoveryWindow() {
val now = 2_000_000L
@@ -29,6 +29,25 @@ class ChatInputPreferencesTest {
assertEquals(true, repository.convertLargePastesToAttachments.first())
}
@Test
fun `Git workspace in Chat defaults on and round trips without replacing other settings`() = runTest {
val unrelatedKey = stringPreferencesKey("unrelated_git_chat_test")
val store = InMemoryChatInputDataStore(
mutablePreferencesOf(unrelatedKey to "keep-me"),
)
val repository = ChatInputPreferencesRepository(store)
assertEquals(true, repository.showGitWorkspaceInChat.first())
repository.setShowGitWorkspaceInChat(false)
assertEquals(false, repository.showGitWorkspaceInChat.first())
assertEquals("keep-me", store.data.first()[unrelatedKey])
repository.setShowGitWorkspaceInChat(true)
assertEquals(true, repository.showGitWorkspaceInChat.first())
assertEquals("keep-me", store.data.first()[unrelatedKey])
}
@Test
fun `enter defaults to send and unknown values fall back safely`() = runTest {
assertEquals(
@@ -0,0 +1,466 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionActivityRegistryTest {
private val owner = owner("connection-a", "Default", "session-a")
private val scope = SessionActivityScope.of("connection-a", "default")
@Test
fun `directory owner stays neutral until status confirms live activity`() {
val checking = SessionActivityRegistry().reduce(
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
)
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Revalidating, checking.record(owner)?.freshness)
assertNull(checking.record(owner)?.presentationState())
val unavailable = checking.reduce(
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
)
assertEquals(SessionActivityFreshness.Unavailable, unavailable.record(owner)?.freshness)
assertNull(unavailable.record(owner)?.presentationState())
val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, confirmedIdle.record(owner)?.freshness)
assertNull(confirmedIdle.record(owner)?.presentationState())
}
@Test
fun `directory refresh cannot restore checking after active status is unavailable`() {
val state = SessionActivityRegistry()
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1))
.reduce(SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2))
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 3))
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `directory observation cannot downgrade confirmed live evidence`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 20))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
}
@Test
fun `owner normalizes profile without collapsing connection ownership`() {
assertEquals(owner, owner("connection-a", " DEFAULT ", "session-a"))
val otherConnection = owner("connection-b", "default", "session-a")
assertEquals(2, setOf(owner, otherConnection).size)
}
@Test
fun `exact pending input outranks live working and answer restores it`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase(nowMillis = 10))
state = state.reduce(closeInput(owner, "request-a", generation = 1))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 10))
}
@Test
fun `expired pending input no longer overrides live state`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1, expiresAt = 50))
.reduce(SessionActivityUpdate.Tick(nowMillis = 50))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 50))
}
@Test
fun `checkpoint is revalidating until successful snapshot settles it`() {
var state = SessionActivityRegistry().reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 1,
),
)
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
state = state.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
}
@Test
fun `successful snapshot absence settles only the same profile`() {
val otherProfile = owner("connection-a", "work", "session-a")
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(live(otherProfile, "runtime-a", SessionLiveStatus.Working, generation = 1))
state = state.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(otherProfile)?.phase())
}
@Test
fun `same ids cannot alias across profiles or connections`() {
val profileB = owner("connection-a", "work", "session-a")
val connectionB = owner("connection-b", "default", "session-a")
var state = SessionActivityRegistry()
listOf(owner, profileB, connectionB).forEach {
state = state.reduce(live(it, "runtime-shared", SessionLiveStatus.Working, generation = 2))
}
assertEquals(owner, state.ownerForRuntime(scope, "runtime-shared"))
assertEquals(
profileB,
state.ownerForRuntime(SessionActivityScope.of("connection-a", "work"), "runtime-shared"),
)
assertEquals(
connectionB,
state.ownerForRuntime(SessionActivityScope.of("connection-b", "default"), "runtime-shared"),
)
}
@Test
fun `unscoped active row cannot mark duplicate stored ids as working`() {
val profileB = owner("connection-a", "work", "session-a")
var state = SessionActivityRegistry()
.reduce(live(owner, "old-a", SessionLiveStatus.Working, generation = 1))
.reduce(live(profileB, "old-b", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
activeList(
scope,
1,
false,
SessionLiveRuntime(
owner = null,
runtimeId = "ambiguous-runtime",
status = SessionLiveStatus.Working,
),
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(profileB)?.phase())
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
}
@Test
fun `partial snapshot applies resolved row without settling absent owner`() {
val absentOwner = owner("connection-a", "default", "session-b")
var state = SessionActivityRegistry()
.reduce(live(absentOwner, "runtime-b", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
activeList(
scope,
1,
false,
SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Working),
SessionLiveRuntime(null, "ambiguous-runtime", SessionLiveStatus.Working),
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityPhase.Working, state.record(absentOwner)?.phase())
assertEquals(owner, state.ownerForRuntime(scope, "runtime-a"))
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
}
@Test
fun `new generation rejects late terminal event and invalidates old alias`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-old", SessionLiveStatus.Working, generation = 3))
.reduce(SessionActivityUpdate.BeginGeneration(scope, generation = 4, observedAtMillis = 20))
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
assertNull(state.ownerForRuntime(scope, "runtime-old"))
state = state.reduce(live(owner, "runtime-old", SessionLiveStatus.Idle, generation = 3))
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
}
@Test
fun `failed or unsupported status refresh preserves evidence but presents a neutral row`() {
val state = SessionActivityRegistry()
.reduce(
SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = "runtime-a",
status = SessionLiveStatus.Working,
source = SessionActivityEvidenceSource.ActiveList,
generation = 1,
observedAtMillis = 10,
),
)
.reduce(
SessionActivityUpdate.StatusUnavailable(
scope = scope,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `active-list failure does not override exact live session event`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(
SessionActivityUpdate.StatusUnavailable(
scope = scope,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
assertEquals(SessionActivityState.Working, state.record(owner)?.presentationState())
}
@Test
fun `presentation keeps starting and background distinct while revalidation stays neutral`() {
val starting = SessionActivityRegistry().reduce(
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
)
assertEquals(SessionActivityState.Starting, starting.record(owner)?.presentationState())
val background = starting
.reduce(process(owner, "process-a", running = true, generation = 1))
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
assertEquals(SessionActivityState.BackgroundWork, background.record(owner)?.presentationState())
val checking = starting.reduce(
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
)
assertNull(checking.record(owner)?.presentationState())
}
@Test
fun `terminal turn with running process projects background work separately`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(process(owner, "process-a", running = true, generation = 1))
.reduce(terminal(owner, "runtime-a", generation = 1))
assertEquals(SessionActivityPhase.BackgroundWork, state.record(owner)?.phase())
state = state.reduce(process(owner, "process-a", running = false, generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
}
@Test
fun `terminal settles pending input and removes its runtime alias`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
state = state.reduce(terminal(owner, "runtime-a", generation = 1))
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertNull(state.ownerForRuntime(scope, "runtime-a"))
}
@Test
fun `authoritative live state is not overwritten by restored checkpoint`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
.reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 30,
),
)
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
}
@Test
fun `restored needs-input checkpoint stays neutral until live confirmation`() {
val state = SessionActivityRegistry().reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.NeedsInput,
generation = 1,
observedAtMillis = 10,
),
)
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `synthetic checkpoint input does not confirm restored working state`() {
var state = SessionActivityRegistry()
.reduce(
SessionActivityUpdate.RestoreCheckpoint(
owner = owner,
runtimeId = "runtime-a",
phase = SessionActivityPhase.Working,
generation = 1,
observedAtMillis = 10,
),
)
.reduce(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:session-a",
confirmed = false,
generation = 1,
observedAtMillis = 11,
),
)
assertNull(state.record(owner)?.presentationState())
state = state.reduce(
SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = "checkpoint:session-a",
confirmed = true,
generation = 1,
observedAtMillis = 12,
),
)
assertEquals(SessionActivityState.NeedsInput, state.record(owner)?.presentationState())
}
@Test
fun `authoritative idle active-list row clears stale pending input`() {
val state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
.reduce(openInput(owner, "request-a", generation = 1))
.reduce(
activeList(
scope,
generation = 1,
runtimes = arrayOf(SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Idle)),
),
)
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
assertTrue(state.record(owner)?.pendingInputs.orEmpty().isEmpty())
assertNull(state.record(owner)?.presentationState())
}
@Test
fun `runtime-only update requires alias in current scoped generation`() {
var state = SessionActivityRegistry()
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
state = state.reduce(
SessionActivityUpdate.RuntimeState(
scope = scope,
runtimeId = "runtime-a",
status = SessionLiveStatus.Waiting,
generation = 1,
observedAtMillis = 20,
),
)
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
state = state.reduce(SessionActivityUpdate.BeginGeneration(scope, 2, 30))
.reduce(
SessionActivityUpdate.RuntimeState(
scope = scope,
runtimeId = "runtime-a",
status = SessionLiveStatus.Idle,
generation = 2,
observedAtMillis = 40,
),
)
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
}
private fun owner(connection: String, profile: String, session: String) =
SessionActivityOwner.of(connection, profile, session)
private fun live(
owner: SessionActivityOwner,
runtime: String,
status: SessionLiveStatus,
generation: Long,
) = SessionActivityUpdate.LiveState(
owner = owner,
runtimeId = runtime,
status = status,
generation = generation,
observedAtMillis = 10,
)
private fun activeList(
scope: SessionActivityScope,
generation: Long,
complete: Boolean = true,
vararg runtimes: SessionLiveRuntime,
) = SessionActivityUpdate.ActiveList(
scope = scope,
runtimes = runtimes.toList(),
isCompleteForScope = complete,
generation = generation,
observedAtMillis = 20,
)
private fun openInput(
owner: SessionActivityOwner,
request: String,
generation: Long,
expiresAt: Long? = null,
) = SessionActivityUpdate.PendingInputOpened(
owner = owner,
requestId = request,
expiresAtMillis = expiresAt,
generation = generation,
observedAtMillis = 10,
)
private fun closeInput(owner: SessionActivityOwner, request: String, generation: Long) =
SessionActivityUpdate.PendingInputClosed(
owner = owner,
requestId = request,
generation = generation,
observedAtMillis = 20,
)
private fun process(owner: SessionActivityOwner, id: String, running: Boolean, generation: Long) =
SessionActivityUpdate.ProcessState(
owner = owner,
processId = id,
running = running,
generation = generation,
observedAtMillis = 10,
)
private fun terminal(owner: SessionActivityOwner, runtime: String, generation: Long) =
SessionActivityUpdate.Terminal(
owner = owner,
runtimeId = runtime,
generation = generation,
observedAtMillis = 20,
)
}
@@ -195,6 +195,11 @@ class GatewayClientHarness(
put("sessions", JsonArray(emptyList()))
}
@Volatile
var activeSessionListPayload: JsonObject = buildJsonObject {
put("sessions", JsonArray(emptyList()))
}
@Volatile
var profileCreatePayload: JsonObject = buildJsonObject {
put("ok", true)
@@ -316,6 +321,7 @@ class GatewayClientHarness(
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
)
"session.list" -> sessionListPayload
"session.active_list" -> activeSessionListPayload
"session.title" -> buildJsonObject { put("ok", true) }
"prompt.submit" -> promptSubmitPayload
"session.interrupt" -> buildJsonObject { put("ok", true) }
@@ -1514,6 +1520,112 @@ class GatewayChatClientTest {
assertTrue((refreshParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
}
@Test
fun `active session list parses every authoritative upstream state`() = runBlocking {
harness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray {
listOf("idle", "starting", "working", "waiting").forEachIndexed { index, status ->
add(buildJsonObject {
put("id", "runtime-$index")
put("session_key", "stored-$index")
put("status", status)
put("last_active", 1_774_000_000.25 + index)
})
}
})
}
val result = client.listActiveSessions()
val rows = (result as GatewayActiveSessionsResult.Success).sessions
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
assertEquals(
listOf(
GatewayActiveSessionStatus.Idle,
GatewayActiveSessionStatus.Starting,
GatewayActiveSessionStatus.Working,
GatewayActiveSessionStatus.Waiting,
),
rows.map(GatewayActiveSession::status),
)
assertEquals("runtime-2", rows[2].runtimeSessionId)
assertEquals("stored-2", rows[2].storedSessionId)
assertEquals(1_774_000_002.25, rows[2].lastActiveEpochSeconds, 0.0)
assertTrue(rows.all { it.profile == null })
}
@Test
fun `active session list treats empty successful snapshot as authoritative`() = runBlocking {
val result = client.listActiveSessions()
assertEquals(emptyList<GatewayActiveSession>(), (result as GatewayActiveSessionsResult.Success).sessions)
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
}
@Test
fun `active session list stays process wide and never synthesizes fixed profile`() = runBlocking {
val routeHarness = GatewayClientHarness()
routeHarness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray {
add(buildJsonObject {
put("id", "runtime-operator")
put("session_key", "stored-shared")
put("status", "working")
put("last_active", 1_774_000_000.0)
})
})
}
val routeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val routeClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = routeHarness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
fixedSessionProfile = "operator",
okHttpClient = OkHttpClient(),
callbackDispatcher = { it() },
scope = routeScope,
)
try {
val result = routeClient.listActiveSessions() as GatewayActiveSessionsResult.Success
val params = routeHarness.awaitRpc("session.active_list")
val requests = List(2) { routeHarness.server.takeRequest(5, TimeUnit.SECONDS) }
assertFalse(params.containsKey("profile"))
assertNull(result.sessions.single().profile)
assertTrue(requests.filterNotNull().any { it.path?.contains("profile=operator") == true })
} finally {
routeClient.shutdown()
routeScope.cancel()
routeHarness.shutdown()
}
}
@Test
fun `active session method not found is explicit and sticky for current socket`() = runBlocking {
harness.methodNotFound += "session.active_list"
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
assertEquals(GatewayActiveSessionCapability.Unsupported, client.activeSessionCapability.value)
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
}
@Test
fun `active session transient error stays distinct from unsupported`() = runBlocking {
harness.rpcErrors["session.active_list"] = 5036 to "could not enumerate active sessions"
val failed = client.listActiveSessions()
assertTrue(failed is GatewayActiveSessionsResult.TransientFailure)
assertEquals(GatewayActiveSessionCapability.Unknown, client.activeSessionCapability.value)
harness.rpcErrors.remove("session.active_list")
assertTrue(client.listActiveSessions() is GatewayActiveSessionsResult.Success)
assertEquals(2, harness.rpcLog.count { it.first == "session.active_list" })
}
@Test
fun `process list uses live session id and parses typed snapshot`() = runBlocking {
assertTrue(client.prewarmAwait("stored-session"))
@@ -4027,6 +4139,10 @@ class GatewayChatClientTest {
harness.awaitRpc("prompt.submit")
assertTrue(client.backgroundActiveTurn())
assertEquals(
GatewayKnownSessionOwner("20260612_120000_abc123", "coder"),
client.knownSessionOwner("live-1"),
)
client.clearSession()
client.sessionProfileProvider = { "writer" }
@@ -0,0 +1,32 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.ui.screens.ChangelogScreen
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h640dp-xhdpi")
class ChangelogHistoryScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test fun latestReleaseIsCuratedAndInstalled() {
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
ChangelogScreen(onClose = {})
}
}
compose.onNodeWithText("Supervised Mode").assertExists()
compose.onNodeWithText("Installed").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/changelog-history.png")
}
}
@@ -0,0 +1,130 @@
package com.hermesandroid.relay.screenshots
import android.app.Application
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.ui.components.ChatGitContextButton
import com.hermesandroid.relay.ui.components.ChatGitWorkspaceRail
import com.hermesandroid.relay.ui.components.ChatGitWorkspaceSummary
import com.hermesandroid.relay.ui.screens.GitStateScreen
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.GitStateViewModel
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w400dp-h800dp-432dpi")
class GitWorkspaceScreenshotTest {
@get:Rule
val compose = createComposeRule()
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun detailWorkspaceMatchesApprovedInformationHierarchy() {
enqueue("""{"repos":[{"id":"hermes-relay","name":"hermes-relay","root":"/srv/projects/hermes-relay","current_branch":"main","dirty":true}]}""")
enqueue(
"""{"counts":{"staged":1,"modified":1,"untracked":1,"changes":3,"additions":24,"deletions":7},"staged":[{"path":"app/src/main/kotlin/RelayApp.kt","additions":12,"deletions":3}],"modified":[{"path":"plugin/git_state.py","additions":8,"deletions":4}],"untracked":[{"path":"docs/git-workspace.md","additions":null,"deletions":null}],"truncated":false}""",
)
enqueue("""{"branches":[{"name":"main","upstream":"origin/main","ahead":1,"behind":0,"is_current":true},{"name":"dev","upstream":"origin/dev","ahead":0,"behind":0,"is_current":false}]}""")
enqueue(
"""{"path":"plugin/git_state.py","kind":"unstaged","diff":"@@ repository containment @@\n+ def is_within_repo(path):\n- return false\n+ return path.startswith(repo_root)","truncated":false}""",
)
val app = ApplicationProvider.getApplicationContext<Application>()
val viewModel = GitStateViewModel(app)
val owner = "visual-owner"
viewModel.configure(DashboardApiClient(server.url("/").toString()), owner)
viewModel.setWriteGrant(owner, true)
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
GitStateScreen(viewModel = viewModel, onBack = {})
}
}
compose.waitUntil(5_000) {
runCatching { compose.onNodeWithText("3 changes").assertExists() }.isSuccess
}
compose.onNodeWithContentDescription("Select plugin/git_state.py").performClick()
compose.onNodeWithText("git_state.py").performClick()
compose.waitUntil(5_000) {
runCatching { compose.onNodeWithText("@@ repository containment @@", substring = true).assertExists() }.isSuccess
}
compose.onRoot().captureRoboImage("build/store-shots/15_git_workspace.png")
}
@Test
fun chatRailMatchesApprovedCompactTreatment() {
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Box(
Modifier.fillMaxSize().background(androidx.compose.material3.MaterialTheme.colorScheme.background),
) {
ChatGitContextButton(
onClick = {},
modifier = Modifier.align(Alignment.TopEnd).padding(16.dp),
)
Column(
Modifier.fillMaxWidth().align(Alignment.BottomCenter).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
ChatGitWorkspaceRail(
summary = ChatGitWorkspaceSummary("main", 3, 24, 7),
onClick = {},
)
Box(
Modifier.fillMaxWidth().background(
androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer,
androidx.compose.foundation.shape.RoundedCornerShape(18.dp),
).padding(vertical = 28.dp),
)
}
}
}
}
compose.onRoot().captureRoboImage("build/ui-evidence/chat-git-workspace-rail.png")
}
private fun enqueue(body: String) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body),
)
}
}
@@ -61,6 +61,7 @@ import com.hermesandroid.relay.data.AppearancePreferences
import com.hermesandroid.relay.data.DashboardConnectionStatus
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.PetBehaviorPreferences
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.ui.components.ChatInputBar
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
@@ -138,9 +139,14 @@ class StoreScreenshotTest {
@get:Rule
val compose = createComposeRule()
private fun capture(name: String, themeId: String = "hermes-relay", body: @Composable () -> Unit) {
private fun capture(
name: String,
themeId: String = "hermes-relay",
themePreference: String = "dark",
body: @Composable () -> Unit,
) {
compose.setContent {
HermesRelayTheme(appThemeId = themeId, themePreference = "dark") {
HermesRelayTheme(appThemeId = themeId, themePreference = themePreference) {
// Adaptive is the app's real default skin (resolve("auto") -> Adaptive);
// it recolors to the active theme. The preview/test fallback is Classic,
// which mismatches the app and reads poorly on light themes.
@@ -251,6 +257,14 @@ class StoreScreenshotTest {
}
@Test fun s06_manage() = capture("06_manage", "hermes-relay") { ManageScene() }
@Test fun s04_sessions() = capture("04_sessions", "hermes-relay") { SessionsScene() }
@Test fun s12_session_activity_states() = capture("12_session_activity_states", "hermes-relay") {
SessionActivityStatesScene()
}
@Test fun s12_session_activity_states_light() = capture(
"12_session_activity_states_light",
"nous-blue",
themePreference = "light",
) { SessionActivityStatesScene() }
@Test fun s07_connections() = capture("07_connections", "hermes-relay") { ConnectionsScene() }
// Real Appearance screen scrolled to the new Font picker — proves the
// bundled Inter/Nunito faces load as visibly distinct previews (vs System).
@@ -805,6 +819,41 @@ private fun SessionsScene() {
}
}
@Composable
private fun SessionActivityStatesScene() {
val states = SessionActivityState.entries
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim)) {
SessionDrawerContent(
sessions = states.mapIndexed { index, state ->
ChatSession(
sessionId = "activity-$index",
title = when (state) {
SessionActivityState.Starting -> "Launching the agent"
SessionActivityState.Working -> "Reviewing the release"
SessionActivityState.NeedsInput -> "Approval required"
SessionActivityState.BackgroundWork -> "Build still running"
SessionActivityState.Checking -> "Reconnecting to Hermes"
SessionActivityState.Unavailable -> "Offline session"
},
model = "gpt-5.6-sol",
)
},
currentSessionId = null,
activeProfileName = "default",
scopeTitle = "Hermes",
scopeSubtitle = "Live session status",
activityStates = states.mapIndexed { index, state ->
"default:activity-$index" to state
}.toMap(),
animationEnabled = false,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
@Composable
private fun ConnectionsScene() = ConnectionsSettingsScreen(
connections = marketingConnections,
@@ -0,0 +1,114 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.CompositionLocalProvider
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.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.ui.components.ChangelogStore
import com.hermesandroid.relay.ui.components.WhatsNewToast
import com.hermesandroid.relay.ui.components.WhatsNewToastContent
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h640dp-xhdpi")
class WhatsNewToastScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test fun compactNoticeRemainsClearAtLargeText() {
compose.setContent {
val context = LocalContext.current
val density = LocalDensity.current
val entry = ChangelogStore.load(context).versions.first()
CompositionLocalProvider(LocalDensity provides Density(density.density, 1.35f)) {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Box(
modifier = Modifier
.fillMaxSize()
.background(androidx.compose.material3.MaterialTheme.colorScheme.background)
.padding(12.dp),
contentAlignment = Alignment.TopCenter,
) {
WhatsNewToastContent(
entry = entry,
progress = 0.62f,
onExpand = {},
onDismiss = {},
)
}
}
}
}
compose.onNodeWithText("Supervised Mode").assertExists()
compose.onNodeWithText("Also: 2 fixes").assertExists()
compose.onNodeWithText("Accurate activity, safer return…").assertExists()
compose.onNodeWithText("View all").assertExists()
compose.onNodeWithContentDescription("Close").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/whats-new-toast-large-text.png")
}
@Test fun exposesExpandAndCloseActions() {
var expanded = false
var dismissed = false
compose.setContent {
val entry = ChangelogStore.load(LocalContext.current).versions.first()
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
WhatsNewToastContent(
entry = entry,
progress = 1f,
onExpand = { expanded = true },
onDismiss = { dismissed = true },
)
}
}
compose.onNodeWithText("Supervised Mode").performClick()
expanded = false
compose.onNodeWithText("View all").performClick()
compose.onNodeWithContentDescription("Close").performClick()
assertTrue(expanded)
assertTrue(dismissed)
}
@Test fun horizontalSwipeDismissesTheNotice() {
var dismissed = false
compose.mainClock.autoAdvance = false
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
WhatsNewToast(
onDismiss = { dismissed = true },
onExpand = {},
autoDismissMillis = 60_000L,
)
}
}
compose.mainClock.advanceTimeBy(300L)
compose.onNodeWithText("Supervised Mode").performTouchInput { swipeLeft(durationMillis = 300L) }
compose.mainClock.advanceTimeBy(300L)
assertTrue(dismissed)
}
}
@@ -0,0 +1,61 @@
package com.hermesandroid.relay.ui
import com.hermesandroid.relay.data.GitBranch
import com.hermesandroid.relay.data.GitRepo
import com.hermesandroid.relay.data.GitStatus
import com.hermesandroid.relay.data.GitStatusCounts
import com.hermesandroid.relay.data.GitStatusEntry
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class GitWorkspaceProjectionTest {
private val parent = GitRepo("parent", "projects", "/srv/projects")
private val nested = GitRepo("nested", "relay", "/srv/projects/hermes-relay")
@Test
fun exactSessionRootWinsAndCwdUsesLongestSegmentMatch() {
assertEquals(
nested,
selectGitRepoForWorkspace(listOf(parent, nested), null, nested.root, null),
)
assertEquals(
nested,
selectGitRepoForWorkspace(
listOf(parent, nested),
null,
null,
"/srv/projects/hermes-relay/app/src",
),
)
}
@Test
fun ambiguousCatalogDoesNotInventASelection() {
assertNull(selectGitRepoForWorkspace(listOf(parent, nested), null, null, null))
assertNull(selectGitRepoForWorkspace(listOf(parent, nested), nested.id, null, null))
assertEquals(parent, selectGitRepoForWorkspace(listOf(parent), null, null, null))
}
@Test
fun summaryCountsEachChangedPathOnce() {
val summary = buildChatGitWorkspaceSummary(
nested,
GitRepoDetailState.Ready(
status = GitStatus(
counts = GitStatusCounts(additions = 24, deletions = 7),
staged = listOf(GitStatusEntry("shared.kt")),
modified = listOf(GitStatusEntry("shared.kt"), GitStatusEntry("other.kt")),
untracked = listOf(GitStatusEntry("new.kt")),
),
branches = listOf(GitBranch("dev", isCurrent = true)),
),
)
assertEquals("dev", summary?.branch)
assertEquals(3, summary?.changeCount)
assertEquals(24, summary?.additions)
assertEquals(7, summary?.deletions)
}
}
@@ -36,6 +36,47 @@ class ChangelogParserTest {
assertEquals(listOf("a", "b"), changelog.versions[0].sections.first().bullets)
}
@Test
fun parsesCuratedHighlightAndImprovements() {
val raw = """
{
"schema": 2,
"versions": [
{
"version": "1.2.0",
"title": "A useful release",
"date": "2026-06-20",
"highlight": {
"title": "The main reason to care",
"summary": "A plain-language explanation.",
"bullets": ["a", "b", "c"]
},
"improvements": ["d", "e"],
"toastDigest": {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["Secondary feature", "Important fix"]
},
"playNotes": "Concise Play copy."
}
]
}
""".trimIndent()
val entry = ChangelogStore.parse(raw).versions.single()
assertEquals("The main reason to care", entry.highlight?.title)
assertEquals("A plain-language explanation.", entry.highlight?.summary)
assertEquals(listOf("a", "b", "c"), entry.highlight?.bullets)
assertEquals(listOf("d", "e"), entry.improvements)
assertEquals(1, entry.toastDigest?.additionalFeatureCount)
assertEquals(2, entry.toastDigest?.fixCount)
assertEquals(listOf("Secondary feature", "Important fix"), entry.toastDigest?.preview)
assertEquals("Concise Play copy.", entry.playNotes)
assertEquals("v1.2.0 · 2026-06-20", entry.versionLine())
assertEquals(listOf("The main reason to care", "Also improved"), entry.toGroups().map { it.header })
}
@Test
fun blankInputYieldsEmptyChangelog() {
assertTrue(ChangelogStore.parse("").versions.isEmpty())
@@ -5,12 +5,10 @@ import org.junit.Test
class MorphingSphereMotionPolicyTest {
@Test
fun `visible idle sphere uses lightweight ambient motion`() {
fun `every visible sphere uses smooth procedural motion`() {
assertEquals(
SphereMotionMode.AmbientLayer,
SphereMotionMode.Procedural,
sphereMotionMode(
state = SphereState.Idle,
voiceMode = false,
motionVisible = true,
fixedTime = null,
fixedColorPhase = null,
@@ -19,26 +17,14 @@ class MorphingSphereMotionPolicyTest {
}
@Test
fun `hidden or paused idle sphere is still`() {
fun `hidden or paused sphere is still`() {
assertEquals(
SphereMotionMode.Still,
sphereMotionMode(SphereState.Idle, false, false, null, null),
sphereMotionMode(false, null, null),
)
assertEquals(
SphereMotionMode.Still,
sphereMotionMode(SphereState.Idle, false, true, 0f, 0f),
)
}
@Test
fun `visible active and voice states keep procedural motion`() {
assertEquals(
SphereMotionMode.Procedural,
sphereMotionMode(SphereState.Thinking, false, true, null, null),
)
assertEquals(
SphereMotionMode.Procedural,
sphereMotionMode(SphereState.Idle, true, true, null, null),
sphereMotionMode(true, 0f, 0f),
)
}
}
@@ -1,8 +1,11 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionDrawerPolicyTest {
@@ -19,6 +22,138 @@ class SessionDrawerPolicyTest {
assertEquals("alpha:same", sessionRowKey(alpha))
assertEquals("beta:same", sessionRowKey(beta))
assertEquals("default:same", sessionRowKey(row("default", "same")))
}
@Test
fun `rest recent activity alone does not mark a session working`() {
val recentlyActive = row("default", "recent", recentlyActive = true)
assertEquals(
SessionDrawerStatus.Idle,
sessionDrawerStatus(recentlyActive, activityStates = emptyMap()),
)
}
@Test
fun `duplicate session ids cannot leak activity across profiles`() {
val alpha = row("alpha", "same")
val beta = row("beta", "same")
val scoped = scopedSessionActivityStates(
rows = listOf(alpha, beta),
activityStates = mapOf(sessionRowKey(alpha) to SessionActivityState.Working),
allowBareSessionIds = false,
)
assertEquals(SessionDrawerStatus.Working, sessionDrawerStatus(alpha, scoped))
assertEquals(SessionDrawerStatus.Idle, sessionDrawerStatus(beta, scoped))
assertFalse(sessionRowKey(beta) in scoped)
}
@Test
fun `all profiles ignores ambiguous bare session activity`() {
val alpha = row("alpha", "same")
val beta = row("beta", "same")
val scoped = scopedSessionActivityStates(
rows = listOf(alpha, beta),
activityStates = mapOf("same" to SessionActivityState.Working),
allowBareSessionIds = false,
)
assertEquals(emptyMap<String, SessionActivityState>(), scoped)
}
@Test
fun `selected profile may scope legacy bare session activity`() {
val row = row("work", "session")
val scoped = scopedSessionActivityStates(
rows = listOf(row),
activityStates = mapOf("session" to SessionActivityState.NeedsInput),
allowBareSessionIds = true,
)
assertEquals(
mapOf(sessionRowKey(row) to SessionActivityState.NeedsInput),
scoped,
)
}
@Test
fun `status filter and grouping use the same authoritative state`() {
val restOnly = row("default", "rest-only", recentlyActive = true)
val working = row("default", "working")
val states = mapOf(sessionRowKey(working) to SessionActivityState.Working)
val filtered = filterAndSortSessionRows(
rows = listOf(restOnly, working),
options = SessionDrawerViewOptions(statuses = setOf(SessionDrawerStatus.Working)),
activityStates = states,
)
val grouped = groupSessionRows(
rows = listOf(restOnly, working),
grouping = SessionDrawerGrouping.Status,
activityStates = states,
)
assertEquals(listOf("working"), filtered.map { it.session.sessionId })
assertEquals(listOf("Idle", "Working"), grouped.mapNotNull { it.label })
}
@Test
fun `expanded live phases retain distinct drawer statuses and labels`() {
val phases = listOf(
SessionActivityState.NeedsInput to (SessionDrawerStatus.NeedsInput to "Needs input"),
SessionActivityState.Starting to (SessionDrawerStatus.Starting to "Starting"),
SessionActivityState.Working to (SessionDrawerStatus.Working to "Working"),
SessionActivityState.BackgroundWork to (SessionDrawerStatus.BackgroundWork to "Background work"),
SessionActivityState.Checking to (SessionDrawerStatus.Checking to "Checking"),
SessionActivityState.Unavailable to (SessionDrawerStatus.Unavailable to "Unavailable"),
)
val rows = phases.mapIndexed { index, _ -> row("default", "session-$index") }
val states = rows.zip(phases).associate { (row, phase) -> sessionRowKey(row) to phase.first }
assertEquals(
phases.map { it.second.first },
rows.map { sessionDrawerStatus(it, states) },
)
assertEquals(
phases.map { it.second.second },
groupSessionRows(rows, SessionDrawerGrouping.Status, states).mapNotNull { it.label },
)
assertEquals(
listOf(
R.string.drawer_activity_needs_input,
R.string.drawer_activity_starting,
R.string.drawer_activity_working,
R.string.drawer_activity_background_work,
R.string.drawer_activity_checking,
R.string.drawer_activity_unavailable,
),
phases.map { sessionActivityLabelResource(it.first) },
)
phases.forEachIndexed { index, phase ->
assertEquals(
listOf("session-$index"),
filterAndSortSessionRows(
rows = rows,
options = SessionDrawerViewOptions(statuses = setOf(phase.second.first)),
activityStates = states,
).map { it.session.sessionId },
)
}
}
@Test
fun `full row border is limited to foreground live work`() {
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Starting))
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Working))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.NeedsInput))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.BackgroundWork))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Checking))
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Unavailable))
assertFalse(sessionActivityShowsRowBorder(null))
}
@Test
@@ -113,6 +248,7 @@ class SessionDrawerPolicyTest {
outputTokens: Int = 0,
cost: Double? = null,
updatedAt: Long = 0L,
recentlyActive: Boolean = false,
) = ProfileSessionRow(
profile = profile,
session = ChatSession(
@@ -126,6 +262,7 @@ class SessionDrawerPolicyTest {
outputTokens = outputTokens,
actualCostUsd = cost,
lastActivityAt = updatedAt,
recentlyActive = recentlyActive,
),
)
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
import org.junit.Rule
import org.junit.Test
@@ -359,6 +360,66 @@ class SessionDrawerTest {
compose.onNodeWithText("Filters").assertIsDisplayed()
}
@Test
fun `drawer renders every authoritative activity phase distinctly`() {
val states = SessionActivityState.entries
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = states.mapIndexed { index, _ ->
ChatSession("session-$index", "Session $index", null)
},
currentSessionId = null,
activeProfileName = "default",
activityStates = states.mapIndexed { index, state ->
"default:session-$index" to state
}.toMap(),
animationEnabled = false,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
listOf(
"Starting",
"Working",
"Needs input",
"Background work",
"Checking",
"Unavailable",
).forEach { label ->
compose.onNodeWithText(label).performScrollTo().assertIsDisplayed()
}
}
@Test
fun `rest recency alone renders no working badge`() {
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = listOf(
ChatSession(
"recent",
"Recently updated",
null,
recentlyActive = true,
),
),
currentSessionId = null,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("Working").assertDoesNotExist()
}
@Test
fun `all profiles customization can override a profile identity color`() {
var changed: Pair<String, String?>? = null
@@ -0,0 +1,57 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.data.ChatSession
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class SessionDrawerTimestampTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun `distinct activity is labeled updated`() {
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
assertEquals(
"Updated Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
}
@Test
fun `relative timestamp changes when the drawer clock advances`() {
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
assertEquals(
"Updated Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
assertEquals(
"Updated 2m ago",
sessionTimestampText(session, Locale.US, context, nowMillis = 240_000L),
)
}
@Test
fun `session without later activity keeps started label`() {
val session = session(startedAt = 120_000L, lastActivityAt = 120_000L)
assertEquals(
"Started Just now",
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
)
}
private fun session(startedAt: Long, lastActivityAt: Long) = ChatSession(
sessionId = "session",
title = "Session",
model = null,
startedAt = startedAt,
lastActivityAt = lastActivityAt,
)
}
@@ -0,0 +1,24 @@
package com.hermesandroid.relay.ui.screens
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Test
class ChangelogScreenTest {
@Test
fun formatsReleaseDateForTheCurrentLocale() {
assertEquals("Aug 25, 2026", formatReleaseDate("2026-08-25", Locale.US))
}
@Test
fun preservesAnUnparseableReleaseDate() {
assertEquals("coming soon", formatReleaseDate("coming soon", Locale.US))
}
@Test
fun matchesInstalledReleaseAcrossFlavorSuffixes() {
assertEquals(true, isInstalledRelease("1.13.2", "1.13.2-sideload", "sideload"))
assertEquals(true, isInstalledRelease("1.13.2-rc.1", "1.13.2-rc.1-googlePlay", "googlePlay"))
assertEquals(false, isInstalledRelease("1.13.1", "1.13.2-sideload", "sideload"))
}
}
@@ -1,66 +0,0 @@
package com.hermesandroid.relay.ui.screens
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatCleanViewHintTest {
@Test
fun petRemainsVisibleOnUnobstructedChat() {
assertFalse(shouldHideChatPet())
}
@Test
fun everyModalChatSurfaceSuppressesPet() {
assertTrue(shouldHideChatPet(ambientMode = true))
assertTrue(shouldHideChatPet(voiceMode = true))
assertTrue(shouldHideChatPet(drawerOpenOrMoving = true))
assertTrue(shouldHideChatPet(commandPaletteVisible = true))
assertTrue(shouldHideChatPet(modelSheetVisible = true))
assertTrue(shouldHideChatPet(effortSheetVisible = true))
assertTrue(shouldHideChatPet(contextSheetVisible = true))
assertTrue(shouldHideChatPet(backgroundProcessesVisible = true))
assertTrue(shouldHideChatPet(agentInfoVisible = true))
}
@Test
fun emptyTextChatShowsCleanViewHint() {
assertTrue(
shouldShowCleanViewHint(
hasMessages = false,
ambientMode = false,
voiceMode = false,
),
)
}
@Test
fun voiceModeOwnsBottomAreaOnEmptyChat() {
assertFalse(
shouldShowCleanViewHint(
hasMessages = false,
ambientMode = false,
voiceMode = true,
),
)
}
@Test
fun existingConversationOrCleanModeSuppressesHint() {
assertFalse(
shouldShowCleanViewHint(
hasMessages = true,
ambientMode = false,
voiceMode = false,
),
)
assertFalse(
shouldShowCleanViewHint(
hasMessages = false,
ambientMode = true,
voiceMode = false,
),
)
}
}
@@ -0,0 +1,25 @@
package com.hermesandroid.relay.ui.screens
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatPetVisibilityTest {
@Test
fun petRemainsVisibleOnUnobstructedChat() {
assertFalse(shouldHideChatPet())
}
@Test
fun everyModalChatSurfaceSuppressesPet() {
assertTrue(shouldHideChatPet(voiceMode = true))
assertTrue(shouldHideChatPet(drawerOpenOrMoving = true))
assertTrue(shouldHideChatPet(commandPaletteVisible = true))
assertTrue(shouldHideChatPet(modelSheetVisible = true))
assertTrue(shouldHideChatPet(effortSheetVisible = true))
assertTrue(shouldHideChatPet(contextSheetVisible = true))
assertTrue(shouldHideChatPet(backgroundProcessesVisible = true))
assertTrue(shouldHideChatPet(agentInfoVisible = true))
}
}
@@ -1,49 +0,0 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class SessionActivityStateTest {
@Test
fun `multiple background turns remain visible beside current working turn`() {
val resolved = resolveSessionActivityStates(
background = mapOf(
"session-a" to SessionActivityState.Working,
"session-b" to SessionActivityState.NeedsInput,
),
currentSessionId = "session-c",
isStreaming = true,
needsInput = false,
)
assertEquals(SessionActivityState.Working, resolved["session-a"])
assertEquals(SessionActivityState.NeedsInput, resolved["session-b"])
assertEquals(SessionActivityState.Working, resolved["session-c"])
}
@Test
fun `needs input takes precedence over current working state`() {
val resolved = resolveSessionActivityStates(
background = emptyMap(),
currentSessionId = "session-a",
isStreaming = true,
needsInput = true,
)
assertEquals(SessionActivityState.NeedsInput, resolved["session-a"])
}
@Test
fun `selected idle session does not retain a stale background state`() {
val resolved = resolveSessionActivityStates(
background = mapOf("session-a" to SessionActivityState.Working),
currentSessionId = "session-a",
isStreaming = false,
needsInput = false,
)
assertFalse(resolved.containsKey("session-a"))
}
}
@@ -14,6 +14,7 @@ import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.upstream.ChatHandler
@@ -23,6 +24,7 @@ import com.hermesandroid.relay.network.upstream.GatewayClientHarness
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.SessionItem
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -291,6 +293,83 @@ class ChatViewModelGatewayInboundTurnTest {
)
}
@Test
fun activeListWorkingThenDisappearanceSettlesDespiteRestRecency() {
bindActivityTestDirectory()
handler.updateSessions(
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
)
gatewayHarness.activeSessionListPayload = activeSessionPayload("working")
viewModel.setChatVisible(true)
gatewayHarness.awaitRpc("session.active_list")
awaitCondition {
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
SessionActivityState.Working
}
gatewayHarness.activeSessionListPayload = buildJsonObject {
put("sessions", buildJsonArray { })
}
viewModel.requestSessionActivityRefresh()
gatewayHarness.awaitRpcCount("session.active_list", 2)
awaitCondition {
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
}
}
@Test
fun activeListWaitingProjectsNeedsInput() {
bindActivityTestDirectory()
gatewayHarness.activeSessionListPayload = activeSessionPayload("waiting")
viewModel.setChatVisible(true)
gatewayHarness.awaitRpc("session.active_list")
awaitCondition {
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
SessionActivityState.NeedsInput
}
}
@Test
fun unsupportedActiveListLeavesRowsNeutralAcrossDirectoryRefresh() {
bindActivityTestDirectory()
handler.updateSessions(
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
)
gatewayHarness.methodNotFound += "session.active_list"
viewModel.setChatVisible(true)
gatewayHarness.awaitRpc("session.active_list")
awaitCondition {
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
}
viewModel.updateSessionActivityDirectory(
rows = listOf("default" to STORED_SESSION_ID),
)
assertFalse("default:$STORED_SESSION_ID" in viewModel.backgroundSessionActivityStates.value)
}
@Test
fun rejectedAdmissionCannotLeaveStartingStatusStale() {
bindActivityTestDirectory()
gatewayClient.clearSession()
gatewayHarness.rpcErrors["session.resume"] = 4090 to "stored session is unavailable"
viewModel.sendMessage("This admission should fail")
gatewayHarness.awaitRpc("session.resume")
awaitCondition { !handler.isStreaming.value }
assertTrue(
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] !=
SessionActivityState.Starting,
)
}
@After
fun tearDown() {
DiagnosticsLog.clear()
@@ -2376,6 +2455,27 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(handler.messages.value.any { it.content == BACKGROUND_ANSWER })
}
private fun bindActivityTestDirectory() {
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", "default"),
STORED_SESSION_ID,
)
viewModel.updateSessionActivityDirectory(
rows = listOf("default" to STORED_SESSION_ID),
)
}
private fun activeSessionPayload(status: String) = buildJsonObject {
put("sessions", buildJsonArray {
add(buildJsonObject {
put("id", "live-resumed")
put("session_key", STORED_SESSION_ID)
put("status", status)
put("last_active", 1.0)
})
})
}
private fun persistedAnswerHistory(
answer: String = BACKGROUND_ANSWER,
id: String = "persisted-background-answer",
@@ -318,10 +318,14 @@ class GatewayProcessControllerTest {
controller.bind(source, "same-id", scopeKey = "profile-a")
controller.sessionReady("same-id")
runCurrent()
assertTrue(controller.ownsSnapshot("same-id", "profile-a"))
controller.selectSession("same-id", scopeKey = "profile-b")
assertFalse(controller.ownsSnapshot("same-id", "profile-a"))
assertFalse(controller.ownsSnapshot("same-id", "profile-b"))
controller.sessionReady("same-id")
runCurrent()
assertTrue(controller.ownsSnapshot("same-id", "profile-b"))
oldResult.complete(Result.success(listOf(process(id = "old-profile"))))
runCurrent()
@@ -0,0 +1,174 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class GitStateExtrasViewModelTest {
private val ownerKey = "connection-a\u0000default\u0000dashboard"
private val mainDispatcher = UnconfinedTestDispatcher()
private lateinit var application: Application
private lateinit var server: MockWebServer
@Before
fun setUp() {
Dispatchers.setMain(mainDispatcher)
application = ApplicationProvider.getApplicationContext()
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
private fun viewModel(grant: Boolean = true): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
vm.setWriteGrant(ownerKey, grant)
return vm
}
private fun enqueueJson(body: String) {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(body),
)
}
private fun selectAlpha(vm: GitStateViewModel) {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
vm.selectRepo("alpha")
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
repeat(3) { server.takeRequest() }
}
// ── AI commit message (magic-wand) ─────────────────────────────────────
@Test
fun `generate commit message pre-fills the suggestion via selected paths`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson("""{"message":"feat: add feature","notice":""}""")
vm.generateCommitMessage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
}
assertEquals("feat: add feature", state.message)
assertEquals("", state.notice)
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/commit_message_selected"))
assertTrue(req.body.readUtf8().contains("a.txt"))
}
@Test
fun `generate message without grant is refused before any POST`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.generateCommitMessage(null)
val state = withTimeout(5_000) {
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
}
assertTrue(state.notice.contains("plugin.api.write"))
assertEquals(3, server.requestCount)
}
@Test
fun `empty staged diff surfaces notice without error`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson("""{"message":"","notice":"nothing staged"}""")
vm.generateCommitMessage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
}
assertEquals("", state.message)
assertEquals("nothing staged", state.notice)
}
// ── Push-after-commit toggle ───────────────────────────────────────────
@Test
fun `push after commit defaults off and toggles`() {
val vm = viewModel()
assertTrue(!vm.isPushAfterCommitEnabled())
vm.setPushAfterCommit(true)
assertTrue(vm.isPushAfterCommitEnabled())
vm.setPushAfterCommit(false)
assertTrue(!vm.isPushAfterCommitEnabled())
}
// ── Stash-checkout ─────────────────────────────────────────────────────
@Test
fun `stash checkout surfaces the stash notice on success`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson(
"""{"head":"abc","stashed":true,"stash_message":"git-state: feature","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
)
// refreshDetail fires two reads (status + branches).
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
vm.stashCheckout("feature")
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
val notice = withTimeout(5_000) { vm.stashNotice.first { it != null } }!!
assertTrue(notice.contains("git-state: feature"))
assertTrue(notice.contains("git stash pop"))
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/stash_checkout"))
assertTrue(req.body.readUtf8().contains("feature"))
}
@Test
fun `stash checkout without grant is refused before any POST`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.stashCheckout("feature")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("plugin.api.write"))
assertEquals(3, server.requestCount)
}
@Test
fun `clean stash checkout yields no stash notice`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueueJson(
"""{"head":"abc","stashed":false,"stash_message":"","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
)
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
vm.stashCheckout("feature")
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
// No stash notice for a clean checkout.
assertEquals(null, vm.stashNotice.value)
}
}
@@ -0,0 +1,186 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class GitStateViewModelTest {
private val ownerKey = "connection-a\u0000default\u0000dashboard"
private val mainDispatcher = UnconfinedTestDispatcher()
private lateinit var application: Application
private lateinit var server: MockWebServer
@Before
fun setUp() {
Dispatchers.setMain(mainDispatcher)
application = ApplicationProvider.getApplicationContext()
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
private fun viewModel(): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
return vm
}
private fun enqueueJson(body: String) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body),
)
}
@Test
fun `loadRepos maps repo list and notice`() = runBlocking {
enqueueJson(
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":false}],"notice":null}""",
)
val vm = viewModel()
val state = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
}
assertEquals(1, state.repos.size)
assertEquals("alpha", state.repos.single().name)
assertNotNull(vm.repos.value)
}
@Test
fun `loadRepos surfaces server error`() = runBlocking {
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository"}"""))
val vm = viewModel()
val state = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Error>().first()
}
assertTrue(state.message.isNotBlank())
}
@Test
fun `loadRepos maps missing plugin route to friendly unavailable state`() = runBlocking {
server.enqueue(
MockResponse()
.setResponseCode(404)
.setBody("""{"detail":"No such API endpoint"}"""),
)
val vm = viewModel()
val state = withTimeout(5_000) {
vm.repos.filterIsInstance<GitStateUiState.Unavailable>().first()
}
assertEquals("Git isn't available on this Hermes host yet.", state.message)
}
@Test
fun `selectRepo loads status and branches and preserves truncation flag`() = runBlocking {
enqueueJson(
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""",
)
// status
enqueueJson(
"""{"counts":{"staged":1,"modified":2,"untracked":3},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":true}""",
)
// branches
enqueueJson(
"""{"branches":[{"name":"main","upstream":"origin/main","ahead":1,"behind":0,"is_current":true}]}""",
)
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("alpha")
val ready = withTimeout(5_000) {
vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first()
}
assertEquals(1, ready.status.counts.staged)
assertEquals(2, ready.status.counts.modified)
assertEquals(3, ready.status.counts.untracked)
assertEquals(-1, ready.status.counts.changes)
assertEquals(0, ready.status.counts.additions)
assertEquals(0, ready.status.counts.deletions)
assertNull(ready.status.staged.single().additions)
assertTrue(ready.status.truncated)
assertEquals("main", ready.branches.single().name)
assertTrue(ready.branches.single().isCurrent)
}
@Test
fun `loadDiff surfaces truncated diff`() = runBlocking {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
enqueueJson("""{"counts":{"staged":0,"modified":1,"untracked":0},"staged":[],"modified":[{"path":"a.txt"}],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
enqueueJson("""{"path":"a.txt","kind":"unstaged","diff":"+change","truncated":true}""")
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("alpha")
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
vm.loadDiff("a.txt", "unstaged")
val content = withTimeout(5_000) {
vm.content.filterIsInstance<GitContentViewState.Diff>().first()
}
assertEquals("a.txt", content.diff.path)
assertTrue(content.diff.truncated)
assertTrue(content.diff.diff.contains("change"))
}
@Test
fun `loadFile surfaces content and truncation`() = runBlocking {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":1},"staged":[],"modified":[],"untracked":[{"path":"new.txt"}],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
enqueueJson("""{"path":"new.txt","content":"hello world","truncated":false}""")
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("alpha")
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
vm.loadFile("new.txt")
val content = withTimeout(5_000) {
vm.content.filterIsInstance<GitContentViewState.File>().first()
}
assertEquals("hello world", content.file.content)
assertFalse(content.file.truncated)
}
@Test
fun `selectRepo surfaces status error for unknown repo`() = runBlocking {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository: bogus"}"""))
enqueueJson("""{"branches":[]}""")
val vm = viewModel()
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.selectRepo("bogus")
val error = withTimeout(5_000) {
vm.detail.filterIsInstance<GitRepoDetailState.Error>().first()
}
assertTrue(error.message.contains("unknown repository"))
}
}
@@ -0,0 +1,272 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class GitStateWriteViewModelTest {
private val ownerKey = "connection-a\u0000default\u0000dashboard"
private val mainDispatcher = UnconfinedTestDispatcher()
private lateinit var application: Application
private lateinit var server: MockWebServer
@Before
fun setUp() {
Dispatchers.setMain(mainDispatcher)
application = ApplicationProvider.getApplicationContext()
server = MockWebServer().apply { start() }
}
@After
fun tearDown() {
server.shutdown()
Dispatchers.resetMain()
}
private fun viewModel(grant: Boolean = true): GitStateViewModel {
val vm = GitStateViewModel(application)
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
vm.setWriteGrant(ownerKey, grant)
return vm
}
private fun enqueueJson(body: String) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body),
)
}
/** Loads the repo list + selects ``alpha`` so a mutation has a target. */
private fun selectAlpha(vm: GitStateViewModel) {
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
vm.selectRepo("alpha")
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
// Drain the three read requests (repos/status/branches) so the next
// takeRequest() returns the mutation POST we actually assert on.
repeat(3) { server.takeRequest() }
}
private fun enqueuePostSuccess(head: String) {
enqueueJson("""{"head":"$head","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}}""")
// refreshDetail fires two more requests (status + branches).
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
enqueueJson("""{"branches":[]}""")
}
// ── Grant gating (security first) ──────────────────────────────────────
@Test
fun `stage without write grant is refused before any POST`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.stage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("plugin.api.write"))
// No write POST was sent (only the 3 read requests for load/select).
assertEquals(3, server.requestCount)
}
@Test
fun `discard without write grant is refused`() = runBlocking {
val vm = viewModel(grant = false)
selectAlpha(vm)
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("plugin.api.write"))
assertEquals(3, server.requestCount)
}
// ── Happy paths ────────────────────────────────────────────────────────
@Test
fun `stage sends POST and surfaces success + fresh status`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc123")
vm.stage(listOf("a.txt"))
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
}
assertEquals("stage", state.label)
assertEquals("abc123", state.head)
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/stage"))
assertTrue(req.body.readUtf8().contains("a.txt"))
}
@Test
fun `commit sends message and returns head`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("deadbeef")
vm.commit("add feature")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
}
assertEquals("commit", state.label)
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/commit"))
assertTrue(req.body.readUtf8().contains("add feature"))
}
@Test
fun `commit success callback fires only after successful response`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
var committedTarget: GitTarget? = null
vm.commit("add feature") { committedTarget = it }
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertEquals("alpha", committedTarget?.repoId)
}
@Test
fun `commit failure never invokes success callback`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"failed"}"""))
var callbackCalled = false
vm.commit("add feature") { callbackCalled = true }
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Error>().first() }
assertFalse(callbackCalled)
}
@Test
fun `connection change revokes grant and rejects prior target`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
val priorTarget = vm.currentTarget()!!
enqueueJson("""{"repos":[]}""")
vm.configure(DashboardApiClient(server.url("/").toString()), "connection-b")
vm.setWriteGrant(ownerKey, true)
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
vm.push(GitConfirmationStrings.PUSH, expectedTarget = priorTarget)
assertFalse(vm.hasWriteGrant())
assertEquals(null, vm.currentTarget())
assertTrue(vm.mutation.value is GitMutationState.Error)
}
@Test
fun `discard echoes the fixed confirmation token`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/discard"))
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.DISCARD))
}
@Test
fun `push echoes the confirmation token`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
vm.push(GitConfirmationStrings.PUSH)
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
val req = server.takeRequest()
assertTrue(req.path!!.contains("/git/push"))
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.PUSH))
}
@Test
fun `fetch and pull send their endpoints`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
vm.fetch()
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertTrue(server.takeRequest().path!!.contains("/git/fetch"))
// Drain the two refreshDetail reads (status + branches) so the next
// takeRequest() sees only the pull POST.
repeat(2) { server.takeRequest() }
enqueuePostSuccess("xyz")
vm.pull("origin", "main")
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertTrue(server.takeRequest().path!!.contains("/git/pull"))
}
// ── Error branches ─────────────────────────────────────────────────────
@Test
fun `commit surfaces server error as readable message`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
server.enqueue(
MockResponse().setResponseCode(400).setBody("""{"detail":"commit message must not be empty"}"""),
)
vm.commit(" ")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("must not be empty"))
}
@Test
fun `discard wrong confirmation surfaces server 403`() = runBlocking {
val vm = viewModel()
selectAlpha(vm)
server.enqueue(
MockResponse().setResponseCode(403).setBody("""{"detail":"confirmation did not match"}"""),
)
vm.discard(listOf("a.txt"), "wrong")
val state = withTimeout(5_000) {
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
}
assertTrue(state.message.contains("confirmation") || state.message.contains("403"))
}
// ── Confirmation gating helpers ────────────────────────────────────────
@Test
fun `requiresConfirmation and confirmationFor match destructive ops`() {
val vm = viewModel()
assertTrue(vm.requiresConfirmation("discard"))
assertTrue(vm.requiresConfirmation("push"))
assertTrue(vm.requiresConfirmation("dirty-checkout"))
assertEquals(GitConfirmationStrings.DISCARD, vm.confirmationFor("discard"))
assertEquals(GitConfirmationStrings.PUSH, vm.confirmationFor("push"))
assertEquals(GitConfirmationStrings.DIRTY_CHECKOUT, vm.confirmationFor("dirty-checkout"))
assertEquals(null, vm.confirmationFor("commit"))
}
}
@@ -0,0 +1,90 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.SessionActivityOwner
import com.hermesandroid.relay.data.SessionLiveStatus
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionActivityResolutionTest {
private val alpha = SessionActivityOwner.of("connection", "alpha", "same")
private val beta = SessionActivityOwner.of("connection", "beta", "same")
@Test
fun `duplicate stored ids stay unresolved without exact runtime binding`() {
val result = resolveGatewayActiveSessions(
sessions = listOf(active("runtime-beta", GatewayActiveSessionStatus.Working)),
directory = setOf(alpha, beta),
currentOwner = alpha,
)
assertNull(result.runtimes.single().owner)
assertTrue(result.ambiguous)
assertTrue(result.ambiguousForCurrent)
}
@Test
fun `exact foreground runtime binding resolves duplicate stored id`() {
val result = resolveGatewayActiveSessions(
sessions = listOf(active("runtime-alpha", GatewayActiveSessionStatus.Waiting)),
directory = setOf(alpha, beta),
currentOwner = alpha,
currentRuntimeId = "runtime-alpha",
)
assertEquals(alpha, result.runtimes.single().owner)
assertEquals(SessionLiveStatus.Waiting, result.runtimes.single().status)
}
@Test
fun `unscoped stored id stays unresolved even when bounded directory looks unique`() {
val unique = SessionActivityOwner.of("connection", "beta", "unique")
val result = resolveGatewayActiveSessions(
sessions = listOf(
GatewayActiveSession(
runtimeSessionId = "runtime",
storedSessionId = "unique",
status = GatewayActiveSessionStatus.Starting,
lastActiveEpochSeconds = 1.0,
),
),
directory = setOf(alpha, unique),
currentOwner = alpha,
)
assertNull(result.runtimes.single().owner)
assertTrue(result.ambiguous)
}
@Test
fun `client known detached runtime resolves exact profile owner`() {
val unique = SessionActivityOwner.of("connection", "beta", "unique")
val result = resolveGatewayActiveSessions(
sessions = listOf(
GatewayActiveSession(
runtimeSessionId = "runtime",
storedSessionId = "unique",
status = GatewayActiveSessionStatus.Starting,
lastActiveEpochSeconds = 1.0,
),
),
directory = setOf(alpha, unique),
currentOwner = alpha,
knownOwnersByRuntime = mapOf("runtime" to unique),
)
assertEquals(unique, result.runtimes.single().owner)
assertEquals(SessionLiveStatus.Starting, result.runtimes.single().status)
}
private fun active(runtimeId: String, status: GatewayActiveSessionStatus) =
GatewayActiveSession(
runtimeSessionId = runtimeId,
storedSessionId = "same",
status = status,
lastActiveEpochSeconds = 1.0,
)
}
@@ -1,6 +1,9 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.assistant.AssistantSessionNotice
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -20,4 +23,35 @@ class VoiceCaptureGuardTest {
fun acceptsSettledShortUtterance() {
assertFalse(shouldDiscardVoiceCapture(durationMs = 420L, pcmBytes = 12_000))
}
@Test
fun noSpeechReturnsToRetryableIdleWithDurableFeedback() {
val state = voiceNoSpeechState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Transcribing,
transcribedText = "stale",
)
)
assertEquals(VoiceState.Idle, state.state)
assertEquals(AssistantSessionNotice.NoSpeech, state.assistantNotice)
assertNull(state.error)
assertNull(state.transcribedText)
assertTrue(state.voiceMode)
}
@Test
fun unrelatedCaptureCancellationDoesNotClaimNoSpeech() {
val state = voiceCaptureCancellationState(
VoiceUiState(
voiceMode = true,
state = VoiceState.Listening,
assistantNotice = AssistantSessionNotice.NoSpeech,
)
)
assertEquals(VoiceState.Idle, state.state)
assertNull(state.assistantNotice)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+13
View File
@@ -91,6 +91,19 @@ the `relay_plugin_draft` tool to create or replace a generated declarative page.
tool accepts the same bounded schema as Android, stores JSON atomically below
`HERMES_HOME/mobile-plugins`, and rejects every `action.request`. Generated previews
therefore cannot reach Relay management APIs or acquire executable backend behavior.
The contribution ID `git` is reserved for the Relay plugin's native Git workspace;
generated drafts cannot shadow or duplicate that route.
Android presents that reserved contribution as a first-class native surface rather
than a generic declarative page. When the live plugin API confirms Git is available,
Chat can show a compact branch/change rail and a dot-only context action; **Chat →
Show Git workspace in Chat** hides those two Chat affordances without disabling the
workspace. The full native workspace remains reachable from Settings and Plugins and
owns repository/branch selection, diffs, staging, confirmed destructive actions,
commits, and remotes. Session `git_repo_root`/`cwd` metadata selects an exact matching
repository when possible; ambiguous catalogs require an explicit user choice. Mobile
discovery alone is not treated as runtime readiness: a missing Git API route renders a
retryable unavailable state instead of exposing the raw Dashboard error.
The Relay mobile manifest exposes drafts as preview pages under the authenticated
`hermes-relay` plugin namespace. Android polls the catalog every five seconds while
+72
View File
@@ -2495,6 +2495,8 @@ boundary.
- Activation heartbeats let the main runtime clean up after assistant-process loss.
Finish and show-failure paths clear pending/watchdog state, while Full Voice
explicitly transfers ownership before the session overlay stops heartbeats.
A recreated session process requests the current activation-fenced voice
snapshot rather than treating its empty local state as authoritative.
- Connection, chat, and voice runtime ownership is application-lifetime in the
main process rather than Activity-owned. The assistant service may initialize
that graph and start a turn while no Activity exists; the app UI later binds
@@ -2508,6 +2510,9 @@ boundary.
- Background and locked-screen invocation is mediated by Android's selected
assistant UI/session rather than an ordinary background Activity launch.
- Locked assistant UI exposes only generic phase and retry status. Transcript,
response, route-specific errors, diagnostics, and screen context remain hidden
until the device is unlocked; no-speech retry copy is deliberately content-free.
- Users can leave Hermes selected for gesture/power-button invocation while
turning continuous KWS off, or remove Hermes through Android's Assistant
settings.
@@ -3910,3 +3915,70 @@ an upstream Hermes change, while vanilla/current Hermes retains a bounded
single-account fallback. Merely configuring a provider credential does not
expose tokens to paired devices. The Android UI can add providers without
adding provider-specific screens or silently treating missing data as zero usage.
---
## ADR 68 — Android session activity has one profile-scoped authority
**Status:** Accepted (2026-08-25).
**Context.** Dashboard and API-server session lists expose `is_active`, but
upstream defines it as an unended persisted row updated within the last five
minutes. It is useful recency metadata, not proof that a model turn is running.
Android nevertheless used it as a fallback for **Working**, while local
composer state, detached-turn checkpoints, pending requests, and drawer rows
each derived activity independently. A completed turn could therefore remain
Working, a restart could restore an unverified busy state, and an All Profiles
row could inherit another profile's live status through a bare session id.
Current upstream exposes live authority through the process-wide Gateway
`session.active_list`. It reports attachable in-memory runtimes as `starting`,
`working`, `waiting`, or `idle`, with both the live id and durable session key.
It accepts only an optional `current_session_id`; rows normally have no profile
metadata or filter. Exact pending-request events carry more specific
Needs-input ownership. Exact turn terminals and `session.info {running:false}`
can settle a matching generation. `process.list` is a different contract: a
background process may remain after its parent model turn is idle.
**Decision.** Android owns one composite activity registry keyed by stable
connection identity, normalized profile, and durable session id. Runtime ids
are aliases only within that owner. The same reducer drives drawer badges and
filters, visible composer state, animation, and accessibility.
The precedence is:
1. An exact pending approval, clarify, sudo, secret, or MCP request is **Needs input**.
2. A successfully resolved process-wide `session.active_list` row supplies
**Starting**, **Working**, or **Idle** to its exact client-owned profile
record. Waiting without an exact pending payload remains a conservative
needs-input state until the request detail arrives or clears.
3. An exact terminal event, `session.info {running:false}`, or
`session.activate {running:false}` settles only the matching runtime
generation.
4. A matching `process.list` row may add **Background work** independently; it
never keeps the conversation Working.
5. A checkpoint restored after process recreation is **Checking** until
revalidated. A failed or unsupported live refresh is **Unavailable**.
Android resolves each active-list row through exact foreground or detached
ownership already held by that client, or explicit profile metadata if a
future upstream sends it. A bounded REST directory never proves that a durable
`session_key` is globally unique. Ambiguous or unresolved rows apply no status.
Resolved rows from a partial snapshot may update their exact owners, but they
cannot infer absence. A missing row clears stale live state for a scope only
when the successful process-wide snapshot was complete and every relevant row
was unambiguously resolved. A failed refresh does not settle anything. REST
`is_active`, `last_active`, and relative timestamps never influence execution
state. Old socket generations, late refreshes, and unscoped session ids cannot
revive a newer settled entry.
**Consequences.** Working and Needs input describe current upstream-owned
runtime state instead of recent persistence. All Profiles remains isolated,
restart recovery is honest about uncertainty, and background processes stay
visible without mislabeling their parent turn. Older Gateways remain usable
but show Unavailable when no exact local terminal truth exists. Declarative
Gateway scenarios cover all four upstream states, complete-snapshot
disappearance, client-side profile isolation, and method-not-found; physical
and current-host certification remains tracked in `TODO.md`. An upstream
profile field/filter or explicitly owned aggregate activity route would remove
the remaining ambiguity for multi-profile clients.
+13
View File
@@ -38,6 +38,9 @@ the upstream contract identifiers it depends on.
| `scope_rejection_inputs` | Exact, foreign, and unscoped event inputs |
| `terminal_gap_activate` | Socket closes after live output; replacement `session.activate` reports `running=false`; history is authoritative |
| `terminal_gap_session_info` | Scoped `session.info {running:false}` settles a turn without `message.complete` |
| `active_status_lifecycle` | `session.active_list` reports starting, working, waiting, and idle, then a complete empty process-wide snapshot permits removal of unambiguously owned prior rows |
| `active_status_profile_scope` | A row has no profile metadata and a caller profile hint has no effect; the client must use exact client-held ownership and reject invented attribution |
| `active_status_unsupported` | An older Gateway returns JSON-RPC method-not-found; the client retains Unknown rather than inventing Idle or Working |
Fixture evidence is a bounded metadata-only ring. It records sequence,
connection number, RPC method, event type, scope classification, and outcome.
@@ -125,6 +128,16 @@ The check is source-only and non-mutating. It starts no runtime, creates no
sessions, and uses no provider or authentication credentials. It fails closed
for dirty, fork-marked, or non-vanilla checkouts.
For activity scenarios, the adapter confirms that current upstream owns
`session.active_list`, emits `starting`, `working`, `waiting`, and `idle`, lets
pending input outrank running work, accepts only `current_session_id` as its
optional selector, and returns both the live runtime id and durable session key
from the process-local registry. The runtime fixture then
tests client reconciliation, including successful disappearance and explicit
method-not-found behavior. Because rows normally carry no profile, partial
ownership resolution may update exact matches but cannot infer absence for an
unresolved scope. Source inspection alone does not claim a client pass.
## Planned extensions
The scenario format is intentionally usable by future official Desktop and TUI
+16 -16
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -30,7 +30,7 @@
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
},
"en": {
"native_name": "English",
@@ -44,11 +44,11 @@
}
},
"es": {
"native_name": "Español",
"native_name": "Espa\u00f1ol",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -65,14 +65,14 @@
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
},
"ja": {
"native_name": "日本語",
"native_name": "\u65e5\u672c\u8a9e",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -89,14 +89,14 @@
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
},
"pt-BR": {
"native_name": "Português (Brasil)",
"native_name": "Portugu\u00eas (Brasil)",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -113,14 +113,14 @@
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
},
"ru": {
"native_name": "Русский",
"native_name": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -131,11 +131,11 @@
}
},
"zh-Hans": {
"native_name": "简体中文",
"native_name": "\u7b80\u4f53\u4e2d\u6587",
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "e23ec83a5d90aaade995b271888ccfcecc9342447b446eef2d449077ffa3dd79",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -152,7 +152,7 @@
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
},
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
}
}
}
+6
View File
@@ -108,6 +108,12 @@
"title": "Start a conversation",
"source": "assets/screenshots/supplemental/14_startup.png",
"captureNote": "Current empty-chat chrome, idle visualization, model and effort controls, and public-safe suggestions."
},
{
"id": "15_git_workspace",
"title": "Native Git workspace",
"source": "assets/screenshots/supplemental/15_git_workspace.png",
"captureNote": "Real native Git workspace with public-safe repository status, line totals, filters, selection, inline diff, branch controls, commit action, and no live host data."
}
]
}
+2 -2
View File
@@ -141,7 +141,7 @@ The companion has a durable home and an optional autonomous roaming mode:
dragging itself remains finger-driven.
- Roaming requires an idle agent, the foreground app, enabled animation, and an
available safe rail. Agent activity, scrolling, dragging, an open pet menu,
startup, voice, clean/ambient mode, Android animator scale 0, or TalkBack touch
startup, voice, Android animator scale 0, or TalkBack touch
exploration stops autonomous travel. During scrolling, the pet follows its
current measured or synthetic habitat rail while it remains valid; it does
not re-dock or teleport. The IME/short-screen compact layout uses 50 dp base
@@ -676,7 +676,7 @@ fallback checks with a pack missing one or more optional rows:
opening and closing the keyboard moves that dock with the input rail without
hiding the pet or placing it over transcript content.
- [ ] Opening or closing the session drawer, command palette, model/reasoning/
context/process/agent sheets, clean mode, or either voice presentation keeps
context/process/agent sheets or either voice presentation keeps
the pet absent until the owning surface has fully closed. Slash autocomplete
and recent-prompt chips remain usable and never become walkable terrain.
- [ ] After a plain assistant response, it approaches through the outer gutter,

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