Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a570e72d | ||
|
|
cbe811fb52 | ||
|
|
ac2508c5ce | ||
|
|
2ad7de4cc9 | ||
|
|
5eb5d198f4 | ||
|
|
7a86b9cee7 | ||
|
|
60f93994f5 | ||
|
|
52e0e38a81 | ||
|
|
471595240b | ||
|
|
061512d330 | ||
|
|
1c59f44ad7 | ||
|
|
269a2b5b36 | ||
|
|
a6879aac6f | ||
|
|
a04930c946 | ||
|
|
b71fea701f | ||
|
|
af54cdddb3 | ||
|
|
a717278e95 | ||
|
|
19d5237ebb | ||
|
|
6fbb21f437 | ||
|
|
c7f54321ad | ||
|
|
e6b9933d46 | ||
|
|
c27ee439ef | ||
|
|
68fce1b1da | ||
|
|
4547031bba | ||
|
|
41e9acf4c2 | ||
|
|
a5f671c06c | ||
|
|
5372fc1fef | ||
|
|
6c5ecbb028 | ||
|
|
bd5a1c3335 | ||
|
|
2c740c9f04 | ||
|
|
3ec89680ed | ||
|
|
42860d38cd | ||
|
|
ef4b3bdb6c | ||
|
|
44bbb16cd3 | ||
|
|
1f5b7e68fc | ||
|
|
4bb8d6fa7b | ||
|
|
6395e73927 | ||
|
|
c956232b96 | ||
|
|
9814cdca55 | ||
|
|
0c337c9384 | ||
|
|
273e3f5aff | ||
|
|
179080d6d9 | ||
|
|
ac1f42b7d5 | ||
|
|
88591fca89 | ||
|
|
00c5f5daa2 | ||
|
|
69b6c005cd | ||
|
|
c902215a15 | ||
|
|
d39368bc51 | ||
|
|
414dc08b9f | ||
|
|
3b78a17bd7 | ||
|
|
a5efb9ec96 | ||
|
|
e5228f2089 | ||
|
|
c738a2c2c0 | ||
|
|
8ed6b35e76 | ||
|
|
c6d4a15226 | ||
|
|
22c8f76c72 | ||
|
|
a0161f51ef | ||
|
|
7aa3b1c697 | ||
|
|
e7112dda9e | ||
|
|
c1413c494f | ||
|
|
bb23e6ab48 | ||
|
|
816638b46a | ||
|
|
df0fe59b0b | ||
|
|
df4d4c8f2a | ||
|
|
398ad253f1 | ||
|
|
ad4bbb2c86 | ||
|
|
7c5894213d | ||
|
|
82c088aa89 | ||
|
|
ed599995d6 | ||
|
|
75feb55adf | ||
|
|
e20540d16f | ||
|
|
c932adbc7b | ||
|
|
23cec497ce | ||
|
|
1739dc36a2 | ||
|
|
f4b366389b | ||
|
|
2ec7b7aac9 |
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+60
-12
@@ -6,22 +6,70 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
### Added
|
||||
|
||||
- **Chat and Bot Chat expose a clear mid-response send choice.** A slim translucent tray slides up behind the composer for Correct now or Queue next without covering the input. Chat settings sets the default; the tray overrides it for one message. Stop pauses pending messages until Resume, and editing or removing a queued message keeps its successors deliverable.
|
||||
- **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.
|
||||
- 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 message delivery labels remain readable inside user bubbles.** Steering confirmations and other delivery states use the bubble's contrasting text color instead of disappearing into its accent background.
|
||||
- **Android voice errors open in a readable dialog.** Long provider messages stay inside a scrollable, selectable detail area with separate Retry and Dismiss actions instead of overlapping Chat or Voice Focus controls.
|
||||
- **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 bounds automatic session refresh and large chat allocations.** A foreground directory refresh can no longer sustain a request loop, routine history opens read one latest 500-row page, Dashboard JSON and in-memory media exports reject oversized bodies, Markdown renders through a bounded presentation window, and chat image previews downsample before allocating pixels.
|
||||
- **Android keeps visible progress through image-generation tool gaps.** Gateway interim replies immediately hand off to a new in-chat working owner, so the standard working animation remains visible until media arrives even when upstream hides tool lifecycle events. Older Tool Search gateways that do expose a structured `tool_call` bridge are normalized to the effective image tool.
|
||||
- **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)
|
||||
- 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 shows Hermes profile display names and groups the resolved server default under its agent identity, while preserving explicit profile selection and saved conversations.
|
||||
|
||||
## [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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# Hermes-Relay Plugin v__VERSION__
|
||||
|
||||
**Release Date:** August 31, 2026
|
||||
**Release Date:** September 10, 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.
|
||||
This patch makes Android and Desktop tool availability fast and reliable when Relay is unavailable, starts late-created Android bridge sessions without restarting Hermes, and restores compatibility with both current and legacy `android_setup` arguments. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
|
||||
|
||||
## 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.
|
||||
- **Fast, accurate tool availability.** Android and Desktop tool checks use explicit IPv4 loopback and one bounded health snapshot instead of repeated per-tool connection attempts. Multi-PC capability advertisements remain isolated, and unavailable Relay clients continue to fail closed.
|
||||
- **Late Android bridge recovery.** `android_*` calls retry profile-scoped and active bridge-session credentials after a stale token is rejected, so a phone connected after Hermes startup becomes usable without restarting the host.
|
||||
- **Compatible Android setup arguments.** `android_setup` accepts the canonical `bridge_session_token` and `pairing_code` fields as well as their legacy aliases, with structured errors when no usable credential is supplied.
|
||||
- **Isolated setup tests.** Android tool setup tests use a temporary Hermes home instead of writing bridge settings into the operator environment.
|
||||
|
||||
## Install / update
|
||||
|
||||
|
||||
+10
-26
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.15.0
|
||||
# Hermes-Relay Android v1.16.1
|
||||
|
||||
**Release Date:** August 31, 2026
|
||||
**Release Date:** September 12, 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.16.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,32 +12,16 @@ 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.
|
||||
|
||||
## 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.
|
||||
This patch fixes a remaining cold-start path that could leave a Dashboard-only connection waiting for Gateway readiness until the app resumed or its network route changed.
|
||||
|
||||
## 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.
|
||||
- Dashboard-only connections now start the exact profile-scoped session directory before Gateway readiness, so the directory and passive Gateway socket no longer wait on each other during a cold launch.
|
||||
|
||||
## 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.16.1** (versionCode **56**).
|
||||
- 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.2** remains the matching optional release for Relay tools; this Gateway startup fix does not require it.
|
||||
- Explicit Direct API/API-only connections remain supported and are not used as silent failover for Dashboard-owned chats.
|
||||
- Granular Device Control and the system Voice Focus overlay remain sideload-only.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+100
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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()
|
||||
}
|
||||
}
|
||||
+183
@@ -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"
|
||||
}
|
||||
}
|
||||
+116
@@ -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"))
|
||||
}
|
||||
}
|
||||
+184
-5
@@ -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")
|
||||
|
||||
@@ -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.16.1 - Dashboard-only cold starts recover
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,177 @@
|
||||
{
|
||||
"schema": 3,
|
||||
"versions": [
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -1,29 +1,11 @@
|
||||
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
|
||||
v1.16.1 - Dashboard-only cold starts recover
|
||||
|
||||
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.
|
||||
* Dashboard-only connections can now prepare the selected profile and open Gateway chat without waiting for a background, resume, or network-route change.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
* Open Gateway chat from a Dashboard-only cold start — The selected profile's session directory starts before Gateway readiness, so it cannot wait on the same passive socket that depends on its result.
|
||||
|
||||
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.
|
||||
* 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.
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -135,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
|
||||
@@ -1506,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()
|
||||
@@ -1525,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")
|
||||
|
||||
@@ -431,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)
|
||||
@@ -633,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
|
||||
@@ -1563,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)
|
||||
@@ -1573,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 }
|
||||
@@ -1733,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1900,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,
|
||||
@@ -2950,6 +2987,7 @@ class GatewayChatClient(
|
||||
unmatchedTurnCompleteListener = null
|
||||
backgroundInteractionListener = null
|
||||
processEventListener = null
|
||||
subagentEventListener = null
|
||||
sessionDirectoryInvalidationListener = null
|
||||
closeSocket("client shutdown")
|
||||
backgroundCloseJob?.cancel()
|
||||
@@ -3485,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
|
||||
@@ -3494,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)
|
||||
@@ -3502,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))
|
||||
}
|
||||
@@ -3696,6 +3741,7 @@ class GatewayChatClient(
|
||||
if (type == "session.info" && !eventSessionId.isNullOrBlank() &&
|
||||
payload?.booleanField("lazy") != true
|
||||
) {
|
||||
sessionReadyFailures.remove(eventSessionId)
|
||||
readyLiveSessions += eventSessionId
|
||||
sessionReadyWaiters.remove(eventSessionId)?.complete(Unit)
|
||||
}
|
||||
@@ -3705,10 +3751,16 @@ class GatewayChatClient(
|
||||
// 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))
|
||||
(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)
|
||||
@@ -3825,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
|
||||
) {
|
||||
@@ -3852,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(
|
||||
@@ -3915,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-
|
||||
@@ -3968,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
|
||||
@@ -3976,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
|
||||
@@ -4159,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",
|
||||
@@ -4461,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
|
||||
@@ -5224,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,
|
||||
|
||||
+128
-54
@@ -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)
|
||||
@@ -292,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" -> {
|
||||
@@ -467,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"
|
||||
@@ -494,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,
|
||||
@@ -546,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,
|
||||
@@ -625,6 +698,7 @@ 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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+19
-23
@@ -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,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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,143 +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 = try {
|
||||
source.bytesProvider()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
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 = try {
|
||||
source.bytesProvider()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
+2
-8
@@ -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() }
|
||||
|
||||
+16
-4
@@ -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()
|
||||
}
|
||||
|
||||
+144
-149
@@ -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")
|
||||
|
||||
+50
-49
@@ -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
|
||||
@@ -616,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)
|
||||
@@ -626,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) {
|
||||
@@ -636,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 for local user messages, including steering.
|
||||
// Use the bubble's foreground: accent-on-accent hides the label.
|
||||
message.deliveryStatus?.takeIf { isUser }?.let { status ->
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 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.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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,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
|
||||
@@ -150,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
|
||||
@@ -159,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
|
||||
@@ -455,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,
|
||||
@@ -870,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) {
|
||||
@@ -935,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,
|
||||
@@ -961,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()
|
||||
@@ -976,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()
|
||||
@@ -1191,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
|
||||
@@ -1370,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)
|
||||
}
|
||||
@@ -1416,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) }
|
||||
@@ -1432,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
|
||||
}
|
||||
|
||||
@@ -1630,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) {
|
||||
@@ -1704,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
|
||||
@@ -1799,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) }
|
||||
@@ -1811,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 = {
|
||||
@@ -2441,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,
|
||||
@@ -2613,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,
|
||||
@@ -2763,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() } }) {
|
||||
@@ -2806,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 {
|
||||
@@ -2882,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(
|
||||
@@ -3212,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()
|
||||
@@ -3669,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,
|
||||
)
|
||||
@@ -3911,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,
|
||||
@@ -4046,7 +4060,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Copy feedback snackbar
|
||||
SnackbarHost(
|
||||
ThemedMessageHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -4061,7 +4075,7 @@ fun ChatScreen(
|
||||
subagentActivities = subagentActivities,
|
||||
subagentPreviewVisibility = subagentPreviewVisibility,
|
||||
loading = backgroundProcessesLoading,
|
||||
onClick = { showBackgroundProcesses = true },
|
||||
onClick = { chatViewModel.openCurrentActivityPreview(); showBackgroundProcesses = true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4569,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"
|
||||
@@ -4588,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()
|
||||
})
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -4814,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
|
||||
@@ -4953,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,
|
||||
@@ -4963,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
|
||||
},
|
||||
)
|
||||
@@ -5505,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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.ChatActivityPhase
|
||||
import com.hermesandroid.relay.data.ChatActivityRecord
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
|
||||
internal data class RetainedChatActivityPreview(
|
||||
val record: ChatActivityRecord,
|
||||
val processes: List<GatewayProcess> = emptyList(),
|
||||
)
|
||||
|
||||
/** Historical rows contain references and bounded summaries, never an invented live transcript. */
|
||||
internal fun ChatActivityRecord.previewActivities(): List<SubagentActivity> = children.mapIndexed { index, child ->
|
||||
val phase = when (child.phase) {
|
||||
ChatActivityPhase.RUNNING -> SubagentActivityPhase.STARTED
|
||||
ChatActivityPhase.COMPLETE -> SubagentActivityPhase.COMPLETED
|
||||
ChatActivityPhase.FAILED -> SubagentActivityPhase.FAILED
|
||||
ChatActivityPhase.CANCELLED -> SubagentActivityPhase.INTERRUPTED
|
||||
ChatActivityPhase.UNKNOWN -> SubagentActivityPhase.ENDED_WITH_PARENT
|
||||
}
|
||||
SubagentActivity(
|
||||
laneId = 0,
|
||||
turnId = "record:$id:${child.id}",
|
||||
taskIndex = index,
|
||||
taskCount = maxOf(taskCount, children.size),
|
||||
goal = child.goal,
|
||||
subagentId = child.id,
|
||||
childSessionId = child.childSessionId,
|
||||
profile = AgentDisplay.parseProfileContextKey(scopeKey)?.requestProfileName,
|
||||
phase = phase,
|
||||
summary = child.summary,
|
||||
events = child.summary?.let { summary -> listOf(SubagentActivityEvent(
|
||||
sequence = 0,
|
||||
kind = SubagentActivityEventKind.COMPLETED,
|
||||
text = summary,
|
||||
phase = phase,
|
||||
observedAtMillis = updatedAt,
|
||||
)) }.orEmpty(),
|
||||
partialAfterGap = true,
|
||||
revision = updatedAt,
|
||||
)
|
||||
}
|
||||
@@ -72,6 +72,9 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyBatch
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyQuestion
|
||||
import com.hermesandroid.relay.data.clarifyQuestionCardKey
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAgentNotice
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
|
||||
@@ -94,6 +97,10 @@ import com.hermesandroid.relay.network.upstream.GatewayModelOptions
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcessCapability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcessEvent
|
||||
import com.hermesandroid.relay.data.ChatActivityRecord
|
||||
import com.hermesandroid.relay.data.ChatActivityKind
|
||||
import com.hermesandroid.relay.data.ChatActivityStore
|
||||
import com.hermesandroid.relay.data.DataStoreChatActivityStore
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySessionModel
|
||||
import com.hermesandroid.relay.network.upstream.ReasoningEffortAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ReasoningEffortIdentity
|
||||
@@ -2235,6 +2242,11 @@ class ChatViewModel : ViewModel() {
|
||||
private var gatewayProcessSource: GatewayProcessSource? = null
|
||||
private val gatewayProcessController = GatewayProcessController(viewModelScope)
|
||||
private val subagentActivityController = SubagentActivityController()
|
||||
private val chatActivityController = ChatActivityController(viewModelScope)
|
||||
internal val activityRecords = chatActivityController.records
|
||||
private var activityStoreInitialized = false
|
||||
private val _retainedActivityPreview = MutableStateFlow<RetainedChatActivityPreview?>(null)
|
||||
internal val retainedActivityPreview = _retainedActivityPreview.asStateFlow()
|
||||
private val subagentChildPreviewController = SubagentChildPreviewController(viewModelScope)
|
||||
internal val subagentChildPreview: StateFlow<SubagentChildPreview?> =
|
||||
subagentChildPreviewController.state
|
||||
@@ -2254,7 +2266,10 @@ class ChatViewModel : ViewModel() {
|
||||
subagentActivityController.activities
|
||||
|
||||
fun openSubagentChildPreview(activityKey: String) {
|
||||
val activity = subagentActivities.value.firstOrNull { it.stableKey == activityKey } ?: return
|
||||
if (supervisedModePolicy.enabled) return
|
||||
val history = _retainedActivityPreview.value
|
||||
val candidates = history?.record?.previewActivities() ?: subagentActivities.value
|
||||
val activity = candidates.firstOrNull { it.stableKey == activityKey } ?: return
|
||||
val parentSessionId = chatHandler?.currentSessionId?.value ?: return
|
||||
val parentScopeKey = activeProfileContextKey
|
||||
val client = gatewayClient
|
||||
@@ -2276,6 +2291,49 @@ class ChatViewModel : ViewModel() {
|
||||
subagentChildPreviewController.close()
|
||||
}
|
||||
|
||||
internal fun setChatActivityStore(store: ChatActivityStore) {
|
||||
activityStoreInitialized = true
|
||||
chatActivityController.bindStore(store)
|
||||
}
|
||||
|
||||
fun openCurrentActivityPreview() {
|
||||
_retainedActivityPreview.value = null
|
||||
closeSubagentChildPreview()
|
||||
subagentActivityController.setPreviewOpen(true)
|
||||
}
|
||||
|
||||
internal fun openRetainedActivity(record: ChatActivityRecord, processDetail: String? = null): Boolean {
|
||||
if (record.scopeKey != activeProfileContextKey || record.sessionId != chatHandler?.currentSessionId?.value) return false
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.visibility.resolved().showWorkingStatus) return false
|
||||
closeSubagentChildPreview()
|
||||
subagentActivityController.setPreviewOpen(false)
|
||||
val process = if (record.kind == ChatActivityKind.PROCESS) {
|
||||
val exact = backgroundProcesses.value.singleOrNull {
|
||||
!supervisedModePolicy.enabled && record.processStartedAt != null &&
|
||||
it.id == record.processId && it.startedAt == record.processStartedAt
|
||||
}
|
||||
exact ?: GatewayProcess(
|
||||
id = record.processId ?: record.sourceId,
|
||||
command = if (supervisedModePolicy.enabled) {
|
||||
appContext?.getString(R.string.chat_activity_receipt_process) ?: "Background command"
|
||||
} else record.title,
|
||||
status = record.phase.name.lowercase(),
|
||||
outputTail = if (supervisedModePolicy.enabled) null else processDetail?.take(4_000)
|
||||
?: appContext?.getString(R.string.chat_activity_output_unavailable)
|
||||
?: "Output is no longer available. This entry preserves the recorded process status.",
|
||||
exitCode = record.exitCode,
|
||||
)
|
||||
} else null
|
||||
_retainedActivityPreview.value = RetainedChatActivityPreview(record, listOfNotNull(process))
|
||||
return true
|
||||
}
|
||||
|
||||
fun closeActivityPreview() {
|
||||
closeSubagentChildPreview()
|
||||
subagentActivityController.setPreviewOpen(false)
|
||||
_retainedActivityPreview.value = null
|
||||
}
|
||||
|
||||
private val _messageReactionsSupported = MutableStateFlow(true)
|
||||
val messageReactionsSupported: StateFlow<Boolean> = _messageReactionsSupported.asStateFlow()
|
||||
|
||||
@@ -2691,10 +2749,12 @@ class ChatViewModel : ViewModel() {
|
||||
previousClient?.setColdPrewarmSessionReadyListener(null)
|
||||
previousClient?.setUnmatchedTurnCompleteListener(null)
|
||||
previousClient?.setBackgroundInteractionListener(null)
|
||||
previousClient?.setSubagentEventListener(null)
|
||||
previousClient?.setSessionDirectoryInvalidationListener(null)
|
||||
}
|
||||
gatewayClient = client
|
||||
if (changed) {
|
||||
chatActivityController.markUnavailable()
|
||||
dismissChatFailure()
|
||||
resetApprovalModeState()
|
||||
_messageReactionsSupported.value = true
|
||||
@@ -2737,6 +2797,29 @@ class ChatViewModel : ViewModel() {
|
||||
client?.setUnsolicitedTurnProvider { storedSessionId ->
|
||||
createGatewayInboundTurnRegistration(client, storedSessionId)
|
||||
}
|
||||
client?.setSubagentEventListener { sessionId, profile, event ->
|
||||
if (gatewayClient === client && streamingEndpoint == "gateway" &&
|
||||
chatHandler?.currentSessionId?.value == sessionId &&
|
||||
currentSessionProfileName() == profile
|
||||
) {
|
||||
val owner = AgentDisplay.parseProfileContextKey(activeProfileContextKey)
|
||||
val checkpointProfileKey = activeTurnCheckpointSeed?.takeIf {
|
||||
it.sessionId == sessionId && it.contextKey == activeProfileContextKey
|
||||
}?.profileKey
|
||||
val previousRevisions = subagentActivities.value.associate { it.stableKey to it.revision }
|
||||
subagentActivityController.onSessionEvent(
|
||||
sessionId, activeProfileContextKey, event,
|
||||
when {
|
||||
checkpointProfileKey != null -> AgentDisplay.profileRequestName(checkpointProfileKey)
|
||||
owner != null -> owner.requestProfileName
|
||||
else -> profile
|
||||
},
|
||||
)
|
||||
chatActivityController.captureSubagents(subagentActivities.value.filter {
|
||||
previousRevisions[it.stableKey] != it.revision
|
||||
})
|
||||
}
|
||||
}
|
||||
client?.setSessionDirectoryInvalidationListener {
|
||||
// Gateway emits this only after durable session state changes. It
|
||||
// is also a useful liveness edge after a Dashboard timeout: retry
|
||||
@@ -2826,6 +2909,8 @@ class ChatViewModel : ViewModel() {
|
||||
if (changed) {
|
||||
gatewayStateSyncJob?.cancel()
|
||||
gatewayStateSyncJob = null
|
||||
_gatewayPreparingSessionId.value = null
|
||||
_gatewaySocketState.value = GatewayConnectionState.Idle
|
||||
client?.let { startGatewayStateSync(it) }
|
||||
if (client != null) {
|
||||
gatewayVisibleReattachJob = viewModelScope.launch {
|
||||
@@ -2905,9 +2990,10 @@ class ChatViewModel : ViewModel() {
|
||||
_reasoningDisplay.value = null
|
||||
}
|
||||
}
|
||||
if (changed && client != null && streamRecovery != null &&
|
||||
AppForegroundTracker.isForeground.value
|
||||
) {
|
||||
// Visibility can arrive before the runtime binder publishes its client.
|
||||
// Start the same socket-only warmup in either ordering; prewarmGateway
|
||||
// retains the directory barrier and exact-checkpoint ownership rules.
|
||||
if (changed && client != null && chatVisible) {
|
||||
prewarmGateway()
|
||||
}
|
||||
if (changed && client != null) requestSessionActivityRefresh()
|
||||
@@ -2969,7 +3055,6 @@ class ChatViewModel : ViewModel() {
|
||||
): GatewayInboundTurnRegistration? {
|
||||
val handler = chatHandler ?: return null
|
||||
val eventScopeKey = activeProfileContextKey
|
||||
val eventProfile = currentSessionProfileName()
|
||||
fun matchesAdmissionContext(): Boolean =
|
||||
gatewayClient === client &&
|
||||
streamingEndpoint == "gateway" &&
|
||||
@@ -3128,13 +3213,6 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
if (acceptsEvent()) {
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = storedSessionId,
|
||||
eventScopeKey = eventScopeKey,
|
||||
turnId = messageId,
|
||||
event = event,
|
||||
profile = eventProfile,
|
||||
)
|
||||
handler.onSubagentEvent(messageId, event)
|
||||
}
|
||||
},
|
||||
@@ -3577,7 +3655,7 @@ class ChatViewModel : ViewModel() {
|
||||
private val backgroundPendingInteractions =
|
||||
ConcurrentHashMap<TurnCheckpointKey, BackgroundPendingInteraction>()
|
||||
|
||||
/** Ask cardKeys with a respond RPC in flight — blocks double-taps until it settles. */
|
||||
/** Request-incarnation/card keys with a respond RPC in flight. */
|
||||
private val answeredAskIds = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
@@ -3873,6 +3951,10 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId: String?,
|
||||
scopeKey: String? = activeProfileContextKey,
|
||||
) {
|
||||
_retainedActivityPreview.value?.record?.let {
|
||||
if (it.sessionId != sessionId || it.scopeKey != scopeKey) closeActivityPreview()
|
||||
}
|
||||
chatActivityController.selectSession(scopeKey, sessionId)
|
||||
subagentChildPreview.value?.let { preview ->
|
||||
if (preview.parentSessionId != sessionId || preview.parentScopeKey != scopeKey) {
|
||||
closeSubagentChildPreview()
|
||||
@@ -4136,6 +4218,10 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private var gatewayStateSyncJob: Job? = null
|
||||
private val _gatewayPreparingSessionId = MutableStateFlow<String?>(null)
|
||||
val gatewayPreparingSessionId = _gatewayPreparingSessionId.asStateFlow()
|
||||
private val _gatewaySocketState = MutableStateFlow(GatewayConnectionState.Idle)
|
||||
val gatewaySocketState = _gatewaySocketState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Last credential_warning already surfaced as a system notice, so the
|
||||
@@ -4158,7 +4244,18 @@ class ChatViewModel : ViewModel() {
|
||||
private fun startGatewayStateSync(client: GatewayChatClient) {
|
||||
gatewayStateSyncJob?.cancel()
|
||||
lastSurfacedCredentialWarning = null
|
||||
gatewayProcessController.setSnapshotListener { processes ->
|
||||
val sessionId = chatHandler?.currentSessionId?.value
|
||||
if (gatewayClient === client && sessionId != null &&
|
||||
gatewayProcessController.ownsSnapshot(sessionId, activeProfileContextKey)
|
||||
) chatActivityController.captureProcesses(processes)
|
||||
}
|
||||
gatewayStateSyncJob = viewModelScope.launch {
|
||||
launch {
|
||||
client.preparingSessionId.collect {
|
||||
if (gatewayClient === client) _gatewayPreparingSessionId.value = it
|
||||
}
|
||||
}
|
||||
launch {
|
||||
backgroundProcesses.collect {
|
||||
if (gatewayClient !== client) return@collect
|
||||
@@ -4172,6 +4269,10 @@ class ChatViewModel : ViewModel() {
|
||||
launch {
|
||||
client.connectionState.collect { state ->
|
||||
if (gatewayClient !== client) return@collect
|
||||
if (_gatewaySocketState.value == GatewayConnectionState.Ready && state != GatewayConnectionState.Ready) {
|
||||
chatActivityController.markUnavailable()
|
||||
}
|
||||
_gatewaySocketState.value = state
|
||||
subagentActivityController.onConnectionReady(
|
||||
state == com.hermesandroid.relay.network.upstream.GatewayConnectionState.Ready,
|
||||
)
|
||||
@@ -4662,6 +4763,7 @@ class ChatViewModel : ViewModel() {
|
||||
dashboardMediaClientProvider: () -> DashboardApiClient? = { null },
|
||||
) {
|
||||
this.appContext = context.applicationContext
|
||||
if (!activityStoreInitialized) setChatActivityStore(DataStoreChatActivityStore(context.applicationContext))
|
||||
if (chatTurnCheckpointStore == null) {
|
||||
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
|
||||
}
|
||||
@@ -4689,6 +4791,7 @@ class ChatViewModel : ViewModel() {
|
||||
/** Route-owned Gateway chat setup without borrowing the active connection's Relay/media clients. */
|
||||
fun initializeGatewayOnly(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
if (!activityStoreInitialized) setChatActivityStore(DataStoreChatActivityStore(context.applicationContext))
|
||||
if (chatTurnCheckpointStore == null) {
|
||||
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
|
||||
}
|
||||
@@ -6159,6 +6262,7 @@ class ChatViewModel : ViewModel() {
|
||||
} else {
|
||||
client?.deleteSession(sessionId) == true
|
||||
}
|
||||
if (success && contextKey != null) chatActivityController.removeSession(contextKey, sessionId)
|
||||
if (
|
||||
activeProfileContextKey != contextKey ||
|
||||
currentSessionProfileName() != profileName
|
||||
@@ -6673,7 +6777,18 @@ class ChatViewModel : ViewModel() {
|
||||
existing.ask.kind == ask.kind &&
|
||||
existing.ask.requestId == ask.requestId
|
||||
) {
|
||||
sessionId?.let { maybeNotifyInteraction(it, existing.ask) }
|
||||
if (ask.questions.isNotEmpty()) {
|
||||
val updated = existing.copy(ask = ask.copy(answers = existing.ask.answers + ask.answers))
|
||||
_pendingAsk.value = updated
|
||||
updateClarifyBatchCard(handler, updated)
|
||||
if (updated.ask.clarifyComplete) {
|
||||
_pendingAsk.value = null
|
||||
updated.sessionId?.let { cancelInteractionNotification(it, updated.ask) }
|
||||
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
_pendingAsk.value?.let { pending -> sessionId?.let { maybeNotifyInteraction(it, pending.ask) } }
|
||||
return
|
||||
}
|
||||
existing?.let { pending ->
|
||||
@@ -6686,8 +6801,11 @@ class ChatViewModel : ViewModel() {
|
||||
publishBackgroundSessionActivity()
|
||||
}
|
||||
val now = restored?.receivedAt ?: System.currentTimeMillis()
|
||||
val cardKey = restored?.cardKey ?: ask.requestId
|
||||
val proposedCardKey = restored?.cardKey ?: ask.requestId
|
||||
?: "approval-${handler.currentSessionId.value ?: "session"}-$now"
|
||||
val cardKey = if (restored == null && handler.messages.value.any { message ->
|
||||
message.cards.any { it.id == proposedCardKey }
|
||||
}) "$proposedCardKey-${java.util.UUID.randomUUID()}" else proposedCardKey
|
||||
val expiresAt = ask.timeoutSeconds.takeIf { it > 0 }?.let { now + it * 1_000L }
|
||||
val card = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> HermesCard(
|
||||
@@ -6786,12 +6904,52 @@ class ChatViewModel : ViewModel() {
|
||||
contextKey = contextKey,
|
||||
sessionId = sessionId,
|
||||
receivedAt = now,
|
||||
ownerId = restored?.ownerId ?: java.util.UUID.randomUUID().toString(),
|
||||
)
|
||||
if (ask.questions.isNotEmpty()) updateClarifyBatchCard(handler, requireNotNull(_pendingAsk.value))
|
||||
if (ask.clarifyComplete) {
|
||||
_pendingAsk.value = null
|
||||
activeKey?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
return
|
||||
}
|
||||
activeKey?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), true) }
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
sessionId?.let { maybeNotifyInteraction(it, ask) }
|
||||
}
|
||||
|
||||
private fun updateClarifyBatchCard(handler: ChatHandler, pending: PendingAsk) {
|
||||
val ask = pending.ask
|
||||
if (ask.questions.isEmpty()) return
|
||||
handler.updateAskCardMessage(
|
||||
pending.messageId,
|
||||
HermesCard(
|
||||
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
|
||||
title = appContext?.getString(R.string.chat_approval_clarify_title) ?: "Hermes needs clarification",
|
||||
accent = HermesCard.Accents.INFO,
|
||||
id = pending.cardKey,
|
||||
clarifyBatch = HermesCardClarifyBatch(
|
||||
questions = ask.questions.map { question ->
|
||||
val key = clarifyQuestionCardKey(pending.cardKey, question.qid)
|
||||
HermesCardClarifyQuestion(
|
||||
key = key,
|
||||
question = question.question,
|
||||
input = HermesCardInput(
|
||||
kind = if (question.choices.isEmpty()) HermesCardInput.Kinds.TEXT else HermesCardInput.Kinds.CHOICE,
|
||||
choices = question.choices,
|
||||
multiSelect = question.multiSelect,
|
||||
allowFreeText = true,
|
||||
),
|
||||
answer = ask.answers[question.qid],
|
||||
submitting = "${pending.ownerId}:$key" in answeredAskIds,
|
||||
)
|
||||
},
|
||||
expiresAtMillis = ask.timeoutSeconds.takeIf { it > 0 }?.let { pending.receivedAt + it * 1_000L },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervised Chat never exposes approval, clarification, sudo, or secret
|
||||
* inputs. Settle the upstream interaction immediately with its safest
|
||||
@@ -6865,21 +7023,35 @@ class ChatViewModel : ViewModel() {
|
||||
fun answerAsk(messageId: String, cardKey: String, value: String) {
|
||||
val handler = chatHandler ?: return
|
||||
val pending = _pendingAsk.value
|
||||
val question = pending?.ask?.questions?.firstOrNull {
|
||||
clarifyQuestionCardKey(pending.cardKey, it.qid) == cardKey
|
||||
}
|
||||
if (pending == null ||
|
||||
pending.cardKey != cardKey ||
|
||||
pending.messageId != messageId ||
|
||||
(if (pending.ask.questions.isNotEmpty()) question == null else pending.cardKey != cardKey) ||
|
||||
pending.contextKey != activeProfileContextKey ||
|
||||
pending.sessionId != handler.currentSessionId.value
|
||||
) {
|
||||
handler.addSystemNotice("This request is no longer active.")
|
||||
return
|
||||
}
|
||||
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY && value.isBlank()) return
|
||||
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY && pending.ask.timeoutSeconds > 0 &&
|
||||
System.currentTimeMillis() >= pending.receivedAt + pending.ask.timeoutSeconds * 1_000L
|
||||
) {
|
||||
expirePendingAsk(GatewayAskExpiry(pending.ask.kind, pending.ask.requestId))
|
||||
return
|
||||
}
|
||||
if (question != null && question.qid in pending.ask.answers) return
|
||||
val gateway = gatewayClient
|
||||
if (gateway == null) {
|
||||
emitError(Exception("Gateway is not connected"), context = "send_message")
|
||||
return
|
||||
}
|
||||
// In-flight guard: one respond RPC per card at a time.
|
||||
if (!answeredAskIds.add(cardKey)) return
|
||||
// Include the request incarnation so a late completion cannot unlock a reused id.
|
||||
val flightKey = "${pending.ownerId}:$cardKey"
|
||||
if (!answeredAskIds.add(flightKey)) return
|
||||
updateClarifyBatchCard(handler, pending)
|
||||
val ask = pending.ask
|
||||
val stampValue = when (ask.kind) {
|
||||
// Empty sudo password = decline — stamp matches the Deny action
|
||||
@@ -6891,11 +7063,19 @@ class ChatViewModel : ViewModel() {
|
||||
else -> value
|
||||
}
|
||||
viewModelScope.launch {
|
||||
fun ownsResponse(): Boolean = chatHandler === handler && gatewayClient === gateway &&
|
||||
activeProfileContextKey == pending.contextKey &&
|
||||
handler.currentSessionId.value == pending.sessionId &&
|
||||
_pendingAsk.value?.ownerId == pending.ownerId
|
||||
if (!ownsResponse()) {
|
||||
answeredAskIds.remove(flightKey)
|
||||
return@launch
|
||||
}
|
||||
val requestId = ask.requestId
|
||||
val result = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> gateway.respondApproval(choice = value)
|
||||
GatewayAsk.Kind.CLARIFY ->
|
||||
requestId?.let { gateway.respondClarify(it, value) }
|
||||
requestId?.let { gateway.respondClarify(it, value.trim(), question?.qid) }
|
||||
?: Result.failure(GatewayRpcException("ask has no request id"))
|
||||
GatewayAsk.Kind.SUDO ->
|
||||
requestId?.let { gateway.respondSudo(it, value) }
|
||||
@@ -6906,6 +7086,8 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
answeredAskIds.remove(flightKey)
|
||||
if (!ownsResponse()) return@fold
|
||||
if (response == GatewayAskResponse.EXPIRED) {
|
||||
expirePendingAsk(
|
||||
GatewayAskExpiry(
|
||||
@@ -6915,10 +7097,24 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
return@fold
|
||||
}
|
||||
if (question != null) {
|
||||
val current = requireNotNull(_pendingAsk.value)
|
||||
val updated = current.copy(ask = current.ask.copy(answers = current.ask.answers + (question.qid to value.trim())))
|
||||
updateClarifyBatchCard(handler, updated)
|
||||
if (updated.ask.questions.all { it.qid in updated.ask.answers }) {
|
||||
updated.sessionId?.let { cancelInteractionNotification(it, updated.ask) }
|
||||
_pendingAsk.value = null
|
||||
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
|
||||
} else {
|
||||
_pendingAsk.value = updated
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
return@fold
|
||||
}
|
||||
// Collapse only after the server confirms — a failed RPC
|
||||
// must leave the card answerable for a retry.
|
||||
handler.recordCardDispatch(pending.messageId, cardKey, stampValue)
|
||||
if (_pendingAsk.value === pending) {
|
||||
if (ownsResponse()) {
|
||||
pending.sessionId?.let { cancelInteractionNotification(it, pending.ask) }
|
||||
_pendingAsk.value = null
|
||||
activeTurnCheckpointKey()?.let {
|
||||
@@ -6928,7 +7124,9 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
answeredAskIds.remove(cardKey)
|
||||
answeredAskIds.remove(flightKey)
|
||||
if (!ownsResponse()) return@fold
|
||||
updateClarifyBatchCard(handler, requireNotNull(_pendingAsk.value))
|
||||
emitError(e, context = "send_message")
|
||||
},
|
||||
)
|
||||
@@ -6953,7 +7151,6 @@ class ChatViewModel : ViewModel() {
|
||||
activeTurnCheckpointKey()?.let {
|
||||
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
|
||||
}
|
||||
answeredAskIds.remove(pending.cardKey)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
chatHandler?.recordCardDispatch(
|
||||
pending.messageId,
|
||||
@@ -6976,7 +7173,9 @@ class ChatViewModel : ViewModel() {
|
||||
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
|
||||
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY) {
|
||||
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, HermesCardDispatch.EXPIRED_STAMP)
|
||||
} else if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
|
||||
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, "deny")
|
||||
}
|
||||
}
|
||||
@@ -7584,6 +7783,9 @@ class ChatViewModel : ViewModel() {
|
||||
text = ask.ask.text,
|
||||
choices = ask.ask.choices,
|
||||
multiSelect = ask.ask.multiSelect,
|
||||
questions = ask.ask.questions,
|
||||
answers = ask.ask.answers,
|
||||
ownerId = ask.ownerId,
|
||||
smartDenied = ask.ask.smartDenied,
|
||||
envVar = ask.ask.envVar,
|
||||
timeoutSeconds = ask.ask.timeoutSeconds,
|
||||
@@ -7792,6 +7994,8 @@ class ChatViewModel : ViewModel() {
|
||||
text = saved.text,
|
||||
choices = saved.choices,
|
||||
multiSelect = saved.multiSelect,
|
||||
questions = saved.questions,
|
||||
answers = saved.answers,
|
||||
smartDenied = saved.smartDenied,
|
||||
envVar = saved.envVar,
|
||||
timeoutSeconds = saved.timeoutSeconds,
|
||||
@@ -8121,18 +8325,6 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
if (owns()) {
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = checkpoint.sessionId,
|
||||
eventScopeKey = checkpoint.contextKey,
|
||||
turnId = messageId,
|
||||
event = event,
|
||||
profile = if (checkpoint.profileKey != null) {
|
||||
AgentDisplay.profileRequestName(checkpoint.profileKey)
|
||||
} else {
|
||||
AgentDisplay.parseProfileContextKey(checkpoint.contextKey)
|
||||
?.requestProfileName
|
||||
},
|
||||
)
|
||||
handler.onSubagentEvent(messageId, event)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
@@ -9578,8 +9770,8 @@ class ChatViewModel : ViewModel() {
|
||||
* 0. **Gateway transport** — the server owns the persona end-to-end: the
|
||||
* session is bound to the selected profile (SOUL applied server-side) and
|
||||
* the personality overlay rides `config.set`/`ephemeral_system_prompt`.
|
||||
* The phone sends NO persona/profile prompt (only the phone-status block)
|
||||
* so it can't double-apply. Cases 1–3 are the SSE-fallback rules.
|
||||
* The phone sends no per-turn system context on Gateway.
|
||||
* Cases 1–3 are the explicit API-only transport rules.
|
||||
* 1. **Selected profile with a non-blank [Profile.systemMessage]** —
|
||||
* profile wins outright. Profile is a richer, newer concept than
|
||||
* personality: it bundles model + persona (from the profile's
|
||||
@@ -9594,15 +9786,12 @@ class ChatViewModel : ViewModel() {
|
||||
* configured default.
|
||||
*
|
||||
* The phone-status [appContextSettings] block is appended to whichever
|
||||
* of the above wins (or sent alone in case 3), so the LLM always sees
|
||||
* phone state regardless of persona source.
|
||||
* of the API-only cases above wins (or sent alone in case 3).
|
||||
*/
|
||||
/**
|
||||
* The exact `system_message` (`ephemeral_system_prompt`) injected for a
|
||||
* turn, split into labeled blocks. Single source of truth shared by
|
||||
* [startStream] (which sends [combinedSystemMessage]) and
|
||||
* [previewInjectedContext] (which renders it in the chat audit sheet) so
|
||||
* the preview can never drift from what is actually sent.
|
||||
* Context prepared for the selected transport, split into labeled blocks.
|
||||
* This is a preview, not a delivery receipt or the complete agent prompt.
|
||||
* Gateway has no general per-turn system-context slot.
|
||||
*/
|
||||
data class InjectedContext(
|
||||
val personaPrompt: String?,
|
||||
@@ -9633,6 +9822,8 @@ class ChatViewModel : ViewModel() {
|
||||
* audit UI labels that block "added server-side".
|
||||
*/
|
||||
val personaOwnedServerSide: Boolean,
|
||||
/** False on Gateway; unsupported blocks are excluded from the payload and preview. */
|
||||
val perTurnContextSupported: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -9668,11 +9859,14 @@ class ChatViewModel : ViewModel() {
|
||||
} else {
|
||||
_sseToolNames.value
|
||||
}
|
||||
val appContextRaw = buildPromptBlock(
|
||||
settings = appContextSettings,
|
||||
snapshot = capturePhoneSnapshot(),
|
||||
availableTools = availableTools,
|
||||
)
|
||||
val appContextRaw = if (!gateway && appContextSettings.master) {
|
||||
buildPromptBlock(
|
||||
settings = appContextSettings,
|
||||
snapshot = capturePhoneSnapshot(),
|
||||
availableTools = availableTools,
|
||||
)
|
||||
} else null
|
||||
val interfaceContext = interfaceContextPrompt?.takeIf { !gateway && it.isNotBlank() }
|
||||
// Gateway has no per-turn system slot. SSE carries the standard
|
||||
// Dashboard route first, then the optional Relay enhancement.
|
||||
val upstreamMediaAvailable = dashboardMediaClientProvider?.invoke() != null
|
||||
@@ -9684,25 +9878,26 @@ class ChatViewModel : ViewModel() {
|
||||
// block (a stable environment fact, like phone status) and before the
|
||||
// per-turn interface context. The per-block fields below null out blanks
|
||||
// only for display.
|
||||
val combined = listOfNotNull(personaPrompt, appContextRaw, mediaCapability, interfaceContextPrompt)
|
||||
val combined = listOfNotNull(personaPrompt, appContextRaw, mediaCapability, interfaceContext)
|
||||
.joinToString("\n\n")
|
||||
.ifBlank { null }
|
||||
return InjectedContext(
|
||||
personaPrompt = personaPrompt,
|
||||
appContext = appContextRaw?.takeIf { it.isNotBlank() },
|
||||
interfaceContext = interfaceContextPrompt?.takeIf { it.isNotBlank() },
|
||||
interfaceContext = interfaceContext,
|
||||
mediaCapability = mediaCapability,
|
||||
relayServerBlocks = relayServerBlocks,
|
||||
relayMediaAvailable = relayMediaAvailable,
|
||||
combinedSystemMessage = combined,
|
||||
transport = streamingEndpoint,
|
||||
personaOwnedServerSide = gateway,
|
||||
perTurnContextSupported = !gateway,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Live audit snapshot of what the agent will be injected with on the next
|
||||
* turn — rendered by the chat context sheet. Per-turn voice context is null
|
||||
* Preview of context supported by the selected transport, plus separately
|
||||
* reported Relay configuration. Per-turn voice context is null
|
||||
* here (it's set only on a spoken turn); the UI notes that.
|
||||
*/
|
||||
fun previewInjectedContext(): InjectedContext {
|
||||
@@ -10664,13 +10859,6 @@ class ChatViewModel : ViewModel() {
|
||||
onSubagentEvent = { event ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.flushNow()
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = handler.currentSessionId.value,
|
||||
eventScopeKey = activeProfileContextKey,
|
||||
turnId = currentMessageId,
|
||||
event = event,
|
||||
profile = currentSessionProfileName(),
|
||||
)
|
||||
handler.onSubagentEvent(currentMessageId, event)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
@@ -10831,6 +11019,10 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
fun cancelStream() {
|
||||
intentionallyCancelled = true
|
||||
if (activeStream != null) {
|
||||
subagentActivityController.interrupt()
|
||||
chatActivityController.captureSubagents(subagentActivities.value)
|
||||
}
|
||||
currentQueueDestination()?.let { destination ->
|
||||
if (queuedMessageItems.any { it.contextKey to it.sessionId == destination }) {
|
||||
pausedQueueDestinations += destination
|
||||
@@ -11366,9 +11558,8 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Classifier produces a specific label (disk full, bad
|
||||
// URI, permission, …) for both the in-card text and the
|
||||
// global snackbar — same event, two surfaces.
|
||||
// Attachment owns failure and retry. History hydration can
|
||||
// fetch many files; never queue a global popup per file.
|
||||
val human = classifyError(e, context = "media_fetch", ctx = appContext)
|
||||
updateAttachmentByToken(handler, messageId, fetchKey, expectedRole = expectedRole) { att ->
|
||||
att.copy(
|
||||
@@ -11376,7 +11567,6 @@ class ChatViewModel : ViewModel() {
|
||||
errorMessage = human.body
|
||||
)
|
||||
}
|
||||
_errorEvents.tryEmit(human)
|
||||
}
|
||||
},
|
||||
onFailure = { err ->
|
||||
@@ -11396,7 +11586,6 @@ class ChatViewModel : ViewModel() {
|
||||
errorMessage = human.body
|
||||
)
|
||||
}
|
||||
_errorEvents.tryEmit(human)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -11661,6 +11850,7 @@ class ChatViewModel : ViewModel() {
|
||||
gatewayClient?.setColdPrewarmSessionReadyListener(null)
|
||||
gatewayClient?.setUnmatchedTurnCompleteListener(null)
|
||||
gatewayClient?.setBackgroundInteractionListener(null)
|
||||
gatewayClient?.setSubagentEventListener(null)
|
||||
backgroundPendingInteractions.clear()
|
||||
backgroundNeedsInputKeys.clear()
|
||||
publishBackgroundSessionActivity()
|
||||
@@ -11759,6 +11949,7 @@ data class PendingAsk(
|
||||
/** Stored session id within [contextKey]; approvals are session-scoped upstream. */
|
||||
val sessionId: String?,
|
||||
val receivedAt: Long = System.currentTimeMillis(),
|
||||
val ownerId: String = java.util.UUID.randomUUID().toString(),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -517,11 +517,22 @@ internal fun resolveEffectiveDashboardUrl(
|
||||
connection.authenticatedDashboardOrigin
|
||||
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
|
||||
?.let { return it }
|
||||
endpoint?.pluginProxyRoutesOrNull()?.dashboardBaseUrl?.let { return it }
|
||||
endpoint?.dashboard?.url
|
||||
// The resolver publishes independently of the active connection. During a
|
||||
// switch its last winner can still belong to the outgoing installation.
|
||||
// Never use that winner as authority for the incoming connection's bearer.
|
||||
val routes = connection.routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(
|
||||
apiServerUrl = connection.apiServerUrl,
|
||||
relayUrl = connection.relayUrl,
|
||||
dashboardUrl = connection.configuredDashboardUrl,
|
||||
)
|
||||
}
|
||||
val ownedEndpoint = endpoint?.takeIf { it in routes }
|
||||
ownedEndpoint?.pluginProxyRoutesOrNull()?.dashboardBaseUrl?.let { return it }
|
||||
ownedEndpoint?.dashboard?.url
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { return it }
|
||||
endpoint?.api?.url?.let { apiUrl ->
|
||||
ownedEndpoint?.api?.url?.let { apiUrl ->
|
||||
connection.dashboardUrl
|
||||
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) }
|
||||
?.let { return it }
|
||||
@@ -1747,15 +1758,20 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
/**
|
||||
* Dashboard URL for the active connection **on the currently-resolved
|
||||
* route** — snapshot twin of [effectiveDashboardUrl], which it delegates
|
||||
* to. Standard voice and the availability probe read this per call, so
|
||||
* route**. Read the authoritative id/list synchronously, not the combined
|
||||
* [effectiveDashboardUrl] StateFlow: its previous emission can outlive a
|
||||
* connection switch and must not authorize the new owner's credentials.
|
||||
* Standard voice and the availability probe read this per call, so
|
||||
* an auto-managed dashboard URL follows LAN/Tailscale handoffs the same
|
||||
* way Manage does; an explicit dashboard override stays pinned. (This
|
||||
* used to read the persisted `resolvedDashboardUrl`, which kept voice
|
||||
* aimed at the LAN host after the resolver had moved chat to Tailscale.)
|
||||
*/
|
||||
fun activeDashboardUrl(): String? =
|
||||
effectiveDashboardUrl.value.takeIf { it.isNotBlank() }
|
||||
resolveEffectiveDashboardUrl(
|
||||
connection = activeConnectionSnapshot(),
|
||||
endpoint = connectionManager.activeEndpoint.value,
|
||||
).takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Promote the exact reviewed Dashboard origin that completed cookie/OIDC
|
||||
@@ -2341,6 +2357,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
get() = profileController.effectiveSessionProfileName
|
||||
val effectiveDisplayProfile: StateFlow<Profile?>
|
||||
get() = profileController.effectiveDisplayProfile
|
||||
val serverDefaultDisplayProfile: StateFlow<Profile?>
|
||||
get() = profileController.serverDefaultDisplayProfile
|
||||
|
||||
fun refreshDashboardProfiles() = profileController.refreshDashboardProfiles()
|
||||
fun refreshDeferredProfileMetadata() = profileController.refreshDeferredProfileMetadata()
|
||||
|
||||
@@ -62,6 +62,10 @@ internal class GatewayProcessController(
|
||||
private var generation = 0L
|
||||
private var refreshSequence = 0L
|
||||
private var allProcesses: List<GatewayProcess> = emptyList()
|
||||
private var snapshotListener: ((List<GatewayProcess>) -> Unit)? = null
|
||||
|
||||
/** Only successful process.list results, never cached output-tail changes. */
|
||||
fun setSnapshotListener(listener: ((List<GatewayProcess>) -> Unit)?) { snapshotListener = listener }
|
||||
|
||||
/** A dismissal applies only to this concrete process identity. */
|
||||
private val dismissedIdentities = mutableMapOf<String, ProcessIdentity>()
|
||||
@@ -179,6 +183,7 @@ internal class GatewayProcessController(
|
||||
}
|
||||
|
||||
fun close() {
|
||||
snapshotListener = null
|
||||
source?.setEventListener(null)
|
||||
source = null
|
||||
capabilityJob?.cancel()
|
||||
@@ -229,6 +234,7 @@ internal class GatewayProcessController(
|
||||
allProcesses = coalesced
|
||||
publishVisibleSnapshot()
|
||||
updatePoller()
|
||||
snapshotListener?.invoke(coalesced)
|
||||
}
|
||||
|
||||
private fun publishVisibleSnapshot() {
|
||||
|
||||
+58
-17
@@ -30,9 +30,8 @@ internal data class SubagentActivityEvent(
|
||||
/**
|
||||
* A bounded, ephemeral projection of parent-session `subagent.*` events.
|
||||
*
|
||||
* This is intentionally not a child transcript. Upstream currently exposes no
|
||||
* durable child-session key or child-history route, so the projection is owned
|
||||
* by the exact profile-scoped parent session and parent turn that emitted it.
|
||||
* The owning profile/session outlives individual parent turns. Child identity
|
||||
* keeps detached work visible until its own terminal event arrives.
|
||||
*/
|
||||
internal data class SubagentActivity(
|
||||
val laneId: Long,
|
||||
@@ -41,6 +40,7 @@ internal data class SubagentActivity(
|
||||
val taskCount: Int,
|
||||
val goal: String,
|
||||
val subagentId: String? = null,
|
||||
val delegationId: String? = null,
|
||||
val childSessionId: String? = null,
|
||||
val parentId: String? = null,
|
||||
val depth: Int? = null,
|
||||
@@ -91,6 +91,9 @@ internal class SubagentActivityController(
|
||||
private var laneSequence = 0L
|
||||
private var connectionWasReady = false
|
||||
private var pendingGap = false
|
||||
private var interrupted = false
|
||||
private var previewOpen = false
|
||||
private val retiredChildIds = linkedSetOf<String>()
|
||||
|
||||
fun selectSession(sessionId: String?, newScopeKey: String?) {
|
||||
if (storedSessionId == sessionId && scopeKey == newScopeKey) return
|
||||
@@ -101,10 +104,16 @@ internal class SubagentActivityController(
|
||||
laneSequence = 0L
|
||||
connectionWasReady = false
|
||||
pendingGap = false
|
||||
interrupted = false
|
||||
previewOpen = false
|
||||
retiredChildIds.clear()
|
||||
_activities.value = emptyList()
|
||||
}
|
||||
|
||||
fun resetConnection() {
|
||||
interrupted = false
|
||||
previewOpen = false
|
||||
retiredChildIds.clear()
|
||||
activeTurnId = null
|
||||
sequence = 0L
|
||||
laneSequence = 0L
|
||||
@@ -133,9 +142,34 @@ internal class SubagentActivityController(
|
||||
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
|
||||
if (activeTurnId == turnId) return
|
||||
activeTurnId = turnId
|
||||
sequence = 0L
|
||||
laneSequence = 0L
|
||||
_activities.value = emptyList()
|
||||
interrupted = false
|
||||
_activities.value.filter { it.isTerminal }.forEach { activity ->
|
||||
activity.subagentId?.let(retiredChildIds::add)
|
||||
activity.childSessionId?.let(retiredChildIds::add)
|
||||
}
|
||||
while (retiredChildIds.size > 256) retiredChildIds.remove(retiredChildIds.first())
|
||||
val visibleTerminalKeys = _activities.value.filter { it.isTerminal }.takeLast(32)
|
||||
.mapTo(HashSet()) { it.stableKey }
|
||||
_activities.value = _activities.value.filter {
|
||||
(previewOpen && it.stableKey in visibleTerminalKeys) ||
|
||||
(!it.isTerminal && (!it.subagentId.isNullOrBlank() || !it.childSessionId.isNullOrBlank()))
|
||||
}
|
||||
}
|
||||
|
||||
fun setPreviewOpen(open: Boolean) {
|
||||
previewOpen = open
|
||||
if (!open) _activities.value = _activities.value.filterNot { it.isTerminal }
|
||||
}
|
||||
|
||||
fun onSessionEvent(sessionId: String, eventScopeKey: String?, event: GatewaySubagentEvent, profile: String?) {
|
||||
onEvent(sessionId, eventScopeKey, activeTurnId ?: "session", event, profile)
|
||||
}
|
||||
|
||||
fun interrupt() {
|
||||
interrupted = true
|
||||
_activities.value = _activities.value.map {
|
||||
if (it.isTerminal) it else it.copy(phase = SubagentActivityPhase.INTERRUPTED, revision = it.revision + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(
|
||||
@@ -146,18 +180,21 @@ internal class SubagentActivityController(
|
||||
profile: String? = null,
|
||||
) {
|
||||
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
|
||||
if (activeTurnId != turnId) return
|
||||
if (interrupted) return
|
||||
|
||||
val taskIndex = event.taskIndex.coerceAtLeast(0)
|
||||
val eventIdentity = event.subagentId?.takeIf(String::isNotBlank)
|
||||
?: event.childSessionId?.takeIf(String::isNotBlank)
|
||||
if (eventIdentity != null && eventIdentity in retiredChildIds) return
|
||||
val identityMatch = eventIdentity?.let { identity ->
|
||||
_activities.value.firstOrNull {
|
||||
it.subagentId == identity || it.childSessionId == identity
|
||||
}
|
||||
}
|
||||
val compatibleIndexMatches = _activities.value.filter { activity ->
|
||||
activity.taskIndex == taskIndex &&
|
||||
activity.turnId == turnId && activity.taskIndex == taskIndex &&
|
||||
(event.delegationId.isNullOrBlank() || activity.delegationId.isNullOrBlank() ||
|
||||
event.delegationId == activity.delegationId) &&
|
||||
(event.subagentId.isNullOrBlank() || activity.subagentId.isNullOrBlank() ||
|
||||
event.subagentId == activity.subagentId) &&
|
||||
(event.childSessionId.isNullOrBlank() || activity.childSessionId.isNullOrBlank() ||
|
||||
@@ -167,13 +204,14 @@ internal class SubagentActivityController(
|
||||
(event.depth == null || activity.depth == null || event.depth == activity.depth)
|
||||
}
|
||||
val current = identityMatch ?: compatibleIndexMatches.singleOrNull()
|
||||
if (
|
||||
current?.isTerminal == true &&
|
||||
event.phase != GatewaySubagentEvent.Phase.SPAWN_REQUESTED &&
|
||||
if (activeTurnId != turnId && identityMatch == null && eventIdentity == null) return
|
||||
if (current?.isTerminal == true) return
|
||||
// Progress and completion cannot invent children when their start was missed.
|
||||
if (current == null && event.phase != GatewaySubagentEvent.Phase.SPAWN_REQUESTED &&
|
||||
event.phase != GatewaySubagentEvent.Phase.START
|
||||
) return
|
||||
|
||||
val base = if (current?.isTerminal == true) null else current
|
||||
val base = current
|
||||
val phase = event.toActivityPhase()
|
||||
val goal = sanitize(event.goal, MAX_GOAL_CHARS)
|
||||
val preview = sanitize(event.preview, MAX_EVENT_TEXT_CHARS).ifBlank { null }
|
||||
@@ -206,16 +244,17 @@ internal class SubagentActivityController(
|
||||
val (boundedEvents, truncated) = boundEvents(appended)
|
||||
val next = SubagentActivity(
|
||||
laneId = base?.laneId ?: laneSequence++,
|
||||
turnId = turnId,
|
||||
turnId = base?.turnId ?: turnId,
|
||||
taskIndex = taskIndex,
|
||||
taskCount = maxOf(1, event.taskCount, base?.taskCount ?: 1),
|
||||
goal = goal.ifBlank { base?.goal.orEmpty() },
|
||||
subagentId = event.subagentId?.takeIf(String::isNotBlank) ?: base?.subagentId,
|
||||
delegationId = event.delegationId?.takeIf(String::isNotBlank) ?: base?.delegationId,
|
||||
childSessionId = event.childSessionId?.takeIf(String::isNotBlank) ?: base?.childSessionId,
|
||||
parentId = event.parentId?.takeIf(String::isNotBlank) ?: base?.parentId,
|
||||
depth = event.depth ?: base?.depth,
|
||||
model = event.model?.takeIf(String::isNotBlank) ?: base?.model,
|
||||
profile = profile?.takeIf(String::isNotBlank) ?: base?.profile,
|
||||
profile = if (base != null) base.profile else profile?.takeIf(String::isNotBlank),
|
||||
phase = phase,
|
||||
summary = summary ?: base?.summary,
|
||||
durationSeconds = event.durationSeconds ?: base?.durationSeconds,
|
||||
@@ -231,7 +270,9 @@ internal class SubagentActivityController(
|
||||
fun endTurn(turnId: String) {
|
||||
if (activeTurnId != turnId) return
|
||||
_activities.value = _activities.value.map { activity ->
|
||||
if (activity.isTerminal) activity else activity.copy(
|
||||
if (activity.isTerminal || !activity.subagentId.isNullOrBlank() ||
|
||||
!activity.childSessionId.isNullOrBlank()
|
||||
) activity else activity.copy(
|
||||
phase = SubagentActivityPhase.ENDED_WITH_PARENT,
|
||||
partialAfterGap = true,
|
||||
revision = activity.revision + 1,
|
||||
@@ -261,9 +302,9 @@ private fun GatewaySubagentEvent.toActivityPhase(): SubagentActivityPhase = when
|
||||
GatewaySubagentEvent.Phase.TOOL -> SubagentActivityPhase.TOOL
|
||||
GatewaySubagentEvent.Phase.PROGRESS -> SubagentActivityPhase.PROGRESS
|
||||
GatewaySubagentEvent.Phase.COMPLETE -> when (status?.trim()?.lowercase()) {
|
||||
"failed", "error" -> SubagentActivityPhase.FAILED
|
||||
"completed", "complete" -> SubagentActivityPhase.COMPLETED
|
||||
"interrupted", "cancelled", "canceled" -> SubagentActivityPhase.INTERRUPTED
|
||||
else -> SubagentActivityPhase.COMPLETED
|
||||
else -> SubagentActivityPhase.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import com.hermesandroid.relay.ui.UiMessageSeverity
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.R
|
||||
@@ -2639,28 +2640,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// route is unavailable. Runs outside the turn state machine so it
|
||||
// doesn't disturb uiState.
|
||||
//
|
||||
// Three toasts so the user knows what's happening: "Testing voice…" on
|
||||
// trigger, "Voice test successful" on completion, "Voice test failed" on
|
||||
// any error. The trigger toast is held in [triggerToast] so it can be
|
||||
// cancelled the moment the result toast fires — without that the two
|
||||
// would briefly overlap on screen. viewModelScope.launch defaults to
|
||||
// Main.immediate so Toast.show() is safe inline without a dispatcher
|
||||
// switch.
|
||||
// Keyed progress feedback is cleared before an outcome replaces it.
|
||||
// Classified failures retain their existing error event/overlay flow.
|
||||
fun testVoice(
|
||||
sample: String = "Hello, this is Hermes. Voice mode is working.",
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
) {
|
||||
val app = getApplication<Application>()
|
||||
val audioClient = voiceAudioClient
|
||||
val relayClient = voiceClient
|
||||
val p = player
|
||||
if (audioClient == null || p == null) {
|
||||
onResult(Result.failure(IllegalStateException("Voice pipeline not initialized")))
|
||||
Toast.makeText(app, "Voice test failed: pipeline not initialized", Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.error("Voice test failed: pipeline not initialized")
|
||||
setError("Voice pipeline not initialized")
|
||||
return
|
||||
}
|
||||
val triggerToast = Toast.makeText(app, "Testing voice…", Toast.LENGTH_SHORT).also { it.show() }
|
||||
val feedbackKey = "voice-test-${System.nanoTime()}"
|
||||
UiMessageBus.post("Testing voice…", severity = UiMessageSeverity.Status, ttlMillis = 0L, key = feedbackKey)
|
||||
viewModelScope.launch {
|
||||
val profileAwareResult = if (audioClient.route == VoiceAudioRoute.Relay && relayClient != null) {
|
||||
testVoiceViaVoiceOutput(relayClient, sample)
|
||||
@@ -2669,9 +2665,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
val result = if (profileAwareResult != null) {
|
||||
if (profileAwareResult.isSuccess) {
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
onResult(Result.success(Unit))
|
||||
Toast.makeText(app, "Voice test successful", Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.success("Voice test successful")
|
||||
return@launch
|
||||
}
|
||||
Log.w(TAG, "profile-aware voice test failed; falling back to legacy synthesize: ${profileAwareResult.exceptionOrNull()?.message}")
|
||||
@@ -2680,34 +2676,35 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
audioClient.synthesize(sample)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
val msg = result.exceptionOrNull()?.message ?: "synthesize failed"
|
||||
onResult(Result.failure(result.exceptionOrNull() ?: IllegalStateException(msg)))
|
||||
Toast.makeText(app, "Voice test failed: $msg", Toast.LENGTH_LONG).show()
|
||||
surfaceError(result.exceptionOrNull(), context = "synthesize")
|
||||
return@launch
|
||||
}
|
||||
val file = result.getOrNull()
|
||||
if (file == null) {
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
onResult(Result.failure(IllegalStateException("No audio returned")))
|
||||
Toast.makeText(app, "Voice test failed: no audio returned", Toast.LENGTH_LONG).show()
|
||||
UiMessageBus.error("Voice test failed: no audio returned")
|
||||
return@launch
|
||||
}
|
||||
trackTtsFile(file)
|
||||
try {
|
||||
p.play(file)
|
||||
p.awaitCompletion()
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
onResult(Result.success(Unit))
|
||||
Toast.makeText(app, "Voice test successful", Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.success("Voice test successful")
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "test playback failed: ${e.message}")
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
onResult(Result.failure(e))
|
||||
Toast.makeText(app, "Voice test failed: ${e.message ?: "playback error"}", Toast.LENGTH_LONG).show()
|
||||
UiMessageBus.error("Voice test failed: ${e.message ?: "playback error"}")
|
||||
}
|
||||
}
|
||||
}.invokeOnCompletion { UiMessageBus.clear(feedbackKey) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2930,17 +2927,16 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
sample: String = "Say a short confirmation that Hermes Realtime Agent is working.",
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
) {
|
||||
val app = getApplication<Application>()
|
||||
val client = voiceClient
|
||||
val pcmPlayer = realtimePcmPlayer
|
||||
if (client == null || pcmPlayer == null) {
|
||||
onResult(Result.failure(IllegalStateException("Voice pipeline not initialized")))
|
||||
Toast.makeText(app, "Realtime test failed: pipeline not initialized", Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.error("Realtime test failed: pipeline not initialized")
|
||||
setError("Voice pipeline not initialized")
|
||||
return
|
||||
}
|
||||
val triggerToast = Toast.makeText(app, "Testing Realtime Agent...", Toast.LENGTH_SHORT)
|
||||
.also { it.show() }
|
||||
val feedbackKey = "realtime-test-${System.nanoTime()}"
|
||||
UiMessageBus.post("Testing Realtime Agent...", severity = UiMessageSeverity.Status, ttlMillis = 0L, key = feedbackKey)
|
||||
viewModelScope.launch {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
@@ -2970,12 +2966,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
audioBytes.addAndGet(audio.size)
|
||||
pcmPlayer.write(audio, rate)
|
||||
}
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
if (result.isFailure) {
|
||||
pcmPlayer.stop()
|
||||
val msg = result.exceptionOrNull()?.message ?: "realtime agent failed"
|
||||
onResult(Result.failure(result.exceptionOrNull() ?: IllegalStateException(msg)))
|
||||
Toast.makeText(app, "Realtime test failed: $msg", Toast.LENGTH_LONG).show()
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
@@ -2988,7 +2983,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (audioBytes.get() <= 0) {
|
||||
pcmPlayer.stop()
|
||||
onResult(Result.failure(IllegalStateException("Provider returned no audio")))
|
||||
Toast.makeText(app, "Realtime test failed: no audio returned", Toast.LENGTH_LONG).show()
|
||||
UiMessageBus.error("Realtime test failed: no audio returned")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
@@ -3002,14 +2997,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
delay(drainMs)
|
||||
pcmPlayer.stop()
|
||||
onResult(Result.success(Unit))
|
||||
Toast.makeText(app, "Realtime test successful", Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.success("Realtime test successful")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = getApplication<Application>().getString(R.string.voice_status_test_complete),
|
||||
detail = "${audioBytes.get()} bytes streamed",
|
||||
)
|
||||
}
|
||||
}.invokeOnCompletion { UiMessageBus.clear(feedbackKey) }
|
||||
}
|
||||
|
||||
private suspend fun testVoiceViaVoiceOutput(
|
||||
@@ -6673,7 +6668,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_uiState.update {
|
||||
voiceNoSpeechState(it)
|
||||
}
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.warning(message)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
-13
@@ -193,6 +193,13 @@ class ProfileController(
|
||||
private val _serverDefaultProfileScope = MutableStateFlow<DashboardProfileScope?>(null)
|
||||
val serverDefaultProfileScope: StateFlow<DashboardProfileScope?> =
|
||||
_serverDefaultProfileScope.asStateFlow()
|
||||
/** Default identity independent of the explicit selection; never a routing override. */
|
||||
val serverDefaultDisplayProfile: StateFlow<Profile?> = combine(
|
||||
agentProfiles,
|
||||
serverDefaultProfileScope,
|
||||
) { profiles, serverDefault ->
|
||||
AgentDisplay.effectiveDisplayProfile(null, profiles, serverDefault?.active)
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
private val _serverDefaultProfileSettled = MutableStateFlow(false)
|
||||
|
||||
private fun pendingProfileNameForActiveConnection(): String? {
|
||||
@@ -361,12 +368,8 @@ class ProfileController(
|
||||
activeConnectionId,
|
||||
selectedProfile,
|
||||
serverDefaultProfileScope,
|
||||
_gatewayProfiles,
|
||||
) { connectionId, selected, serverDefault, gatewayProfiles ->
|
||||
connectionId to (
|
||||
selected?.name ?: serverDefault?.active
|
||||
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
|
||||
)
|
||||
) { connectionId, selected, serverDefault ->
|
||||
connectionId to (selected?.name ?: serverDefault?.active)
|
||||
}
|
||||
|
||||
/** Cached bytes fetched from Hermes; always preferred over the local fallback. */
|
||||
@@ -398,12 +401,8 @@ class ProfileController(
|
||||
fun profileIconFlow(profileName: String?): Flow<String?> = combine(
|
||||
activeConnectionId,
|
||||
serverDefaultProfileScope,
|
||||
_gatewayProfiles,
|
||||
) { connectionId, serverDefault, gatewayProfiles ->
|
||||
connectionId to (
|
||||
profileName ?: serverDefault?.active
|
||||
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
|
||||
)
|
||||
) { connectionId, serverDefault ->
|
||||
connectionId to (profileName ?: serverDefault?.active)
|
||||
}
|
||||
.flatMapLatest { (connectionId, serverProfileName) ->
|
||||
if (connectionId == null) return@flatMapLatest flowOf(null)
|
||||
@@ -1282,7 +1281,6 @@ class ProfileController(
|
||||
|
||||
private fun resolveSharedAssetProfileName(): String? =
|
||||
resolveSessionProfileName()
|
||||
?: _gatewayProfiles.value.firstOrNull(Profile::isDefault)?.name
|
||||
|
||||
private companion object {
|
||||
const val LOCAL_PROFILE_ICON_MAX_BYTES = 8_000_000
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.app.KeyguardManager
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/** Permissions do not grant permission to start a session: a resumed UI action is also required. */
|
||||
data class VoiceOverlayAccess(
|
||||
val microphone: Boolean,
|
||||
val notifications: Boolean,
|
||||
val overlay: Boolean,
|
||||
val unlocked: Boolean,
|
||||
) {
|
||||
val ready: Boolean get() = microphone && notifications && overlay && unlocked
|
||||
|
||||
companion object {
|
||||
fun read(context: Context): VoiceOverlayAccess {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val channelEnabled = Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
manager?.getNotificationChannel(VoiceOverlayForegroundService.CHANNEL_ID)?.importance !=
|
||||
NotificationManager.IMPORTANCE_NONE
|
||||
return VoiceOverlayAccess(
|
||||
microphone = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
notifications = NotificationManagerCompat.from(context).areNotificationsEnabled() && channelEnabled,
|
||||
overlay = Settings.canDrawOverlays(context),
|
||||
unlocked = context.getSystemService(KeyguardManager::class.java)?.isKeyguardLocked == false &&
|
||||
context.getSystemService(PowerManager::class.java)?.isInteractive == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
-15
@@ -8,9 +8,13 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -27,31 +31,84 @@ import com.hermesandroid.relay.R
|
||||
* execution state Android requires once [MainActivity] is backgrounded.
|
||||
*/
|
||||
class VoiceOverlayForegroundService : Service() {
|
||||
private var activeSessionId: Long? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val accessMonitor = object : Runnable {
|
||||
override fun run() {
|
||||
val id = activeSessionId ?: return
|
||||
if (VoiceOverlayHost.peek()?.canContinue(id) != true) {
|
||||
endSession()
|
||||
} else {
|
||||
// Notification channels can be disabled without a runtime-permission event.
|
||||
handler.postDelayed(this, 1_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val screenOffReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == Intent.ACTION_SCREEN_OFF) endSession()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextCompat.registerReceiver(this, screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForegroundNotification()
|
||||
val id = intent?.getLongExtra(EXTRA_SESSION_ID, -1L) ?: -1L
|
||||
val host = VoiceOverlayHost.peek()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "Notification stop requested")
|
||||
VoiceOverlayHost.peek()?.exitVoiceSession()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
if (id == activeSessionId) endSession()
|
||||
else if (activeSessionId == null && host?.sessionId == null) stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// No sticky restart, unowned Intent, or stale callback can start a microphone session.
|
||||
if (intent?.action != ACTION_START || host?.canStart(id) != true) {
|
||||
if (host?.sessionId == id) host.exitVoiceSession(id)
|
||||
if (activeSessionId == null) stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
activeSessionId = id
|
||||
if (!startForegroundNotification() || !host.onServiceReady(id)) {
|
||||
endSession()
|
||||
} else {
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
handler.post(accessMonitor)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.i(TAG, "App task removed; closing voice overlay")
|
||||
VoiceOverlayHost.peek()?.exitVoiceSession()
|
||||
endSession()
|
||||
}
|
||||
|
||||
private fun endSession() {
|
||||
val id = activeSessionId
|
||||
activeSessionId = null
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
if (id != null) VoiceOverlayHost.peek()?.exitVoiceSession(id)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
val id = activeSessionId
|
||||
activeSessionId = null
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
unregisterReceiver(screenOffReceiver)
|
||||
if (id != null) VoiceOverlayHost.peek()?.exitVoiceSession(id)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
private fun startForegroundNotification(): Boolean {
|
||||
try {
|
||||
ensureChannel()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
@@ -63,9 +120,9 @@ class VoiceOverlayForegroundService : Service() {
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "Could not foreground voice overlay microphone service", t)
|
||||
VoiceOverlayHost.peek()?.hide()
|
||||
stopSelf()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
@@ -76,8 +133,10 @@ class VoiceOverlayForegroundService : Service() {
|
||||
val openPending = PendingIntent.getActivity(this, 0, openIntent, pendingFlags)
|
||||
val stopPending = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, VoiceOverlayForegroundService::class.java).setAction(ACTION_STOP),
|
||||
activeSessionId?.toInt() ?: 0,
|
||||
Intent(this, VoiceOverlayForegroundService::class.java).setAction(ACTION_STOP)
|
||||
.setData(android.net.Uri.parse("hermes-voice-overlay:stop/$activeSessionId"))
|
||||
.putExtra(EXTRA_SESSION_ID, activeSessionId ?: -1L),
|
||||
pendingFlags,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
@@ -93,6 +152,7 @@ class VoiceOverlayForegroundService : Service() {
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.addAction(
|
||||
0,
|
||||
getString(R.string.voice_overlay_notification_stop),
|
||||
@@ -123,14 +183,15 @@ class VoiceOverlayForegroundService : Service() {
|
||||
const val NOTIFICATION_ID = 4715
|
||||
const val ACTION_START = "com.hermesandroid.relay.voice.OVERLAY_MIC_START"
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.voice.OVERLAY_MIC_STOP"
|
||||
const val EXTRA_SESSION_ID = "voice_overlay_session_id"
|
||||
|
||||
fun start(context: Context): Boolean {
|
||||
fun start(context: Context, sessionId: Long): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
return runCatching {
|
||||
ContextCompat.startForegroundService(
|
||||
appContext,
|
||||
Intent(appContext, VoiceOverlayForegroundService::class.java)
|
||||
.setAction(ACTION_START),
|
||||
.setAction(ACTION_START).putExtra(EXTRA_SESSION_ID, sessionId),
|
||||
)
|
||||
true
|
||||
}.getOrElse { error ->
|
||||
|
||||
@@ -22,6 +22,8 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -31,6 +33,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -81,6 +84,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
@@ -153,21 +157,78 @@ class VoiceOverlayHost(context: Context) {
|
||||
private var overlayView: View? = null
|
||||
private var overlayOwner: VoiceOverlayLifecycleOwner? = null
|
||||
private var overlayParams: WindowManager.LayoutParams? = null
|
||||
private var generation = android.os.SystemClock.elapsedRealtimeNanos()
|
||||
internal var sessionId: Long? = null
|
||||
private set
|
||||
private var callerLifecycle: Lifecycle? = null
|
||||
private val callerObserver = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
// Keep microphone protection until the real Activity is foreground again.
|
||||
Lifecycle.Event.ON_RESUME -> if (overlayView != null) handoffToApp()
|
||||
Lifecycle.Event.ON_DESTROY -> exitVoiceSession()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun hasOverlayPermission(): Boolean = Settings.canDrawOverlays(appContext)
|
||||
|
||||
fun show(session: VoiceOverlaySession, lifecycle: Lifecycle): Boolean {
|
||||
if (!com.hermesandroid.relay.data.BuildFlavor.voiceSystemOverlay ||
|
||||
!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) ||
|
||||
!VoiceOverlayAccess.read(appContext).ready || !session.uiState.value.voiceMode
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (sessionId != null) return true
|
||||
val id = ++generation
|
||||
sessionId = id
|
||||
fun guarded(action: () -> Unit): () -> Unit = {
|
||||
if (canContinue(id)) action() else exitVoiceSession(id)
|
||||
}
|
||||
sessionState.value = session.copy(
|
||||
onStartListening = guarded(session.onStartListening),
|
||||
onStopListening = guarded(session.onStopListening),
|
||||
onInterrupt = guarded(session.onInterrupt),
|
||||
onPauseAutoMode = guarded(session.onPauseAutoMode),
|
||||
onReturnToHermes = guarded {
|
||||
session.onReturnToHermes()
|
||||
// Already foreground: no lifecycle transition is needed for a safe handoff.
|
||||
if (sessionId == id &&
|
||||
callerLifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true
|
||||
) handoffToApp()
|
||||
},
|
||||
onExit = { exitVoiceSession(id) },
|
||||
onDismissOverlay = { exitVoiceSession(id) },
|
||||
onResetPosition = guarded { moveTo(24, 96) },
|
||||
)
|
||||
exitCallback = session.onExit
|
||||
callerLifecycle = lifecycle
|
||||
lifecycle.addObserver(callerObserver)
|
||||
if (!VoiceOverlayForegroundService.start(appContext, id)) {
|
||||
exitVoiceSession(id)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private var exitCallback: (() -> Unit)? = null
|
||||
|
||||
private fun handoffToApp() {
|
||||
val id = sessionId ?: return
|
||||
// A fast return from Android Settings must not bypass revocation teardown.
|
||||
if (canContinue(id)) hide() else exitVoiceSession(id)
|
||||
}
|
||||
|
||||
internal fun canStart(id: Long): Boolean = sessionId == id &&
|
||||
callerLifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true && canContinue(id)
|
||||
|
||||
internal fun canContinue(id: Long): Boolean = sessionId == id &&
|
||||
sessionState.value?.uiState?.value?.voiceMode == true && VoiceOverlayAccess.read(appContext).ready
|
||||
|
||||
/** Called only after startForeground succeeds; a queued service start is not readiness. */
|
||||
@SuppressLint("InflateParams")
|
||||
fun show(session: VoiceOverlaySession): Boolean {
|
||||
sessionState.value = session
|
||||
if (!hasOverlayPermission()) {
|
||||
Log.w(TAG, "show: SYSTEM_ALERT_WINDOW not granted")
|
||||
return false
|
||||
}
|
||||
if (!VoiceOverlayForegroundService.start(appContext)) {
|
||||
Log.w(TAG, "show: microphone foreground service could not start")
|
||||
sessionState.value = null
|
||||
return false
|
||||
}
|
||||
internal fun onServiceReady(id: Long): Boolean {
|
||||
if (!canStart(id)) return false
|
||||
if (overlayView != null) return true
|
||||
|
||||
val compose = ComposeView(appContext).apply {
|
||||
@@ -191,9 +252,7 @@ class VoiceOverlayHost(context: Context) {
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
overlayType(),
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
@@ -205,8 +264,6 @@ class VoiceOverlayHost(context: Context) {
|
||||
.onFailure { Log.w(TAG, "addView(voice overlay) failed", it) }
|
||||
.isSuccess
|
||||
if (!added) {
|
||||
sessionState.value = null
|
||||
VoiceOverlayForegroundService.stop(appContext)
|
||||
overlayOwner?.stop()
|
||||
overlayOwner = null
|
||||
return false
|
||||
@@ -214,10 +271,17 @@ class VoiceOverlayHost(context: Context) {
|
||||
|
||||
overlayView = compose
|
||||
overlayParams = params
|
||||
compose.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
moveTo(params.x, params.y)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
sessionId = null
|
||||
exitCallback = null
|
||||
callerLifecycle?.removeObserver(callerObserver)
|
||||
callerLifecycle = null
|
||||
val view = overlayView
|
||||
overlayView = null
|
||||
overlayParams = null
|
||||
@@ -231,17 +295,29 @@ class VoiceOverlayHost(context: Context) {
|
||||
VoiceOverlayForegroundService.stop(appContext)
|
||||
}
|
||||
|
||||
fun exitVoiceSession() {
|
||||
val onExit = sessionState.value?.onExit
|
||||
fun exitVoiceSession(expectedId: Long? = sessionId) {
|
||||
if (expectedId == null || sessionId != expectedId) return
|
||||
val onExit = exitCallback
|
||||
hide()
|
||||
onExit?.invoke()
|
||||
}
|
||||
|
||||
private fun moveBy(dx: Float, dy: Float) {
|
||||
val params = overlayParams ?: return
|
||||
moveTo(params.x + dx.roundToInt(), params.y + dy.roundToInt())
|
||||
}
|
||||
|
||||
private fun moveTo(x: Int, y: Int) {
|
||||
val view = overlayView ?: return
|
||||
val params = overlayParams ?: return
|
||||
params.x = (params.x + dx.roundToInt()).coerceAtLeast(0)
|
||||
params.y = (params.y + dy.roundToInt()).coerceAtLeast(0)
|
||||
val size = android.graphics.Point()
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getSize(size)
|
||||
val nextX = x.coerceIn(0, (size.x - view.width).coerceAtLeast(0))
|
||||
val nextY = y.coerceIn(0, (size.y - view.height).coerceAtLeast(0))
|
||||
if (params.x == nextX && params.y == nextY) return
|
||||
params.x = nextX
|
||||
params.y = nextY
|
||||
runCatching { wm.updateViewLayout(view, params) }
|
||||
.onFailure { Log.w(TAG, "updateViewLayout(voice overlay) failed", it) }
|
||||
}
|
||||
@@ -280,10 +356,12 @@ data class VoiceOverlaySession(
|
||||
val onReturnToHermes: () -> Unit,
|
||||
val onDismissOverlay: () -> Unit,
|
||||
val onExit: () -> Unit,
|
||||
val onResetPosition: () -> Unit = {},
|
||||
val connectionLabel: String? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun VoiceFloatingOverlayPill(
|
||||
internal fun VoiceFloatingOverlayPill(
|
||||
session: VoiceOverlaySession,
|
||||
onDragBy: (Float, Float) -> Unit,
|
||||
) {
|
||||
@@ -301,20 +379,31 @@ private fun VoiceFloatingOverlayPill(
|
||||
?: stringResource(R.string.voice_overlay_label_default_profile)
|
||||
val stateText = voiceOverlayStateLabel(uiState.state)
|
||||
val overlayWidth = (LocalConfiguration.current.screenWidthDp - 24)
|
||||
.coerceIn(280, 368)
|
||||
.coerceIn(200, 368)
|
||||
.dp
|
||||
|
||||
if (minimized) {
|
||||
VoiceFloatingOverlayBubble(
|
||||
uiState = uiState,
|
||||
stateText = stateText,
|
||||
onExpand = { minimized = false },
|
||||
onStartListening = session.onStartListening,
|
||||
onStopListening = session.onStopListening,
|
||||
onInterrupt = session.onInterrupt,
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
onDragBy = onDragBy,
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
VoiceFloatingOverlayBubble(
|
||||
uiState = uiState,
|
||||
stateText = stateText,
|
||||
onExpand = { minimized = false },
|
||||
onStartListening = session.onStartListening,
|
||||
onStopListening = session.onStopListening,
|
||||
onInterrupt = session.onInterrupt,
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
onDragBy = onDragBy,
|
||||
)
|
||||
TextButton(onClick = session.onExit) {
|
||||
Text(stringResource(R.string.voice_overlay_notification_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -329,6 +418,7 @@ private fun VoiceFloatingOverlayPill(
|
||||
},
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 10.dp,
|
||||
) {
|
||||
@@ -382,7 +472,9 @@ private fun VoiceOverlayHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(50.dp), contentAlignment = Alignment.Center) {
|
||||
if (LocalConfiguration.current.screenWidthDp >= 360) Box(
|
||||
modifier = Modifier.size(50.dp), contentAlignment = Alignment.Center,
|
||||
) {
|
||||
OverlayCircularWaveformRing(
|
||||
amplitude = uiState.amplitude,
|
||||
state = uiState.state,
|
||||
@@ -396,21 +488,28 @@ private fun VoiceOverlayHeader(
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = listOfNotNull(session.connectionLabel, session.profileName).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stateText,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = overlayPrimaryText(uiState, stateText),
|
||||
val primaryText = overlayPrimaryText(uiState, stateText)
|
||||
if (primaryText != stateText) Text(
|
||||
text = primaryText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onToggleExpanded, modifier = Modifier.size(38.dp)) {
|
||||
IconButton(onClick = onToggleExpanded, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = stringResource(
|
||||
@@ -426,6 +525,9 @@ private fun VoiceOverlayHeader(
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
size = 50.dp,
|
||||
)
|
||||
IconButton(onClick = session.onExit, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.voice_overlay_notification_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,7 +541,10 @@ private fun ExpandedVoiceOverlayBody(
|
||||
session: VoiceOverlaySession,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
modifier = Modifier
|
||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp - 120).coerceAtLeast(100).dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 8.dp),
|
||||
) {
|
||||
OverlayLinearWaveform(
|
||||
amplitude = uiState.amplitude,
|
||||
@@ -502,31 +607,36 @@ private fun ExpandedVoiceOverlayBody(
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.62f))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val actions: @Composable (Modifier) -> Unit = { actionModifier ->
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.Filled.ExpandMore,
|
||||
label = stringResource(R.string.voice_overlay_minimize),
|
||||
onClick = onMinimize,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = actionModifier,
|
||||
)
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
label = stringResource(R.string.voice_overlay_hide),
|
||||
onClick = session.onDismissOverlay,
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
label = stringResource(R.string.voice_overlay_reset_position),
|
||||
onClick = session.onResetPosition,
|
||||
modifier = actionModifier,
|
||||
)
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
label = stringResource(R.string.voice_overlay_open_hermes),
|
||||
onClick = session.onReturnToHermes,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = actionModifier,
|
||||
)
|
||||
}
|
||||
val actionContainer = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
val stackActions = androidx.compose.ui.platform.LocalDensity.current.fontScale > 1.2f &&
|
||||
LocalConfiguration.current.screenWidthDp < 360
|
||||
if (stackActions) {
|
||||
Column(modifier = actionContainer) { actions(Modifier.fillMaxWidth()) }
|
||||
} else {
|
||||
Row(modifier = actionContainer, verticalAlignment = Alignment.CenterVertically) {
|
||||
actions(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +680,7 @@ private fun VoiceOverlayAction(
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(54.dp),
|
||||
modifier = modifier.heightIn(min = 72.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -584,7 +694,7 @@ private fun VoiceOverlayAction(
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@@ -1049,7 +1159,7 @@ private fun voiceEngineLabel(engineMode: String?): String = when (engineMode) {
|
||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
}
|
||||
|
||||
fun openHermesFromOverlay(context: Context) {
|
||||
fun openHermesFromOverlay(context: Context): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
val launchIntent = appContext.packageManager.getLaunchIntentForPackage(appContext.packageName)
|
||||
?: Intent().setPackage(appContext.packageName)
|
||||
@@ -1057,8 +1167,9 @@ fun openHermesFromOverlay(context: Context) {
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
runCatching { appContext.startActivity(launchIntent) }
|
||||
return runCatching { appContext.startActivity(launchIntent) }
|
||||
.onFailure { Log.w("VoiceOverlayHost", "return to Hermes failed", it) }
|
||||
.isSuccess
|
||||
}
|
||||
|
||||
private class VoiceOverlayLifecycleOwner :
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/** Settings and Voice Focus share the explanation; only Voice Focus supplies a start action. */
|
||||
@Composable
|
||||
fun VoiceOverlaySetupDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onStart: (() -> Unit)? = null,
|
||||
onBeforePermission: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
var access by remember { mutableStateOf(VoiceOverlayAccess.read(context)) }
|
||||
var requestedMicrophone by remember { mutableStateOf(false) }
|
||||
var requestedNotifications by remember { mutableStateOf(false) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
access = VoiceOverlayAccess.read(context)
|
||||
}
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) access = VoiceOverlayAccess.read(context)
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
fun open(intent: Intent) {
|
||||
onBeforePermission()
|
||||
failed = runCatching { context.startActivity(intent) }.isFailure
|
||||
}
|
||||
VoiceOverlaySetupContent(
|
||||
access = access,
|
||||
failed = failed,
|
||||
onDismiss = onDismiss,
|
||||
onStart = onStart?.let { start -> {
|
||||
access = VoiceOverlayAccess.read(context)
|
||||
if (access.ready && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) start()
|
||||
} },
|
||||
onMicrophone = {
|
||||
if (access.microphone || requestedMicrophone) {
|
||||
open(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}")))
|
||||
} else {
|
||||
onBeforePermission()
|
||||
requestedMicrophone = true
|
||||
permission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
onNotifications = {
|
||||
if (Build.VERSION.SDK_INT >= 33 && !requestedNotifications &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
onBeforePermission()
|
||||
requestedNotifications = true
|
||||
permission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
open(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName))
|
||||
}
|
||||
},
|
||||
onOverlay = {
|
||||
open(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${context.packageName}")))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun VoiceOverlaySetupContent(
|
||||
access: VoiceOverlayAccess,
|
||||
failed: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onStart: (() -> Unit)?,
|
||||
onMicrophone: () -> Unit,
|
||||
onNotifications: () -> Unit,
|
||||
onOverlay: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.voice_overlay_setup_title)) },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_body))
|
||||
AccessButton(R.string.perms_microphone, access.microphone, onMicrophone)
|
||||
AccessButton(R.string.perms_app_notifications, access.notifications, onNotifications)
|
||||
AccessButton(R.string.perms_display_over_apps, access.overlay, onOverlay)
|
||||
if (onStart == null) Text(stringResource(R.string.voice_overlay_settings_hint))
|
||||
if (failed) Text(stringResource(R.string.chat_overlay_start_failed), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (onStart != null) TextButton(onClick = onStart, enabled = access.ready) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_start))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cw_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccessButton(@androidx.annotation.StringRes label: Int, granted: Boolean, onClick: () -> Unit) {
|
||||
TextButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(label) + " · " + stringResource(
|
||||
if (granted) R.string.ncs_access_granted else R.string.chat_open_settings,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VoiceOverlaySettingsCard() {
|
||||
var show by remember { mutableStateOf(false) }
|
||||
Card {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.voice_overlay_settings_hint))
|
||||
TextButton(onClick = { show = true }) { Text(stringResource(R.string.onboarding_review_optional_permissions)) }
|
||||
}
|
||||
}
|
||||
if (show) VoiceOverlaySetupDialog(onDismiss = { show = false })
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Pergunta %1$d de %2$d</string>
|
||||
<string name="clarify_batch_complete">Todas as perguntas respondidas</string>
|
||||
<string name="clarify_batch_expired">Esta solicitação terminou</string>
|
||||
<string name="clarify_batch_sending">Enviando resposta…</string>
|
||||
<string name="clarify_batch_answered">Perguntas respondidas (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">interface do agente</string>
|
||||
@@ -757,7 +762,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Alertas de chat em segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
|
||||
<string name="chat_settings_share_phone_status">Compartilhar o status do celular com o agente</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Inclua uma breve mensagem do sistema sobre o app e o celular em cada turno do chat</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Incluir contexto do app e do telefone em chats apenas por API. Não é enviado nos chats padrão do Gateway.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permissões</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Se acessibilidade, captura de tela, sobreposição e notificações foram concedidas</string>
|
||||
<string name="chat_settings_foreground_app">App em primeiro plano</string>
|
||||
@@ -766,7 +771,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Porcentagem atual da bateria. Desativado por padrão.</string>
|
||||
<string name="chat_settings_safety_rails">Proteções de segurança</string>
|
||||
<string name="chat_settings_safety_rails_desc">Quantidade de itens bloqueados, quantidade de verbos destrutivos e temporizador de desativação automática</string>
|
||||
<string name="chat_settings_preview">Prévia</string>
|
||||
<string name="chat_settings_preview">Exemplo de chat apenas por API</string>
|
||||
<string name="chat_settings_no_system_message">(nenhuma mensagem do sistema será enviada)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Analisar anotações de ferramentas</string>
|
||||
<string name="chat_settings_experimental">Experimental</string>
|
||||
@@ -3196,10 +3201,10 @@
|
||||
<string name="injected_context_phone_status_title">Status do celular</string>
|
||||
<string name="injected_context_relay_not_set">Nenhum relay configurado</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Contexto enviado com a próxima mensagem</string>
|
||||
<string name="injected_context_subtitle">Prévia deste chat. O contexto do servidor é configurado separadamente.</string>
|
||||
<string name="injected_context_title">Contexto injetado</string>
|
||||
<string name="injected_context_turn_not_set">Não definido</string>
|
||||
<string name="injected_context_turn_title">Modo de detecção de turno</string>
|
||||
<string name="injected_context_turn_title">Contexto do turno</string>
|
||||
<string name="model_picker_cd_clear">Limpar</string>
|
||||
<string name="model_picker_count">%d modelos</string>
|
||||
<string name="power_feature_back_desc">Voltar</string>
|
||||
@@ -4429,4 +4434,23 @@
|
||||
<string name="chat_queue_paused">Fila pausada</string>
|
||||
<string name="chat_queue_resume">Retomar</string>
|
||||
<string name="chat_queue_remove">Remover mensagem da fila</string>
|
||||
<string name="chat_activity_receipt_subagents">Subagentes</string>
|
||||
<string name="chat_activity_receipt_process">Comando em segundo plano</string>
|
||||
<string name="chat_activity_receipt_view_activity">Ver atividade</string>
|
||||
<string name="chat_activity_receipt_view_output">Ver saída</string>
|
||||
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
|
||||
<string name="chat_activity_history_title">Atividade do chat</string>
|
||||
<string name="chat_activity_history_empty">A atividade detalhada não foi mantida neste dispositivo. O registro de conclusão permanece no chat.</string>
|
||||
<string name="tool_progress_status_dispatched">Enviado</string>
|
||||
<string name="chat_activity_history_notice">Atividade registrada. As atualizações de progresso não são mantidas; o histórico disponível dos subagentes pode ser aberto somente para leitura.</string>
|
||||
<string name="chat_activity_output_unavailable">A saída não está mais disponível. Este registro preserva o estado registrado do processo.</string>
|
||||
<string name="profile_follow_server_default">Seguir o perfil padrão do servidor</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voz sobre outros apps</string>
|
||||
<string name="voice_overlay_setup_body">Mantenha os controles de voz visíveis sobre outro app. O áudio vai para o servidor Hermes configurado. Isso não permite ler a tela nem controlar o telefone. Permita a exibição sobre outros apps nas configurações do Android e volte aqui. É necessária uma notificação do microfone com Parar voz. Fechar a sobreposição ou bloquear a tela encerra a sessão de voz.</string>
|
||||
<string name="voice_overlay_setup_start">Iniciar sobreposição de voz</string>
|
||||
<string name="voice_overlay_settings_hint">Opcional nas duas versões. Abra o foco de voz no Chat e escolha Sobreposição. As permissões sozinhas nunca iniciam a escuta.</string>
|
||||
<string name="voice_overlay_reset_position">Redefinir posição</string>
|
||||
<string name="injected_context_gateway_unsupported">Não é enviado no chat do Gateway. Esta conexão não oferece suporte a contexto adicional por turno.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">问题 %1$d / %2$d</string>
|
||||
<string name="clarify_batch_complete">所有问题均已回答</string>
|
||||
<string name="clarify_batch_expired">此请求已结束</string>
|
||||
<string name="clarify_batch_sending">正在发送回答…</string>
|
||||
<string name="clarify_batch_answered">已回答的问题(%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">代理界面</string>
|
||||
@@ -796,7 +801,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">后台聊天提醒</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Hermes 在后台需要输入或完成回复时通知</string>
|
||||
<string name="chat_settings_share_phone_status">与代理分享手机状态</string>
|
||||
<string name="chat_settings_share_phone_status_desc">每轮聊天附带一条关于应用和手机的简短系统消息</string>
|
||||
<string name="chat_settings_share_phone_status_desc">在仅使用 API 的聊天中包含应用和手机上下文。标准 Gateway 聊天不会发送此内容。</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + 权限</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">无障碍、屏幕捕获、悬浮窗、通知是否已授权</string>
|
||||
<string name="chat_settings_foreground_app">前台应用</string>
|
||||
@@ -805,7 +810,7 @@
|
||||
<string name="chat_settings_battery_level_desc">当前电池百分比。默认关闭。</string>
|
||||
<string name="chat_settings_safety_rails">安全护栏</string>
|
||||
<string name="chat_settings_safety_rails_desc">黑名单数量、破坏性动词数量和自动禁用计时器</string>
|
||||
<string name="chat_settings_preview">预览</string>
|
||||
<string name="chat_settings_preview">仅使用 API 的聊天示例</string>
|
||||
<string name="chat_settings_no_system_message">(不会发送系统消息)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">解析工具标注</string>
|
||||
<string name="chat_settings_experimental">实验性</string>
|
||||
@@ -3295,10 +3300,10 @@
|
||||
<string name="injected_context_phone_status_title">手机状态</string>
|
||||
<string name="injected_context_relay_not_set">未配置 Relay</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">随下一条消息发送的上下文</string>
|
||||
<string name="injected_context_subtitle">此聊天的预览。服务器端上下文单独配置。</string>
|
||||
<string name="injected_context_title">注入上下文</string>
|
||||
<string name="injected_context_turn_not_set">未设置</string>
|
||||
<string name="injected_context_turn_title">轮换检测模式</string>
|
||||
<string name="injected_context_turn_title">本轮上下文</string>
|
||||
<string name="model_picker_cd_clear">清空</string>
|
||||
<string name="model_picker_count">%d 个模型</string>
|
||||
<string name="power_feature_back_desc">返回</string>
|
||||
@@ -4510,4 +4515,23 @@
|
||||
<string name="chat_queue_paused">队列已暂停</string>
|
||||
<string name="chat_queue_resume">继续</string>
|
||||
<string name="chat_queue_remove">移除队列消息</string>
|
||||
<string name="chat_activity_receipt_subagents">子代理</string>
|
||||
<string name="chat_activity_receipt_process">后台命令</string>
|
||||
<string name="chat_activity_receipt_view_activity">查看活动</string>
|
||||
<string name="chat_activity_receipt_view_output">查看输出</string>
|
||||
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
|
||||
<string name="chat_activity_history_title">聊天活动</string>
|
||||
<string name="chat_activity_history_empty">此设备未保留详细活动。完成记录仍保留在聊天中。</string>
|
||||
<string name="tool_progress_status_dispatched">已分派</string>
|
||||
<string name="chat_activity_history_notice">已记录的活动。进度更新不会保留;可用的子代理历史记录可以以只读方式打开。</string>
|
||||
<string name="chat_activity_output_unavailable">输出已不可用。此条目保留了记录的进程状态。</string>
|
||||
<string name="profile_follow_server_default">跟随服务器默认配置</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">在其他应用上使用语音</string>
|
||||
<string name="voice_overlay_setup_body">在其他应用上保持语音控件可见。音频会发送到你配置的 Hermes 服务器。这不会授权读取屏幕或控制手机。请在 Android 设置中允许显示在其他应用上层,然后返回此处。必须启用带有停止操作的麦克风通知。关闭悬浮窗或锁定屏幕会结束语音会话。</string>
|
||||
<string name="voice_overlay_setup_start">启动语音悬浮窗</string>
|
||||
<string name="voice_overlay_settings_hint">两个版本均可选择使用。在聊天的语音专注模式中选择悬浮窗。仅授予权限不会开始监听。</string>
|
||||
<string name="voice_overlay_reset_position">重置位置</string>
|
||||
<string name="injected_context_gateway_unsupported">Gateway 聊天不会发送此内容。此连接不支持每轮附加上下文。</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Frage %1$d von %2$d</string>
|
||||
<string name="clarify_batch_complete">Alle Fragen beantwortet</string>
|
||||
<string name="clarify_batch_expired">Diese Anfrage ist beendet</string>
|
||||
<string name="clarify_batch_sending">Antwort wird gesendet…</string>
|
||||
<string name="clarify_batch_answered">Beantwortete Fragen (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">Agentenoberfläche</string>
|
||||
@@ -799,7 +804,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Chat-Benachrichtigungen im Hintergrund</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
|
||||
<string name="chat_settings_share_phone_status">Smartphone-Status mit Agent teilen</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Bei jedem Chatdurchlauf eine kurze Systemnachricht über App und Smartphone einfügen</string>
|
||||
<string name="chat_settings_share_phone_status_desc">App- und Smartphone-Kontext in reinen API-Chats mitsenden. Wird in Standard-Gateway-Chats nicht gesendet.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + Berechtigungen</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Ob Bedienungshilfe, Bildschirmaufnahme, Overlay und Benachrichtigungen erlaubt sind</string>
|
||||
<string name="chat_settings_foreground_app">App im Vordergrund</string>
|
||||
@@ -808,7 +813,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Aktueller Akkustand in Prozent. Standardmäßig aus.</string>
|
||||
<string name="chat_settings_safety_rails">Schutzmechanismen</string>
|
||||
<string name="chat_settings_safety_rails_desc">Anzahl gesperrter Einträge und destruktiver Verben sowie Timer zur automatischen Deaktivierung</string>
|
||||
<string name="chat_settings_preview">Vorschau</string>
|
||||
<string name="chat_settings_preview">Beispiel für einen reinen API-Chat</string>
|
||||
<string name="chat_settings_no_system_message">(es wird keine Systemnachricht gesendet)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Werkzeughinweise auswerten</string>
|
||||
<string name="chat_settings_experimental">Experimentell</string>
|
||||
@@ -3364,10 +3369,10 @@
|
||||
<string name="injected_context_phone_status_title">Smartphone-Status</string>
|
||||
<string name="injected_context_relay_not_set">Kein Relay konfiguriert</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Kontext, der mit der nächsten Nachricht gesendet wird</string>
|
||||
<string name="injected_context_subtitle">Vorschau für diesen Chat. Serverseitiger Kontext wird separat konfiguriert.</string>
|
||||
<string name="injected_context_title">Eingefügter Kontext</string>
|
||||
<string name="injected_context_turn_not_set">Nicht festgelegt</string>
|
||||
<string name="injected_context_turn_title">Modus zur Durchlauferkennung</string>
|
||||
<string name="injected_context_turn_title">Nachrichtenkontext</string>
|
||||
<string name="model_picker_cd_clear">Leeren</string>
|
||||
<string name="model_picker_count">%d Modelle</string>
|
||||
<string name="power_feature_back_desc">Zurück</string>
|
||||
@@ -4586,4 +4591,23 @@
|
||||
<string name="chat_queue_paused">Warteschlange pausiert</string>
|
||||
<string name="chat_queue_resume">Fortsetzen</string>
|
||||
<string name="chat_queue_remove">Nachricht aus Warteschlange entfernen</string>
|
||||
<string name="chat_activity_receipt_subagents">Unteragenten</string>
|
||||
<string name="chat_activity_receipt_process">Hintergrundbefehl</string>
|
||||
<string name="chat_activity_receipt_view_activity">Aktivität anzeigen</string>
|
||||
<string name="chat_activity_receipt_view_output">Ausgabe anzeigen</string>
|
||||
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
|
||||
<string name="chat_activity_history_title">Chat-Aktivität</string>
|
||||
<string name="chat_activity_history_empty">Detaillierte Aktivitäten wurden auf diesem Gerät nicht gespeichert. Der Abschlusseintrag bleibt im Chat.</string>
|
||||
<string name="tool_progress_status_dispatched">Übergeben</string>
|
||||
<string name="chat_activity_history_notice">Gespeicherte Aktivität. Fortschrittsmeldungen werden nicht gespeichert; der verfügbare Verlauf der Unteragenten kann schreibgeschützt geöffnet werden.</string>
|
||||
<string name="chat_activity_output_unavailable">Die Ausgabe ist nicht mehr verfügbar. Dieser Eintrag enthält den gespeicherten Prozessstatus.</string>
|
||||
<string name="profile_follow_server_default">Serverstandard folgen</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Sprache über anderen Apps</string>
|
||||
<string name="voice_overlay_setup_body">Sprachsteuerung bleibt über anderen Apps sichtbar. Audio geht an deinen konfigurierten Hermes-Server. Dies erlaubt weder Bildschirmlesen noch Telefonsteuerung. Erlaube die Anzeige über anderen Apps in den Android-Einstellungen und kehre zurück. Eine Mikrofonbenachrichtigung mit Stopp-Aktion ist erforderlich. Schließen des Overlays oder Sperren des Bildschirms beendet die Sprachsitzung.</string>
|
||||
<string name="voice_overlay_setup_start">Sprach-Overlay starten</string>
|
||||
<string name="voice_overlay_settings_hint">In beiden Versionen optional. Öffne den Sprachfokus im Chat und wähle Overlay. Berechtigungen allein starten kein Zuhören.</string>
|
||||
<string name="voice_overlay_reset_position">Position zurücksetzen</string>
|
||||
<string name="injected_context_gateway_unsupported">Wird im Gateway-Chat nicht gesendet. Diese Verbindung unterstützt keinen zusätzlichen Kontext pro Nachricht.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Pregunta %1$d de %2$d</string>
|
||||
<string name="clarify_batch_complete">Todas las preguntas respondidas</string>
|
||||
<string name="clarify_batch_expired">Esta solicitud ha finalizado</string>
|
||||
<string name="clarify_batch_sending">Enviando respuesta…</string>
|
||||
<string name="clarify_batch_answered">Preguntas respondidas (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">interfaz del agente</string>
|
||||
@@ -724,7 +729,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Alertas de chat en segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
|
||||
<string name="chat_settings_share_phone_status">Compartir el estado del teléfono con el agente</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Incluya un breve mensaje del sistema sobre la aplicación y el teléfono en cada turno de chat.</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Incluir contexto de la app y el teléfono en chats solo por API. No se envía en los chats estándar de Gateway.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permisos</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Si se concede accesibilidad, captura de pantalla, superposición o notificaciones</string>
|
||||
<string name="chat_settings_foreground_app">Aplicación en primer plano</string>
|
||||
@@ -733,7 +738,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Porcentaje de batería actual. Desactivado de forma predeterminada.</string>
|
||||
<string name="chat_settings_safety_rails">Barandillas de seguridad</string>
|
||||
<string name="chat_settings_safety_rails_desc">Recuento de listas de bloqueo, recuento de verbos destructivos y temporizador de desactivación automática</string>
|
||||
<string name="chat_settings_preview">Avance</string>
|
||||
<string name="chat_settings_preview">Ejemplo de chat solo por API</string>
|
||||
<string name="chat_settings_no_system_message">(no se enviará ningún mensaje del sistema)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Anotaciones de la herramienta de análisis</string>
|
||||
<string name="chat_settings_experimental">Experimental</string>
|
||||
@@ -3028,10 +3033,10 @@
|
||||
<string name="injected_context_phone_status_title">Estado del teléfono</string>
|
||||
<string name="injected_context_relay_not_set">Ningún relay configurado</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Contexto enviado con el siguiente mensaje.</string>
|
||||
<string name="injected_context_subtitle">Vista previa de este chat. El contexto del servidor se configura por separado.</string>
|
||||
<string name="injected_context_title">Contexto inyectado</string>
|
||||
<string name="injected_context_turn_not_set">No establecido</string>
|
||||
<string name="injected_context_turn_title">Modo de detección de giro</string>
|
||||
<string name="injected_context_turn_title">Contexto del turno</string>
|
||||
<string name="model_picker_cd_clear">Borrar</string>
|
||||
<string name="model_picker_count">Modelos %d</string>
|
||||
<string name="power_feature_back_desc">Atrás</string>
|
||||
@@ -4277,4 +4282,23 @@
|
||||
<string name="chat_queue_paused">Cola pausada</string>
|
||||
<string name="chat_queue_resume">Reanudar</string>
|
||||
<string name="chat_queue_remove">Quitar mensaje de la cola</string>
|
||||
<string name="chat_activity_receipt_subagents">Subagentes</string>
|
||||
<string name="chat_activity_receipt_process">Comando en segundo plano</string>
|
||||
<string name="chat_activity_receipt_view_activity">Ver actividad</string>
|
||||
<string name="chat_activity_receipt_view_output">Ver salida</string>
|
||||
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
|
||||
<string name="chat_activity_history_title">Actividad del chat</string>
|
||||
<string name="chat_activity_history_empty">La actividad detallada no se guardó en este dispositivo. La entrada de finalización permanece en el chat.</string>
|
||||
<string name="tool_progress_status_dispatched">Enviado</string>
|
||||
<string name="chat_activity_history_notice">Actividad registrada. No se conservan las actualizaciones de progreso; el historial disponible de los subagentes se puede abrir en modo de solo lectura.</string>
|
||||
<string name="chat_activity_output_unavailable">La salida ya no está disponible. Esta entrada conserva el estado registrado del proceso.</string>
|
||||
<string name="profile_follow_server_default">Seguir el perfil predeterminado del servidor</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voz sobre otras apps</string>
|
||||
<string name="voice_overlay_setup_body">Mantén los controles de voz visibles sobre otra app. El audio se envía a tu servidor Hermes configurado. Esto no permite leer la pantalla ni controlar el teléfono. Permite mostrar sobre otras apps en los ajustes de Android y vuelve aquí. Se requiere una notificación del micrófono con Detener voz. Cerrar la ventana o bloquear la pantalla termina la sesión de voz.</string>
|
||||
<string name="voice_overlay_setup_start">Iniciar ventana de voz</string>
|
||||
<string name="voice_overlay_settings_hint">Opcional en ambas versiones. Abre el enfoque de voz en Chat y elige Superposición. Los permisos por sí solos nunca inician la escucha.</string>
|
||||
<string name="voice_overlay_reset_position">Restablecer posición</string>
|
||||
<string name="injected_context_gateway_unsupported">No se envía en el chat de Gateway. Esta conexión no admite contexto adicional por turno.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">質問 %1$d / %2$d</string>
|
||||
<string name="clarify_batch_complete">すべての質問に回答しました</string>
|
||||
<string name="clarify_batch_expired">このリクエストは終了しました</string>
|
||||
<string name="clarify_batch_sending">回答を送信中…</string>
|
||||
<string name="clarify_batch_answered">回答済みの質問(%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">エージェントインターフェース</string>
|
||||
@@ -796,7 +801,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">バックグラウンドのチャット通知</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
|
||||
<string name="chat_settings_share_phone_status">電話のステータスをエージェントと共有する</string>
|
||||
<string name="chat_settings_share_phone_status_desc">すべてのチャット ターンにアプリと電話に関する短いシステム メッセージを含めます</string>
|
||||
<string name="chat_settings_share_phone_status_desc">API のみのチャットにアプリと端末のコンテキストを含めます。標準の Gateway チャットでは送信されません。</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + 権限</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">アクセシビリティ、画面キャプチャ、オーバーレイ、通知が許可されているかどうか</string>
|
||||
<string name="chat_settings_foreground_app">フォアグラウンドアプリ</string>
|
||||
@@ -805,7 +810,7 @@
|
||||
<string name="chat_settings_battery_level_desc">現在のバッテリーのパーセント。デフォルトではオフです。</string>
|
||||
<string name="chat_settings_safety_rails">安全レール</string>
|
||||
<string name="chat_settings_safety_rails_desc">ブロックリストの数、破壊的な動詞の数、および自動無効化タイマー</string>
|
||||
<string name="chat_settings_preview">プレビュー</string>
|
||||
<string name="chat_settings_preview">API のみのチャットの例</string>
|
||||
<string name="chat_settings_no_system_message">(システムメッセージは送信されません)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">解析ツールの注釈</string>
|
||||
<string name="chat_settings_experimental">実験的</string>
|
||||
@@ -3371,10 +3376,10 @@
|
||||
<string name="injected_context_phone_status_title">電話のステータス</string>
|
||||
<string name="injected_context_relay_not_set">Relayが設定されていません</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">次のメッセージで送信されるコンテキスト</string>
|
||||
<string name="injected_context_subtitle">このチャットのプレビューです。サーバー側のコンテキストは別途設定されます。</string>
|
||||
<string name="injected_context_title">挿入されたコンテキスト</string>
|
||||
<string name="injected_context_turn_not_set">未設定</string>
|
||||
<string name="injected_context_turn_title">回転検出モード</string>
|
||||
<string name="injected_context_turn_title">ターンのコンテキスト</string>
|
||||
<string name="model_picker_cd_clear">クリア</string>
|
||||
<string name="model_picker_count">%d モデル</string>
|
||||
<string name="power_feature_back_desc">戻る</string>
|
||||
@@ -4581,4 +4586,23 @@
|
||||
<string name="chat_queue_paused">キューを一時停止中</string>
|
||||
<string name="chat_queue_resume">再開</string>
|
||||
<string name="chat_queue_remove">キューからメッセージを削除</string>
|
||||
<string name="chat_activity_receipt_subagents">サブエージェント</string>
|
||||
<string name="chat_activity_receipt_process">バックグラウンドコマンド</string>
|
||||
<string name="chat_activity_receipt_view_activity">アクティビティを表示</string>
|
||||
<string name="chat_activity_receipt_view_output">出力を表示</string>
|
||||
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
|
||||
<string name="chat_activity_history_title">チャットのアクティビティ</string>
|
||||
<string name="chat_activity_history_empty">詳細なアクティビティはこのデバイスに保存されていません。完了の記録はチャットに残っています。</string>
|
||||
<string name="tool_progress_status_dispatched">ディスパッチ済み</string>
|
||||
<string name="chat_activity_history_notice">記録されたアクティビティです。進捗の更新は保存されません。利用可能なサブエージェントの履歴は読み取り専用で開けます。</string>
|
||||
<string name="chat_activity_output_unavailable">出力は利用できなくなりました。この項目には記録されたプロセスの状態が残っています。</string>
|
||||
<string name="profile_follow_server_default">サーバーのデフォルトに従う</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">他のアプリ上で音声を使用</string>
|
||||
<string name="voice_overlay_setup_body">他のアプリ上に音声操作を表示します。音声は設定したHermesサーバーに送信されます。画面の読み取りや端末操作は許可されません。Androidの設定で他のアプリ上への表示を許可し、ここに戻ってください。停止操作付きのマイク通知が必要です。表示を閉じるか画面をロックすると音声セッションは終了します。</string>
|
||||
<string name="voice_overlay_setup_start">音声オーバーレイを開始</string>
|
||||
<string name="voice_overlay_settings_hint">両方のビルドで任意に利用できます。チャットの音声フォーカスでオーバーレイを選択します。権限の付与だけで録音は始まりません。</string>
|
||||
<string name="voice_overlay_reset_position">位置をリセット</string>
|
||||
<string name="injected_context_gateway_unsupported">Gateway チャットでは送信されません。この接続はターンごとの追加コンテキストに対応していません。</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Вопрос %1$d из %2$d</string>
|
||||
<string name="clarify_batch_complete">Все вопросы отвечены</string>
|
||||
<string name="clarify_batch_expired">Этот запрос завершён</string>
|
||||
<string name="clarify_batch_sending">Отправка ответа…</string>
|
||||
<string name="clarify_batch_answered">Отвеченные вопросы (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">интерфейс агента</string>
|
||||
@@ -773,7 +778,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Уведомления о фоновом чате</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Уведомлять, когда Гермес требует ввода или заканчивает работу, когда приложение находится в фоновом режиме</string>
|
||||
<string name="chat_settings_share_phone_status">Поделиться статусом телефона с агентом</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Включите короткое системное сообщение о приложении и телефоне в каждом обороте чата</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Добавлять контекст приложения и телефона в чаты только через API. В стандартных чатах Gateway он не отправляется.</string>
|
||||
<string name="chat_settings_bridge_permissions">Мост + разрешения</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Разрешения на доступность, захват экрана, наложение, уведомления</string>
|
||||
<string name="chat_settings_foreground_app">Приложение на переднем плане</string>
|
||||
@@ -782,7 +787,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Текущий процент заряда батареи. По умолчанию выключено.</string>
|
||||
<string name="chat_settings_safety_rails">Ограждения безопасности</string>
|
||||
<string name="chat_settings_safety_rails_desc">Количество в черном списке, количество деструктивных глаголов и таймер автоматического отключения</string>
|
||||
<string name="chat_settings_preview">Предпросмотр</string>
|
||||
<string name="chat_settings_preview">Пример чата только через API</string>
|
||||
<string name="chat_settings_no_system_message">(системное сообщение не будет отправлено)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Анализ аннотаций инструментов</string>
|
||||
<string name="chat_settings_experimental">Экспериментальные</string>
|
||||
@@ -3234,10 +3239,10 @@
|
||||
<string name="injected_context_phone_status_title">Статус телефона</string>
|
||||
<string name="injected_context_relay_not_set">плагин Relay не настроен</string>
|
||||
<string name="injected_context_relay_title">плагин Relay</string>
|
||||
<string name="injected_context_subtitle">Контекст отправляется с следующим сообщением</string>
|
||||
<string name="injected_context_subtitle">Предпросмотр для этого чата. Контекст на стороне сервера настраивается отдельно.</string>
|
||||
<string name="injected_context_title">Внедренный контекст</string>
|
||||
<string name="injected_context_turn_not_set">Не установлено</string>
|
||||
<string name="injected_context_turn_title">Режим обнаружения Turn</string>
|
||||
<string name="injected_context_turn_title">Контекст хода</string>
|
||||
<string name="model_picker_cd_clear">Очистить</string>
|
||||
<string name="model_picker_count">%d моделей</string>
|
||||
<string name="power_feature_back_desc">Назад</string>
|
||||
@@ -4325,4 +4330,23 @@
|
||||
<string name="chat_queue_paused">Очередь приостановлена</string>
|
||||
<string name="chat_queue_resume">Продолжить</string>
|
||||
<string name="chat_queue_remove">Удалить сообщение из очереди</string>
|
||||
<string name="chat_activity_receipt_subagents">Субагенты</string>
|
||||
<string name="chat_activity_receipt_process">Фоновая команда</string>
|
||||
<string name="chat_activity_receipt_view_activity">Посмотреть активность</string>
|
||||
<string name="chat_activity_receipt_view_output">Посмотреть вывод</string>
|
||||
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
|
||||
<string name="chat_activity_history_title">Активность чата</string>
|
||||
<string name="chat_activity_history_empty">Подробная активность не сохранена на этом устройстве. Запись о завершении остаётся в чате.</string>
|
||||
<string name="tool_progress_status_dispatched">Отправлено</string>
|
||||
<string name="chat_activity_history_notice">Сохранённая активность. Обновления хода работы не сохраняются; доступную историю субагентов можно открыть только для чтения.</string>
|
||||
<string name="chat_activity_output_unavailable">Вывод больше недоступен. Эта запись сохраняет зафиксированное состояние процесса.</string>
|
||||
<string name="profile_follow_server_default">Следовать профилю сервера по умолчанию</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Голос поверх других приложений</string>
|
||||
<string name="voice_overlay_setup_body">Голосовые элементы управления видны поверх другого приложения. Аудио отправляется на настроенный сервер Hermes. Это не разрешает чтение экрана или управление телефоном. Разрешите показ поверх приложений в настройках Android и вернитесь сюда. Требуется уведомление микрофона с кнопкой остановки. Закрытие окна или блокировка экрана завершает голосовой сеанс.</string>
|
||||
<string name="voice_overlay_setup_start">Начать голосовой оверлей</string>
|
||||
<string name="voice_overlay_settings_hint">Необязательно в обеих сборках. Откройте голосовой фокус в чате и выберите оверлей. Одни разрешения никогда не включают прослушивание.</string>
|
||||
<string name="voice_overlay_reset_position">Сбросить положение</string>
|
||||
<string name="injected_context_gateway_unsupported">Не отправляется в чате Gateway. Это подключение не поддерживает дополнительный контекст для каждого хода.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Question %1$d of %2$d</string>
|
||||
<string name="clarify_batch_complete">All questions answered</string>
|
||||
<string name="clarify_batch_expired">This request has ended</string>
|
||||
<string name="clarify_batch_sending">Sending answer…</string>
|
||||
<string name="clarify_batch_answered">Answered questions (%1$d)</string>
|
||||
<string name="chat_debug_title" translatable="false">Session diagnostics</string>
|
||||
<string name="chat_debug_open" translatable="false">Open session diagnostics</string>
|
||||
<string name="chat_debug_close" translatable="false">Close session diagnostics</string>
|
||||
<string name="chat_debug_preparing" translatable="false">Preparing session…</string>
|
||||
<string name="chat_debug_sign_in" translatable="false">Dashboard sign-in</string>
|
||||
<string name="chat_debug_sign_in_needed" translatable="false">Sign in to continue this conversation.</string>
|
||||
<string name="chat_debug_authenticated" translatable="false">Authenticated for this connection.</string>
|
||||
<string name="chat_debug_auth_unknown" translatable="false">Sign-in has not been confirmed.</string>
|
||||
<string name="chat_debug_gateway" translatable="false">Live connection</string>
|
||||
<string name="chat_debug_socket_ready" translatable="false">Gateway is ready to receive requests.</string>
|
||||
<string name="chat_debug_ticket" translatable="false">Requesting a secure connection ticket…</string>
|
||||
<string name="chat_debug_socket_connecting" translatable="false">Opening the Gateway connection…</string>
|
||||
<string name="chat_debug_socket_waiting" translatable="false">Waiting for the Gateway to become ready…</string>
|
||||
<string name="chat_debug_socket_idle" translatable="false">No live Gateway connection.</string>
|
||||
<string name="chat_debug_session" translatable="false">Conversation session</string>
|
||||
<string name="chat_debug_preparing_detail" translatable="false">Hermes is initializing this session. The message has not been sent yet.</string>
|
||||
<string name="chat_debug_history_loading" translatable="false">Loading saved messages…</string>
|
||||
<string name="chat_debug_history_failed" translatable="false">Saved conversations could not be refreshed.</string>
|
||||
<string name="chat_debug_session_selected" translatable="false">Session selected.</string>
|
||||
<string name="chat_debug_session_new" translatable="false">A session will be created when you send.</string>
|
||||
<string name="chat_debug_response" translatable="false">Agent response</string>
|
||||
<string name="chat_debug_response_failed" translatable="false">The request failed. Details are below.</string>
|
||||
<string name="chat_debug_response_waiting" translatable="false">Waiting for session initialization.</string>
|
||||
<string name="chat_debug_response_active" translatable="false">Waiting for the current reply to finish.</string>
|
||||
<string name="chat_debug_response_idle" translatable="false">No reply is currently in progress.</string>
|
||||
<string name="chat_debug_init_recovery" translatable="false">Hermes could not initialize the agent. Check the server runtime and logs before retrying; signing in again may not resolve this.</string>
|
||||
<string name="chat_debug_session_id" translatable="false">Session · %1$s</string>
|
||||
<string name="chat_debug_connections" translatable="false">Connection settings</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">agent interface</string>
|
||||
@@ -854,7 +887,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Background chat alerts</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
|
||||
<string name="chat_settings_share_phone_status">Share phone status with agent</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Include a short system message about the app and phone on every chat turn</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Include app and phone context in API-only chats. Not sent in standard Gateway chats.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permissions</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Whether accessibility, screen capture, overlay, notifications are granted</string>
|
||||
<string name="chat_settings_foreground_app">Foreground app</string>
|
||||
@@ -863,7 +896,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Current battery percent. Off by default.</string>
|
||||
<string name="chat_settings_safety_rails">Safety rails</string>
|
||||
<string name="chat_settings_safety_rails_desc">Blocklist count, destructive-verb count, and auto-disable timer</string>
|
||||
<string name="chat_settings_preview">Preview</string>
|
||||
<string name="chat_settings_preview">Example for API-only chat</string>
|
||||
<string name="chat_settings_no_system_message">(no system message will be sent)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Parse tool annotations</string>
|
||||
<string name="chat_settings_experimental">Experimental</string>
|
||||
@@ -3736,10 +3769,10 @@
|
||||
<string name="injected_context_phone_status_title">Phone status</string>
|
||||
<string name="injected_context_relay_not_set">No relay configured</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Context sent with next message</string>
|
||||
<string name="injected_context_subtitle">Preview for this chat. Server-side context is configured separately.</string>
|
||||
<string name="injected_context_title">Injected context</string>
|
||||
<string name="injected_context_turn_not_set">Not set</string>
|
||||
<string name="injected_context_turn_title">Turn detection mode</string>
|
||||
<string name="injected_context_turn_title">Turn context</string>
|
||||
<string name="model_picker_cd_clear">Clear</string>
|
||||
<string name="model_picker_count">%d models</string>
|
||||
<string name="power_feature_back_desc">Back</string>
|
||||
@@ -4602,4 +4635,37 @@
|
||||
<string name="chat_queue_paused">Queue paused</string>
|
||||
<string name="chat_queue_resume">Resume</string>
|
||||
<string name="chat_queue_remove">Remove queued message</string>
|
||||
<string name="chat_activity_receipt_subagents">Subagents</string>
|
||||
<string name="chat_activity_receipt_process">Background command</string>
|
||||
<string name="chat_activity_receipt_view_activity">View activity</string>
|
||||
<string name="chat_activity_receipt_view_output">View output</string>
|
||||
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
|
||||
<string name="chat_activity_history_title">Chat activity</string>
|
||||
<string name="chat_activity_history_empty">Detailed activity was not retained on this device. The completion entry remains in chat.</string>
|
||||
<string name="tool_progress_status_dispatched">Dispatched</string>
|
||||
<string name="chat_activity_history_notice">Recorded activity. Progress updates are not retained; available child history can be opened read-only.</string>
|
||||
<string name="chat_activity_output_unavailable">Output is no longer available. This entry preserves the recorded process status.</string>
|
||||
<string name="dev_message_previews" translatable="false">Message previews</string>
|
||||
<string name="dev_message_previews_desc" translatable="false">Local-only samples using the app’s real message surfaces. No requests are sent. Samples clear when you leave this screen.</string>
|
||||
<string name="dev_message_info" translatable="false">Info preview</string>
|
||||
<string name="dev_message_success" translatable="false">Success preview</string>
|
||||
<string name="dev_message_progress" translatable="false">Progress preview</string>
|
||||
<string name="dev_message_warning" translatable="false">Warning preview</string>
|
||||
<string name="dev_message_error" translatable="false">Error preview</string>
|
||||
<string name="dev_message_preview_sample" translatable="false">Show a themed sample. Progress stays until replaced or cleared.</string>
|
||||
<string name="dev_message_action" translatable="false">Actionable error preview</string>
|
||||
<string name="dev_message_action_body" translatable="false">This is a simulated error with a longer explanation. Expand or scroll to check readability. Retry only confirms the preview action; it does not contact a server.</string>
|
||||
<string name="dev_message_retry" translatable="false">Retry</string>
|
||||
<string name="dev_message_retry_result" translatable="false">Preview retry selected — no request sent.</string>
|
||||
<string name="dev_message_clear" translatable="false">Clear previews</string>
|
||||
<string name="dev_message_clear_desc" translatable="false">Dismiss only these samples; real app messages are preserved.</string>
|
||||
<string name="profile_follow_server_default">Follow server default</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voice over other apps</string>
|
||||
<string name="voice_overlay_setup_body">Keep voice controls visible while using another app. Audio goes to your configured Hermes server. This does not grant screen reading or phone control. Allow display over other apps in Android Settings, then return here. A microphone notification with Stop voice is required. Closing the overlay or locking the screen ends this voice session.</string>
|
||||
<string name="voice_overlay_setup_start">Start voice overlay</string>
|
||||
<string name="voice_overlay_settings_hint">Optional in both builds. Open Voice Focus in Chat and choose Overlay to start. Permissions alone never start listening.</string>
|
||||
<string name="voice_overlay_reset_position">Reset position</string>
|
||||
<string name="injected_context_gateway_unsupported">Not sent in Gateway chat. This connection does not support extra per-turn context.</string>
|
||||
</resources>
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
<!--
|
||||
Tier C (C1-C4) sideload-only permissions.
|
||||
|
||||
These are deliberately NOT declared in the main manifest: Google Play
|
||||
policy forbids CALL_PHONE / SEND_SMS / READ_CONTACTS without a default-
|
||||
dialer / default-SMS-app justification, and ACCESS_FINE_LOCATION is
|
||||
flagged alongside auto-dial on the `googlePlay` track. The `sideload`
|
||||
flavor is the only track that ships them.
|
||||
These are deliberately kept outside Play's voice-only capability surface.
|
||||
SMS permissions require an eligible default-handler/approved use on Play;
|
||||
contacts, foreground location and CALL_PHONE have distinct requirements.
|
||||
Their omission here is a product boundary, not a blanket permission ban.
|
||||
|
||||
Each of the four `android_*` tools (location, search_contacts, call,
|
||||
send_sms) runtime-checks the corresponding permission in
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user