Compare commits

...
Author SHA1 Message Date
Bailey Dixon 52f3f75658 Merge pull request #591 from Codename-11/dev
release: Android 1.17.0 and Plugin 1.11.3
2026-09-13 21:39:36 -04:00
Bailey Dixon 1d25985aca Merge pull request #592 from Codename-11/release/android-1.17.0-play-notes
release(android): reconcile Chinese Play notes for 1.17.0
2026-09-13 21:38:29 -04:00
Bailey Dixon 500cc83de6 release(android): reconcile Chinese Play notes for 1.17.0 2026-09-13 21:26:31 -04:00
Bailey Dixon 7d3c761c6a Merge pull request #590 from Codename-11/release/android-1.17.0-plugin-1.11.3
release: prepare Android 1.17.0 and Plugin 1.11.3
2026-09-13 21:25:20 -04:00
Bailey Dixon 2e097035fb release(android): android-v1.17.0 and server-v1.11.3 2026-09-13 21:14:30 -04:00
Bailey Dixon 280c20dbca Merge pull request #585 from Codename-11/fix/android-batch-clarify
fix(android): support batch Clarify and simplify chat cards
2026-09-13 20:33:18 -04:00
Bailey Dixon 86a570e72d chore: refresh chat UI cleanup against dev 2026-09-13 20:13:40 -04:00
Bailey Dixon cbe811fb52 fix(android): simplify chat bubble surfaces and footers 2026-09-13 20:13:15 -04:00
Bailey Dixon ac2508c5ce Merge pull request #588 from Codename-11/fix/android-gateway-context-audit
fix(android): show Gateway context-sharing limitations
2026-09-13 16:04:24 -04:00
Bailey Dixon 2ad7de4cc9 fix(android): make injected context previews transport-aware 2026-09-13 13:36:50 -04:00
Bailey Dixon 5eb5d198f4 chore: refresh batch Clarify against current dev 2026-09-13 13:29:57 -04:00
Bailey Dixon 7a86b9cee7 Merge pull request #583 from nicolasestrem/fix/dashboard-ws-guard-compat
fix(dashboard): resolve relocated upstream WebSocket guards
2026-09-12 20:14:26 -04:00
Bailey Dixon 60f93994f5 chore: merge current dev into dashboard WebSocket guard fix 2026-09-12 20:12:06 -04:00
Bailey Dixon 52e0e38a81 chore: merge current dev into batch Clarify fix 2026-09-12 20:11:40 -04:00
Bailey Dixon 471595240b Merge pull request #584 from Codename-11/feature/play-voice-overlay
feat(android): add user-started Play voice overlay
2026-09-12 20:03:40 -04:00
Bailey Dixon 061512d330 fix(android): support upstream batch Clarify requests 2026-09-12 19:59:06 -04:00
Bailey Dixon 1c59f44ad7 fix(website): ignore release-only screenshot metadata drift 2026-09-12 19:53:48 -04:00
Bailey Dixon 269a2b5b36 fix(android): enforce revocation before voice handoff 2026-09-12 18:02:58 -04:00
Bailey Dixon a6879aac6f fix(android): fit overlay actions at large font sizes 2026-09-12 17:43:45 -04:00
Bailey Dixon a04930c946 feat(android): add user-started Play voice overlay 2026-09-12 17:43:01 -04:00
Bailey Dixon b71fea701f Merge pull request #581 from Codename-11/fix/android-profile-default-identity
fix(android): resolve profile identity and group server default
2026-09-12 16:54:23 -04:00
nicolasestrem af54cdddb3 fix(dashboard): resolve relocated upstream WebSocket guards
Preserve fail-closed admission and legacy compatibility. Add regression and real-upstream conformance tests plus transport documentation.
2026-09-12 18:21:33 +02:00
Bailey Dixon 1a6f9cc10b Merge pull request #580 from Codename-11/dev
release(android): 1.16.1
2026-09-12 09:54:40 -04:00
Bailey Dixon a717278e95 fix(android): guard unresolved profile assets and verify identity controls 2026-09-12 09:49:09 -04:00
Bailey Dixon 19d5237ebb chore: merge current dev into profile identity fix
# Conflicts:
#	CHANGELOG.md
2026-09-12 09:28:56 -04:00
Bailey Dixon 6fbb21f437 fix(android): resolve profile display identity and group server default 2026-09-12 09:28:33 -04:00
Bailey Dixon c7f54321ad Merge pull request #579 from Codename-11/release/android-1.16.1
release(android): android-v1.16.1
2026-09-12 09:26:59 -04:00
Bailey Dixon e6b9933d46 release(android): android-v1.16.1 2026-09-12 09:17:51 -04:00
Bailey Dixon c27ee439ef Merge pull request #578 from Codename-11/fix/android-gateway-directory-bootstrap
fix(android): release Gateway directory bootstrap
2026-09-12 08:55:33 -04:00
Bailey Dixon 68fce1b1da fix(android): release gateway directory bootstrap 2026-09-10 20:07:41 -04:00
Bailey Dixon 2865456761 Merge pull request #577 from Codename-11/dev
docs(release): reconcile 1.16.0 and 1.11.2 dates
2026-09-10 10:00:16 -04:00
Bailey Dixon 4547031bba Merge pull request #576 from Codename-11/docs/release-date-2026-09-10
docs(release): correct 1.16.0 and 1.11.2 dates
2026-09-10 09:52:19 -04:00
Bailey Dixon 41e9acf4c2 docs(release): correct 1.16.0 and 1.11.2 dates 2026-09-10 09:43:58 -04:00
Bailey Dixon a5f671c06c Merge pull request #575 from Codename-11/dev
release: Android 1.16.0 and Plugin 1.11.2
2026-09-10 09:33:47 -04:00
Bailey Dixon 5372fc1fef Merge pull request #574 from Codename-11/release/android-1.16.0
release(android): android-v1.16.0
2026-09-09 22:44:43 -04:00
Bailey Dixon 6c5ecbb028 release(android): android-v1.16.0 2026-09-09 22:32:25 -04:00
Bailey Dixon bd5a1c3335 Merge pull request #573 from Codename-11/release/plugin-1.11.2
release(server): server-v1.11.2
2026-09-09 22:25:27 -04:00
Bailey Dixon 2c740c9f04 release(server): server-v1.11.2 2026-09-09 22:23:26 -04:00
Bailey Dixon 3ec89680ed Merge pull request #572 from Codename-11/fix/android-endpoint-cache-race
fix(android): serialize endpoint probe invalidation
2026-09-09 22:04:10 -04:00
Bailey Dixon 42860d38cd chore: merge queued Android fixes for endpoint verification 2026-09-09 21:49:24 -04:00
Bailey Dixon ef4b3bdb6c Merge pull request #571 from Codename-11/fix/android-gateway-cold-start
fix(android): wake gateway on cold foreground
2026-09-09 21:48:53 -04:00
Bailey Dixon 44bbb16cd3 chore: merge current dev before gateway startup integration 2026-09-09 21:35:37 -04:00
Bailey Dixon 1f5b7e68fc Merge pull request #570 from Codename-11/fix/android-basic-auth-paste
fix(android): normalize pasted dashboard credentials
2026-09-09 21:35:00 -04:00
Bailey Dixon 4bb8d6fa7b chore: merge current dev for endpoint invalidation verification 2026-09-09 21:20:42 -04:00
Bailey Dixon 6395e73927 Merge remote-tracking branch 'origin/dev' into fix/android-basic-auth-paste
# Conflicts:
#	CHANGELOG.md
2026-09-09 21:20:33 -04:00
Bailey Dixon c956232b96 fix(android): serialize endpoint probe invalidation 2026-09-09 21:20:16 -04:00
Bailey Dixon 9814cdca55 chore: merge current dev for gateway startup verification 2026-09-09 21:17:51 -04:00
Bailey Dixon 0c337c9384 Merge pull request #569 from Codename-11/fix/plugin-availability-probe
fix(plugin): share relay availability checks
2026-09-09 21:06:46 -04:00
Bailey Dixon 273e3f5aff fix(android): wake gateway on cold foreground 2026-09-09 21:05:02 -04:00
Bailey DixonandJack Hunzicker 179080d6d9 fix(plugin): share relay availability checks
Co-authored-by: Jack Hunzicker <JackHunzicker@users.noreply.github.com>
2026-09-09 20:57:22 -04:00
Bailey Dixon ac1f42b7d5 Merge remote-tracking branch 'origin/dev' into fix/android-basic-auth-paste
# Conflicts:
#	CHANGELOG.md
2026-09-09 20:21:19 -04:00
Bailey Dixon 88591fca89 fix(android): normalize pasted dashboard credentials
Refs #541
2026-09-09 20:20:48 -04:00
Bailey Dixon 00c5f5daa2 Merge pull request #566 from trevornk/fix/connection-owned-dashboard-auth
fix(android): bind dashboard auth to connection-owned routes
2026-09-09 20:14:28 -04:00
Bailey Dixon 69b6c005cd chore: merge current dev for dashboard auth verification 2026-09-09 19:50:39 -04:00
Bailey Dixon c902215a15 Merge pull request #560 from JackHunzicker/contrib/git-state-callback-test-order
test(android): await git commit success callback
2026-09-09 19:37:25 -04:00
Bailey Dixon d39368bc51 Merge pull request #568 from Codename-11/fix/470-bot-mode-list-identity
fix(android): prevent duplicate Bot Mode keys across connections
2026-09-09 19:34:28 -04:00
Bailey Dixon 414dc08b9f Merge branch 'dev' into contrib/git-state-callback-test-order 2026-09-09 19:28:12 -04:00
Bailey Dixon 3b78a17bd7 chore: merge current dev for Bot Mode verification 2026-09-09 19:22:10 -04:00
Bailey Dixon a5efb9ec96 fix(android): scope Bot Mode item identity to connection and profile 2026-09-09 19:21:34 -04:00
Bailey Dixon e5228f2089 Merge pull request #567 from Codename-11/fix/android-detached-subagent-preview
fix(android): preserve chat activity and correct feedback ownership
2026-09-09 17:18:15 -04:00
Bailey Dixon c738a2c2c0 chore: merge current dev before activity integration 2026-09-09 17:05:38 -04:00
Bailey Dixon 8ed6b35e76 Merge pull request #555 from Codename-11/dependabot/gradle/dev/com.android.library-9.4.0
chore(deps): bump com.android.library from 9.3.2 to 9.4.0
2026-09-09 17:02:23 -04:00
Bailey Dixon c6d4a15226 Merge pull request #552 from Codename-11/dependabot/github_actions/dev/actions/cache-6
chore(deps): bump actions/cache from 5 to 6
2026-09-09 16:59:59 -04:00
Bailey Dixon 22c8f76c72 fix(android): preserve chat activity and correct feedback ownership 2026-09-09 16:57:40 -04:00
Bailey Dixon a0161f51ef Merge origin/dev into dependabot/gradle/dev/com.android.library-9.4.0 2026-09-09 16:52:49 -04:00
Bailey Dixon 7aa3b1c697 test(ci): align cache contract with v6 2026-09-09 16:49:59 -04:00
Bailey Dixon e7112dda9e Merge origin/dev into dependabot/github_actions/dev/actions/cache-6 2026-09-09 16:49:26 -04:00
trevornk c1413c494f fix(android): bind dashboard auth to connection-owned routes
Refs #565
2026-09-09 11:44:14 -05:00
Jack bb23e6ab48 test(android): await git commit success callback
Mutation success is published before detail refresh finishes and the callback runs. Await callback delivery explicitly instead of treating the state flow as a callback-completion barrier.

Signed-off-by: Jack <JLHunzicker@gmail.com>
2026-09-07 22:27:56 -05:00
dependabot[bot] 816638b46a chore(deps): bump com.android.library from 9.3.2 to 9.4.0
Bumps com.android.library from 9.3.2 to 9.4.0.

---
updated-dependencies:
- dependency-name: com.android.library
  dependency-version: 9.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-07 11:56:52 +00:00
dependabot[bot] df0fe59b0b chore(deps): bump coil from 3.6.0 to 3.6.2 (#554)
Bumps `coil` from 3.6.0 to 3.6.2.

Updates `io.coil-kt.coil3:coil-compose` from 3.6.0 to 3.6.2
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.6.0...3.6.2)

Updates `io.coil-kt.coil3:coil-gif` from 3.6.0 to 3.6.2
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.6.0...3.6.2)

Updates `io.coil-kt.coil3:coil-network-okhttp` from 3.6.0 to 3.6.2
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.6.0...3.6.2)

---
updated-dependencies:
- dependency-name: io.coil-kt.coil3:coil-compose
  dependency-version: 3.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.coil-kt.coil3:coil-gif
  dependency-version: 3.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.coil-kt.coil3:coil-network-okhttp
  dependency-version: 3.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 11:56:33 +00:00
