Compare commits

..
Author SHA1 Message Date
Bailey Dixon 6395e73927 Merge remote-tracking branch 'origin/dev' into fix/android-basic-auth-paste
# Conflicts:
#	CHANGELOG.md
2026-09-09 21:20:33 -04:00
Bailey Dixon 0c337c9384 Merge pull request #569 from Codename-11/fix/plugin-availability-probe
fix(plugin): share relay availability checks
2026-09-09 21:06:46 -04:00
Bailey DixonandJack Hunzicker 179080d6d9 fix(plugin): share relay availability checks
Co-authored-by: Jack Hunzicker <JackHunzicker@users.noreply.github.com>
2026-09-09 20:57:22 -04:00
Bailey Dixon ac1f42b7d5 Merge remote-tracking branch 'origin/dev' into fix/android-basic-auth-paste
# Conflicts:
#	CHANGELOG.md
2026-09-09 20:21:19 -04:00
Bailey Dixon 88591fca89 fix(android): normalize pasted dashboard credentials
Refs #541
2026-09-09 20:20:48 -04:00
Bailey Dixon 00c5f5daa2 Merge pull request #566 from trevornk/fix/connection-owned-dashboard-auth
fix(android): bind dashboard auth to connection-owned routes
2026-09-09 20:14:28 -04:00
Bailey Dixon 69b6c005cd chore: merge current dev for dashboard auth verification 2026-09-09 19:50:39 -04:00
Bailey Dixon c902215a15 Merge pull request #560 from JackHunzicker/contrib/git-state-callback-test-order
test(android): await git commit success callback
2026-09-09 19:37:25 -04:00
Bailey Dixon d39368bc51 Merge pull request #568 from Codename-11/fix/470-bot-mode-list-identity
fix(android): prevent duplicate Bot Mode keys across connections
2026-09-09 19:34:28 -04:00
Bailey Dixon 414dc08b9f Merge branch 'dev' into contrib/git-state-callback-test-order 2026-09-09 19:28:12 -04:00
Bailey Dixon 3b78a17bd7 chore: merge current dev for Bot Mode verification 2026-09-09 19:22:10 -04:00
Bailey Dixon a5efb9ec96 fix(android): scope Bot Mode item identity to connection and profile 2026-09-09 19:21:34 -04:00
Bailey Dixon e5228f2089 Merge pull request #567 from Codename-11/fix/android-detached-subagent-preview
fix(android): preserve chat activity and correct feedback ownership
2026-09-09 17:18:15 -04:00
Bailey Dixon c738a2c2c0 chore: merge current dev before activity integration 2026-09-09 17:05:38 -04:00
Bailey Dixon 8ed6b35e76 Merge pull request #555 from Codename-11/dependabot/gradle/dev/com.android.library-9.4.0
chore(deps): bump com.android.library from 9.3.2 to 9.4.0
2026-09-09 17:02:23 -04:00
Bailey Dixon c6d4a15226 Merge pull request #552 from Codename-11/dependabot/github_actions/dev/actions/cache-6
chore(deps): bump actions/cache from 5 to 6
2026-09-09 16:59:59 -04:00
Bailey Dixon 22c8f76c72 fix(android): preserve chat activity and correct feedback ownership 2026-09-09 16:57:40 -04:00
Bailey Dixon a0161f51ef Merge origin/dev into dependabot/gradle/dev/com.android.library-9.4.0 2026-09-09 16:52:49 -04:00
Bailey Dixon 7aa3b1c697 test(ci): align cache contract with v6 2026-09-09 16:49:59 -04:00
Bailey Dixon e7112dda9e Merge origin/dev into dependabot/github_actions/dev/actions/cache-6 2026-09-09 16:49:26 -04:00
trevornk c1413c494f fix(android): bind dashboard auth to connection-owned routes
Refs #565
2026-09-09 11:44:14 -05:00
Jack bb23e6ab48 test(android): await git commit success callback
Mutation success is published before detail refresh finishes and the callback runs. Await callback delivery explicitly instead of treating the state flow as a callback-completion barrier.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified: 43 passed (test_android_tool.py + test_android_tool_device_selector.py),
and against the physical device with ANDROID_BRIDGE_TOKEN unset from the
environment — /ping and /current_app both 200 via the new disk fallback.
2026-09-03 22:40:30 +02:00
Bailey Dixon e20540d16f Merge pull request #539 from Codename-11/release/patch-2026-09-02
release: prepare Android 1.15.1 and CLI+UI 0.4.0-beta.7
2026-09-02 21:43:43 -04:00
Bailey Dixon c932adbc7b release(desktop): desktop-v0.4.0-beta.7 2026-09-02 21:34:28 -04:00
Bailey Dixon 23cec497ce release(android): android-v1.15.1 2026-09-02 21:34:28 -04:00
Bailey Dixon 1739dc36a2 Merge pull request #538 from Codename-11/fix/android-voice-error-dialog
fix(android): polish voice errors and steer/queue controls
2026-09-02 21:21:05 -04:00
122 changed files with 6271 additions and 1285 deletions
+2 -2
View File
@@ -115,8 +115,8 @@ assert.match(
/ref: \$\{\{ contains\(inputs\.version, '-'\) && 'dev' \|\| 'main' \}\}/,
);
assert.match(cliReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved CLI\+UI version/);
assert.match(cliReleaseWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
assert.match(desktopCiWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
assert.match(cliReleaseWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v6/);
assert.match(desktopCiWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v6/);
assert.match(pluginReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved Plugin version/);
assert.match(androidPreflightWorkflow, /Package immutable preflight artifacts/);
assert.match(androidApprovalWorkflow, /Android public approval accepts stable SemVer only/);
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Restore exact-source tray build cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
~/.cargo/registry/index
+5 -1
View File
@@ -111,4 +111,8 @@ jobs:
plugin/tests/test_git_state.py \
plugin/tests/test_git_state_write.py \
plugin/tests/test_git_state_extras.py \
plugin/tests/test_mobile_plugin_store.py
plugin/tests/test_mobile_plugin_store.py \
plugin/tests/test_android_tool.py \
plugin/tests/test_android_navigate.py \
plugin/tests/test_phone_platform.py \
plugin/tests/test_desktop_tool_availability.py
+1 -1
View File
@@ -308,7 +308,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Restore exact-source tray build cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
~/.cargo/registry/index
+5 -1
View File
@@ -107,7 +107,11 @@ jobs:
plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py \
plugin/tests/test_proactive_channel.py \
plugin/tests/test_android_phone_status.py
plugin/tests/test_android_phone_status.py \
plugin/tests/test_android_tool.py \
plugin/tests/test_android_navigate.py \
plugin/tests/test_phone_platform.py \
plugin/tests/test_desktop_tool_availability.py
package:
name: Build and publish Plugin package
+32 -11
View File
@@ -6,22 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Fixed
- Android Dashboard sign-in removes pasted line breaks from username and password fields, matching the browser login while preserving every other credential character. (#541)
- **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 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)
- **`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 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.
- 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
- **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.
- Delivery and correction labels remain readable inside user-message bubbles.
- Voice errors use a scrollable dialog with separate Retry and Dismiss actions.
- Attachment previews stay open through rotation, and videos retain their original proportions. (#483)
- Release builds preserve the native configuration names required for wake-word startup. (#444)
- Standard Hermes attachments stream to disk while enforcing download size limits. (#531)
- Session refresh no longer sustains a request loop. History loads, chat rendering, image previews, and media exports keep memory use bounded.
- Image-generation progress remains visible between interim replies and media delivery.
- New Gateway chats wait for session readiness before the first prompt; ownership refusals preserve the retryable prompt and server error.
## [0.4.0-beta.7] - 2026-09-02
### Fixed
- Windows updates detect a colocated management UI, report both installed versions, and update the CLI and UI together through the verified bundle installer. CLI-only installations keep their standalone updater.
## [Android 1.15.0] - 2026-08-31
+5 -9
View File
@@ -1,22 +1,18 @@
# Hermes-Relay CLI+UI v__VERSION__
**Release Date:** 2026-08-31
**Release Date:** 2026-09-02
This beta preserves complete multi-route pairing while preventing Desktop from dialing Dashboard-ingress Relay routes before Dashboard WebSocket ticket support is available. (Related: #399)
This beta fixes Windows updates so the installed CLI and management UI advance together. Explicit CLI-only installations keep their standalone update path.
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64; the management UI is Windows-only.
## What's changed
### Changed
- **Saved hosts retain the full route topology.** Dashboard, Relay, optional API, route priority, transport protection, certificate pins, and the selected host survive LAN, Tailscale, and public-route changes without creating duplicate hosts.
- **API-less pairing is first-class.** Dashboard plus direct Relay can pair without inventing an optional API server, while secure-first ranking retains plain LAN as the final fallback.
### Fixed
- **Dashboard-ingress Relay routes fail closed on Desktop.** The daemon, host selector, and Relay transport reject ingress that requires a Dashboard WebSocket ticket and choose a compatible direct Relay fallback instead of attempting an unauthenticated dial.
- **Pairing accepts the current v3 candidate shape.** Optional API records, same-origin Dashboard/Relay routes, and legacy top-level payloads remain compatible without collapsing route ownership.
- `hermes-relay update` detects an installed management UI beside the CLI and reports both installed versions.
- Bundle installations use the checksum-verified Windows installer to update and restart the affected CLI and UI together.
- Explicit CLI-only installations continue to use the standalone binary updater.
## Install
+20 -23
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.15.0
# Hermes-Relay Android v1.15.1
**Release Date:** August 31, 2026
**Release Date:** September 2, 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.15.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,29 @@ 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.
This patch improves chat, media, and voice reliability. It reduces memory-heavy work, keeps attachment previews stable through rotation, and makes follow-up message behavior and voice errors easier to understand.
## 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.
- Choose Correct now or Queue next from a slim tray behind the composer. Chat settings sets the default; the tray overrides one message. Stop pauses the queue, Resume continues it, and editing or removing an item preserves its successors.
- Chat and Voice use readable centered layouts on wider screens, including landscape Voice Focus.
## 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.
- Correction and delivery labels remain visible inside user-message bubbles.
- Voice errors open in a scrollable dialog with separate Retry and Dismiss actions.
- Attachment previews remain open through rotation, and video previews preserve their proportions.
- Release optimization preserves the native speech configuration required for wake-word startup.
- Standard Hermes attachments download directly to disk with bounded size checks.
- Session refresh avoids repeated request loops; history loads, Markdown, image previews, and media exports keep memory use bounded.
- Image-generation progress stays visible through gaps between interim replies and returned media.
- The first prompt waits for Gateway session readiness. Ownership refusals retain the prompt for retry and show the original server error.
## 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.15.1** (versionCode **54**).
- 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.1** remains the current optional plugin release; this Android patch does not require a new plugin version.
- Paused text queues can be restored. Attachment bytes are not persisted in preferences; unrestorable attachment queues must be reviewed and sent again.
- 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.
@@ -0,0 +1,65 @@
package com.hermesandroid.relay.ui.screens
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
import com.hermesandroid.relay.ui.components.ChatDebugOverlay
import com.hermesandroid.relay.ui.components.chatDebugHeaderGesture
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class ChatDebugDrawerInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
@Test
fun longPressOpensDiagnosticsBelowHeaderAndCloseRestoresChat() {
compose.setContent {
var open by remember { mutableStateOf(false) }
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Box(Modifier.fillMaxSize()) {
Text("Hermes", Modifier.fillMaxWidth().height(64.dp).testTag("header")
.chatDebugHeaderGesture(true, onClick = {}, onHold = { open = true }))
ChatDebugOverlay(open, 64.dp, onClose = { open = false }) {
ChatDebugDrawer(
profile = "Server Default", model = "Example", sessionId = "session",
gateway = true, signedIn = true, signInRequired = false,
socketState = GatewayConnectionState.Ready, preparing = false,
streaming = false, loadingHistory = false, directoryUnavailable = false,
failure = null, onClose = { open = false }, onConnections = {},
)
}
}
}
}
val header = compose.onNodeWithTag("header")
val before = header.fetchSemanticsNode().boundsInRoot
header.performTouchInput { longClick() }
compose.onNodeWithText("Session diagnostics").assertIsDisplayed()
assertEquals(before, header.fetchSemanticsNode().boundsInRoot)
compose.onNodeWithContentDescription("Close session diagnostics").performClick()
compose.onNodeWithText("Session diagnostics").assertDoesNotExist()
header.assertIsDisplayed()
}
}
@@ -0,0 +1,183 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.data.projectChatActivityReceipts
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/** Real Gateway callbacks drive production activity surfaces through Android lifecycle changes. */
class ChatActivityReceiptInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: AndroidGatewayContractFixture
private lateinit var gatewayScope: CoroutineScope
private lateinit var gateway: GatewayChatClient
private lateinit var handler: ChatHandler
private lateinit var viewModel: ChatViewModel
private lateinit var socket: WebSocket
private val owner = AgentDisplay.profileContextKey("fixture-connection", "research")
@Before
fun setUp() {
fixture = AndroidGatewayContractFixture().also { it.profileName = "research" }
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val http = OkHttpClient()
gateway = GatewayChatClient(
initialDashboardClient = DashboardApiClient(fixture.server.url("/").toString().trimEnd('/'), okHttpClient = http),
okHttpClient = http,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
viewModel = ChatViewModel().also {
it.initialize(HermesApiClient(fixture.server.url("/").toString(), "fixture-key"), handler)
it.streamingEndpoint = "gateway"
it.setSessionProfileNameProvider { "research" }
it.setProfileMessageLoader { Result.success(emptyList()) }
it.setChatActivityStore(InMemoryChatActivityStore())
it.switchProfileContext(owner, STORED_SESSION_ID)
it.updateGatewayClient(gateway)
it.setChatVisible(true)
}
compose.setContent {
val messages by viewModel.messages.collectAsStateWithLifecycle()
val records by viewModel.activityRecords.collectAsStateWithLifecycle()
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
val retained by viewModel.retainedActivityPreview.collectAsStateWithLifecycle()
val childPreview by viewModel.subagentChildPreview.collectAsStateWithLifecycle()
val session by viewModel.currentSessionId.collectAsStateWithLifecycle()
var sheetOpen by remember { mutableStateOf(false) }
MaterialTheme {
Column {
GatewayBackgroundProcessStrip(
processes = emptyList(), subagentActivities = children,
subagentPreviewVisibility = SubagentPreviewVisibility(), loading = false,
onClick = { viewModel.openCurrentActivityPreview(); sheetOpen = true },
modifier = Modifier.testTag("active-activity"),
)
projectChatActivityReceipts(messages, records, owner, session).forEach { message ->
message.activityRecord?.let { record ->
ChatActivityReceipt(
record = record,
onClick = { sheetOpen = viewModel.openRetainedActivity(record) },
modifier = Modifier.testTag("activity-receipt"),
)
}
}
}
if (sheetOpen) {
GatewayBackgroundProcessSheet(
processes = retained?.processes.orEmpty(),
subagentActivities = retained?.record?.previewActivities() ?: children,
subagentChildPreview = childPreview,
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = false, stoppingProcessIds = emptySet(),
onRefresh = viewModel::refreshBackgroundProcesses,
onStop = viewModel::stopBackgroundProcess,
onDismissProcess = viewModel::dismissBackgroundProcess,
onOpenSubagentChild = viewModel::openSubagentChildPreview,
onDismiss = { viewModel.closeActivityPreview(); sheetOpen = false },
readOnlyHistory = retained != null,
historyNotice = "Recorded activity. Available child history is read-only.",
)
}
}
}
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
socket = fixture.awaitServerSocket()
fixture.awaitRpc("session.resume")
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
gateway.shutdown()
gatewayScope.cancel()
fixture.shutdown()
}
@Test
fun detachedCompletionLeavesReopenableReceiptAcrossActivityResume() {
viewModel.sendMessage("Delegate a background task")
fixture.awaitRpc("prompt.submit")
socket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
socket.send(fixture.event("subagent.start", buildJsonObject {
put("subagent_id", "receipt-child")
put("delegation_id", "receipt-delegation")
put("task_count", 1)
put("goal", "Inspect activity lifecycle")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.subagentActivities.value.size == 1 }
compose.onNodeWithTag("active-activity").assertIsDisplayed()
socket.send(fixture.event("message.complete", buildJsonObject { put("text", "Launched") }, LIVE_SESSION_ID))
compose.waitUntil(5_000) { !handler.isStreaming.value }
compose.onNodeWithTag("active-activity").assertIsDisplayed()
socket.send(fixture.event("subagent.complete", buildJsonObject {
put("subagent_id", "receipt-child")
put("delegation_id", "receipt-delegation")
put("status", "completed")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.COMPLETE }
compose.onNodeWithTag("active-activity").assertDoesNotExist()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed().performClick()
compose.onNodeWithText("Chat activity").assertIsDisplayed()
compose.onNodeWithText("Recorded activity. Available child history is read-only.").assertIsDisplayed()
compose.onNodeWithText("Stop").assertDoesNotExist()
compose.onNodeWithContentDescription("Close activity preview").performClick()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed()
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.onNodeWithTag("active-activity").assertDoesNotExist()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed().performClick()
compose.onNodeWithText("Chat activity").assertIsDisplayed()
}
private companion object {
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val LIVE_SESSION_ID = "fixture-live-1"
}
}
@@ -28,6 +28,8 @@ 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.network.upstream.models.MessageItem
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
@@ -115,9 +117,18 @@ 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()
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"),
@@ -153,6 +164,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")
@@ -1,3 +1,3 @@
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
v1.15.1 - Steadier chat, media, and voice
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.
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.
+77
View File
@@ -1,6 +1,83 @@
{
"schema": 3,
"versions": [
{
"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",
+14 -19
View File
@@ -1,29 +1,24 @@
v1.15.0 - Standard Hermes first, with clearer Relay boundaries
v1.15.1 - Steadier chat, media, and voice
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.
* 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.
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.
* Choose when follow-up messages are sent — 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.
* Keep attachment previews open through rotation — Image, video, audio, PDF, text, and file previews stay open as the screen rotates. Videos retain their original proportions.
* Keep large chats and media manageable — 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.
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.
* Make better use of wider screens — Chat and Voice keep text and controls on readable centered layouts. Landscape Voice Focus separates identity controls from conversation activity.
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.
* Read message delivery status clearly — Correction and delivery labels use contrasting text instead of disappearing into the message bubble.
* Read and dismiss voice errors — Voice errors open in a contained dialog with scrollable details and separate Retry and Dismiss actions, without overlapping chat controls.
* Fix wake-word startup in release builds — Release optimization now preserves the native speech configuration names needed to initialize wake-word detection.
* Download attachments with less memory — Standard Hermes attachments stream into the on-disk cache while download size limits remain enforced.
* Keep image-generation progress visible — The working indicator stays visible between interim replies and the generated image, including gateways that omit tool activity events.
* Wait for new chats to be ready — The first message waits for the Gateway session to initialize. Ownership refusals keep the prompt retryable and show the server's error.
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 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.
@@ -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(
@@ -1451,6 +1451,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 +1617,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 +1652,9 @@ class ChatHandler {
// nothing local to carry).
ChatMessage(
id = messageId,
activitySourceId = activitySourceId,
activityTaskCount = activityTaskCount,
activityFailedCount = activityFailedCount,
rowId = item.resolvedRowId,
reactions = item.reactions,
role = role,
@@ -1525,10 +1525,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)
@@ -2950,6 +2963,7 @@ class GatewayChatClient(
unmatchedTurnCompleteListener = null
backgroundInteractionListener = null
processEventListener = null
subagentEventListener = null
sessionDirectoryInvalidationListener = null
closeSocket("client shutdown")
backgroundCloseJob?.cancel()
@@ -3485,6 +3499,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 +3509,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 +3520,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 +3717,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 +3727,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)
@@ -3915,6 +3943,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-
@@ -3976,10 +4005,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
@@ -292,34 +292,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 +440,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"
@@ -293,6 +293,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 }
}
@@ -36,7 +36,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
@@ -230,11 +230,7 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
// Short-lived snackbar by default; retryable errors get Long so users have
// time to tap the action before it auto-dismisses.
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
return showSnackbar(
message = err.body,
actionLabel = err.actionLabel,
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
)
return showSnackbar(com.hermesandroid.relay.ui.components.HumanErrorVisuals(err))
}
/** Startup chrome should wait for either standard chat surface, not Relay. */
@@ -1952,6 +1948,8 @@ fun RelayApp() {
// child TopAppBar doesn't double-pad when this banner owns the top edge.
val activeMessageCount by UiMessageBus.activeCount.collectAsState()
val showMessageBanner = activeMessageCount > 0
val modalMessageHostActive by UiMessageBus.modalHostActive.collectAsState()
val showActionMessage = snackbarHostState.currentSnackbarData != null && !modalMessageHostActive
// Update availability (unified): googlePlay = Play In-App Update FLEXIBLE,
// sideload = GitHub releases. The handle filters dismissed versions +
// throttles checks internally, exposing a surfaceable status for the
@@ -2077,6 +2075,14 @@ fun RelayApp() {
includeStatusBarPadding =
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure,
)
// Action feedback owns layout space at the top, never the composer's
// touch area. Modal windows keep their own scoped host.
ThemedMessageHost(
snackbarHostState,
modifier = if (showActionMessage && !showMessageBanner &&
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure
) Modifier.windowInsetsPadding(WindowInsets.statusBars) else Modifier,
)
// The update banner AND the connection-status indicator now render as
// floating overlay TOASTS in the Box below (see the top-overlay Column
@@ -2117,7 +2123,7 @@ fun RelayApp() {
// participates in the top-inset accounting.
if (showUnattendedBanner || showDemoBanner || showHostResourcePressure ||
connectionChipVisible ||
showMessageBanner
showMessageBanner || showActionMessage
) {
Modifier.consumeWindowInsets(WindowInsets.statusBars)
} else {
@@ -2125,7 +2131,6 @@ fun RelayApp() {
}
),
contentWindowInsets = WindowInsets(0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (
!suppressGlobalChrome &&
@@ -2212,7 +2217,10 @@ fun RelayApp() {
}
}
) { innerPadding ->
CompositionLocalProvider(LocalSnackbarHost provides snackbarHostState) {
CompositionLocalProvider(
LocalSnackbarHost provides snackbarHostState,
com.hermesandroid.relay.ui.components.LocalMessageActionHost provides snackbarHostState,
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicLong
/** Visual tone of a transient banner message. */
enum class UiMessageSeverity { Info, Success, Status, Warning }
enum class UiMessageSeverity { Info, Success, Status, Warning, Error }
data class UiMessage(
val id: Long,
@@ -23,6 +23,7 @@ data class UiMessage(
sealed interface UiMessageEvent {
data class Show(val message: UiMessage) : UiMessageEvent
data class Clear(val key: String) : UiMessageEvent
data object ClearAll : UiMessageEvent
}
internal fun reduceUiMessages(
@@ -30,6 +31,7 @@ internal fun reduceUiMessages(
event: UiMessageEvent,
maxRetained: Int,
): List<UiMessage> = when (event) {
UiMessageEvent.ClearAll -> emptyList()
is UiMessageEvent.Clear -> current.filterNot { it.key == event.key }
is UiMessageEvent.Show -> {
val incoming = event.message
@@ -41,15 +43,14 @@ internal fun reduceUiMessages(
}
/**
* App-wide bus for transient, non-error status/confirmation messages that
* App-wide bus for transient status, confirmation, and error messages that
* surface in the top [com.hermesandroid.relay.ui.components.MessageBannerHost]
* — a thin banner that takes its own space (content slides down, no overlay),
* shows the newest line collapsed, expands to a few recent lines, auto-dismisses
* and coalesces duplicates.
*
* App-owned errors and persistent/actionable messages keep going to the
* snackbar so they demand acknowledgement. Upstream keyed AgentNotices may use
* the warning tone here because their own sticky/clear lifecycle owns them.
* Messages requiring Retry or Undo use ThemedMessageHost and retain their
* action-result contract. Upstream keyed AgentNotices own their sticky/clear lifecycle.
* Migrate frequent
* `snackbarHostState.showSnackbar("…")` confirmations/status to [info] /
* [success] / [status] here.
@@ -62,6 +63,23 @@ object UiMessageBus {
const val STATUS_TTL_MS = 6_000L
private val counter = AtomicLong(0L)
private val hosts = linkedMapOf<Long, Boolean>()
private val _activeHost = MutableStateFlow<Long?>(null)
internal val activeHost = _activeHost.asStateFlow()
private val _modalHostActive = MutableStateFlow(false)
internal val modalHostActive = _modalHostActive.asStateFlow()
internal fun registerHost(primary: Boolean): Long = synchronized(hosts) {
counter.incrementAndGet().also {
hosts[it] = primary
_activeHost.value = hosts.keys.lastOrNull()
_modalHostActive.value = hosts.values.any { primaryHost -> !primaryHost }
}
}
internal fun unregisterHost(id: Long) { synchronized(hosts) {
hosts.remove(id)
_activeHost.value = hosts.keys.lastOrNull()
_modalHostActive.value = hosts.values.any { !it }
} }
private val _events = MutableSharedFlow<UiMessageEvent>(extraBufferCapacity = 24)
val events: SharedFlow<UiMessageEvent> = _events.asSharedFlow()
@@ -97,6 +115,11 @@ object UiMessageBus {
key.trim().takeIf(String::isNotEmpty)?.let { _events.tryEmit(UiMessageEvent.Clear(it)) }
}
fun clearAll() { _events.tryEmit(UiMessageEvent.ClearAll) }
fun warning(text: String, ttlMillis: Long = STATUS_TTL_MS) = post(text, UiMessageSeverity.Warning, ttlMillis)
fun error(text: String, ttlMillis: Long = 10_000L) = post(text, UiMessageSeverity.Error, ttlMillis)
/** Neutral confirmation/info (e.g. "Pairing code copied"). */
fun info(text: String, ttlMillis: Long = DEFAULT_TTL_MS) =
post(text, UiMessageSeverity.Info, ttlMillis)
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -209,7 +209,7 @@ fun ActiveCardRelayStatusSection(
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
// Pre-resolve strings for Toast (non-composable context)
// Pre-resolve strings for action callbacks (non-composable context).
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
val relayStatusText = when (relayRowState.phase) {
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
@@ -250,11 +250,7 @@ fun ActiveCardRelayStatusSection(
onClick = {
if (relayUiState == RelayUiState.Stale) {
connectionViewModel.reconnectIfStale()
Toast.makeText(
context,
reconnectingRelayToast,
Toast.LENGTH_SHORT,
).show()
UiMessageBus.status(reconnectingRelayToast)
} else {
onOpenRelayInfo()
}
@@ -817,7 +813,7 @@ private fun ManualUrlSubsection(
}
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
// Pre-resolve strings for Toast (non-composable context)
// Pre-resolve strings for action callbacks (non-composable context).
val apiHermesVoiceReachableToast = stringResource(R.string.active_section_api_hermes_voice_reachable)
val apiRelayVoiceReachableToast = stringResource(R.string.active_section_api_relay_voice_reachable)
val apiReachableVoiceReviewToast = stringResource(R.string.active_section_api_reachable_voice_review)
@@ -926,21 +922,21 @@ private fun ManualUrlSubsection(
relayOverrideVisible = true
result.relayUrl?.let { relayUrlInput = it }
}
Toast.makeText(
context,
when {
result.apiReachable && result.voiceConfigReachable ->
if (result.voiceRoute == "standard") {
apiHermesVoiceReachableToast
} else {
apiRelayVoiceReachableToast
}
result.apiReachable ->
apiReachableVoiceReviewToast
else -> cannotReachApiToast
},
Toast.LENGTH_SHORT,
).show()
val feedback = when {
result.apiReachable && result.voiceConfigReachable ->
if (result.voiceRoute == "standard") {
apiHermesVoiceReachableToast
} else {
apiRelayVoiceReachableToast
}
result.apiReachable -> apiReachableVoiceReviewToast
else -> cannotReachApiToast
}
when {
result.apiReachable && result.voiceConfigReachable -> UiMessageBus.success(feedback)
result.apiReachable -> UiMessageBus.warning(feedback)
else -> UiMessageBus.error(feedback)
}
}
},
enabled = apiUrlInput.isNotBlank() && !isTestingApi && inputApiKeyError == null,
@@ -9,7 +9,7 @@ import android.media.audiofx.Visualizer
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.annotation.OptIn
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -312,110 +312,112 @@ fun AttachmentViewer(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
MessageOverlayScope {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val blurMode = LocalMediaBlurMode.current
var revealed by remember(attachment.cachedUri, attachment.relayToken) {
mutableStateOf(initiallyRevealed)
}
val blurred = !revealed &&
attachment.renderMode == AttachmentRenderMode.IMAGE &&
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
// --- One shared Share / Save / Open-externally action set ----------
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
scope.launch {
busy = true
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
busy = false
viewerToast(context, context.getString(R.string.inbound_attach_share_failed))
return@launch
}
action(bytes)
busy = false
val blurMode = LocalMediaBlurMode.current
var revealed by remember(attachment.cachedUri, attachment.relayToken) {
mutableStateOf(initiallyRevealed)
}
}
val blurred = !revealed &&
attachment.renderMode == AttachmentRenderMode.IMAGE &&
shouldBlurImage(blurMode, attachment.sensitive)
val onShare = {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
}
val onSave = {
runWithBytes { bytes ->
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
MediaSaver.saveImage(context, bytes, attachment.fileName, attachment.contentType)
} else {
MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)
}
when (result) {
is MediaSaver.SaveResult.Saved ->
viewerToast(context, context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
// --- One shared Share / Save / Open-externally action set ----------
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
scope.launch {
busy = true
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
busy = false
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
return@launch
}
is MediaSaver.SaveResult.Failed ->
viewerToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
action(bytes)
busy = false
}
}
}
val onOpenExternal = {
val cached = attachment.cachedUri
if (!cached.isNullOrBlank()) {
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
} else {
val onShare = {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.open(context, uri, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f))
.testTag("attachment-viewer"),
) {
// Body fills; toolbar floats on top. PDF/TEXT add their own top
// inset so the first line clears the toolbar.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (attachment.renderMode) {
AttachmentRenderMode.IMAGE -> ImageBody(
attachment = attachment,
blurred = blurred,
onReveal = { revealed = true },
)
AttachmentRenderMode.VIDEO -> VideoBody(attachment)
AttachmentRenderMode.AUDIO -> AudioBody(attachment)
AttachmentRenderMode.PDF -> PdfBody(attachment)
AttachmentRenderMode.TEXT -> TextBody(attachment)
AttachmentRenderMode.GENERIC -> GenericBody(attachment, onOpenExternal)
val onSave = {
runWithBytes { bytes ->
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
MediaSaver.saveImage(context, bytes, attachment.fileName, attachment.contentType)
} else {
MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)
}
when (result) {
is MediaSaver.SaveResult.Saved ->
UiMessageBus.success(context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
UiMessageBus.error(context.getString(R.string.inbound_attach_save_failed, result.message))
}
}
}
val onOpenExternal = {
val cached = attachment.cachedUri
if (!cached.isNullOrBlank()) {
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
} else {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.open(context, uri, attachment.contentType)
}
}
}
MediaViewerToolbar(
title = title,
busy = busy,
actionsEnabled = !blurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f))
.testTag("attachment-viewer"),
) {
// Body fills; toolbar floats on top. PDF/TEXT add their own top
// inset so the first line clears the toolbar.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (attachment.renderMode) {
AttachmentRenderMode.IMAGE -> ImageBody(
attachment = attachment,
blurred = blurred,
onReveal = { revealed = true },
)
AttachmentRenderMode.VIDEO -> VideoBody(attachment)
AttachmentRenderMode.AUDIO -> AudioBody(attachment)
AttachmentRenderMode.PDF -> PdfBody(attachment)
AttachmentRenderMode.TEXT -> TextBody(attachment)
AttachmentRenderMode.GENERIC -> GenericBody(attachment, onOpenExternal)
}
}
MediaViewerToolbar(
title = title,
busy = busy,
actionsEnabled = !blurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
}
}
@@ -452,164 +454,165 @@ internal fun AttachmentGalleryViewer(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val revealed = remember { mutableStateMapOf<String, Boolean>() }
LaunchedEffect(initiallyRevealedKeys) {
initiallyRevealedKeys.forEach { revealed[it] = true }
}
val pagerState = rememberPagerState(
initialPage = initialIndex.coerceIn(attachments.indices),
pageCount = { attachments.size },
)
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
val attachment = attachments[currentIndex]
val currentKey = galleryAttachmentKey(attachment, currentIndex)
val blurMode = LocalMediaBlurMode.current
val currentBlurred = revealed[currentKey] != true &&
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
// Capture the currently visible attachment in each click lambda. A
// swipe while IO is running must not redirect Save/Share to a new page.
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
if (currentBlurred || busy) return
val target = attachment
scope.launch {
busy = true
try {
val bytes = attachmentBytes(context, target)
if (bytes == null) {
viewerToast(context, "Couldn't read this image")
return@launch
}
action(target, bytes)
} catch (error: Exception) {
viewerToast(
context,
error.message?.takeIf { it.isNotBlank() }
?: "Couldn't complete that image action",
)
} finally {
busy = false
}
MessageOverlayScope {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val revealed = remember { mutableStateMapOf<String, Boolean>() }
LaunchedEffect(initiallyRevealedKeys) {
initiallyRevealedKeys.forEach { revealed[it] = true }
}
}
val pagerState = rememberPagerState(
initialPage = initialIndex.coerceIn(attachments.indices),
pageCount = { attachments.size },
)
val onShare = {
runWithBytes { target, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
)
MediaSaver.share(context, uri, target.contentType)
}
}
val onSave = {
runWithBytes { target, bytes ->
when (val result = MediaSaver.saveImage(
context,
bytes,
target.fileName,
target.contentType,
)) {
is MediaSaver.SaveResult.Saved ->
viewerToast(context, "Saved to ${result.location}")
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
val attachment = attachments[currentIndex]
val currentKey = galleryAttachmentKey(attachment, currentIndex)
val blurMode = LocalMediaBlurMode.current
val currentBlurred = revealed[currentKey] != true &&
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
// Capture the currently visible attachment in each click lambda. A
// swipe while IO is running must not redirect Save/Share to a new page.
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
if (currentBlurred || busy) return
val target = attachment
scope.launch {
busy = true
try {
val bytes = attachmentBytes(context, target)
if (bytes == null) {
UiMessageBus.error("Couldn't read this image")
return@launch
}
action(target, bytes)
} catch (error: Exception) {
UiMessageBus.error(
error.message?.takeIf { it.isNotBlank() }
?: "Couldn't complete that image action",
)
MediaSaver.share(context, uri, target.contentType)
} finally {
busy = false
}
is MediaSaver.SaveResult.Failed ->
viewerToast(context, "Save failed: ${result.message}")
}
}
}
val onOpenExternal: () -> Unit = openExternal@{
if (currentBlurred || busy) return@openExternal
val target = attachment
val cached = target.cachedUri
if (!cached.isNullOrBlank()) {
runCatching {
MediaSaver.open(context, Uri.parse(cached), target.contentType)
}.onFailure {
viewerToast(context, "Couldn't open this image")
}
} else {
runWithBytes { item, bytes ->
val onShare = {
runWithBytes { target, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
item.fileName,
item.contentType,
target.fileName,
target.contentType,
)
MediaSaver.open(context, uri, item.contentType)
MediaSaver.share(context, uri, target.contentType)
}
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f)),
) {
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 0,
pageSpacing = 12.dp,
modifier = Modifier
.fillMaxSize()
.testTag("attachment-gallery-pager"),
) { page ->
val pageAttachment = attachments[page]
val pageKey = galleryAttachmentKey(pageAttachment, page)
val blurred = revealed[pageKey] != true && shouldBlurImage(
blurMode,
pageAttachment.sensitive,
)
ImageBody(
attachment = pageAttachment,
blurred = blurred,
onReveal = { revealed[pageKey] = true },
)
val onSave = {
runWithBytes { target, bytes ->
when (val result = MediaSaver.saveImage(
context,
bytes,
target.fileName,
target.contentType,
)) {
is MediaSaver.SaveResult.Saved ->
UiMessageBus.success("Saved to ${result.location}")
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
)
MediaSaver.share(context, uri, target.contentType)
}
is MediaSaver.SaveResult.Failed ->
UiMessageBus.error("Save failed: ${result.message}")
}
}
}
val onOpenExternal: () -> Unit = openExternal@{
if (currentBlurred || busy) return@openExternal
val target = attachment
val cached = target.cachedUri
if (!cached.isNullOrBlank()) {
runCatching {
MediaSaver.open(context, Uri.parse(cached), target.contentType)
}.onFailure {
UiMessageBus.error("Couldn't open this image")
}
} else {
runWithBytes { item, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
item.fileName,
item.contentType,
)
MediaSaver.open(context, uri, item.contentType)
}
}
}
MediaViewerToolbar(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f)),
) {
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 0,
pageSpacing = 12.dp,
modifier = Modifier
.fillMaxSize()
.testTag("attachment-gallery-pager"),
) { page ->
val pageAttachment = attachments[page]
val pageKey = galleryAttachmentKey(pageAttachment, page)
val blurred = revealed[pageKey] != true && shouldBlurImage(
blurMode,
pageAttachment.sensitive,
)
ImageBody(
attachment = pageAttachment,
blurred = blurred,
onReveal = { revealed[pageKey] = true },
)
}
Text(
text = "${currentIndex + 1} / ${attachments.size}",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 12.dp, vertical = 6.dp),
)
MediaViewerToolbar(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Text(
text = "${currentIndex + 1} / ${attachments.size}",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
}
@@ -1191,10 +1194,6 @@ private fun rememberPlayableUri(attachment: Attachment): Uri? {
return uri
}
private fun viewerToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
private const val MAX_TEXT_BYTES = 2 * 1024 * 1024
// --- Non-composable IO helpers ---------------------------------------------
@@ -0,0 +1,118 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
/** Stable transcript entry into the same read-only preview used by active work. */
@Composable
internal fun ChatActivityReceipt(
record: ChatActivityRecord,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val subagents = record.kind == ChatActivityKind.SUBAGENTS
val title = stringResource(
if (subagents) R.string.chat_activity_receipt_subagents else R.string.chat_activity_receipt_process,
)
val action = stringResource(
if (subagents) R.string.chat_activity_receipt_view_activity else R.string.chat_activity_receipt_view_output,
)
val status = if (subagents && record.children.isNotEmpty()) {
val groups = record.children.groupingBy { it.phase }.eachCount().toMutableMap()
val missing = (record.taskCount - record.children.size).coerceAtLeast(0)
if (missing > 0) groups[ChatActivityPhase.UNKNOWN] = (groups[ChatActivityPhase.UNKNOWN] ?: 0) + missing
val labels = groups.map { (phase, count) ->
stringResource(R.string.chat_activity_receipt_count_phase, count, activityPhaseLabel(phase))
}
labels.joinToString(" · ")
} else if (subagents && record.taskCount > 0) {
stringResource(R.string.chat_activity_receipt_count_phase, record.taskCount, activityPhaseLabel(record.phase))
} else {
activityPhaseLabel(record.phase)
}
Surface(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.semantics(mergeDescendants = true) { stateDescription = status }
.clickable(role = Role.Button, onClickLabel = action, onClick = onClick),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (subagents) Icons.Filled.AccountTree else Icons.Filled.Terminal,
contentDescription = null,
tint = if (record.phase == ChatActivityPhase.FAILED) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "$title · $status",
style = MaterialTheme.typography.labelMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = action,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
@Composable
private fun activityPhaseLabel(phase: ChatActivityPhase): String = stringResource(
when (phase) {
ChatActivityPhase.RUNNING -> R.string.bg_processes_running
ChatActivityPhase.COMPLETE -> R.string.agent_activity_status_completed
ChatActivityPhase.FAILED -> R.string.agent_activity_status_failed
ChatActivityPhase.CANCELLED -> R.string.task_status_cancelled
ChatActivityPhase.UNKNOWN -> R.string.agent_activity_status_unavailable
},
)
@@ -0,0 +1,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()
}
@@ -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),
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -20,10 +22,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.viewmodel.ConnectionStepState
data class ConnectionSetupTimelineStep(
val title: String,
val detail: String,
val state: ConnectionStepState = ConnectionStepState.Done,
)
/** Compact completed-state timeline shared by connection and auth flows. */
@@ -43,13 +47,21 @@ fun ConnectionSetupTimeline(
Box(
modifier = Modifier
.size(28.dp)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
.background(when (step.state) {
ConnectionStepState.Failed -> MaterialTheme.colorScheme.errorContainer
ConnectionStepState.Pending -> MaterialTheme.colorScheme.surfaceVariant
else -> MaterialTheme.colorScheme.primaryContainer
}, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Check,
if (step.state == ConnectionStepState.Active) {
CircularProgressIndicator(Modifier.size(17.dp), strokeWidth = 2.dp)
} else if (step.state == ConnectionStepState.Pending) {
Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else Icon(
imageVector = if (step.state == ConnectionStepState.Failed) Icons.Filled.Close else Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
tint = if (step.state == ConnectionStepState.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(17.dp),
)
}
@@ -6,7 +6,7 @@ import android.Manifest
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.annotation.StringRes
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -343,11 +343,7 @@ fun ConnectionWizard(
// user at the manual pairing paths (URL entry / 6-char code)
// instead of leaving them on a vanishing toast with no scanner.
step = WizardStep.Nearby
Toast.makeText(
context,
context.getString(R.string.cw_camera_denied),
Toast.LENGTH_LONG
).show()
UiMessageBus.warning(context.getString(R.string.cw_camera_denied))
}
}
@@ -1,7 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -104,120 +103,119 @@ private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit)
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.WarningAmber,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
MessageOverlayScope {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.WarningAmber,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.crash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.crash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
text = stringResource(R.string.crash_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.crash_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.crash_privacy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = stringResource(R.string.crash_privacy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
if (showDetails) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
if (showDetails) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
}
}
Spacer(Modifier.height(18.dp))
// FlowRow so the actions wrap instead of clipping on narrow /
// foldable cover screens now that a fourth (Share) action exists.
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
if (!showDetails) {
Button(onClick = { showDetails = true }) {
Text(stringResource(R.string.crash_review))
}
} else {
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, copiedMessage)
},
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
// until they choose to send it — same privacy posture as Copy.
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
reportSubject,
reportText,
chooserTitle = shareTitle,
)
if (!shared) {
Spacer(Modifier.height(18.dp))
// FlowRow so the actions wrap instead of clipping on narrow /
// foldable cover screens now that a fourth (Share) action exists.
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
if (!showDetails) {
Button(onClick = { showDetails = true }) {
Text(stringResource(R.string.crash_review))
}
} else {
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, noShareMessage)
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
IssueReport.copyToClipboard(context, reportText)
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
toast(context, if (opened) openedMessage else noBrowserMessage)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
UiMessageBus.success(copiedMessage)
},
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
// until they choose to send it — same privacy posture as Copy.
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
reportSubject,
reportText,
chooserTitle = shareTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, reportText)
UiMessageBus.warning(noShareMessage)
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
IssueReport.copyToClipboard(context, reportText)
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
if (opened) UiMessageBus.success(openedMessage)
else UiMessageBus.warning(noBrowserMessage)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
}
}
}
}
}
}
}
private fun toast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
@@ -1,7 +1,7 @@
package com.hermesandroid.relay.ui.components
import android.text.format.DateFormat
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -78,159 +78,158 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
DiagnosticSeverityChip(entry.severity)
Spacer(Modifier.width(10.dp))
Text(
text = entry.category.label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
MessageOverlayScope {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
DiagnosticSeverityChip(entry.severity)
Spacer(Modifier.width(10.dp))
Text(
text = entry.category.label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(10.dp))
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(10.dp))
// Metadata rows — only render the ones that are present.
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
MetaRow("Severity", severityName)
MetaRow("Category", entry.category.label)
entry.operation?.let { MetaRow("Operation", it) }
entry.endpointRole?.let { MetaRow("Route", it) }
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
entry.requestUrl?.let { MetaRow("Request", it) }
if (entry.configuredUrl == null && entry.requestUrl == null) {
entry.url?.let { MetaRow("URL", it) }
}
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
entry.suggestion?.let { suggestion ->
Spacer(Modifier.height(10.dp))
Text(
text = "Suggested next step",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = entry.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(14.dp))
val body = entry.stacktrace ?: entry.detail
if (body != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 320.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
) {
SelectionContainer {
Text(
text = body,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
Spacer(Modifier.height(10.dp))
// Metadata rows — only render the ones that are present.
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
MetaRow("Severity", severityName)
MetaRow("Category", entry.category.label)
entry.operation?.let { MetaRow("Operation", it) }
entry.endpointRole?.let { MetaRow("Route", it) }
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
entry.requestUrl?.let { MetaRow("Request", it) }
if (entry.configuredUrl == null && entry.requestUrl == null) {
entry.url?.let { MetaRow("URL", it) }
}
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
entry.suggestion?.let { suggestion ->
Spacer(Modifier.height(10.dp))
Text(
text = "Suggested next step",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
} else {
Text(
text = stringResource(R.string.diagnostic_no_detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (expectationVisible) {
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = expectation,
onValueChange = { expectation = it },
label = { Text("What were you expecting to happen?") },
supportingText = {
Text(stringResource(R.string.diagnostic_routine_hint))
},
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, plainText)
toast(context, copiedToast)
},
) { Text(stringResource(R.string.common_copy)) }
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
subject = "Hermes-Relay diagnostic — ${entry.title}",
text = plainText,
chooserTitle = exportChooserTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, plainText)
toast(context, "Copied — no app found to share to")
}
},
) { Text(stringResource(R.string.common_export)) }
Button(
enabled = !expectationVisible || expectation.isNotBlank(),
onClick = {
if (needsExpectation && !expectationVisible) {
expectationVisible = true
return@Button
}
// Copy full text first; the GitHub URL only carries the
// head of long traces, so the user can paste the rest.
IssueReport.copyToClipboard(context, plainText)
val opened = IssueReport.openUrl(
context,
IssueReport.buildGithubIssueUrl(
title = DiagnosticIssuePrefill.issueTitle(entry),
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
entry,
expectation = expectation.takeIf { expectationVisible },
),
labels = DiagnosticIssuePrefill.issueLabels(entry),
val body = entry.stacktrace ?: entry.detail
if (body != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 320.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
)
toast(
context,
if (opened) "Full diagnostic copied — paste it into the issue if truncated"
else "Copied — no browser found to open GitHub",
)
},
) { Text(stringResource(R.string.common_report)) }
) {
SelectionContainer {
Text(
text = body,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
} else {
Text(
text = stringResource(R.string.diagnostic_no_detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (expectationVisible) {
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = expectation,
onValueChange = { expectation = it },
label = { Text("What were you expecting to happen?") },
supportingText = {
Text(stringResource(R.string.diagnostic_routine_hint))
},
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, plainText)
UiMessageBus.success(copiedToast)
},
) { Text(stringResource(R.string.common_copy)) }
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
subject = "Hermes-Relay diagnostic — ${entry.title}",
text = plainText,
chooserTitle = exportChooserTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, plainText)
UiMessageBus.warning("Copied — no app found to share to")
}
},
) { Text(stringResource(R.string.common_export)) }
Button(
enabled = !expectationVisible || expectation.isNotBlank(),
onClick = {
if (needsExpectation && !expectationVisible) {
expectationVisible = true
return@Button
}
// Copy full text first; the GitHub URL only carries the
// head of long traces, so the user can paste the rest.
IssueReport.copyToClipboard(context, plainText)
val opened = IssueReport.openUrl(
context,
IssueReport.buildGithubIssueUrl(
title = DiagnosticIssuePrefill.issueTitle(entry),
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
entry,
expectation = expectation.takeIf { expectationVisible },
),
labels = DiagnosticIssuePrefill.issueLabels(entry),
),
)
if (opened) UiMessageBus.success("Full diagnostic copied — paste it into the issue if truncated")
else UiMessageBus.warning("Copied — no browser found to open GitHub")
},
) { Text(stringResource(R.string.common_report)) }
}
}
}
}
@@ -276,10 +275,6 @@ internal fun DiagnosticSeverityChip(severity: DiagnosticSeverity) {
}
}
private fun toast(context: android.content.Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
/** Full, copy/share-ready plain-text rendering of a single diagnostic entry. */
private fun DiagnosticLogEntry.toPlainText(): String = buildString {
appendLine("Hermes-Relay diagnostic")
@@ -24,12 +24,12 @@ import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material.icons.filled.PauseCircleOutline
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.PauseCircleOutline
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -66,7 +66,6 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.GatewayProcess
import com.hermesandroid.relay.viewmodel.SubagentActivity
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
import kotlinx.coroutines.launch
@@ -84,27 +83,18 @@ internal fun GatewayBackgroundProcessStrip(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Initial/switch refreshes are silent. The strip appears only after the
// session actually owns a process, avoiding a transient "Checking" row on
// every ordinary chat open.
// Completed work remains reachable from its transcript receipt. Only active
// work owns composer space; refreshing history must not resurrect the strip.
val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty()
if (processes.isEmpty() && visibleActivities.isEmpty()) return
val running = processes.count { it.isRunning }
val runningAgents = visibleActivities.count { !it.isTerminal }
val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
val failedAgents = visibleActivities.count { it.phase == SubagentActivityPhase.FAILED }
val interruptedAgents = visibleActivities.count {
it.phase == SubagentActivityPhase.INTERRUPTED ||
it.phase == SubagentActivityPhase.ENDED_WITH_PARENT
}
val failureCount = failed + failedAgents
if (running == 0 && runningAgents == 0) return
val status = when {
runningAgents > 0 && running > 0 -> stringResource(
R.string.current_chat_activity_summary, runningAgents, running,
)
runningAgents > 0 -> stringResource(R.string.subagent_lane_running_count, runningAgents)
running > 0 -> "$running ${stringResource(R.string.bg_processes_running)}"
failureCount > 0 -> "$failureCount ${stringResource(R.string.task_status_failed)}"
interruptedAgents > 0 -> stringResource(R.string.agent_activity_status_interrupted)
else -> stringResource(R.string.task_status_complete)
else -> "$running ${stringResource(R.string.bg_processes_running)}"
}
val openDescription = stringResource(R.string.current_chat_activity_open)
@@ -130,24 +120,7 @@ internal fun GatewayBackgroundProcessStrip(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (running > 0 || runningAgents > 0 || loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Icon(
imageVector = when {
failureCount > 0 -> Icons.Filled.ErrorOutline
interruptedAgents > 0 -> Icons.Filled.PauseCircleOutline
else -> Icons.Filled.CheckCircle
},
contentDescription = null,
modifier = Modifier.size(17.dp),
tint = when {
failureCount > 0 -> MaterialTheme.colorScheme.error
interruptedAgents > 0 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
},
)
}
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(9.dp))
Text(
text = stringResource(R.string.current_chat_activity_title),
@@ -157,11 +130,7 @@ internal fun GatewayBackgroundProcessStrip(
Text(
text = status,
style = MaterialTheme.typography.labelMedium,
color = if (failureCount > 0 && running == 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = Icons.Filled.Visibility,
@@ -190,6 +159,8 @@ internal fun GatewayBackgroundProcessSheet(
onDismissProcess: (String) -> Unit,
onOpenSubagentChild: (String) -> Unit,
onDismiss: () -> Unit,
readOnlyHistory: Boolean = false,
historyNotice: String? = null,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
@@ -246,17 +217,19 @@ internal fun GatewayBackgroundProcessSheet(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.current_chat_activity_title),
stringResource(
if (readOnlyHistory) R.string.chat_activity_history_title else R.string.current_chat_activity_title,
),
modifier = Modifier.semantics { heading() },
style = MaterialTheme.typography.titleLarge,
)
Text(
stringResource(R.string.current_chat_activity_subtitle),
historyNotice ?: stringResource(R.string.current_chat_activity_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (processes.isNotEmpty()) IconButton(onClick = onRefresh, enabled = !loading) {
if (processes.isNotEmpty() && !readOnlyHistory) IconButton(onClick = onRefresh, enabled = !loading) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
@@ -300,7 +273,9 @@ internal fun GatewayBackgroundProcessSheet(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(R.string.current_chat_activity_empty),
stringResource(
if (readOnlyHistory) R.string.chat_activity_history_empty else R.string.current_chat_activity_empty,
),
modifier = Modifier.padding(top = 12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -341,7 +316,7 @@ internal fun GatewayBackgroundProcessSheet(
GatewayProcessRow(
process = process,
stopping = process.id in stoppingProcessIds,
onStop = { onStop(process.id) },
onStop = if (readOnlyHistory) null else ({ onStop(process.id) }),
onDismiss = null,
)
}
@@ -356,7 +331,7 @@ internal fun GatewayBackgroundProcessSheet(
process = process,
stopping = false,
onStop = null,
onDismiss = { onDismissProcess(process.id) },
onDismiss = if (readOnlyHistory) null else ({ onDismissProcess(process.id) }),
)
}
}
@@ -384,7 +359,7 @@ private fun GatewayProcessRow(
onDismiss: (() -> Unit)?,
) {
var expanded by remember(process.id) { mutableStateOf(false) }
val failed = !process.isRunning && (process.exitCode ?: 0) != 0
val failed = processDisplayPhase(process) == ProcessDisplayPhase.FAILED
val output = sanitizeTerminalText(
process.outputTail.orEmpty().ifBlank { process.outputPreview.orEmpty() },
).trimEnd()
@@ -478,9 +453,11 @@ private fun GatewayProcessRow(
@Composable
private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping: Boolean) {
val phase = processDisplayPhase(process)
val tint: Color = when {
failed -> MaterialTheme.colorScheme.error
process.isRunning || stopping -> MaterialTheme.colorScheme.primary
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.tertiary
}
Surface(
@@ -501,6 +478,14 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
modifier = Modifier.size(18.dp),
tint = tint,
)
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> Icon(
imageVector = if (phase == ProcessDisplayPhase.UNKNOWN) Icons.Filled.HelpOutline else Icons.Filled.PauseCircleOutline,
contentDescription = stringResource(
if (phase == ProcessDisplayPhase.UNKNOWN) R.string.agent_activity_status_unavailable else R.string.task_status_cancelled,
),
modifier = Modifier.size(18.dp),
tint = tint,
)
else -> Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.tool_completed_a11y),
@@ -514,8 +499,11 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
@Composable
private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
val phase = processDisplayPhase(process)
val state = when {
process.isRunning -> stringResource(R.string.bg_processes_running)
phase == ProcessDisplayPhase.UNKNOWN -> stringResource(R.string.agent_activity_status_unavailable)
phase == ProcessDisplayPhase.CANCELLED -> stringResource(R.string.task_status_cancelled)
failed -> stringResource(R.string.task_status_failed) +
process.exitCode?.let { " · exit $it" }.orEmpty()
else -> stringResource(R.string.task_status_complete) +
@@ -525,6 +513,19 @@ private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
if (process.detached) " · recovered" else ""
}
internal enum class ProcessDisplayPhase { RUNNING, COMPLETE, FAILED, CANCELLED, UNKNOWN }
/** A recovered or unfamiliar state is not evidence of successful completion. */
internal fun processDisplayPhase(process: GatewayProcess): ProcessDisplayPhase = when {
process.isRunning -> ProcessDisplayPhase.RUNNING
process.status.equals("unknown", ignoreCase = true) -> ProcessDisplayPhase.UNKNOWN
process.status.equals("cancelled", ignoreCase = true) ||
process.status.equals("canceled", ignoreCase = true) -> ProcessDisplayPhase.CANCELLED
process.status.equals("failed", ignoreCase = true) || (process.exitCode ?: 0) != 0 -> ProcessDisplayPhase.FAILED
process.exitCode == 0 || process.status.lowercase() in setOf("complete", "completed", "exited") -> ProcessDisplayPhase.COMPLETE
else -> ProcessDisplayPhase.UNKNOWN
}
private val ansiTerminalEscape = Regex(
"\u001B(?:\\].*?(?:\u0007|\u001B\\\\)|\\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~])",
RegexOption.DOT_MATCHES_ALL,
@@ -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,
@@ -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
@@ -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
@@ -870,9 +878,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 +951,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 +981,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()
@@ -1199,7 +1220,10 @@ fun ChatScreen(
// 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 +1394,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 +1436,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 +1455,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
}
@@ -1799,7 +1822,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 +1834,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 = {
@@ -2613,11 +2632,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 +2778,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 +2822,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 +2899,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(
@@ -3669,7 +3694,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 +3944,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 +4082,7 @@ fun ChatScreen(
}
// Copy feedback snackbar
SnackbarHost(
ThemedMessageHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -4061,7 +4097,7 @@ fun ChatScreen(
subagentActivities = subagentActivities,
subagentPreviewVisibility = subagentPreviewVisibility,
loading = backgroundProcessesLoading,
onClick = { showBackgroundProcesses = true },
onClick = { chatViewModel.openCurrentActivityPreview(); showBackgroundProcesses = true },
)
}
@@ -4569,17 +4605,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 +4618,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 +4842,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 +5005,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 +5015,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 +5559,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
@@ -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
@@ -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),
@@ -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,
)
}
@@ -94,6 +94,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 +2239,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 +2263,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 +2288,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 +2746,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 +2794,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 +2906,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 {
@@ -2969,7 +3051,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 +3209,6 @@ class ChatViewModel : ViewModel() {
},
onSubagentEvent = { event ->
if (acceptsEvent()) {
subagentActivityController.onEvent(
sessionId = storedSessionId,
eventScopeKey = eventScopeKey,
turnId = messageId,
event = event,
profile = eventProfile,
)
handler.onSubagentEvent(messageId, event)
}
},
@@ -3873,6 +3947,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 +4214,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 +4240,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 +4265,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 +4759,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 +4787,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 +6258,7 @@ class ChatViewModel : ViewModel() {
} else {
client?.deleteSession(sessionId) == true
}
if (success && contextKey != null) chatActivityController.removeSession(contextKey, sessionId)
if (
activeProfileContextKey != contextKey ||
currentSessionProfileName() != profileName
@@ -8121,18 +8221,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)
}
@@ -10664,13 +10752,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 +10912,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 +11451,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 +11460,6 @@ class ChatViewModel : ViewModel() {
errorMessage = human.body
)
}
_errorEvents.tryEmit(human)
}
},
onFailure = { err ->
@@ -11396,7 +11479,6 @@ class ChatViewModel : ViewModel() {
errorMessage = human.body
)
}
_errorEvents.tryEmit(human)
}
)
}
@@ -11661,6 +11743,7 @@ class ChatViewModel : ViewModel() {
gatewayClient?.setColdPrewarmSessionReadyListener(null)
gatewayClient?.setUnmatchedTurnCompleteListener(null)
gatewayClient?.setBackgroundInteractionListener(null)
gatewayClient?.setSubagentEventListener(null)
backgroundPendingInteractions.clear()
backgroundNeedsInputKeys.clear()
publishBackgroundSessionActivity()
@@ -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
@@ -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() {
@@ -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)
}
/**
@@ -4429,4 +4429,14 @@
<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>
</resources>
@@ -4510,4 +4510,14 @@
<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>
</resources>
+10
View File
@@ -4586,4 +4586,14 @@
<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>
</resources>
+10
View File
@@ -4277,4 +4277,14 @@
<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>
</resources>
+10
View File
@@ -4581,4 +4581,14 @@
<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>
</resources>
+10
View File
@@ -4325,4 +4325,14 @@
<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>
</resources>
+52
View File
@@ -1,5 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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>
@@ -4602,4 +4630,28 @@
<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>
</resources>
@@ -0,0 +1,207 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatActivityProjectionTest {
private fun record(
id: String = "record",
sourceId: String = "delegation-a",
phase: ChatActivityPhase = ChatActivityPhase.COMPLETE,
at: Long = 20,
) = ChatActivityRecord(
id, "connection::profile", "session", ChatActivityKind.SUBAGENTS,
sourceId, "Research", phase, 10, at,
children = listOf(ChatActivityChild("child-a", "child-session", phase = phase)),
taskCount = 1,
)
private fun message(id: String, at: Long = 30) =
ChatMessage(id, MessageRole.ASSISTANT, "Reply $id", at)
private fun completion(id: String = "server-row", source: String? = "delegation:delegation-a") =
message(id).copy(
role = MessageRole.SYSTEM,
content = "1 background task completed",
activitySourceId = source,
activityTaskCount = 1,
)
private fun project(messages: List<ChatMessage>, records: List<ChatActivityRecord>) =
projectChatActivityReceipts(messages, records, "connection::profile", "session")
@Test
fun `canonical completion preserves identity and original payload`() {
val row = completion().copy(uiKey = "live-key", rowId = 77)
val result = project(listOf(row), listOf(record())).single()
assertEquals(row, result.copy(activityRecord = null))
assertEquals("record", result.activityRecord?.id)
}
@Test
fun `completion wake replaces synthetic receipt without dropping ordinary messages`() {
val before = project(listOf(message("first", 1)), listOf(record()))
assertEquals(listOf("first", "activity:record"), before.map { it.id })
val after = project(before + completion() + message("wake", 40), listOf(record()))
assertEquals(listOf("first", "server-row", "wake"), after.map { it.id })
assertEquals(after, project(after, listOf(record())))
}
@Test
fun `duplicate group completion rows retain separate identities and live sibling state`() {
val running = record(phase = ChatActivityPhase.RUNNING).copy(
children = listOf(
ChatActivityChild("done", phase = ChatActivityPhase.COMPLETE),
ChatActivityChild("working", phase = ChatActivityPhase.RUNNING),
),
taskCount = 2,
)
val result = project(listOf(completion("group-one"), completion("group-two")), listOf(running))
assertEquals(listOf("group-one", "group-two"), result.map { it.id })
result.forEach {
assertEquals(ChatActivityPhase.RUNNING, it.activityRecord?.phase)
assertEquals(ChatActivityPhase.RUNNING, it.activityRecord?.children?.last()?.phase)
}
}
@Test
fun `missing and unknown delegation IDs never select archive by count or recency`() {
listOf("unavailable:server-row", "delegation:", "delegation:other").forEach { source ->
val result = project(listOf(completion(source = source)), listOf(record()))
val fallback = result.single { it.id == "server-row" }.activityRecord!!
assertTrue(fallback.children.isEmpty())
assertEquals("canonical:server-row", fallback.id)
assertEquals(1, fallback.taskCount)
assertEquals(2, result.size)
}
}
@Test
fun `incomplete metadata exposes unknown detail without fabricated task count`() {
val row = completion(source = "unavailable:server-row").copy(activityTaskCount = null)
val fallback = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(ChatActivityPhase.UNKNOWN, fallback.phase)
assertEquals(0, fallback.taskCount)
assertTrue(fallback.children.isEmpty())
}
@Test
fun `partial failure count never labels every missing child failed`() {
val row = completion().copy(activityTaskCount = 3, activityFailedCount = 1)
val result = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(ChatActivityPhase.UNKNOWN, result.phase)
assertEquals(3, result.taskCount)
assertTrue(result.children.isEmpty())
}
@Test
fun `archive ownership excludes sibling profiles and sessions`() {
val result = project(
listOf(completion()),
listOf(record().copy(scopeKey = "connection::other"), record().copy(sessionId = "other")),
)
assertEquals(1, result.size)
assertTrue(result.single().activityRecord!!.children.isEmpty())
}
@Test
fun `same snapshot identity chooses newest and restored unknown remains unknown`() {
val result = project(emptyList(), listOf(
record(phase = ChatActivityPhase.COMPLETE, at = 15),
record(phase = ChatActivityPhase.UNKNOWN, at = 20),
)).single()
assertEquals(ChatActivityPhase.UNKNOWN, result.activityRecord?.phase)
assertEquals(ChatActivityPhase.UNKNOWN, result.activityRecord?.children?.single()?.phase)
}
@Test
fun `running archive has no synthetic receipt and timestamp merge preserves history order`() {
val rows = listOf(message("first", 40), message("second", 5), message("third", 50))
val result = project(rows, listOf(record(), record(id = "running", phase = ChatActivityPhase.RUNNING)))
assertEquals(listOf("activity:record", "first", "second", "third"), result.map { it.id })
assertEquals(rows, result.filter { !it.clientOnly })
}
private fun processRow() = message("process-row").copy(
role = MessageRole.USER,
content = "[IMPORTANT: Background process proc-1 completed normally (exit code 0).\nCommand: build\nOutput:\nOK]",
)
private fun processRecord(id: String, startedAt: String) = record(id, "proc-1").copy(
kind = ChatActivityKind.PROCESS,
processId = "proc-1",
processStartedAt = startedAt,
children = emptyList(),
)
@Test
fun `exact process ID joins receipt and retains authoritative output in source row`() {
val row = processRow()
val result = project(listOf(row), listOf(processRecord("process", "start-1"))).single()
assertEquals(row, result.copy(activityRecord = null))
assertEquals("process", result.activityRecord?.id)
assertEquals("proc-1", result.activityRecord?.processId)
}
@Test
fun `reused process ID does not choose a generation by timestamps`() {
val result = project(listOf(processRow()), listOf(
processRecord("generation-1", "start-1"),
processRecord("generation-2", "start-2").copy(updatedAt = 25),
))
val fallback = result.single { it.id == "process-row" }.activityRecord!!
assertEquals("canonical:process-row", fallback.id)
assertNull(fallback.processStartedAt)
assertEquals(ChatActivityPhase.COMPLETE, fallback.phase)
assertEquals(0, fallback.exitCode)
}
@Test
fun `canonical process headline supplies terminal outcome without a retained process`() {
listOf(
Triple("completed normally (exit code 0).", ChatActivityPhase.COMPLETE, 0),
Triple("exited (exit code 1).", ChatActivityPhase.FAILED, 1),
Triple("terminated by Hermes (exit code -15, SIGTERM).", ChatActivityPhase.CANCELLED, -15),
).forEach { (status, phase, code) ->
val row = processRow().copy(content = "[IMPORTANT: Background process proc-1 $status\nOutput:\nresult]")
val receipt = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(phase, receipt.phase)
assertEquals(code, receipt.exitCode)
}
}
@Test
fun `watch and malformed headlines cannot borrow exit codes from output`() {
listOf(
"matched watch pattern \"done\".",
"exited (exit code ?).",
"exited (exit code 0). trailing text",
"exited (exit code 99999999999999999999).",
).forEach { status ->
val row = processRow().copy(
content = "[IMPORTANT: Background process proc-1 $status\nOutput:\nBackground process proc-1 completed normally (exit code 0).]",
)
val receipt = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(ChatActivityPhase.UNKNOWN, receipt.phase)
assertNull(receipt.exitCode)
}
}
@Test
fun `ordinary important messages never become activity receipts`() {
val row = message("important").copy(role = MessageRole.USER, content = "[IMPORTANT: Read this carefully]")
assertEquals(listOf(row), project(listOf(row), emptyList()))
}
@Test
fun `missing owner clears previously projected detail and synthetic rows`() {
val projected = project(listOf(completion()), listOf(record()))
val result = projectChatActivityReceipts(projected, listOf(record()), null, "session")
assertEquals(1, result.size)
assertNull(result.single().activityRecord)
assertFalse(result.single().clientOnly)
}
}
@@ -0,0 +1,197 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferencesSerializer
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
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
import org.junit.rules.TemporaryFolder
import okio.Path.Companion.toPath
import okio.FileSystem
class ChatActivityStoreTest {
@get:Rule val tempFolder = TemporaryFolder()
private lateinit var scope: CoroutineScope
private lateinit var dataStore: DataStore<Preferences>
private lateinit var store: DataStoreChatActivityStore
private var now = CHAT_ACTIVITY_MAX_AGE_MS * 2
@Before
fun setUp() {
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Okio performs a real atomic replacement on Windows; File.renameTo cannot replace
// an existing destination there. Android production keeps its shared platform store.
dataStore = PreferenceDataStoreFactory.create(
storage = OkioStorage(FileSystem.SYSTEM, PreferencesSerializer) {
tempFolder.root.resolve("activity.preferences_pb").absolutePath.toPath()
},
scope = scope,
)
store = DataStoreChatActivityStore(dataStore) { now }
}
@After
fun tearDown() = runBlocking { scope.coroutineContext[Job]?.cancelAndJoin(); Unit }
@Test
fun completedMetadataAndReferencesSurviveStoreRecreation() = runTest {
val record = sample().copy(children = listOf(ChatActivityChild(
id = "child", childSessionId = "child-session", goal = "Review", phase = ChatActivityPhase.COMPLETE,
summary = "Reviewed",
)))
store.upsert(record)
val recreated = DataStoreChatActivityStore(dataStore) { now }
assertEquals(listOf(record), recreated.read("scope", "session"))
}
@Test
fun recoveredRunningStateNeverClaimsLiveness() = runTest {
store.upsert(sample().copy(
phase = ChatActivityPhase.RUNNING,
children = listOf(ChatActivityChild("child", phase = ChatActivityPhase.RUNNING)),
))
val recovered = store.read("scope", "session").single()
assertEquals(ChatActivityPhase.UNKNOWN, recovered.phase)
assertEquals(ChatActivityPhase.UNKNOWN, recovered.children.single().phase)
}
@Test
fun exactOwnerKeysDoNotAliasAndRemovalIsScoped() = runTest {
val first = sample().copy(scopeKey = "a::b", sessionId = "c")
val second = sample().copy(scopeKey = "a", sessionId = "b::c")
store.upsert(first)
store.upsert(second)
assertEquals(listOf(first), store.read("a::b", "c"))
store.removeSession("a::b", "c")
assertTrue(store.read("a::b", "c").isEmpty())
assertEquals(listOf(second), store.read("a", "b::c"))
}
@Test
fun concurrentUpsertsPreserveDistinctRecordsAndOlderUpdatesCannotRegressState() = runTest {
coroutineScope { repeat(20) { index -> launch { store.upsert(sample("$index")) } } }
assertEquals(20, store.read("scope", "session").size)
store.upsert(sample("0").copy(updatedAt = now - 1, createdAt = now - 2, phase = ChatActivityPhase.RUNNING))
assertEquals(ChatActivityPhase.COMPLETE, store.read("scope", "session").first { it.id == "0" }.phase)
}
@Test
fun invalidEnvelopeFailsClosedAndMalformedRowsDoNotHideValidSiblings() = runTest {
val key = stringPreferencesKey("chat_activity_records_v1")
for (raw in listOf("{broken", "{\"version\":2,\"records\":[]}", "[]")) {
dataStore.edit { it[key] = raw }
assertTrue(store.read("scope", "session").isEmpty())
}
dataStore.edit { it[key] = "{\"version\":1,\"records\":[{},${Json.encodeToString(sample())}]}" }
assertEquals(listOf(sample()), store.read("scope", "session"))
}
@Test
fun retentionExpiresWithoutRequiringAnotherWrite() = runTest {
store.upsert(sample())
now += CHAT_ACTIVITY_MAX_AGE_MS + 1
assertTrue(store.read("scope", "session").isEmpty())
}
@Test
fun boundedFieldsPreserveExactIdentifiersAndRejectOversizedOwners() = runTest {
store.upsert(sample().copy(title = "t".repeat(500), children = (0..40).map {
ChatActivityChild("child-$it", goal = "g".repeat(2000), summary = "s".repeat(2000))
}))
val record = store.read("scope", "session").single()
assertEquals(160, record.title.length)
assertEquals(32, record.children.size)
assertEquals(512, record.children.first().goal.length)
assertEquals(512, record.children.first().summary?.length)
store.upsert(sample("bad").copy(scopeKey = "x".repeat(2049)))
assertTrue(store.read("x".repeat(2048), "session").isEmpty())
}
@Test
fun totalAndSessionLimitsEvictOldest() {
val session = boundChatActivities((0..40).map {
sample("$it").copy(createdAt = now - 100, updatedAt = now - it)
}, now)
assertEquals(32, session.size)
assertEquals("0", session.first().id)
val total = boundChatActivities((0..150).map {
sample("$it").copy(sessionId = "session-$it", createdAt = now - 200, updatedAt = now - it)
}, now)
assertEquals(128, total.size)
}
@Test
fun inMemoryStoreUsesSameRecoveryAndOwnershipRules() = runTest {
val fake = InMemoryChatActivityStore { now }
fake.upsert(sample().copy(phase = ChatActivityPhase.RUNNING))
assertEquals(ChatActivityPhase.UNKNOWN, fake.read("scope", "session").single().phase)
fake.removeSession("other", "session")
assertEquals(1, fake.read("scope", "session").size)
fake.removeSession("scope", "session")
assertTrue(fake.read("scope", "session").isEmpty())
}
@Test
fun processGenerationReferenceRoundTrips() = runTest {
val process = sample().copy(
kind = ChatActivityKind.PROCESS, processId = "pid-1", processStartedAt = "2026-09-07T12:00:00Z",
exitCode = 0,
)
store.upsert(process)
assertEquals(listOf(process), store.read("scope", "session"))
}
@Test
fun aggregateCountDoesNotInventChildReferencesAndIsBounded() = runTest {
store.upsert(sample().copy(taskCount = 3))
assertEquals(3, store.read("scope", "session").single().taskCount)
assertTrue(store.read("scope", "session").single().children.isEmpty())
store.upsert(sample().copy(taskCount = Int.MAX_VALUE))
assertEquals(10_000, store.read("scope", "session").single().taskCount)
store.upsert(sample().copy(taskCount = -1))
assertEquals(0, store.read("scope", "session").single().taskCount)
}
@Test
fun shortLogicalClockSkewIsAcceptedButFarFutureRecordsAreRejected() = runTest {
store.upsert(sample().copy(updatedAt = now + 1))
assertEquals(now + 1, store.read("scope", "session").single().updatedAt)
store.upsert(sample("future").copy(updatedAt = now + 300_001))
assertEquals(1, store.read("scope", "session").size)
}
@Test
fun removeRecordKeepsSiblingAndSameIdOtherOwner() = runTest {
store.upsert(sample("a"))
store.upsert(sample("b"))
store.upsert(sample("a").copy(scopeKey = "other"))
store.removeRecord("scope", "session", "a")
assertEquals(listOf("b"), store.read("scope", "session").map { it.id })
assertEquals(listOf("a"), store.read("other", "session").map { it.id })
}
private fun sample(id: String = "activity") = ChatActivityRecord(
id = id, scopeKey = "scope", sessionId = "session", kind = ChatActivityKind.SUBAGENTS,
sourceId = "dispatch", title = "Subagents", phase = ChatActivityPhase.COMPLETE,
createdAt = now - 10, updatedAt = now,
)
}
@@ -12,6 +12,7 @@ import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
@@ -659,6 +660,40 @@ class DashboardApiClientTest {
assertEquals("basic", session.provider)
}
@Test
fun passwordLogin_stripsOnlyBrowserForbiddenLineBreaksFromCredentials() = runTest {
val preservedCredential = " \t\u00A0påss\u200B "
val cases = listOf(
listOf("user", "line\rbreak", "user", "linebreak"),
listOf("user", "line\nbreak", "user", "linebreak"),
listOf("user", "line\r\nbreak", "user", "linebreak"),
listOf("us\r\ner", "secret", "user", "secret"),
listOf(
preservedCredential,
preservedCredential,
preservedCredential,
preservedCredential,
),
)
repeat(cases.size) {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"ok": true, "next": "/"}"""),
)
}
val client = DashboardApiClient(baseUrl = server.url("/").toString())
cases.forEach { (username, password, expectedUsername, expectedPassword) ->
client.loginPassword(username = username, password = password).getOrThrow()
val body = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject
assertEquals(expectedUsername, body["username"]?.jsonPrimitive?.content)
assertEquals(expectedPassword, body["password"]?.jsonPrimitive?.content)
}
}
private fun storedCookie(
name: String,
value: String,
@@ -2408,6 +2408,27 @@ class GatewayChatClientTest {
assertEquals("focus on Android", (params["text"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `session child listener receives detached updates after parent terminal and rejects foreign frames`() = runBlocking {
val events = LinkedBlockingQueue<GatewaySubagentEvent>()
client.setSubagentEventListener { _, _, event -> events.add(event) }
val recorder = Recorder()
client.sendTurn(null, "delegate", null, recorder.callbacks) { recorder.preflightFailures += it }
val socket = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val payload = buildJsonObject { put("subagent_id", "child"); put("status", "completed") }
socket.send(harness.eventFrame("subagent.start", payload, "live-1"))
assertEquals(GatewaySubagentEvent.Phase.START, events.poll(5, TimeUnit.SECONDS)?.phase)
socket.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "Launched") }, "live-1"))
socket.send(harness.eventFrame("subagent.tool", payload, "foreign"))
socket.send(harness.eventFrame("subagent.tool", payload, null))
socket.send(harness.eventFrame("subagent.progress", payload, "live-1"))
socket.send(harness.eventFrame("subagent.complete", payload, "live-1"))
assertEquals(GatewaySubagentEvent.Phase.PROGRESS, events.poll(5, TimeUnit.SECONDS)?.phase)
assertEquals(GatewaySubagentEvent.Phase.COMPLETE, events.poll(5, TimeUnit.SECONDS)?.phase)
assertTrue(events.isEmpty())
}
@Test
fun `child watch is profile pinned bounded and isolated from main session`() = runBlocking {
harness.sessionProfileOverride = "operator"
@@ -3992,6 +4013,27 @@ class GatewayChatClientTest {
assertTrue(r.preflightFailures.isEmpty())
}
@Test
fun `agent init failure before lazy acknowledgement remains authoritative`() {
harness.suppressAckMethods += "session.create"
val r = Recorder()
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
val ack = harness.awaitPendingAck()
ack.ws.send(harness.eventFrame("error", buildJsonObject {
put("message", "agent init failed: incompatible runtime helper")
}, "live-1"))
harness.releaseAck(ack, buildJsonObject {
put("session_id", "live-1")
put("stored_session_id", "stored-1")
put("profile", "default")
put("info", buildJsonObject { put("lazy", true) })
})
waitUntil { r.preflightFailures.isNotEmpty() }
assertTrue(r.preflightFailures.single().contains("incompatible runtime helper"))
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
assertNull(client.preparingSessionId.value)
}
@Test
fun `lazy fresh session readiness timeout never submits`() {
rebuildClient(sessionReadyTimeoutMs = 50L)
@@ -741,6 +741,29 @@ class GatewayEventMapperTest {
assertNull(event.durationSeconds)
}
@Test
fun `subagent events preserve delegation identity independently of child identity`() {
val phases = listOf("spawn_requested", "start", "thinking", "tool", "progress", "complete")
phases.forEach { phase ->
val event = GatewayEventMapper.parseSubagentEvent(
"subagent.$phase",
obj("""{"delegation_id":"delegation-9","subagent_id":"child-17","child_session_id":"session-17"}"""),
)!!
assertEquals("delegation-9", event.delegationId)
assertEquals("child-17", event.subagentId)
assertEquals("session-17", event.childSessionId)
}
}
@Test
fun `older subagent emitters do not invent a delegation identity`() {
val event = GatewayEventMapper.parseSubagentEvent(
"subagent.complete",
obj("""{"subagent_id":"child-17","task_index":0,"task_count":1}"""),
)!!
assertNull(event.delegationId)
}
// --- Usage translation (tui_gateway key names, not SSE names) ---
@Test
@@ -0,0 +1,141 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
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.ToolCall
import com.hermesandroid.relay.network.upstream.GatewayProcess
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import com.hermesandroid.relay.ui.components.ToolProgressCard
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xhdpi")
class ChatActivityReceiptScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test
fun completedActivityStaysCompactAndOpensOnlyOnTap() {
var opened = 0
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Surface {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
ToolProgressCard(ToolCall(
name = "delegate_task", args = null, result = "{\"status\":\"dispatched\"}",
success = true, isComplete = true,
))
ChatActivityReceipt(
record(ChatActivityKind.SUBAGENTS, ChatActivityPhase.COMPLETE).copy(
children = listOf(
ChatActivityChild("child-one", phase = ChatActivityPhase.COMPLETE, summary = "Private result"),
ChatActivityChild("child-two", phase = ChatActivityPhase.FAILED),
),
),
onClick = { opened++ },
)
ChatActivityReceipt(record(ChatActivityKind.PROCESS, ChatActivityPhase.UNKNOWN), {})
}
}
}
}
assertEquals(0, opened)
compose.onNodeWithText("Dispatched").assertExists()
compose.onNodeWithText("Subagents · 1 Completed · 1 Failed").assertExists()
compose.onNodeWithText("Private result").assertDoesNotExist()
compose.onNodeWithText("Background command · Final state unavailable").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/chat-activity-receipts.png")
compose.onNodeWithText("View activity").performClick()
assertEquals(1, opened)
}
@Test
fun completionRemovesActiveStripButLeavesTranscriptEntry() {
val running = mutableStateOf(true)
compose.setContent {
HermesRelayTheme {
Column {
GatewayBackgroundProcessStrip(
processes = listOf(GatewayProcess("process", "command", status = if (running.value) "running" else "completed")),
subagentActivities = emptyList(),
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = true,
onClick = {},
)
ChatActivityReceipt(
record(ChatActivityKind.PROCESS, if (running.value) ChatActivityPhase.RUNNING else ChatActivityPhase.COMPLETE),
{},
)
}
}
}
compose.onNodeWithText("Current chat activity").assertExists()
compose.runOnIdle { running.value = false }
compose.onNodeWithText("Current chat activity").assertDoesNotExist()
compose.onNodeWithText("Background command · Completed").assertExists()
}
@Test
fun recordedProcessPreviewOffersNoMutationControls() {
compose.setContent {
HermesRelayTheme {
GatewayBackgroundProcessSheet(
processes = listOf(GatewayProcess("process", "Saved command", status = "completed", exitCode = 0)),
subagentActivities = emptyList(),
subagentChildPreview = null,
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = false,
stoppingProcessIds = emptySet(),
onRefresh = {},
onStop = {},
onDismissProcess = {},
onOpenSubagentChild = {},
onDismiss = {},
readOnlyHistory = true,
historyNotice = "Recorded activity. Output is unavailable.",
)
}
}
compose.onNodeWithText("Chat activity").assertExists()
compose.onNodeWithText("Recorded activity. Output is unavailable.").assertExists()
compose.onNodeWithText("Saved command").assertExists()
compose.onNodeWithText("Stop").assertDoesNotExist()
compose.onNodeWithText("Dismiss").assertDoesNotExist()
}
private fun record(kind: ChatActivityKind, phase: ChatActivityPhase) = ChatActivityRecord(
id = "receipt-${kind.name}",
scopeKey = "scope",
sessionId = "session",
kind = kind,
sourceId = "source",
title = "Activity",
phase = phase,
createdAt = 1L,
updatedAt = 2L,
)
}
@@ -0,0 +1,47 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xhdpi")
class ChatDebugDrawerScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test
fun initializationFailureShowsConfirmedConnectionAndActionableError() {
var closed = false
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
ChatDebugDrawer(
profile = "Server Default", model = "gpt-5.6-sol", sessionId = "session-example",
gateway = true, signedIn = true, signInRequired = false,
socketState = GatewayConnectionState.Ready, preparing = false,
streaming = false, loadingHistory = false, directoryUnavailable = false,
failure = "agent init failed: incompatible runtime helper",
onClose = { closed = true }, onConnections = {},
)
}
}
compose.onNodeWithText("Authenticated for this connection.").assertExists()
compose.onNodeWithText("The request failed. Details are below.").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/chat-debug-drawer.png")
compose.onNodeWithContentDescription("Close session diagnostics").performClick()
assertTrue(closed)
}
}
@@ -0,0 +1,56 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.components.LocalMessageActionHost
import com.hermesandroid.relay.ui.components.MessageBannerHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import com.hermesandroid.relay.ui.screens.MessagePreviewControls
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h1200dp-xhdpi")
class MessagePreviewControlsTest {
@get:Rule val compose = createComposeRule()
@Test
fun previewRetryIsLocalAndClearPreservesRealMessages() {
val host = SnackbarHostState()
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
CompositionLocalProvider(LocalMessageActionHost provides host) {
Surface { Column {
MessageBannerHost(includeStatusBarPadding = false)
MessagePreviewControls()
ThemedMessageHost(host)
} }
}
}
}
compose.onNodeWithContentDescription("Actionable error preview").performClick()
compose.onNodeWithText("Retry").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/message-previews.png")
compose.onNodeWithText("Retry").performClick()
compose.onNodeWithText("Preview retry selected — no request sent.").assertExists()
compose.runOnIdle { UiMessageBus.post("Real app message", ttlMillis = 0L, key = "real-test-message") }
compose.onNodeWithContentDescription("Clear previews").performClick()
compose.onNodeWithText("Real app message").assertExists()
compose.runOnIdle { UiMessageBus.clear("real-test-message") }
}
}
@@ -0,0 +1,44 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.ToolCall
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DetachedDelegationDispatchTest {
@Test
fun completedSuccessfulStructuredDispatchIsRecognized() {
assertTrue(isDetachedDelegationDispatch(call("""{"status":"dispatched"}""")))
assertTrue(isDetachedDelegationDispatch(call("""{"mode":"background"}""")))
}
@Test
fun synchronousDelegationAndUnstructuredTextRemainOrdinaryCompletion() {
listOf(
"""{"status":"completed","results":["done"]}""",
"""{"result":"status=dispatched mode=background"}""",
"dispatched in background",
"""{"status":{"status":"dispatched"}}""",
"""[{"status":"dispatched"}]""",
"{broken",
).forEach { assertFalse(isDetachedDelegationDispatch(call(it))) }
}
@Test
fun failedRunningAndOtherToolCallsAreNeverDispatchSuccess() {
val dispatch = call("""{"status":"dispatched"}""")
assertFalse(isDetachedDelegationDispatch(dispatch.copy(success = false)))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(success = null)))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(isComplete = false)))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(name = "terminal")))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(result = null)))
}
private fun call(result: String) = ToolCall(
name = "delegate_task",
args = null,
result = result,
success = true,
isComplete = true,
)
}
@@ -0,0 +1,29 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.network.upstream.GatewayProcess
import org.junit.Assert.assertEquals
import org.junit.Test
class ProcessDisplayPhaseTest {
@Test
fun unavailableAndCancelledDoNotBecomeSuccessfulWithoutExitCode() {
assertEquals(ProcessDisplayPhase.UNKNOWN, phase("unknown"))
assertEquals(ProcessDisplayPhase.UNKNOWN, phase("unrecognized"))
assertEquals(ProcessDisplayPhase.CANCELLED, phase("cancelled"))
assertEquals(ProcessDisplayPhase.FAILED, phase("failed"))
}
@Test
fun knownLiveAndTerminalStatesRetainTheirMeaning() {
assertEquals(ProcessDisplayPhase.RUNNING, phase("running"))
assertEquals(ProcessDisplayPhase.COMPLETE, phase("complete"))
assertEquals(ProcessDisplayPhase.COMPLETE, phase("completed"))
assertEquals(ProcessDisplayPhase.COMPLETE, phase("exited", 0))
assertEquals(ProcessDisplayPhase.FAILED, phase("exited", 1))
assertEquals(ProcessDisplayPhase.UNKNOWN, phase("unknown", 0))
}
private fun phase(status: String, exitCode: Int? = null) = processDisplayPhase(
GatewayProcess("process", "command", status = status, exitCode = exitCode),
)
}
@@ -2,8 +2,11 @@ package com.hermesandroid.relay.ui.screens
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.performClick
import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotModeRoster
import com.hermesandroid.relay.data.BotModeState
@@ -13,6 +16,7 @@ import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -65,10 +69,64 @@ class BotModeScreenTest {
compose.onNodeWithText("Lucy").assertDoesNotExist()
}
@Test
fun `same named bots on different gateways render and open their own conversation`() {
val opened = mutableListOf<String>()
render(onOpenBot = { opened += it.route!!.connectionId })
compose.onNodeWithText("Lucy").performClick()
compose.onNodeWithText("Researcher").performClick()
assertEquals(listOf("home", "lab"), opened)
}
@Test
fun `same named active bots on different gateways render and open their own owner`() {
val opened = mutableListOf<String>()
render(nowMs = NOW, onOpenBot = { opened += it.route!!.connectionId })
compose.onAllNodesWithText("Lucy")[0].performClick()
compose.onAllNodesWithText("Researcher")[0].performClick()
assertEquals(listOf("home", "lab"), opened)
}
@Test
fun `opening progress only replaces the selected owners preview`() {
render(openingRoute = BotGatewayRouteKey("home", "default"))
compose.onNodeWithText("Drafted a rollout plan").assertDoesNotExist()
compose.onNodeWithText("Findings ready").assertExists()
}
@Test
fun `item identity survives presentation refresh and separates delimiter containing owners`() {
val bot = state().roster.bots.first()
val refreshed = bot.copy(
displayName = "Renamed",
handle = "new-handle",
stale = true,
route = BotGatewayRoute(bot.route!!.key, "New label", "new-install-metadata"),
canonicalSession = BotSessionSummary(id = "compressed-tip"),
)
assertEquals(bot.lazyItemKey, refreshed.lazyItemKey)
assertNotEquals(
bot.copy(route = BotGatewayRoute(BotGatewayRouteKey("a:b", "c"), "Same label")).lazyItemKey,
bot.copy(route = BotGatewayRoute(BotGatewayRouteKey("a", "b:c"), "Same label")).lazyItemKey,
)
assertNotEquals(
bot.lazyItemKey,
bot.copy(route = BotGatewayRoute(BotGatewayRouteKey("home", "other"), "Hermes")).lazyItemKey,
)
assertNotEquals(bot.lazyItemKey, bot.copy(route = null).lazyItemKey)
}
private fun render(
onOpenBot: (BotRosterEntry) -> Unit = {},
onOpenGroup: (BotGroupRoom) -> Unit = {},
selectedGatewayId: String? = null,
nowMs: Long = NOW + 200_000L,
openingRoute: BotGatewayRouteKey? = null,
) {
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
@@ -77,13 +135,14 @@ class BotModeScreenTest {
connections = listOf(connection(), labConnection()),
activeConnection = connection(),
selectedGatewayId = selectedGatewayId,
openingRoute = openingRoute,
onBack = {},
onRefresh = {},
onSelectGateway = {},
onOpenBot = onOpenBot,
onOpenGroup = onOpenGroup,
onNewBot = {},
nowMs = NOW + 200_000L,
nowMs = nowMs,
)
}
}
@@ -106,10 +165,10 @@ class BotModeScreenTest {
),
),
BotRosterEntry(
profile = Profile(name = "researcher", model = "gpt-5.6", description = "Research"),
profile = Profile(name = "default", model = "gpt-5.6", description = "Research"),
displayName = "Researcher",
route = com.hermesandroid.relay.data.BotGatewayRoute(
key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "researcher"),
key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "default"),
connectionLabel = "Lab server",
),
canonicalSession = BotSessionSummary(
@@ -1,5 +1,7 @@
package com.hermesandroid.relay.util
import com.hermesandroid.relay.network.upstream.DashboardHttpException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
@@ -11,6 +13,54 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class RelayErrorClassifierTest {
@Test
fun dashboard404UsesTypedStatusAndNamesItsOwner() {
val error = classifyError(DashboardHttpException(404, "request failed"), context = "send_message")
assertEquals("Endpoint not found", error.title)
assertTrue(error.body.contains("Dashboard"))
assertFalse(error.body.contains("relay", ignoreCase = true))
assertFalse(error.retryable)
}
@Test
fun typedDashboardStatusWinsOverMisleadingResponseBody() {
val error = classifyError(DashboardHttpException(500, "HTTP 404 missing"))
assertFalse(error.title == "Endpoint not found")
assertTrue(error.body.contains("HTTP 500"))
assertTrue(error.retryable)
}
@Test
fun explicitLegacy404IsNeutralForVoiceAndChat() {
listOf("HTTP 404", "API error 404: Not found", "Relay responded HTTP 404").forEach { text ->
listOf("voice_config", "send_message").forEach { context ->
val error = classifyError(IOException(text), context)
assertEquals("Endpoint not found", error.title)
assertFalse(error.body.contains("relay", ignoreCase = true))
assertFalse(error.body.contains("older", ignoreCase = true))
}
}
}
@Test
fun incidental404NeverBecomesMissingEndpoint() {
listOf("file 404 not readable", "HTTP 4040", "request id=404", "See https://example.test/404", "Response body: HTTP 404").forEach { text ->
val error = classifyError(IOException(text))
assertEquals("Network error", error.title)
assertEquals(text, error.body)
}
}
@Test
fun typedDashboardAuthDoesNotSuggestRelayRepair() {
listOf(401, 403).forEach { code ->
val error = classifyError(DashboardHttpException(code, "request failed"), "media_fetch")
assertEquals(if (code == 401) "Dashboard sign-in required" else "Not allowed", error.title)
assertEquals(null, error.action)
assertFalse(error.body.contains("re-pair", ignoreCase = true))
}
}
@Test
fun gatewayDrainIsNotMisclassifiedAsProviderOutage() {
val err = classifyError(
@@ -0,0 +1,285 @@
package com.hermesandroid.relay.viewmodel
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.network.upstream.GatewayProcess
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatActivityControllerTest {
private val now = 100_000L
@Test
fun completedChildrenRemainWhenNewParentTurnPrunesActiveList() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("first", SubagentActivityPhase.COMPLETED)))
val original = controller.records.value.single()
controller.captureSubagents(emptyList())
controller.captureSubagents(listOf(child("second", SubagentActivityPhase.THINKING).copy(turnId = "next-turn")))
val record = controller.records.value.single()
assertEquals(original.id, record.id)
assertEquals(original.createdAt, record.createdAt)
assertEquals(listOf("first", "second"), record.children.map { it.id })
assertEquals(ChatActivityPhase.RUNNING, record.phase)
controller.captureSubagents(listOf(child("second", SubagentActivityPhase.COMPLETED)))
assertEquals(ChatActivityPhase.COMPLETE, controller.records.value.single().phase)
}
@Test
fun groupsOnlyByExactDelegationAndSkipsAnonymousChildren() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(
child("a").copy(delegationId = null), child("b").copy(delegationId = null),
child("c").copy(delegationId = "different"),
child("anonymous").copy(subagentId = null, childSessionId = null),
))
assertEquals(3, controller.records.value.size)
assertEquals(3, controller.records.value.map { it.id }.distinct().size)
}
@Test
fun uncertaintyAndExactFailuresDriveAggregateWithoutPersistingEventBodies() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(
child("a", SubagentActivityPhase.FAILED), child("b", SubagentActivityPhase.ENDED_WITH_PARENT),
))
assertEquals(ChatActivityPhase.UNKNOWN, controller.records.value.single().phase)
controller.captureSubagents(listOf(child("b", SubagentActivityPhase.COMPLETED)))
assertEquals(ChatActivityPhase.FAILED, controller.records.value.single().phase)
}
@Test
fun processIdReuseCreatesSeparateHistoryAndDisappearanceBecomesUnknown() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(process("old")))
val original = controller.records.value.single()
controller.captureProcesses(listOf(process("new")))
val records = controller.records.value
assertEquals(2, records.size)
assertEquals(ChatActivityPhase.UNKNOWN, records.first { it.id == original.id }.phase)
assertEquals(ChatActivityPhase.RUNNING, records.first { it.processStartedAt == "new" }.phase)
controller.captureProcesses(listOf(process("new").copy(status = "exited", exitCode = 0)))
assertEquals(ChatActivityPhase.COMPLETE, controller.records.value.first { it.processStartedAt == "new" }.phase)
assertTrue(controller.records.value.none { it.title.contains("private") })
}
@Test
fun identityEncodingSeparatesNullAndLiteralStartValues() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(process(null), process("null")))
assertEquals(2, controller.records.value.size)
assertNotEquals(controller.records.value[0].id, controller.records.value[1].id)
}
@Test
fun staleReadCannotPublishIntoAnotherSession() = runTest {
val gate = CompletableDeferred<List<ChatActivityRecord>>()
val store = RecordingStore { _, session -> if (session == "old") gate.await() else emptyList() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "old")
runCurrent()
controller.selectSession("scope", "new")
controller.captureSubagents(listOf(child("new-child")))
gate.complete(listOf(record("old")))
advanceUntilIdle()
assertEquals(listOf("new"), controller.records.value.map { it.sessionId })
}
@Test
fun delayedRestoreDoesNotOverwriteFreshLiveState() = runTest {
val gate = CompletableDeferred<List<ChatActivityRecord>>()
val store = RecordingStore { _, _ -> gate.await() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
runCurrent()
controller.captureProcesses(listOf(process("same")))
val fresh = controller.records.value.single()
gate.complete(listOf(fresh.copy(phase = ChatActivityPhase.UNKNOWN)))
advanceUntilIdle()
assertEquals(ChatActivityPhase.RUNNING, controller.records.value.single().phase)
}
@Test
fun storeBindingMigratesMemoryAndFencesPreviousLoads() = runTest {
val memory = InMemoryChatActivityStore { now }
val controller = ChatActivityController(this, memory) { now }
controller.selectSession("scope", "session")
advanceUntilIdle()
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.COMPLETED)))
advanceUntilIdle()
val durable = RecordingStore { _, _ -> emptyList() }
controller.bindStore(durable)
advanceUntilIdle()
assertEquals(1, controller.records.value.size)
assertEquals(controller.records.value, durable.written)
}
@Test
fun unchangedSnapshotsDoNotWriteAndChangedMetadataUsesMonotonicRevision() = runTest {
val store = RecordingStore { _, _ -> emptyList() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
advanceUntilIdle()
controller.captureSubagents(listOf(child("a")))
advanceUntilIdle()
controller.captureSubagents(listOf(child("a")))
advanceUntilIdle()
assertEquals(1, store.written.size)
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.COMPLETED)))
advanceUntilIdle()
assertEquals(2, store.written.size)
assertTrue(store.written.last().updatedAt > store.written.first().updatedAt)
}
@Test
fun removeFencesPendingLoadAndRemovesOnlyRequestedOwner() = runTest {
val gate = CompletableDeferred<List<ChatActivityRecord>>()
val store = RecordingStore { _, _ -> gate.await() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
runCurrent()
controller.removeSession("scope", "session")
gate.complete(listOf(record("session")))
advanceUntilIdle()
assertTrue(controller.records.value.isEmpty())
assertEquals(listOf("scope" to "session"), store.removed)
}
@Test
fun lateDelegationMigratesExactFallbackAndRemovesDurableDuplicate() = runTest {
val store = RecordingStore { _, _ -> emptyList() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
advanceUntilIdle()
controller.captureSubagents(listOf(child("a").copy(delegationId = null)))
val fallback = controller.records.value.single()
controller.captureSubagents(listOf(child("a")))
advanceUntilIdle()
val grouped = controller.records.value.single()
assertNotEquals(fallback.id, grouped.id)
assertEquals(fallback.createdAt, grouped.createdAt)
assertEquals(1, grouped.children.size)
assertEquals(listOf(fallback.id), store.removedRecords)
}
@Test
fun childSessionIdentityEnrichesWithoutDuplicatingChild() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a").copy(subagentId = null)))
controller.captureSubagents(listOf(child("a")))
val children = controller.records.value.single().children
assertEquals(1, children.size)
assertEquals("a", children.single().id)
}
@Test
fun declaredUnobservedChildrenPreventFalseCompletion() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.COMPLETED).copy(taskCount = 3)))
assertEquals(3, controller.records.value.single().taskCount)
assertEquals(ChatActivityPhase.UNKNOWN, controller.records.value.single().phase)
}
@Test
fun deletionRejectsLateCapturesAndSameOwnerReselectionUntilSwitchAway() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a")))
controller.removeSession("scope", "session")
controller.captureSubagents(listOf(child("late")))
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(process("late")))
assertTrue(controller.records.value.isEmpty())
controller.selectSession("scope", "other")
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("new")))
assertEquals(1, controller.records.value.size)
}
@Test
fun connectionLossDowngradesOnlyRunningRecordsAndChildren() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a"), child("b", SubagentActivityPhase.COMPLETED)))
controller.captureProcesses(listOf(process("now")))
controller.markUnavailable()
assertTrue(controller.records.value.all { it.phase == ChatActivityPhase.UNKNOWN })
val children = controller.records.value.first { it.kind == ChatActivityKind.SUBAGENTS }.children
assertEquals(ChatActivityPhase.UNKNOWN, children.first { it.id == "a" }.phase)
assertEquals(ChatActivityPhase.COMPLETE, children.first { it.id == "b" }.phase)
}
@Test
fun freshChildAfterConnectionLossDoesNotReviveUnobservedSibling() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a"), child("b")))
controller.markUnavailable()
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.PROGRESS)))
val record = controller.records.value.single()
assertEquals(ChatActivityPhase.RUNNING, record.phase)
assertEquals(ChatActivityPhase.RUNNING, record.children.first { it.id == "a" }.phase)
assertEquals(ChatActivityPhase.UNKNOWN, record.children.first { it.id == "b" }.phase)
}
@Test
fun explicitZeroProcessExitCompletesUnknownStatusButDoesNotOverrideCancellationOrRunning() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(
process("zero").copy(status = "unknown", exitCode = 0),
process("cancelled").copy(status = "cancelled", exitCode = 0),
process("running").copy(status = "running", exitCode = 0),
))
val phases = controller.records.value.associate { it.processStartedAt to it.phase }
assertEquals(ChatActivityPhase.COMPLETE, phases["zero"])
assertEquals(ChatActivityPhase.CANCELLED, phases["cancelled"])
assertEquals(ChatActivityPhase.RUNNING, phases["running"])
}
private fun child(id: String, phase: SubagentActivityPhase = SubagentActivityPhase.THINKING) = SubagentActivity(
laneId = 1, turnId = "turn", taskIndex = 1, taskCount = 2, goal = "Review", subagentId = id,
childSessionId = "session-$id", phase = phase, delegationId = "delegation",
)
private fun process(start: String?) = GatewayProcess(
id = "process", command = "private command", cwd = "/private/path", startedAt = start,
status = "running", outputTail = "private output", sessionScoped = true,
)
private fun record(session: String) = ChatActivityRecord(
id = "old-record", scopeKey = "scope", sessionId = session, kind = ChatActivityKind.SUBAGENTS,
sourceId = "source", title = "Subagents", phase = ChatActivityPhase.COMPLETE,
createdAt = now, updatedAt = now,
)
private class RecordingStore(
val reader: suspend (String, String) -> List<ChatActivityRecord>,
) : ChatActivityStore {
val written = mutableListOf<ChatActivityRecord>()
val removed = mutableListOf<Pair<String, String>>()
val removedRecords = mutableListOf<String>()
override suspend fun read(scopeKey: String, sessionId: String) = reader(scopeKey, sessionId)
override suspend fun upsert(record: ChatActivityRecord) { written += record }
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) { removedRecords += id }
override suspend fun removeSession(scopeKey: String, sessionId: String) { removed += scopeKey to sessionId }
}
}
@@ -0,0 +1,203 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.data.Profile
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.GatewayClientHarness
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ChatActivityIntegrationTest {
private lateinit var harness: GatewayClientHarness
private lateinit var gatewayScope: CoroutineScope
private lateinit var client: GatewayChatClient
private lateinit var socket: WebSocket
private lateinit var handler: ChatHandler
private lateinit var viewModel: ChatViewModel
private val owner = AgentDisplay.profileContextKey("connection-a", "research")
@Volatile private var history: List<MessageItem> = emptyList()
@Before
fun setUp() {
harness = GatewayClientHarness()
// This harness has a fixed process list for process-controller tests.
// Keep this delegation-only lane independent of those unrelated rows.
harness.methodNotFound.add("process.list")
harness.resumeLiveSessionIds["child-session"] = "child-live"
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
client = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
harness.server.url("/").toString().trimEnd('/'), OkHttpClient(),
),
okHttpClient = OkHttpClient(),
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
)
handler = ChatHandler().also { it.setSessionId(SESSION) }
viewModel = ChatViewModel().also {
it.initialize(HermesApiClient(harness.server.url("/").toString(), "test-key"), handler)
it.streamingEndpoint = "gateway"
it.setSelectedProfileProvider { Profile(name = "research", model = "model") }
it.setSessionProfileNameProvider { "research" }
it.setProfileMessageLoader { Result.success(history) }
it.setChatActivityStore(InMemoryChatActivityStore())
it.switchProfileContext(owner, SESSION)
it.updateGatewayClient(client)
}
assertTrue(runBlocking { client.prewarmAwait(SESSION) })
socket = harness.awaitServerSocket()
shadowOf(Looper.getMainLooper()).idle()
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
client.shutdown()
gatewayScope.cancel()
harness.shutdown()
}
@Test
fun detachedCompletionKeepsHistoryEntryThroughWakeAndClosesOnProfileSwitch() {
viewModel.sendMessage("Inspect the project")
harness.awaitRpc("prompt.submit")
emit("message.start")
emit("subagent.start", childPayload())
awaitCondition { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.RUNNING }
history = listOf(row("prompt", "user", "Inspect the project"), row("parent", "assistant", "Children launched."))
emit("message.complete", buildJsonObject {
put("text", "Children launched.")
put("status", "complete")
})
awaitCondition { !handler.isStreaming.value }
assertEquals(ChatActivityPhase.RUNNING, viewModel.activityRecords.value.single().phase)
emit("subagent.complete", childPayload(completed = true))
awaitCondition { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.COMPLETE }
val completed = viewModel.activityRecords.value.single()
assertEquals("delegation-a", completed.sourceId)
assertEquals("child-session", completed.children.single().childSessionId)
assertTrue(viewModel.openRetainedActivity(completed))
assertEquals(completed.id, viewModel.retainedActivityPreview.value?.record?.id)
val promptCount = harness.rpcLog.count { it.first == "prompt.submit" }
viewModel.openSubagentChildPreview(completed.previewActivities().single().stableKey)
awaitCondition { viewModel.subagentChildPreview.value?.childWatchAvailable == true }
val childResume = harness.rpcLog.single {
it.first == "session.resume" && it.second["session_id"] == JsonPrimitive("child-session")
}.second
assertEquals(JsonPrimitive(true), childResume["lazy"])
assertEquals(JsonPrimitive("research"), childResume["profile"])
assertEquals("live-resumed", client.currentLiveSessionId(SESSION))
assertEquals(promptCount, harness.rpcLog.count { it.first == "prompt.submit" })
val receipt = row("receipt", "user", "[ASYNC DELEGATION COMPLETE — delegation-a]").copy(
rowId = 73,
displayKind = "async_delegation_complete",
displayMetadata = buildJsonObject {
put("delegation_id", "delegation-a")
put("task_count", 1)
put("completed_count", 1)
put("failed_count", 0)
},
)
history = history + receipt + row("wake", "assistant", "Inspection complete.")
emit("message.start")
emit("message.complete", buildJsonObject {
put("text", "Inspection complete.")
put("status", "complete")
})
awaitCondition { handler.messages.value.any { it.id == "receipt" } }
assertEquals(completed.id, viewModel.activityRecords.value.single().id)
val raw = handler.messages.value
val canonical = raw.single { it.id == "receipt" }
val projected = projectChatActivityReceipts(raw, viewModel.activityRecords.value, owner, SESSION)
val displayed = projected.single { it.activityRecord != null }
assertEquals(canonical.id, displayed.id)
assertEquals(canonical.uiKey, displayed.uiKey)
assertEquals(73L, displayed.rowId)
assertEquals(canonical.content, displayed.content)
assertEquals(completed.id, displayed.activityRecord?.id)
assertEquals(raw, handler.messages.value)
assertTrue(handler.messages.value.none { it.activityRecord != null || it.id.startsWith("activity:") })
assertFalse(viewModel.openRetainedActivity(completed.copy(scopeKey = "foreign-owner")))
assertFalse(viewModel.openRetainedActivity(completed.copy(sessionId = "foreign-session")))
assertNotNull(viewModel.retainedActivityPreview.value)
viewModel.switchProfileContext(AgentDisplay.profileContextKey("connection-a", "other"), SESSION)
shadowOf(Looper.getMainLooper()).idle()
assertTrue(viewModel.activityRecords.value.isEmpty())
assertNull(viewModel.retainedActivityPreview.value)
assertNull(viewModel.subagentChildPreview.value)
assertFalse(viewModel.openRetainedActivity(completed))
}
private fun row(id: String, role: String, content: String) = MessageItem(
id = id, sessionId = SESSION, role = role, content = JsonPrimitive(content),
)
private fun childPayload(completed: Boolean = false) = buildJsonObject {
put("delegation_id", "delegation-a")
put("subagent_id", "child-a")
put("child_session_id", "child-session")
put("task_index", 0)
put("task_count", 1)
put("goal", "Inspect the project")
if (completed) {
put("status", "completed")
put("summary", "Inspected the project")
}
}
private fun emit(type: String, payload: JsonObject? = null) {
assertTrue(socket.send(harness.eventFrame(type, payload, "live-resumed")))
}
private fun awaitCondition(condition: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (System.nanoTime() < deadline) {
shadowOf(Looper.getMainLooper()).idleFor(20, TimeUnit.MILLISECONDS)
if (condition()) return
Thread.sleep(20)
}
assertTrue("Activity=${viewModel.activityRecords.value}; messages=${handler.messages.value}", condition())
}
companion object {
private const val SESSION = "stored-session"
}
}
@@ -2475,6 +2475,15 @@ class ChatViewModelGatewayInboundTurnTest {
)
}
@Test
fun recoveredLegacyContextKeepsExplicitPersistedSubagentProfile() {
assertRecoveredSubagentWatchProfile(
contextKey = "connection-a/profile-default",
persistedProfileKey = "default",
expectedProfile = "default",
)
}
@Test
fun currentServerDefaultCheckpointPersistsExplicitSentinel() {
assertCurrentCheckpointProfileKey(
@@ -2553,6 +2562,37 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals(JsonPrimitive("default"), resume["profile"])
}
@Test
fun detachedChildRemainsPreviewableAfterParentCompletesAndAcceptsLateProgress() {
viewModel.sendMessage("Delegate work")
gatewayHarness.awaitRpc("prompt.submit")
val child = buildJsonObject {
put("subagent_id", "detached-child")
put("child_session_id", "detached-session")
put("goal", "Inspect")
}
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("message.interim", buildJsonObject { put("text", "Delegating now") }, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("subagent.start", child, "live-resumed"))
awaitCondition { viewModel.subagentActivities.value.size == 1 }
val key = viewModel.subagentActivities.value.single().stableKey
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject { put("text", "Launched") }, "live-resumed"))
awaitCondition { !handler.isStreaming.value }
assertFalse(viewModel.subagentActivities.value.single().isTerminal)
serverWs.send(gatewayHarness.eventFrame("subagent.progress", buildJsonObject {
put("subagent_id", "detached-child")
put("text", "Still inspecting")
}, "live-resumed"))
awaitCondition { viewModel.subagentActivities.value.single().events.last().text == "Still inspecting" }
assertEquals(key, viewModel.subagentActivities.value.single().stableKey)
assertFalse(handler.isStreaming.value)
serverWs.send(gatewayHarness.eventFrame("subagent.complete", buildJsonObject {
put("subagent_id", "detached-child")
put("status", "completed")
}, "live-resumed"))
awaitCondition { viewModel.subagentActivities.value.single().phase == SubagentActivityPhase.COMPLETED }
}
@Test
fun explicitApprovalActionAloneEmitsResponseAndCollapsesCard() {
viewModel.sendMessage("Run the guarded command")
@@ -14,6 +14,10 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.hermesandroid.relay.util.HumanError
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -285,6 +289,35 @@ class ChatViewModelMediaStateTest {
assertEquals("/media/by-path", server.takeRequest().requestUrl?.encodedPath)
}
@Test
fun missingDashboardMediaStaysInAttachmentWithoutGlobalError() {
dashboardServer.enqueue(MockResponse().setResponseCode(404).setBody("{\"detail\":\"Path not found\"}"))
viewModel.cellularNetworkOverride = false
viewModel.initializeMedia(
context = RuntimeEnvironment.getApplication(),
relayHttpClient = RelayHttpClient(
okHttpClient = OkHttpClient(), relayUrlProvider = { null },
sessionTokenProvider = { null }, pairedTokenSnapshot = { null },
),
mediaSettingsRepo = MediaSettingsRepository(RuntimeEnvironment.getApplication()),
mediaCacheWriter = cache,
dashboardMediaClientProvider = { DashboardApiClient(baseUrl = dashboardServer.url("/").toString()) },
)
val errors = mutableListOf<HumanError>()
val collector = CoroutineScope(Dispatchers.Unconfined).launch { viewModel.errorEvents.collect { errors.add(it) } }
try {
handler.loadMessageHistory(listOf(MessageItem(
id = "missing-image", role = "assistant", content = JsonPrimitive("MEDIA:/tmp/missing-image.png"),
)))
val failed = awaitMessage { it.attachments.singleOrNull()?.state == AttachmentState.FAILED }
shadowOf(Looper.getMainLooper()).idle()
assertEquals(AttachmentState.FAILED, failed.attachments.single().state)
assertEquals(1, dashboardServer.requestCount)
assertEquals(0, server.requestCount)
assertEquals(emptyList<HumanError>(), errors)
} finally { collector.cancel() }
}
@Test
fun assistantBarePathDoesNotBypassUpstreamSensitiveFileDenial() {
val path = "/home/user/.ssh/id_ed25519"
@@ -13,6 +13,31 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class EffectiveDashboardRouteTest {
@Test
fun `outgoing resolver endpoint cannot retarget incoming connection credentials`() {
val incomingRoute = EndpointCandidate(
role = "public",
dashboard = DashboardEndpoint("https://b.example.invalid"),
)
val outgoingRoute = EndpointCandidate(
role = "public",
dashboard = DashboardEndpoint("https://a.example.invalid"),
)
val incoming = connection(
dashboardUrl = "https://b.example.invalid",
apiServerUrl = "",
).copy(routeCandidates = listOf(incomingRoute))
assertEquals(
"https://b.example.invalid",
resolveEffectiveDashboardUrl(incoming, outgoingRoute),
)
assertEquals(
"https://b.example.invalid",
resolveEffectiveDashboardUrl(incoming, incomingRoute),
)
}
@Test
fun `late dashboard probe cannot publish across connection or route change`() {
assertTrue(
@@ -82,7 +107,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"http://100.71.8.56:9119",
resolveEffectiveDashboardUrl(connection, tailscale),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
)
}
@@ -102,7 +127,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"https://hermes.example.com",
resolveEffectiveDashboardUrl(connection, tailscale),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
)
assertEquals("http://100.71.8.56:8642", resolveEffectiveApiServerUrl(connection.apiServerUrl, tailscale))
}
@@ -138,7 +163,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"https://hermes.example.com:443",
resolveEffectiveDashboardUrl(connection, fallback),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(fallback)), fallback),
)
}
@@ -156,7 +181,7 @@ class EffectiveDashboardRouteTest {
assertEquals(
"http://100.71.8.56:9119",
resolveEffectiveDashboardUrl(connection, tailscale),
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
)
}
@@ -19,6 +19,29 @@ import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class GatewayProcessControllerTest {
@Test
fun retainedStatusListenerOnlyReceivesAuthoritativeSnapshots() = runTest {
val source = FakeProcessSource().apply {
snapshot = listOf(process(id = "p1", status = "running"))
}
val snapshots = mutableListOf<List<GatewayProcess>>()
val controller = GatewayProcessController(this)
controller.setSnapshotListener { snapshots += it }
controller.bind(source, "chat")
controller.sessionReady("chat")
runCurrent()
assertEquals(1, snapshots.size)
source.emit(GatewayProcessEvent.Output("p1", "new output"))
runCurrent()
assertEquals(1, snapshots.size)
source.snapshot = emptyList()
controller.refresh()
runCurrent()
assertEquals(2, snapshots.size)
assertTrue(snapshots.last().isEmpty())
controller.close()
}
@Test
fun duplicateProcessSnapshotsPublishOneComposeIdentityWithLatestState() = runTest {
val source = FakeProcessSource().apply {
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterIsInstance
@@ -147,12 +148,14 @@ class GitStateWriteViewModelTest {
val vm = viewModel()
selectAlpha(vm)
enqueuePostSuccess("abc")
var committedTarget: GitTarget? = null
val committedTarget = CompletableDeferred<GitTarget>()
vm.commit("add feature") { committedTarget = it }
vm.commit("add feature") { committedTarget.complete(it) }
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
assertEquals("alpha", committedTarget?.repoId)
// Success is published before detail refresh and the callback complete.
val target = withTimeout(5_000) { committedTarget.await() }
assertEquals("alpha", target.repoId)
}
@Test
@@ -0,0 +1,359 @@
package com.hermesandroid.relay.viewmodel
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.auth.SecureStoreCache
import com.hermesandroid.relay.auth.SessionTokenStore
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.DashboardConnectionStatus
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.network.relay.ConnectionManager
import com.hermesandroid.relay.network.upstream.EncryptedNativeDashboardTokenStore
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
import com.hermesandroid.relay.network.upstream.NativeDashboardTokenStore
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.Properties
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/**
* Extends the switch-coordinator/route-pool seams with real HTTP and production
* token JSON. Only Android Keystore is substituted: its raw string backend is
* file-backed and rereads the file on every access. No NativeDashboardTokens
* object survives a store reload. ConnectionStore owns its real JSON encoding.
* Loopback HTTP is the documented fixture exception, not a production TLS bypass.
*/
class MultiGatewayAuthPersistenceTest {
@get:Rule
val files = TemporaryFolder()
@Test
fun switchingWithOutgoingResolverSnapshotPreservesBothSerializedSessions() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
// The coordinator publishes B's id before the endpoint resolver
// finishes. Keep A's endpoint deliberately stale through the probe.
fixture.switchTo("b")
fixture.switchTo("a")
fixture.switchTo("b")
fixture.assertBothStored()
assertEquals(0, fixture.a.foreignBearerRequests.get())
assertEquals(0, fixture.b.foreignBearerRequests.get())
assertEquals(0, fixture.a.rejectedRefreshes.get())
assertEquals(0, fixture.b.rejectedRefreshes.get())
fixture.reload()
fixture.assertBothStored()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
}
}
@Test
fun coldReloadPreservesBothSessionsWithoutAnySwitch() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.reload()
fixture.assertBothStored()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
}
}
@Test
fun rotatedCredentialsSurviveClientAndStoreRecreation() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.a.rejectCurrentAccess = true
fixture.assertAuthenticated("a")
assertEquals(1, fixture.a.refreshes.get())
assertEquals(0, fixture.b.refreshes.get())
fixture.reload()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
assertEquals(1, fixture.a.refreshes.get())
}
}
@Test
fun mismatchedAndIneligibleRoutesNeverAttachOrClearStoredBearer() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
val wrongRoute = fixture.transport.dashboardClientFor("a", fixture.b.url)
assertFalse(wrongRoute.currentSession().getOrThrow().authenticated)
wrongRoute.shutdown()
fixture.eligible = false
// A new controller ensures this assertion tests eligibility itself,
// independently of the separate cached-client policy transition.
fixture.replaceTransport()
val ineligible = fixture.transport.dashboardClientFor("a", fixture.a.url)
assertFalse(ineligible.currentSession().getOrThrow().authenticated)
ineligible.shutdown()
fixture.assertBothStored()
assertEquals(0, fixture.a.refreshes.get())
assertEquals(0, fixture.b.refreshes.get())
assertEquals(0, fixture.b.foreignBearerRequests.get())
}
}
private inner class Fixture(private val scope: CoroutineScope) : AutoCloseable {
val a = AuthPeer("a")
val b = AuthPeer("b")
private val context = mockk<Context>().also { every { it.applicationContext } returns it }
private val preferences = PreferencesBackend()
private val suffix = UUID.randomUUID().toString()
private val rawStores = listOf("a", "b").associateWith {
FileStrings(files.newFile("$suffix-$it.properties"))
}
private val definitions = listOf(a, b).map { peer ->
Connection(
id = peer.id,
label = peer.id,
apiServerUrl = "",
relayUrl = "",
dashboardUrl = peer.url,
routeCandidates = listOf(EndpointCandidate(
role = "lan",
dashboard = com.hermesandroid.relay.data.DashboardEndpoint(peer.url),
)),
tokenStoreKey = "fixture-$suffix-${peer.id}",
)
}
private var store = ConnectionStore(preferences, scope)
private var endpoint: EndpointCandidate? = null
private var tokenStores = emptyMap<String, NativeDashboardTokenStore>()
var eligible = true
lateinit var transport: UpstreamTransportController
private set
suspend fun initialize() {
store.isHydrated.first { it }
definitions.forEach { store.addConnection(it) }
store.setActiveConnection("a")
endpoint = connection("a").routeCandidates.single()
tokenStores = definitions.associate { connection ->
SecureStoreCache.getOrBuild(connection.tokenStoreKey) { rawStores.getValue(connection.id) }
connection.id to EncryptedNativeDashboardTokenStore(context, connection.tokenStoreKey)
}
replaceTransport()
}
private fun connection(id: String): Connection = store.connections.value.single { it.id == id }
// Same synchronous ownership inputs as ConnectionViewModel.activeDashboardUrl.
// Endpoint publication intentionally lags connection-id publication.
private fun activeUrl(): String = resolveEffectiveDashboardUrl(
store.connections.value.firstOrNull { it.id == store.activeConnectionId.value },
endpoint,
)
fun replaceTransport() {
if (::transport.isInitialized) definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
transport = UpstreamTransportController(
context = context,
activeConnectionIdProvider = { store.activeConnectionId.value },
dashboardUrlProvider = { activeUrl() },
gatewayKeepAliveProvider = { false },
tokenStoreKeyProvider = { connection(it).tokenStoreKey },
trustedDashboardUrlProvider = { id ->
if (id == store.activeConnectionId.value) activeUrl() else connection(id).resolvedDashboardUrl
},
nativeDashboardBearerEligibleProvider = { id ->
eligible && nativeDashboardBearerCompatible(
connection(id).dashboardLastStatus?.authProviders
?.takeIf { it.isNotEmpty() }
?: connection(id).dashboardAuthProviders,
)
},
dashboardTokenStoreFactory = { key ->
tokenStores.getValue(definitions.single { it.tokenStoreKey == key }.id)
},
dashboardCookieStoreFactory = { _, _ -> InMemoryDashboardCookieStore() },
)
}
fun signInBoth() {
listOf(a, b).forEach { peer ->
val client = NativeDashboardAuthClient(peer.url, tokenStores.getValue(peer.id))
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
client.exchangeCallback(authorization, "/callback?code=fixture&state=${authorization.state}")
}
assertBothStored()
}
suspend fun assertAuthenticated(id: String) {
val client = transport.dashboardClientFor(id, connection(id).resolvedDashboardUrl)
try {
val status = client.getStatus().getOrThrow()
assertTrue(status.authRequired)
store.setDashboardStatus(
id,
DashboardConnectionStatus(authRequired = status.authRequired, authProviders = status.authProviders),
)
assertTrue("REST session must authenticate for its owner", client.currentSession().getOrThrow().authenticated)
assertTrue("WebSocket admission must authenticate for its owner", client.requestWsTicket().isSuccess)
} finally {
client.shutdown()
}
}
suspend fun switchTo(id: String) {
val manager = mockk<ConnectionManager>(relaxed = true)
val auth = mockk<AuthManager>(relaxed = true)
every { auth.authState } returns MutableStateFlow<AuthState>(AuthState.Unpaired)
every { auth.hasPairContext } returns false
val coordinator = ConnectionSwitchCoordinator(
connectionStore = store,
connectionManager = manager,
scope = scope,
authManagerFactory = { auth },
installAuthManager = {},
setApiServerUrl = {},
setRelayUrl = {},
persistUrls = { _, _ -> },
rebuildApiClient = {
val client = transport.dashboardClientFor(id, activeUrl())
try {
client.getStatus().getOrThrow()
client.currentSession().getOrThrow()
} finally {
client.shutdown()
}
},
)
transport.resetGatewayForConnectionSwitch()
coordinator.switchConnection(id).join()
endpoint = connection(id).routeCandidates.single()
assertBothStored()
assertAuthenticated(id)
}
fun assertBothStored() {
assertTrue("A's serialized token must remain readable", tokenStores.getValue("a").load() != null)
assertTrue("B's serialized token must remain readable", tokenStores.getValue("b").load() != null)
}
suspend fun reload() {
definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
// ConnectionStore decodes its persisted connections_v1 JSON again;
// the native stores decode their saved JSON from files again.
store = ConnectionStore(preferences, scope)
store.isHydrated.first { it }
tokenStores = definitions.associate {
it.id to EncryptedNativeDashboardTokenStore(context, it.tokenStoreKey)
}
endpoint = connection(store.activeConnectionId.value!!).routeCandidates.single()
replaceTransport()
}
override fun close() {
if (::transport.isInitialized) definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
a.close()
b.close()
}
}
private class AuthPeer(val id: String) : AutoCloseable {
private val server = MockWebServer()
private var generation = 0
@Volatile
var rejectCurrentAccess = false
val refreshes = AtomicInteger()
val rejectedRefreshes = AtomicInteger()
val foreignBearerRequests = AtomicInteger()
val url: String
init {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
if (request.path == "/api/status") {
return MockResponse().setBody(
"""{"auth_required":true,"auth_providers":["basic"],"auth_flows":["cookie","native_pkce"]}""",
)
}
if (request.path == "/auth/native/token") return tokens()
if (request.path == "/auth/native/refresh") {
if (!request.body.readUtf8().contains("fixture-refresh-$id-$generation")) {
rejectedRefreshes.incrementAndGet()
return MockResponse().setResponseCode(401)
}
generation += 1
rejectCurrentAccess = false
refreshes.incrementAndGet()
return tokens()
}
val header = request.getHeader("Authorization")
if (header != null && !header.startsWith("Bearer fixture-access-$id-")) {
foreignBearerRequests.incrementAndGet()
}
if (header != "Bearer fixture-access-$id-$generation" || rejectCurrentAccess) {
return MockResponse().setResponseCode(401)
}
return when (request.path) {
"/api/auth/me" -> MockResponse().setBody("""{"authenticated":true,"provider":"basic"}""")
"/api/auth/ws-ticket" -> MockResponse().setBody("""{"ticket":"fixture-ticket"}""")
else -> MockResponse().setResponseCode(404)
}
}
}
server.start()
url = server.url("/").newBuilder().host("127.0.0.1").build().toString().trimEnd('/')
}
private fun tokens() = MockResponse().setBody(
"""{"access_token":"fixture-access-$id-$generation","refresh_token":"fixture-refresh-$id-$generation","expires_at":4102444800,"provider":"basic"}""",
)
override fun close() = server.shutdown()
}
/** Raw storage seam only: production NativeDashboardTokenStore owns JSON. */
private class FileStrings(private val file: File) : SessionTokenStore {
override val hasHardwareBackedStorage = false
private fun read() = Properties().apply { file.inputStream().use { load(it) } }
private fun write(values: Properties) = file.outputStream().use { values.store(it, null) }
@Synchronized override fun getString(key: String): String? = read().getProperty(key)
@Synchronized override fun putString(key: String, value: String) { write(read().apply { setProperty(key, value) }) }
@Synchronized override fun remove(key: String) { write(read().apply { remove(key) }) }
@Synchronized override fun contains(key: String): Boolean = read().containsKey(key)
@Synchronized override fun clearAll() { write(Properties()) }
}
private class PreferencesBackend : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
transform(state.value).also { state.value = it }
}
}
@@ -10,6 +10,86 @@ class SubagentActivityControllerTest {
private var now = 1_000L
private val controller = SubagentActivityController { now++ }
@Test
fun `open sheet keeps completed details across parent wake until dismissed`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "first")
controller.setPreviewOpen(true)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.COMPLETE, subagentId = "child", status = "completed"), null)
controller.beginTurn("parent", "scope", "wake")
assertEquals(SubagentActivityPhase.COMPLETED, controller.activities.value.single().phase)
controller.setPreviewOpen(false)
assertTrue(controller.activities.value.isEmpty())
}
@Test
fun `detached child survives parent completion and a new turn with stable preview identity`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "parent-turn")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
val key = controller.activities.value.single().stableKey
controller.endTurn("parent-turn")
assertFalse(controller.activities.value.single().isTerminal)
controller.beginTurn("parent", "scope", "next-turn")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "Still working", subagentId = "child"), null)
assertEquals(key, controller.activities.value.single().stableKey)
assertEquals("Still working", controller.activities.value.single().events.last().text)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "completed", subagentId = "child"), null)
assertEquals(SubagentActivityPhase.COMPLETED, controller.activities.value.single().phase)
controller.beginTurn("parent", "scope", "completion-wake")
assertTrue(controller.activities.value.isEmpty())
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
assertTrue(controller.activities.value.isEmpty())
}
@Test
fun `idle session accepts child start but not orphan progress or other profile`() {
controller.selectSession("parent", "scope")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.PROGRESS, subagentId = "unknown"), null)
controller.onSessionEvent("parent", "foreign", event(0, GatewaySubagentEvent.Phase.START, subagentId = "foreign"), null)
assertTrue(controller.activities.value.isEmpty())
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
assertFalse(controller.activities.value.single().isTerminal)
controller.interrupt()
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "late"), null)
assertEquals(SubagentActivityPhase.INTERRUPTED, controller.activities.value.single().phase)
}
@Test
fun `concurrent batches reusing task index remain separate across turns`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "first")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child-a"), null)
controller.beginTurn("parent", "scope", "second")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child-b"), null)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "completed", subagentId = "child-a"), null)
assertEquals(2, controller.activities.value.size)
assertFalse(controller.activities.value.single { it.subagentId == "child-b" }.isTerminal)
}
@Test
fun `timeouts and unknown terminal statuses never report success`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "turn")
listOf("timeout", "error", "unknown", "running").forEachIndexed { index, status ->
controller.onSessionEvent("parent", "scope", event(index, GatewaySubagentEvent.Phase.START, subagentId = "child-$index"), null)
controller.onSessionEvent("parent", "scope", event(index, GatewaySubagentEvent.Phase.COMPLETE, status = status, subagentId = "child-$index"), null)
}
assertTrue(controller.activities.value.all { it.phase == SubagentActivityPhase.FAILED })
}
@Test
fun `child preview keeps its admitted profile after parent checkpoint is cleared`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "turn")
val start = event(0, GatewaySubagentEvent.Phase.START, subagentId = "child")
controller.onSessionEvent("parent", "scope", start, "default")
controller.endTurn("turn")
controller.onSessionEvent("parent", "scope", start.copy(phase = GatewaySubagentEvent.Phase.PROGRESS), null)
assertEquals("default", controller.activities.value.single().profile)
}
@Test
fun `interleaved children retain independent lifecycle previews`() {
controller.selectSession("parent", "connection::default")
+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id("com.android.application") version "9.3.2" apply false
id("com.android.library") version "9.3.2" apply false
id("com.android.application") version "9.4.0" apply false
id("com.android.library") version "9.4.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@hermes-relay/cli",
"version": "0.4.0-beta.6",
"version": "0.4.0-beta.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hermes-relay/cli",
"version": "0.4.0-beta.6",
"version": "0.4.0-beta.7",
"license": "MIT",
"bin": {
"hermes-relay": "bin/hermes-relay.js"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hermes-relay/cli",
"version": "0.4.0-beta.6",
"version": "0.4.0-beta.7",
"description": "Thin-client CLI for Hermes-Relay — talk to a remote Hermes agent over WSS with pairing auth, stream-renders tool calls and responses to plain stdout.",
"type": "module",
"bin": {
+1 -1
View File
@@ -1,2 +1,2 @@
// Regenerated from package.json by gen:version script. Do not edit by hand.
export const VERSION = "0.4.0-beta.6" as const
export const VERSION = "0.4.0-beta.7" as const
+1 -1
View File
@@ -1232,7 +1232,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermes-relay-tray"
version = "0.4.0-beta.6"
version = "0.4.0-beta.7"
dependencies = [
"base64 0.22.1",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hermes-relay-tray"
version = "0.4.0-beta.6"
version = "0.4.0-beta.7"
description = "Compact Windows management UI for Hermes-Relay CLI"
authors = ["Axiom Labs"]
edition = "2021"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@hermes-relay/tray-ui",
"version": "0.4.0-beta.6",
"version": "0.4.0-beta.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hermes-relay/tray-ui",
"version": "0.4.0-beta.6",
"version": "0.4.0-beta.7",
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"lucide-react": "^0.468.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hermes-relay/tray-ui",
"private": true,
"version": "0.4.0-beta.6",
"version": "0.4.0-beta.7",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hermes-Relay CLI UI",
"version": "0.4.0-beta.6",
"version": "0.4.0-beta.7",
"identifier": "com.axiomlabs.hermes-relay-tray",
"build": {
"beforeDevCommand": "npm run dev",
+10
View File
@@ -46,6 +46,16 @@ Android workflow from the selected `dev` ref.
## Local use
### Message appearance previews
In a development build, open Developer settings → Message previews to exercise
the production info, success, progress, warning, error, and Retry/Dismiss surfaces.
These samples make no network requests and do not change connection state.
Progress remains visible until replaced or cleared. Clear previews and leaving
the screen remove only sample messages, preserving real app feedback. Use these
controls for on-device theme, text sizing, readability, and action checks; they
do not substitute for transport/error-routing tests.
The Windows `scripts/dev.bat` commands and `scripts/android-prepush.py` acquire
the lane automatically. For an ad hoc Gradle command, use:
+7 -1
View File
@@ -3982,7 +3982,13 @@ compression path instead of forking the relationship. New Bot creates an upstrea
only the small `hermes-bots` metadata marker; profile skills/model remain managed
through the established Hermes surfaces.
Every Bot owner is the immutable `(connectionId, profile)` pair. A typed route
Every Bot owner is the immutable `(connectionId, profile)` pair. Both the active
Bot strip and conversation list use that pair for stable Compose item identity;
opening progress belongs to the same exact owner. Profile names, display labels,
and handles alone are not unique across installations. Upstream
[`profiles.list`](https://github.com/NousResearch/hermes-agent/blob/2db0c7a2d8f29debe7d1cbfb4a72f4f98dc00808/tui_gateway/methods_profiles.py)
returns installation-local profile names and profile-local session summaries.
A typed route
pool holds separate clients for separate owners, validates bearer authority
against that connection's exact trusted Dashboard base, adds the profile to the
WebSocket URL, mints a fresh one-use ticket on every dial, and uses request or
+10
View File
@@ -68,6 +68,8 @@ the upstream contract identifiers it depends on.
|---|---|
| `initial_history_bind` | Durable, profile-scoped history is already available when the client resumes and first binds its rendered transcript |
| `ordinary_turn` | Normal message start, deltas, completion, and persisted history |
| `session_initialization_failure` | Exact-session initialization error arrives before a lazy create acknowledgement; Android must fail the pending send without waiting for the readiness deadline |
| `subagent_child_preview` | Child activity continues after the parent terminal, followed by child completion and a separate completion wake; preview ownership remains on the same profile/session |
| `ownership_rejection` | A submit acknowledged before the defense-in-depth ownership check emits the canonical terminal refusal; no user/model row is persisted and clients must not enter history recovery |
| `compaction_status` | Compaction status is client-visible before terminal completion and may repeat as a heartbeat |
| `rapid_tools_interims` | Rapid chunks, reasoning, tool activity, and interim assistant boundaries |
@@ -82,6 +84,14 @@ the upstream contract identifiers it depends on.
| `active_status_unsupported` | An older Gateway returns JSON-RPC method-not-found; the client retains Unknown rather than inventing Idle or Working |
| `cross_client_observation` | A second client observes a Desktop-owned working session through active status and history without resume, activate, submit, or interrupt; the producing client receives the terminal event |
Activity receipts join canonical completion metadata by exact delegation identity
within the connection/profile/session owner. Process notices use the canonical
process ID; ambiguous reused process generations do not attach cached output.
Local metadata recovery never asserts live execution, and receipt projection
does not modify the transport/model/voice transcript. Controller/store tests
cover late identity enrichment, partial groups, stale loads, removal, and bounded
retention; rendered tests cover active-strip disappearance and historical controls.
Fixture evidence is a bounded metadata-only ring. It records sequence,
connection number, RPC method, event type, scope classification, and outcome.
It never records prompts, responses, RPC parameters, credentials, URLs,
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {

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