dependabot[bot] df4d4c8f2a chore(deps): bump com.android.application from 9.3.2 to 9.4.0 (#553)
Bumps com.android.application from 9.3.2 to 9.4.0.

---
updated-dependencies:
- dependency-name: com.android.application
  dependency-version: 9.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 11:56:21 +00:00
dependabot[bot] 398ad253f1 chore(deps): bump actions/cache from 5 to 6
Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-07 11:54:44 +00:00
Bailey Dixon ad4bbb2c86 Merge pull request #543 from nicolasestrem/fix/android-bridge-token-fallback-final
fix(plugin): resolve bridge token after host startup
2026-09-03 21:28:08 -04:00
Bailey Dixon 7c5894213d fix(plugin): harden bridge credential recovery 2026-09-03 20:53:19 -04:00
nicolasestrem 82c088aa89 test(plugin): remove trailing blank line 2026-09-03 22:40:30 +02:00
nicolasestrem ed599995d6 fix(plugin): resolve bridge token from disk and make android_setup callable
Every android_* tool returned `401 Client Error: Unauthorized` on a phone
that was paired, connected and healthy (`/bridge/status` reported
phone_connected: true, accessibility_granted: true).

Cause: `_bridge_token()` read ANDROID_BRIDGE_TOKEN from the process
environment only. The env is snapshotted when the host starts, so a token
written afterwards — by `android_setup`, by `hermes-pair`, or by hand —
stayed invisible and `_require_bearer_session` rejected every bridge
dispatch until a full restart.

Resolution order is now env -> ~/.hermes/.env -> most-recently-seen paired
session in ~/.hermes/hermes-relay-sessions.json, with the disk fallbacks
cached for 30s so we don't stat two files on every bridge call. The process
environment still wins when set, and HERMES_HOME is honoured.

The documented recovery path was itself broken: android_setup's schema
required `pairing_code` while the signature had been renamed to
`bridge_session_token`, and the dispatcher calls func(**call_args) — so the
schema-conformant call raised TypeError and the canonical one was rejected
by the validator. The tool could not be invoked at all. Both spellings are
now accepted (canonical wins), neither is schema-required, a missing token
returns a structured error, and the description no longer mislabels the
value as a "6-character pairing code" or claims the tool performs pairing.

Also pin TestSetup to a temporary home. It exercises the real
android_setup, which persists ANDROID_BRIDGE_* to ~/.hermes/.env — running
the suite on a real host overwrote the machine's live paired session token
with a fixture value.

Verified: 43 passed (test_android_tool.py + test_android_tool_device_selector.py),
and against the physical device with ANDROID_BRIDGE_TOKEN unset from the
environment — /ping and /current_app both 200 via the new disk fallback.
2026-09-03 22:40:30 +02:00
Bailey Dixon 75feb55adf Merge pull request #540 from Codename-11/dev
release: Android 1.15.1 and CLI+UI 0.4.0-beta.7
2026-09-02 22:02:09 -04:00
Bailey Dixon e20540d16f Merge pull request #539 from Codename-11/release/patch-2026-09-02
release: prepare Android 1.15.1 and CLI+UI 0.4.0-beta.7
2026-09-02 21:43:43 -04:00
Bailey Dixon c932adbc7b release(desktop): desktop-v0.4.0-beta.7 2026-09-02 21:34:28 -04:00
Bailey Dixon 23cec497ce release(android): android-v1.15.1 2026-09-02 21:34:28 -04:00
Bailey Dixon 1739dc36a2 Merge pull request #538 from Codename-11/fix/android-voice-error-dialog
fix(android): polish voice errors and steer/queue controls
2026-09-02 21:21:05 -04:00
Bailey Dixon c917a94fcb fix(android): polish voice errors and steer/queue controls 2026-09-02 21:09:32 -04:00
Bailey Dixon e1a72ee8af Merge pull request #537 from Codename-11/fix/android-session-refresh-oom
fix(android): harden chat sessions and media rendering
2026-09-02 13:49:02 -04:00
Bailey Dixon 94f9ba4305 fix(android): harden chat sessions and media rendering 2026-09-02 13:34:14 -04:00
Bailey Dixon 3186afdde8 Merge pull request #536 from Codename-11/fix/android-attachment-viewer-rotation-salvage
fix(android): preserve attachment previews across rotation
2026-09-02 06:59:34 -04:00
Bailey Dixon f4b366389b release: Android 1.15.0, Plugin 1.11.1, CLI+UI 0.4.0-beta.6 (#525) 2026-08-31 23:45:58 -04:00
Bailey Dixon 2ec7b7aac9 release: Android 1.14.0 and Plugin 1.11.0 (#490) 2026-08-30 23:50:41 -04:00
274 changed files with 12824 additions and 2038 deletions
+1
View File
@@ -17,6 +17,7 @@ function classifyCiPaths(paths) {
android: forceAll || under(['app/', '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-capabilities.py', 'scripts/tests/check_android_capabilities_test.py',
'scripts/check-android-collection-apis.py', 'scripts/check-android-native-compat.py',
'scripts/check-android-release-notes.py',
'scripts/android_release_artifacts.py',
+5 -2
View File
@@ -115,8 +115,8 @@ assert.match(
/ref: \$\{\{ contains\(inputs\.version, '-'\) && 'dev' \|\| 'main' \}\}/,
);
assert.match(cliReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved CLI\+UI version/);
assert.match(cliReleaseWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
assert.match(desktopCiWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
assert.match(cliReleaseWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v6/);
assert.match(desktopCiWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v6/);
assert.match(pluginReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved Plugin version/);
assert.match(androidPreflightWorkflow, /Package immutable preflight artifacts/);
assert.match(androidApprovalWorkflow, /Android public approval accepts stable SemVer only/);
@@ -128,3 +128,6 @@ assert.match(releaseTrainWorkflow, /name: Hermes-Relay Coordinated Release Appro
assert.match(releaseTrainWorkflow, /Coordinated Android approval is stable-only/);
console.log('CI path classification tests passed.');
assert.deepEqual(classifyCiPaths(['scripts/check-android-capabilities.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/tests/check_android_capabilities_test.py']), { ...none, android: true });
+6
View File
@@ -166,6 +166,9 @@ jobs:
- name: Build debug APKs
run: ./gradlew assembleDebug --console=plain
- name: Verify Play capability manifest
run: python3 scripts/check-android-capabilities.py --variant googlePlayDebug
- name: Verify packaged native compatibility
run: |
python3 scripts/check-android-native-compat.py \
@@ -203,6 +206,9 @@ jobs:
- name: Build release bundles and APKs
run: ./gradlew bundleRelease assembleRelease --console=plain
- name: Verify Play release capability manifest
run: python3 scripts/check-android-capabilities.py --variant googlePlayRelease
- name: Scan release DEX for unsupported collection APIs
run: |
python3 scripts/check-android-collection-apis.py \
+10
View File
@@ -34,6 +34,8 @@ on:
- "scripts/check-android-locales.py"
- "scripts/android-locale-harness.py"
- "scripts/check-android-collection-apis.py"
- "scripts/check-android-capabilities.py"
- "scripts/tests/check_android_capabilities_test.py"
- "scripts/check-android-native-compat.py"
- "scripts/check-android-release-notes.py"
- "scripts/tests/check_android_native_compat_test.py"
@@ -73,6 +75,11 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
- name: Validate Play capability boundary
run: |
python3 scripts/check-android-capabilities.py
python3 -m unittest scripts.tests.check_android_capabilities_test
- name: Validate translation catalogs
run: python3 scripts/check-android-locales.py
@@ -117,6 +124,9 @@ jobs:
- name: Build debug APK
run: ./gradlew assembleDebug --console=plain
- name: Verify Play capability manifest
run: python3 scripts/check-android-capabilities.py --variant googlePlayDebug
- name: Verify packaged ONNX Runtime compatibility
run: |
python3 scripts/check-android-native-compat.py \
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Restore exact-source tray build cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
~/.cargo/registry/index
+5 -1
View File
@@ -111,4 +111,8 @@ jobs:
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
plugin/tests/test_mobile_plugin_store.py \
plugin/tests/test_android_tool.py \
plugin/tests/test_android_navigate.py \
plugin/tests/test_phone_platform.py \
plugin/tests/test_desktop_tool_availability.py
+18
View File
@@ -8,6 +8,15 @@ on:
- "assets/screenshots/03_voice.png"
- "assets/screenshots/06_manage.png"
- "docs/media/screenshots.json"
- "docs/media/desktop-ui-screenshots.json"
- "assets/screenshots/desktop-ui/**"
- "desktop/tray/ui/**"
- "desktop/tray/scripts/*.mjs"
- "desktop/tray/package-lock.json"
- "desktop/tray/index.html"
- "desktop/tray/icons/icon-256.png"
- "desktop/src/endpoint.ts"
- "desktop/src/transportSecurity.ts"
- ".github/workflows/ci-website.yml"
push:
branches: [main, dev]
@@ -17,6 +26,15 @@ on:
- "assets/screenshots/03_voice.png"
- "assets/screenshots/06_manage.png"
- "docs/media/screenshots.json"
- "docs/media/desktop-ui-screenshots.json"
- "assets/screenshots/desktop-ui/**"
- "desktop/tray/ui/**"
- "desktop/tray/scripts/*.mjs"
- "desktop/tray/package-lock.json"
- "desktop/tray/index.html"
- "desktop/tray/icons/icon-256.png"
- "desktop/src/endpoint.ts"
- "desktop/src/transportSecurity.ts"
- ".github/workflows/ci-website.yml"
permissions:
@@ -106,6 +106,9 @@ jobs:
:app:assembleSideloadRelease \
--console=plain
- name: Verify Play capability manifest
run: python3 scripts/check-android-capabilities.py --variant googlePlayRelease
- name: Scan final release DEX
run: |
python3 scripts/check-android-collection-apis.py \
+1 -1
View File
@@ -308,7 +308,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Restore exact-source tray build cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
~/.cargo/registry/index
+5 -1
View File
@@ -107,7 +107,11 @@ jobs:
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py \
plugin/tests/test_proactive_channel.py \
plugin/tests/test_android_phone_status.py
plugin/tests/test_android_phone_status.py \
plugin/tests/test_android_tool.py \
plugin/tests/test_android_navigate.py \
plugin/tests/test_phone_platform.py \
plugin/tests/test_desktop_tool_availability.py
package:
name: Build and publish Plugin package
+67 -7
View File
@@ -6,17 +6,77 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Changed
## [Android 1.17.0] - 2026-09-13
- **Android Chat and Voice use tablet space intentionally.** Expanded layouts keep introductions, transcripts, composer controls, and status chrome on readable centered rails, while landscape Voice Focus separates identity controls from conversation activity without changing phone or portrait interaction behavior.
### Added
- Optional voice controls over other apps in Google Play, with contextual permission setup, a persistent Stop voice notification, and session shutdown on screen lock or permission loss. Phone control remains sideload-only.
### Fixed
- **Android attachment previews survive rotation and preserve video proportions.** Full-screen image, video, audio, PDF, text, and file previews remain open while the chat reflows, respect the device rotation preference, and render portrait or landscape video through Media3's fitted content surface instead of a fixed 16:9 box. (#483)
- **Android wake-word JNI configuration survives release shrinking.** R8 now preserves sherpa-onnx configuration class and field names that the native keyword spotter resolves at runtime. (#444)
- **Android streams Standard Hermes attachments into its on-disk media cache.** Automatic Dashboard downloads no longer preallocate and duplicate the full response body on the OkHttp dispatcher, while declared and observed size limits remain enforced. (#531)
- **Android fresh Gateway chats wait for their upstream runtime before sending.** Android now consumes the lazy `session.create` readiness edge instead of racing the first prompt into compute-host ownership rejection. Genuine ownership refusals keep the exact holder-aware error and retryable local prompt instead of falling into unfinished-message history recovery.
- **Windows desktop updates keep the installed CLI and UI on one release.** `hermes-relay update` now detects a colocated management UI, reports both installed versions, and uses the verified bundle installer to replace and restart the affected surfaces together. Explicit CLI-only installations retain the standalone binary updater.
- Android shows standalone response cards without an outer bubble, uses subtler assistant surfaces, and places delivery status beside message timestamps.
- Android answers upstream Clarify batches one question at a time, with independent choices, custom answers, and confirmed progress preserved across reconnects. (#474)
- Android context previews mark phone status and turn context as unavailable in Gateway chats instead of claiming they are sent. Settings clarify that automatic phone-status sharing applies to API-only chats. (#556)
- Android shows Hermes profile display names and groups the resolved server default under its agent identity, while preserving explicit profile selection and saved conversations.
## [Plugin 1.11.3] - 2026-09-13
### Fixed
- Relay Dashboard WebSockets work with current Hermes authentication helpers while preserving older-host compatibility, single-use tickets, Host/Origin/IP checks, and Relay session authentication.
## [Android 1.16.1] - 2026-09-12
### Fixed
- Android Dashboard-only connections start the profile-scoped session directory before Gateway readiness, so a cold launch no longer leaves both the directory and passive Gateway socket waiting on each other. (#495, #528)
## [Android 1.16.0] - 2026-09-10
### Fixed
- Android no longer crashes when a route probe finishes while a network change invalidates the endpoint cache.
- Android opens an authenticated Gateway chat on the first foreground launch instead of waiting for a background-and-resume cycle to leave the waking state. (#495, #528)
- Android Dashboard sign-in removes pasted line breaks from username and password fields, matching the browser login while preserving every other credential character. (#541)
- Android keeps saved Dashboard sign-ins bound to their connection when switching gateways, rather than letting a stale resolver route invalidate another connection's session.
- Bot Mode no longer crashes when different connections have bots with the same profile name. Both the conversation list and Active Now strip preserve each bot's connection, and opening progress appears only on the selected bot.
- Android feedback uses themed banners and action cards instead of platform toasts and default snackbars. Dashboard errors no longer misidentify missing resources as an outdated Relay. Developer settings includes local-only message previews.
- Missing chat attachments show their error and retry in the attachment card without repeated global popups. Global action messages occupy the top message area instead of covering the composer.
- Chat distinguishes session preparation from response streaming and retains initialization errors that arrive before the session acknowledgement. Long-press the agent header to open a live session-diagnostics drawer.
- Delegated-agent activity survives parent replies and leaves compact history entries for later read-only review. The activity strip appears only while work runs; historical process views cannot stop or dismiss live work. (#447)
## [Plugin 1.11.2] - 2026-09-10
### Fixed
- **Relay tool availability avoids repeated Windows loopback delays and preserves multi-PC capabilities.** Host-local Android, Desktop, and Phone paths use explicit IPv4 loopback, while Desktop checks share a bounded health snapshot that preserves per-client advertisements and fails closed when Hermes-Relay is unavailable. (#562, #563)
- **`android_*` tools resolve bridge credentials written after host startup.** Requests retry profile-scoped env and active bridge-session credentials after a stale token is rejected, and vision navigation now shares the same current Relay transport instead of the retired standalone default.
- **`android_setup` accepts both its canonical and legacy schema keys.** `bridge_session_token` and `pairing_code` are accepted, while a missing token returns a structured error.
- **Android tool setup tests use a temporary Hermes home.** Test runs no longer write bridge settings into a developer environment.
## [Android 1.15.1] - 2026-09-02
### Changed
- Chat and Bot Chat offer a compact Correct now / Queue next tray behind the composer. Chat settings sets the default; each message can override it. Stop pauses pending work until Resume, and editing or removing queued messages preserves the remaining order.
- Wider Chat and Voice layouts keep text and controls centered and readable, including landscape Voice Focus.
### Fixed
- Delivery and correction labels remain readable inside user-message bubbles.
- Voice errors use a scrollable dialog with separate Retry and Dismiss actions.
- Attachment previews stay open through rotation, and videos retain their original proportions. (#483)
- Release builds preserve the native configuration names required for wake-word startup. (#444)
- Standard Hermes attachments stream to disk while enforcing download size limits. (#531)
- Session refresh no longer sustains a request loop. History loads, chat rendering, image previews, and media exports keep memory use bounded.
- Image-generation progress remains visible between interim replies and media delivery.
- New Gateway chats wait for session readiness before the first prompt; ownership refusals preserve the retryable prompt and server error.
## [0.4.0-beta.7] - 2026-09-02
### Fixed
- Windows updates detect a colocated management UI, report both installed versions, and update the CLI and UI together through the verified bundle installer. CLI-only installations keep their standalone updater.
## [Android 1.15.0] - 2026-08-31
+5 -9
View File
@@ -1,22 +1,18 @@
# Hermes-Relay CLI+UI v__VERSION__
**Release Date:** 2026-08-31
**Release Date:** 2026-09-02
This beta preserves complete multi-route pairing while preventing Desktop from dialing Dashboard-ingress Relay routes before Dashboard WebSocket ticket support is available. (Related: #399)
This beta fixes Windows updates so the installed CLI and management UI advance together. Explicit CLI-only installations keep their standalone update path.
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64; the management UI is Windows-only.
## What's changed
### Changed
- **Saved hosts retain the full route topology.** Dashboard, Relay, optional API, route priority, transport protection, certificate pins, and the selected host survive LAN, Tailscale, and public-route changes without creating duplicate hosts.
- **API-less pairing is first-class.** Dashboard plus direct Relay can pair without inventing an optional API server, while secure-first ranking retains plain LAN as the final fallback.
### Fixed
- **Dashboard-ingress Relay routes fail closed on Desktop.** The daemon, host selector, and Relay transport reject ingress that requires a Dashboard WebSocket ticket and choose a compatible direct Relay fallback instead of attempting an unauthenticated dial.
- **Pairing accepts the current v3 candidate shape.** Optional API records, same-origin Dashboard/Relay routes, and legacy top-level payloads remain compatible without collapsing route ownership.
- `hermes-relay update` detects an installed management UI beside the CLI and reports both installed versions.
- Bundle installations use the checksum-verified Windows installer to update and restart the affected CLI and UI together.
- Explicit CLI-only installations continue to use the standalone binary updater.
## Install
+3 -4
View File
@@ -1,15 +1,14 @@
# Hermes-Relay Plugin v__VERSION__
**Release Date:** August 31, 2026
**Release Date:** September 13, 2026
## Summary
This patch restores native installation compatibility on affected Hermes versions and makes Relay prompt context advertise only capabilities the selected session can actually call. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
Dashboard WebSocket connections work again with current Hermes authentication helpers, while older Hermes hosts remain supported.
## Fixed
- **Native installer compatibility.** The plugin keeps its complete current manifest while avoiding the installer/runtime schema mismatch that caused `manifest_version 2` installs to fail after an apparent Hermes update.
- **Capability-gated phone context.** Phone-control and cross-platform delivery guidance now follows the selected session/profile tool catalog instead of implying unavailable `android_*` or `send_message` callables.
- Resolve WebSocket guards from their current upstream module and retain the older-host fallback. Single-use tickets, Host/Origin/IP checks, and independent Hermes-Relay session authentication remain enforced. Missing or incomplete helper contracts deny admission.
## Install / update
+19 -23
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.15.0
# Hermes-Relay Android v1.17.0
**Release Date:** August 31, 2026
**Release Date:** September 13, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.15.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.17.0-sideload-release.apk` and tap it for the full feature set, or install 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,32 +12,28 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This release makes the everyday Android experience work through current upstream Hermes without treating the optional Hermes-Relay Plugin as a prerequisite. It also strengthens parent access, cross-client activity, transport ownership, recovery, and release visibility.
Google Play gains optional voice controls over other apps. This release also makes Clarify batches, profile identity, and chat context easier to follow while preserving confirmed answers and saved conversations.
## Added
- Start Voice Overlay from Voice Focus after granting microphone, notification, and display-over-other-apps access. Permission grants require a separate Start action. Stop voice from the overlay or persistent notification; screen lock, task removal, and permission loss end the session.
## Changed
- **Upstream-first standard surfaces.** Chat attachments, inbound host files, current-session Git reads, Nous usage bars, and keyed Hermes notices use authenticated Dashboard/Gateway contracts first. Relay remains an additive compatibility and enhancement layer.
- **Clear Settings ownership.** Media sits with Chat and Voice under Hermes. Proactive Threads, Terminal, Notification Companion, Relay sessions, enhanced voice, and Device Control stay grouped under Relay tools.
- **App-specific supervised parent access.** Parents choose a PIN or password and receive a six-word recovery phrase; Android device credentials and biometrics no longer grant parent authority.
- **Complete What's New history.** Release highlights, the remaining improvements and fixes, compatibility notes, toast counts, and history now come from one structured inventory.
- Standalone response cards use one surface, assistant bubbles are subtler, and timestamps share a row with delivery status.
## Fixed
- Host-local images, audio, video, and files download through the authenticated Dashboard and remain loaded across history reconciliation instead of flashing `Relay URL not configured` or returning to Loading.
- Standard voice remains usable after explicit Relay removal, while temporary Relay outages preserve configured choices and default-profile preferences stay isolated across connections.
- Android can display uniquely matched Desktop/TUI Working and Waiting activity without resuming, activating, or interrupting the external turn.
- Dashboard/Gateway chats preserve their transcript, draft, profile, and session through sign-out or outages instead of silently changing to Direct API.
- Completed text survives Dashboard-history authentication expiry and returns the existing sign-in recovery path.
- Long-running context compaction refreshes the turn watchdog instead of being interrupted as idle.
- Bot Chat history renders immediately after its route-owned handler binds.
- An exact idle live-session snapshot settles an Android-owned turn whose terminal frame was lost, reconciles history, and drains its queued follow-up.
- Supervised Add Gateway remains parent-owned across relock, back navigation, and pending setup cancellation.
- Completed generated images stay visible during marker persistence lag and retain the intended image-generation presentation.
- Answer upstream Clarify batches one question at a time, with independent choices, custom answers, and confirmed progress across reconnects. (#474)
- Context previews show that Gateway chats cannot send phone status or general turn context. Automatic phone-status sharing remains supported for API-only chats. (#556)
- Profiles display their Hermes names and group the resolved server default under its agent identity, preserving explicit selection and saved conversations.
## Install / Verify
- App version: **1.15.0** (versionCode **53**).
- Standard Chat, sessions, profiles, Manage, standard voice, outbound attachments, and ordinary inbound files work against current unmodified upstream Hermes without the optional Hermes-Relay Plugin.
- Install Hermes-Relay Plugin v1.11.1 for Terminal, proactive Threads/offline delivery, Notification Companion, Relay sessions, provider-native/realtime voice, compatibility media metadata, Secure Link, and phone/device control.
- Explicit Direct API/API-only connections remain supported and are never used as a silent failover for a Dashboard-owned chat.
- Granular Device Control and the system Voice Focus overlay remain sideload-only; the Google Play build does not declare their restricted permissions.
- App version: **1.17.0** (versionCode **57**).
- Standard Chat, sessions, profiles, Manage, voice, and ordinary media use current upstream Hermes. Speech-to-text still requires a configured provider on the host.
- Hermes-Relay Plugin **1.11.3** is the optional release for Hermes-Relay tools and current Dashboard WebSocket compatibility.
- Explicit Direct API/API-only connections remain supported and are not used as silent failover for Dashboard-owned chats.
- Voice Overlay is available in Google Play and sideload builds. Device Control remains sideload-only.
- Gateway phone-status delivery and automatic Android identification remain unavailable pending upstream support.
- Physical Android 14-16 and OEM voice-overlay testing was not performed for this release. Code, rendered UI, existing emulator evidence, CI, and signed-package preflight provide the recorded verification.
+2 -2
View File
@@ -100,9 +100,9 @@ android {
}
// ─── Bridge release tracks ─────────────────────────────────────────────────
// Google Play ships Bridge Core only: pairing, chat, voice, terminal/TUI,
// Google Play ships Bridge Core and user-started voice-only overlay: pairing, chat, voice, terminal/TUI,
// media, notification companion, relay sessions, and status. It does not
// declare AccessibilityService, overlay, MediaProjection, wake-lock device
// declare AccessibilityService, MediaProjection, wake-lock device
// control, SMS/call/contact/location, or unattended-control permissions.
//
// googlePlay — canonical Play Store install. Bridge Core only.
@@ -0,0 +1,100 @@
package com.hermesandroid.relay.network.shared
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.InterruptedIOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
@RunWith(AndroidJUnit4::class)
class EndpointResolverConcurrencyInstrumentedTest {
@Test
fun probeCompletionRacingInvalidation_staysCrashFreeOnAndroidCollections() = runBlocking {
repeat(25) { iteration ->
val candidateCount = 8
val requestsStarted = CountDownLatch(candidateCount)
val releaseRequests = CountDownLatch(1)
val raceGate = CountDownLatch(1)
val requestSequence = AtomicInteger(0)
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
if (requestSequence.incrementAndGet() <= candidateCount) {
requestsStarted.countDown()
releaseRequests.await(5, TimeUnit.SECONDS)
throw InterruptedIOException("instrumented invalidation race")
}
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("{}".toResponseBody())
.build()
}
.build()
val resolver = EndpointResolver(client)
val candidates = (1..candidateCount).map { index ->
EndpointCandidate(
role = "instrumented-$iteration-$index",
priority = 0,
api = ApiEndpoint(host = "127.0.0.1", port = 1, tls = false),
)
}
try {
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
resolver.resolve(candidates, EndpointSurface.Api)
}
assertTrue(requestsStarted.await(5, TimeUnit.SECONDS))
val invalidation = async(Dispatchers.Default) {
raceGate.await(5, TimeUnit.SECONDS)
resolver.clearCache()
}
val completions = async(Dispatchers.Default) {
raceGate.await(5, TimeUnit.SECONDS)
releaseRequests.countDown()
}
raceGate.countDown()
withTimeout(2_000L) {
invalidation.await()
completions.await()
staleResolve.await()
}
assertTrue(resolver.cacheSnapshot().isEmpty())
resolver.clearCache()
val freshWinner = withTimeout(2_000L) {
resolver.resolve(listOf(candidates.first()), EndpointSurface.Api)
}
assertEquals(candidates.first(), freshWinner)
assertTrue(
resolver.probeOutcomes.value.getValue(
EndpointResolver.cacheKey(candidates.first(), EndpointSurface.Api),
).reachable,
)
} finally {
raceGate.countDown()
releaseRequests.countDown()
client.dispatcher.executorService.shutdown()
}
}
}
}
@@ -0,0 +1,65 @@
package com.hermesandroid.relay.ui.screens
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
import com.hermesandroid.relay.ui.components.ChatDebugOverlay
import com.hermesandroid.relay.ui.components.chatDebugHeaderGesture
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class ChatDebugDrawerInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
@Test
fun longPressOpensDiagnosticsBelowHeaderAndCloseRestoresChat() {
compose.setContent {
var open by remember { mutableStateOf(false) }
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Box(Modifier.fillMaxSize()) {
Text("Hermes", Modifier.fillMaxWidth().height(64.dp).testTag("header")
.chatDebugHeaderGesture(true, onClick = {}, onHold = { open = true }))
ChatDebugOverlay(open, 64.dp, onClose = { open = false }) {
ChatDebugDrawer(
profile = "Server Default", model = "Example", sessionId = "session",
gateway = true, signedIn = true, signInRequired = false,
socketState = GatewayConnectionState.Ready, preparing = false,
streaming = false, loadingHistory = false, directoryUnavailable = false,
failure = null, onClose = { open = false }, onConnections = {},
)
}
}
}
}
val header = compose.onNodeWithTag("header")
val before = header.fetchSemanticsNode().boundsInRoot
header.performTouchInput { longClick() }
compose.onNodeWithText("Session diagnostics").assertIsDisplayed()
assertEquals(before, header.fetchSemanticsNode().boundsInRoot)
compose.onNodeWithContentDescription("Close session diagnostics").performClick()
compose.onNodeWithText("Session diagnostics").assertDoesNotExist()
header.assertIsDisplayed()
}
}
@@ -0,0 +1,183 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.data.projectChatActivityReceipts
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/** Real Gateway callbacks drive production activity surfaces through Android lifecycle changes. */
class ChatActivityReceiptInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: AndroidGatewayContractFixture
private lateinit var gatewayScope: CoroutineScope
private lateinit var gateway: GatewayChatClient
private lateinit var handler: ChatHandler
private lateinit var viewModel: ChatViewModel
private lateinit var socket: WebSocket
private val owner = AgentDisplay.profileContextKey("fixture-connection", "research")
@Before
fun setUp() {
fixture = AndroidGatewayContractFixture().also { it.profileName = "research" }
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val http = OkHttpClient()
gateway = GatewayChatClient(
initialDashboardClient = DashboardApiClient(fixture.server.url("/").toString().trimEnd('/'), okHttpClient = http),
okHttpClient = http,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
viewModel = ChatViewModel().also {
it.initialize(HermesApiClient(fixture.server.url("/").toString(), "fixture-key"), handler)
it.streamingEndpoint = "gateway"
it.setSessionProfileNameProvider { "research" }
it.setProfileMessageLoader { Result.success(emptyList()) }
it.setChatActivityStore(InMemoryChatActivityStore())
it.switchProfileContext(owner, STORED_SESSION_ID)
it.updateGatewayClient(gateway)
it.setChatVisible(true)
}
compose.setContent {
val messages by viewModel.messages.collectAsStateWithLifecycle()
val records by viewModel.activityRecords.collectAsStateWithLifecycle()
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
val retained by viewModel.retainedActivityPreview.collectAsStateWithLifecycle()
val childPreview by viewModel.subagentChildPreview.collectAsStateWithLifecycle()
val session by viewModel.currentSessionId.collectAsStateWithLifecycle()
var sheetOpen by remember { mutableStateOf(false) }
MaterialTheme {
Column {
GatewayBackgroundProcessStrip(
processes = emptyList(), subagentActivities = children,
subagentPreviewVisibility = SubagentPreviewVisibility(), loading = false,
onClick = { viewModel.openCurrentActivityPreview(); sheetOpen = true },
modifier = Modifier.testTag("active-activity"),
)
projectChatActivityReceipts(messages, records, owner, session).forEach { message ->
message.activityRecord?.let { record ->
ChatActivityReceipt(
record = record,
onClick = { sheetOpen = viewModel.openRetainedActivity(record) },
modifier = Modifier.testTag("activity-receipt"),
)
}
}
}
if (sheetOpen) {
GatewayBackgroundProcessSheet(
processes = retained?.processes.orEmpty(),
subagentActivities = retained?.record?.previewActivities() ?: children,
subagentChildPreview = childPreview,
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = false, stoppingProcessIds = emptySet(),
onRefresh = viewModel::refreshBackgroundProcesses,
onStop = viewModel::stopBackgroundProcess,
onDismissProcess = viewModel::dismissBackgroundProcess,
onOpenSubagentChild = viewModel::openSubagentChildPreview,
onDismiss = { viewModel.closeActivityPreview(); sheetOpen = false },
readOnlyHistory = retained != null,
historyNotice = "Recorded activity. Available child history is read-only.",
)
}
}
}
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
socket = fixture.awaitServerSocket()
fixture.awaitRpc("session.resume")
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
gateway.shutdown()
gatewayScope.cancel()
fixture.shutdown()
}
@Test
fun detachedCompletionLeavesReopenableReceiptAcrossActivityResume() {
viewModel.sendMessage("Delegate a background task")
fixture.awaitRpc("prompt.submit")
socket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
socket.send(fixture.event("subagent.start", buildJsonObject {
put("subagent_id", "receipt-child")
put("delegation_id", "receipt-delegation")
put("task_count", 1)
put("goal", "Inspect activity lifecycle")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.subagentActivities.value.size == 1 }
compose.onNodeWithTag("active-activity").assertIsDisplayed()
socket.send(fixture.event("message.complete", buildJsonObject { put("text", "Launched") }, LIVE_SESSION_ID))
compose.waitUntil(5_000) { !handler.isStreaming.value }
compose.onNodeWithTag("active-activity").assertIsDisplayed()
socket.send(fixture.event("subagent.complete", buildJsonObject {
put("subagent_id", "receipt-child")
put("delegation_id", "receipt-delegation")
put("status", "completed")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.COMPLETE }
compose.onNodeWithTag("active-activity").assertDoesNotExist()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed().performClick()
compose.onNodeWithText("Chat activity").assertIsDisplayed()
compose.onNodeWithText("Recorded activity. Available child history is read-only.").assertIsDisplayed()
compose.onNodeWithText("Stop").assertDoesNotExist()
compose.onNodeWithContentDescription("Close activity preview").performClick()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed()
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.onNodeWithTag("active-activity").assertDoesNotExist()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed().performClick()
compose.onNodeWithText("Chat activity").assertIsDisplayed()
}
private companion object {
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val LIVE_SESSION_ID = "fixture-live-1"
}
}
@@ -0,0 +1,116 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.ui.components.MessageBubble
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/** Production socket, ViewModel, transcript, Compose and IME actions on a virtual device. */
class ClarifyBatchInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: AndroidGatewayContractFixture
private lateinit var scope: CoroutineScope
private lateinit var gateway: GatewayChatClient
private lateinit var viewModel: ChatViewModel
private lateinit var handler: ChatHandler
private lateinit var socket: WebSocket
@Before fun setUp() {
fixture = AndroidGatewayContractFixture()
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val http = OkHttpClient()
gateway = GatewayChatClient(
initialDashboardClient = DashboardApiClient(fixture.server.url("/").toString().trimEnd('/'), http),
okHttpClient = http, scope = scope,
callbackDispatcher = { Handler(Looper.getMainLooper()).post(it) },
)
handler = ChatHandler().also { it.setSessionId("20260821_120000_fixture") }
viewModel = ChatViewModel().also {
it.initialize(HermesApiClient(fixture.server.url("/").toString(), "fixture-key"), handler)
it.streamingEndpoint = "gateway"
it.setProfileMessageLoader { Result.success(emptyList()) }
it.updateGatewayClient(gateway)
}
compose.setContent {
val messages by handler.messages.collectAsStateWithLifecycle()
MaterialTheme {
LazyColumn(Modifier.fillMaxSize()) {
items(messages.size, key = { messages[it].id }) { index ->
MessageBubble(messages[index], showTimestamps = false,
onCardInput = viewModel::answerAsk, animationEnabled = false)
}
}
}
}
assertTrue(runBlocking { gateway.prewarmAwait("20260821_120000_fixture") })
socket = fixture.awaitServerSocket()
}
@After fun tearDown() {
viewModel.updateGatewayClient(null)
gateway.shutdown()
scope.cancel()
fixture.shutdown()
}
@Test fun confirmedProgressSurvivesLifecycleAndCustomAnswerUsesIme() {
compose.runOnIdle { viewModel.sendMessage("Ask two questions") }
fixture.awaitRpc("prompt.submit")
socket.send(fixture.event("clarify.request", Json.parseToJsonElement("""
{"request_id":"batch-device","questions":[
{"qid":"route/a","question":"Which route?","choices":["Canary","Immediate"]},
{"qid":"notes:b","question":"Anything else?","choices":null}
]}
""") as JsonObject, "fixture-live-1"))
compose.waitUntil(10_000) { viewModel.pendingAsk.value != null }
compose.onNodeWithText("Canary").performClick()
compose.waitUntil(10_000) { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "Canary" }
assertEquals(JsonPrimitive("route/a"), fixture.awaitRpc("clarify.respond")["question_id"])
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.onNodeWithText("Question 2 of 2").assertIsDisplayed()
compose.onNodeWithContentDescription("Type an answer…").apply {
performClick()
performTextInput(" Keep rollback ready ")
performImeAction()
}
compose.waitUntil(10_000) { viewModel.pendingAsk.value == null }
compose.onNodeWithText("All questions answered").assertIsDisplayed()
assertEquals(2, fixture.rpcCount("clarify.respond"))
assertEquals(1, fixture.rpcCount("prompt.submit"))
}
}
@@ -15,6 +15,10 @@ import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.platform.app.InstrumentationRegistry
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.MessageRole
@@ -171,6 +175,81 @@ class GatewayExternalFixtureInstrumentedTest {
}
}
@Test
fun queuedStopResume_preservesWorkAcrossLifecycleAndUsesExplicitResume() {
val base = InstrumentationRegistry.getArguments().getString(ARG_FIXTURE_BASE_URL)?.trimEnd('/')
assumeTrue("Pass a queued_stop_resume fixture URL", !base.isNullOrBlank())
requireNotNull(base)
val http = OkHttpClient.Builder().callTimeout(10, TimeUnit.SECONDS).build()
assertEquals("queued_stop_resume", readFixtureJson(http, "$base/__fixture__/state")["scenario"]?.jsonString())
val dashboard = DashboardApiClient(base, http)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { gatewayScope = it }
val gateway = GatewayChatClient(
initialDashboardClient = dashboard, okHttpClient = http, scope = scope,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
reconnectJitterUnit = { 0.0 },
).also { gatewayClient = it }
val handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
val vm = ChatViewModel().also {
it.initialize(null, handler)
it.streamingEndpoint = "gateway"
it.setProfileMessageLoaderWithMode { profile, session, mode -> dashboard.getSessionMessages(session, profile, mode) }
it.updateGatewayClient(gateway)
it.switchProfileContext(com.hermesandroid.relay.data.AgentDisplay.profileContextKey("fixture-queue", null), STORED_SESSION_ID)
}.also { viewModel = it }
compose.setContent {
val queue by vm.queuedMessages.collectAsStateWithLifecycle()
val paused by vm.queuePaused.collectAsStateWithLifecycle()
com.hermesandroid.relay.ui.theme.HermesRelayTheme(themePreference = "dark") {
androidx.compose.material3.Surface {
Column {
com.hermesandroid.relay.ui.components.ChatBusyActionSelector(
com.hermesandroid.relay.data.BusyMessageAction.QueueNext, {}, onStop = vm::cancelStream,
)
com.hermesandroid.relay.ui.components.ChatMessageQueue(
queue, paused, vm::resumeQueue, vm::clearQueue, {}, vm::removeQueuedAt, canEdit = true,
)
}
}
}
}
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
compose.runOnIdle { vm.sendMessage("Original work") }
compose.waitUntil(10_000) { handler.isStreaming.value && vm.steerableTurn.value }
compose.runOnIdle {
vm.sendMessage("Remove this follow-up", com.hermesandroid.relay.data.BusyMessageAction.QueueNext)
vm.sendMessage("Keep this follow-up", com.hermesandroid.relay.data.BusyMessageAction.QueueNext)
}
compose.onAllNodesWithContentDescription("Remove queued message")[0].performClick()
compose.onNodeWithContentDescription("Stop streaming").performClick()
compose.onNodeWithText("Queue paused").assertIsDisplayed()
assertEquals(listOf("Keep this follow-up"), vm.queuedMessages.value)
compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
compose.onNodeWithText("Keep this follow-up").assertIsDisplayed()
compose.onNodeWithText("Resume").performClick()
try {
compose.waitUntil(15_000) {
vm.queuedMessages.value.isEmpty() && !handler.isStreaming.value &&
handler.messages.value.any { it.content == "Resumed follow-up." }
}
} catch (error: androidx.compose.ui.test.ComposeTimeoutException) {
throw AssertionError(
"Synthetic queue fixture did not settle: queued=${vm.queuedMessages.value.size}, " +
"paused=${vm.queuePaused.value}, streaming=${handler.isStreaming.value}, " +
"messages=${handler.messages.value.map { it.role to it.content }}, " +
"error=${handler.error.value}",
error,
)
}
val evidence = readFixtureJson(http, "$base/__fixture__/evidence")["entries"] as JsonArray
assertEquals(2, evidence.rpcCount("prompt.submit"))
assertEquals(1, evidence.rpcCount("session.interrupt"))
assertEquals(0, evidence.rpcCount("session.redirect"))
assertEquals("gateway", vm.streamingEndpoint)
}
private fun JsonArray.rpcCount(method: String): Int = count { element ->
val entry = element as? JsonObject ?: return@count false
entry["kind"]?.jsonString() == "rpc" && entry["method"]?.jsonString() == method
@@ -5,11 +5,12 @@ import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.Modifier
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
@@ -17,23 +18,30 @@ import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.GatewayChatClient
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 com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import com.hermesandroid.relay.ui.screens.shouldOwnVisibleGateway
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -83,6 +91,8 @@ class GatewayForegroundRecoveryInstrumentedTest {
@Volatile
private var persistedHistory: List<MessageItem> = emptyList()
private val historySignInRequired = MutableStateFlow(false)
private val coldStartAdmissionEnabled = MutableStateFlow(false)
private val coldStartGatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
@Before
fun setUp() {
@@ -115,9 +125,31 @@ class GatewayForegroundRecoveryInstrumentedTest {
compose.setContent {
val messages by viewModel.messages.collectAsStateWithLifecycle()
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
val admissionEnabled by coldStartAdmissionEnabled.collectAsStateWithLifecycle()
val admissionAvailability by coldStartGatewayAvailability.collectAsStateWithLifecycle()
LaunchedEffect(admissionEnabled, admissionAvailability) {
if (admissionEnabled) {
viewModel.setChatVisible(
shouldOwnVisibleGateway(
appForeground = true,
isGatewayTransport = true,
gatewayAvailability = admissionAvailability,
),
)
}
}
MaterialTheme {
Column(Modifier.testTag("contract-transcript")) {
GatewayBackgroundProcessStrip(
processes = emptyList(),
subagentActivities = children,
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = false,
onClick = {},
modifier = Modifier.testTag("child-activity"),
)
Text(
text = if (streaming) "STREAMING" else "IDLE",
modifier = Modifier.testTag("stream-state"),
@@ -145,6 +177,122 @@ class GatewayForegroundRecoveryInstrumentedTest {
fixture.awaitRpc("session.resume")
}
@Test
fun authenticatedUnknownColdLaunch_opensObservationSocketWithoutLifecycleBounce() {
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val controlMethods = setOf(
"session.resume",
"session.activate",
"prompt.submit",
"session.interrupt",
)
val baseline = controlMethods.associateWith(fixture::rpcCount)
val ticketMintsBefore = fixture.requestsTo("/api/auth/ws-ticket")
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val okHttp = OkHttpClient()
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = okHttp,
),
okHttpClient = okHttp,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
viewModel.setChatTurnCheckpointStore(null)
viewModel.updateGatewayClient(gatewayClient)
coldStartGatewayAvailability.value = GatewayAvailability.Unknown
coldStartAdmissionEnabled.value = true
compose.waitUntil(5_000) {
gatewayClient.connectionState.value == GatewayConnectionState.Ready
}
serverSocket = fixture.awaitServerSocket()
assertEquals(ticketMintsBefore + 1, fixture.requestsTo("/api/auth/ws-ticket"))
controlMethods.forEach { method ->
assertEquals(
"cold observation sent $method",
baseline.getValue(method),
fixture.rpcCount(method),
)
}
}
@Test
fun dashboardOnlyColdLaunch_waitsForExactDirectoryThenOpensObservationSocket() {
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val directoryStarted = CompletableDeferred<Unit>()
val directoryResult = CompletableDeferred<Result<List<SessionItem>>>()
viewModel.setProfileSessionLister { profile ->
assertEquals(PROFILE_NAME, profile)
directoryStarted.complete(Unit)
directoryResult.await()
}
handler.setSessionId(null)
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("fixture-connection", PROFILE_NAME),
STORED_SESSION_ID,
)
val controlMethods = setOf(
"session.resume",
"session.activate",
"prompt.submit",
"session.interrupt",
)
val baseline = controlMethods.associateWith(fixture::rpcCount)
val ticketMintsBefore = fixture.requestsTo("/api/auth/ws-ticket")
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val okHttp = OkHttpClient()
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = okHttp,
),
okHttpClient = okHttp,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
viewModel.setChatTurnCheckpointStore(null)
viewModel.updateGatewayClient(gatewayClient)
viewModel.setChatVisible(true)
// This is the production binder's Dashboard/profile hydration edge.
// The socket must stay passive and closed until the exact-owner REST
// directory publishes, then open without a lifecycle bounce.
viewModel.refreshSessions()
compose.waitUntil(5_000) { directoryStarted.isCompleted }
assertEquals(ticketMintsBefore, fixture.requestsTo("/api/auth/ws-ticket"))
directoryResult.complete(
Result.success(listOf(SessionItem(id = STORED_SESSION_ID, title = "Fixture session"))),
)
compose.waitUntil(5_000) {
gatewayClient.connectionState.value == GatewayConnectionState.Ready
}
serverSocket = fixture.awaitServerSocket()
assertEquals(ticketMintsBefore + 1, fixture.requestsTo("/api/auth/ws-ticket"))
controlMethods.forEach { method ->
assertEquals(
"directory-gated cold observation sent $method",
baseline.getValue(method),
fixture.rpcCount(method),
)
}
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
@@ -153,6 +301,37 @@ class GatewayForegroundRecoveryInstrumentedTest {
fixture.shutdown()
}
@Test
fun detachedChildActivity_survivesParentTerminalAndActivityResume() {
viewModel.sendMessage("Delegate a background task")
fixture.awaitRpc("prompt.submit")
serverSocket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
serverSocket.send(fixture.event("subagent.start", buildJsonObject {
put("subagent_id", "detached-child")
put("goal", "Inspect")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.subagentActivities.value.size == 1 }
compose.onNodeWithTag("child-activity").assertIsDisplayed()
serverSocket.send(fixture.event("message.complete", buildJsonObject { put("text", "Launched") }, LIVE_SESSION_ID))
compose.waitUntil(5_000) { !handler.isStreaming.value }
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
serverSocket.send(fixture.event("subagent.progress", buildJsonObject {
put("subagent_id", "detached-child")
put("text", "Still working")
}, LIVE_SESSION_ID))
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.waitUntil(5_000) { viewModel.subagentActivities.value.single().events.last().text == "Still working" }
compose.onNodeWithTag("child-activity").assertIsDisplayed()
assertFalse(viewModel.subagentActivities.value.single().isTerminal)
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
serverSocket.send(fixture.event("subagent.complete", buildJsonObject {
put("subagent_id", "detached-child")
put("status", "completed")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.subagentActivities.value.single().isTerminal }
compose.onNodeWithTag("child-activity").assertDoesNotExist()
}
@Test
fun terminalGapActivate_recoversForegroundTurnWithoutNavigationOrCrossSessionLeak() {
viewModel.sendMessage("Run a long foreground task")
+4 -1
View File
@@ -8,7 +8,7 @@
Google Play ships Hermes Bridge Core only. It intentionally does not merge
any Device Control services or permissions.
This file is intentionally kept as an empty overlay so future flavor-specific
This overlay owns the voice-only special access so future flavor-specific
permissions / activities have an obvious home. Mirror structural additions
in `app/src/sideload/AndroidManifest.xml` unless the change is intentionally
track-specific.
@@ -22,6 +22,9 @@
android:name="android.permission.WAKE_LOCK"
tools:node="remove" />
<!-- User-started voice controls only; does not enable Device Control. -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application />
</manifest>
@@ -23,7 +23,7 @@ GOOGLE PLAY AND SIDELOAD
The Google Play build includes Chat, voice, sessions, Manage, profiles, notifications, media, and Terminal/TUI when the Hermes-Relay plugin is paired.
Google Play does not include Android Device Control. It cannot read the phone screen, tap, type, swipe, take device screenshots, send SMS, place calls, or access contacts or location.
Google Play does not include Android Device Control. It cannot tap, type, swipe, send SMS, place calls, or access contacts or location. Optional Voice Overlay provides user-started voice controls over other apps, with microphone notification and Stop voice. Selecting Hermes as Android Digital Assistant can provide bounded screen text and a screenshot for an explicit unlocked assistant invocation; this context goes to your configured server and AI provider.
Device Control is available only in the signed Sideload build on this project's GitHub Releases. It requires the Sideload app, a paired Hermes-Relay plugin, explicit Android accessibility permission, and the app's safety controls.
@@ -32,9 +32,6 @@ FEATURES
- Streaming Chat with reasoning, markdown, tool progress, attachments, mid-turn steering, edit-and-resend, and searchable commands.
- Manage models and provider keys, edit profiles, and browse, install, or update skills through the Hermes Dashboard.
- Hands-free voice through your server's speech providers. Hermes-Relay pairing adds per-profile voices and an experimental realtime engine.
- Create, switch, search, rename, pin, archive, and continue sessions.
- Connect multiple Hermes servers and switch in one tap; add LAN, Tailscale, or public routes.
- Pair the Hermes-Relay plugin for Terminal/TUI, notifications, media, enhanced voice, Relay sessions, and per-feature grants.
- Inspect connection readiness, routes, response timing, token usage, and stream health without exposing credentials.
SECURITY AND PRIVACY
@@ -1,3 +1,3 @@
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
v1.17.0 - Voice over other apps and clearer conversations
Standard Chat, Voice, attachments, returned files, current-session Git, usage, and Hermes notices now prefer upstream Dashboard and Gateway support without requiring Relay. Returned media stays loaded, voice survives Relay removal, and Settings clearly separates standard Hermes from Relay tools. Supervised Mode also gains app-specific parent access and recovery.
Use optional voice controls over other apps, with clear permission setup and an immediate Stop action. Answer Clarify batches one question at a time, with progress preserved across reconnects. Enjoy cleaner chat cards, recognizable profile names, and context previews that show what your connection supports. Phone control remains sideload-only.
@@ -1 +1,3 @@
新增监督模式:家长可配置并固定到指定配置文件,设置受设备身份验证保护。家长可限制附件、标准语音、生成媒体、历史记录、操作和技术详情。实时活动不可用时会话行保持中性显示,从家长设置返回时监督聊天也不再空白。
v1.17.0 - 跨应用语音与更清晰的对话
可选的语音悬浮控件让你在使用其他应用时继续语音会话,提供清晰的权限说明和随时停止操作。逐题回答 Clarify 批量问题,重连后保留已确认的进度。聊天卡片更简洁,配置文件显示名称更易辨认,上下文预览准确说明当前连接支持发送哪些信息。设备控制仍仅限侧载版本。
+190
View File
@@ -1,6 +1,196 @@
{
"schema": 3,
"versions": [
{
"version": "1.17.0",
"title": "Voice over other apps and clearer conversations",
"date": "2026-09-13",
"summary": "Google Play gains optional voice controls over other apps. Clarify questions, profile names, and chat context are easier to follow without losing confirmed answers or saved conversations.",
"changes": [
{"id": "play-voice-overlay", "kind": "added", "title": "Keep voice controls over other apps", "summary": "Start Voice Overlay from Voice Focus after granting microphone, notification, and display-over-other-apps access. Stop voice from its controls or persistent notification; screen lock and permission loss end the session.", "highlight": true},
{"id": "clarify-batches", "kind": "fixed", "title": "Answer Clarify questions one at a time", "summary": "Multi-question requests support separate choices and custom answers. Confirmed progress survives reconnects, and failed sends keep the current answer available.", "highlight": true},
{"id": "chat-card-surfaces", "kind": "improved", "title": "Read cleaner chat cards", "summary": "Standalone response cards lose the extra outer bubble, assistant messages use quieter surfaces, and delivery status sits beside the timestamp."},
{"id": "transport-context-preview", "kind": "fixed", "title": "See which context your chat can send", "summary": "Gateway previews mark phone status and turn context as unavailable. Automatic phone-status sharing is labeled for API-only chats, where it remains supported."},
{"id": "profile-display-names", "kind": "fixed", "title": "Recognize profiles by their display names", "summary": "Profiles use their Hermes display names, and the resolved server default appears under its agent identity. Explicit profile choices and saved conversations are preserved.", "highlight": true}
],
"compatibility": [
"Voice Overlay is now available in Google Play and sideload builds. Device Control remains sideload-only.",
"Standard Chat and voice use upstream Hermes without requiring Hermes-Relay Plugin. Gateway phone-status delivery and automatic Android identification remain unavailable."
],
"playNotes": "Use optional voice controls over other apps, with clear permission setup and an immediate Stop action. Answer Clarify batches one question at a time, with progress preserved across reconnects. Enjoy cleaner chat cards, recognizable profile names, and context previews that show what your connection supports. Phone control remains sideload-only.",
"sections": []
},
{
"version": "1.16.1",
"title": "Dashboard-only cold starts recover",
"date": "2026-09-12",
"summary": "Dashboard-only connections can now prepare the selected profile and open Gateway chat without waiting for a background, resume, or network-route change.",
"changes": [
{
"id": "gateway-directory-bootstrap",
"kind": "fixed",
"title": "Open Gateway chat from a Dashboard-only cold start",
"summary": "The selected profile's session directory starts before Gateway readiness, so it cannot wait on the same passive socket that depends on its result.",
"highlight": true
}
],
"compatibility": [
"Standard Dashboard and Gateway chat continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.",
"Hermes-Relay Plugin 1.11.2 remains the matching optional release for Relay tools."
],
"playNotes": "Dashboard-only connections now prepare the selected profile before Gateway readiness, fixing a remaining cold-start path that could stay on waking or waiting for Gateway until the app resumed or its network route changed.",
"sections": []
},
{
"version": "1.16.0",
"title": "Safer startup, connections, and activity",
"date": "2026-09-10",
"summary": "Gateway chat opens reliably from a cold launch, saved sign-ins stay with the correct connection, and network changes no longer race the route cache. Bot Mode and delegated-work feedback also remain stable across multiple gateways and later review.",
"changes": [
{
"id": "gateway-cold-start",
"kind": "fixed",
"title": "Open Gateway chat on the first launch",
"summary": "An authenticated Gateway wakes and opens from a cold foreground start instead of waiting for the app to background and resume.",
"highlight": true
},
{
"id": "connection-owned-signin",
"kind": "fixed",
"title": "Keep saved sign-ins with their connection",
"summary": "Switching gateways cannot reuse an outgoing resolver route to invalidate another connection's saved Dashboard session.",
"highlight": true
},
{
"id": "network-change-invalidation",
"kind": "fixed",
"title": "Recover safely when the network changes",
"summary": "Route-probe completion and endpoint-cache invalidation are serialized so Wi-Fi, mobile-data, VPN, or Tailscale changes do not trigger the reported crash.",
"highlight": true
},
{
"id": "bot-mode-connection-identity",
"kind": "fixed",
"title": "Open same-named bots from multiple gateways",
"summary": "Bot Mode and Active Now keep connection and profile identity together, avoiding duplicate list keys and opening progress on the selected bot."
},
{
"id": "delegated-activity-receipts",
"kind": "improved",
"title": "Review delegated work after it finishes",
"summary": "Compact, bounded activity receipts survive parent replies and remain available read-only, while the live strip appears only during active work.",
"highlight": true
},
{
"id": "chat-feedback-surfaces",
"kind": "improved",
"title": "Keep feedback with the surface that owns it",
"summary": "Themed banners and action cards replace platform popups, global actions stay clear of the composer, and local Developer previews make feedback states inspectable."
},
{
"id": "attachment-error-recovery",
"kind": "fixed",
"title": "Retry missing attachments in place",
"summary": "A missing attachment keeps its error and Retry action in the attachment card without producing repeated global messages."
},
{
"id": "session-preparation-diagnostics",
"kind": "improved",
"title": "See session preparation and initialization failures",
"summary": "Chat distinguishes session preparation from response streaming, retains early initialization errors, and opens session diagnostics from the agent header."
},
{
"id": "pasted-dashboard-credentials",
"kind": "fixed",
"title": "Paste Dashboard credentials without hidden line breaks",
"summary": "Username and password fields remove pasted carriage returns and line feeds while preserving every other credential character."
}
],
"compatibility": [
"Standard Chat, sessions, profiles, Manage, voice, Bot Mode, and delegated activity continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.",
"Hermes-Relay Plugin 1.11.2 is the matching optional release for Relay tools. Existing erased or revoked Dashboard credentials still require a legitimate sign-in.",
"Granular Device Control and the system Voice Focus overlay remain sideload-only."
],
"playNotes": "Gateway chat now opens reliably on a cold launch. Saved Dashboard sign-ins stay bound to the correct connection, pasted credentials ignore accidental line breaks, and network changes no longer race the route cache. Bot Mode supports duplicate profile names across gateways, while delegated work, session setup, attachment errors, and feedback remain visible and easier to review.",
"sections": []
},
{
"version": "1.15.1",
"title": "Steadier chat, media, and voice",
"date": "2026-09-02",
"summary": "Chats use less memory, attachment previews stay in place, and voice failures are easier to recover from. Follow-up controls make it clear whether a message changes the current response or waits for the next turn.",
"changes": [
{
"id": "follow-up-controls",
"kind": "improved",
"title": "Choose when follow-up messages are sent",
"summary": "A slim tray behind the composer offers Correct now or Queue next. Chat settings sets the default, and a composer choice applies to one message. Stop pauses pending work until Resume; editing or removing an item keeps the remaining queue usable.",
"highlight": true
},
{
"id": "tablet-layouts",
"kind": "improved",
"title": "Make better use of wider screens",
"summary": "Chat and Voice keep text and controls on readable centered layouts. Landscape Voice Focus separates identity controls from conversation activity."
},
{
"id": "delivery-labels",
"kind": "fixed",
"title": "Read message delivery status clearly",
"summary": "Correction and delivery labels use contrasting text instead of disappearing into the message bubble."
},
{
"id": "voice-error-dialog",
"kind": "fixed",
"title": "Read and dismiss voice errors",
"summary": "Voice errors open in a contained dialog with scrollable details and separate Retry and Dismiss actions, without overlapping chat controls."
},
{
"id": "attachment-previews",
"kind": "fixed",
"title": "Keep attachment previews open through rotation",
"summary": "Image, video, audio, PDF, text, and file previews stay open as the screen rotates. Videos retain their original proportions.",
"highlight": true
},
{
"id": "wake-word-startup",
"kind": "fixed",
"title": "Fix wake-word startup in release builds",
"summary": "Release optimization now preserves the native speech configuration names needed to initialize wake-word detection."
},
{
"id": "attachment-downloads",
"kind": "fixed",
"title": "Download attachments with less memory",
"summary": "Standard Hermes attachments stream into the on-disk cache while download size limits remain enforced."
},
{
"id": "chat-memory-safety",
"kind": "fixed",
"title": "Keep large chats and media manageable",
"summary": "Automatic session refresh no longer loops. Routine history loads, chat rendering, image previews, and media exports use bounded memory instead of allocating entire large responses.",
"highlight": true
},
{
"id": "image-progress",
"kind": "fixed",
"title": "Keep image-generation progress visible",
"summary": "The working indicator stays visible between interim replies and the generated image, including gateways that omit tool activity events."
},
{
"id": "first-message-readiness",
"kind": "fixed",
"title": "Wait for new chats to be ready",
"summary": "The first message waits for the Gateway session to initialize. Ownership refusals keep the prompt retryable and show the server's error."
}
],
"compatibility": [
"Standard Chat, sessions, media, and voice continue to use upstream Hermes. Voice transcription still requires a configured speech-to-text provider on the Hermes host.",
"Paused text queues can be restored. Attachment bytes are not stored in preferences; an attachment queue that cannot be restored must be reviewed and sent again."
],
"playNotes": "More reliable chats and media: fewer memory-heavy refreshes, smoother large histories, and attachment previews that survive rotation. Choose whether follow-ups correct the current response or wait in a queue. Voice errors are easier to read, image-generation progress stays visible, and wake-word startup and first-message readiness are fixed.",
"sections": []
},
{
"version": "1.15.0",
"title": "Standard Hermes first, with clearer Relay boundaries",
+9 -19
View File
@@ -1,29 +1,19 @@
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
v1.17.0 - Voice over other apps and clearer conversations
Summary
* Chat, voice, attachments, inbound files, current-session Git, usage, and Hermes notices now prefer current upstream Dashboard and Gateway support. Relay stays optional for compatibility and the tools it uniquely provides.
* Google Play gains optional voice controls over other apps. Clarify questions, profile names, and chat context are easier to follow without losing confirmed answers or saved conversations.
Highlights
* Use standard Hermes without Relay prompts — Chat attachments, inbound files, current-session Git, Nous usage, and Hermes notices use upstream routes first.
* Keep returned files loaded — Images, audio, video, and documents download through the Dashboard and no longer flash Relay errors or return to Loading.
* Use app-specific parent access — A parent PIN or password plus recovery phrase protects Supervised Mode without trusting the phone unlock credential.
* Keep voice controls over other apps — Start Voice Overlay from Voice Focus after granting microphone, notification, and display-over-other-apps access. Stop voice from its controls or persistent notification; screen lock and permission loss end the session.
* Answer Clarify questions one at a time — Multi-question requests support separate choices and custom answers. Confirmed progress survives reconnects, and failed sends keep the current answer available.
* Recognize profiles by their display names — Profiles use their Hermes display names, and the resolved server default appears under its agent identity. Explicit profile choices and saved conversations are preserved.
Improved
* See which features need Relay — Media sits with standard Hermes settings while Threads, Terminal, notifications, enhanced voice, and device tools stay under Relay tools.
* Read the complete release record — What's New shows one release summary, selected highlights, every remaining change, and relevant compatibility notes.
* Read cleaner chat cards — Standalone response cards lose the extra outer bubble, assistant messages use quieter surfaces, and delivery status sits beside the timestamp.
Fixed
* Keep Standard voice after removing Relay — Dashboard voice remains ready, temporary outages preserve choices, and shared default-profile settings stay isolated.
* Observe another client's activity safely — A uniquely matched Desktop or TUI turn can show Working or Waiting without Android taking control.
* Keep each chat on its chosen transport — Dashboard chats preserve their transcript, draft, profile, and session through sign-out or outages instead of silently changing databases.
* Preserve completed replies at sign-in expiry — A Dashboard history authentication failure keeps completed text visible and opens the existing sign-in recovery path.
* Let long context compaction finish — Visible compaction activity refreshes the turn watchdog instead of being interrupted as idle.
* Render Bot Chat history immediately — Route-owned Bot Chats observe their bound history from first composition.
* Settle turns after a lost terminal frame — An exact idle live-session snapshot reconciles the Android-owned turn and drains its queued follow-up.
* Keep Gateway setup parent-owned — Relock and back navigation cancel the exact pending setup without bypassing parent authority.
* Keep completed generated images visible — Generated media survives marker persistence lag and retains its intended Chat animation.
* See which context your chat can send — Gateway previews mark phone status and turn context as unavailable. Automatic phone-status sharing is labeled for API-only chats, where it remains supported.
Compatibility
* Current upstream Hermes provides standard Chat, sessions, Manage, voice, attachments, inbound files, current-session Git reads, usage, and notices without the optional Hermes-Relay Plugin.
* Hermes-Relay Plugin 1.11.1 remains required for Terminal, proactive Threads and offline delivery, Notification Companion, Relay sessions, enhanced voice, Secure Link, and phone or device control.
* Granular Device Control and the system Voice Focus overlay remain sideload-only.
* Voice Overlay is now available in Google Play and sideload builds. Device Control remains sideload-only.
* Standard Chat and voice use upstream Hermes without requiring Hermes-Relay Plugin. Gateway phone-status delivery and automatic Android identification remain unavailable.
@@ -34,9 +34,7 @@ object AgentDisplay {
profiles: List<Profile>,
): Profile? = selectedProfile
// Display can use the root default profile's metadata without making it a
// request/session override. Verbose SOUL summaries are filtered by
// profileDisplayName below, so this is safe for headers/cards.
// Display resolution never changes selection or persistence identity.
fun effectiveDisplayProfile(
selectedProfile: Profile?,
profiles: List<Profile>,
@@ -44,36 +42,22 @@ object AgentDisplay {
): Profile? {
selectedProfile?.let { return it }
val resolvedServerDefault = profileRequestName(serverDefaultProfileName)
return resolvedServerDefault
?.let { activeName ->
profiles.firstOrNull { it.name.equals(activeName, ignoreCase = true) }
}
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
// An absent roster row is not authority to substitute the root profile.
// Retain the confirmed name while its display metadata is loading.
return resolvedServerDefault?.let { activeName ->
profiles.firstOrNull { it.name == activeName }
?: Profile(name = activeName, model = "")
}
}
// The NAME goes in the name slot. Non-default profiles use their profile
// name first. The synthetic default profile uses its description only when
// that description looks like a concise human agent name ("Victor"), not a
// verbose SOUL summary.
// Match upstream Desktop: presentation-only display_name, then exact request name.
fun profileDisplayName(profile: Profile?): String? {
if (profile == null) return null
if (profile.name.equals("default", ignoreCase = true)) {
return defaultProfileDisplayName(profile)
}
return when {
profile.name.isNotBlank() -> titleCase(profile.name.trim())
profile.description.isNotBlank() -> profile.description.trim()
else -> null
}
return profile.displayName.trim().takeIf(String::isNotEmpty)
?: profile.name.trim().takeIf(String::isNotEmpty)
}
fun defaultProfileDisplayName(profile: Profile?): String? =
profile
?.description
?.trim()
?.takeIf { it.looksLikeConciseAgentName() }
?.let(::titleCase)
@Suppress("UNUSED_PARAMETER") // connectionLabel retained for source compatibility.
fun agentName(
profile: Profile?,
selectedPersonality: String,
@@ -86,7 +70,7 @@ object AgentDisplay {
// "none"/"neutral" are the upstream "cleared overlay" aliases — treat
// them like "default" for identity: fall through to the server default
// (or the base connection identity) rather than rendering the literal
// identity rather than rendering the literal
// word as an agent name.
val personalityName = if (
isClearedPersonality(selectedPersonality) &&
@@ -102,7 +86,6 @@ object AgentDisplay {
return when {
personalityName.isNotBlank() && personalityName != "default" ->
titleCase(personalityName.trim())
!connectionLabel.isNullOrBlank() -> connectionLabel.trim()
else -> "Hermes"
}
}
@@ -199,16 +182,6 @@ object AgentDisplay {
?.replace(Regex("\\s+"), " ")
?.takeIf { it.isNotEmpty() }
private fun String.looksLikeConciseAgentName(): Boolean {
if (isBlank() || length > 40 || contains('\n') || contains('\r')) {
return false
}
if (any { it == '.' || it == ':' || it == ';' }) {
return false
}
return trim().split(Regex("\\s+")).size <= 4
}
private fun titleCase(value: String): String =
value.replaceFirstChar { it.uppercase() }
}
@@ -0,0 +1,102 @@
package com.hermesandroid.relay.data
/** Presentation only: canonical rows retain their wire identity, role and content. */
internal fun projectChatActivityReceipts(
messages: List<ChatMessage>,
records: List<ChatActivityRecord>,
scopeKey: String?,
sessionId: String?,
): List<ChatMessage> {
val originals = messages.filterNot {
it.clientOnly && it.id.startsWith("activity:") && it.activityRecord != null
}
if (scopeKey.isNullOrBlank() || sessionId.isNullOrBlank()) {
return originals.map { it.copy(activityRecord = null) }
}
val owned = records.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
.sortedByDescending { it.updatedAt }
.distinctBy { it.id }
val represented = mutableSetOf<String>()
val canonical = originals.map { message ->
val process = message.hermesProcessNotificationOrNull()
val delegation = message.activitySourceId?.takeIf { it.startsWith("delegation:") }
?.removePrefix("delegation:")?.takeIf { it.isNotBlank() }
val kind = when {
message.activitySourceId != null -> ChatActivityKind.SUBAGENTS
process != null -> ChatActivityKind.PROCESS
else -> return@map message.copy(activityRecord = null)
}
val sourceId = delegation ?: process?.processId
val processTerminal = process?.let { notice ->
PROCESS_TERMINAL_HEADLINE.matchEntire(notice.headline)?.let { match ->
match.groupValues[2].toIntOrNull()?.let { code ->
val phase = when {
match.groupValues[1].startsWith("terminated by ") -> ChatActivityPhase.CANCELLED
code == 0 -> ChatActivityPhase.COMPLETE
else -> ChatActivityPhase.FAILED
}
phase to code
}
}
}
// A process id can be reused after a registry restart. A canonical row has
// no start-generation field, so multiple generations must remain unmatched.
val matching = if (sourceId == null) emptyList() else owned.filter {
it.kind == kind && it.sourceId == sourceId
}
val record = matching.singleOrNull()?.also { represented += it.id }
?: ChatActivityRecord(
id = "canonical:${message.id}",
scopeKey = scopeKey,
sessionId = sessionId,
kind = kind,
sourceId = sourceId ?: "unavailable:${message.id}",
title = process?.headline ?: message.content,
phase = when {
kind == ChatActivityKind.PROCESS -> processTerminal?.first ?: ChatActivityPhase.UNKNOWN
(message.activityFailedCount ?: 0) > 0 ->
if (message.activityFailedCount == message.activityTaskCount) {
ChatActivityPhase.FAILED
} else ChatActivityPhase.UNKNOWN
(message.activityTaskCount ?: 0) > 0 -> ChatActivityPhase.COMPLETE
else -> ChatActivityPhase.UNKNOWN
},
createdAt = message.timestamp,
updatedAt = message.timestamp,
taskCount = message.activityTaskCount?.coerceAtLeast(0) ?: 0,
processId = process?.processId,
exitCode = processTerminal?.second,
)
// Aggregate completion metadata never rewrites captured child phases.
message.copy(activityRecord = record)
}
val pending = owned.filter { it.phase != ChatActivityPhase.RUNNING && it.id !in represented }
.sortedWith(compareBy<ChatActivityRecord> { it.updatedAt }.thenBy { it.id })
.map { record ->
ChatMessage(
id = "activity:${record.id}",
role = MessageRole.SYSTEM,
content = record.title,
timestamp = record.updatedAt,
clientOnly = true,
activityRecord = record,
)
}
// Stable merge: history order is authoritative even if server timestamps
// regress. Only insert local receipts; never sort canonical messages.
var next = 0
return buildList {
canonical.forEach { message ->
while (next < pending.size && pending[next].timestamp < message.timestamp) {
add(pending[next++])
}
add(message)
}
while (next < pending.size) add(pending[next++])
}
}
/** Upstream completion envelope only; never search arbitrary command/output text. */
private val PROCESS_TERMINAL_HEADLINE = Regex(
"""Background process \S+ (completed normally|exited|terminated by [^\r\n]+|marked lost because the process backend disappeared|failed to start) \(exit code (-?\d+)(?:, SIGTERM)?\)\.""",
)
@@ -0,0 +1,222 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import java.io.IOException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.intOrNull
@Serializable
enum class ChatActivityKind { SUBAGENTS, PROCESS }
@Serializable
enum class ChatActivityPhase { RUNNING, COMPLETE, FAILED, CANCELLED, UNKNOWN }
@Serializable
data class ChatActivityChild(
val id: String,
val childSessionId: String? = null,
val goal: String = "",
val phase: ChatActivityPhase = ChatActivityPhase.UNKNOWN,
val summary: String? = null,
)
/** Local presentation metadata and exact references, never transcripts or process output. */
@Serializable
data class ChatActivityRecord(
val id: String,
val scopeKey: String,
val sessionId: String,
val kind: ChatActivityKind,
val sourceId: String,
val title: String,
val phase: ChatActivityPhase,
val createdAt: Long,
val updatedAt: Long,
val children: List<ChatActivityChild> = emptyList(),
val taskCount: Int = 0,
val processId: String? = null,
val processStartedAt: String? = null,
val exitCode: Int? = null,
)
interface ChatActivityStore {
/** Recovery is not live evidence: RUNNING becomes UNKNOWN, including child phases. */
suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord>
suspend fun upsert(record: ChatActivityRecord)
suspend fun removeRecord(scopeKey: String, sessionId: String, id: String)
suspend fun removeSession(scopeKey: String, sessionId: String)
}
/**
* Bounded app-private history references in the shared settings DataStore.
* Retains 30 days, 128 records overall, 32 per exact owner/session, and 32 children
* per record. Titles are 160 characters; goals/summaries 512. Identity fields are
* rejected above 512 characters (scope 2048), never truncated into another owner.
* The complete encoded envelope is capped at 1 MiB, evicting oldest records first.
* Five minutes of future skew allows monotonic local revisions within one clock tick.
* All read/modify/write operations occur inside DataStore's serialized edit.
*/
class DataStoreChatActivityStore(
private val dataStore: DataStore<Preferences>,
private val now: () -> Long = System::currentTimeMillis,
) : ChatActivityStore {
constructor(context: Context) : this(context.applicationContext.relayDataStore)
override suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord> {
val raw = try {
dataStore.data.first()[CHAT_ACTIVITY_KEY]
} catch (_: IOException) {
return emptyList()
}
return boundChatActivities(decodeChatActivities(raw), now())
.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
.map(ChatActivityRecord::recovered)
}
override suspend fun upsert(record: ChatActivityRecord) {
dataStore.edit { preferences ->
val records = mergeChatActivity(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), record, now())
preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(records)
}
}
override suspend fun removeSession(scopeKey: String, sessionId: String) {
dataStore.edit { preferences ->
val remaining = boundChatActivities(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId }
if (remaining.isEmpty()) preferences.remove(CHAT_ACTIVITY_KEY)
else preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(remaining)
}
}
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) {
dataStore.edit { preferences ->
val remaining = boundChatActivities(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId && it.id == id }
if (remaining.isEmpty()) preferences.remove(CHAT_ACTIVITY_KEY)
else preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(remaining)
}
}
}
/** Test/ephemeral implementation with the same bounds and recovery semantics. */
class InMemoryChatActivityStore(
private val now: () -> Long = System::currentTimeMillis,
) : ChatActivityStore {
private val mutex = Mutex()
private var records = emptyList<ChatActivityRecord>()
override suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord> = mutex.withLock {
records = boundChatActivities(records, now())
records.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
.map(ChatActivityRecord::recovered)
}
override suspend fun upsert(record: ChatActivityRecord) = mutex.withLock {
records = mergeChatActivity(records, record, now())
}
override suspend fun removeSession(scopeKey: String, sessionId: String) = mutex.withLock {
records = boundChatActivities(records, now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId }
}
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) = mutex.withLock {
records = boundChatActivities(records, now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId && it.id == id }
}
}
internal const val CHAT_ACTIVITY_MAX_AGE_MS = 30L * 24L * 60L * 60L * 1_000L
private const val MAX_RECORDS = 128
private const val MAX_SESSION_RECORDS = 32
private const val MAX_PAYLOAD_BYTES = 1_048_576
private val CHAT_ACTIVITY_KEY = stringPreferencesKey("chat_activity_records_v1")
private val activityJson = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Serializable
private data class ChatActivityEnvelope(val version: Int = 1, val records: List<ChatActivityRecord>)
private fun encodeChatActivities(records: List<ChatActivityRecord>): String =
activityJson.encodeToString(ChatActivityEnvelope(records = records))
internal fun decodeChatActivities(raw: String?): List<ChatActivityRecord> {
if (raw == null || raw.length > MAX_PAYLOAD_BYTES || raw.toByteArray().size > MAX_PAYLOAD_BYTES) {
return emptyList()
}
val envelope = runCatching { activityJson.parseToJsonElement(raw) as? JsonObject }.getOrNull()
?: return emptyList()
val version = runCatching { envelope["version"]?.jsonPrimitive?.intOrNull }.getOrNull()
if (version != 1) return emptyList()
val rows = envelope["records"] as? JsonArray ?: return emptyList()
// One corrupt or newer row must not hide independently valid records.
return rows.mapNotNull { row ->
runCatching { activityJson.decodeFromJsonElement(ChatActivityRecord.serializer(), row) }.getOrNull()
}
}
private fun ChatActivityRecord.identity() = Triple(scopeKey, sessionId, id)
private fun mergeChatActivity(
existing: List<ChatActivityRecord>,
record: ChatActivityRecord,
now: Long,
): List<ChatActivityRecord> {
// Sorting first also rejects a late write for an older generation of the same record.
return boundChatActivities(listOf(record) + existing, now)
}
internal fun boundChatActivities(records: List<ChatActivityRecord>, now: Long): List<ChatActivityRecord> {
val counts = mutableMapOf<Pair<String, String>, Int>()
val bounded = records.mapNotNull { it.bounded(now) }
.sortedByDescending(ChatActivityRecord::updatedAt)
.distinctBy { it.identity() }
.filter {
val owner = it.scopeKey to it.sessionId
val count = counts.getOrDefault(owner, 0)
counts[owner] = count + 1
count < MAX_SESSION_RECORDS
}.take(MAX_RECORDS).toMutableList()
while (bounded.isNotEmpty() && encodeChatActivities(bounded).toByteArray().size > MAX_PAYLOAD_BYTES) {
bounded.removeAt(bounded.lastIndex)
}
return bounded
}
private fun validIdentity(value: String, max: Int = 512) = value.isNotBlank() && value.length <= max
private fun ChatActivityRecord.bounded(now: Long): ChatActivityRecord? {
if (!validIdentity(scopeKey, 2048) || !validIdentity(sessionId) || !validIdentity(id) ||
!validIdentity(sourceId) || (processId != null && !validIdentity(processId)) ||
(processStartedAt != null && !validIdentity(processStartedAt)) ||
createdAt < 0 || updatedAt < createdAt || updatedAt > now + 300_000L ||
now - updatedAt > CHAT_ACTIVITY_MAX_AGE_MS
) return null
return copy(
title = title.take(160),
taskCount = taskCount.coerceIn(0, 10_000),
children = children.asSequence().filter {
validIdentity(it.id) && (it.childSessionId == null || validIdentity(it.childSessionId))
}.distinctBy(ChatActivityChild::id).take(32)
.map { it.copy(goal = it.goal.take(512), summary = it.summary?.take(512)) }.toList(),
)
}
private fun ChatActivityRecord.recovered() = copy(
phase = if (phase == ChatActivityPhase.RUNNING) ChatActivityPhase.UNKNOWN else phase,
children = children.map {
if (it.phase == ChatActivityPhase.RUNNING) it.copy(phase = ChatActivityPhase.UNKNOWN) else it
},
)
@@ -22,6 +22,26 @@ enum class PhysicalKeyboardEnterBehavior(val storedValue: String) {
}
}
/** Default intent for a message submitted while an agent is responding. */
enum class BusyMessageAction(val storedValue: String) {
CorrectNow("correct_now"),
QueueNext("queue_next");
companion object {
fun fromStoredValue(value: String?): BusyMessageAction =
entries.firstOrNull { it.storedValue == value } ?: CorrectNow
}
}
fun canCorrectBusyMessage(
steerable: Boolean,
hasAttachments: Boolean,
hasPendingInput: Boolean,
status: String?,
text: String,
): Boolean = steerable && !hasAttachments && !hasPendingInput &&
status?.contains("compact", ignoreCase = true) != true && !text.trimStart().startsWith("/")
/** Device-level chat input preferences shared by every Hermes profile. */
class ChatInputPreferencesRepository(
private val dataStore: DataStore<Preferences>,
@@ -29,6 +49,7 @@ class ChatInputPreferencesRepository(
constructor(context: Context) : this(context.relayDataStore)
companion object {
internal val KEY_BUSY_MESSAGE_ACTION = stringPreferencesKey("busy_message_action")
internal val KEY_PHYSICAL_KEYBOARD_ENTER =
stringPreferencesKey("physical_keyboard_enter_behavior")
internal val KEY_CONVERT_LARGE_PASTES =
@@ -45,6 +66,14 @@ class ChatInputPreferencesRepository(
}
.distinctUntilChanged()
val busyMessageAction: Flow<BusyMessageAction> = dataStore.data
.map { BusyMessageAction.fromStoredValue(it[KEY_BUSY_MESSAGE_ACTION]) }
.distinctUntilChanged()
suspend fun setBusyMessageAction(action: BusyMessageAction) {
dataStore.edit { it[KEY_BUSY_MESSAGE_ACTION] = action.storedValue }
}
val convertLargePastesToAttachments: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[KEY_CONVERT_LARGE_PASTES] ?: true }
.distinctUntilChanged()
@@ -170,6 +170,12 @@ data class ChatMessage(
* but server history never owns these presentation blocks.
*/
val moaReferences: List<MoaReference> = emptyList(),
/** Exact upstream identity on a persisted activity-completion marker. */
val activitySourceId: String? = null,
val activityTaskCount: Int? = null,
val activityFailedCount: Int? = null,
/** Read-only UI projection; never sent as model history or voice input. */
val activityRecord: ChatActivityRecord? = null,
)
data class MessageReaction(
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.hermesandroid.relay.network.upstream.GatewayClarifyQuestion
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@@ -35,6 +36,9 @@ data class ChatTurnCheckpoint(
val baselineAssistantCount: Int,
val pendingAsk: ChatTurnAskCheckpoint? = null,
val queuedMessages: List<ChatQueuedMessageCheckpoint> = emptyList(),
val queuePaused: Boolean = false,
/** Only pending local work remains; never reattach the completed/stopped turn. */
val queueOnly: Boolean = false,
val startedAt: Long,
val updatedAt: Long,
) {
@@ -132,6 +136,9 @@ data class ChatTurnAskCheckpoint(
val text: String,
val choices: List<String>? = null,
val multiSelect: Boolean = false,
val questions: List<GatewayClarifyQuestion> = emptyList(),
val answers: Map<String, String> = emptyMap(),
val ownerId: String? = null,
val smartDenied: Boolean = false,
val envVar: String? = null,
val timeoutSeconds: Int,
@@ -96,7 +96,8 @@ object FeatureFlags {
* flavor ships AccessibilityService-backed Device Control. The `googlePlay`
* flavor is Bridge Core: relay pairing, chat, voice, terminal, notification
* companion, media, and session-grant surfaces without screen reading, taps,
* typing, screenshots, overlays, or unattended control.
* typing, MediaProjection screenshots, or unattended control. Voice-only overlay
* presentation is a separate capability shared by both flavors.
*
* Device Control tier definitions (see `Phase 3 — Bridge Channel.md`):
* 1. baseline — sideload only (app open, tap, navigate within app)
@@ -126,6 +127,9 @@ object BuildFlavor {
*/
val isSideload: Boolean get() = current == SIDELOAD
/** Voice presentation does not grant Device Control. Unknown distributions fail closed. */
val voiceSystemOverlay: Boolean get() = current == GOOGLE_PLAY || current == SIDELOAD
val bridgeTier1: Boolean get() = current == SIDELOAD // baseline device control
val bridgeTier2: Boolean get() = current == SIDELOAD // screen context
val bridgeTier3: Boolean get() = current == SIDELOAD // voice-first
@@ -71,6 +71,8 @@ data class HermesCard(
* actions.
*/
val input: HermesCardInput? = null,
/** Local Gateway batch; never an ordinary chat-message answer protocol. */
val clarifyBatch: HermesCardClarifyBatch? = null,
) {
object BuiltInTypes {
const val SKILL_RESULT = "skill_result"
@@ -96,6 +98,25 @@ data class HermesCard(
}
}
@Serializable
data class HermesCardClarifyBatch(
val questions: List<HermesCardClarifyQuestion>,
val expiresAtMillis: Long? = null,
)
@Serializable
data class HermesCardClarifyQuestion(
val key: String,
val question: String,
val input: HermesCardInput,
val answer: String? = null,
val submitting: Boolean = false,
)
/** Local callback identity. The RPC always uses the original qid, never this UI key. */
fun clarifyQuestionCardKey(cardKey: String, qid: String): String =
Json.encodeToString(listOf(cardKey, qid))
/**
* Interactive input slot on a [HermesCard]. The flags compose rather than
* branch — a sudo ask can be `masked + holdToConfirm` (password field whose
@@ -111,6 +111,8 @@ data class Profile(
val hasAvatar: Boolean = false,
@SerialName("ui_meta")
val uiMeta: JsonObject = JsonObject(emptyMap()),
@SerialName("display_name")
val displayName: String = "",
) {
val hasIsolatedApi: Boolean
get() = !apiServerUrl.isNullOrBlank()
@@ -149,7 +149,10 @@ class EndpointResolver(
)
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
private val inFlightProbes = ConcurrentHashMap<String, Deferred<Boolean>>()
// Every access is owned by [probeStateLock]. This must not be a
// concurrently-mutated collection: clearCache() takes a stable snapshot
// while completion callbacks remove finished probes.
private val inFlightProbes = mutableMapOf<String, Deferred<Boolean>>()
private val probeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val probeStateLock = Any()
private var probeGeneration = 0L
@@ -486,7 +489,13 @@ class EndpointResolver(
probe(candidate, surface, generation)
}.also { deferred ->
inFlightProbes[key] = deferred
deferred.invokeOnCompletion { inFlightProbes.remove(key, deferred) }
deferred.invokeOnCompletion {
synchronized(probeStateLock) {
// Identity-aware removal prevents an invalidated
// probe from removing its fresh replacement.
inFlightProbes.remove(key, deferred)
}
}
deferred.start()
}
}
@@ -518,6 +518,16 @@ class ChatHandler {
}
}
/** Refresh only an existing local ask, preserving its dispatches and transcript position. */
fun updateAskCardMessage(messageId: String, card: HermesCard) {
_messages.update { list ->
list.map { message ->
if (message.clientOnly && message.matchesIdentity(messageId)) message.copy(cards = listOf(card))
else message
}
}
}
/**
* Edit-and-regenerate local truncation: drop [messageId] and everything
* after it. The gateway performs the authoritative truncation via
@@ -1451,6 +1461,12 @@ class ChatHandler {
val loaded = renderedItems.mapNotNull { item ->
val displayKind = item.displayKind?.trim()?.lowercase()
if (displayKind == "hidden") return@mapNotNull null
val activitySourceId = if (displayKind == "async_delegation_complete") {
item.displayMetadata.stringField("delegation_id")?.let { "delegation:$it" }
?: "unavailable:${item.id}"
} else null
val activityTaskCount = if (activitySourceId != null) item.displayMetadata.intField("task_count") else null
val activityFailedCount = if (activitySourceId != null) item.displayMetadata.intField("failed_count") else null
val role = when {
displayKind == "model_switch" ||
displayKind == "async_delegation_complete" ||
@@ -1611,6 +1627,9 @@ class ChatHandler {
// this as the same visible row across the post-turn reload.
prior.copy(
id = messageId,
activitySourceId = activitySourceId,
activityTaskCount = activityTaskCount,
activityFailedCount = activityFailedCount,
rowId = item.resolvedRowId,
reactions = item.reactions,
role = role,
@@ -1643,6 +1662,9 @@ class ChatHandler {
// nothing local to carry).
ChatMessage(
id = messageId,
activitySourceId = activitySourceId,
activityTaskCount = activityTaskCount,
activityFailedCount = activityFailedCount,
rowId = item.resolvedRowId,
reactions = item.reactions,
role = role,
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
@@ -49,6 +50,7 @@ import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
@@ -1355,7 +1357,7 @@ class DashboardApiClient(
suspend fun getSessionMessages(
sessionId: String,
profile: String? = null,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
mode: SessionMessageLoadMode = SessionMessageLoadMode.LATEST,
): Result<List<MessageItem>> = withContext(Dispatchers.IO) {
val name = profile?.trim().orEmpty()
loadSessionMessages(mode) { page ->
@@ -1505,7 +1507,7 @@ class DashboardApiClient(
val patched = buildJsonObject {
obj.forEach { (k, v) -> put(k, v) }
if (obj["name"] == null && !nameOverride.isNullOrBlank()) put("name", nameOverride)
if (obj["model"] == null) put("model", "")
if (obj["model"] == null || obj["model"] == JsonNull) put("model", "")
}
json.decodeFromJsonElement(Profile.serializer(), patched)
}.getOrNull()
@@ -1524,10 +1526,14 @@ class DashboardApiClient(
password: String,
next: String = "/",
): Result<DashboardLoginResponse> = withContext(Dispatchers.IO) {
// Match the Dashboard's single-line HTML username/password controls:
// remove only forbidden line breaks and preserve every other code point.
val normalizedUsername = username.replace("\r", "").replace("\n", "")
val normalizedPassword = password.replace("\r", "").replace("\n", "")
val payload = buildJsonObject {
put("provider", provider)
put("username", username)
put("password", password)
put("username", normalizedUsername)
put("password", normalizedPassword)
put("next", next)
}
val httpUrl = resolveUrl("/auth/password-login")
@@ -1759,6 +1765,7 @@ class DashboardApiClient(
// Mirrors current upstream `_MANAGED_FILE_MAX_BYTES`; enforcing it
// client-side avoids uploading a body the Dashboard will reject.
internal const val MAX_BACKUP_TRANSFER_BYTES = 100L * 1024L * 1024L
internal const val MAX_JSON_RESPONSE_BYTES = 8L * 1024L * 1024L
fun pathSegment(value: String): String =
URLEncoder.encode(value, "UTF-8").replace("+", "%20")
@@ -2487,17 +2494,44 @@ data class StoredDashboardCookie(
}
private fun Response.readJsonObject(json: Json): JsonObject {
val raw = body.string()
val raw = body.readUtf8Bounded(DashboardApiClient.MAX_JSON_RESPONSE_BYTES)
if (raw.isBlank()) return JsonObject(emptyMap())
return json.parseToJsonElement(raw).jsonObject
}
private fun Response.readJsonElement(json: Json): JsonElement {
val raw = body.string()
val raw = body.readUtf8Bounded(DashboardApiClient.MAX_JSON_RESPONSE_BYTES)
if (raw.isBlank()) return JsonObject(emptyMap())
return json.parseToJsonElement(raw)
}
internal fun okhttp3.ResponseBody.readUtf8Bounded(maxBytes: Long): String {
require(maxBytes in 1..Int.MAX_VALUE.toLong()) { "Invalid response byte limit" }
val declaredLength = contentLength()
if (declaredLength > maxBytes) {
throw IOException("Dashboard response exceeds Android's bounded JSON limit")
}
val initialSize = when {
declaredLength in 1..maxBytes -> declaredLength.toInt()
else -> minOf(maxBytes, DEFAULT_BUFFER_SIZE.toLong()).toInt()
}
val output = ByteArrayOutputStream(initialSize)
byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0L
while (true) {
val read = input.read(buffer)
if (read < 0) break
total += read
if (total > maxBytes) {
throw IOException("Dashboard response exceeds Android's bounded JSON limit")
}
output.write(buffer, 0, read)
}
}
return output.toString(Charsets.UTF_8.name())
}
private fun contentDispositionFileName(header: String): String? {
val encoded = Regex("""filename\*=UTF-8''([^;]+)""", RegexOption.IGNORE_CASE)
.find(header)
@@ -2586,7 +2620,11 @@ internal fun Throwable.isDashboardManagedFilesUnsupported(): Boolean {
}
private fun apiFailure(response: Response, operation: String): IOException {
val bodyDetail = runCatching { response.body.string() }.getOrDefault("")
val bodyDetail = try {
response.body.readUtf8Bounded(4L * 1024L)
} catch (_: Exception) {
""
}
val detail = bodyDetail.take(240).ifBlank { response.message }
return DashboardHttpException(
statusCode = response.code,
@@ -416,6 +416,10 @@ class GatewayChatClient(
@Volatile
private var webSocket: WebSocket? = null
/** Profile explicitly routed by the Dashboard `/api/ws?profile=` handshake. */
@Volatile
private var connectedSocketProfile: String? = null
/** Completed when the server's `gateway.ready` event arrives for the current socket. */
@Volatile
private var readySignal: CompletableDeferred<Unit>? = null
@@ -427,6 +431,9 @@ class GatewayChatClient(
private val lazyLiveSessions = ConcurrentHashMap.newKeySet<String>()
private val readyLiveSessions = ConcurrentHashMap.newKeySet<String>()
private val sessionReadyWaiters = ConcurrentHashMap<String, CompletableDeferred<Unit>>()
private val sessionReadyFailures = ConcurrentHashMap<String, String>()
private val _preparingSessionId = MutableStateFlow<String?>(null)
val preparingSessionId: StateFlow<String?> = _preparingSessionId.asStateFlow()
/** Monotonic client-local fence for lazy child watch open/close races. */
private val childWatchGeneration = AtomicLong(0)
@@ -629,6 +636,13 @@ class GatewayChatClient(
@Volatile
private var processEventListener: ((GatewayProcessEvent) -> Unit)? = null
@Volatile
private var subagentEventListener: ((String, String?, GatewaySubagentEvent) -> Unit)? = null
fun setSubagentEventListener(listener: ((String, String?, GatewaySubagentEvent) -> Unit)?) {
subagentEventListener = listener
}
/** Process-wide durable-session invalidation/liveness edge. */
@Volatile
private var sessionDirectoryInvalidationListener: (() -> Unit)? = null
@@ -1559,7 +1573,10 @@ class GatewayChatClient(
.orEmpty()
.also { recoveryEvents = null }
}
buffered.forEach { event -> boundTurn?.onEvent(event.type, event.payload) }
buffered.forEach { event ->
dispatchSubagentEvent(event.type, event.payload, event.sessionId)
boundTurn.onEvent(event.type, event.payload)
}
queued?.let { queuedTurn ->
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
boundTurn.installQueuedSuccessor(registration)
@@ -1569,6 +1586,7 @@ class GatewayChatClient(
claimedBackground?.pendingAsk?.let { ask ->
boundTurn.restoreInteraction(ask)
}
boundTurn.restorePendingClarify(response)
boundTurn.armWatchdog()
} else if (queued != null) {
synchronized(recoveryEventLock) { recoveryEvents = null }
@@ -1729,17 +1747,39 @@ class GatewayChatClient(
)
/** Answer a [GatewayAsk.Kind.CLARIFY] ask. */
suspend fun respondClarify(requestId: String, answer: String): Result<GatewayAskResponse> {
suspend fun respondClarify(
requestId: String,
answer: String,
questionId: String? = null,
): Result<GatewayAskResponse> {
val respondingTurn = activeTurn
if (questionId != null) {
val ask = respondingTurn?.pendingInteraction
// A lost acknowledgement is ambiguous until activation replays server progress.
// Never overwrite an accepted answer while reconnect is still reconciling it.
if (rejoinInProgress || ask?.kind != GatewayAsk.Kind.CLARIFY || ask.requestId != requestId ||
ask.questions.none { it.qid == questionId } || ask.ownershipToken.retired.get() ||
questionId in ask.ownershipToken.answers.get()
) return Result.failure(GatewayRpcException("Clarification is not ready for this question"))
}
val generation = respondingTurn?.interactionGeneration
val ownershipToken = respondingTurn?.pendingInteraction?.ownershipToken
return rpc(
"clarify.respond",
buildJsonObject {
put("request_id", requestId)
put("answer", answer)
questionId?.let { put("question_id", it) }
},
).map {
it.gatewayAskResponse().also {
respondingTurn?.acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, requestId))
if (generation != null) {
respondingTurn.acknowledgeClarify(requestId, questionId, answer, it == GatewayAskResponse.EXPIRED, generation)
}
val currentTurn = activeTurn
if (ownershipToken != null && currentTurn !== respondingTurn) {
currentTurn?.acknowledgeClarifyOwner(requestId, questionId, answer, it == GatewayAskResponse.EXPIRED, ownershipToken)
}
}
}
}
@@ -1896,6 +1936,7 @@ class GatewayChatClient(
model = row.stringField("model").orEmpty(),
provider = row.stringField("provider").orEmpty(),
description = row.stringField("description").orEmpty(),
displayName = row.stringField("display_name").orEmpty(),
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
@@ -2946,6 +2987,7 @@ class GatewayChatClient(
unmatchedTurnCompleteListener = null
backgroundInteractionListener = null
processEventListener = null
subagentEventListener = null
sessionDirectoryInvalidationListener = null
closeSocket("client shutdown")
backgroundCloseJob?.cancel()
@@ -3066,9 +3108,10 @@ class GatewayChatClient(
)
}
val ticketMs = (System.nanoTime() - connectStart) / 1_000_000
val socketProfile = currentSessionProfile()
val url = dashboardClient.gatewayWebSocketUrl(
ticket = ticket.ticket,
profile = currentSessionProfile(),
profile = socketProfile,
)
?: throw GatewayConnectAttemptException(
"could not build /api/ws URL",
@@ -3102,6 +3145,7 @@ class GatewayChatClient(
if (readyFailure != null) {
socket.cancel()
webSocket = null
connectedSocketProfile = null
throw (readyFailure as? GatewayConnectAttemptException
?: GatewayConnectAttemptException(
"gateway connection failed: ${readyFailure.message}",
@@ -3114,6 +3158,7 @@ class GatewayChatClient(
val wsMs = (System.nanoTime() - connectStart) / 1_000_000 - ticketMs
Log.i(TAG, "Gateway connected (/api/ws ready) — ticket=${ticketMs}ms ws=${wsMs}ms")
hasEverReachedReady = true
connectedSocketProfile = socketProfile
coldStartFailureEpisodes = 0
_reconnectDisposition.value = GatewayReconnectDisposition.None
_connectionState.value = GatewayConnectionState.Ready
@@ -3288,7 +3333,8 @@ class GatewayChatClient(
?.stringField("profile_name")
?.trim()
?.takeIf { it.isNotEmpty() }
if (actual != expected) {
val socketConfirmed = actual == null && connectedSocketProfile == expected
if (actual != expected && !socketConfirmed) {
val detail = actual?.let { "Hermes returned profile '$it'" }
?: "Hermes did not confirm profile ownership"
throw GatewayPreflightException(
@@ -3477,6 +3523,7 @@ class GatewayChatClient(
liveId: String,
sessionResult: JsonObject? = null,
) {
sessionReadyFailures[liveId]?.let { throw GatewayPreflightException(it) }
val lazy = (sessionResult?.get("info") as? JsonObject)?.booleanField("lazy") == true
if (lazy) lazyLiveSessions += liveId
if (liveId !in lazyLiveSessions) return
@@ -3486,6 +3533,9 @@ class GatewayChatClient(
}
val waiter = sessionReadyWaiters.computeIfAbsent(liveId) { CompletableDeferred() }
if (readyLiveSessions.remove(liveId)) waiter.complete(Unit)
sessionReadyFailures[liveId]?.let { waiter.completeExceptionally(GatewayRpcException(it)) }
val preparingStoredId = storedSessionId
_preparingSessionId.value = preparingStoredId
try {
withTimeout(sessionReadyTimeoutMs) { waiter.await() }
lazyLiveSessions.remove(liveId)
@@ -3494,11 +3544,14 @@ class GatewayChatClient(
error.message ?: "Hermes session initialization timed out",
)
} finally {
if (_preparingSessionId.value == preparingStoredId) _preparingSessionId.value = null
sessionReadyWaiters.remove(liveId, waiter)
}
}
private fun failSessionReadyWaiters(message: String) {
_preparingSessionId.value = null
sessionReadyFailures.clear()
sessionReadyWaiters.values.forEach {
it.completeExceptionally(GatewayRpcException(message))
}
@@ -3688,10 +3741,33 @@ class GatewayChatClient(
if (type == "session.info" && !eventSessionId.isNullOrBlank() &&
payload?.booleanField("lazy") != true
) {
sessionReadyFailures.remove(eventSessionId)
readyLiveSessions += eventSessionId
sessionReadyWaiters.remove(eventSessionId)?.complete(Unit)
}
// Deferred agent construction can fail after lazy session.create/
// resume returns but before prompt.submit. Fail the readiness barrier
// immediately so the optimistic prompt remains retryable/Not sent
// instead of waiting for the five-minute readiness timeout.
if (type == "error" && !eventSessionId.isNullOrBlank() &&
(eventSessionId in lazyLiveSessions || sessionReadyWaiters.containsKey(eventSessionId) ||
payload?.stringField("message")?.startsWith("agent init failed:") == true)
) {
val message = payload?.stringField("message")
?: "Hermes session initialization failed"
// A fast deferred build can fail before the lazy RPC acknowledgement.
// Retain the exact runtime failure so the subsequent readiness wait
// cannot lose that edge and hang until its deadline.
if (sessionReadyFailures.size >= 64) sessionReadyFailures.keys.firstOrNull()?.let(sessionReadyFailures::remove)
sessionReadyFailures[eventSessionId] = message.take(2_000)
lazyLiveSessions.remove(eventSessionId)
readyLiveSessions.remove(eventSessionId)
sessionReadyWaiters.remove(eventSessionId)
?.completeExceptionally(GatewayRpcException(message))
return
}
// Upstream emits session.reclaimed process-wide, so it is identified
// by payload rather than params.session_id. Retire only an exact live
// runtime we own; preserve the durable id so the next send resumes it.
@@ -3801,7 +3877,10 @@ class GatewayChatClient(
val interactionRequest = GatewayEventMapper.interactionRequest(type, payload)
if (interactionRequest != null) {
val previous = backgroundTurn.pendingAsk
backgroundTurn.pendingAsk = interactionRequest
backgroundTurn.pendingAsk = if (previous?.kind == interactionRequest.kind &&
previous.requestId == interactionRequest.requestId && interactionRequest.questions.isNotEmpty()
) interactionRequest.withAnswers(previous.answers + interactionRequest.answers, previous)
else interactionRequest
if (previous?.kind != interactionRequest.kind ||
previous.requestId != interactionRequest.requestId
) {
@@ -3828,6 +3907,7 @@ class GatewayChatClient(
// be replayed or buffered. Only an authoritative expiry retires a
// detached ask; an explicit response is retired by its foreground VM.
if (explicitlyExpired) {
pendingAsk.ownershipToken.retired.set(true)
backgroundTurn.pendingAsk = null
callbackDispatcher {
backgroundInteractionListener?.invoke(
@@ -3891,6 +3971,7 @@ class GatewayChatClient(
dispatchProcessEvent(type, payload, eventSessionId)
if (consumeCancelledTurnEvent(type, eventSessionId)) return
if (consumeSettledTurnTerminal(type, eventSessionId)) return
dispatchSubagentEvent(type, payload, eventSessionId)
var turn = activeTurn
if (turn == null && type == "message.start") {
// Unsolicited turns are accepted only with an explicit exact live-
@@ -3944,6 +4025,9 @@ class GatewayChatClient(
}
return
}
if (type == "session.info" && eventSessionId != null && eventSessionId == liveSessionId) {
payload?.let(turn::restorePendingClarify)
}
turn.onEvent(type, payload)
if (turn.ended) {
if (activeTurn === turn) activeTurn = null
@@ -3952,10 +4036,25 @@ class GatewayChatClient(
}
/**
* Deliver session-scoped process events before the active-turn gate. The
* gateway socket is process-wide, so an exact non-blank live id match is
* required; missing/foreign ids must never leak another window's process.
* Detached child updates belong to the session even between parent turns.
* Recheck socket, session, profile and listener ownership on UI dispatch.
*/
private fun dispatchSubagentEvent(type: String, payload: JsonObject?, eventSessionId: String?) {
val liveId = liveSessionId ?: return
val storedId = storedSessionId ?: return
if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return
val event = GatewayEventMapper.parseSubagentEvent(type, payload) ?: return
val profile = liveSessionProfile
val socket = webSocket
val listener = subagentEventListener ?: return
callbackDispatcher {
if (webSocket === socket && liveSessionId == liveId && storedSessionId == storedId &&
liveSessionProfile == profile && subagentEventListener === listener
) listener(storedId, profile, event)
}
}
/** Process updates likewise require an exact live-session match before turn admission. */
private fun dispatchProcessEvent(type: String, payload: JsonObject?, eventSessionId: String?) {
val liveId = liveSessionId ?: return
if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return
@@ -4006,6 +4105,7 @@ class GatewayChatClient(
// id on the shared gateway stream) keeps matching after reconnect.
val preservedLiveId = liveSessionId
webSocket = null
connectedSocketProfile = null
readySignal = null
liveSessionId = null
attachMethodForSocket = null
@@ -4134,6 +4234,7 @@ class GatewayChatClient(
activated.isSuccess -> {
activated.getOrNull()?.let { result ->
applySessionResultInfo(result)
turn.restorePendingClarify(result)
turn.settleFromAuthoritativeSessionState(
running = result.booleanField("running"),
source = "session.activate",
@@ -4197,6 +4298,7 @@ class GatewayChatClient(
private fun closeSocket(reason: String) {
webSocket?.close(1000, reason)
webSocket = null
connectedSocketProfile = null
readySignal = null
liveSessionId = null
failSessionReadyWaiters("gateway socket closed")
@@ -4435,6 +4537,19 @@ class GatewayChatClient(
fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
mapper.acknowledgeInteraction(expiry)
}
val interactionGeneration: Long get() = mapper.interactionGeneration
fun acknowledgeClarify(requestId: String, questionId: String?, answer: String, expired: Boolean, generation: Long) {
mapper.acknowledgeClarify(requestId, questionId, answer, expired, generation)
}
fun acknowledgeClarifyOwner(requestId: String, questionId: String?, answer: String, expired: Boolean, owner: GatewayAskOwnership) {
mapper.acknowledgeClarifyOwner(requestId, questionId, answer, expired, owner)
}
fun restorePendingClarify(snapshot: JsonObject) {
val payload = snapshot["pending_clarify"] as? JsonObject
?: (snapshot["info"] as? JsonObject)?.get("pending_clarify") as? JsonObject
?: return
GatewayEventMapper.interactionRequest("clarify.request", payload)?.let(mapper::restoreInteraction)
}
private val deferredEventLock = Any()
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
private var eventsDeferred = deferEvents
@@ -5198,6 +5313,7 @@ private fun parseBotRosterEntry(row: JsonObject): BotRosterEntry? {
model = row.stringField("model").orEmpty(),
provider = row.stringField("provider").orEmpty(),
description = row.stringField("description")?.take(512).orEmpty(),
displayName = row.stringField("display_name").orEmpty(),
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
@@ -32,19 +32,66 @@ class GatewayEventMapper(
var turnEnded: Boolean = false
private set
@get:Synchronized
internal val currentInteraction: GatewayAsk?
get() = pendingInteraction
@Volatile internal var interactionGeneration: Long = 0
private set
internal fun restoreInteraction(ask: GatewayAsk) {
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
pendingInteraction = ask
if (!duplicate) callbacks.onInteractionRequest(ask)
@Synchronized internal fun restoreInteraction(ask: GatewayAsk) {
val previous = pendingInteraction
val duplicate = previous?.sameRequestAs(ask) == true
if (!duplicate) interactionGeneration++
val merged = if (duplicate && ask.questions.isNotEmpty()) {
ask.withAnswers(previous.answers + ask.answers, previous)
} else if (ask.questions.isNotEmpty()) ask.withAnswers(emptyMap()) else ask
if (merged.ownershipToken.retired.get() && !merged.clarifyComplete) {
if (duplicate) pendingInteraction = null
callbacks.onInteractionExpired(GatewayAskExpiry(merged.kind, merged.requestId))
if (pendingInteraction == null) drainDeferredTerminalEvent()
return
}
pendingInteraction = merged
if (!duplicate || previous != merged) callbacks.onInteractionRequest(merged)
if (merged.clarifyComplete) {
pendingInteraction = null
drainDeferredTerminalEvent()
}
}
@Synchronized internal fun acknowledgeClarify(
requestId: String,
questionId: String?,
answer: String,
expired: Boolean,
generation: Long = interactionGeneration,
) {
if (generation != interactionGeneration) return
val pending = pendingInteraction ?: return
if (pending.kind != GatewayAsk.Kind.CLARIFY || pending.requestId != requestId) return
if (!expired && questionId != null && pending.questions.none { it.qid == questionId }) return
if (!expired && questionId != null && pending.questions.any { it.qid == questionId }) {
val updated = pending.withAnswers(pending.answers + (questionId to answer))
if (updated.questions.any { it.qid !in updated.answers }) {
pendingInteraction = updated
return
}
}
acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, requestId))
}
@Synchronized internal fun acknowledgeClarifyOwner(
requestId: String, questionId: String?, answer: String, expired: Boolean, owner: GatewayAskOwnership,
) {
if (pendingInteraction?.ownershipToken !== owner) return
acknowledgeClarify(requestId, questionId, answer, expired)
}
/** Retire only the ask whose explicit respond RPC reached server truth. */
internal fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
@Synchronized internal fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
val pending = pendingInteraction ?: return
if (pending.matches(expiry)) {
pending.ownershipToken.retired.set(true)
pendingInteraction = null
drainDeferredTerminalEvent()
}
@@ -77,7 +124,7 @@ class GatewayEventMapper(
*/
private val generatingIdsByName = mutableMapOf<String, ArrayDeque<String>>()
fun onEvent(type: String, payload: JsonObject?) {
@Synchronized fun onEvent(type: String, payload: JsonObject?) {
if (turnEnded) return
interactionRequest(type, payload)?.let { ask ->
@@ -88,6 +135,7 @@ class GatewayEventMapper(
interactionExpiry(type, payload)?.let { expiry ->
val pending = pendingInteraction
if (pending != null && pending.matches(expiry)) {
pending.ownershipToken.retired.set(true)
pendingInteraction = null
}
callbacks.onInteractionExpired(expiry)
@@ -196,7 +244,8 @@ class GatewayEventMapper(
"tool.start" -> {
clearActivityStatuses()
val name = payload.string("name") ?: "unknown"
val identity = payload.effectiveToolIdentity()
val name = identity.name
// A pending generating placeholder for this name is adopted
// (consumed FIFO) whether or not the server sent a real id.
val adopted = generatingIdsByName[name]?.removeFirstOrNull()
@@ -208,7 +257,7 @@ class GatewayEventMapper(
}
else -> syntheticToolId(name)
}
val argsPreview = payload?.get("args")
val argsPreview = identity.argsPreview ?: payload?.get("args")
?.takeUnless { it is JsonPrimitive && it.contentOrNull.isNullOrBlank() }
?.toString()
?.takeIf { it.isNotBlank() && it != "null" }
@@ -220,7 +269,7 @@ class GatewayEventMapper(
"tool.complete" -> {
clearActivityStatuses()
val name = payload.string("name") ?: "unknown"
val name = payload.effectiveToolIdentity().name
val toolId = payload.string("tool_id")
?: openSyntheticIdsByName[name]?.removeFirstOrNull()
?: return
@@ -291,34 +340,7 @@ class GatewayEventMapper(
"subagent.progress", "subagent.complete",
-> {
clearActivityStatuses()
val phase = when (type) {
"subagent.spawn_requested" -> GatewaySubagentEvent.Phase.SPAWN_REQUESTED
"subagent.start" -> GatewaySubagentEvent.Phase.START
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
else -> GatewaySubagentEvent.Phase.COMPLETE
}
callbacks.onSubagentEvent(
GatewaySubagentEvent(
phase = phase,
taskIndex = payload.int("task_index") ?: 0,
taskCount = payload.int("task_count") ?: 1,
goal = payload.string("goal") ?: "",
status = payload.string("status"),
summary = payload.string("summary"),
toolName = payload.string("tool_name"),
// subagent.tool sets tool_preview AND mirrors it into
// text; thinking/progress carry text only.
preview = payload.string("tool_preview") ?: payload.string("text"),
durationSeconds = payload.double("duration_seconds"),
subagentId = payload.string("subagent_id"),
childSessionId = payload.string("child_session_id"),
parentId = payload.string("parent_id"),
depth = payload.int("depth"),
model = payload.string("model"),
),
)
parseSubagentEvent(type, payload)?.let(callbacks.onSubagentEvent)
}
"tool.output_risk" -> {
@@ -466,6 +488,42 @@ class GatewayEventMapper(
}
companion object {
/** Shared by turn transcripts and the session-owned activity stream. */
fun parseSubagentEvent(type: String, payload: JsonObject?): GatewaySubagentEvent? {
if (type !in setOf(
"subagent.spawn_requested", "subagent.start", "subagent.thinking",
"subagent.tool", "subagent.progress", "subagent.complete",
)) return null
val phase = when (type) {
"subagent.spawn_requested" -> GatewaySubagentEvent.Phase.SPAWN_REQUESTED
"subagent.start" -> GatewaySubagentEvent.Phase.START
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
else -> GatewaySubagentEvent.Phase.COMPLETE
}
return GatewaySubagentEvent(
phase = phase,
taskIndex = payload.int("task_index") ?: 0,
taskCount = payload.int("task_count") ?: 1,
goal = payload.string("goal") ?: "",
status = payload.string("status"),
summary = payload.string("summary"),
toolName = payload.string("tool_name"),
// subagent.tool sets tool_preview AND mirrors it into
// text; thinking/progress carry text only.
preview = payload.string("tool_preview") ?: payload.string("text"),
durationSeconds = payload.double("duration_seconds"),
subagentId = payload.string("subagent_id"),
childSessionId = payload.string("child_session_id"),
parentId = payload.string("parent_id"),
depth = payload.int("depth"),
model = payload.string("model"),
delegationId = payload.string("delegation_id"),
)
}
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
const val COMPACTION_STATUS_KIND = "compacting"
const val ERROR_STATUS_KIND = "error"
@@ -493,26 +551,7 @@ class GatewayEventMapper(
}
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
"clarify.request" -> {
val choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() }
?.filter { it.isNotEmpty() }
?.distinct()
?.take(MAX_CLARIFY_CHOICES)
?.takeIf { it.isNotEmpty() }
GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = choices,
multiSelect = payload.boolean("multi_select") == true && choices != null,
// Current upstream owns expiry through clarify.expire and
// does not advertise its configurable deadline. Never
// invent a local deadline; consume future additive
// metadata only when it is present and positive.
timeoutSeconds = payload.int("timeout_seconds")?.coerceAtLeast(0) ?: 0,
)
}
"clarify.request" -> clarifyRequest(payload)
"approval.request" -> GatewayAsk(
kind = GatewayAsk.Kind.APPROVAL,
@@ -545,6 +584,41 @@ class GatewayEventMapper(
else -> null
}
private fun clarifyRequest(payload: JsonObject?): GatewayAsk? {
val rawQuestions = payload?.get("questions")
if (rawQuestions != null && rawQuestions !is JsonArray) return null
val questions = rawQuestions?.map { value ->
val row = value as? JsonObject ?: return null
val qid = (row["qid"] as? JsonPrimitive)?.takeIf { it.isString }
?.contentOrNull?.takeIf(String::isNotBlank) ?: return null
val text = row.string("question")?.takeIf(String::isNotBlank) ?: return null
val choices = (row["choices"] as? JsonArray)?.mapNotNull {
(it as? JsonPrimitive)?.takeIf { option -> option.isString }
?.contentOrNull?.takeIf(String::isNotBlank)
}.orEmpty().take(MAX_CLARIFY_CHOICES)
GatewayClarifyQuestion(qid, text, choices, row.boolean("multi_select") == true && choices.isNotEmpty())
}.orEmpty()
if (questions.size > MAX_CLARIFY_QUESTIONS || questions.map { it.qid }.distinct().size != questions.size) return null
val choices = (payload?.get("choices") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() }
?.filter(String::isNotEmpty)?.distinct()?.take(MAX_CLARIFY_CHOICES)
?.takeIf { it.isNotEmpty() }
return GatewayAsk(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
text = payload.string("question") ?: "The agent needs clarification",
choices = choices,
multiSelect = payload.boolean("multi_select") == true && choices != null,
// Upstream owns its configurable deadline; only consume advertised metadata.
timeoutSeconds = payload.int("timeout_seconds")?.coerceAtLeast(0) ?: 0,
questions = questions,
answers = (payload?.get("answers") as? JsonObject)?.mapNotNull { (qid, value) ->
(value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull
?.takeIf { questions.any { q -> q.qid == qid } }?.let { qid to it }
}?.toMap().orEmpty(),
)
}
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
"clarify.expire" -> GatewayAskExpiry(
kind = GatewayAsk.Kind.CLARIFY,
@@ -624,9 +698,38 @@ class GatewayEventMapper(
// Upstream clarify tool accepts at most four choices. Sudo/secret retain fixed
// `_block()` timeouts; clarify is configurable and expires authoritatively.
private const val MAX_CLARIFY_CHOICES = 4
private const val MAX_CLARIFY_QUESTIONS = 5
private const val SUDO_TIMEOUT_SECONDS = 120
private const val SECRET_TIMEOUT_SECONDS = 300
private data class GatewayToolIdentity(
val name: String,
val argsPreview: String?,
)
/**
* Older Tool Search gateways can expose the model-visible `tool_call` bridge
* instead of the effective tool identity that current upstream callbacks use.
* Unwrap only the bridge's explicit structured `{name, arguments}` envelope;
* never infer a tool from prompt text or result content.
*/
private fun JsonObject?.effectiveToolIdentity(): GatewayToolIdentity {
val outerName = string("name") ?: "unknown"
val outerArgs = this?.get("args") as? JsonObject
if (outerName != "tool_call" || outerArgs == null) {
return GatewayToolIdentity(outerName, null)
}
val effectiveName = outerArgs.string("name")
?.trim()
?.takeIf { it.isNotEmpty() }
?: return GatewayToolIdentity(outerName, null)
val effectiveArgs = outerArgs["arguments"]
?.takeUnless { it is JsonPrimitive && it.contentOrNull.isNullOrBlank() }
?.toString()
?.takeIf { it.isNotBlank() && it != "null" }
return GatewayToolIdentity(effectiveName, effectiveArgs)
}
private fun JsonObject?.string(key: String): String? =
(this?.get(key) as? JsonPrimitive)?.contentOrNull
@@ -2,11 +2,14 @@ package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.UsageInfo
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* Shared types for the Gateway chat transport — upstream hermes-agent's
@@ -230,10 +233,35 @@ data class GatewayAsk(
* authoritative `*.expire` event still retires the interaction.
*/
val timeoutSeconds: Int,
val questions: List<GatewayClarifyQuestion> = emptyList(),
val answers: Map<String, String> = emptyMap(),
) {
enum class Kind { CLARIFY, APPROVAL, SUDO, SECRET }
val clarifyComplete: Boolean get() = questions.isNotEmpty() && questions.all { it.qid in answers }
/** In-memory request incarnation shared when a live ask moves between turn mappers. */
internal var ownershipToken = GatewayAskOwnership(answers)
private set
internal fun withAnswers(answers: Map<String, String>, owner: GatewayAsk = this): GatewayAsk =
copy(answers = owner.ownershipToken.answers.updateAndGet { it + answers })
.also { it.ownershipToken = owner.ownershipToken }
}
/** A detached/reclaimed mapper shares confirmed progress with an RPC still owned by its predecessor. */
internal class GatewayAskOwnership(answers: Map<String, String>) {
val answers = AtomicReference(answers.toMap())
val retired = AtomicBoolean(false)
}
@Serializable
data class GatewayClarifyQuestion(
val qid: String,
val question: String,
val choices: List<String> = emptyList(),
val multiSelect: Boolean = false,
)
/**
* Server-side expiry of one blocking gateway interaction. Sudo/secret asks
* correlate by [requestId]; approvals remain session-scoped and therefore
@@ -293,6 +321,8 @@ data class GatewaySubagentEvent(
val depth: Int? = null,
/** Effective child model, when the emitter exposes it. */
val model: String? = null,
/** Exact delegation group id shared with persisted async completion metadata. */
val delegationId: String? = null,
) {
enum class Phase { SPAWN_REQUESTED, START, THINKING, TOOL, PROGRESS, COMPLETE }
}
@@ -726,10 +726,10 @@ class HermesApiClient(
suspend fun getMessages(
sessionId: String,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
mode: SessionMessageLoadMode = SessionMessageLoadMode.LATEST,
): List<MessageItem> = withContext(Dispatchers.IO) {
loadSessionMessages(mode) { page ->
runCatching {
try {
val url = "$baseUrl/api/sessions/$sessionId/messages".toHttpUrlOrNull()
?.newBuilder()
?.addQueryParameter("limit", page.limit.toString())
@@ -740,14 +740,20 @@ class HermesApiClient(
val request = authRequest(url.toString()).get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
val body = response.body?.string() ?: error("empty response body")
val body = response.body.readUtf8Bounded(
DashboardApiClient.MAX_JSON_RESPONSE_BYTES,
)
val parsed = json.decodeFromString<MessageListResponse>(body)
SessionMessagePage(
messages = parsed.data ?: parsed.items ?: parsed.messages ?: emptyList(),
pagination = parsed.pagination,
payloadChars = body.length,
)
}
}.let { Result.success(it) }
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Result.failure(error)
}
}.getOrElse { error ->
if (error is CancellationException) throw error
@@ -72,7 +72,7 @@ internal suspend fun loadSessionMessages(
Result.success(collected)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
} catch (error: Exception) {
Result.failure(error)
}
}
@@ -195,7 +195,7 @@ class StandardHermesVoiceClient(
.let { Result.success<VoiceSpeechStream?>(it) }
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
} catch (error: Exception) {
Result.failure(error)
}
}
@@ -222,7 +222,9 @@ class StandardHermesVoiceClient(
if (!response.isSuccessful) {
return Result.failure(apiFailure(response, operation))
}
val body = response.body.string()
val body = response.body.readUtf8Bounded(
DashboardApiClient.MAX_JSON_RESPONSE_BYTES,
)
if (body.isBlank()) {
return Result.failure(IOException("$operation returned an empty response"))
}
@@ -245,7 +247,11 @@ class StandardHermesVoiceClient(
}
private fun apiFailure(response: Response, operation: String): IOException {
val body = runCatching { response.body.string() }.getOrDefault("")
val body = try {
response.body.readUtf8Bounded(4L * 1024L)
} catch (_: Exception) {
""
}
val detail = body.takeIf { it.isNotBlank() } ?: response.message
val message = when (response.code) {
400 -> "$operation rejected that input - ${detail.ifBlank { "bad request" }}"
@@ -351,14 +351,18 @@ internal class HermesRuntimeBinder(
connection.activeConnectionId,
connection.effectiveSessionProfileName,
connection.lastSessionId,
connection.activeEndpoint,
) { ready, connectionId, profileName, sessionId, activeEndpoint ->
// The session directory belongs to the standard Dashboard
// route. connection.activeEndpoint is the optional Relay
// socket's selected candidate and stays null on a valid
// Dashboard-only LAN connection.
connection.effectiveDashboardUrl,
) { ready, connectionId, profileName, sessionId, dashboardUrl ->
ProfileContextInputs(
ready,
connectionId,
profileName,
sessionId,
dashboardRouteResolved = activeEndpoint != null,
dashboardUrl = dashboardUrl,
)
}
combine(
@@ -374,7 +378,7 @@ internal class HermesRuntimeBinder(
)
}.collectLatest { inputs ->
profileContextReady.value = false
if (!shouldRefreshSessionDirectory(inputs.chatReady, inputs.dashboardRouteResolved)) {
if (!shouldRefreshSessionDirectory(inputs.chatReady, inputs.dashboardUrl)) {
return@collectLatest
}
if (!inputs.profileSelectionSettled) {
@@ -600,7 +604,7 @@ internal class HermesRuntimeBinder(
val connectionId: String?,
val profileName: String?,
val sessionId: String?,
val dashboardRouteResolved: Boolean,
val dashboardUrl: String,
val profileSelectionSettled: Boolean = false,
val profileLocked: Boolean = false,
val hiddenSources: Set<String> = emptySet(),
@@ -632,12 +636,13 @@ internal class HermesRuntimeBinder(
/**
* Session browsing is Dashboard HTTP state, not Gateway-socket state. API-only
* connections still use chat readiness; Dashboard connections can refresh once
* the resolver has selected a live route, after the profile-settle fence.
* their persisted/resolved Dashboard origin publishes, after the profile-settle
* fence. The optional Relay endpoint is deliberately not part of this decision.
*/
internal fun shouldRefreshSessionDirectory(
chatReady: Boolean,
dashboardRouteResolved: Boolean,
): Boolean = chatReady || dashboardRouteResolved
dashboardUrl: String,
): Boolean = chatReady || dashboardUrl.isNotBlank()
internal fun assistantCanTransmitScreenContext(engineMode: VoiceEngineMode): Boolean =
engineMode == VoiceEngineMode.HermesVoiceOutput
@@ -36,7 +36,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
@@ -230,11 +230,7 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
// Short-lived snackbar by default; retryable errors get Long so users have
// time to tap the action before it auto-dismisses.
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
return showSnackbar(
message = err.body,
actionLabel = err.actionLabel,
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
)
return showSnackbar(com.hermesandroid.relay.ui.components.HumanErrorVisuals(err))
}
/** Startup chrome should wait for either standard chat surface, not Relay. */
@@ -1952,6 +1948,8 @@ fun RelayApp() {
// child TopAppBar doesn't double-pad when this banner owns the top edge.
val activeMessageCount by UiMessageBus.activeCount.collectAsState()
val showMessageBanner = activeMessageCount > 0
val modalMessageHostActive by UiMessageBus.modalHostActive.collectAsState()
val showActionMessage = snackbarHostState.currentSnackbarData != null && !modalMessageHostActive
// Update availability (unified): googlePlay = Play In-App Update FLEXIBLE,
// sideload = GitHub releases. The handle filters dismissed versions +
// throttles checks internally, exposing a surfaceable status for the
@@ -2077,6 +2075,14 @@ fun RelayApp() {
includeStatusBarPadding =
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure,
)
// Action feedback owns layout space at the top, never the composer's
// touch area. Modal windows keep their own scoped host.
ThemedMessageHost(
snackbarHostState,
modifier = if (showActionMessage && !showMessageBanner &&
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure
) Modifier.windowInsetsPadding(WindowInsets.statusBars) else Modifier,
)
// The update banner AND the connection-status indicator now render as
// floating overlay TOASTS in the Box below (see the top-overlay Column
@@ -2117,7 +2123,7 @@ fun RelayApp() {
// participates in the top-inset accounting.
if (showUnattendedBanner || showDemoBanner || showHostResourcePressure ||
connectionChipVisible ||
showMessageBanner
showMessageBanner || showActionMessage
) {
Modifier.consumeWindowInsets(WindowInsets.statusBars)
} else {
@@ -2125,7 +2131,6 @@ fun RelayApp() {
}
),
contentWindowInsets = WindowInsets(0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (
!suppressGlobalChrome &&
@@ -2212,7 +2217,10 @@ fun RelayApp() {
}
}
) { innerPadding ->
CompositionLocalProvider(LocalSnackbarHost provides snackbarHostState) {
CompositionLocalProvider(
LocalSnackbarHost provides snackbarHostState,
com.hermesandroid.relay.ui.components.LocalMessageActionHost provides snackbarHostState,
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicLong
/** Visual tone of a transient banner message. */
enum class UiMessageSeverity { Info, Success, Status, Warning }
enum class UiMessageSeverity { Info, Success, Status, Warning, Error }
data class UiMessage(
val id: Long,
@@ -23,6 +23,7 @@ data class UiMessage(
sealed interface UiMessageEvent {
data class Show(val message: UiMessage) : UiMessageEvent
data class Clear(val key: String) : UiMessageEvent
data object ClearAll : UiMessageEvent
}
internal fun reduceUiMessages(
@@ -30,6 +31,7 @@ internal fun reduceUiMessages(
event: UiMessageEvent,
maxRetained: Int,
): List<UiMessage> = when (event) {
UiMessageEvent.ClearAll -> emptyList()
is UiMessageEvent.Clear -> current.filterNot { it.key == event.key }
is UiMessageEvent.Show -> {
val incoming = event.message
@@ -41,15 +43,14 @@ internal fun reduceUiMessages(
}
/**
* App-wide bus for transient, non-error status/confirmation messages that
* App-wide bus for transient status, confirmation, and error messages that
* surface in the top [com.hermesandroid.relay.ui.components.MessageBannerHost]
* — a thin banner that takes its own space (content slides down, no overlay),
* shows the newest line collapsed, expands to a few recent lines, auto-dismisses
* and coalesces duplicates.
*
* App-owned errors and persistent/actionable messages keep going to the
* snackbar so they demand acknowledgement. Upstream keyed AgentNotices may use
* the warning tone here because their own sticky/clear lifecycle owns them.
* Messages requiring Retry or Undo use ThemedMessageHost and retain their
* action-result contract. Upstream keyed AgentNotices own their sticky/clear lifecycle.
* Migrate frequent
* `snackbarHostState.showSnackbar("…")` confirmations/status to [info] /
* [success] / [status] here.
@@ -62,6 +63,23 @@ object UiMessageBus {
const val STATUS_TTL_MS = 6_000L
private val counter = AtomicLong(0L)
private val hosts = linkedMapOf<Long, Boolean>()
private val _activeHost = MutableStateFlow<Long?>(null)
internal val activeHost = _activeHost.asStateFlow()
private val _modalHostActive = MutableStateFlow(false)
internal val modalHostActive = _modalHostActive.asStateFlow()
internal fun registerHost(primary: Boolean): Long = synchronized(hosts) {
counter.incrementAndGet().also {
hosts[it] = primary
_activeHost.value = hosts.keys.lastOrNull()
_modalHostActive.value = hosts.values.any { primaryHost -> !primaryHost }
}
}
internal fun unregisterHost(id: Long) { synchronized(hosts) {
hosts.remove(id)
_activeHost.value = hosts.keys.lastOrNull()
_modalHostActive.value = hosts.values.any { !it }
} }
private val _events = MutableSharedFlow<UiMessageEvent>(extraBufferCapacity = 24)
val events: SharedFlow<UiMessageEvent> = _events.asSharedFlow()
@@ -97,6 +115,11 @@ object UiMessageBus {
key.trim().takeIf(String::isNotEmpty)?.let { _events.tryEmit(UiMessageEvent.Clear(it)) }
}
fun clearAll() { _events.tryEmit(UiMessageEvent.ClearAll) }
fun warning(text: String, ttlMillis: Long = STATUS_TTL_MS) = post(text, UiMessageSeverity.Warning, ttlMillis)
fun error(text: String, ttlMillis: Long = 10_000L) = post(text, UiMessageSeverity.Error, ttlMillis)
/** Neutral confirmation/info (e.g. "Pairing code copied"). */
fun info(text: String, ttlMillis: Long = DEFAULT_TTL_MS) =
post(text, UiMessageSeverity.Info, ttlMillis)
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -209,7 +209,7 @@ fun ActiveCardRelayStatusSection(
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
// Pre-resolve strings for Toast (non-composable context)
// Pre-resolve strings for action callbacks (non-composable context).
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
val relayStatusText = when (relayRowState.phase) {
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
@@ -250,11 +250,7 @@ fun ActiveCardRelayStatusSection(
onClick = {
if (relayUiState == RelayUiState.Stale) {
connectionViewModel.reconnectIfStale()
Toast.makeText(
context,
reconnectingRelayToast,
Toast.LENGTH_SHORT,
).show()
UiMessageBus.status(reconnectingRelayToast)
} else {
onOpenRelayInfo()
}
@@ -817,7 +813,7 @@ private fun ManualUrlSubsection(
}
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
// Pre-resolve strings for Toast (non-composable context)
// Pre-resolve strings for action callbacks (non-composable context).
val apiHermesVoiceReachableToast = stringResource(R.string.active_section_api_hermes_voice_reachable)
val apiRelayVoiceReachableToast = stringResource(R.string.active_section_api_relay_voice_reachable)
val apiReachableVoiceReviewToast = stringResource(R.string.active_section_api_reachable_voice_review)
@@ -926,21 +922,21 @@ private fun ManualUrlSubsection(
relayOverrideVisible = true
result.relayUrl?.let { relayUrlInput = it }
}
Toast.makeText(
context,
when {
result.apiReachable && result.voiceConfigReachable ->
if (result.voiceRoute == "standard") {
apiHermesVoiceReachableToast
} else {
apiRelayVoiceReachableToast
}
result.apiReachable ->
apiReachableVoiceReviewToast
else -> cannotReachApiToast
},
Toast.LENGTH_SHORT,
).show()
val feedback = when {
result.apiReachable && result.voiceConfigReachable ->
if (result.voiceRoute == "standard") {
apiHermesVoiceReachableToast
} else {
apiRelayVoiceReachableToast
}
result.apiReachable -> apiReachableVoiceReviewToast
else -> cannotReachApiToast
}
when {
result.apiReachable && result.voiceConfigReachable -> UiMessageBus.success(feedback)
result.apiReachable -> UiMessageBus.warning(feedback)
else -> UiMessageBus.error(feedback)
}
}
},
enabled = apiUrlInput.isNotBlank() && !isTestingApi && inputApiKeyError == null,
@@ -9,7 +9,7 @@ import android.media.audiofx.Visualizer
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.annotation.OptIn
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -312,110 +312,112 @@ fun AttachmentViewer(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
MessageOverlayScope {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val blurMode = LocalMediaBlurMode.current
var revealed by remember(attachment.cachedUri, attachment.relayToken) {
mutableStateOf(initiallyRevealed)
}
val blurred = !revealed &&
attachment.renderMode == AttachmentRenderMode.IMAGE &&
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
// --- One shared Share / Save / Open-externally action set ----------
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
scope.launch {
busy = true
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
busy = false
viewerToast(context, context.getString(R.string.inbound_attach_share_failed))
return@launch
}
action(bytes)
busy = false
val blurMode = LocalMediaBlurMode.current
var revealed by remember(attachment.cachedUri, attachment.relayToken) {
mutableStateOf(initiallyRevealed)
}
}
val blurred = !revealed &&
attachment.renderMode == AttachmentRenderMode.IMAGE &&
shouldBlurImage(blurMode, attachment.sensitive)
val onShare = {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
}
val onSave = {
runWithBytes { bytes ->
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
MediaSaver.saveImage(context, bytes, attachment.fileName, attachment.contentType)
} else {
MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)
}
when (result) {
is MediaSaver.SaveResult.Saved ->
viewerToast(context, context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
// --- One shared Share / Save / Open-externally action set ----------
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
scope.launch {
busy = true
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
busy = false
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
return@launch
}
is MediaSaver.SaveResult.Failed ->
viewerToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
action(bytes)
busy = false
}
}
}
val onOpenExternal = {
val cached = attachment.cachedUri
if (!cached.isNullOrBlank()) {
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
} else {
val onShare = {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.open(context, uri, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f))
.testTag("attachment-viewer"),
) {
// Body fills; toolbar floats on top. PDF/TEXT add their own top
// inset so the first line clears the toolbar.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (attachment.renderMode) {
AttachmentRenderMode.IMAGE -> ImageBody(
attachment = attachment,
blurred = blurred,
onReveal = { revealed = true },
)
AttachmentRenderMode.VIDEO -> VideoBody(attachment)
AttachmentRenderMode.AUDIO -> AudioBody(attachment)
AttachmentRenderMode.PDF -> PdfBody(attachment)
AttachmentRenderMode.TEXT -> TextBody(attachment)
AttachmentRenderMode.GENERIC -> GenericBody(attachment, onOpenExternal)
val onSave = {
runWithBytes { bytes ->
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
MediaSaver.saveImage(context, bytes, attachment.fileName, attachment.contentType)
} else {
MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)
}
when (result) {
is MediaSaver.SaveResult.Saved ->
UiMessageBus.success(context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
UiMessageBus.error(context.getString(R.string.inbound_attach_save_failed, result.message))
}
}
}
val onOpenExternal = {
val cached = attachment.cachedUri
if (!cached.isNullOrBlank()) {
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
} else {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.open(context, uri, attachment.contentType)
}
}
}
MediaViewerToolbar(
title = title,
busy = busy,
actionsEnabled = !blurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f))
.testTag("attachment-viewer"),
) {
// Body fills; toolbar floats on top. PDF/TEXT add their own top
// inset so the first line clears the toolbar.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (attachment.renderMode) {
AttachmentRenderMode.IMAGE -> ImageBody(
attachment = attachment,
blurred = blurred,
onReveal = { revealed = true },
)
AttachmentRenderMode.VIDEO -> VideoBody(attachment)
AttachmentRenderMode.AUDIO -> AudioBody(attachment)
AttachmentRenderMode.PDF -> PdfBody(attachment)
AttachmentRenderMode.TEXT -> TextBody(attachment)
AttachmentRenderMode.GENERIC -> GenericBody(attachment, onOpenExternal)
}
}
MediaViewerToolbar(
title = title,
busy = busy,
actionsEnabled = !blurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
}
}
@@ -452,164 +454,165 @@ internal fun AttachmentGalleryViewer(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val revealed = remember { mutableStateMapOf<String, Boolean>() }
LaunchedEffect(initiallyRevealedKeys) {
initiallyRevealedKeys.forEach { revealed[it] = true }
}
val pagerState = rememberPagerState(
initialPage = initialIndex.coerceIn(attachments.indices),
pageCount = { attachments.size },
)
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
val attachment = attachments[currentIndex]
val currentKey = galleryAttachmentKey(attachment, currentIndex)
val blurMode = LocalMediaBlurMode.current
val currentBlurred = revealed[currentKey] != true &&
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
// Capture the currently visible attachment in each click lambda. A
// swipe while IO is running must not redirect Save/Share to a new page.
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
if (currentBlurred || busy) return
val target = attachment
scope.launch {
busy = true
try {
val bytes = attachmentBytes(context, target)
if (bytes == null) {
viewerToast(context, "Couldn't read this image")
return@launch
}
action(target, bytes)
} catch (error: Exception) {
viewerToast(
context,
error.message?.takeIf { it.isNotBlank() }
?: "Couldn't complete that image action",
)
} finally {
busy = false
}
MessageOverlayScope {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val revealed = remember { mutableStateMapOf<String, Boolean>() }
LaunchedEffect(initiallyRevealedKeys) {
initiallyRevealedKeys.forEach { revealed[it] = true }
}
}
val pagerState = rememberPagerState(
initialPage = initialIndex.coerceIn(attachments.indices),
pageCount = { attachments.size },
)
val onShare = {
runWithBytes { target, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
)
MediaSaver.share(context, uri, target.contentType)
}
}
val onSave = {
runWithBytes { target, bytes ->
when (val result = MediaSaver.saveImage(
context,
bytes,
target.fileName,
target.contentType,
)) {
is MediaSaver.SaveResult.Saved ->
viewerToast(context, "Saved to ${result.location}")
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
val attachment = attachments[currentIndex]
val currentKey = galleryAttachmentKey(attachment, currentIndex)
val blurMode = LocalMediaBlurMode.current
val currentBlurred = revealed[currentKey] != true &&
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
// Capture the currently visible attachment in each click lambda. A
// swipe while IO is running must not redirect Save/Share to a new page.
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
if (currentBlurred || busy) return
val target = attachment
scope.launch {
busy = true
try {
val bytes = attachmentBytes(context, target)
if (bytes == null) {
UiMessageBus.error("Couldn't read this image")
return@launch
}
action(target, bytes)
} catch (error: Exception) {
UiMessageBus.error(
error.message?.takeIf { it.isNotBlank() }
?: "Couldn't complete that image action",
)
MediaSaver.share(context, uri, target.contentType)
} finally {
busy = false
}
is MediaSaver.SaveResult.Failed ->
viewerToast(context, "Save failed: ${result.message}")
}
}
}
val onOpenExternal: () -> Unit = openExternal@{
if (currentBlurred || busy) return@openExternal
val target = attachment
val cached = target.cachedUri
if (!cached.isNullOrBlank()) {
runCatching {
MediaSaver.open(context, Uri.parse(cached), target.contentType)
}.onFailure {
viewerToast(context, "Couldn't open this image")
}
} else {
runWithBytes { item, bytes ->
val onShare = {
runWithBytes { target, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
item.fileName,
item.contentType,
target.fileName,
target.contentType,
)
MediaSaver.open(context, uri, item.contentType)
MediaSaver.share(context, uri, target.contentType)
}
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f)),
) {
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 0,
pageSpacing = 12.dp,
modifier = Modifier
.fillMaxSize()
.testTag("attachment-gallery-pager"),
) { page ->
val pageAttachment = attachments[page]
val pageKey = galleryAttachmentKey(pageAttachment, page)
val blurred = revealed[pageKey] != true && shouldBlurImage(
blurMode,
pageAttachment.sensitive,
)
ImageBody(
attachment = pageAttachment,
blurred = blurred,
onReveal = { revealed[pageKey] = true },
)
val onSave = {
runWithBytes { target, bytes ->
when (val result = MediaSaver.saveImage(
context,
bytes,
target.fileName,
target.contentType,
)) {
is MediaSaver.SaveResult.Saved ->
UiMessageBus.success("Saved to ${result.location}")
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
)
MediaSaver.share(context, uri, target.contentType)
}
is MediaSaver.SaveResult.Failed ->
UiMessageBus.error("Save failed: ${result.message}")
}
}
}
val onOpenExternal: () -> Unit = openExternal@{
if (currentBlurred || busy) return@openExternal
val target = attachment
val cached = target.cachedUri
if (!cached.isNullOrBlank()) {
runCatching {
MediaSaver.open(context, Uri.parse(cached), target.contentType)
}.onFailure {
UiMessageBus.error("Couldn't open this image")
}
} else {
runWithBytes { item, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
item.fileName,
item.contentType,
)
MediaSaver.open(context, uri, item.contentType)
}
}
}
MediaViewerToolbar(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f)),
) {
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 0,
pageSpacing = 12.dp,
modifier = Modifier
.fillMaxSize()
.testTag("attachment-gallery-pager"),
) { page ->
val pageAttachment = attachments[page]
val pageKey = galleryAttachmentKey(pageAttachment, page)
val blurred = revealed[pageKey] != true && shouldBlurImage(
blurMode,
pageAttachment.sensitive,
)
ImageBody(
attachment = pageAttachment,
blurred = blurred,
onReveal = { revealed[pageKey] = true },
)
}
Text(
text = "${currentIndex + 1} / ${attachments.size}",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 12.dp, vertical = 6.dp),
)
MediaViewerToolbar(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Text(
text = "${currentIndex + 1} / ${attachments.size}",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
}
@@ -1191,10 +1194,6 @@ private fun rememberPlayableUri(attachment: Attachment): Uri? {
return uri
}
private fun viewerToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
private const val MAX_TEXT_BYTES = 2 * 1024 * 1024
// --- Non-composable IO helpers ---------------------------------------------
@@ -0,0 +1,118 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
/** Stable transcript entry into the same read-only preview used by active work. */
@Composable
internal fun ChatActivityReceipt(
record: ChatActivityRecord,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val subagents = record.kind == ChatActivityKind.SUBAGENTS
val title = stringResource(
if (subagents) R.string.chat_activity_receipt_subagents else R.string.chat_activity_receipt_process,
)
val action = stringResource(
if (subagents) R.string.chat_activity_receipt_view_activity else R.string.chat_activity_receipt_view_output,
)
val status = if (subagents && record.children.isNotEmpty()) {
val groups = record.children.groupingBy { it.phase }.eachCount().toMutableMap()
val missing = (record.taskCount - record.children.size).coerceAtLeast(0)
if (missing > 0) groups[ChatActivityPhase.UNKNOWN] = (groups[ChatActivityPhase.UNKNOWN] ?: 0) + missing
val labels = groups.map { (phase, count) ->
stringResource(R.string.chat_activity_receipt_count_phase, count, activityPhaseLabel(phase))
}
labels.joinToString(" · ")
} else if (subagents && record.taskCount > 0) {
stringResource(R.string.chat_activity_receipt_count_phase, record.taskCount, activityPhaseLabel(record.phase))
} else {
activityPhaseLabel(record.phase)
}
Surface(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.semantics(mergeDescendants = true) { stateDescription = status }
.clickable(role = Role.Button, onClickLabel = action, onClick = onClick),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (subagents) Icons.Filled.AccountTree else Icons.Filled.Terminal,
contentDescription = null,
tint = if (record.phase == ChatActivityPhase.FAILED) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "$title · $status",
style = MaterialTheme.typography.labelMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = action,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
@Composable
private fun activityPhaseLabel(phase: ChatActivityPhase): String = stringResource(
when (phase) {
ChatActivityPhase.RUNNING -> R.string.bg_processes_running
ChatActivityPhase.COMPLETE -> R.string.agent_activity_status_completed
ChatActivityPhase.FAILED -> R.string.agent_activity_status_failed
ChatActivityPhase.CANCELLED -> R.string.task_status_cancelled
ChatActivityPhase.UNKNOWN -> R.string.agent_activity_status_unavailable
},
)
@@ -0,0 +1,215 @@
package com.hermesandroid.relay.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
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.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex
import com.hermesandroid.relay.ui.theme.appearanceTopRoundedCornerShape
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.BusyMessageAction
private val COMPOSER_TRAY_UNDERLAP = 12.dp
/** A separate rear surface tucked beneath the composer's foreground edge. */
@Composable
fun ChatComposerLayers(
action: BusyMessageAction?,
onActionChange: (BusyMessageAction) -> Unit,
correctionAvailable: Boolean,
onStop: (() -> Unit)?,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(-COMPOSER_TRAY_UNDERLAP),
) {
if (action != null) {
ChatBusyActionSelector(
action, onActionChange,
correctionAvailable = correctionAvailable,
onStop = onStop,
bottomUnderlap = COMPOSER_TRAY_UNDERLAP,
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).zIndex(0f),
)
}
Box(Modifier.fillMaxWidth().zIndex(1f).testTag("chatComposerForeground")) { content() }
}
}
/** Compact current intent; optional plain-text choices slide out from behind it. */
@Composable
fun ChatBusyActionSelector(
action: BusyMessageAction,
onActionChange: (BusyMessageAction) -> Unit,
modifier: Modifier = Modifier,
correctionAvailable: Boolean = true,
onStop: (() -> Unit)? = null,
bottomUnderlap: Dp = 0.dp,
) {
var expanded by remember { mutableStateOf(false) }
BackHandler(enabled = expanded) { expanded = false }
Surface(
modifier = modifier.testTag("chatBusyActionTray"),
color = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.82f),
shape = appearanceTopRoundedCornerShape(12.dp),
tonalElevation = 0.dp,
) {
Column(Modifier.padding(bottom = bottomUnderlap)) {
AnimatedVisibility(
visible = expanded,
enter = expandVertically(tween(220), expandFrom = Alignment.Bottom) +
slideInVertically(tween(220)) { it } + fadeIn(tween(160)),
exit = shrinkVertically(tween(180), shrinkTowards = Alignment.Bottom) +
slideOutVertically(tween(180)) { it } + fadeOut(tween(120)),
) {
Row(
Modifier.fillMaxWidth().testTag("chatBusyActionDrawer").padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BusyMessageAction.entries.forEach { option ->
val available = option == BusyMessageAction.QueueNext || correctionAvailable
Row(
Modifier.weight(1f).heightIn(min = 48.dp)
.testTag("chatBusyAction-${option.storedValue}")
.selectable(selected = option == action, enabled = available, role = Role.RadioButton) {
onActionChange(option)
expanded = false
}.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (option == action) {
Icon(Icons.Default.Check, null, Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurface)
} else Spacer(Modifier.size(14.dp))
Text(
stringResource(if (option == BusyMessageAction.CorrectNow) R.string.chat_correct_now else R.string.chat_queue_next),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (available) 1f else 0.38f),
)
}
}
}
}
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
Modifier.weight(1f).heightIn(min = 40.dp)
.testTag("chatBusyActionTrigger")
.clickable(role = Role.Button) { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
stringResource(if (action == BusyMessageAction.CorrectNow) R.string.chat_correct_now else R.string.chat_queue_next),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
if (expanded) Icons.Default.ExpandMore else Icons.Default.ExpandLess,
null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (onStop != null) {
IconButton(onClick = onStop) {
Icon(Icons.Default.Stop, stringResource(R.string.chat_input_stop_streaming), Modifier.size(18.dp))
}
}
}
}
}
}
@Composable
fun ChatMessageQueue(
messages: List<String>,
paused: Boolean,
onResume: () -> Unit,
onClear: () -> Unit,
onEdit: (Int) -> Unit,
onRemove: (Int) -> Unit,
canEdit: Boolean,
modifier: Modifier = Modifier,
) {
if (messages.isEmpty()) return
Column(modifier) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
if (paused) stringResource(R.string.chat_queue_paused)
else pluralStringResource(R.plurals.chat_queue_count, messages.size, messages.size),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.weight(1f),
)
if (paused) TextButton(onClick = onResume) { Text(stringResource(R.string.chat_queue_resume)) }
TextButton(onClick = onClear) { Text(stringResource(R.string.chat_clear)) }
}
Column(
Modifier.heightIn(max = 144.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
messages.forEachIndexed { index, text ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = text,
maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
.clickable(enabled = canEdit, role = Role.Button) { onEdit(index) }
.padding(vertical = 12.dp),
)
IconButton(onClick = { onRemove(index) }) {
Icon(Icons.Default.Close, stringResource(R.string.chat_queue_remove))
}
}
}
}
}
}
@@ -0,0 +1,197 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.activity.compose.BackHandler
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.HorizontalDivider
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.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.viewmodel.ConnectionStepState
@Composable
internal fun Modifier.chatDebugHeaderGesture(enabled: Boolean, onClick: () -> Unit, onHold: () -> Unit): Modifier =
combinedClickable(enabled = enabled, onClick = onClick, onLongClick = onHold,
onLongClickLabel = stringResource(R.string.chat_debug_open))
@Composable
internal fun BoxScope.ChatDebugOverlay(
visible: Boolean,
headerHeight: Dp,
onClose: () -> Unit,
content: @Composable () -> Unit,
) {
BackHandler(enabled = visible, onBack = onClose)
if (visible) {
Box(Modifier.fillMaxSize().padding(top = headerHeight)
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.28f))
.clickable(onClick = onClose))
}
AnimatedVisibility(
visible = visible,
enter = slideInVertically { -it } + fadeIn(),
exit = slideOutVertically { -it } + fadeOut(),
modifier = Modifier.align(Alignment.TopCenter).padding(top = headerHeight).fillMaxWidth().clipToBounds(),
) { content() }
}
/** Read-only snapshot of the visible conversation; opening it sends no RPCs. */
@Composable
internal fun ChatDebugDrawer(
profile: String,
model: String,
sessionId: String?,
gateway: Boolean,
signedIn: Boolean,
signInRequired: Boolean,
socketState: GatewayConnectionState,
preparing: Boolean,
streaming: Boolean,
loadingHistory: Boolean,
directoryUnavailable: Boolean,
failure: String?,
onClose: () -> Unit,
onConnections: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = stringResource(R.string.chat_debug_title)
val safeFailure = DiagnosticsLog.redactReportText(failure)?.take(600)
val ready = socketState == GatewayConnectionState.Ready
val steps = buildList {
if (gateway) {
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_sign_in),
stringResource(when {
signInRequired -> R.string.chat_debug_sign_in_needed
signedIn -> R.string.chat_debug_authenticated
else -> R.string.chat_debug_auth_unknown
}),
when {
signInRequired -> ConnectionStepState.Failed
signedIn -> ConnectionStepState.Done
else -> ConnectionStepState.Pending
},
))
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_gateway),
stringResource(when (socketState) {
GatewayConnectionState.Ready -> R.string.chat_debug_socket_ready
GatewayConnectionState.MintingTicket -> R.string.chat_debug_ticket
GatewayConnectionState.Connecting -> R.string.chat_debug_socket_connecting
GatewayConnectionState.AwaitingReady -> R.string.chat_debug_socket_waiting
GatewayConnectionState.Idle -> R.string.chat_debug_socket_idle
}),
when {
ready -> ConnectionStepState.Done
signInRequired -> ConnectionStepState.Failed
socketState != GatewayConnectionState.Idle -> ConnectionStepState.Active
else -> ConnectionStepState.Pending
},
))
}
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_session),
stringResource(when {
preparing -> R.string.chat_debug_preparing_detail
loadingHistory -> R.string.chat_debug_history_loading
directoryUnavailable -> R.string.chat_debug_history_failed
sessionId != null -> R.string.chat_debug_session_selected
else -> R.string.chat_debug_session_new
}),
when {
preparing || loadingHistory -> ConnectionStepState.Active
directoryUnavailable -> ConnectionStepState.Failed
sessionId != null -> ConnectionStepState.Done
else -> ConnectionStepState.Pending
},
))
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_response),
stringResource(when {
safeFailure != null -> R.string.chat_debug_response_failed
preparing -> R.string.chat_debug_response_waiting
streaming -> R.string.chat_debug_response_active
else -> R.string.chat_debug_response_idle
}),
when {
safeFailure != null -> ConnectionStepState.Failed
preparing -> ConnectionStepState.Pending
streaming -> ConnectionStepState.Active
else -> ConnectionStepState.Pending
},
))
}
Surface(
modifier = modifier.fillMaxWidth().semantics { paneTitle = title },
shape = RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp,
) {
Column(Modifier.heightIn(max = 560.dp).verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleLarge)
Text(listOf(if (gateway) "Gateway" else "Direct API", profile, model)
.filter(String::isNotBlank).joinToString(" · "), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, stringResource(R.string.chat_debug_close))
}
}
ConnectionSetupTimeline(steps)
if (safeFailure != null) {
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = RoundedCornerShape(12.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(safeFailure, style = MaterialTheme.typography.bodySmall)
if (safeFailure.contains("agent init failed", ignoreCase = true)) {
Text(stringResource(R.string.chat_debug_init_recovery), style = MaterialTheme.typography.bodySmall)
}
}
}
}
HorizontalDivider()
Text(stringResource(R.string.chat_debug_session_id, sessionId ?: "—"),
style = MaterialTheme.typography.labelSmall, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton(onClick = onConnections) { Text(stringResource(R.string.chat_debug_connections)) }
}
}
}
@@ -53,7 +53,13 @@ fun ChatFailurePanel(
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.chat_failure_title),
text = stringResource(
if (isInferenceUnavailableFailure(failure.rawError)) {
R.string.chat_failure_inference_unavailable
} else {
R.string.chat_failure_title
},
),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
@@ -148,3 +154,10 @@ internal fun failureIdentity(route: String, model: String?, provider: String?):
provider?.trim()?.takeIf { it.isNotEmpty() },
model?.trim()?.takeIf { it.isNotEmpty() },
).distinct().joinToString(" · ")
internal fun isInferenceUnavailableFailure(rawError: String): Boolean {
val message = rawError.lowercase()
return "agent init failed" in message ||
"no codex credentials stored" in message ||
"runtime resolution still failed" in message
}
@@ -505,15 +505,17 @@ private fun RelayServerImage(
phase = withContext(Dispatchers.IO) {
val result = try {
resolver.fetch(image.src)
} catch (t: Throwable) {
ServerImageResult.Failure(t.message ?: "relay fetch failed")
} catch (error: Exception) {
ServerImageResult.Failure(error.message ?: "relay fetch failed")
}
when (result) {
is ServerImageResult.Success -> {
val cachedUri = Uri.parse(result.cachedUri)
val bmp = runCatching {
decodeOrientedBitmap(context, cachedUri)
}.getOrNull()?.asImageBitmap()
val bmp = try {
decodeBoundedOrientedBitmap(context, cachedUri)
} catch (_: Exception) {
null
}?.asImageBitmap()
if (bmp != null) {
putInlineImage(image.src, bmp, result.sensitive)
RelayImagePhase.Loaded(bmp, result.sensitive)
@@ -2,7 +2,7 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -107,135 +107,141 @@ fun ChatImageViewer(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
MessageOverlayScope {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
val blurMode = LocalMediaBlurMode.current
var revealed by remember(source) { mutableStateOf(initiallyRevealed) }
val blurred = !revealed && shouldBlurImage(blurMode, sensitive)
val blurMode = LocalMediaBlurMode.current
var revealed by remember(source) { mutableStateOf(initiallyRevealed) }
val blurred = !revealed && shouldBlurImage(blurMode, sensitive)
val gestureModifier = Modifier.fillMaxSize().zoomable()
val gestureModifier = Modifier.fillMaxSize().zoomable()
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.94f)),
contentAlignment = Alignment.Center,
) {
BlurredMedia(
blurred = blurred,
onReveal = { revealed = true },
modifier = Modifier.fillMaxSize(),
) {
when (source) {
is ChatImageViewerSource.Coil -> AsyncImage(
model = source.model,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
is ChatImageViewerSource.Bitmap -> Image(
bitmap = source.bitmap,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
}
}
if (busy) {
CircularProgressIndicator(color = Color.White)
}
// Control bar — top-right, inset past the status bar / notch.
Row(
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.94f)),
contentAlignment = Alignment.Center,
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
val cdClose = stringResource(R.string.cd_close_viewer)
val errorMsg = context.getString(R.string.image_viewer_error)
if (exportAllowed) {
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
busy = false
if (bytes == null) {
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = runCatching { source.bytesProvider() }.getOrNull()
if (bytes == null) {
busy = false
toast(context, errorMsg)
return@launch
}
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
toast(context, savedFmt.format(result.location))
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, failedFmt.format(result.message))
}
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = cdSave)
BlurredMedia(
blurred = blurred,
onReveal = { revealed = true },
modifier = Modifier.fillMaxSize(),
) {
when (source) {
is ChatImageViewerSource.Coil -> AsyncImage(
model = source.model,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
is ChatImageViewerSource.Bitmap -> Image(
bitmap = source.bitmap,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
}
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = cdClose)
if (busy) {
CircularProgressIndicator(color = Color.White)
}
// Control bar — top-right, inset past the status bar / notch.
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
val cdClose = stringResource(R.string.cd_close_viewer)
val errorMsg = context.getString(R.string.image_viewer_error)
if (exportAllowed) {
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = try {
source.bytesProvider()
} catch (_: Exception) {
null
}
busy = false
if (bytes == null) {
UiMessageBus.error(errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = try {
source.bytesProvider()
} catch (_: Exception) {
null
}
if (bytes == null) {
busy = false
UiMessageBus.error(errorMsg)
return@launch
}
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
UiMessageBus.success(savedFmt.format(result.location))
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
UiMessageBus.error(failedFmt.format(result.message))
}
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = cdSave)
}
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = cdClose)
}
}
}
}
}
}
private fun toast(context: android.content.Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.BusyMessageAction
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@@ -191,6 +192,9 @@ fun ChatInputBar(
physicalEnterSends: Boolean = true,
largePasteThreshold: Int? = null,
onLargePaste: (String) -> Unit = {},
busyAction: BusyMessageAction? = null,
correctionAvailable: Boolean = true,
onBusyActionChange: (BusyMessageAction) -> Unit = {},
) {
val canSubmit = enabled && submitEnabled && trailing in setOf(
ChatInputTrailing.SEND,
@@ -236,7 +240,7 @@ fun ChatInputBar(
// Correction and queueing are materially different actions. Keep the
// explanation, but lead with a visible state pill so the distinction
// does not depend on the trailing icon or accent color alone.
AnimatedVisibility(visible = caption != null) {
AnimatedVisibility(visible = caption != null && busyAction == null) {
val detail = caption ?: lastCaption.orEmpty()
val actionLabel = when (trailing) {
ChatInputTrailing.STEER -> stringResource(R.string.chat_input_steer_response)
@@ -319,6 +323,12 @@ fun ChatInputBar(
)
}
ChatComposerLayers(
action = busyAction,
onActionChange = onBusyActionChange,
correctionAvailable = correctionAvailable,
onStop = onStop.takeUnless { trailing == ChatInputTrailing.STOP },
) {
Surface(
shape = appearanceComposerShape(),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -646,6 +656,7 @@ fun ChatInputBar(
}
}
}
}
internal data class LargeTextInsertion(
val insertedText: String,
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.padding
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.upstream.GatewayAvailability
@@ -140,18 +139,13 @@ fun ChatTransportStatusBadge(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
val context = LocalContext.current
val textColor = status.textColor()
val background = status.backgroundColor()
Surface(
modifier = modifier.combinedClickable(
onClick = { onClick?.invoke() },
onLongClick = {
Toast.makeText(
context,
"${status.reason}: ${status.detail}",
Toast.LENGTH_LONG,
).show()
UiMessageBus.info("${status.reason}: ${status.detail}")
},
),
shape = RoundedCornerShape(999.dp),
@@ -0,0 +1,103 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.HermesCardClarifyBatch
import kotlinx.serialization.json.Json
/** One mobile question surface; confirmed qids advance without resubmitting earlier answers. */
@Composable
internal fun ClarifyBatchContent(
batch: HermesCardClarifyBatch,
expired: Boolean,
onInputSubmit: (String, String) -> Unit,
) {
val answered = batch.questions.filter { it.answer != null }
val activeIndex = batch.questions.indexOfFirst { it.answer == null }
val active = batch.questions.getOrNull(activeIndex)
var showAnswers by rememberSaveable { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
LaunchedEffect(active?.key, expired) { focusManager.clearFocus() }
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = when {
expired -> stringResource(R.string.clarify_batch_expired)
active == null -> stringResource(R.string.clarify_batch_complete)
else -> stringResource(R.string.clarify_batch_progress, activeIndex + 1, batch.questions.size)
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
)
if (batch.questions.size > 1) {
LinearProgressIndicator(
progress = { answered.size.toFloat() / batch.questions.size },
modifier = Modifier.fillMaxWidth(),
)
}
if (active != null && !expired) {
key(active.key) {
Text(
active.question,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.semantics { heading() },
)
CardInputSlot(
input = active.input,
onSubmit = { if (!active.submitting) onInputSubmit(active.key, it) },
enabled = !active.submitting,
stackedChoices = true,
)
if (active.submitting) {
Text(stringResource(R.string.clarify_batch_sending), style = MaterialTheme.typography.labelMedium)
}
}
}
if (answered.isNotEmpty()) {
if (active == null || expired) {
Text(stringResource(R.string.clarify_batch_answered, answered.size), style = MaterialTheme.typography.labelLarge)
} else {
TextButton(onClick = { showAnswers = !showAnswers }) {
Text(stringResource(R.string.clarify_batch_answered, answered.size))
}
}
if (showAnswers || active == null || expired) {
answered.forEach { question ->
Text(question.question, style = MaterialTheme.typography.labelLarge)
val answer = if (question.input.multiSelect) {
runCatching { Json.decodeFromString<List<String>>(question.answer.orEmpty()).joinToString(", ") }
.getOrDefault(question.answer.orEmpty())
} else question.answer.orEmpty()
Text(answer, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(4.dp))
}
}
}
}
}
@@ -3625,7 +3625,7 @@ internal fun ProfileDisplayManagerDialog(
val isServerDefault = key == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
val profile = profiles.firstOrNull { it.name == key }
val label = if (isServerDefault) {
stringResource(R.string.conn_info_server_default)
stringResource(R.string.profile_follow_server_default)
} else {
profile?.let(AgentDisplay::profileDisplayName)
?: key.replaceFirstChar { it.uppercase() }
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -20,10 +22,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.viewmodel.ConnectionStepState
data class ConnectionSetupTimelineStep(
val title: String,
val detail: String,
val state: ConnectionStepState = ConnectionStepState.Done,
)
/** Compact completed-state timeline shared by connection and auth flows. */
@@ -43,13 +47,21 @@ fun ConnectionSetupTimeline(
Box(
modifier = Modifier
.size(28.dp)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
.background(when (step.state) {
ConnectionStepState.Failed -> MaterialTheme.colorScheme.errorContainer
ConnectionStepState.Pending -> MaterialTheme.colorScheme.surfaceVariant
else -> MaterialTheme.colorScheme.primaryContainer
}, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Check,
if (step.state == ConnectionStepState.Active) {
CircularProgressIndicator(Modifier.size(17.dp), strokeWidth = 2.dp)
} else if (step.state == ConnectionStepState.Pending) {
Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else Icon(
imageVector = if (step.state == ConnectionStepState.Failed) Icons.Filled.Close else Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
tint = if (step.state == ConnectionStepState.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(17.dp),
)
}
@@ -6,7 +6,7 @@ import android.Manifest
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.annotation.StringRes
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -343,11 +343,7 @@ fun ConnectionWizard(
// user at the manual pairing paths (URL entry / 6-char code)
// instead of leaving them on a vanishing toast with no scanner.
step = WizardStep.Nearby
Toast.makeText(
context,
context.getString(R.string.cw_camera_denied),
Toast.LENGTH_LONG
).show()
UiMessageBus.warning(context.getString(R.string.cw_camera_denied))
}
}
@@ -1,7 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -104,120 +103,119 @@ private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit)
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.WarningAmber,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
MessageOverlayScope {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.WarningAmber,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.crash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.crash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
text = stringResource(R.string.crash_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.crash_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.crash_privacy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = stringResource(R.string.crash_privacy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
if (showDetails) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
if (showDetails) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
}
}
Spacer(Modifier.height(18.dp))
// FlowRow so the actions wrap instead of clipping on narrow /
// foldable cover screens now that a fourth (Share) action exists.
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
if (!showDetails) {
Button(onClick = { showDetails = true }) {
Text(stringResource(R.string.crash_review))
}
} else {
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, copiedMessage)
},
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
// until they choose to send it — same privacy posture as Copy.
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
reportSubject,
reportText,
chooserTitle = shareTitle,
)
if (!shared) {
Spacer(Modifier.height(18.dp))
// FlowRow so the actions wrap instead of clipping on narrow /
// foldable cover screens now that a fourth (Share) action exists.
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
if (!showDetails) {
Button(onClick = { showDetails = true }) {
Text(stringResource(R.string.crash_review))
}
} else {
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, noShareMessage)
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
IssueReport.copyToClipboard(context, reportText)
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
toast(context, if (opened) openedMessage else noBrowserMessage)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
UiMessageBus.success(copiedMessage)
},
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
// until they choose to send it — same privacy posture as Copy.
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
reportSubject,
reportText,
chooserTitle = shareTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, reportText)
UiMessageBus.warning(noShareMessage)
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
IssueReport.copyToClipboard(context, reportText)
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
if (opened) UiMessageBus.success(openedMessage)
else UiMessageBus.warning(noBrowserMessage)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
}
}
}
}
}
}
}
private fun toast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
@@ -1,7 +1,7 @@
package com.hermesandroid.relay.ui.components
import android.text.format.DateFormat
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -78,159 +78,158 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
DiagnosticSeverityChip(entry.severity)
Spacer(Modifier.width(10.dp))
Text(
text = entry.category.label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
MessageOverlayScope {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
DiagnosticSeverityChip(entry.severity)
Spacer(Modifier.width(10.dp))
Text(
text = entry.category.label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(10.dp))
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(10.dp))
// Metadata rows — only render the ones that are present.
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
MetaRow("Severity", severityName)
MetaRow("Category", entry.category.label)
entry.operation?.let { MetaRow("Operation", it) }
entry.endpointRole?.let { MetaRow("Route", it) }
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
entry.requestUrl?.let { MetaRow("Request", it) }
if (entry.configuredUrl == null && entry.requestUrl == null) {
entry.url?.let { MetaRow("URL", it) }
}
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
entry.suggestion?.let { suggestion ->
Spacer(Modifier.height(10.dp))
Text(
text = "Suggested next step",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = entry.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(14.dp))
val body = entry.stacktrace ?: entry.detail
if (body != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 320.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
) {
SelectionContainer {
Text(
text = body,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
Spacer(Modifier.height(10.dp))
// Metadata rows — only render the ones that are present.
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
MetaRow("Severity", severityName)
MetaRow("Category", entry.category.label)
entry.operation?.let { MetaRow("Operation", it) }
entry.endpointRole?.let { MetaRow("Route", it) }
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
entry.requestUrl?.let { MetaRow("Request", it) }
if (entry.configuredUrl == null && entry.requestUrl == null) {
entry.url?.let { MetaRow("URL", it) }
}
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
entry.suggestion?.let { suggestion ->
Spacer(Modifier.height(10.dp))
Text(
text = "Suggested next step",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
} else {
Text(
text = stringResource(R.string.diagnostic_no_detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (expectationVisible) {
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = expectation,
onValueChange = { expectation = it },
label = { Text("What were you expecting to happen?") },
supportingText = {
Text(stringResource(R.string.diagnostic_routine_hint))
},
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, plainText)
toast(context, copiedToast)
},
) { Text(stringResource(R.string.common_copy)) }
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
subject = "Hermes-Relay diagnostic — ${entry.title}",
text = plainText,
chooserTitle = exportChooserTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, plainText)
toast(context, "Copied — no app found to share to")
}
},
) { Text(stringResource(R.string.common_export)) }
Button(
enabled = !expectationVisible || expectation.isNotBlank(),
onClick = {
if (needsExpectation && !expectationVisible) {
expectationVisible = true
return@Button
}
// Copy full text first; the GitHub URL only carries the
// head of long traces, so the user can paste the rest.
IssueReport.copyToClipboard(context, plainText)
val opened = IssueReport.openUrl(
context,
IssueReport.buildGithubIssueUrl(
title = DiagnosticIssuePrefill.issueTitle(entry),
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
entry,
expectation = expectation.takeIf { expectationVisible },
),
labels = DiagnosticIssuePrefill.issueLabels(entry),
val body = entry.stacktrace ?: entry.detail
if (body != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 320.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
)
toast(
context,
if (opened) "Full diagnostic copied — paste it into the issue if truncated"
else "Copied — no browser found to open GitHub",
)
},
) { Text(stringResource(R.string.common_report)) }
) {
SelectionContainer {
Text(
text = body,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
} else {
Text(
text = stringResource(R.string.diagnostic_no_detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (expectationVisible) {
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = expectation,
onValueChange = { expectation = it },
label = { Text("What were you expecting to happen?") },
supportingText = {
Text(stringResource(R.string.diagnostic_routine_hint))
},
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, plainText)
UiMessageBus.success(copiedToast)
},
) { Text(stringResource(R.string.common_copy)) }
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
subject = "Hermes-Relay diagnostic — ${entry.title}",
text = plainText,
chooserTitle = exportChooserTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, plainText)
UiMessageBus.warning("Copied — no app found to share to")
}
},
) { Text(stringResource(R.string.common_export)) }
Button(
enabled = !expectationVisible || expectation.isNotBlank(),
onClick = {
if (needsExpectation && !expectationVisible) {
expectationVisible = true
return@Button
}
// Copy full text first; the GitHub URL only carries the
// head of long traces, so the user can paste the rest.
IssueReport.copyToClipboard(context, plainText)
val opened = IssueReport.openUrl(
context,
IssueReport.buildGithubIssueUrl(
title = DiagnosticIssuePrefill.issueTitle(entry),
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
entry,
expectation = expectation.takeIf { expectationVisible },
),
labels = DiagnosticIssuePrefill.issueLabels(entry),
),
)
if (opened) UiMessageBus.success("Full diagnostic copied — paste it into the issue if truncated")
else UiMessageBus.warning("Copied — no browser found to open GitHub")
},
) { Text(stringResource(R.string.common_report)) }
}
}
}
}
@@ -276,10 +275,6 @@ internal fun DiagnosticSeverityChip(severity: DiagnosticSeverity) {
}
}
private fun toast(context: android.content.Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
/** Full, copy/share-ready plain-text rendering of a single diagnostic entry. */
private fun DiagnosticLogEntry.toPlainText(): String = buildString {
appendLine("Hermes-Relay diagnostic")
@@ -24,12 +24,12 @@ import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material.icons.filled.PauseCircleOutline
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.PauseCircleOutline
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -66,7 +66,6 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.GatewayProcess
import com.hermesandroid.relay.viewmodel.SubagentActivity
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
import kotlinx.coroutines.launch
@@ -84,27 +83,18 @@ internal fun GatewayBackgroundProcessStrip(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Initial/switch refreshes are silent. The strip appears only after the
// session actually owns a process, avoiding a transient "Checking" row on
// every ordinary chat open.
// Completed work remains reachable from its transcript receipt. Only active
// work owns composer space; refreshing history must not resurrect the strip.
val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty()
if (processes.isEmpty() && visibleActivities.isEmpty()) return
val running = processes.count { it.isRunning }
val runningAgents = visibleActivities.count { !it.isTerminal }
val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
val failedAgents = visibleActivities.count { it.phase == SubagentActivityPhase.FAILED }
val interruptedAgents = visibleActivities.count {
it.phase == SubagentActivityPhase.INTERRUPTED ||
it.phase == SubagentActivityPhase.ENDED_WITH_PARENT
}
val failureCount = failed + failedAgents
if (running == 0 && runningAgents == 0) return
val status = when {
runningAgents > 0 && running > 0 -> stringResource(
R.string.current_chat_activity_summary, runningAgents, running,
)
runningAgents > 0 -> stringResource(R.string.subagent_lane_running_count, runningAgents)
running > 0 -> "$running ${stringResource(R.string.bg_processes_running)}"
failureCount > 0 -> "$failureCount ${stringResource(R.string.task_status_failed)}"
interruptedAgents > 0 -> stringResource(R.string.agent_activity_status_interrupted)
else -> stringResource(R.string.task_status_complete)
else -> "$running ${stringResource(R.string.bg_processes_running)}"
}
val openDescription = stringResource(R.string.current_chat_activity_open)
@@ -130,24 +120,7 @@ internal fun GatewayBackgroundProcessStrip(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (running > 0 || runningAgents > 0 || loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Icon(
imageVector = when {
failureCount > 0 -> Icons.Filled.ErrorOutline
interruptedAgents > 0 -> Icons.Filled.PauseCircleOutline
else -> Icons.Filled.CheckCircle
},
contentDescription = null,
modifier = Modifier.size(17.dp),
tint = when {
failureCount > 0 -> MaterialTheme.colorScheme.error
interruptedAgents > 0 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
},
)
}
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(9.dp))
Text(
text = stringResource(R.string.current_chat_activity_title),
@@ -157,11 +130,7 @@ internal fun GatewayBackgroundProcessStrip(
Text(
text = status,
style = MaterialTheme.typography.labelMedium,
color = if (failureCount > 0 && running == 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = Icons.Filled.Visibility,
@@ -190,6 +159,8 @@ internal fun GatewayBackgroundProcessSheet(
onDismissProcess: (String) -> Unit,
onOpenSubagentChild: (String) -> Unit,
onDismiss: () -> Unit,
readOnlyHistory: Boolean = false,
historyNotice: String? = null,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
@@ -246,17 +217,19 @@ internal fun GatewayBackgroundProcessSheet(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.current_chat_activity_title),
stringResource(
if (readOnlyHistory) R.string.chat_activity_history_title else R.string.current_chat_activity_title,
),
modifier = Modifier.semantics { heading() },
style = MaterialTheme.typography.titleLarge,
)
Text(
stringResource(R.string.current_chat_activity_subtitle),
historyNotice ?: stringResource(R.string.current_chat_activity_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (processes.isNotEmpty()) IconButton(onClick = onRefresh, enabled = !loading) {
if (processes.isNotEmpty() && !readOnlyHistory) IconButton(onClick = onRefresh, enabled = !loading) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
@@ -300,7 +273,9 @@ internal fun GatewayBackgroundProcessSheet(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(R.string.current_chat_activity_empty),
stringResource(
if (readOnlyHistory) R.string.chat_activity_history_empty else R.string.current_chat_activity_empty,
),
modifier = Modifier.padding(top = 12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -341,7 +316,7 @@ internal fun GatewayBackgroundProcessSheet(
GatewayProcessRow(
process = process,
stopping = process.id in stoppingProcessIds,
onStop = { onStop(process.id) },
onStop = if (readOnlyHistory) null else ({ onStop(process.id) }),
onDismiss = null,
)
}
@@ -356,7 +331,7 @@ internal fun GatewayBackgroundProcessSheet(
process = process,
stopping = false,
onStop = null,
onDismiss = { onDismissProcess(process.id) },
onDismiss = if (readOnlyHistory) null else ({ onDismissProcess(process.id) }),
)
}
}
@@ -384,7 +359,7 @@ private fun GatewayProcessRow(
onDismiss: (() -> Unit)?,
) {
var expanded by remember(process.id) { mutableStateOf(false) }
val failed = !process.isRunning && (process.exitCode ?: 0) != 0
val failed = processDisplayPhase(process) == ProcessDisplayPhase.FAILED
val output = sanitizeTerminalText(
process.outputTail.orEmpty().ifBlank { process.outputPreview.orEmpty() },
).trimEnd()
@@ -478,9 +453,11 @@ private fun GatewayProcessRow(
@Composable
private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping: Boolean) {
val phase = processDisplayPhase(process)
val tint: Color = when {
failed -> MaterialTheme.colorScheme.error
process.isRunning || stopping -> MaterialTheme.colorScheme.primary
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.tertiary
}
Surface(
@@ -501,6 +478,14 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
modifier = Modifier.size(18.dp),
tint = tint,
)
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> Icon(
imageVector = if (phase == ProcessDisplayPhase.UNKNOWN) Icons.Filled.HelpOutline else Icons.Filled.PauseCircleOutline,
contentDescription = stringResource(
if (phase == ProcessDisplayPhase.UNKNOWN) R.string.agent_activity_status_unavailable else R.string.task_status_cancelled,
),
modifier = Modifier.size(18.dp),
tint = tint,
)
else -> Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.tool_completed_a11y),
@@ -514,8 +499,11 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
@Composable
private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
val phase = processDisplayPhase(process)
val state = when {
process.isRunning -> stringResource(R.string.bg_processes_running)
phase == ProcessDisplayPhase.UNKNOWN -> stringResource(R.string.agent_activity_status_unavailable)
phase == ProcessDisplayPhase.CANCELLED -> stringResource(R.string.task_status_cancelled)
failed -> stringResource(R.string.task_status_failed) +
process.exitCode?.let { " · exit $it" }.orEmpty()
else -> stringResource(R.string.task_status_complete) +
@@ -525,6 +513,19 @@ private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
if (process.detached) " · recovered" else ""
}
internal enum class ProcessDisplayPhase { RUNNING, COMPLETE, FAILED, CANCELLED, UNKNOWN }
/** A recovered or unfamiliar state is not evidence of successful completion. */
internal fun processDisplayPhase(process: GatewayProcess): ProcessDisplayPhase = when {
process.isRunning -> ProcessDisplayPhase.RUNNING
process.status.equals("unknown", ignoreCase = true) -> ProcessDisplayPhase.UNKNOWN
process.status.equals("cancelled", ignoreCase = true) ||
process.status.equals("canceled", ignoreCase = true) -> ProcessDisplayPhase.CANCELLED
process.status.equals("failed", ignoreCase = true) || (process.exitCode ?: 0) != 0 -> ProcessDisplayPhase.FAILED
process.exitCode == 0 || process.status.lowercase() in setOf("complete", "completed", "exited") -> ProcessDisplayPhase.COMPLETE
else -> ProcessDisplayPhase.UNKNOWN
}
private val ansiTerminalEscape = Regex(
"\u001B(?:\\].*?(?:\u0007|\u001B\\\\)|\\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~])",
RegexOption.DOT_MATCHES_ALL,
@@ -59,6 +59,7 @@ import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -145,7 +146,7 @@ fun HermesCardBubble(
// Expiry clock for timed asks. Ticks once a second while the deadline
// is ahead; freezes after. Keyed on the deadline so a re-used card id
// with a fresh expiry restarts the loop.
val expiresAt = card.input?.expiresAtMillis
val expiresAt = card.input?.expiresAtMillis ?: card.clarifyBatch?.expiresAtMillis
var nowMillis by remember(expiresAt) { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(expiresAt) {
if (expiresAt == null) return@LaunchedEffect
@@ -252,6 +253,14 @@ fun HermesCardBubble(
// action button all collapse the same way.
val input = card.input
when {
card.clarifyBatch != null -> {
Spacer(Modifier.height(10.dp))
ClarifyBatchContent(
batch = card.clarifyBatch,
expired = expired || alreadyChosen?.actionValue == HermesCardDispatch.EXPIRED_STAMP,
onInputSubmit = onInputSubmit,
)
}
alreadyChosen != null -> {
Spacer(Modifier.height(10.dp))
val chosenAction = card.actions.firstOrNull {
@@ -408,15 +417,18 @@ private fun ChoseRow(
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CardInputSlot(
internal fun CardInputSlot(
input: HermesCardInput,
onSubmit: (String) -> Unit,
enabled: Boolean = true,
stackedChoices: Boolean = false,
) {
// Deliberately remember, not rememberSaveable — a typed secret must
// never be written into the saved-instance-state Bundle.
var answerText by remember { mutableStateOf("") }
// Batch drafts survive lazy-item disposal. Secrets never enter saved state.
var answerText by if (stackedChoices && !input.masked) rememberSaveable { mutableStateOf("") }
else remember { mutableStateOf("") }
var reveal by remember { mutableStateOf(false) }
var selectedChoices by remember(input.choices) { mutableStateOf(emptyList<String>()) }
var selectedChoices by if (stackedChoices) rememberSaveable(input.choices) { mutableStateOf(emptyList<String>()) }
else remember(input.choices) { mutableStateOf(emptyList<String>()) }
val isMultiSelect = input.multiSelect && input.choices.isNotEmpty()
val showFreeText = !input.masked && (
@@ -429,7 +441,7 @@ private fun CardInputSlot(
val submitFreeText = {
val customAnswer = answerText.trim()
if (customAnswer.isNotEmpty()) {
if (enabled && customAnswer.isNotEmpty()) {
onSubmit(
if (isMultiSelect) {
encodeClarifyMultiSelectAnswer(selectedChoices + customAnswer)
@@ -443,14 +455,13 @@ private fun CardInputSlot(
Column(modifier = Modifier.fillMaxWidth()) {
// Choice chips
if (input.choices.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val choices: @Composable () -> Unit = {
input.choices.forEach { choice ->
if (isMultiSelect) {
val selected = choice in selectedChoices
FilterChip(
enabled = enabled,
modifier = if (stackedChoices) Modifier.fillMaxWidth() else Modifier,
selected = selected,
onClick = {
selectedChoices = if (selected) {
@@ -459,7 +470,11 @@ private fun CardInputSlot(
selectedChoices + choice
}
},
label = { Text(choice, style = MaterialTheme.typography.labelMedium) },
label = {
Text(choice,
style = if (stackedChoices) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium,
modifier = if (stackedChoices) Modifier.padding(vertical = 8.dp) else Modifier)
},
leadingIcon = if (selected) {
{
Icon(
@@ -477,9 +492,13 @@ private fun CardInputSlot(
)
} else {
AssistChip(
enabled = enabled,
modifier = if (stackedChoices) Modifier.fillMaxWidth() else Modifier,
onClick = { onSubmit(choice) },
label = {
Text(choice, style = MaterialTheme.typography.labelMedium)
Text(choice,
style = if (stackedChoices) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium,
modifier = if (stackedChoices) Modifier.padding(vertical = 8.dp) else Modifier)
},
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -489,6 +508,11 @@ private fun CardInputSlot(
}
}
}
if (stackedChoices) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { choices() }
} else {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { choices() }
}
}
// Masked secret field
@@ -534,12 +558,13 @@ private fun CardInputSlot(
else R.string.card_answer_placeholder,
),
onSubmit = submitFreeText,
enabled = enabled,
modifier = Modifier.weight(1f),
)
if (!isMultiSelect) {
IconButton(
onClick = submitFreeText,
enabled = answerText.isNotBlank(),
enabled = enabled && answerText.isNotBlank(),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
@@ -559,7 +584,7 @@ private fun CardInputSlot(
Spacer(Modifier.height(10.dp))
Button(
onClick = { onSubmit(encodeClarifyMultiSelectAnswer(answers)) },
enabled = answers.isNotEmpty(),
enabled = enabled && answers.isNotEmpty(),
) {
Text(
stringResource(R.string.card_submit),
@@ -615,6 +640,7 @@ private fun InlineAnswerField(
placeholder: String,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val shape = appearanceRoundedCornerShape(16.dp)
Box(
@@ -632,6 +658,7 @@ private fun InlineAnswerField(
)
}
BasicTextField(
enabled = enabled,
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
@@ -639,7 +666,7 @@ private fun InlineAnswerField(
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSubmit() }),
keyboardActions = KeyboardActions(onSend = { onSubmit() }, onDone = { onSubmit() }),
maxLines = 3,
modifier = Modifier
.fillMaxWidth()
@@ -5,7 +5,7 @@ import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
@@ -14,6 +14,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -303,19 +304,21 @@ private fun ImageRender(
}
LaunchedEffect(attachment.cachedUri, attachment.content) {
val decoded = withContext(Dispatchers.IO) {
runCatching {
try {
when {
!attachment.cachedUri.isNullOrBlank() ->
decodeOrientedBitmap(context, Uri.parse(attachment.cachedUri))
decodeBoundedOrientedBitmap(context, Uri.parse(attachment.cachedUri))
?.asImageBitmap()
attachment.content.isNotBlank() ->
android.util.Base64.decode(
attachment.content,
android.util.Base64.DEFAULT,
).let { decodeOrientedBitmap(it)?.asImageBitmap() }
).let { decodeBoundedOrientedBitmap(it)?.asImageBitmap() }
else -> null
}
}.getOrNull()
} catch (_: Exception) {
null
}
}
if (decoded != null) bitmap = decoded else decodeFailed = true
}
@@ -375,6 +378,8 @@ private fun ImageRender(
contentDescription = attachment.fileName,
modifier = Modifier
.widthIn(max = maxWidth)
.fillMaxWidth()
.aspectRatio(bmp.width.toFloat() / bmp.height.coerceAtLeast(1))
.clip(RoundedCornerShape(8.dp))
.combinedClickable(
onClick = {
@@ -391,7 +396,7 @@ private fun ImageRender(
},
onLongClick = { menuExpanded = true },
),
contentScale = ContentScale.FillWidth,
contentScale = ContentScale.Fit,
)
}
// One-tap save overlay — hidden while the blur cover is up so it
@@ -611,7 +616,7 @@ fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
private suspend fun shareAttachment(context: Context, attachment: Attachment) {
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
return
}
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
@@ -621,7 +626,7 @@ private suspend fun shareAttachment(context: Context, attachment: Attachment) {
suspend fun saveAttachment(context: Context, attachment: Attachment) {
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
return
}
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
@@ -631,13 +636,13 @@ suspend fun saveAttachment(context: Context, attachment: Attachment) {
}
when (result) {
is MediaSaver.SaveResult.Saved ->
attachmentToast(context, context.getString(R.string.inbound_attach_saved, result.location))
UiMessageBus.success(context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
attachmentToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
UiMessageBus.error(context.getString(R.string.inbound_attach_save_failed, result.message))
}
}
@@ -653,7 +658,7 @@ private suspend fun openAttachmentExternally(context: Context, attachment: Attac
if (uri != null) {
MediaSaver.open(context, uri, attachment.contentType)
} else {
attachmentToast(context, context.getString(R.string.inbound_attach_open_failed))
UiMessageBus.error(context.getString(R.string.inbound_attach_open_failed))
}
}
@@ -683,7 +688,11 @@ private fun rememberAttachmentThumbnail(attachment: Attachment): ImageBitmap? {
}
LaunchedEffect(attachment.cachedUri, attachment.content, attachment.renderMode) {
thumb = withContext(Dispatchers.IO) {
runCatching { generateThumbnail(context, attachment) }.getOrNull()
try {
generateThumbnail(context, attachment)
} catch (_: Exception) {
null
}
}
}
return thumb
@@ -765,10 +774,6 @@ internal suspend fun attachmentBytes(context: Context, attachment: Attachment):
}
}
private fun attachmentToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@Composable
private fun emojiAndLabelFor(
mode: AttachmentRenderMode,
@@ -23,14 +23,9 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.viewmodel.ChatViewModel
/**
* Bottom-sheet audit of the exact extra context the agent is injected with on
* the next turn — opened by tapping the chat [ContextMeterBar].
*
* Renders the SAME [ChatViewModel.InjectedContext] the send path builds (via
* [ChatViewModel.previewInjectedContext] → `composeInjectedContext`), so it is
* a faithful audit, not a re-derivation that could drift. Empty blocks show a
* labeled note instead of vanishing, and the gateway's server-side persona is
* explicitly called out as not-sent-from-this-device.
* Transport-aware context preview, opened from the chat [ContextMeterBar].
* Unsupported blocks are labeled rather than represented as delivered.
* Server-reported configuration is separate from device-supplied context.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -53,7 +48,7 @@ fun InjectedContextSheet(
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.injected_context_subtitle, context.transport),
text = stringResource(R.string.injected_context_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -71,6 +66,7 @@ fun InjectedContextSheet(
val mediaNoRelay = stringResource(R.string.injected_context_media_no_relay)
val relayNotSet = stringResource(R.string.injected_context_relay_not_set)
val turnNotSet = stringResource(R.string.injected_context_turn_not_set)
val unsupported = stringResource(R.string.injected_context_gateway_unsupported)
ContextSection(
title = personaTitle,
@@ -84,7 +80,7 @@ fun InjectedContextSheet(
ContextSection(
title = phoneStatusTitle,
body = context.appContext,
emptyNote = phoneStatusNotSet,
emptyNote = if (context.perTurnContextSupported) phoneStatusNotSet else unsupported,
)
ContextSection(
title = mediaTitle,
@@ -107,7 +103,7 @@ fun InjectedContextSheet(
ContextSection(
title = turnTitle,
body = context.interfaceContext,
emptyNote = turnNotSet,
emptyNote = if (context.perTurnContextSupported) turnNotSet else unsupported,
)
}
}
@@ -46,7 +46,11 @@ internal fun decodeInlineImageDataUrl(
// ceil(maxBytes / 3) * 4 plus at most two trailing padding characters.
val maxEncoded = ((maxBytes.toLong() + 2L) / 3L) * 4L
if (encoded.length.toLong() > maxEncoded) return null
val bytes = runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: return null
val bytes = try {
Base64.getDecoder().decode(encoded)
} catch (_: IllegalArgumentException) {
return null
}
if (bytes.isEmpty() || bytes.size > maxBytes || !matchesImageMagic(mime, bytes)) return null
return DecodedInlineImageData(bytes, mime)
}
@@ -73,7 +73,7 @@ fun MarkdownContent(
modifier: Modifier = Modifier
) {
ConfiguredMarkdownContent(
content = content,
content = markdownRenderWindow(content),
textColor = textColor,
modifier = modifier,
)
@@ -318,7 +318,7 @@ fun StreamingMarkdownContent(
var generation by remember { mutableIntStateOf(0) }
key(generation) {
NativeStreamingMarkdownGeneration(
content = content.withoutLeadingBlankLines(),
content = markdownRenderWindow(content.withoutLeadingBlankLines()),
textColor = textColor,
modifier = modifier,
onResetRequired = { generation += 1 },
@@ -353,6 +353,17 @@ private fun NativeStreamingMarkdownGeneration(
)
}
internal const val MAX_RENDERED_MARKDOWN_CHARS = 256_000
private const val MARKDOWN_TRUNCATION_NOTICE =
"\n\n---\n_Response display was shortened for Android memory safety._"
internal fun markdownRenderWindow(content: String): String =
if (content.length <= MAX_RENDERED_MARKDOWN_CHARS) {
content
} else {
content.take(MAX_RENDERED_MARKDOWN_CHARS) + MARKDOWN_TRUNCATION_NOTICE
}
internal data class StreamingMarkdownAppendPlan(
val resetRequired: Boolean,
val delta: String,
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -47,6 +48,7 @@ import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -64,21 +66,26 @@ private const val MAX_VISIBLE_EXPANDED = 3
private const val ROW_MIN_HEIGHT_DP = 34
/**
* Top, thin, info-only banner host. Collects [UiMessageBus] and renders the
* Top, thin, themed banner host. Collects [UiMessageBus] and renders the
* newest transient message on one line; tapping expands to the recent few
* (scrolling past three). It takes its own vertical space — the Scaffold below
* reflows, so content slides down smoothly instead of being covered by an
* overlay. Auto-dismisses (paused while expanded) and coalesces duplicates so a
* burst of the same status collapses to one refreshed row.
*
* App-owned errors stay on the snackbar. Keyed upstream AgentNotices may also
* use the warning tone because their sticky/clear lifecycle is server-owned.
* Actionable messages use ThemedMessageHost. Modal hosts render above their
* dialog while the primary host retains messages for the return to the app.
*/
@Composable
fun MessageBannerHost(
modifier: Modifier = Modifier,
includeStatusBarPadding: Boolean = true,
primary: Boolean = true,
) {
val hostId = remember { UiMessageBus.registerHost(primary) }
val activeHost by UiMessageBus.activeHost.collectAsState()
val active = activeHost == hostId
DisposableEffect(hostId) { onDispose { UiMessageBus.unregisterHost(hostId) } }
// Backing queue (oldest first; newest is last). expiresAt is kept in a
// parallel map so coalescing/auto-dismiss can address rows by id.
val shown = remember { mutableStateListOf<UiMessage>() }
@@ -122,12 +129,12 @@ fun MessageBannerHost(
}
// Collapse + report count to the scaffold (for inset accounting).
LaunchedEffect(shown.size) {
LaunchedEffect(shown.size, active) {
if (shown.isEmpty()) expanded = false
UiMessageBus.reportActiveCount(shown.size)
if (primary) UiMessageBus.reportActiveCount(if (active) shown.size else 0)
}
DisposableEffect(Unit) {
onDispose { UiMessageBus.reportActiveCount(0) }
onDispose { if (primary) UiMessageBus.reportActiveCount(0) }
}
// Mirror the live queue into a retained copy so the exit animation still
@@ -147,7 +154,7 @@ fun MessageBannerHost(
// under the notch. The smooth "slide" lives in animateContentSize below
// (collapsed↔expanded and message-count changes).
AnimatedVisibility(
visible = shown.isNotEmpty(),
visible = active && shown.isNotEmpty(),
enter = fadeIn(tween(180)),
exit = fadeOut(tween(160)),
modifier = modifier,
@@ -155,7 +162,7 @@ fun MessageBannerHost(
MessageBannerContent(
messages = rendered,
expanded = expanded,
onToggle = { if (rendered.size > 1) expanded = !expanded },
onToggle = { expanded = !expanded },
includeStatusBarPadding = includeStatusBarPadding,
)
}
@@ -170,6 +177,7 @@ private fun MessageBannerContent(
) {
val newest = messages.lastOrNull() ?: return
val multiple = messages.size > 1
val expandable = multiple || newest.text.length > 80 || newest.severity in setOf(UiMessageSeverity.Warning, UiMessageSeverity.Error)
val insetModifier = if (includeStatusBarPadding) {
Modifier.windowInsetsPadding(WindowInsets.statusBars)
} else {
@@ -190,14 +198,14 @@ private fun MessageBannerContent(
tonalElevation = 0.dp,
modifier = Modifier
.fillMaxWidth()
.then(if (multiple) Modifier.clickable(onClick = onToggle) else Modifier)
.then(if (expandable) Modifier.clickable(onClick = onToggle) else Modifier)
.animateContentSize(animationSpec = tween(durationMillis = 180)),
) {
if (!expanded) {
MessageRow(
message = newest,
trailing = {
if (multiple) {
if (expandable) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -236,6 +244,7 @@ private fun MessageBannerContent(
ordered.forEachIndexed { index, message ->
MessageRow(
message = message,
expanded = true,
trailing = {
if (index == 0) {
Icon(
@@ -256,6 +265,7 @@ private fun MessageBannerContent(
@Composable
private fun MessageRow(
message: UiMessage,
expanded: Boolean = false,
trailing: @Composable (() -> Unit)? = null,
) {
Row(
@@ -275,7 +285,7 @@ private fun MessageRow(
Text(
text = message.text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
maxLines = if (expanded) 12 else 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
@@ -284,22 +294,25 @@ private fun MessageRow(
}
@Composable
private fun severityContainer(severity: UiMessageSeverity): Color = when (severity) {
internal fun severityContainer(severity: UiMessageSeverity): Color = (when (severity) {
UiMessageSeverity.Error -> MaterialTheme.colorScheme.errorContainer
UiMessageSeverity.Success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
UiMessageSeverity.Status -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.72f)
UiMessageSeverity.Info -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.90f)
}
}).compositeOver(MaterialTheme.colorScheme.surface)
@Composable
private fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
internal fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
UiMessageSeverity.Error -> MaterialTheme.colorScheme.onErrorContainer
UiMessageSeverity.Success -> MaterialTheme.colorScheme.onTertiaryContainer
UiMessageSeverity.Status -> MaterialTheme.colorScheme.onSecondaryContainer
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.onErrorContainer
UiMessageSeverity.Info -> MaterialTheme.colorScheme.onSurfaceVariant
}
private fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
internal fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
UiMessageSeverity.Error -> Icons.Filled.Warning
UiMessageSeverity.Success -> Icons.Filled.CheckCircle
UiMessageSeverity.Status -> Icons.Filled.Sync
UiMessageSeverity.Warning -> Icons.Filled.Warning
@@ -62,6 +62,7 @@ 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.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
@@ -214,7 +215,7 @@ fun MessageBubble(
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
message.role == MessageRole.SYSTEM -> MaterialTheme.colorScheme.tertiaryContainer
isActionBubble -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.45f)
else -> MaterialTheme.colorScheme.surfaceVariant
else -> MaterialTheme.colorScheme.surfaceContainerLow
}
val textColor = when (message.role) {
@@ -436,8 +437,8 @@ fun MessageBubble(
// rows) would otherwise paint a bare timestamp-only chip between the
// Thought-process block and the tool pill. The first-token working state
// is rendered directly in the conversation
// lane below, without an opaque bubble. Cards and attachments still own
// a normal bubble even when response prose has not arrived yet.
// lane below, without an opaque bubble. Standalone cards own their own
// surface; wrapping those in another filled bubble duplicates the chrome.
streamingStatusLabel?.takeIf { showWorkingStatus }?.let { streamingStatus ->
StandaloneStreamingStatus(
status = streamingStatus,
@@ -454,6 +455,10 @@ fun MessageBubble(
message.cards.isNotEmpty() ||
message.attachments.isNotEmpty() ||
inlineImages.isNotEmpty()
val standaloneCards = !isUser && !isSystem &&
visibleMessageContent.isBlank() && quoteEnvelope == null &&
message.cards.isNotEmpty() && message.attachments.isEmpty() &&
inlineImages.isEmpty() && !showImageGeneration
if (showBubble) {
Row(
modifier = Modifier.widthIn(max = maxBubbleWidth),
@@ -599,11 +604,11 @@ fun MessageBubble(
),
) {
Surface(
shape = bubbleShape,
color = backgroundColor,
shape = if (standaloneCards) RectangleShape else bubbleShape,
color = if (standaloneCards) Color.Transparent else backgroundColor,
modifier = Modifier
.then(
if (!isUser && !isSystem && isDarkTheme) {
if (!isUser && !isSystem && isDarkTheme && !standaloneCards) {
Modifier.leftEdgeGlow(
alpha = 0.12f,
width = 28.dp,
@@ -651,7 +656,8 @@ fun MessageBubble(
)
) {
Column(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
modifier = if (standaloneCards) Modifier
else Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
) {
quoteEnvelope?.let { envelope ->
ChatQuoteReferenceChip(
@@ -741,7 +747,7 @@ fun MessageBubble(
onInputSubmit = { key, value ->
onCardInput(message.id, key, value)
},
maxWidth = maxBubbleWidth - 24.dp,
maxWidth = if (standaloneCards) maxBubbleWidth else maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
}
@@ -823,6 +829,7 @@ fun MessageBubble(
val hasTokenUsage = showUsage && !isUser &&
(message.inputTokens != null || message.outputTokens != null)
val deliveryStatus = message.deliveryStatus?.takeIf { isUser }
// Timestamp — only on the LAST bubble of a same-author run so a
// burst of fragments doesn't stack three near-touching time labels.
@@ -831,13 +838,14 @@ fun MessageBubble(
// This row is reserved from the first streaming frame. Completion
// can reveal both timestamp and token usage without adding a new
// footer line or changing the bubble's measured height.
if (isLastInGroup && (showTimestamps || hasTokenUsage)) {
if ((isLastInGroup && (showTimestamps || hasTokenUsage)) || deliveryStatus != null) {
Spacer(modifier = Modifier.height(2.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = if (standaloneCards) Modifier.padding(horizontal = 4.dp) else Modifier,
) {
if (showTimestamps) Text(
if (isLastInGroup && showTimestamps) Text(
text = timeFormat.format(Date(message.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
@@ -847,33 +855,30 @@ fun MessageBubble(
Modifier
},
)
if (hasTokenUsage) {
if (isLastInGroup && hasTokenUsage) {
TokenDisplay(
inputTokens = message.inputTokens,
outputTokens = message.outputTokens,
)
}
// Share the footer line, retaining the user bubble's contrasting foreground.
deliveryStatus?.let { status ->
MessageDeliveryIndicator(
status = status,
contentColor = textColor,
text = MessageDeliveryIndicatorText(
sending = stringResource(R.string.msg_bubble_sending),
queued = stringResource(R.string.msg_bubble_queued),
steered = stringResource(R.string.msg_bubble_steered),
delivered = stringResource(R.string.msg_bubble_delivered),
failed = stringResource(R.string.msg_bubble_not_sent),
tapToRetry = stringResource(R.string.chat_retry),
),
)
}
}
}
// Delivery status — only on agent-Thread reply bubbles (a user
// message routed over the relay proactive channel). Null on every
// ordinary chat message, which render nothing here.
message.deliveryStatus?.takeIf { isUser }?.let { status ->
Spacer(modifier = Modifier.height(2.dp))
MessageDeliveryIndicator(
status = status,
text = MessageDeliveryIndicatorText(
sending = stringResource(R.string.msg_bubble_sending),
queued = stringResource(R.string.msg_bubble_queued),
steered = stringResource(R.string.msg_bubble_steered),
delivered = stringResource(R.string.msg_bubble_delivered),
failed = stringResource(R.string.msg_bubble_not_sent),
tapToRetry = stringResource(R.string.chat_retry),
),
)
}
// Non-tail historical fragments have no reserved timestamp row.
// Preserve their existing standalone token metadata layout.
if (!isLastInGroup && hasTokenUsage) {
@@ -18,6 +18,7 @@ import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
@@ -57,6 +58,7 @@ fun MessageDeliveryIndicator(
modifier: Modifier = Modifier,
failureMessage: String? = null,
onRetry: (() -> Unit)? = null,
contentColor: Color? = null,
) {
val retryAction = onRetry.takeIf { status == MessageDeliveryStatus.FAILED }
val retryable = retryAction != null
@@ -67,7 +69,7 @@ fun MessageDeliveryIndicator(
retryable = retryable,
)
val presentation = messageDeliveryPresentation(status)
val tint = when (presentation.colorRole) {
val tint = contentColor ?: when (presentation.colorRole) {
DeliveryColorRole.NEUTRAL -> MaterialTheme.colorScheme.onSurfaceVariant
DeliveryColorRole.ACCENT -> MaterialTheme.colorScheme.primary
DeliveryColorRole.QUEUED -> MaterialTheme.colorScheme.tertiary
@@ -27,6 +27,11 @@ internal fun imageOrientationTransform(orientation: Int): ImageOrientationTransf
else -> ImageOrientationTransform()
}
internal fun shouldApplyExifOrientation(mimeType: String?): Boolean = when (mimeType?.lowercase()) {
"image/png", "image/gif", "image/bmp" -> false
else -> true
}
/**
* Decode image bytes for display and apply their EXIF orientation. BitmapFactory
* returns the stored pixel layout and otherwise leaves portrait phone photos
@@ -43,14 +48,16 @@ internal fun decodeOrientedBitmap(
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
} ?: return null
val orientation = runCatching {
val orientation = try {
ByteArrayInputStream(bytes).use { stream ->
ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
} catch (_: Exception) {
ExifInterface.ORIENTATION_NORMAL
}
val transform = imageOrientationTransform(orientation)
if (transform.rotationDegrees == 0f && !transform.flipHorizontal) return decoded
@@ -58,26 +65,53 @@ internal fun decodeOrientedBitmap(
if (transform.rotationDegrees != 0f) postRotate(transform.rotationDegrees)
if (transform.flipHorizontal) postScale(-1f, 1f)
}
return runCatching {
val oriented = try {
Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
}.getOrNull()?.also { oriented ->
if (oriented !== decoded) decoded.recycle()
} catch (_: Exception) {
null
}
return oriented?.also { bitmap ->
if (bitmap !== decoded) decoded.recycle()
} ?: decoded
}
/** Decode a cached content URI without first copying the encoded file into RAM. */
internal fun decodeOrientedBitmap(context: Context, uri: Uri): Bitmap? {
val decoded = context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
BitmapFactory.decodeFileDescriptor(descriptor.fileDescriptor)
/** Decode a cached content URI without copying the encoded file into RAM. */
internal fun decodeOrientedBitmap(context: Context, uri: Uri): Bitmap? =
decodeBoundedOrientedBitmap(context, uri)
internal fun decodeBoundedOrientedBitmap(context: Context, uri: Uri): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
val boundsInput = context.contentResolver.openInputStream(uri) ?: return null
boundsInput.use { input ->
// Bounds-only decode deliberately returns null; the populated Options
// are the result and must not be treated as a decode failure.
BitmapFactory.decodeStream(input, null, bounds)
}
val sample = inlineImageSampleSize(bounds.outWidth, bounds.outHeight) ?: return null
val decoded = context.contentResolver.openInputStream(uri)?.use { input ->
BitmapFactory.decodeStream(
input,
null,
BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = Bitmap.Config.ARGB_8888
},
)
} ?: return null
val orientation = runCatching {
context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
ExifInterface(descriptor.fileDescriptor).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}
}.getOrNull() ?: ExifInterface.ORIENTATION_NORMAL
val orientation = if (shouldApplyExifOrientation(bounds.outMimeType)) {
try {
context.contentResolver.openInputStream(uri)?.use { input ->
ExifInterface(input).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}
} catch (_: Exception) {
null
} ?: ExifInterface.ORIENTATION_NORMAL
} else {
ExifInterface.ORIENTATION_NORMAL
}
val transform = imageOrientationTransform(orientation)
if (transform.rotationDegrees == 0f && !transform.flipHorizontal) return decoded
@@ -85,9 +119,27 @@ internal fun decodeOrientedBitmap(context: Context, uri: Uri): Bitmap? {
if (transform.rotationDegrees != 0f) postRotate(transform.rotationDegrees)
if (transform.flipHorizontal) postScale(-1f, 1f)
}
return runCatching {
val oriented = try {
Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
}.getOrNull()?.also { oriented ->
if (oriented !== decoded) decoded.recycle()
} catch (_: Exception) {
null
}
return oriented?.also { bitmap ->
if (bitmap !== decoded) decoded.recycle()
} ?: decoded
}
/** Decode an encoded chat image only after bounding its decoded pixel footprint. */
internal fun decodeBoundedOrientedBitmap(bytes: ByteArray): Bitmap? {
if (bytes.isEmpty()) return null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val sample = inlineImageSampleSize(bounds.outWidth, bounds.outHeight) ?: return null
return decodeOrientedBitmap(
bytes,
BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = Bitmap.Config.ARGB_8888
},
)
}
@@ -296,12 +296,12 @@ internal fun formatAttachmentSize(bytes: Long): String {
private suspend fun decodePendingAttachmentImage(attachment: Attachment): ImageBitmap? =
withContext(Dispatchers.IO) {
runCatching {
try {
val bytes = Base64.decode(attachment.content, Base64.DEFAULT)
if (bytes.isEmpty()) return@runCatching null
if (bytes.isEmpty()) return@withContext null
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
var sample = 1
while (
@@ -314,7 +314,9 @@ private suspend fun decodePendingAttachmentImage(attachment: Attachment): ImageB
bytes,
BitmapFactory.Options().apply { inSampleSize = sample },
)?.asImageBitmap()
}.getOrNull()
} catch (_: Exception) {
null
}
}
private sealed interface PendingPreviewState {
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -41,6 +42,8 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Checkbox
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -80,9 +83,10 @@ object ProfileShelfPolicy {
profiles: List<Profile>,
presentation: ProfilePresentation,
selectedProfileName: String?,
serverDefaultProfileName: String? = null,
): List<ProfileChoice> {
val selectedKey = AgentDisplay.profileSessionKey(selectedProfileName)
return ProfilePresentationPolicy
val choices = ProfilePresentationPolicy
.visibleKeys(profiles, presentation, selectedKey)
.mapNotNull { key ->
if (key == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY) {
@@ -91,6 +95,17 @@ object ProfileShelfPolicy {
profiles.firstOrNull { it.name == key }?.let { ProfileChoice(key, it) }
}
}
// Group only exact upstream identities, never matching display labels.
// Keep the selected choice's request/presentation key unchanged.
val hasDefault = choices.any { it.isServerDefault }
val hasResolved = choices.any { it.key == serverDefaultProfileName }
if (!hasDefault || !hasResolved) return choices
val omittedKey = if (selectedProfileName == serverDefaultProfileName) {
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
} else {
serverDefaultProfileName
}
return choices.filterNot { it.key == omittedKey }
}
fun canSwitch(isStreaming: Boolean, streamingEndpoint: String): Boolean =
@@ -124,8 +139,9 @@ fun ProfileShelf(
onHide: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
val choices = remember(profiles, presentation, selectedProfile?.name) {
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name)
val serverDefaultProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
val choices = remember(profiles, presentation, selectedProfile?.name, serverDefaultProfile) {
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name, serverDefaultProfile?.name)
}
if (choices.size <= 1) return
@@ -157,7 +173,7 @@ fun ProfileShelf(
val label = if (selected) {
activeDisplayName
} else {
profileChoiceLabel(choice, resolvedProfile)
profileChoiceLabel(choice, serverDefaultProfile)
}
if (selected) {
val openPassportDescription = stringResource(R.string.profile_shelf_open_passport)
@@ -191,7 +207,7 @@ fun ProfileShelf(
ProfileChoiceAvatar(
connectionViewModel,
choice,
resolvedProfile,
serverDefaultProfile,
label,
36,
)
@@ -238,7 +254,7 @@ fun ProfileShelf(
ProfileChoiceAvatar(
connectionViewModel,
choice,
resolvedProfile,
serverDefaultProfile,
label,
36,
)
@@ -275,8 +291,8 @@ fun ProfileShelf(
actionChoice?.let { choice ->
val selected = ProfileShelfPolicy.isSelected(choice, selectedProfile?.name)
val target = choice.profile ?: resolvedProfile
val label = profileChoiceLabel(choice, resolvedProfile)
val target = choice.profile ?: serverDefaultProfile
val label = profileChoiceLabel(choice, serverDefaultProfile)
ProfileShelfActionsDialog(
label = label,
canSwitch = switchEnabled && !isProfileLocked,
@@ -319,10 +335,14 @@ fun ProfileSwitcherSheet(
onManageDisplay: () -> Unit,
onDismiss: () -> Unit,
) {
val choices = remember(profiles, presentation, selectedProfile?.name) {
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name)
val serverDefaultProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
val choices = remember(profiles, presentation, selectedProfile?.name, serverDefaultProfile) {
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name, serverDefaultProfile?.name)
}
ModalBottomSheet(onDismissRequest = onDismiss) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -346,8 +366,15 @@ fun ProfileSwitcherSheet(
}
choices.forEach { choice ->
val selected = ProfileShelfPolicy.isSelected(choice, selectedProfile?.name)
val label = profileChoiceLabel(choice, resolvedProfile)
val model = choice.profile?.model?.takeIf { it.isNotBlank() }
val label = profileChoiceLabel(choice, serverDefaultProfile)
val target = if (choice.isServerDefault) serverDefaultProfile else choice.profile
val defaultGroup = serverDefaultProfile != null &&
(choice.isServerDefault || choice.key == serverDefaultProfile?.name)
val detail = listOfNotNull(
stringResource(R.string.conn_info_server_default).takeIf { defaultGroup },
target?.name?.takeIf { it != label },
target?.model?.takeIf { it.isNotBlank() },
).joinToString(" · ")
ListItem(
modifier = Modifier.selectable(
selected = selected,
@@ -361,13 +388,13 @@ fun ProfileSwitcherSheet(
headlineContent = {
Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
supportingContent = if (model != null) {
{ Text(model, maxLines = 1, overflow = TextOverflow.Ellipsis) }
supportingContent = if (detail.isNotBlank()) {
{ Text(detail, maxLines = 2, overflow = TextOverflow.Ellipsis) }
} else {
null
},
leadingContent = {
ProfileChoiceAvatar(connectionViewModel, choice, resolvedProfile, label, 42)
ProfileChoiceAvatar(connectionViewModel, choice, serverDefaultProfile, label, 42)
},
trailingContent = if (selected) {
{ Icon(Icons.Filled.Check, contentDescription = null) }
@@ -375,6 +402,27 @@ fun ProfileSwitcherSheet(
null
},
)
if (defaultGroup && selected) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)
.toggleable(
value = choice.isServerDefault,
enabled = switchEnabled && !isProfileLocked,
role = Role.Checkbox,
onValueChange = {
onSelect(if (choice.isServerDefault) serverDefaultProfile else null)
onDismiss()
},
).padding(start = 64.dp, end = 24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = choice.isServerDefault, onCheckedChange = null,
enabled = switchEnabled && !isProfileLocked)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.profile_follow_server_default),
style = MaterialTheme.typography.bodyMedium)
}
}
}
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
TextButton(
@@ -518,7 +566,8 @@ private fun ProfileActionRow(
@Composable
private fun profileChoiceLabel(choice: ProfileChoice, resolvedProfile: Profile?): String =
if (choice.isServerDefault) {
stringResource(R.string.conn_info_server_default)
AgentDisplay.profileDisplayName(resolvedProfile)
?: stringResource(R.string.conn_info_server_default)
} else {
choice.profile?.let(AgentDisplay::profileDisplayName)
?: choice.profile?.name?.replaceFirstChar { it.uppercase() }
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -92,68 +92,70 @@ fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.support_bundle_privacy, state.recordCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 160.dp, max = 420.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
) {
SelectionContainer {
Text(
text = state.text,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
)
MessageOverlayScope {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.support_bundle_privacy, state.recordCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 160.dp, max = 420.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
) {
SelectionContainer {
Text(
text = state.text,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
)
}
}
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
enabled = state.shareEnabled,
onClick = {
IssueReport.copyToClipboard(context, state.text)
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
},
) { Text(stringResource(R.string.common_copy)) }
Button(
enabled = state.shareEnabled,
onClick = {
if (!IssueReport.share(
context,
subject = chooser,
text = state.text,
chooserTitle = chooser,
)
) {
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
enabled = state.shareEnabled,
onClick = {
IssueReport.copyToClipboard(context, state.text)
Toast.makeText(context, noShare, Toast.LENGTH_LONG).show()
}
},
) { Text(stringResource(R.string.common_share)) }
UiMessageBus.success(copied)
},
) { Text(stringResource(R.string.common_copy)) }
Button(
enabled = state.shareEnabled,
onClick = {
if (!IssueReport.share(
context,
subject = chooser,
text = state.text,
chooserTitle = chooser,
)
) {
IssueReport.copyToClipboard(context, state.text)
UiMessageBus.warning(noShare)
}
},
) { Text(stringResource(R.string.common_share)) }
}
}
}
}
@@ -0,0 +1,88 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
import com.hermesandroid.relay.util.HumanError
val LocalMessageActionHost = staticCompositionLocalOf<SnackbarHostState?> { null }
data class HumanErrorVisuals(val error: HumanError) : SnackbarVisuals {
override val message get() = error.body
override val actionLabel get() = error.actionLabel
override val withDismissAction = true
override val duration get() = if (error.retryable) SnackbarDuration.Long else SnackbarDuration.Short
}
/** Uses the app banner's appearance while preserving existing suspend/action contracts. */
@Composable
fun ThemedMessageHost(hostState: SnackbarHostState, modifier: Modifier = Modifier, scoped: Boolean = false) {
val modalActive by UiMessageBus.modalHostActive.collectAsState()
if (!scoped && modalActive) return
SnackbarHost(hostState = hostState, modifier = modifier) { data ->
val error = (data.visuals as? HumanErrorVisuals)?.error
val severity = if (error != null) UiMessageSeverity.Error else UiMessageSeverity.Info
Surface(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = severityContainer(severity), contentColor = severityOnContainer(severity),
) {
Column(Modifier.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(severityIcon(severity), null, Modifier.size(18.dp))
Column(Modifier.weight(1f).padding(10.dp).heightIn(max = 200.dp).verticalScroll(rememberScrollState())) {
error?.title?.let { Text(it, style = MaterialTheme.typography.titleSmall) }
Text(data.visuals.message, style = MaterialTheme.typography.bodyMedium)
}
IconButton(onClick = data::dismiss) {
Icon(Icons.Filled.Close, stringResource(R.string.common_dismiss))
}
}
data.visuals.actionLabel?.let { label ->
TextButton(onClick = data::performAction, modifier = Modifier.align(Alignment.End)) { Text(label) }
}
}
}
}
}
/** Modal-window host; the root retains the same bus events until their normal expiry. */
@Composable
fun MessageOverlayScope(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val actions = LocalMessageActionHost.current
Box(modifier) {
content()
MessageBannerHost(Modifier.align(Alignment.TopCenter), includeStatusBarPadding = false, primary = false)
if (actions != null) ThemedMessageHost(actions, Modifier.align(Alignment.BottomCenter), scoped = true)
}
}
@@ -57,6 +57,18 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/** A completed dispatch call is not evidence that its detached child finished. */
internal fun isDetachedDelegationDispatch(toolCall: ToolCall): Boolean {
if (toolCall.name != "delegate_task" || !toolCall.isComplete || toolCall.success != true) return false
val result = toolCall.result ?: return false
val payload = runCatching { Json.parseToJsonElement(result) as? JsonObject }.getOrNull() ?: return false
return (payload["status"] as? JsonPrimitive)?.content == "dispatched" ||
(payload["mode"] as? JsonPrimitive)?.content == "background"
}
@Composable
fun ToolProgressCard(
@@ -116,7 +128,9 @@ fun ToolProgressCard(
}
val toolIcon = toolIcon(toolCall.name)
val detachedDispatch = isDetachedDelegationDispatch(toolCall)
val statusText = when {
detachedDispatch -> stringResource(R.string.tool_progress_status_dispatched)
toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed)
toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed)
isPreparing -> stringResource(R.string.tool_preparing_a11y)
@@ -187,7 +201,7 @@ fun ToolProgressCard(
)
// Duration + completion time ("3.1s · 5:32 PM")
val metaLabel = listOfNotNull(duration, timeLabel).joinToString(" · ")
val metaLabel = if (detachedDispatch) statusText else listOfNotNull(duration, timeLabel).joinToString(" · ")
if (metaLabel.isNotEmpty()) {
Text(
text = metaLabel,
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -39,6 +40,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CenterFocusStrong
import androidx.compose.material.icons.filled.Close
@@ -48,10 +52,11 @@ import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.IconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
@@ -77,6 +82,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
@@ -145,8 +151,7 @@ fun VoiceModeOverlay(
onModeChange: (InteractionMode) -> Unit,
onClearError: () -> Unit,
modifier: Modifier = Modifier,
// Nullable so existing call sites compile; when wired, voice errors
// surface as global snackbars in addition to the inline banner below.
// Retained for callers; uiState.error owns the modal while voice is visible.
errorEvents: SharedFlow<HumanError>? = null,
// === PHASE3-voice-mode-transcript ===
// Compact rolling transcript of the last N chat messages — the
@@ -255,7 +260,7 @@ fun VoiceModeOverlay(
)
}
// Voice errors surface ONLY on the overlay's own inline top banner
// Voice errors surface ONLY in the overlay's dialog
// (uiState.error) while the overlay is up — we deliberately do NOT also pipe
// them to the app-wide bottom snackbar. Doing both duplicated the message
// and left a retry-only, un-dismissable toast at the bottom during long /
@@ -510,59 +515,15 @@ fun VoiceModeOverlay(
)
}
// Error banner
AnimatedVisibility(
visible = uiState.error != null,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopCenter)
.then(
responsiveLayout.chromeMaxWidth?.let {
Modifier.widthIn(max = it)
} ?: Modifier,
)
.fillMaxWidth()
.padding(top = 64.dp, start = 16.dp, end = 16.dp),
) {
Surface(
shape = appearanceRoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.errorContainer,
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = uiState.error.orEmpty(),
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
// Dismiss clears the error and returns to Idle without
// retrying, so a failed/timed-out turn never traps the user
// on a retry-only banner.
TextButton(onClick = { onClearError() }) {
Text(stringResource(R.string.common_dismiss))
}
TextButton(
onClick = {
onClearError()
onMicTap()
},
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.size(4.dp))
Text(stringResource(R.string.voice_overlay_retry))
}
}
}
uiState.error?.let { error ->
VoiceErrorDialog(
error = error,
onDismiss = onClearError,
onRetry = {
onClearError()
onMicTap()
},
)
}
// Mic button — dispatch by current voice state.
@@ -589,6 +550,60 @@ fun VoiceModeOverlay(
}
}
@Composable
private fun VoiceErrorDialog(
error: String,
onDismiss: () -> Unit,
onRetry: () -> Unit,
) {
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(error) { keyboard?.hide() }
// Match ChatFailureDetailsDialog: keep provider detail selectable and inside
// a bounded surface, with actions outside the scroll area. A real dialog
// owns focus and blocks the underlying chat in both voice presentations.
AlertDialog(
onDismissRequest = onDismiss,
containerColor = MaterialTheme.colorScheme.surface,
icon = {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
title = { Text(stringResource(R.string.voice_overlay_error_title)) },
text = {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small,
) {
SelectionContainer {
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp)
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
},
confirmButton = {
TextButton(onClick = onRetry) {
Text(stringResource(R.string.voice_overlay_retry))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.common_dismiss))
}
},
)
}
@Composable
private fun VoiceFocusIdentityPane(
uiState: VoiceUiState,
@@ -4,7 +4,7 @@ package com.hermesandroid.relay.ui.screens
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -94,7 +94,7 @@ fun AboutScreen(
val scope = rememberCoroutineScope()
val isDarkTheme = LocalBrand.current.isDark
// Pre-resolved Toast messages (Toast is not a composable scope)
// Pre-resolved messages for non-composable action callbacks.
val devUnlockedMsg = stringResource(R.string.about_dev_options_unlocked)
val updateCopiedMsg = stringResource(R.string.about_update_copied)
val emDash = stringResource(R.string.about_em_dash)
@@ -235,17 +235,13 @@ fun AboutScreen(
versionTapCount = 0
scope.launch {
FeatureFlags.unlockDevOptions(context)
Toast.makeText(
context,
devUnlockedMsg,
Toast.LENGTH_SHORT,
).show()
UiMessageBus.success(devUnlockedMsg)
onUnlockDeveloperOptions()
}
}
remaining <= 3 -> {
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
Toast.makeText(context, tapsMsg, Toast.LENGTH_SHORT).show()
UiMessageBus.info(tapsMsg)
}
}
},
@@ -326,11 +322,7 @@ fun AboutScreen(
if (ru.updateAvailable && !ru.updateCommand.isNullOrBlank()) {
TextButton(onClick = {
clipboard.setText(AnnotatedString(ru.updateCommand))
Toast.makeText(
context,
updateCopiedMsg,
Toast.LENGTH_SHORT,
).show()
UiMessageBus.success(updateCopiedMsg)
}) {
Text(stringResource(R.string.about_copy_fix))
}
@@ -59,7 +59,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
@@ -268,7 +268,7 @@ fun AppearanceSettingsScreen(
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = { ThemedMessageHost(snackbarHostState) },
) { innerPadding ->
Column(
modifier = Modifier
@@ -1,5 +1,9 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.data.BusyMessageAction
import com.hermesandroid.relay.data.canCorrectBusyMessage
import com.hermesandroid.relay.ui.components.ChatComposerLayers
import com.hermesandroid.relay.ui.components.ChatMessageQueue
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -85,6 +89,7 @@ fun BotChatScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
) {
val busyMessageAction by connectionViewModel.busyMessageAction.collectAsState()
ChatMediaViewerHost(route.key, sessionId) {
BotChatScreen(
route = route,
@@ -103,6 +108,7 @@ fun BotChatScreen(
)
},
profileIconFlow = connectionViewModel::profileIconFlow,
busyMessageAction = busyMessageAction,
)
}
}
@@ -120,6 +126,7 @@ internal fun BotChatScreen(
handlerFactory: () -> ChatHandler,
historyLoader: BotChatHistoryLoader,
profileIconFlow: BotChatProfileIconFlow,
busyMessageAction: BusyMessageAction = BusyMessageAction.CorrectNow,
) {
val handler = remember(route.key) { handlerFactory() }
val context = LocalContext.current
@@ -136,6 +143,21 @@ internal fun BotChatScreen(
.collectAsState(initial = null)
val listState = rememberLazyListState()
var composer by remember(route.key, sessionId) { mutableStateOf("") }
var editingQueuedMessage by remember(route.key, sessionId) { mutableStateOf(false) }
var busyActionOverride by remember(route.key, sessionId, busyMessageAction) {
mutableStateOf<BusyMessageAction?>(null)
}
val steerable by chatViewModel.steerableTurn.collectAsState()
val attachments by chatViewModel.pendingAttachments.collectAsState()
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
val turnStatus by handler.turnStatus.collectAsState()
val queue by chatViewModel.queuedMessages.collectAsState()
val queuePaused by chatViewModel.queuePaused.collectAsState()
val correctionAvailable = canCorrectBusyMessage(
steerable, attachments.isNotEmpty(), pendingAsk != null, turnStatus, composer,
)
val effectiveBusyAction = if (correctionAvailable && !editingQueuedMessage) busyActionOverride ?: busyMessageAction
else BusyMessageAction.QueueNext
DisposableEffect(chatViewModel, gatewayClient, dashboardClient, route.key) {
chatViewModel.initialize(apiClient = null, chatHandler = handler)
@@ -237,49 +259,82 @@ internal fun BotChatScreen(
)
},
bottomBar = {
ChatComposerLayers(
action = effectiveBusyAction.takeIf { isStreaming },
onActionChange = {
editingQueuedMessage = false
busyActionOverride = it
},
correctionAvailable = correctionAvailable,
onStop = if (composer.isNotBlank()) chatViewModel::cancelStream else null,
modifier = Modifier.imePadding(),
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
modifier = Modifier.imePadding(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Bottom,
) {
OutlinedTextField(
value = composer,
onValueChange = { composer = it },
placeholder = { Text(stringResource(R.string.chat_placeholder_message)) },
modifier = Modifier.weight(1f),
minLines = 1,
maxLines = 6,
)
Spacer(Modifier.width(6.dp))
IconButton(
onClick = {
if (isStreaming) {
chatViewModel.cancelStream()
} else {
val text = composer.trim()
if (text.isNotEmpty()) {
composer = ""
chatViewModel.sendMessage(text)
}
Column {
ChatMessageQueue(
messages = queue,
paused = queuePaused,
onResume = chatViewModel::resumeQueue,
onClear = chatViewModel::clearQueue,
onRemove = chatViewModel::removeQueuedAt,
canEdit = composer.isBlank() && attachments.isEmpty(),
onEdit = { index ->
chatViewModel.takeQueuedForEdit(index)?.let {
composer = it
editingQueuedMessage = true
}
},
enabled = isStreaming || composer.isNotBlank(),
modifier = Modifier.padding(horizontal = 12.dp),
)
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Bottom,
) {
Icon(
if (isStreaming) Icons.Filled.Stop else Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(
if (isStreaming) R.string.chat_input_stop_streaming
else R.string.chat_input_send_message,
),
OutlinedTextField(
value = composer,
onValueChange = { composer = it },
placeholder = { Text(stringResource(R.string.chat_placeholder_message)) },
modifier = Modifier.weight(1f),
minLines = 1,
maxLines = 6,
)
Spacer(Modifier.width(6.dp))
IconButton(
onClick = {
if (isStreaming && composer.isBlank()) {
chatViewModel.cancelStream()
} else {
val text = composer.trim()
if (text.isNotEmpty()) {
composer = ""
chatViewModel.sendMessage(text, effectiveBusyAction)
editingQueuedMessage = false
busyActionOverride = null
}
}
},
enabled = isStreaming || composer.isNotBlank(),
) {
Icon(
if (isStreaming && composer.isBlank()) Icons.Filled.Stop else Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(
when {
isStreaming && composer.isBlank() -> R.string.chat_input_stop_streaming
isStreaming && effectiveBusyAction == BusyMessageAction.CorrectNow -> R.string.chat_correct_now
isStreaming -> R.string.chat_queue_next
else -> R.string.chat_input_send_message
},
),
)
}
}
}
}
}
},
) { padding ->
Box(
@@ -41,7 +41,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -73,6 +73,7 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotModeState
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.Connection
@@ -84,6 +85,13 @@ import kotlinx.coroutines.launch
internal enum class BotModeFilter { All, Bots, Groups }
/** Bundle-compatible key; length-prefix the connection so delimiters cannot alias owners. */
internal val BotRosterEntry.lazyItemKey: String
get() {
val owner = route?.key ?: return "bot:unbound:${profile.name}"
return "bot:route:${owner.connectionId.length}:${owner.connectionId}:${owner.profileName}"
}
private sealed interface BotModeRow {
val activityAtMs: Long
@@ -109,7 +117,7 @@ fun BotModeScreen(
val scope = rememberCoroutineScope()
val resources = LocalResources.current
val snackbar = remember { SnackbarHostState() }
var openingProfile by remember { mutableStateOf<String?>(null) }
var openingRoute by remember { mutableStateOf<BotGatewayRouteKey?>(null) }
var showCreateBot by remember { mutableStateOf(false) }
var creatingBot by remember { mutableStateOf(false) }
var selectedGatewayId by rememberSaveable { mutableStateOf<String?>(null) }
@@ -136,10 +144,11 @@ fun BotModeScreen(
onBack = onBack,
onRefresh = connectionViewModel::refreshBotMode,
onSelectGateway = { selectedGatewayId = it },
openingProfile = openingProfile,
openingRoute = openingRoute,
onOpenBot = { bot ->
val route = bot.route ?: return@BotModeContent
openingProfile = bot.profile.name
if (openingRoute != null) return@BotModeContent
openingRoute = route.key
scope.launch {
val result = connectionViewModel.ensureCanonicalBotChat(route)
.map { it.resolvedSessionId }
@@ -147,12 +156,12 @@ fun BotModeScreen(
onSuccess = { onOpenBotChat(route, it) },
onFailure = { snackbar.showSnackbar(it.message ?: chatOpenFailed) },
)
openingProfile = null
openingRoute = null
}
},
onOpenGroup = { onOpenGroup(it.key) },
onNewBot = { showCreateBot = true },
snackbarHost = { SnackbarHost(snackbar) },
snackbarHost = { ThemedMessageHost(snackbar) },
botAvatar = { bot, size ->
BotProfileAvatar(
connectionViewModel = connectionViewModel,
@@ -206,7 +215,7 @@ internal fun BotModeContent(
onBack: () -> Unit,
onRefresh: () -> Unit,
onSelectGateway: (String?) -> Unit,
openingProfile: String? = null,
openingRoute: BotGatewayRouteKey? = null,
onOpenBot: (BotRosterEntry) -> Unit,
onOpenGroup: (BotGroupRoom) -> Unit,
onNewBot: () -> Unit,
@@ -404,12 +413,12 @@ internal fun BotModeContent(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
items(activeBots, key = { it.profile.name }) { bot ->
items(activeBots, key = { it.lazyItemKey }) { bot ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(78.dp)
.clickable(enabled = openingProfile == null) { onOpenBot(bot) },
.clickable(enabled = openingRoute == null) { onOpenBot(bot) },
) {
Box {
botAvatar(bot, 56.dp)
@@ -455,7 +464,7 @@ internal fun BotModeContent(
itemsIndexed(
items = rows,
key = { _, row -> when (row) {
is BotModeRow.Bot -> "bot:${row.value.profile.name}"
is BotModeRow.Bot -> row.value.lazyItemKey
is BotModeRow.Group -> "group:${row.value.key}"
} },
) { index, row ->
@@ -463,7 +472,7 @@ internal fun BotModeContent(
is BotModeRow.Bot -> BotConversationRow(
bot = row.value,
connectionLabel = row.value.route?.connectionLabel,
opening = openingProfile == row.value.profile.name,
opening = openingRoute != null && openingRoute == row.value.route?.key,
onClick = { onOpenBot(row.value) },
avatar = { botAvatar(row.value, 56.dp) },
nowMs = nowMs,
@@ -2,6 +2,9 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.data.BusyMessageAction
import com.hermesandroid.relay.data.canCorrectBusyMessage
import com.hermesandroid.relay.ui.components.ChatMessageQueue
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Canvas
@@ -9,6 +12,14 @@ import androidx.compose.foundation.MutatePriority
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.testTag
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
import com.hermesandroid.relay.data.projectChatActivityReceipts
import com.hermesandroid.relay.viewmodel.previewActivities
import com.hermesandroid.relay.ui.components.ChatDebugOverlay
import com.hermesandroid.relay.ui.components.chatDebugHeaderGesture
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -147,7 +158,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration
@@ -156,7 +167,7 @@ import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -452,6 +463,21 @@ internal fun shouldShowRetainedHistoryDashboardSignIn(
gatewayAvailability == GatewayAvailability.SignInRequired &&
!apiReachable
/**
* A foreground Gateway-owned Chat must be allowed to open its observation
* socket before `gateway.ready` can make chatReady true. Authentication and
* protocol failures are terminal; ordinary reachability failures remain
* visible so the Gateway client's bounded retry policy can recover them.
*/
internal fun shouldOwnVisibleGateway(
appForeground: Boolean,
isGatewayTransport: Boolean,
gatewayAvailability: GatewayAvailability,
): Boolean = appForeground &&
isGatewayTransport &&
gatewayAvailability != GatewayAvailability.SignInRequired &&
gatewayAvailability != GatewayAvailability.Unsupported
internal fun shouldPresentChatFailureDuringDashboardSignIn(
failure: ChatFailureNotice,
dashboardSignInRequired: Boolean,
@@ -867,9 +893,17 @@ fun ChatScreen(
val rawMessages by chatViewModel.messages.collectAsState()
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
if (!supervised) rawMessages
else rawMessages.map { message ->
val activityRecords by chatViewModel.activityRecords.collectAsState()
val activityOwner by chatViewModel.conversationBinding.collectAsState()
val activitySessionId by chatViewModel.currentSessionId.collectAsState()
val receiptMessages = remember(rawMessages, activityRecords, activityOwner, activitySessionId, supervised, supervisedVisibility) {
if (activityOwner.transport == com.hermesandroid.relay.data.SessionTransport.SSE ||
(supervised && !supervisedVisibility.showWorkingStatus)
) rawMessages else projectChatActivityReceipts(rawMessages, activityRecords, activityOwner.contextKey, activitySessionId)
}
val messages = remember(receiptMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
if (!supervised) receiptMessages
else receiptMessages.map { message ->
if (message.role == MessageRole.ASSISTANT) {
message.copy(
attachments = if (supervisedPolicy.capabilities.generatedImages) {
@@ -932,6 +966,10 @@ fun ChatScreen(
val sessionArchivingSupported by chatViewModel.sessionArchivingSupported.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val structuredChatFailure by chatViewModel.chatFailure.collectAsState()
val preparingGatewaySessionId by chatViewModel.gatewayPreparingSessionId.collectAsState()
val gatewaySocketState by chatViewModel.gatewaySocketState.collectAsState()
val preparingGatewaySession = isStreaming && currentSessionId != null &&
preparingGatewaySessionId == currentSessionId
val visibleChatFailure = scopedChatFailure(
structuredChatFailure,
currentSessionId,
@@ -958,6 +996,7 @@ fun ChatScreen(
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
val subagentActivities by chatViewModel.subagentActivities.collectAsState()
val retainedActivityPreview by chatViewModel.retainedActivityPreview.collectAsState()
val subagentChildPreview by chatViewModel.subagentChildPreview.collectAsState()
val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState()
val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState()
@@ -973,6 +1012,7 @@ fun ChatScreen(
// of available profiles itself now lives entirely inside the sheet.
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
val serverDefaultDisplayProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
val profilePresentation by connectionViewModel.profilePresentation.collectAsState()
val isProfileLocked by connectionViewModel.isProfileLocked.collectAsState()
val lockedProfileName by connectionViewModel.lockedProfileName.collectAsState()
@@ -1138,6 +1178,12 @@ fun ChatScreen(
val availableSkills by chatViewModel.availableSkills.collectAsState()
val queuedMessages by chatViewModel.queuedMessages.collectAsState()
val queuePaused by chatViewModel.queuePaused.collectAsState()
val busyMessageAction by connectionViewModel.busyMessageAction.collectAsState()
var busyActionOverride by remember(currentSessionId, busyMessageAction) {
mutableStateOf<BusyMessageAction?>(null)
}
var editingQueuedMessage by remember(currentSessionId) { mutableStateOf(false) }
val recentPrompts by chatViewModel.recentPrompts.collectAsState()
val recentPromptsEnabled by connectionViewModel.chatRecentPromptsEnabled.collectAsState()
// Effective approval-bypass (server-computed: ORs global approvals.mode=off,
@@ -1182,15 +1228,22 @@ fun ChatScreen(
// the foreground. setChatVisible owns that edge; an ordinary Gateway open
// warms only the observation socket and never attaches a saved session.
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
LaunchedEffect(isGatewayTransport, appForeground, chatReady) {
val visibleGatewayOwner = appForeground && chatReady && isGatewayTransport
LaunchedEffect(isGatewayTransport, appForeground, chatGatewayAvailability) {
val visibleGatewayOwner = shouldOwnVisibleGateway(
appForeground = appForeground,
isGatewayTransport = isGatewayTransport,
gatewayAvailability = chatGatewayAvailability,
)
chatViewModel.setChatVisible(visibleGatewayOwner)
// updateGatewayClient owns the one-time catalog/reasoning bootstrap for
// a newly-ready socket. Repeating it here created a duplicate cold-open
// RPC burst while the session directory was also trying to hydrate.
}
DisposableEffect(chatViewModel) {
onDispose { chatViewModel.setChatVisible(false) }
onDispose {
chatViewModel.setChatVisible(false)
chatViewModel.closeActivityPreview()
}
}
// Cold-open recovery: the dashboard probe that flips gatewayAvailability to
@@ -1361,14 +1414,10 @@ fun ChatScreen(
}
}
if (request.payload.omittedUriCount > 0) {
Toast.makeText(
context,
context.getString(
UiMessageBus.warning(context.getString(
R.string.chat_shared_files_limited,
com.hermesandroid.relay.util.MAX_SHARED_CONTENT_ATTACHMENTS,
),
Toast.LENGTH_LONG,
).show()
))
}
com.hermesandroid.relay.util.SharedContentRequest.consume(request.id)
}
@@ -1407,6 +1456,9 @@ fun ChatScreen(
var showEffortSheet by remember { mutableStateOf(false) }
var showAgentInfo by remember { mutableStateOf(false) }
var showProfileShelf by remember { mutableStateOf(false) }
var showChatDebug by remember { mutableStateOf(false) }
var chatHeaderHeightPx by remember { mutableStateOf(0) }
LaunchedEffect(currentSessionId, activeConnection?.id, supervised) { showChatDebug = false }
var showProfileSwitcher by remember { mutableStateOf(false) }
var showProfileManager by remember { mutableStateOf(false) }
var showBackgroundProcesses by remember { mutableStateOf(false) }
@@ -1423,7 +1475,7 @@ fun ChatScreen(
// A process inventory is scoped to one gateway session. Never leave a
// sheet opened onto a different chat after a drawer/profile switch.
LaunchedEffect(currentSessionId, selectedProfile?.name, activeConnection?.id) {
chatViewModel.closeSubagentChildPreview()
chatViewModel.closeActivityPreview()
showBackgroundProcesses = false
}
@@ -1621,65 +1673,53 @@ fun ChatScreen(
voiceOutputConfig?.enabled
}
val voiceSystemOverlayAvailable = BuildFlavor.isSideload
val voiceSystemOverlayAvailable = BuildFlavor.voiceSystemOverlay
val showVoiceSystemOverlay: () -> Unit = {
if (!voiceSystemOverlayAvailable) {
pendingVoiceOverlayPermission = false
} else if (assistantSessionActive) {
voiceOverlayHost.hide()
} else if (!voiceOverlayHost.hasOverlayPermission()) {
if (voiceSystemOverlayAvailable && !assistantSessionActive) {
pendingVoiceOverlayPermission = true
runCatching {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}"),
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
context.startActivity(intent)
}
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.chat_overlay_perm_enable),
duration = SnackbarDuration.Short,
)
}
} else {
pendingVoiceOverlayPermission = false
val shown = voiceOverlayHost.show(
VoiceOverlaySession(
uiState = voiceViewModel.uiState,
engineMode = voiceStats.voiceEngineMode,
provider = activeVoiceProvider,
model = activeVoiceModel,
voice = activeVoiceName,
profileName = AgentDisplay.profileDisplayName(effectiveProfile),
configScope = activeVoiceScope,
outputEnabled = activeVoiceEnabled,
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
onStartListening = { voiceViewModel.startListening() },
onStopListening = { voiceViewModel.stopListening() },
onInterrupt = { voiceViewModel.interruptSpeaking() },
onPauseAutoMode = { voiceViewModel.pauseContinuousMode() },
onReturnToHermes = {
openHermesFromOverlay(context)
voiceOverlayHost.hide()
},
onDismissOverlay = { voiceOverlayHost.hide() },
onExit = {
voiceOverlayHost.hide()
voiceViewModel.exitVoiceMode()
},
),
)
if (!shown) {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.chat_overlay_start_failed),
duration = SnackbarDuration.Short,
)
}
}
}
}
if (pendingVoiceOverlayPermission && voiceUiState.voiceMode && !assistantSessionActive) {
com.hermesandroid.relay.voice.VoiceOverlaySetupDialog(
onDismiss = { pendingVoiceOverlayPermission = false },
onBeforePermission = { voiceViewModel.pauseContinuousMode() },
onStart = {
pendingVoiceOverlayPermission = false
val shown = voiceOverlayHost.show(
VoiceOverlaySession(
uiState = voiceViewModel.uiState,
engineMode = voiceStats.voiceEngineMode,
connectionLabel = activeConnection?.label,
provider = activeVoiceProvider,
model = activeVoiceModel,
voice = activeVoiceName,
profileName = AgentDisplay.profileDisplayName(effectiveProfile),
configScope = activeVoiceScope,
outputEnabled = activeVoiceEnabled,
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
onStartListening = { voiceViewModel.startListening() },
onStopListening = { voiceViewModel.stopListening() },
onInterrupt = { voiceViewModel.interruptSpeaking() },
onPauseAutoMode = { voiceViewModel.pauseContinuousMode() },
onReturnToHermes = {
if (!openHermesFromOverlay(context)) voiceOverlayHost.exitVoiceSession()
},
onDismissOverlay = { voiceOverlayHost.exitVoiceSession() },
onExit = { voiceViewModel.exitVoiceMode() },
),
lifecycleOwner.lifecycle,
)
if (!shown) {
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.chat_overlay_start_failed),
duration = SnackbarDuration.Short,
)
}
}
},
)
}
LaunchedEffect(voiceUiState.voiceMode) {
if (!voiceUiState.voiceMode) {
@@ -1695,37 +1735,6 @@ fun ChatScreen(
}
}
DisposableEffect(
lifecycleOwner,
pendingVoiceOverlayPermission,
voiceUiState.voiceMode,
voiceOutputConfig,
realtimeAgentConfig,
voiceStats.voiceEngineMode,
) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && pendingVoiceOverlayPermission) {
when {
voiceOverlayHost.hasOverlayPermission() && voiceUiState.voiceMode -> {
showVoiceSystemOverlay()
}
!voiceOverlayHost.hasOverlayPermission() -> {
pendingVoiceOverlayPermission = false
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.chat_overlay_perm_denied),
duration = SnackbarDuration.Short,
)
}
}
else -> pendingVoiceOverlayPermission = false
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(voiceClient, voiceUiState.voiceMode, selectedProfile?.name) {
if (!voiceUiState.voiceMode) return@LaunchedEffect
val client = voiceClient ?: return@LaunchedEffect
@@ -1790,7 +1799,7 @@ fun ChatScreen(
cameraLauncher.launch(uri)
}.onFailure {
pendingCameraUri = null
Toast.makeText(context, context.getString(R.string.chat_camera_open_failed), Toast.LENGTH_SHORT).show()
UiMessageBus.error(context.getString(R.string.chat_camera_open_failed))
}
}
var pendingCameraAfterPermission by remember { mutableStateOf(false) }
@@ -1802,11 +1811,7 @@ fun ChatScreen(
if (granted && wanted) {
launchCamera()
} else if (!granted) {
Toast.makeText(
context,
context.getString(R.string.chat_camera_perm_needed),
Toast.LENGTH_SHORT,
).show()
UiMessageBus.warning(context.getString(R.string.chat_camera_perm_needed))
}
}
val requestCameraCapture: () -> Unit = {
@@ -2432,11 +2437,12 @@ fun ChatScreen(
}
}
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
val profileShelfAvailable = !supervised && com.hermesandroid.relay.ui.components.ProfileShelfPolicy.choices(
profiles = agentProfiles,
presentation = profilePresentation,
selectedKey = selectedProfileKey,
)
selectedProfileName = selectedProfile?.name,
serverDefaultProfileName = serverDefaultDisplayProfile?.name,
).size > 1
val profileSwitchEnabled = com.hermesandroid.relay.ui.components.ProfileShelfPolicy.canSwitch(
isStreaming = isStreaming,
streamingEndpoint = chatViewModel.streamingEndpoint,
@@ -2604,11 +2610,7 @@ fun ChatScreen(
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(copySessionIdLabel, sessionId))
)
Toast.makeText(
context,
copiedToClipboardMsg,
Toast.LENGTH_SHORT,
).show()
UiMessageBus.success(copiedToClipboardMsg)
}
},
threadsCapabilityActive = threadsCapabilityActive,
@@ -2754,6 +2756,7 @@ fun ChatScreen(
) {
// Top bar — messaging app style with avatar, name, model subtitle
TopAppBar(
modifier = Modifier.onSizeChanged { chatHeaderHeightPx = it.height },
navigationIcon = {
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
@@ -2797,6 +2800,7 @@ fun ChatScreen(
val showStreamingState = isStreaming &&
(!supervised || supervisedVisibility.showWorkingStatus)
val statusText = when {
preparingGatewaySession -> stringResource(R.string.chat_debug_preparing)
headerChatReady -> if (showStreamingState) {
stringResource(R.string.chat_streaming)
} else {
@@ -2873,13 +2877,21 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.clickable(enabled = !supervised) {
.chatDebugHeaderGesture(enabled = !supervised,
onHold = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showProfileShelf = false
showChatDebug = !showChatDebug
},
onClick = {
showChatDebug = false
if (profileShelfAvailable) {
showProfileShelf = !showProfileShelf
} else {
showAgentInfo = true
}
}
})
.testTag("chat-agent-header")
.semantics {
contentDescription = if (profileShelfAvailable) {
context.getString(
@@ -3203,8 +3215,8 @@ fun ChatScreen(
)
}
if (!supervised && showContextSheet) {
// Live audit of the exact extra context the agent will be
// injected with on the next turn (transparency / auditability).
// Snapshot of supported client context and separately reported
// server configuration, not a delivery receipt.
InjectedContextSheet(
context = remember(showContextSheet) {
chatViewModel.previewInjectedContext()
@@ -3660,7 +3672,15 @@ fun ChatScreen(
)
}
if (processNotification != null) {
if (message.activityRecord != null && (!supervised || supervisedVisibility.showWorkingStatus)) {
ChatActivityReceipt(
record = message.activityRecord,
onClick = {
showBackgroundProcesses = chatViewModel.openRetainedActivity(message.activityRecord, processNotification?.detail)
},
modifier = Modifier.padding(vertical = 4.dp),
)
} else if (processNotification != null) {
val notificationModifier = Modifier.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
)
@@ -3902,6 +3922,9 @@ fun ChatScreen(
// optional diagnostic scaffolding. Keep lanes
// visible in Off, Compact, and Detailed modes.
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
val laneCalls = laneGroups.getValue(taskIndex)
val retainedIds = activityRecords.flatMap { it.children }.mapTo(HashSet()) { it.id }
if (laneCalls.all { it.subagentId != null && it.subagentId in retainedIds }) return@forEach
Spacer(modifier = Modifier.height(4.dp))
SubagentLane(
taskIndex = taskIndex,
@@ -4037,7 +4060,7 @@ fun ChatScreen(
}
// Copy feedback snackbar
SnackbarHost(
ThemedMessageHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -4052,7 +4075,7 @@ fun ChatScreen(
subagentActivities = subagentActivities,
subagentPreviewVisibility = subagentPreviewVisibility,
loading = backgroundProcessesLoading,
onClick = { showBackgroundProcesses = true },
onClick = { chatViewModel.openCurrentActivityPreview(); showBackgroundProcesses = true },
)
}
@@ -4114,69 +4137,21 @@ fun ChatScreen(
}
}
AnimatedVisibility(visible = queuedMessages.isNotEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = pluralStringResource(
R.plurals.chat_queue_count,
queuedMessages.size,
queuedMessages.size,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
TextButton(
onClick = { chatViewModel.clearQueue() },
modifier = Modifier.height(28.dp)
) {
Text(
stringResource(R.string.chat_clear),
style = MaterialTheme.typography.labelSmall
)
}
ChatMessageQueue(
messages = queuedMessages,
paused = queuePaused,
onResume = chatViewModel::resumeQueue,
onClear = chatViewModel::clearQueue,
onRemove = chatViewModel::removeQueuedAt,
canEdit = inputText.isBlank() && pendingAttachments.isEmpty(),
onEdit = { index ->
chatViewModel.takeQueuedForEdit(index)?.let {
inputText = it
editingQueuedMessage = true
}
// Per-item: tap the text to pull it back into the composer
// for editing; ✕ to drop just that one. (Reorder omitted —
// low value vs. drag-handle complexity on a transient queue.)
queuedMessages.forEachIndexed { index, msg ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = msg,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.clickable {
chatViewModel.takeQueuedForEdit(index)?.let { t ->
inputText = if (inputText.isBlank()) t else "$inputText $t"
}
}
.padding(vertical = 4.dp),
)
TextButton(
onClick = { chatViewModel.removeQueuedAt(index) },
modifier = Modifier.height(28.dp),
) {
Text("✕", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
)
val quoteYouLabel = stringResource(R.string.chat_quote_you)
val quoteHermesLabel = stringResource(R.string.chat_quote_hermes)
@@ -4285,25 +4260,32 @@ fun ChatScreen(
// Gateway redirect is text-only. Attachment-bearing follow-ups must
// retain their files in the session-owned queue instead of showing
// a correction action that cannot carry them.
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty() &&
val canSteerCurrentMessage = canCorrectBusyMessage(
steerableTurn, pendingAttachments.isNotEmpty(), pendingAsk != null, turnStatus, inputText,
) &&
(!supervised || supervisedPolicy.capabilities.steerResponse)
val effectiveBusyAction = if (canSteerCurrentMessage && !editingQueuedMessage) busyActionOverride ?: busyMessageAction
else BusyMessageAction.QueueNext
val correctCurrentMessage = canSteerCurrentMessage &&
effectiveBusyAction == BusyMessageAction.CorrectNow
val trailing = when {
!isStreaming && hasContent -> ChatInputTrailing.SEND
!isStreaming -> ChatInputTrailing.VOICE
isStreaming && !hasContent -> ChatInputTrailing.STOP
canSteerCurrentMessage -> ChatInputTrailing.STEER
correctCurrentMessage -> ChatInputTrailing.STEER
else -> ChatInputTrailing.QUEUE
}
val inputCaption = when {
isStreaming && hasContent && canSteerCurrentMessage ->
isStreaming && hasContent && correctCurrentMessage ->
stringResource(R.string.chat_sends_now)
isStreaming && hasContent && queuePaused -> stringResource(R.string.chat_queue_paused)
isStreaming && hasContent -> stringResource(R.string.chat_delivered_after_turn)
isStreaming && steerNotice != null -> steerNotice
else -> null
}
val inputPlaceholder = when {
editingMessage != null -> stringResource(R.string.chat_placeholder_edit)
isStreaming && canSteerCurrentMessage -> stringResource(R.string.chat_placeholder_steer)
isStreaming && correctCurrentMessage -> stringResource(R.string.chat_placeholder_steer)
isStreaming -> stringResource(R.string.chat_placeholder_queue)
else -> stringResource(R.string.chat_placeholder_message)
}
@@ -4555,6 +4537,12 @@ fun ChatScreen(
}
ChatInputBar(
busyAction = effectiveBusyAction.takeIf { isStreaming },
correctionAvailable = canSteerCurrentMessage,
onBusyActionChange = {
editingQueuedMessage = false
busyActionOverride = it
},
value = inputText,
onValueChange = { inputText = it },
placeholder = inputPlaceholder,
@@ -4583,7 +4571,9 @@ fun ChatScreen(
}
}
} else {
chatViewModel.sendMessage(outboundText)
chatViewModel.sendMessage(outboundText, effectiveBusyAction)
editingQueuedMessage = false
busyActionOverride = null
inputText = ""
finishSuccessfulSend()
}
@@ -4593,17 +4583,11 @@ fun ChatScreen(
isDemoMode = isDemoMode,
voiceReady = voiceReady,
onDemoNotice = {
Toast.makeText(
context,
"Voice is unavailable in the offline demo — connect to Hermes to use it",
Toast.LENGTH_LONG,
).show()
UiMessageBus.warning("Voice is unavailable in the offline demo — connect to Hermes to use it")
},
onStartVoice = requestVoiceMode,
onSetupNotice = {
Toast.makeText(
context,
when (standardVoiceAvailability) {
UiMessageBus.warning(when (standardVoiceAvailability) {
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
standardVoiceSignInRouteHint?.let { route ->
"Voice needs a one-time sign-in on the $route route — open Manage"
@@ -4612,9 +4596,7 @@ fun ChatScreen(
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
else ->
context.getString(R.string.chat_voice_needs_route)
},
Toast.LENGTH_SHORT,
).show()
})
},
)
},
@@ -4838,6 +4820,30 @@ fun ChatScreen(
}
} // end Column
ChatDebugOverlay(
visible = showChatDebug && !supervised,
headerHeight = with(density) { chatHeaderHeightPx.toDp() },
onClose = { showChatDebug = false },
) {
ChatDebugDrawer(
profile = AgentDisplay.profileDisplayName(conversationProfile)
?: stringResource(R.string.chat_server_default),
model = AgentDisplay.displayModelName(sessionModelState.model).orEmpty(),
sessionId = currentSessionId,
gateway = isGatewayTransport,
signedIn = chatGatewayAvailability == GatewayAvailability.Ready,
signInRequired = chatGatewayAvailability == GatewayAvailability.SignInRequired,
socketState = gatewaySocketState,
preparing = preparingGatewaySession,
streaming = isStreaming,
loadingHistory = isLoadingHistory,
directoryUnavailable = sessionListUnavailable,
failure = visibleChatFailure?.rawError,
onClose = { showChatDebug = false },
onConnections = { showChatDebug = false; onNavigateToConnections() },
)
}
// Mic permission denied banner — title + body + Open Settings action.
// System "Don't ask again" gives no callback, so a toast would leave
// the user stranded. Banner + direct-to-app-details deep link is the
@@ -4977,8 +4983,8 @@ fun ChatScreen(
if (showBackgroundProcesses) {
GatewayBackgroundProcessSheet(
processes = backgroundProcesses,
subagentActivities = subagentActivities,
processes = retainedActivityPreview?.processes ?: backgroundProcesses,
subagentActivities = retainedActivityPreview?.record?.previewActivities() ?: subagentActivities,
subagentChildPreview = subagentChildPreview,
subagentPreviewVisibility = subagentPreviewVisibility,
loading = backgroundProcessesLoading,
@@ -4987,8 +4993,10 @@ fun ChatScreen(
onStop = chatViewModel::stopBackgroundProcess,
onDismissProcess = chatViewModel::dismissBackgroundProcess,
onOpenSubagentChild = chatViewModel::openSubagentChildPreview,
readOnlyHistory = retainedActivityPreview != null,
historyNotice = if (retainedActivityPreview != null) stringResource(R.string.chat_activity_history_notice) else null,
onDismiss = {
chatViewModel.closeSubagentChildPreview()
chatViewModel.closeActivityPreview()
showBackgroundProcesses = false
},
)
@@ -5529,13 +5537,9 @@ private suspend fun ingestAttachmentFromUri(
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: AttachmentTooLargeException) {
Toast.makeText(
context,
context.getString(R.string.chat_file_too_large, maxAttachmentMb),
Toast.LENGTH_SHORT,
).show()
UiMessageBus.warning(context.getString(R.string.chat_file_too_large, maxAttachmentMb))
} catch (e: Exception) {
Toast.makeText(context, context.getString(R.string.chat_failed_read_file), Toast.LENGTH_SHORT).show()
UiMessageBus.error(context.getString(R.string.chat_failed_read_file))
}
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.ui.components.ChatBusyActionSelector
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -499,6 +500,22 @@ fun ChatSettingsScreen(
HorizontalDivider()
val busyMessageAction by connectionViewModel.busyMessageAction.collectAsState()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.chat_busy_default_title), style = MaterialTheme.typography.bodyMedium)
Text(
stringResource(R.string.chat_busy_default_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ChatBusyActionSelector(
action = busyMessageAction,
onActionChange = connectionViewModel::setBusyMessageAction,
modifier = Modifier.fillMaxWidth(),
)
}
HorizontalDivider()
val physicalKeyboardEnterBehavior by
connectionViewModel.physicalKeyboardEnterBehavior.collectAsState()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -30,7 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -124,7 +124,7 @@ fun CustomPetGuideScreen(
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = { ThemedMessageHost(snackbarHostState) },
) { innerPadding ->
Column(
modifier = Modifier
@@ -4475,7 +4475,8 @@ internal fun summarizeObjectItem(
)
}
val title = obj.stringField("name")
val title = obj.stringField("display_name")?.takeIf(String::isNotBlank)
?: obj.stringField("name")
?: obj.stringField("id")
?: obj.stringField("title")
?: fallbackTitle
@@ -2,7 +2,7 @@
package com.hermesandroid.relay.ui.screens
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.hermesandroid.relay.ui.theme.LocalBrand
@@ -92,11 +92,7 @@ fun DeveloperSettingsScreen(
) { uri ->
if (uri != null && backupJson != null) {
connectionViewModel.writeBackupToUri(uri, backupJson!!) { success ->
Toast.makeText(
context,
if (success) context.getString(R.string.dev_settings_exported) else context.getString(R.string.dev_settings_export_failed),
Toast.LENGTH_SHORT
).show()
UiMessageBus.post(if (success) context.getString(R.string.dev_settings_exported) else context.getString(R.string.dev_settings_export_failed), severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
backupJson = null
}
}
@@ -108,11 +104,7 @@ fun DeveloperSettingsScreen(
) { uri ->
if (uri != null) {
connectionViewModel.importFromUri(uri) { success ->
Toast.makeText(
context,
if (success) context.getString(R.string.dev_settings_imported) else context.getString(R.string.dev_settings_import_failed),
Toast.LENGTH_SHORT
).show()
UiMessageBus.post(if (success) context.getString(R.string.dev_settings_imported) else context.getString(R.string.dev_settings_import_failed), severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
}
}
}
@@ -184,15 +176,11 @@ fun DeveloperSettingsScreen(
}
IconButton(onClick = {
connectionViewModel.resetOnboarding { success ->
Toast.makeText(
context,
if (success) {
UiMessageBus.post(if (success) {
context.getString(R.string.dev_settings_onboarding_reset_toast)
} else {
context.getString(R.string.dev_settings_reset_failed)
},
Toast.LENGTH_SHORT,
).show()
}, severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
}
}) {
Icon(
@@ -433,11 +421,7 @@ fun DeveloperSettingsScreen(
IconButton(onClick = {
scope.launch {
FeatureFlags.lockDevOptions(context)
Toast.makeText(
context,
context.getString(R.string.dev_settings_locked_toast),
Toast.LENGTH_SHORT,
).show()
UiMessageBus.info(context.getString(R.string.dev_settings_locked_toast))
onBack()
}
}) {
@@ -455,6 +439,7 @@ fun DeveloperSettingsScreen(
// logged error, a live update). Gated by isDevBuild so it never
// ships in a release APK.
if (FeatureFlags.isDevBuild) {
MessagePreviewControls()
Text(
text = stringResource(R.string.dev_settings_test_harness),
style = MaterialTheme.typography.titleMedium,
@@ -501,7 +486,7 @@ fun DeveloperSettingsScreen(
context.getString(R.string.dev_settings_sample_stacktrace),
),
)
Toast.makeText(context, context.getString(R.string.dev_settings_diagnostics_emitted_toast), Toast.LENGTH_SHORT).show()
UiMessageBus.info(context.getString(R.string.dev_settings_diagnostics_emitted_toast))
},
)
@@ -518,7 +503,7 @@ fun DeveloperSettingsScreen(
is UpdateStatus.Downloaded -> context.getString(R.string.dev_settings_update_state_downloaded)
else -> context.getString(R.string.dev_settings_update_state_off)
}
Toast.makeText(context, context.getString(R.string.dev_settings_update_banner_preview_toast, state), Toast.LENGTH_SHORT).show()
UiMessageBus.info(context.getString(R.string.dev_settings_update_banner_preview_toast, state))
},
)
@@ -568,11 +553,7 @@ fun DeveloperSettingsScreen(
showExportDialog = false
connectionViewModel.exportSettings { json ->
if (json == null) {
Toast.makeText(
context,
context.getString(R.string.dev_settings_export_failed),
Toast.LENGTH_SHORT,
).show()
UiMessageBus.error(context.getString(R.string.dev_settings_export_failed))
} else {
backupJson = json
exportLauncher.launch("hermes-relay-sensitive-backup.json")
@@ -633,15 +614,11 @@ fun DeveloperSettingsScreen(
onClick = {
showResetDialog = false
connectionViewModel.resetAppData { success ->
Toast.makeText(
context,
if (success) {
UiMessageBus.post(if (success) {
context.getString(R.string.dev_settings_app_data_reset_toast)
} else {
context.getString(R.string.dev_settings_reset_failed)
},
Toast.LENGTH_SHORT,
).show()
}, severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
}
}
) {
@@ -658,7 +635,7 @@ fun DeveloperSettingsScreen(
}
@Composable
private fun TestHarnessRow(
internal fun TestHarnessRow(
title: String,
subtitle: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
@@ -3,7 +3,7 @@
package com.hermesandroid.relay.ui.screens
import android.content.Context
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -285,11 +285,7 @@ fun MediaSettingsScreen(
R.string.media_clear_cache_freed,
formatBytesHuman(context, freed)
)
Toast.makeText(
context,
freedMsg,
Toast.LENGTH_SHORT
).show()
UiMessageBus.info(freedMsg)
}
}
) {
@@ -0,0 +1,83 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Science
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
import com.hermesandroid.relay.ui.components.HumanErrorVisuals
import com.hermesandroid.relay.ui.components.LocalMessageActionHost
import com.hermesandroid.relay.util.HumanError
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/** Local-only samples; no network, diagnostics, or connection-state mutations. */
@Composable
internal fun MessagePreviewControls() {
val scope = rememberCoroutineScope()
val actionHost = LocalMessageActionHost.current
val jobs = remember { arrayOfNulls<Job>(1) }
val key = "developer-message-preview"
fun reset() {
jobs[0]?.cancel()
jobs[0] = null
UiMessageBus.clear(key)
}
DisposableEffect(Unit) { onDispose { reset() } }
Text(stringResource(R.string.dev_message_previews), style = MaterialTheme.typography.titleMedium)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.dev_message_previews_desc), style = MaterialTheme.typography.bodySmall)
val samples = listOf(
R.string.dev_message_info to UiMessageSeverity.Info,
R.string.dev_message_success to UiMessageSeverity.Success,
R.string.dev_message_progress to UiMessageSeverity.Status,
R.string.dev_message_warning to UiMessageSeverity.Warning,
R.string.dev_message_error to UiMessageSeverity.Error,
)
samples.forEach { (label, severity) ->
val text = stringResource(label)
TestHarnessRow(text, stringResource(R.string.dev_message_preview_sample), Icons.Filled.Science, onClick = {
reset()
UiMessageBus.post(text, severity, ttlMillis = if (severity == UiMessageSeverity.Status) 0L else 10_000L, key = key)
})
}
val errorTitle = stringResource(R.string.dev_message_action)
val errorBody = stringResource(R.string.dev_message_action_body)
val retryLabel = stringResource(R.string.dev_message_retry)
val retryResult = stringResource(R.string.dev_message_retry_result)
TestHarnessRow(errorTitle, errorBody, Icons.Filled.Science, onClick = {
reset()
jobs[0] = scope.launch {
val result = actionHost?.showSnackbar(HumanErrorVisuals(HumanError(
title = errorTitle, body = errorBody, retryable = true, actionLabel = retryLabel,
)))
if (result == SnackbarResult.ActionPerformed) {
UiMessageBus.post(retryResult, UiMessageSeverity.Success, key = key)
}
}
})
TestHarnessRow(
stringResource(R.string.dev_message_clear),
stringResource(R.string.dev_message_clear_desc),
Icons.Filled.Science,
onClick = { reset() },
)
}
}
}
@@ -2,7 +2,7 @@
package com.hermesandroid.relay.ui.screens
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -170,7 +170,7 @@ fun PairScreen(
ConnectionWizard(
connectionViewModel = connectionViewModel,
onComplete = {
Toast.makeText(context, context.getString(R.string.pair_connection_updated), Toast.LENGTH_SHORT).show()
UiMessageBus.success(context.getString(R.string.pair_connection_updated))
onComplete()
},
onCancel = onCancel,
@@ -38,7 +38,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -150,7 +150,7 @@ fun PairedDevicesScreen(
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
snackbarHost = { ThemedMessageHost(snackbarHostState) }
) { inner ->
Box(
modifier = Modifier
@@ -86,6 +86,10 @@ fun PermissionsStatusScreen(
onBack: () -> Unit,
onOpenBridge: () -> Unit = {},
) {
var showVoiceOverlaySetup by remember { mutableStateOf(false) }
if (showVoiceOverlaySetup) com.hermesandroid.relay.voice.VoiceOverlaySetupDialog(
onDismiss = { showVoiceOverlaySetup = false },
)
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var status by remember { mutableStateOf(AppPermissionStatusProbe.snapshot(context)) }
@@ -209,6 +213,21 @@ fun PermissionsStatusScreen(
)
}
PermissionSection(
title = stringResource(R.string.voice_overlay_setup_title),
subtitle = stringResource(R.string.voice_overlay_settings_hint),
) {
PermissionStatusRow(
icon = Icons.Filled.PictureInPicture,
title = stringResource(R.string.perms_display_over_apps),
subtitle = stringResource(R.string.voice_overlay_setup_body),
badge = stringResource(R.string.perms_badge_optional),
statusLabel = optionalStatus(context, status.overlayPermitted),
granted = status.overlayPermitted,
onClick = { showVoiceOverlaySetup = true },
)
}
if (BuildFlavor.isSideload) {
SideloadPermissionsSection(
status = status,
@@ -36,7 +36,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -189,7 +189,7 @@ fun PetdexBrowseScreen(
),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = { ThemedMessageHost(snackbarHostState) },
) { innerPadding ->
LazyVerticalGrid(
columns = GridCells.Adaptive(156.dp),
@@ -1287,7 +1287,7 @@ private fun ProfileLockDialog(
HorizontalDivider()
// Server default option.
ProfileLockOptionRow(
label = stringResource(R.string.settings_server_default),
label = stringResource(R.string.profile_follow_server_default),
secondary = stringResource(R.string.settings_use_default_profile),
selected = lockedIsServerDefault,
onSelect = { onLock(null) },
@@ -582,6 +582,7 @@ fun VoiceSettingsScreen(
}
VoiceSettingsSection.Listening -> {
com.hermesandroid.relay.voice.VoiceOverlaySettingsCard()
GlobalVoiceControlsCard(
voiceSettings = voiceSettings,
prefsRepo = prefsRepo,
@@ -14,6 +14,9 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.concurrent.TimeUnit
/**
@@ -39,6 +42,7 @@ object MediaSaver {
private const val SAVE_SUBDIR = "Hermes-Relay"
private const val SHARE_CACHE_DIR = "hermes-media"
private const val MAX_IN_MEMORY_MEDIA_BYTES = 25L * 1024L * 1024L
private val httpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
@@ -70,7 +74,10 @@ object MediaSaver {
httpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) error("HTTP ${resp.code}")
val contentType = resp.header("Content-Type")?.substringBefore(';')?.trim()
val body = resp.body.bytes()
val responseBody = resp.body
val body = responseBody.byteStream().use { input ->
readBoundedBytes(input, responseBody.contentLength())
}
body to contentType
}
}
@@ -78,11 +85,42 @@ object MediaSaver {
/** Read the bytes behind a `content://` (or `file://`) [uri]. */
suspend fun readUriBytes(context: Context, uri: Uri): ByteArray? =
withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
try {
val declaredLength = context.contentResolver.openAssetFileDescriptor(uri, "r")
?.use { it.length }
?.takeIf { it >= 0L }
?: -1L
context.contentResolver.openInputStream(uri)?.use { input ->
readBoundedBytes(input, declaredLength)
}
} catch (_: Exception) {
null
}
}
private fun readBoundedBytes(input: InputStream, declaredLength: Long): ByteArray {
if (declaredLength > MAX_IN_MEMORY_MEDIA_BYTES) {
throw IOException("Media exceeds Android's in-memory export limit")
}
val initialSize = when {
declaredLength in 1..MAX_IN_MEMORY_MEDIA_BYTES -> declaredLength.toInt()
else -> DEFAULT_BUFFER_SIZE
}
val output = ByteArrayOutputStream(initialSize)
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var total = 0L
while (true) {
val read = input.read(buffer)
if (read < 0) break
total += read
if (total > MAX_IN_MEMORY_MEDIA_BYTES) {
throw IOException("Media exceeds Android's in-memory export limit")
}
output.write(buffer, 0, read)
}
return output.toByteArray()
}
// --- Save ---------------------------------------------------------------
suspend fun saveImage(
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.upstream.DashboardHttpException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
@@ -86,6 +87,8 @@ private fun nullFallback(context: String?, ctx: Context?): HumanError {
return HumanError(title = titlePrefix(context, ctx), body = base, retryable = false)
}
private val LEGACY_HTTP_STATUS = Regex("""^(?:http(?: error)?|api error|relay responded http) ([1-5]\d{2})(?=\s|:|$)""")
private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): HumanError? {
// Ordered most-specific-first; callers have already handled the typed
// SSL / timeout / connect exceptions so this only runs on generic IOs.
@@ -149,11 +152,9 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
"server. Update the relay, then try again.",
retryable = false,
)
"404" in msg -> HumanError(
LEGACY_HTTP_STATUS.find(msg.trim())?.groupValues?.get(1) == "404" -> HumanError(
title = ctx?.getString(R.string.error_classify_endpoint) ?: "Endpoint not found",
body = if (context == "voice_config")
"The relay doesn't have voice endpoints — it may be an older version"
else "The relay doesn't have this endpoint — it may be an older version",
body = "The requested resource or endpoint is unavailable on this server.",
retryable = false,
)
"413" in msg -> HumanError(
@@ -255,6 +256,30 @@ private fun String.diagnosticOperation(): String =
private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?): HumanError {
if (t == null) return nullFallback(context, ctx)
// Preserve the actual transport and status instead of inferring them from
// a response body, resource identifier, or unrelated number in an error.
if (t is DashboardHttpException) {
return when (t.statusCode) {
401 -> HumanError(
title = ctx?.getString(R.string.power_feature_dashboard_signin_label) ?: "Dashboard sign-in required",
body = "Sign in to the Hermes Dashboard to continue this action.",
)
403 -> HumanError(
title = ctx?.getString(R.string.error_classify_not_allowed) ?: "Not allowed",
body = "The Dashboard refused this action.",
)
404 -> HumanError(
title = ctx?.getString(R.string.error_classify_endpoint) ?: "Endpoint not found",
body = "The requested Dashboard resource or endpoint is unavailable.",
)
else -> HumanError(
title = titlePrefix(context, ctx),
body = "The Dashboard request failed (HTTP ${t.statusCode}).",
retryable = t.statusCode == 408 || t.statusCode == 429 || t.statusCode >= 500,
)
}
}
val msg = t.message.orEmpty().lowercase()
if ("cannot create audiorecord" in msg ||
@@ -0,0 +1,286 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.ChatActivityChild
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.data.ChatActivityStore
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.data.boundChatActivities
import com.hermesandroid.relay.network.upstream.GatewayProcess
import java.io.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** Session-owned history metadata. Call capture only with events/snapshots owned by the selected session. */
internal class ChatActivityController(
private val scope: CoroutineScope,
private var store: ChatActivityStore = InMemoryChatActivityStore(),
private val clock: () -> Long = System::currentTimeMillis,
) {
private val mutableRecords = MutableStateFlow<List<ChatActivityRecord>>(emptyList())
val records: StateFlow<List<ChatActivityRecord>> = mutableRecords.asStateFlow()
private var owner: Pair<String, String>? = null
private var generation = 0L
private var deletedOwner: Pair<String, String>? = null
private val retiredIds = mutableSetOf<String>()
private val writes = Mutex()
fun bindStore(store: ChatActivityStore) {
if (this.store === store) return
this.store = store
generation++
load(migrate = true)
}
fun selectSession(scopeKey: String?, sessionId: String?) {
val next = if (!scopeKey.isNullOrBlank() && !sessionId.isNullOrBlank()) scopeKey to sessionId else null
if (next != null && next == deletedOwner) return
if (next != null && next != deletedOwner) deletedOwner = null
if (next == owner) return
owner = next
generation++
mutableRecords.value = emptyList()
retiredIds.clear()
load()
}
private fun load(migrate: Boolean = false) {
val selected = owner ?: return
val selectedStore = store
val selectedGeneration = generation
scope.launch {
val loaded = try {
selectedStore.read(selected.first, selected.second)
} catch (_: IOException) {
emptyList()
}
if (selectedGeneration != generation || owner != selected || selectedStore !== store) return@launch
val fresh = mutableRecords.value.associateBy(ChatActivityRecord::id)
val merged = loaded.filter { it.scopeKey == selected.first && it.sessionId == selected.second && it.id !in retiredIds }
.map { restored -> fresh[restored.id]?.let { live -> mergeRestored(restored, live) } ?: restored }
.associateByTo(linkedMapOf(), ChatActivityRecord::id)
fresh.forEach { (id, record) -> if (id !in merged) merged[id] = record }
// A lazy disk read can reveal a fallback only after its live event supplied delegation identity.
merged.values.filter { it.kind == ChatActivityKind.SUBAGENTS && !it.isFallback() }.toList()
.forEach { target ->
val fallbacks = merged.values.filter { candidate ->
candidate.isFallback() && candidate.children.any { old ->
target.children.any { child -> old.id == child.id ||
(child.childSessionId != null && old.childSessionId == child.childSessionId) }
}
}
var consolidated = target
fallbacks.forEach { fallback ->
consolidated = mergeRestored(fallback, consolidated)
merged.remove(fallback.id)
retire(fallback)
}
merged[target.id] = consolidated
}
publish(merged.values.toList())
mutableRecords.value.filter { it.id in fresh }.forEach { record ->
if (migrate || fresh[record.id] != record) persist(record)
}
}
}
private fun mergeRestored(restored: ChatActivityRecord, fresh: ChatActivityRecord): ChatActivityRecord {
if (fresh.kind != ChatActivityKind.SUBAGENTS) return fresh.copy(createdAt = minOf(restored.createdAt, fresh.createdAt))
val children = mergeChildren(restored.children, fresh.children)
val count = maxOf(restored.taskCount, fresh.taskCount, children.size)
return fresh.copy(
createdAt = minOf(restored.createdAt, fresh.createdAt), children = children,
phase = aggregate(children, count), taskCount = count,
)
}
fun captureSubagents(activities: List<SubagentActivity>) {
val selected = owner ?: return
val identifiable = activities.mapNotNull { activity ->
val childId = activity.subagentId?.takeIf(String::isNotBlank)
?: activity.childSessionId?.takeIf(String::isNotBlank) ?: return@mapNotNull null
val delegation = activity.delegationId?.takeIf(String::isNotBlank)
val identityKind = when {
delegation != null -> "delegation"
!activity.subagentId.isNullOrBlank() -> "subagent"
else -> "session"
}
val existing = if (delegation == null) mutableRecords.value.firstOrNull { record ->
record.kind == ChatActivityKind.SUBAGENTS && record.children.any { it.matches(activity) }
} else null
val id = existing?.id ?: identity("subagents", identityKind, delegation ?: childId)
Triple(id, existing?.sourceId ?: delegation ?: childId, activity)
}
identifiable.groupBy { it.first }.forEach { (id, group) ->
val previous = mutableRecords.value.firstOrNull { it.id == id }
val migrated = mutableRecords.value.filter { record ->
record.id != id && record.isFallback() && group.any { (_, _, activity) ->
record.children.any { it.matches(activity) }
}
}
val inherited = mergeChildren(migrated.flatMap { it.children }, previous?.children.orEmpty())
val children = inherited.associateByTo(linkedMapOf(), ChatActivityChild::id)
group.forEach { (_, _, activity) ->
val childId = activity.subagentId?.takeIf(String::isNotBlank) ?: activity.childSessionId!!
val old = children.values.firstOrNull { it.matches(activity) }
if (old != null && old.id != childId) children.remove(old.id)
children[childId] = ChatActivityChild(
id = childId,
childSessionId = activity.childSessionId ?: old?.childSessionId,
goal = activity.goal.take(512).ifBlank { old?.goal.orEmpty() },
phase = activity.phase.toHistoryPhase(),
// Terminal summary is bounded metadata; progress/tool-event bodies are never copied.
summary = if (activity.isTerminal) activity.summary?.take(512) ?: old?.summary else old?.summary,
)
}
val now = clock()
val values = children.values.toList()
val count = maxOf(previous?.taskCount ?: 0, migrated.maxOfOrNull { it.taskCount } ?: 0,
group.maxOf { it.third.taskCount }, values.size).coerceIn(0, 10_000)
migrated.forEach(::retire)
update(ChatActivityRecord(
id = id, scopeKey = selected.first, sessionId = selected.second,
kind = ChatActivityKind.SUBAGENTS, sourceId = group.first().second,
title = "Subagents", phase = aggregate(values, count),
createdAt = minOf(previous?.createdAt ?: now, migrated.minOfOrNull { it.createdAt } ?: now),
updatedAt = previous?.updatedAt ?: now,
children = values, taskCount = count,
))
}
}
/** An authoritative session-scoped process.list snapshot, including an empty successful snapshot. */
fun captureProcesses(processes: List<GatewayProcess>) {
val selected = owner ?: return
val seen = mutableSetOf<String>()
processes.filter { it.id.isNotBlank() }.forEach { process ->
val id = identity("process", process.id, process.startedAt)
seen += id
val previous = mutableRecords.value.firstOrNull { it.id == id }
val now = clock()
update(ChatActivityRecord(
id = id, scopeKey = selected.first, sessionId = selected.second, kind = ChatActivityKind.PROCESS,
sourceId = process.id, title = "Background command", phase = process.toHistoryPhase(),
createdAt = previous?.createdAt ?: now, updatedAt = previous?.updatedAt ?: now,
processId = process.id, processStartedAt = process.startedAt, exitCode = process.exitCode,
))
}
mutableRecords.value.filter {
it.kind == ChatActivityKind.PROCESS && it.phase == ChatActivityPhase.RUNNING && it.id !in seen
}.forEach { update(it.copy(phase = ChatActivityPhase.UNKNOWN)) }
}
fun removeSession(scopeKey: String, sessionId: String) {
if (owner == (scopeKey to sessionId)) {
deletedOwner = owner
owner = null
generation++
mutableRecords.value = emptyList()
}
val selectedStore = store
scope.launch { writes.withLock { selectedStore.removeSession(scopeKey, sessionId) } }
}
fun markUnavailable() {
mutableRecords.value.toList().forEach { record ->
val children = record.children.map {
if (it.phase == ChatActivityPhase.RUNNING) it.copy(phase = ChatActivityPhase.UNKNOWN) else it
}
val phase = if (record.phase == ChatActivityPhase.RUNNING) ChatActivityPhase.UNKNOWN else record.phase
update(record.copy(phase = phase, children = children))
}
}
private fun retire(record: ChatActivityRecord) {
retiredIds += record.id
mutableRecords.value = mutableRecords.value.filterNot { it.id == record.id }
val selectedStore = store
scope.launch {
try {
writes.withLock { selectedStore.removeRecord(record.scopeKey, record.sessionId, record.id) }
} catch (_: IOException) {
// The live selection still suppresses the superseded entry.
}
}
}
private fun update(candidate: ChatActivityRecord) {
val previous = mutableRecords.value.firstOrNull { it.id == candidate.id }
if (previous == candidate) return
val updated = candidate.copy(updatedAt = maxOf(clock(), (previous?.updatedAt ?: -1L) + 1L))
publish(mutableRecords.value.filterNot { it.id == updated.id } + updated)
mutableRecords.value.firstOrNull { it.id == updated.id }?.let(::persist)
}
private fun publish(records: List<ChatActivityRecord>) {
// Logical timestamps can advance within one wall-clock millisecond.
mutableRecords.value = boundChatActivities(records, maxOf(clock(), records.maxOfOrNull { it.updatedAt } ?: 0L))
.sortedWith(compareBy<ChatActivityRecord> { it.createdAt }.thenBy { it.id })
}
private fun persist(record: ChatActivityRecord) {
val selectedStore = store
scope.launch {
try {
writes.withLock { selectedStore.upsert(record) }
} catch (_: IOException) {
// Keep the session's visible metadata when local persistence is temporarily unavailable.
}
}
}
}
private fun identity(vararg parts: String?): String = parts.joinToString("") {
if (it == null) "-1:" else "${it.length}:$it"
}
private fun SubagentActivityPhase.toHistoryPhase(): ChatActivityPhase = when (this) {
SubagentActivityPhase.STARTED, SubagentActivityPhase.THINKING, SubagentActivityPhase.TOOL,
SubagentActivityPhase.PROGRESS -> ChatActivityPhase.RUNNING
SubagentActivityPhase.COMPLETED -> ChatActivityPhase.COMPLETE
SubagentActivityPhase.FAILED -> ChatActivityPhase.FAILED
SubagentActivityPhase.INTERRUPTED -> ChatActivityPhase.CANCELLED
SubagentActivityPhase.ENDED_WITH_PARENT -> ChatActivityPhase.UNKNOWN
}
private fun aggregate(children: List<ChatActivityChild>, taskCount: Int): ChatActivityPhase = when {
children.any { it.phase == ChatActivityPhase.RUNNING } -> ChatActivityPhase.RUNNING
children.isEmpty() || children.size < taskCount || children.any { it.phase == ChatActivityPhase.UNKNOWN } -> ChatActivityPhase.UNKNOWN
children.any { it.phase == ChatActivityPhase.FAILED } -> ChatActivityPhase.FAILED
children.any { it.phase == ChatActivityPhase.CANCELLED } -> ChatActivityPhase.CANCELLED
else -> ChatActivityPhase.COMPLETE
}
private fun ChatActivityChild.matches(activity: SubagentActivity): Boolean =
(!activity.subagentId.isNullOrBlank() && id == activity.subagentId) ||
(!activity.childSessionId.isNullOrBlank() && childSessionId == activity.childSessionId)
private fun ChatActivityRecord.isFallback(): Boolean = kind == ChatActivityKind.SUBAGENTS &&
(id == identity("subagents", "subagent", sourceId) || id == identity("subagents", "session", sourceId))
private fun mergeChildren(old: List<ChatActivityChild>, fresh: List<ChatActivityChild>): List<ChatActivityChild> {
val merged = old.associateByTo(linkedMapOf(), ChatActivityChild::id)
fresh.forEach { child ->
val alias = merged.values.firstOrNull {
it.id == child.id || (child.childSessionId != null && it.childSessionId == child.childSessionId)
}
if (alias != null && alias.id != child.id) merged.remove(alias.id)
merged[child.id] = child
}
return merged.values.toList()
}
private fun GatewayProcess.toHistoryPhase(): ChatActivityPhase = when {
isRunning -> ChatActivityPhase.RUNNING
status.lowercase() in setOf("cancelled", "canceled", "killed", "interrupted") -> ChatActivityPhase.CANCELLED
status.equals("failed", ignoreCase = true) || (exitCode != null && exitCode != 0) -> ChatActivityPhase.FAILED
exitCode == 0 -> ChatActivityPhase.COMPLETE
status.lowercase() in setOf("completed", "complete", "exited", "finished", "done") -> ChatActivityPhase.COMPLETE
else -> ChatActivityPhase.UNKNOWN
}

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