Compare commits

..
Author SHA1 Message Date
Bailey Dixon ca98f2ae97 Merge pull request #317 from Codename-11/dev
Release Android 1.7.1 and Server 1.6.1
2026-08-08 19:44:56 -04:00
Bailey Dixon ee08187c67 release(server): server-v1.6.1 2026-08-08 19:15:32 -04:00
Bailey Dixon 7ccde11a2d release(android): android-v1.7.1 2026-08-08 19:15:31 -04:00
Bailey Dixon 2378e6ab9f Merge branch 'fix/android-stream-tail-follow' into dev 2026-08-08 18:37:07 -04:00
Bailey Dixon 9045550317 fix(android): follow growing streamed replies 2026-08-08 18:37:01 -04:00
Bailey Dixon 7074920625 test(android): reconcile post-release issue coverage 2026-08-08 18:01:50 -04:00
Bailey Dixon cac8a517b4 Merge branch 'fix/android-nous-hosted-onboarding' into dev 2026-08-08 16:26:38 -04:00
Bailey Dixon c27019cdbb fix(connections): stabilize hosted Hermes onboarding 2026-08-08 16:26:33 -04:00
Bailey Dixon d0b060fe12 Merge branch 'fix/android-session-owned-queue' into dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt
2026-08-08 13:48:09 -04:00
Bailey Dixon 098cc82711 Merge branch 'fix/android-agent-passport-safety-sheet' into dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
2026-08-08 13:30:55 -04:00
Bailey Dixon 98a311afb5 fix(android): keep queued messages with origin session 2026-08-08 13:29:21 -04:00
Bailey Dixon cb1560fff3 fix(android): improve agent passport safety controls 2026-08-08 13:27:32 -04:00
Bailey Dixon 76bb9f8dcb Merge branch 'fix/issue-316-markdown-completion' into dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
2026-08-08 12:39:54 -04:00
Bailey Dixon 1eb0393dd9 fix(android): render markdown when replies complete 2026-08-08 12:38:13 -04:00
Bailey Dixon 96e6d29c9e Merge branch 'fix/android-approval-explicit-consent' into dev 2026-08-08 12:34:55 -04:00
Bailey Dixon f9bf4e384f fix(android): require explicit approval decisions 2026-08-08 12:34:46 -04:00
Bailey Dixon 615119b100 Merge branch 'fix/android-session-pin-archive' into dev
# Conflicts:
#	CHANGELOG.md
#	docs/decisions.md
2026-08-08 09:24:40 -04:00
Bailey Dixon cfe86b6cc3 fix(android): persist session pin and archive state 2026-08-08 08:56:55 -04:00
Bailey Dixon 677914a51d Merge branch 'fix/android-active-tool-card-expansion' into dev 2026-08-08 08:47:13 -04:00
Bailey Dixon 8b3adf10c6 fix(android): preserve live tool card expansion 2026-08-08 08:47:07 -04:00
Bailey Dixon 5cc8e7187b Merge branch 'fix/android-model-inventory-identity' into dev 2026-08-08 08:42:55 -04:00
Bailey Dixon ece46cae24 fix(android): normalize model inventory identities 2026-08-08 08:42:47 -04:00
Bailey Dixon c4d3934cfe chore: backmerge Android 1.7.0 and Server 1.6.0 releases 2026-08-06 21:59:49 -04:00
Bailey Dixon 545d238fd2 Merge pull request #311 from Codename-11/dev
release: Android 1.7.0 and Server 1.6.0
2026-08-06 21:38:49 -04:00
Bailey Dixon 9b36a34e2a release(android): android-v1.7.0 2026-08-06 21:01:01 -04:00
Bailey Dixon 7c35a51aed release(server): server-v1.6.0 2026-08-06 21:00:52 -04:00
Bailey Dixon 474147cb62 test(server): align session TTL route expectations 2026-08-06 20:59:59 -04:00
Bailey Dixon 0bd246586a chore: backmerge released main into dev 2026-08-06 20:16:54 -04:00
Bailey Dixon fc6aaffc11 fix(android): keep restored chats bottom-pinned 2026-08-05 22:08:55 -04:00
Bailey Dixon 197b23f344 feat: resolve Codex effort levels dynamically 2026-08-05 21:28:43 -04:00
Bailey Dixon 0862b4c346 Merge branch 'fix/android-provider-effort-levels' into dev 2026-08-05 21:09:51 -04:00
Bailey Dixon 9c7bc46c7c feat(android): explain reasoning effort compatibility 2026-08-05 21:09:37 -04:00
Bailey Dixon 9554eab757 Merge fix/android-provider-effort-levels into dev 2026-08-05 20:38:32 -04:00
Bailey Dixon 3cacc7d6d6 Merge dev into fix/android-provider-effort-levels
# Conflicts:
#	DEVLOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt
2026-08-05 20:37:14 -04:00
Bailey Dixon 311888a9fb fix(android): settle chat viewport and pet grounding 2026-08-05 20:35:22 -04:00
Bailey Dixon 96dfe472ef feat: resolve reasoning efforts through relay 2026-08-05 20:34:56 -04:00
Bailey Dixon 7d6a0215ba fix(android): stabilize chat drawer interactions 2026-08-05 19:55:55 -04:00
Bailey Dixon 51ec55e08c fix(android): restore developer settings imports 2026-08-05 19:23:31 -04:00
Bailey Dixon 4edcd18398 Merge fix/standard-voice-speech into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt
2026-08-05 19:19:27 -04:00
Bailey Dixon 3f50a94d03 Merge fix/developer-options-data into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/data/FeatureFlags.kt
#	app/src/main/kotlin/com/hermesandroid/relay/ui/components/ActiveConnectionSections.kt
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/DeveloperSettingsScreen.kt
#	app/src/test/kotlin/com/hermesandroid/relay/data/FeatureFlagsTest.kt
#	docs/localization-status.json
2026-08-05 19:18:59 -04:00
Bailey Dixon 94d2231a80 Merge chore/backmerge-main-after-android-1.6.0 into dev 2026-08-05 19:17:30 -04:00
Bailey Dixon 5ac798d5b3 Merge feature/local-dev-work-20260805 into dev 2026-08-05 19:17:17 -04:00
Bailey Dixon ebe1a8ebc4 chore(android): streamline local dev loop 2026-08-05 19:16:36 -04:00
Bailey Dixon fc3a1c169b fix(android): stabilize pet and model sync feedback 2026-08-05 19:08:27 -04:00
Bailey Dixon 6eb2c8bd9e fix(android): smooth active session glow loop 2026-08-05 18:53:31 -04:00
Bailey Dixon cbe323cf6d Merge fix/android-chat-tail-settle into dev 2026-08-05 18:35:50 -04:00
Bailey Dixon 5401e8f274 fix(android): keep completed chat tail stable 2026-08-05 17:58:09 -04:00
Bailey Dixon 0b62afa6c3 Merge feature/android-session-activity-ui into dev 2026-08-05 16:54:50 -04:00
Bailey Dixon 02801dda70 feat(android): surface active session states 2026-08-05 16:52:56 -04:00
Bailey Dixon 39011d5600 Merge pull request #307 from Codename-11/fix/android-focus-pointer-input
fix(android): restore focus voice controls
2026-08-05 16:21:32 -04:00
Bailey Dixon e50c644d09 fix(android): restore focus voice controls 2026-08-05 14:30:20 -04:00
Bailey Dixon 3ff4e7cf8e Merge pull request #304 from Codename-11/fix/android-actionable-diagnostics
fix(android): make connection diagnostics actionable
2026-08-05 10:52:31 -04:00
Bailey Dixon 683d9712d8 fix(android): make connection diagnostics actionable 2026-08-05 10:36:56 -04:00
Bailey Dixon 7ae4a3987e Merge pull request #301 from Codename-11/feature/android-reliability-support
feat(android): unify local reliability reporting
2026-08-04 21:44:52 -04:00
Bailey Dixon bbea5d1b73 Merge pull request #297 from Codename-11/dev
fix(release): repair Server 1.5.1 metadata
2026-08-03 22:25:29 -04:00
Bailey Dixon c3ff201ecc Merge pull request #294 from Codename-11/dev
release: server-v1.5.1 and android-v1.6.1
2026-08-03 22:20:48 -04:00
Bailey Dixon 9fdb11ae94 chore(android): script foreground service demo capture 2026-08-02 20:56:31 -04:00
Bailey Dixon fd35c9b2b6 docs(android): document Play foreground services 2026-08-02 19:47:52 -04:00
Bailey Dixon 9dc625ddd0 chore: backmerge Android 1.6.0 release 2026-08-02 19:46:48 -04:00
Bailey Dixon e5b25ab650 Merge pull request #284 from Codename-11/dev
release(android): android-v1.6.0
2026-08-02 19:28:01 -04:00
Bailey Dixon 0e6d64f987 fix(android): restore standard voice narration 2026-07-26 08:42:04 -04:00
Bailey Dixon e3bc816624 fix(android): repair developer options and data actions 2026-07-25 16:29:36 -04:00
148 changed files with 10970 additions and 1471 deletions
+39 -2
View File
@@ -6,16 +6,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Fixed
- **Issue area labels require maintainer review.** The unreliable keyword-based auto-labeling workflow no longer assigns ownership from ambiguous issue text.
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
## [Android 1.7.1] - 2026-08-08
### Fixed
- **Android chat follows a growing live reply.** Bottom-owned conversations now observe each replacement of the streaming message list, keeping newly added lines visible while preserving the reader's position after a manual scroll away.
- **Hosted Hermes onboarding completes through the official Dashboard sign-in path.** Android recognizes hosted account addresses, uses the system-browser native PKCE flow, and resumes the verified Dashboard session after its loopback callback.
- **Live Android tool cards remain expandable while a run is active.** Streaming Gateway updates preserve stable card identity and merge tool arguments and result previews into the existing row, so details can be opened before the session finishes.
- **Completed Android replies format Markdown immediately.** Live assistant text keeps its stable plain renderer only while incomplete, then the same owned row transitions to rich code blocks, lists, emphasis, and links without leaving or reopening the session.
- **Android approval cards require an explicit labeled decision.** Reading or scrolling a guarded command, navigating away, backgrounding, recomposition, later turn activity, and card dismissal cannot submit or locally resolve it; pending requests remain bound to their owning profile and session until an explicit response or authoritative upstream expiry.
- **Android Agent Passport controls are readable and easy to dismiss.** Safety and speed choices use full-width accessible targets with plain-language selected-state explanations, while a persistent close action and boundary-aware downward swipe make the sheet reliably dismissible without stealing nested content scrolling.
- **Android queued messages stay with their originating chat.** Follow-ups now retain their exact connection, profile, session, run, route, attachments, and voice context across concurrent Gateway session switches instead of following whichever session is visible when a run finishes.
- **Android model pickers reject duplicate catalog identities before rendering.** Repeated provider/model rows from cached or refreshed inventories are merged at the provider boundary, while identical model IDs under different providers remain distinct choices with provider-aware reasoning capabilities.
- **Android session pins and archives survive app restarts.** The session drawer now reads and updates the owning Hermes profile's durable session metadata, rolls failed changes back, and makes unpinned stars clearly distinct in light theme.
## [1.6.1] - 2026-08-08
### Fixed
- **The Dashboard plugin hands hosted Hermes connections to Android reliably.** Mobile setup exposes the canonical Dashboard address and keeps dialog focus handling contained, so system-browser authentication can return to the correct connection without disrupting the Dashboard.
## [Android 1.7.0] - 2026-08-06
### Added
- **Android exposes provider-aware reasoning controls.** The effort drawer consumes exact upstream or optional Relay capability metadata for each provider/model identity, while unmodified or older Hermes installations retain a fail-soft standard fallback including `max` and `ultra`.
- **Android support information is local, redacted, and reviewable.** Fatal crashes and handled failures share a bounded on-device record, Diagnostics can copy or share the exact reviewed text, and nothing is uploaded automatically.
### Fixed
- **Android chat chrome follows its active interaction state.** Opening the session drawer dismisses the composer keyboard, refreshed sessions keep their newest row visible, and floating pets wait for measured chat terrain, sit flush on supported rails, and treat the complete scroll-to-bottom control as forbidden space.
- **Android pets and optional model discovery initialize quietly.** Floating companions wait for a measured overlay before taking their home position, and background API model-inventory failures retain actionable local diagnostics without interrupting chat with a generic notice.
- **Android chat and Voice stay precisely bottom-pinned through replies, restores, and layout changes.** The active tail keeps its stable live renderer until another row takes ownership, restored sessions follow late composer and message measurement without overriding a reader, and bottom-owned transcripts settle to the exact list boundary after replies and keyboard animations instead of leaving a small hidden remainder.
- **Android Focus voice controls remain responsive.** The modal click-through guard now sits behind the voice UI instead of consuming pointer events from the mic, close, expansion, and panel controls.
- **Android diagnostics explain what failed and what to try next.** Relay, route, WebSocket, and API checks distinguish the saved route from the redacted request they actually attempted, name the operation, and provide targeted guidance for connection, DNS, timeout, TLS, authentication, rate-limit, and server failures.
- **Android chat and Voice keep one render identity through recovery.** Checkpoint restore, streamed callbacks, server-ID adoption, and replay now resolve the same owned transcript row before publication, preventing recurring Compose duplicate-key crashes.
- **Issue area labels require maintainer review.** The unreliable keyword-based auto-labeling workflow no longer assigns ownership from ambiguous issue text.
- **Android crash reports retain actionable release context.** Reports identify the Android surface, avoid exposing hosts and credentials, migrate earlier local crash records, and release automation retains exact Play and sideload R8 mappings for retrace.
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
## [1.6.0] - 2026-08-06
### Added
- **Relay supplies exact provider/model reasoning capabilities when providers expose them.** The bounded, profile-aware overlay resolves dynamic catalogs for OpenAI Codex, Copilot, LM Studio, and Ollama Cloud, keeps provider credentials on the host, and leaves unknown or unavailable catalogs on the advisory fallback.
## [Android 1.6.1] - 2026-08-03
+32 -7
View File
@@ -17,17 +17,41 @@ That's it — no extra setup or credentials required for a debug build.
Helper scripts for common development tasks:
```bash
scripts/dev.bat build # Build debug APK
scripts/dev.bat build # Build the sideload debug APK
scripts/dev.bat compile # Compile sideload Kotlin only
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Run one test class
scripts/dev.bat install-fast # Build arm64 only + install + launch
scripts/dev.bat release # Build signed release APK
scripts/dev.bat bundle # Build release AAB for Google Play
scripts/dev.bat run # Build + install + launch + logcat
scripts/dev.bat test # Run unit tests
scripts/dev.bat run # Build sideload + install + launch + logcat
scripts/dev.bat test # Run sideload debug unit tests
scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start relay server (dev, no TLS)
```
Linux/macOS equivalent lives at `scripts/dev.sh`.
### Fast Android iteration
Gradle's daemon, local build cache, configuration cache, and parallel task
execution are enabled for repeat local builds. Keep the same Gradle JVM
configuration between invocations and do not add `--no-daemon` to normal dev
commands; a different heap or Java home starts a separate daemon and discards
the warm-process benefit.
Use the narrowest command that proves the change:
1. `scripts/dev.bat compile` for a Kotlin compile check.
2. `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"` for a focused regression.
3. `scripts/dev.bat install-fast` when the result must run on the connected
arm64 phone. This passes `-Phermes.devAbi=arm64-v8a`, avoiding the x86,
x86_64, and armeabi-v7a native libraries in the local APK.
4. `scripts/dev.bat prepush` before pushing Android work.
`install-fast` is intentionally phone-specific. Use `install` for a universal
sideload debug APK or when the target ABI is not arm64. Release builds remain
universal and are unaffected unless `-Phermes.devAbi` is explicitly supplied.
## Repository Structure
```
@@ -51,12 +75,12 @@ The legacy `relay_server/` directory is a thin compatibility shim around `plugin
| Component | Stack |
|-----------|-------|
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
| **Android App** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
| **Relay Server** | Python 3.11+, aiohttp |
| **Serialization** | kotlinx.serialization |
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 |
| **CI/CD** | GitHub Actions (lint, build, test, signed APK artifacts) |
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
| **Min SDK** | 26 (Android 8.0) / Target SDK 36 |
## Running the Relay Locally
@@ -174,7 +198,8 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
CI in one cached Gradle invocation. Run it before pushing Android PR updates
to catch common hosted failures without waiting for another full Actions
cycle; hosted CI remains the exhaustive all-variant gate.
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
- **Focused Android unit test:** `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"`
- **Android unit tests:** `scripts/dev.bat test` (runs the sideload debug JUnit + MockK + Compose suite)
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
+184
View File
@@ -1,5 +1,189 @@
# Hermes-Relay — Dev Log
## 2026-08-08 — Streaming reply tail follow
Android's conversation-bottom follower now reads the current immutable message
list from Compose state inside its long-lived layout observer. Each streamed
replacement can therefore advance the viewport as the active bubble gains
lines, while dragging or scrolling away still releases bottom ownership.
## 2026-08-08 — Settled live replies transition to Markdown
Android now releases the live plain-text renderer immediately after an
assistant row settles. The transition retains the row's stable UI identity,
commits the final live frame before replacing its selectable text topology,
and anchors the same bottom-owned row during the Markdown remeasure. Readers
who scrolled away retain their viewport, while completed code fences, lists,
emphasis, links, and interrupted partial replies no longer require session
navigation before rich rendering appears.
## 2026-08-08 — Explicit-consent ownership for Android approvals
Android no longer treats unrelated Gateway activity or a terminal display event
as proof that an approval was resolved. Approval cards remain pending through
scrolling, recomposition, navigation, and background restoration, and retain
their exact connection, profile, and session ownership. Only a labeled action
that successfully reaches `approval.respond`, an authoritative upstream expiry,
or an explicit interrupt can retire the local request; the interrupt path stays
visibly denied because upstream force-denies it.
## 2026-08-08 — Agent Passport control and dismissal accessibility
The Android Agent Passport keeps its title and explicit close action outside
the nested content scroller. Material bottom-sheet gestures are enabled again,
so a downward gesture scrolls long content toward its top boundary before the
sheet receives the gesture and dismisses; backdrop and Back dismissal retain
the same callback.
Safety and speed choices now sit below their labels instead of competing for a
narrow horizontal column. Every segment provides at least a 48 dp target,
allows two-line labels, exposes radio-selection semantics, and states the
meaning of the current approval, chat override, or processing-tier choice in
plain language. The layout remains scrollable on compact heights and at larger
font scales without moving the close action off-screen.
## 2026-08-08 — Session-owned Android send queues
Android follow-up queues now capture the composing connection/profile context,
stored session, configured transport, originating run, attachments, and voice
context as one immutable destination. Queue presentation is filtered to the
visible session, while completion eligibility is tied to the exact run and live
Gateway generation that owned the queue. A completion from another session or
an older live generation cannot dispatch through the current composer route.
In-flight checkpoints retain bounded queued text across process restoration.
Attachment bytes are not copied into Preferences DataStore; an attachment queue
that cannot be restored is rejected with a visible review-and-resend notice.
Connection replacement and session deletion cancel their owned queues, while
switching among concurrent Gateway sessions preserves each session's queue.
## 2026-08-07 — Provider-owned model inventory identity
Android now normalizes Gateway and API model inventories before publishing
them to picker consumers. Repeated provider rows merge by canonical provider
slug, repeated exact model IDs collapse within that provider, and capability
metadata follows the same provider/model identity. Models intentionally offered
by different providers remain distinct choices, even when their display labels
and model IDs match.
The searchable picker groups and keys rows by provider slug plus exact model ID
instead of provider display text. Cached loads, dynamic refreshes, API aliases,
and Manage inventory use the same idempotent identity rule, preventing duplicate
catalog data from reaching keyed Compose lists without hiding valid routes.
## 2026-08-05 — Restored chat bottom ownership and effort fallback clarity
Opening an existing Android session now retains exact bottom ownership through
late, non-streaming layout changes. Composer capability controls, status rows,
and restored message content can finish measuring after history first reaches
the footer; a session-scoped geometry observer corrects those changes without
using a fixed delay. New-message following remains governed by the smooth
auto-scroll setting, while a real drag, IME ownership, and the Voice dock keep
their existing anchors.
The advisory effort drawer now states that Hermes does not advertise exact
levels for the selected model before explaining why standard options are shown.
The wording is consistent across all shipped Android locales.
## 2026-08-05 — Provider-aware reasoning effort discovery
The optional Relay plugin now exposes a bearer-protected, profile-aware model
capability overlay without requiring changes to upstream Hermes. Android merges
that overlay with the standard `model.options` inventory using exact provider
and model identities, while older or unpaired Relay installations continue with
the canonical advisory fallback.
Dynamic LM Studio, Ollama Cloud, and Copilot discovery is bounded by a shared
network limiter, cached by profile, endpoint, model, and credential fingerprint,
and fenced across refresh generations. Neither credentials nor internal cache
scope are returned to clients. Composer controls, Agent Passport, session
creation, and asynchronous server reconciliation share the same capability
resolver so a displayed effort cannot silently differ from the value sent.
## 2026-08-05 — Chat drawer and companion terrain ownership
The Chat screen now clears composer focus when the session drawer commits to
opening, dismissing the IME without continuously clearing focus from drawer
search or rename fields. Drawer refreshes override keyed list anchoring only
when the leading session identity changes, keeping the newest row visible after
activity-based reordering.
Floating companions wait for Chat's measured composer rail before publishing
their first roaming position. Their collision footprint contains both the
pointer target and rendered sprite, and the complete scroll-to-bottom control
envelope is an obstacle rather than a landing perch. Supported rails add no
visual lift, and the floating-only renderer aligns each frame's opaque bottom
edge to its canvas baseline so transparent atlas padding cannot make pets hover;
centered previews and message avatars remain unchanged.
## 2026-08-05 — Measured pet placement and passive model sync
The floating pet now remains unpublished until the app-level overlay has a
positive measured viewport. Its initial home coordinate is therefore derived
from the real safe bounds instead of the zero-size pre-measure bounds that
collapsed to the top-left corner.
API provider inventory remains an optional background catalog on Gateway-led
connections. A timeout, refusal, or unavailable optional route no longer emits
a global chat notice during initialization, reconnection, or connection-sheet
refresh. The failure is retained as a contextual warning in local Diagnostics,
including the operation, endpoint role, redacted stack trace, preserved network
cause, and targeted troubleshooting guidance. Cached and Gateway-owned model
options remain unchanged.
## 2026-08-05 — Stable chat-tail completion
Chat and Voice now treat the active streamed reply as the owner of its live
renderer until a different row becomes the conversation tail. Stream
completion retains the existing Compose subtree and list anchor; the full
Markdown renderer is deferred until the row is no longer active or the session
is revisited.
The last-in-group timestamp occupies its final geometry from the first
streaming frame and is only revealed at completion. Measured positive growth
during an active stream continues to follow the bottom without replacing the
logical anchor. Once completion layout stabilizes, a bottom-owned transcript
settles to the exact LazyColumn boundary; proximity slop is reserved for
retaining follow intent during motion and cannot define the final position. The
visible footer supplies the exact remaining distance so rounding or adjacent
layout changes cannot leave a residual forward range.
IME expansion participates in that same viewport owner. A transcript already
at the bottom advances by the measured viewport-height loss throughout the
keyboard animation, then settles exactly after inset updates stop on both open
and close. A transcript being read above the bottom preserves its existing
anchor, and a real drag cancels keyboard follow immediately. Host-side coverage
verifies renderer ownership, unchanged bubble height, exact footer settling,
keyboard arming, viewport loss, completion/IME settlement ownership, and
history-reading behavior.
## 2026-08-05 — Focus voice input boundary repair
The Focus voice presentation remains modal without installing a consuming
pointer handler on the full overlay ancestor. Its click-through guard is now a
behind-content sibling: empty-space gestures cannot reach the chat or drawer,
while the mic, close, expand/collapse, and panel controls receive their full
pointer sequence.
Host-side Compose coverage injects real touch events instead of invoking
semantic click actions. It verifies both child callback delivery and the modal
background boundary so the two requirements cannot regress independently.
## 2026-08-05 — Actionable Android connection diagnostics
Android diagnostic entries now separate the configured route from the exact
request operation and path used to test it. Relay health checks identify the
HTTP `/health` probe that precedes a WebSocket connection, route selection
records its Dashboard, API, or Relay probe, and WebSocket and API checks name
their handshake or authentication stage.
Known network and HTTP failure classes attach a bounded next step for refused
listeners, DNS, routing, timeouts, TLS, credentials, rate limits, missing
routes, and server failures. The activity list, status timeline, detail dialog,
copy text, and GitHub issue prefill all carry the same context. Public issue
text preserves protocol and request paths while redacting hosts, credentials,
queries, and user information.
## 2026-08-04 — Android transcript identity ownership
ChatHandler now owns one render identity for every published transcript row.
+4 -4
View File
@@ -1,8 +1,8 @@
# Hermes-Relay-Server v__VERSION__
**Release Date:** August 3, 2026
**Release Date:** August 8, 2026
This patch makes intentional re-pairing repair the existing device record instead of accumulating duplicate Relay sessions.
This patch makes the optional Dashboard plugin's Android setup handoff reliable for hosted Hermes connections.
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
@@ -10,8 +10,8 @@ Standard chat, session history, and Vanilla Hermes voice remain upstream-owned a
### Fixed
- **Re-pairing replaces stale credentials for the same device.** After the host approves a new pair, Relay revokes older sessions and refresh credentials that belong to that device before issuing the replacement.
- **Existing and unrelated sessions remain operator-controlled.** The Dashboard and `/relay revoke <token-prefix>` continue to provide explicit cleanup without treating optional Relay pairing as a requirement.
- **Canonical hosted-Hermes setup handoff.** The Dashboard plugin supplies the verified Dashboard address Android needs to continue through the official system-browser authentication flow.
- **Contained dialog focus behavior.** Mobile setup dialogs retain their own focus and keyboard handling without disrupting the surrounding Dashboard.
## Install / update
+9 -6
View File
@@ -267,11 +267,14 @@ Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `
```bash
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
scripts/dev.bat build # Build debug APK
scripts/dev.bat build # Build sideload debug APK
scripts/dev.bat compile # Compile sideload Kotlin only
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Focused unit test
scripts/dev.bat install-fast # arm64 phone build + install + launch
scripts/dev.bat release # Build signed release APK
scripts/dev.bat bundle # Build release AAB for Google Play
scripts/dev.bat run # Build + install + launch + logcat
scripts/dev.bat test # Run unit tests
scripts/dev.bat run # Build sideload + install + launch + logcat
scripts/dev.bat test # Run sideload debug unit tests
scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start the relay server (dev, no TLS)
```
@@ -280,13 +283,13 @@ scripts/dev.bat relay # Start the relay server (dev, no TLS)
| Component | Stack |
|-----------|-------|
| **Android app** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
| **Android app** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
| **Server / plugin** | Python 3.11+, aiohttp |
| **Serialization** | kotlinx.serialization (Android) |
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
| **Min SDK** | 26 (Android 8.0) · Target SDK 35 |
| **Min SDK** | 26 (Android 8.0) · Target SDK 36 |
<details>
<summary><b>Repository structure</b></summary>
+5 -1
View File
@@ -517,7 +517,11 @@ the new app version and a higher `appVersionCode`.
the version reference and the "Release Notes" section that gets
pasted into the Play Console "What's new" field. Keep the Play
"What's new" within **500 characters** and framed around the
release's themes, not a feature dump.
release's themes, not a feature dump. Compare its **Foreground service
permissions** section with the merged `googlePlayRelease` manifest and
complete Play Console declarations for every declared service type before
approval; the Publisher API can upload a draft and still reject promotion
when an App content declaration is missing.
#### Scrub for public distribution
+27 -13
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.6.1
# Hermes-Relay-Android v1.7.1
**Release Date:** August 3, 2026
**Release Date:** August 8, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.6.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.7.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,19 +12,33 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch clarifies optional Relay recovery, restores the full session drawer, and fixes several chat and Voice regressions.
This patch tightens Android chat ownership, interaction safety, session
durability, and hosted-Hermes onboarding after the 1.7.0 release.
## Fixed
- Optional Relay failures stay within Relay-only surfaces, use consistent status labels, and only request re-pairing when the stored Relay credential actually needs it.
- Foreground recovery reconnects immediately after ordinary backoff, while retained credentials are described as stored details rather than an active in-memory session.
- Session history loads its 200-row drawer window through upstream-compatible 100-row pages instead of failing with HTTP 422.
- Selecting text remains stable when a streamed response changes from live text to rendered Markdown.
- Manual Voice recording waits for barge-in microphone teardown and gives a useful recovery message when the microphone is unavailable.
- New-chat coaching yields while Voice owns the composer, so it no longer covers the expanding Voice drawer.
- Growing streamed replies stay visible while bottom-follow is owned, and
intentional scrollback still releases that ownership.
- Completed replies transition immediately from stable live text to rendered
Markdown without reopening the session.
- Queued follow-ups retain their originating connection, profile, session,
route, attachments, and voice context through concurrent session switches.
- Approval cards remain pending until an explicit labeled response or an
authoritative upstream expiry; scrolling, navigation, and later activity
cannot silently decide them.
- Session pins and archives persist through the owning Hermes profile, with
rollback when an update fails.
- Live tool cards retain stable identity and details while a run is active, and
duplicate model inventory rows are reconciled before rendering.
- Hosted Hermes addresses complete through the official Dashboard system-browser
sign-in flow and resume the verified connection after callback.
- Agent Passport safety controls use accessible full-width choices and a
reliable close or downward-swipe path.
## Install / Verify
- App version: **1.6.1** (versionCode **38**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- Same-device Relay re-pair replacement requires the optional Server 1.5.1 plugin; Dashboard and `/relay revoke <token-prefix>` remain available for explicit cleanup.
- App version: **1.7.1** (versionCode **40**).
- Standard Chat, sessions, Manage, and Vanilla Hermes voice continue to work
against unmodified upstream Hermes.
- The optional Relay plugin is not required for standard Android chat or hosted
Dashboard authentication.
+16
View File
@@ -24,6 +24,22 @@ multi-session protocol before treating `lifecycle=session` as an isolation claim
---
## Split fast Android unit tests from resource and screenshot tests
The quick-loop commands now narrow execution to the sideload debug variant and
support one-class filtering, but all `:app` unit tests still share one Android
test variant. That variant includes merged Android resources, gives every test
worker a 2 GiB heap, runs on JDK 21, and enables Roborazzi recording because a
small subset of Robolectric/screenshot tests requires those settings.
Create a separate resource/screenshot test lane so pure state, parser, routing,
and formatting tests can run as ordinary JVM tests without Android resource
packaging. Keep golden-image recording explicit rather than applying it to all
unit tests, preserve a CI task that runs both lanes, and benchmark cold plus
warm focused-test latency before adopting the split.
---
## Verify Android native dashboard sign-in on device
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
+20
View File
@@ -7,6 +7,15 @@ plugins {
alias(libs.plugins.play.publisher)
}
val supportedHermesDevAbis = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
val hermesDevAbi = providers.gradleProperty("hermes.devAbi").orNull
hermesDevAbi?.let { requestedAbi ->
require(requestedAbi in supportedHermesDevAbis) {
"Unsupported hermes.devAbi '$requestedAbi'. Expected one of: " +
supportedHermesDevAbis.sorted().joinToString()
}
}
// Rename output artifacts to include the app version. AGP respects
// `archivesName` for both APK (assemble*) and AAB (bundle*) outputs, so
// this single line produces `hermes-relay-<version>-<flavor>-<buildType>`
@@ -43,6 +52,17 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Optional local-only fast path for device iteration. Native voice/VAD
// dependencies make the universal sideload APK very large, while a
// connected phone needs only its own ABI. Release and normal debug
// builds remain universal unless the developer explicitly supplies
// -Phermes.devAbi=<abi>.
hermesDevAbi?.let { requestedAbi ->
ndk {
abiFilters += requestedAbi
}
}
// Feature flags — DEV_MODE enables all experimental features in debug builds
buildConfigField("boolean", "DEV_MODE", "false")
}
@@ -121,12 +121,15 @@ class OnboardingFlowTest {
}
@Test
fun connectPage_showsNearbyFirst() {
fun connectPage_recommendsGeneralSetupQr() {
setOnboardingContent()
navigateToPage(4)
composeTestRule
.onNodeWithText("Enter address instead")
.onNodeWithText("Scan Hermes setup QR")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Recommended")
.assertIsDisplayed()
}
@@ -135,7 +138,7 @@ class OnboardingFlowTest {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Enter address instead").performClick()
composeTestRule.onNodeWithText("Server or VPS").performClick()
composeTestRule.waitForIdle()
composeTestRule
@@ -148,7 +151,7 @@ class OnboardingFlowTest {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Enter address instead").performClick()
composeTestRule.onNodeWithText("Server or VPS").performClick()
composeTestRule.waitForIdle()
composeTestRule
@@ -156,12 +159,28 @@ class OnboardingFlowTest {
.assertIsDisplayed()
}
@Test
fun cloudSetup_requestsTheHostedDashboardAddress() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Nous-hosted Hermes").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Connect to Nous-hosted Hermes")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Use the complete HTTPS address shown for your hosted agent.")
.assertIsDisplayed()
}
@Test
fun connectPage_keepsPairingOptional() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Other connection methods").performClick()
composeTestRule.onNodeWithText("Advanced").performClick()
composeTestRule.waitForIdle()
composeTestRule
@@ -1 +1 @@
Optional Relay recovery now stays in the right surfaces with clear status labels and immediate foreground retry. Session history again loads its full window through upstream-compatible paging. Streamed text selection, Voice microphone handoff, and the expanding Voice drawer are also more reliable.
Growing replies now stay visible without overriding intentional scrollback, completed Markdown renders immediately, and queued follow-ups remain with their originating chat. Session pins and archives persist, approval cards require an explicit decision, Agent Passport controls are easier to use, and hosted Hermes setup completes through the official Dashboard sign-in flow.
+18 -4
View File
@@ -48,6 +48,18 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- The loopback native-PKCE result page uses this fixed, tokenless
link only to bring the installed flavor back to the foreground.
MainActivity intentionally does not interpret the URI as an auth
callback or navigation command. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="${applicationId}"
android:host="return" />
</intent-filter>
<!-- Some Android OEM assistant pickers enumerate ACTION_ASSIST
activities in addition to VoiceInteractionService providers. -->
<intent-filter>
@@ -101,8 +113,8 @@
<!-- Protects user-started active turns automatically; the optional
"Persistent connection" setting extends the same foreground
protection to idle/background connectivity (and relay-paired
device features). In main so BOTH flavors ship it. specialUse
needs a Play Console foreground-service declaration at submission. -->
device features). In main so BOTH flavors ship it. Every Play
foreground-service type needs its matching App content declaration. -->
<service
android:name=".network.upstream.GatewayKeepAliveService"
android:exported="false"
@@ -113,7 +125,8 @@
</service>
<!-- Experimental, explicitly user-started on-device wake-word listener.
Audio remains local and the service is never boot/restart started. -->
Audio remains local and the service is never boot/restart started.
The Play build's microphone type needs an App content declaration. -->
<service
android:name=".wake.WakeWordForegroundService"
android:exported="false"
@@ -122,7 +135,8 @@
<!-- User-started protection for voice capture from the system overlay.
The service does not own AudioRecord; it keeps foreground-only
microphone app-ops available while Hermes is behind another app. -->
microphone app-ops available while Hermes is behind another app.
Include this use case in the Play microphone declaration. -->
<service
android:name=".voice.VoiceOverlayForegroundService"
android:exported="false"
+57
View File
@@ -1,5 +1,62 @@
{
"versions": [
{
"version": "1.7.1",
"title": "Safer, steadier conversations",
"date": "2026-08-08",
"sections": [
{
"header": "Chat stays with you",
"bullets": [
"Growing streamed replies stay visible while you are at the bottom, and intentional scrollback remains undisturbed.",
"Completed replies render Markdown immediately while live tool details remain expandable."
]
},
{
"header": "Sessions keep their ownership",
"bullets": [
"Queued follow-ups retain their originating connection, profile, session, route, attachments, and voice context.",
"Session pins and archives persist across restarts, and duplicate model rows are reconciled before rendering."
]
},
{
"header": "Safer controls and setup",
"bullets": [
"Approval cards require an explicit labeled decision, and Agent Passport safety controls are easier to read and dismiss.",
"Hosted Hermes setup completes through the official Dashboard system-browser sign-in flow."
]
}
]
},
{
"version": "1.7.0",
"title": "Smarter controls, steadier sessions",
"date": "2026-08-06",
"sections": [
{
"header": "Model controls fit the model",
"bullets": [
"Reasoning effort choices follow the selected provider and model when an exact supported list is available.",
"Unmodified Hermes and setups without the optional Relay capability overlay keep a fail-soft standard choice list."
]
},
{
"header": "Active chats stay easy to follow",
"bullets": [
"The searchable session drawer shows which conversations are working or waiting for input.",
"Restored and completed chats remain bottom-pinned through late layout changes without overriding intentional scrollback.",
"Chat and Voice keep stable rows through recovery, and Focus Voice controls receive taps normally."
]
},
{
"header": "Support stays private and useful",
"bullets": [
"Review locally redacted support information before choosing to copy, share, or open GitHub; nothing uploads automatically.",
"Connection diagnostics identify the failed operation and offer targeted guidance without exposing hosts or credentials."
]
}
]
},
{
"version": "1.6.1",
"title": "Clearer recovery, steadier chat",
+6 -5
View File
@@ -1,6 +1,7 @@
v1.6.1 - Clearer recovery, steadier chat
v1.7.1 - Safer, steadier conversations
* Keep optional Relay recovery scoped to Relay surfaces with clear status labels.
* Restore the 200-session drawer through upstream-compatible paging.
* Stabilize streamed text selection and Voice microphone handoff.
* Keep new-chat coaching clear of the expanding Voice drawer.
* Follow growing replies without overriding intentional scrollback.
* Render completed Markdown immediately and keep live tool details expandable.
* Keep queued follow-ups with their originating chat and persist session pins and archives.
* Require explicit approval decisions and improve Agent Passport controls.
* Complete hosted Hermes setup through the official Dashboard sign-in flow.
@@ -373,6 +373,12 @@ data class ToolCall(
val outputRiskFindings: List<String> = emptyList(),
/** Upstream removed sensitive spans before emitting the findings. */
val outputRiskRedacted: Boolean = false,
/**
* Stable UI identity retained when a generating placeholder adopts its
* gateway tool ID. This keeps per-card interaction state attached to the
* logical call across streaming reconciliation and list updates.
*/
val uiKey: String = id ?: "$name:$startedAt",
)
enum class MessageRole {
@@ -409,6 +415,9 @@ data class ChatSession(
val source: String? = null,
/** Server reports a persisted session runtime/model binding. */
val hasModelConfig: Boolean = false,
/** Durable upstream session metadata, scoped by the owning connection/profile DB. */
val pinned: Boolean = false,
val archived: Boolean = false,
) {
val activityTimestamp: Long
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
@@ -32,6 +32,7 @@ data class ChatTurnCheckpoint(
val priorUserMessageCount: Int,
val baselineAssistantCount: Int,
val pendingAsk: ChatTurnAskCheckpoint? = null,
val queuedMessages: List<ChatQueuedMessageCheckpoint> = emptyList(),
val startedAt: Long,
val updatedAt: Long,
) {
@@ -41,6 +42,17 @@ data class ChatTurnCheckpoint(
}
}
@Serializable
data class ChatQueuedMessageCheckpoint(
val id: String,
val text: String,
val transport: String,
val ownerRunId: String,
val interfaceContextPrompt: String? = null,
/** Attachment bytes are intentionally not copied into Preferences DataStore. */
val hadAttachments: Boolean = false,
)
@Serializable
data class ChatTurnUserCheckpoint(
val id: String,
@@ -134,7 +134,7 @@ data class Connection(
* than to crash).
*/
fun extractDefaultLabel(apiServerUrl: String): String =
extractHost(apiServerUrl) ?: apiServerUrl
extractHost(apiServerUrl)?.let(::defaultLabelFromHost) ?: apiServerUrl
/** Preserve explicit labels while upgrading an auto-generated IP label to a discovered host name. */
fun chooseDiscoveredLabel(
@@ -160,7 +160,25 @@ data class Connection(
val primary = dashboardUrl?.trim()?.takeIf { it.isNotBlank() }
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
?: relayUrl.trim()
return extractHost(primary) ?: primary
return extractHost(primary)?.let(::defaultLabelFromHost) ?: primary
}
/**
* Nous-hosted agent gateways use the stable
* `<slug>.agents.nousresearch.com` origin contract. The public Hermes
* status response deliberately carries no tenant/agent display name,
* so a URL-only connection uses that exact single-label slug as its
* least-surprising default. Portal's human-readable agent name is only
* available through its separately authenticated discovery API.
*
* Match the complete suffix and exactly one leading DNS label. This
* avoids shortening lookalike or operator-controlled hostnames.
*/
private fun defaultLabelFromHost(host: String): String {
val suffix = ".agents.nousresearch.com"
if (!host.endsWith(suffix, ignoreCase = true)) return host
val slug = host.dropLast(suffix.length)
return slug.takeIf { it.isNotBlank() && '.' !in it } ?: host
}
private fun extractHost(url: String): String? = try {
@@ -0,0 +1,7 @@
package com.hermesandroid.relay.data
/** Live activity surfaced beside a session without conflating it with selection. */
enum class SessionActivityState {
Working,
NeedsInput,
}
@@ -27,7 +27,16 @@ data class DiagnosticLogEntry(
val severity: DiagnosticSeverity,
val title: String,
val detail: String? = null,
/** Human-readable action that produced this event, not merely its subsystem. */
val operation: String? = null,
val endpointRole: String? = null,
/** User/configuration-facing route before protocol/path normalization. */
val configuredUrl: String? = null,
/** Exact sanitized URL attempted on the wire, including the diagnostic path. */
val requestUrl: String? = null,
/** Concrete next troubleshooting step for failures with a known interpretation. */
val suggestion: String? = null,
/** Legacy single-URL field retained for diagnostics that have not needed split context. */
val url: String? = null,
val elapsedMs: Long? = null,
/**
@@ -36,7 +45,11 @@ data class DiagnosticLogEntry(
* the detail view shows this. Null for non-error / manually-recorded entries.
*/
val stacktrace: String? = null,
)
) {
/** Best route for mode inference and compact list rendering. */
val primaryUrl: String?
get() = configuredUrl ?: requestUrl ?: url
}
/**
* Current health of a single subsystem on the Diagnostics status timeline.
@@ -83,19 +96,29 @@ object DiagnosticsLog {
severity: DiagnosticSeverity = DiagnosticSeverity.Info,
title: String,
detail: String? = null,
operation: String? = null,
endpointRole: String? = null,
configuredUrl: String? = null,
requestUrl: String? = null,
suggestion: String? = null,
url: String? = null,
elapsedMs: Long? = null,
stacktrace: String? = null,
) {
val safeConfiguredUrl = sanitizeUrl(configuredUrl)
val safeRequestUrl = sanitizeUrl(requestUrl)
val entry = DiagnosticLogEntry(
timestampMs = System.currentTimeMillis(),
category = category,
severity = severity,
title = clean(title) ?: title.take(MAX_TEXT_LENGTH),
detail = clean(detail),
operation = clean(operation),
endpointRole = clean(endpointRole),
url = sanitizeUrl(url),
configuredUrl = safeConfiguredUrl,
requestUrl = safeRequestUrl,
suggestion = clean(suggestion),
url = if (safeConfiguredUrl == null && safeRequestUrl == null) sanitizeUrl(url) else null,
elapsedMs = elapsedMs,
stacktrace = redactTrace(stacktrace),
)
@@ -123,7 +146,11 @@ object DiagnosticsLog {
title: String,
detail: String? = null,
throwable: Throwable? = null,
operation: String? = null,
endpointRole: String? = null,
configuredUrl: String? = null,
requestUrl: String? = null,
suggestion: String? = null,
url: String? = null,
elapsedMs: Long? = null,
reliabilityContext: String? = null,
@@ -133,7 +160,11 @@ object DiagnosticsLog {
severity = DiagnosticSeverity.Error,
title = title,
detail = detail ?: throwable?.message,
operation = operation,
endpointRole = endpointRole,
configuredUrl = configuredUrl,
requestUrl = requestUrl,
suggestion = suggestion,
url = url,
elapsedMs = elapsedMs,
stacktrace = throwable?.let { stackTraceText(it) },
@@ -0,0 +1,42 @@
package com.hermesandroid.relay.diagnostics
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
/**
* Maps network failure classes to narrow, truthful next steps.
*
* These messages are diagnostic guidance, not recovery behavior: callers still
* own retries, routing, and authentication. Walking the cause chain preserves
* useful classification when OkHttp or a coroutine boundary wraps the socket
* exception in a higher-level failure.
*/
object NetworkDiagnosticGuidance {
fun forThrowable(throwable: Throwable, target: String): String? {
val causes = generateSequence(throwable as Throwable?) { it.cause }.take(12).toList()
return when {
causes.any { it is ConnectException } ->
"Verify $target is running and listening on the configured host and port."
causes.any { it is UnknownHostException } ->
"Verify the configured hostname resolves from this device."
causes.any { it is NoRouteToHostException } ->
"Verify the device has a network path to the configured host."
causes.any { it is SocketTimeoutException } ->
"Check network routing or firewall rules between this device and $target."
causes.any { it is SSLException } ->
"Verify the TLS scheme, certificate, and trust configuration for $target."
else -> null
}
}
fun forHttpStatus(statusCode: Int, target: String): String? = when (statusCode) {
401, 403 -> "Verify the configured $target credentials or pair the device again."
404 -> "Verify this URL points to the expected $target route and version."
429 -> "Wait for the server's backoff period before retrying."
in 500..599 -> "Check the $target server logs for the failing request."
else -> null
}
}
@@ -15,6 +15,7 @@ import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.EndpointResolver
import com.hermesandroid.relay.network.shared.EndpointSurface
@@ -441,7 +442,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.conn_diag_socket_blocked) ?: "Relay socket blocked",
detail = "ws:// is disabled",
url = url,
operation = "Open Relay WebSocket",
configuredUrl = url,
suggestion = "Use wss:// or explicitly allow plain ws:// for a trusted LAN or VPN.",
)
return
}
@@ -452,7 +455,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.conn_diag_url_invalid) ?: "Relay socket URL invalid",
detail = "URL must start with ws:// or wss://",
url = url,
operation = "Open Relay WebSocket",
configuredUrl = url,
suggestion = "Edit or re-pair the Relay route with a ws:// or wss:// URL.",
)
return
}
@@ -488,14 +493,18 @@ class ConnectionManager(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.conn_diag_opening_insecure) ?: "Opening insecure relay socket",
url = normalized,
operation = "Open Relay WebSocket",
configuredUrl = url,
requestUrl = normalized,
)
} else {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.conn_diag_opening_socket) ?: "Opening relay socket",
url = normalized,
operation = "Open Relay WebSocket",
configuredUrl = url,
requestUrl = normalized,
)
}
@@ -985,7 +994,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Error,
title = "Invalid relay URL",
detail = "The relay address could not be parsed; re-pair to refresh it.",
url = url,
operation = "Build Relay WebSocket request",
configuredUrl = url,
suggestion = "Edit or re-pair the Relay route to replace the invalid address.",
)
authenticated = false
_connectionState.value = ConnectionState.Disconnected
@@ -1018,7 +1029,8 @@ class ConnectionManager(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.conn_diag_connected) ?: "Relay socket connected",
url = url,
operation = "Relay WebSocket handshake",
requestUrl = url,
)
// TOFU: record the peer cert fingerprint if we don't have one
@@ -1079,7 +1091,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.conn_diag_closed) ?: "Relay socket closed",
detail = "code=$code reason=$reason",
url = url,
operation = "Relay WebSocket session",
requestUrl = url,
suggestion = if (code == 1000) null else "Check the Relay server logs for the matching close code and reason.",
)
authenticated = false
_connectionState.value = ConnectionState.Disconnected
@@ -1102,7 +1116,11 @@ class ConnectionManager(
t.message,
code?.let { "HTTP $it" },
).joinToString(": "),
url = url,
operation = "Relay WebSocket handshake",
requestUrl = url,
suggestion = code?.let {
NetworkDiagnosticGuidance.forHttpStatus(it, "Relay")
} ?: NetworkDiagnosticGuidance.forThrowable(t, "Relay"),
)
lastUpgradeResponseCode = code
if (response == null) {
@@ -7,10 +7,12 @@ import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
@@ -59,6 +61,10 @@ class RelayHttpClient(
companion object {
private const val TAG = "RelayHttpClient"
const val MAX_MODEL_CAPABILITY_ROWS = 64
private const val MAX_MODEL_CAPABILITY_PROVIDER_CHARS = 128
private const val MAX_MODEL_CAPABILITY_MODEL_CHARS = 512
private const val MAX_MODEL_CAPABILITY_PROFILE_CHARS = 128
private val sessionsJson = Json {
ignoreUnknownKeys = true
isLenient = true
@@ -710,6 +716,37 @@ class RelayHttpClient(
@SerialName("age_seconds") val ageSeconds: Int? = null,
)
@Serializable
data class ModelCapabilityRequestRow(
val provider: String,
val model: String,
)
@Serializable
data class ModelCapabilitiesRequest(
@SerialName("schema_version") val schemaVersion: Int = 1,
val profile: String? = null,
val refresh: Boolean = false,
val models: List<ModelCapabilityRequestRow>,
)
@Serializable
data class ModelCapabilityRow(
val provider: String,
val model: String,
val reasoning: Boolean? = null,
@SerialName("reasoning_efforts") val reasoningEfforts: List<String> = emptyList(),
@SerialName("reasoning_efforts_exact") val reasoningEffortsExact: Boolean = false,
val source: String = "",
)
@Serializable
data class ModelCapabilitiesResponse(
@SerialName("schema_version") val schemaVersion: Int = 1,
@SerialName("contract_version") val contractVersion: String = "",
val capabilities: List<ModelCapabilityRow> = emptyList(),
)
/** Fetch the installed plugin/protocol/profile capability contract. */
suspend fun fetchRelayInfo(): Result<RelayInfo?> = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
@@ -743,6 +780,64 @@ class RelayHttpClient(
}
}
/** Optional provider/model reasoning overlay; 404 and no pairing are fail-soft. */
suspend fun fetchModelCapabilities(
models: List<ModelCapabilityRequestRow>,
profile: String? = null,
refresh: Boolean = false,
): Result<ModelCapabilitiesResponse?> = withContext(Dispatchers.IO) {
val boundedModels = models
.asSequence()
.map {
ModelCapabilityRequestRow(
it.provider.trim().take(MAX_MODEL_CAPABILITY_PROVIDER_CHARS),
it.model.trim().take(MAX_MODEL_CAPABILITY_MODEL_CHARS),
)
}
.filter { it.provider.isNotEmpty() && it.model.isNotEmpty() }
.distinct()
.take(MAX_MODEL_CAPABILITY_ROWS)
.toList()
if (boundedModels.isEmpty()) return@withContext Result.success(null)
val relayUrl = relayUrlProvider()?.trim().orEmpty()
val token = sessionTokenProvider()
if (relayUrl.isEmpty() || token.isNullOrBlank()) return@withContext Result.success(null)
val base = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val url = runCatching { "$base/relay/model-capabilities".toHttpUrl() }.getOrElse {
return@withContext Result.success(null)
}
val payload = ModelCapabilitiesRequest(
profile = profile?.trim()?.take(MAX_MODEL_CAPABILITY_PROFILE_CHARS)
?.takeIf { it.isNotEmpty() },
refresh = refresh,
models = boundedModels,
)
val request = Request.Builder()
.url(url)
.post(sessionsJson.encodeToString(payload).toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer $token")
.header("Accept", "application/json")
.build()
try {
okHttpClient.newBuilder().callTimeout(4, java.util.concurrent.TimeUnit.SECONDS).build()
.newCall(request).execute().use { response ->
if (response.code == 404) return@withContext Result.success(null)
if (!response.isSuccessful) return@withContext Result.failure(IOException("HTTP ${response.code}"))
val body = response.body?.string().orEmpty()
val parsed = body.takeIf { it.isNotBlank() }?.let {
sessionsJson.decodeFromString(ModelCapabilitiesResponse.serializer(), it)
}
if (parsed?.schemaVersion != 1) Result.success(null) else Result.success(parsed)
}
} catch (e: Exception) {
Log.w(TAG, "fetchModelCapabilities failed: ${e.message}")
Result.failure(e)
}
}
/**
* Ask the relay whether a newer plugin release is available — it compares its
* installed version against the latest `plugin-v*` GitHub release (cached an
@@ -1139,6 +1234,7 @@ class RelayHttpClient(
logSuccess: Boolean = true,
): Result<RelayHealth> = withContext(Dispatchers.IO) {
val trimmed = relayUrl.trim()
val operation = "Relay health probe before WebSocket connection"
if (trimmed.isEmpty()) {
return@withContext Result.failure(
IllegalArgumentException("Relay URL is empty")
@@ -1159,7 +1255,9 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.http_diag_url_invalid) ?: "Relay URL invalid",
detail = e.message,
url = relayUrl,
operation = operation,
configuredUrl = relayUrl,
suggestion = "Enter a Relay URL beginning with ws:// or wss://.",
)
return@withContext Result.failure(
IOException("Invalid relay URL: ${e.message}")
@@ -1180,6 +1278,7 @@ class RelayHttpClient(
.get()
.header("Accept", "application/json")
.build()
val requestUrl = request.url.toString()
try {
fastClient.newCall(request).execute().use { response ->
@@ -1189,8 +1288,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "HTTP ${response.code}",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forHttpStatus(response.code, "Relay"),
)
return@withContext Result.failure(
IOException("Relay responded HTTP ${response.code}")
@@ -1203,8 +1305,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Empty response",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Verify this route points to a Hermes-Relay server and inspect its logs.",
)
return@withContext Result.failure(
IOException("Relay returned an empty response")
@@ -1220,8 +1325,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Non-JSON response",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Verify this route points to a Hermes-Relay server rather than another HTTP service.",
)
return@withContext Result.failure(
IOException("Relay returned non-JSON: ${e.message ?: "parse error"}")
@@ -1234,8 +1342,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "status=${status ?: "missing"}",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Check the Relay service health and server logs.",
)
return@withContext Result.failure(
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
@@ -1248,8 +1359,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Missing version field",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Verify this route points to a current Hermes-Relay server.",
)
return@withContext Result.failure(
IOException("Response doesn't look like a hermes-relay — missing 'version' field")
@@ -1265,7 +1379,9 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.http_diag_health_ok) ?: "Relay health ok",
detail = "version=$version clients=$clients sessions=$sessions",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
}
@@ -1278,8 +1394,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_timeout) ?: "Relay health timeout",
detail = "No HTTP response in 3s",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(IOException("Relay is not responding (3s timeout)"))
} catch (e: java.net.ConnectException) {
@@ -1289,8 +1408,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.http_diag_conn_refused) ?: "Relay connection refused",
detail = e.message,
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(IOException("Connection refused — is the relay running on this URL?"))
} catch (e: IOException) {
@@ -1300,8 +1422,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = e.message ?: "Network error",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(IOException("Network error: ${e.message ?: "unreachable"}"))
} catch (e: Exception) {
@@ -1311,8 +1436,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = e.message ?: e.javaClass.simpleName,
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(e)
}
@@ -9,6 +9,7 @@ import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
@@ -353,6 +354,10 @@ class EndpointResolver(
surface: EndpointSurface,
): Boolean {
val startedAtMs = clock()
val operation = when (surface) {
EndpointSurface.Standard -> "Dashboard or API route health probe"
EndpointSurface.Relay -> "Relay route health probe"
}
val target = probeTarget(candidate, surface)
val url = target?.requestUrl?.toHttpUrlOrNull()
?: run {
@@ -362,8 +367,10 @@ class EndpointResolver(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
detail = "No valid Dashboard, API, or Relay URL",
operation = operation,
endpointRole = candidate.role,
url = candidate.primaryRouteUrl(),
configuredUrl = candidate.primaryRouteUrl(),
suggestion = "Edit or re-pair this route so it contains a valid service URL.",
)
recordOutcome(candidate, surface, reachable = false, detail = "Invalid route URL")
return false
@@ -397,9 +404,16 @@ class EndpointResolver(
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
title = probeTitle,
detail = if (ok) null else "HTTP ${resp.code}",
operation = operation,
endpointRole = candidate.role,
url = target.baseUrl,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = if (ok) {
null
} else {
NetworkDiagnosticGuidance.forHttpStatus(resp.code, surface.diagnosticTarget())
},
)
recordOutcome(
candidate,
@@ -415,9 +429,12 @@ class EndpointResolver(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
operation = operation,
endpointRole = candidate.role,
url = target.baseUrl,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = "Check network routing or firewall rules between this device and ${surface.diagnosticTarget()}.",
)
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
@@ -428,9 +445,12 @@ class EndpointResolver(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
operation = operation,
endpointRole = candidate.role,
url = target.baseUrl,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = "Check network routing or firewall rules between this device and ${surface.diagnosticTarget()}.",
)
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
@@ -441,10 +461,13 @@ class EndpointResolver(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
detail = e.javaClass.simpleName,
detail = humanProbeFailure(e),
operation = operation,
endpointRole = candidate.role,
url = target.baseUrl,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, surface.diagnosticTarget()),
)
recordOutcome(candidate, surface, reachable = false, detail = humanProbeFailure(e))
false
@@ -521,6 +544,11 @@ class EndpointResolver(
else -> e.javaClass.simpleName
}
private fun EndpointSurface.diagnosticTarget(): String = when (this) {
EndpointSurface.Standard -> "Dashboard or API server"
EndpointSurface.Relay -> "Relay"
}
/**
* Mark [candidate] unreachable without re-probing. Called from
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
@@ -61,7 +61,7 @@ object HermesLanDiscovery {
.callTimeout(PROBE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
.build()
coroutineScope {
val results = coroutineScope {
val semaphore = Semaphore(MAX_CONCURRENT_PROBES)
hosts.map { host ->
async {
@@ -79,6 +79,8 @@ object HermesLanDiscovery {
.thenBy { it.host },
)
}
Log.d(TAG, "scan complete hosts=${hosts.size} matches=${results.size}")
results
}
private fun probeHost(
@@ -133,8 +135,7 @@ object HermesLanDiscovery {
val body = response.body.string().take(2_048)
expectedBody(body, contentType)
}
} catch (e: Exception) {
Log.d(TAG, "probe failed url=$url type=${e.javaClass.simpleName}")
} catch (_: Exception) {
false
}
}
@@ -1962,6 +1962,8 @@ class ChatHandler {
// rows (default source). (ADR 12 — Threads surface, slice 1.)
source = item.source,
hasModelConfig = item.hasModelConfig,
pinned = item.pinned,
archived = item.archived,
)
}.sortedByDescending { it.activityTimestamp }
// Preserve the active session's optimistic row when the server list
@@ -2008,6 +2010,26 @@ class ChatHandler {
}
}
/** Apply an optimistic pin/archive mutation; a failed server write restores the copy. */
fun setSessionFlagsLocal(
sessionId: String,
pinned: Boolean? = null,
archived: Boolean? = null,
) {
_sessions.update { sessions ->
sessions.map { session ->
if (session.sessionId == sessionId) {
session.copy(
pinned = pinned ?: session.pinned,
archived = archived ?: session.archived,
)
} else {
session
}
}
}
}
/**
* Add a newly created session to the list.
*/
@@ -2726,6 +2748,7 @@ class ChatHandler {
messageId: String,
toolCallId: String,
toolName: String,
argsPreview: String? = null,
runId: String? = null,
provenance: String? = null,
) {
@@ -2753,6 +2776,7 @@ class ChatHandler {
calls[genIdx] = calls[genIdx].copy(
id = toolCallId,
name = toolName,
args = argsPreview ?: calls[genIdx].args,
isGenerating = false,
// Execution starts now — preparing time isn't runtime.
startedAt = System.currentTimeMillis(),
@@ -2765,7 +2789,7 @@ class ChatHandler {
toolCalls = msg.toolCalls + ToolCall(
id = toolCallId,
name = toolName,
args = null,
args = argsPreview,
result = null,
success = null,
isComplete = false,
@@ -2786,7 +2810,7 @@ class ChatHandler {
ToolCall(
id = toolCallId,
name = toolName,
args = null,
args = argsPreview,
result = null,
success = null,
isComplete = false,
@@ -832,6 +832,20 @@ class DashboardApiClient(
},
)
/** Persist the upstream keep flag that backs official-client session pins. */
suspend fun setSessionPinned(
sessionId: String,
pinned: Boolean,
profile: String? = null,
): Result<JsonObject> =
patchJsonObject(
"/api/sessions/${pathSegment(sessionId)}${profileQuery(profile)}",
buildJsonObject {
put("pinned", pinned)
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", it) }
},
)
/**
* Dry-run a server-backed bulk session cleanup via the dashboard
* `POST /api/sessions/prune` (`dry_run: true`). Returns what WOULD be
@@ -260,6 +260,9 @@ class GatewayChatClient(
private val _serverProvider = MutableStateFlow<String?>(null)
val serverProvider: StateFlow<String?> = _serverProvider.asStateFlow()
private val _serverModelIdentity = MutableStateFlow<GatewayModelIdentity?>(null)
val serverModelIdentity: StateFlow<GatewayModelIdentity?> = _serverModelIdentity.asStateFlow()
/**
* Active reasoning EFFORT from `session.info` (string; "" when reasoning is
* disabled). The reasoning DISPLAY mode is NOT on session.info — it stays a
@@ -269,6 +272,10 @@ class GatewayChatClient(
private val _serverReasoningEffort = MutableStateFlow<String?>(null)
val serverReasoningEffort: StateFlow<String?> = _serverReasoningEffort.asStateFlow()
private val _serverReasoningIdentity = MutableStateFlow<GatewayReasoningIdentity?>(null)
val serverReasoningIdentity: StateFlow<GatewayReasoningIdentity?> =
_serverReasoningIdentity.asStateFlow()
/**
* Server-reported credential warning (upstream `session.info.credential_warning`)
* — present ONLY when the active provider's key is missing/invalid, absent
@@ -1115,41 +1122,59 @@ class GatewayChatClient(
)
/** Answer a [GatewayAsk.Kind.CLARIFY] ask. */
suspend fun respondClarify(requestId: String, answer: String): Result<GatewayAskResponse> =
rpc(
suspend fun respondClarify(requestId: String, answer: String): Result<GatewayAskResponse> {
val respondingTurn = activeTurn
return rpc(
"clarify.respond",
buildJsonObject {
put("request_id", requestId)
put("answer", answer)
},
).map { it.gatewayAskResponse() }
).map {
it.gatewayAskResponse().also {
respondingTurn?.acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, requestId))
}
}
}
/**
* Answer a [GatewayAsk.Kind.SUDO] ask. The password must NEVER be logged
* or persisted — it exists only inside this outbound frame.
*/
suspend fun respondSudo(requestId: String, password: String): Result<GatewayAskResponse> =
rpc(
suspend fun respondSudo(requestId: String, password: String): Result<GatewayAskResponse> {
val respondingTurn = activeTurn
return rpc(
"sudo.respond",
buildJsonObject {
put("request_id", requestId)
put("password", password)
},
).map { it.gatewayAskResponse() }
).map {
it.gatewayAskResponse().also {
respondingTurn?.acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.SUDO, requestId))
}
}
}
/**
* Answer a [GatewayAsk.Kind.SECRET] ask. Empty [value] = skip (upstream
* returns `skipped: true` to the tool). The value must NEVER be logged
* or persisted — it exists only inside this outbound frame.
*/
suspend fun respondSecret(requestId: String, value: String): Result<GatewayAskResponse> =
rpc(
suspend fun respondSecret(requestId: String, value: String): Result<GatewayAskResponse> {
val respondingTurn = activeTurn
return rpc(
"secret.respond",
buildJsonObject {
put("request_id", requestId)
put("value", value)
},
).map { it.gatewayAskResponse() }
).map {
it.gatewayAskResponse().also {
respondingTurn?.acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.SECRET, requestId))
}
}
}
/**
* Answer a [GatewayAsk.Kind.APPROVAL] ask — correlated by the live
@@ -1157,6 +1182,7 @@ class GatewayChatClient(
* resolves every pending approval on the session at once.
*/
suspend fun respondApproval(choice: String, all: Boolean = false): Result<GatewayAskResponse> {
val respondingTurn = activeTurn
val sid = liveSessionId
?: return Result.failure(GatewayRpcException("no live session"))
return rpc(
@@ -1166,7 +1192,11 @@ class GatewayChatClient(
put("choice", choice)
put("all", all)
},
).map { it.gatewayAskResponse() }
).map {
it.gatewayAskResponse().also {
respondingTurn?.acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.APPROVAL, null))
}
}
}
/**
@@ -1419,23 +1449,12 @@ class GatewayChatClient(
if (refresh) put("refresh", true)
}
return rpc("model.options", params).map { result ->
val providers = (result["providers"] as? JsonArray).orEmpty().mapNotNull { el ->
val obj = el as? JsonObject ?: return@mapNotNull null
val slug = obj.stringField("slug") ?: return@mapNotNull null
GatewayModelProvider(
name = obj.stringField("name") ?: slug,
slug = slug,
models = (obj["models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
warning = obj.stringField("warning"),
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() ?: 0,
)
}
val providers = normalizeGatewayModelProviders(
(result["providers"] as? JsonArray).orEmpty().mapNotNull { el ->
val obj = el as? JsonObject ?: return@mapNotNull null
parseGatewayModelProvider(obj)
},
)
GatewayModelOptions(
providers = providers,
currentModel = result.stringField("model") ?: "",
@@ -1784,12 +1803,23 @@ class GatewayChatClient(
_serverPersonality.value =
(info.stringField("personality") ?: "").ifBlank { "none" }
}
info.stringField("model")?.takeIf { it.isNotBlank() }?.let { _serverModel.value = it }
info.stringField("provider")?.takeIf { it.isNotBlank() }?.let { _serverProvider.value = it }
val model = info.stringField("model")?.takeIf { it.isNotBlank() }
val provider = info.stringField("provider")?.takeIf { it.isNotBlank() }
model?.let { _serverModel.value = it }
provider?.let { _serverProvider.value = it }
if (model != null && provider != null) {
_serverModelIdentity.value = GatewayModelIdentity(model = model, provider = provider)
}
// reasoning effort: ignore "" (reasoning disabled) so it can't clobber
// the chip; display mode is config.get-only, not here.
info.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
?.let { _serverReasoningEffort.value = it }
val reasoningEffort = info.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
reasoningEffort?.let { _serverReasoningEffort.value = it }
if (model != null && provider != null && reasoningEffort != null) {
_serverReasoningIdentity.value = GatewayReasoningIdentity(
identity = GatewayModelIdentity(model = model, provider = provider),
effort = reasoningEffort,
)
}
// credential_warning: present only when the provider key is missing/
// invalid. ABSENT means healthy — clear to null so it self-resolves.
_serverCredentialWarning.value =
@@ -2136,13 +2166,14 @@ class GatewayChatClient(
pendingAsk.kind == expiry.kind &&
(pendingAsk.kind == GatewayAsk.Kind.APPROVAL ||
pendingAsk.requestId == expiry.requestId)
val turnResumed = pendingAsk != null &&
GatewayEventMapper.isInteractionResumeEvent(type)
if (explicitlyExpired || turnResumed) {
// Ordinary turn activity is not a decision acknowledgement. It can
// be replayed or buffered. Only an authoritative expiry retires a
// detached ask; an explicit response is retired by its foreground VM.
if (explicitlyExpired) {
backgroundTurn.pendingAsk = null
callbackDispatcher {
backgroundInteractionListener?.invoke(
GatewayBackgroundInteractionEvent.Resolved(
GatewayBackgroundInteractionEvent.Expired(
storedSessionId = backgroundTurn.storedSessionId,
profile = backgroundTurn.profile,
ask = pendingAsk,
@@ -2158,6 +2189,7 @@ class GatewayChatClient(
unmatchedTurnCompleteListener?.invoke(
GatewayBackgroundTurnCompletion(
storedSessionId = backgroundTurn.storedSessionId,
liveSessionId = eventSessionId,
profile = backgroundTurn.profile,
expectedAssistantText = expectedText,
),
@@ -2232,6 +2264,7 @@ class GatewayChatClient(
unmatchedTurnCompleteListener?.invoke(
GatewayBackgroundTurnCompletion(
storedSessionId = storedId,
liveSessionId = eventSessionId,
profile = liveSessionProfile,
expectedAssistantText = expectedText,
),
@@ -2640,6 +2673,9 @@ class GatewayChatClient(
fun restoreInteraction(ask: GatewayAsk) {
mapper.restoreInteraction(ask)
}
fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
mapper.acknowledgeInteraction(expiry)
}
private val deferredEventLock = Any()
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
private var eventsDeferred = deferEvents
@@ -2969,7 +3005,9 @@ class GatewayChatClient(
callbackDispatcher { callbacks.onInterimReconciled(text) }
},
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
onToolCallStart = { id, name, args ->
callbackDispatcher { callbacks.onToolCallStart(id, name, args) }
},
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
onToolCallFailed = { a, b -> callbackDispatcher { callbacks.onToolCallFailed(a, b) } },
onToolOutputRisk = { v -> callbackDispatcher { callbacks.onToolOutputRisk(v) } },
@@ -2983,7 +3021,6 @@ class GatewayChatClient(
onMoaReference = { v -> callbackDispatcher { callbacks.onMoaReference(v) } },
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
// MUST be wrapped like every other member: GatewayTurnCallbacks gives
// onStatusUpdate a default no-op, so omitting it here silently swallows
// EVERY gateway status line — the ❌ terminal-error lifecycle update
@@ -40,6 +40,15 @@ class GatewayEventMapper(
if (!duplicate) callbacks.onInteractionRequest(ask)
}
/** Retire only the ask whose explicit respond RPC reached server truth. */
internal fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
val pending = pendingInteraction ?: return
if (pending.matches(expiry)) {
pendingInteraction = null
drainDeferredTerminalEvent()
}
}
private var sawMessageStart = false
private var previousEventType: String? = null
private var sawTextDelta = false
@@ -50,6 +59,7 @@ class GatewayEventMapper(
private var compactionStatusActive = false
private var moaStatusActive = false
private var pendingInteraction: GatewayAsk? = null
private var deferredTerminalEvent: Pair<String, JsonObject?>? = null
/**
* `tool.complete` events match their `tool.start` by `tool_id`; when a
@@ -81,17 +91,17 @@ class GatewayEventMapper(
}
callbacks.onInteractionExpired(expiry)
previousEventType = type
if (pendingInteraction == null) drainDeferredTerminalEvent()
return
}
if (type in INTERACTION_RESUME_EVENTS) {
pendingInteraction?.let { ask ->
pendingInteraction = null
callbacks.onInteractionResolved(
GatewayAskExpiry(kind = ask.kind, requestId = ask.requestId),
)
}
if (pendingInteraction != null && type in TERMINAL_EVENTS) {
// A buffered/late terminal frame is not consent. Upstream blocks
// the turn on an interaction, so hold the first terminal until an
// explicit response acknowledgement or authoritative expiry.
if (deferredTerminalEvent == null) deferredTerminalEvent = type to payload
previousEventType = type
return
}
when (type) {
"reasoning.delta" -> {
val text = payload.string("text")
@@ -197,7 +207,10 @@ class GatewayEventMapper(
}
else -> syntheticToolId(name)
}
callbacks.onToolCallStart(toolId, name)
val argsPreview = payload.string("args_text")
?.takeIf { it.isNotBlank() }
?: payload.string("context")?.takeIf { it.isNotBlank() }
callbacks.onToolCallStart(toolId, name, argsPreview)
}
"tool.complete" -> {
@@ -210,7 +223,10 @@ class GatewayEventMapper(
if (!error.isNullOrEmpty()) {
callbacks.onToolCallFailed(toolId, error)
} else {
callbacks.onToolCallDone(toolId, payload.string("summary"))
val resultPreview = payload.string("result_text")
?.takeIf { it.isNotBlank() }
?: payload.string("summary")?.takeIf { it.isNotBlank() }
callbacks.onToolCallDone(toolId, resultPreview)
}
}
@@ -371,6 +387,12 @@ class GatewayEventMapper(
previousEventType = type
}
private fun drainDeferredTerminalEvent() {
val deferred = deferredTerminalEvent ?: return
deferredTerminalEvent = null
onEvent(deferred.first, deferred.second)
}
private fun syntheticToolId(name: String): String {
val id = "gateway-tool-$name-${syntheticToolCounter++}"
openSyntheticIdsByName.getOrPut(name) { ArrayDeque() }.addLast(id)
@@ -410,20 +432,7 @@ class GatewayEventMapper(
private const val MAX_MOA_LABEL_CHARS = 120
private const val MAX_MOA_REFERENCE_CHARS = 16_000
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
private val INTERACTION_RESUME_EVENTS = setOf(
"reasoning.delta",
"thinking.delta",
"reasoning.available",
"message.delta",
"message.interim",
"message.start",
"tool.generating",
"tool.start",
"tool.complete",
"message.complete",
"error",
)
private val TERMINAL_EVENTS = setOf("message.complete", "error")
internal fun isFailedMoaReference(text: String): Boolean {
val normalized = text.trimStart().lowercase()
return normalized.startsWith("[failed:") || normalized.startsWith("[skipped:")
@@ -497,8 +506,6 @@ class GatewayEventMapper(
else -> null
}
fun isInteractionResumeEvent(type: String): Boolean = type in INTERACTION_RESUME_EVENTS
/**
* Hermes 2026-07-15 emits these operational wait lines through the
* legacy `thinking.delta` display callback. Match the deliberately
@@ -1,6 +1,11 @@
package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.network.upstream.models.UsageInfo
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
/**
* Shared types for the Gateway chat transport — upstream hermes-agent's
@@ -168,6 +173,7 @@ data class GatewaySessionRecovery(
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
data class GatewayBackgroundTurnCompletion(
val storedSessionId: String,
val liveSessionId: String,
val profile: String?,
val expectedAssistantText: String?,
)
@@ -184,7 +190,8 @@ sealed interface GatewayBackgroundInteractionEvent {
override val ask: GatewayAsk,
) : GatewayBackgroundInteractionEvent
data class Resolved(
/** An authoritative upstream `*.expire` event ended this request. */
data class Expired(
override val storedSessionId: String,
override val profile: String?,
override val ask: GatewayAsk,
@@ -347,8 +354,122 @@ data class GatewayModelProvider(
val unavailableModels: List<String> = emptyList(),
val freeTier: Boolean = false,
val totalModels: Int = 0,
/** Per-model capability rows keyed by the exact model id. */
val capabilities: Map<String, GatewayModelCapabilities> = emptyMap(),
)
/** Shared tolerant parser for the gateway RPC and API-server REST twins. */
internal fun parseGatewayModelProvider(obj: JsonObject): GatewayModelProvider? {
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val capabilities = (obj["capabilities"] as? JsonObject).orEmpty().mapNotNull { (model, raw) ->
val row = raw as? JsonObject ?: return@mapNotNull null
val effortsElement = row["reasoning_efforts"]
val efforts = if (effortsElement is JsonArray) {
effortsElement
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotEmpty) }
.distinct()
} else {
null
}
val modelId = model.trim().takeIf { it.isNotEmpty() } ?: return@mapNotNull null
modelId to GatewayModelCapabilities(
reasoning = (row["reasoning"] as? JsonPrimitive)?.booleanOrNull,
reasoningEfforts = efforts,
reasoningEffortsExact =
(row["reasoning_efforts_exact"] as? JsonPrimitive)?.booleanOrNull,
)
}.toMap()
return GatewayModelProvider(
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
slug = slug,
models = (obj["models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotEmpty) }
.distinct(),
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotEmpty) }
.distinct(),
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() ?: 0,
capabilities = capabilities,
)
}
/**
* Publish one coherent row per provider identity.
*
* Dynamic catalogs and compatibility payloads can repeat a provider row or a
* model inside that row. Provider slugs are case-insensitive upstream, while
* model ids remain exact request values. Merge only equal provider slugs so a
* model intentionally offered by two different providers stays selectable.
*/
internal fun normalizeGatewayModelProviders(
providers: List<GatewayModelProvider>,
): List<GatewayModelProvider> {
val normalized = linkedMapOf<String, GatewayModelProvider>()
providers.forEach { raw ->
val slug = raw.slug.trim()
if (slug.isEmpty()) return@forEach
val models = raw.models.map(String::trim).filter(String::isNotEmpty).distinct()
val unavailable = raw.unavailableModels
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
val capabilities = raw.capabilities.mapNotNull { (model, capability) ->
model.trim().takeIf(String::isNotEmpty)?.let { it to capability }
}.toMap()
val row = raw.copy(
name = raw.name.trim().ifEmpty { slug },
slug = slug,
models = models,
unavailableModels = unavailable,
totalModels = maxOf(raw.totalModels, models.size),
capabilities = capabilities,
)
val identity = slug.lowercase()
val existing = normalized[identity]
normalized[identity] = if (existing == null) {
row
} else {
val mergedModels = (existing.models + row.models).distinct()
existing.copy(
models = mergedModels,
isCurrent = existing.isCurrent || row.isCurrent,
warning = existing.warning ?: row.warning,
authenticated = existing.authenticated || row.authenticated,
unavailableModels = (existing.unavailableModels + row.unavailableModels).distinct(),
freeTier = existing.freeTier || row.freeTier,
totalModels = maxOf(existing.totalModels, row.totalModels, mergedModels.size),
capabilities = mergeGatewayModelCapabilities(existing.capabilities, row.capabilities),
)
}
}
return normalized.values.toList()
}
private fun mergeGatewayModelCapabilities(
existing: Map<String, GatewayModelCapabilities>,
incoming: Map<String, GatewayModelCapabilities>,
): Map<String, GatewayModelCapabilities> {
val merged = existing.toMutableMap()
incoming.forEach { (model, next) ->
val current = merged[model]
merged[model] = if (current == null) {
next
} else {
GatewayModelCapabilities(
reasoning = next.reasoning ?: current.reasoning,
reasoningEfforts = next.reasoningEfforts ?: current.reasoningEfforts,
reasoningEffortsExact = next.reasoningEffortsExact ?: current.reasoningEffortsExact,
)
}
}
return merged
}
data class GatewayMoaReference(
val index: Int?,
val count: Int?,
@@ -364,6 +485,15 @@ data class GatewayModelOptions(
val currentProvider: String,
)
/** Coherent model identity from a single `session.info` payload. */
data class GatewayModelIdentity(val model: String, val provider: String)
/** Model identity and effort observed together in one `session.info` payload. */
data class GatewayReasoningIdentity(
val identity: GatewayModelIdentity,
val effort: String,
)
/** Reject provider catalogs that completed after a profile/context switch. */
internal fun isCurrentModelOptionsResponse(
requestGeneration: Long,
@@ -437,7 +567,7 @@ class GatewayTurnCallbacks(
*/
val onInterimReconciled: (text: String) -> Unit = { _ -> },
val onThinkingDelta: (String) -> Unit,
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
val onToolCallStart: (toolCallId: String, toolName: String, argsPreview: String?) -> Unit,
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
val onToolCallFailed: (toolCallId: String, errorMsg: String?) -> Unit,
/** Attach deterministic output-risk metadata to the matching tool card. */
@@ -470,8 +600,6 @@ class GatewayTurnCallbacks(
val onInteractionRequest: (GatewayAsk) -> Unit,
/** Server declared a pending interaction expired; clear only the matching card. */
val onInteractionExpired: (GatewayAskExpiry) -> Unit,
/** The turn resumed after a pending interaction was resolved elsewhere. */
val onInteractionResolved: (GatewayAskExpiry) -> Unit = { _ -> },
/**
* Gateway `status.update` lifecycle line — model fallback, retries, and
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
@@ -27,6 +27,7 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.put
@@ -213,25 +214,12 @@ internal fun parseApiProviderModelOptionsBody(
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
val rows = root["providers"] as? JsonArray ?: return null
val providers = rows.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
GatewayModelProvider(
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
slug = slug,
models = (obj["models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
?.toIntOrNull() ?: 0,
)
}
val providers = normalizeGatewayModelProviders(
rows.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
parseGatewayModelProvider(obj)
},
)
return ApiProviderModelOptions(
providers = providers,
currentModel = (root["model"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
@@ -254,7 +242,8 @@ enum class ApiModelRoutingErrorCode {
class ApiModelRoutingException(
val code: ApiModelRoutingErrorCode,
message: String,
) : IOException(message)
cause: Throwable? = null,
) : IOException(message, cause)
sealed interface ApiModelSelectionAck {
data object ServerDefault : ApiModelSelectionAck
@@ -337,7 +326,7 @@ internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOptio
root = (obj["root"] as? JsonPrimitive)?.contentOrNull,
parent = (obj["parent"] as? JsonPrimitive)?.contentOrNull,
)
}
}.distinctBy { it.id }
}
private const val STREAM_ERROR_BODY_LIMIT = 16L * 1024L
@@ -696,6 +685,34 @@ class HermesApiClient(
}
}
suspend fun setSessionPinned(sessionId: String, pinned: Boolean): Boolean =
patchSessionFlag(sessionId, "pinned", pinned)
suspend fun setSessionArchived(sessionId: String, archived: Boolean): Boolean =
patchSessionFlag(sessionId, "archived", archived)
private suspend fun patchSessionFlag(
sessionId: String,
field: String,
value: Boolean,
): Boolean = withContext(Dispatchers.IO) {
try {
val reqBody = buildJsonObject { put(field, value) }.toString()
val request = authRequest("$baseUrl/api/sessions/$sessionId")
.patch(reqBody.toRequestBody(JSON_MEDIA))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.w(TAG, "Set session $field failed: HTTP ${response.code}")
}
response.isSuccessful
}
} catch (e: Exception) {
Log.w(TAG, "Failed to set session $field: ${e.message}")
false
}
}
suspend fun getMessages(sessionId: String): List<MessageItem> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/api/sessions/$sessionId/messages")
@@ -815,6 +832,7 @@ class HermesApiClient(
ApiModelRoutingException(
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
"Model inventory could not be loaded.",
e,
)
},
)
@@ -4,17 +4,28 @@ import android.content.Context
import com.hermesandroid.relay.auth.SessionTokenStore
import com.hermesandroid.relay.auth.SecureStoreCache
import com.hermesandroid.relay.auth.buildRawTokenStore
import java.io.EOFException
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.InetAddress
import java.net.NoRouteToHostException
import java.net.SocketException
import java.net.UnknownHostException
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Collections
import java.util.IdentityHashMap
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Dns
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
@@ -28,6 +39,7 @@ import okio.ByteString.Companion.toByteString
private const val NATIVE_PKCE_FLOW = "native_pkce"
private const val CALLBACK_PATH = "/callback"
private const val TOKEN_KEY = "dashboard_native_tokens_json"
private const val NATIVE_AUTH_DNS_RETRY_BACKOFF_MILLIS = 75L
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
@Serializable
@@ -91,6 +103,8 @@ class NativeDashboardAuthClient(
baseUrl: String,
private val tokenStore: NativeDashboardTokenStore,
private val client: OkHttpClient = OkHttpClient.Builder()
.dns(RetryingNativeAuthDns())
.retryOnConnectionFailure(false)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
@@ -271,12 +285,19 @@ class NativeDashboardAuthClient(
}
throw NativeDashboardAuthHttpException(response.code)
}
val body = response.body?.string().orEmpty()
val body = response.body.string()
runCatching { json.decodeFromString<NativeDashboardTokens>(body) }
.getOrElse { throw IOException("Dashboard token response was malformed", it) }
.getOrElse {
throw NativeDashboardTokenShapeException(
"Dashboard token response was malformed",
it,
)
}
.also {
if (it.accessToken.isBlank()) {
throw IOException("Dashboard token response did not include an access token")
throw NativeDashboardTokenShapeException(
"Dashboard token response did not include an access token",
)
}
}
}
@@ -285,7 +306,7 @@ class NativeDashboardAuthClient(
NativeTokenRefreshCoordinator.currentGeneration(tokenStore.coordinationKey) !=
expectedGeneration
) {
throw IOException("Dashboard sign-in is no longer active")
throw NativeDashboardInactiveAuthorizationException()
}
tokenStore.save(tokens)
}
@@ -312,6 +333,42 @@ class NativeDashboardAuthClient(
}
}
/**
* Retries only the name lookup that precedes a native-auth request. The HTTP
* call itself remains single-shot, so a one-time authorization code is never
* replayed after the server may have consumed it.
*/
internal class RetryingNativeAuthDns(
private val delegate: Dns = Dns.SYSTEM,
private val backoffMillis: Long = NATIVE_AUTH_DNS_RETRY_BACKOFF_MILLIS,
private val sleeper: (Long) -> Unit = { Thread.sleep(it) },
) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
val firstFailure = try {
return delegate.lookup(hostname)
} catch (error: UnknownHostException) {
error
}
if (backoffMillis > 0L) {
try {
sleeper(backoffMillis)
} catch (interrupted: InterruptedException) {
Thread.currentThread().interrupt()
firstFailure.addSuppressed(interrupted)
throw firstFailure
}
}
return try {
delegate.lookup(hostname)
} catch (secondFailure: UnknownHostException) {
secondFailure.addSuppressed(firstFailure)
throw secondFailure
}
}
}
internal class NativeDashboardCallbackException(
message: String,
val retryable: Boolean = true,
@@ -430,10 +487,56 @@ class DashboardBearerAuth(
}
}
private class NativeDashboardAuthHttpException(
internal class NativeDashboardAuthHttpException(
val statusCode: Int,
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
internal class NativeDashboardTokenShapeException(
message: String,
cause: Throwable? = null,
) : IOException(message, cause)
internal class NativeDashboardInactiveAuthorizationException :
IOException("Dashboard sign-in is no longer active")
internal fun nativeDashboardSignInFailureStage(error: Throwable): String {
error.firstCauseOfType<NativeDashboardCallbackException>()?.let { return "callback_error" }
error.firstCauseOfType<NativeDashboardAuthHttpException>()?.let {
return "token_http_${it.statusCode}"
}
if (error.firstCauseOfType<NativeDashboardTokenShapeException>() != null) return "token_shape"
if (error.firstCauseOfType<NativeDashboardInactiveAuthorizationException>() != null) {
return "inactive_generation"
}
if (error.firstCauseOfType<InterruptedIOException>() != null) return "token_transport_timeout"
if (error.firstCauseOfType<UnknownHostException>() != null) return "token_transport_dns"
if (error.firstCauseOfType<ConnectException>() != null ||
error.firstCauseOfType<NoRouteToHostException>() != null
) {
return "token_transport_connect"
}
if (error.firstCauseOfType<SSLException>() != null) return "token_transport_tls"
if (error.firstCauseOfType<SocketException>() != null ||
error.firstCauseOfType<EOFException>() != null
) {
return "token_transport_socket"
}
return if (error.firstCauseOfType<IOException>() != null) "token_transport" else "token_store"
}
internal fun nativeDashboardSignInFailureDiagnostic(error: Throwable): String =
"dashboard_native_pkce_failed stage=${nativeDashboardSignInFailureStage(error)}"
private inline fun <reified T : Throwable> Throwable.firstCauseOfType(): T? {
val seen = Collections.newSetFromMap(IdentityHashMap<Throwable, Boolean>())
var current: Throwable? = this
while (current != null && seen.add(current)) {
if (current is T) return current
current = current.cause
}
return null
}
private object NativeTokenRefreshCoordinator {
private val locks = ConcurrentHashMap<String, Any>()
private val generations = ConcurrentHashMap<String, Long>()
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.BuildConfig
import java.io.IOException
import java.io.InputStream
import java.net.InetAddress
@@ -7,6 +8,7 @@ import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketTimeoutException
import java.security.MessageDigest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.currentCoroutineContext
@@ -14,12 +16,44 @@ import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okio.ByteString.Companion.toByteString
private const val CALLBACK_PATH = "/callback"
private const val MAX_REQUEST_LINE_BYTES = 8 * 1024
private const val MAX_HEADER_BYTES = 16 * 1024
private const val ACCEPT_POLL_MILLIS = 500
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 2 * 60 * 1000L
internal val NATIVE_SIGN_IN_RETURN_URI = "${BuildConfig.APPLICATION_ID}://return"
private enum class CallbackPage(
val modifier: String,
val eyebrow: String,
val title: String,
val message: String,
val guidance: String,
) {
Success(
modifier = "success",
eyebrow = "Secure sign-in",
title = "Sign-in complete",
message = "Your secure session is ready in Hermes Relay.",
guidance = "Return to Hermes Relay to continue.",
),
Failure(
modifier = "failure",
eyebrow = "Secure sign-in",
title = "Sign-in needs another try",
message = "No session details were saved from this attempt.",
guidance = "Return to Hermes Relay and start sign-in again.",
),
Rejected(
modifier = "rejected",
eyebrow = "Protected callback",
title = "Callback not accepted",
message = "Hermes Relay ignored this request to protect your sign-in.",
guidance = "Return to the app and continue the sign-in already in progress.",
),
}
internal enum class DashboardRedirectAuthMode {
NativePkce,
@@ -119,7 +153,7 @@ class NativeDashboardSignInCoordinator(
writeResponse(
socket,
status = "403 Forbidden",
body = "This sign-in callback was not accepted.",
page = CallbackPage.Rejected,
)
return@use null
}
@@ -132,7 +166,7 @@ class NativeDashboardSignInCoordinator(
writeResponse(
socket,
status = "400 Bad Request",
body = "This sign-in callback was not accepted.",
page = CallbackPage.Rejected,
)
return@use null
}
@@ -145,7 +179,7 @@ class NativeDashboardSignInCoordinator(
writeResponse(
socket,
status = "200 OK",
body = "Sign-in complete. You can return to Hermes Relay.",
page = CallbackPage.Success,
)
}
} catch (error: NativeDashboardCallbackException) {
@@ -153,21 +187,21 @@ class NativeDashboardSignInCoordinator(
writeResponse(
socket,
status = "400 Bad Request",
body = "This sign-in callback was not accepted.",
page = CallbackPage.Rejected,
)
return@use null
}
writeResponse(
socket,
status = "400 Bad Request",
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
page = CallbackPage.Failure,
)
throw error
} catch (error: Exception) {
writeResponse(
socket,
status = "400 Bad Request",
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
page = CallbackPage.Failure,
)
throw error
}
@@ -237,17 +271,20 @@ class NativeDashboardSignInCoordinator(
throw IOException("Native sign-in callback line was too large")
}
private fun writeResponse(socket: Socket, status: String, body: String) {
val html = """
<!doctype html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body><p>${escapeHtml(body)}</p></body></html>
""".trimIndent().toByteArray(Charsets.UTF_8)
private fun writeResponse(socket: Socket, status: String, page: CallbackPage) {
val html = callbackPageHtml(page).toByteArray(Charsets.UTF_8)
val headers = buildString {
append("HTTP/1.1 ").append(status).append("\r\n")
append("Content-Type: text/html; charset=utf-8\r\n")
append("Content-Length: ").append(html.size).append("\r\n")
append("Cache-Control: no-store\r\n")
append("Pragma: no-cache\r\n")
append("Expires: 0\r\n")
append("Content-Security-Policy: ").append(CALLBACK_CSP).append("\r\n")
append("Referrer-Policy: no-referrer\r\n")
append("X-Content-Type-Options: nosniff\r\n")
append("X-Frame-Options: DENY\r\n")
append("Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=(), usb=()\r\n")
append("Connection: close\r\n\r\n")
}.toByteArray(Charsets.US_ASCII)
runCatching {
@@ -259,8 +296,63 @@ class NativeDashboardSignInCoordinator(
}
}
private fun escapeHtml(value: String): String =
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
private fun callbackPageHtml(page: CallbackPage): String = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark">
<title>${page.title} · Hermes Relay</title>
<style>$CALLBACK_STYLE</style>
</head>
<body>
<main class="shell">
<section class="card ${page.modifier}" aria-labelledby="page-title" aria-describedby="page-message page-guidance">
<div class="status-rail" aria-hidden="true"></div>
<p class="brand">Hermes Relay</p>
<p class="eyebrow">${page.eyebrow}</p>
<h1 id="page-title" tabindex="-1">${page.title}</h1>
<p id="page-message" class="message">${page.message}</p>
<p id="page-guidance" class="guidance">${page.guidance}</p>
<a class="return-link" href="$NATIVE_SIGN_IN_RETURN_URI">Return to Hermes Relay</a>
</section>
</main>
</body>
</html>
""".trimIndent()
private companion object {
private val CALLBACK_STYLE = """
:root{color-scheme:dark;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#08090d;color:#f7f4ff}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;min-height:100svh;background:#08090d}
.shell{min-height:100vh;min-height:100svh;display:grid;place-items:center;padding:max(24px,env(safe-area-inset-top)) max(20px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(20px,env(safe-area-inset-left))}
.card{position:relative;width:min(100%,460px);overflow:hidden;border:1px solid #2d2938;border-radius:18px;background:#111219;padding:32px 28px 28px;box-shadow:0 18px 48px rgba(0,0,0,.34);animation:card-in 220ms ease-out both}
.status-rail{position:absolute;inset:0 auto 0 0;width:4px;background:#9b6bf0}
.success .status-rail{background:#55d98b}.failure .status-rail{background:#ff7188}
.brand{margin:0 0 28px;color:#bba4f5;font-size:.78rem;font-weight:750;letter-spacing:.12em;text-transform:uppercase}
.eyebrow{margin:0 0 8px;color:#cac4d8;font-size:.9rem;font-weight:650}
h1{margin:0;color:#fff;font-size:clamp(1.8rem,8vw,2.45rem);font-weight:720;letter-spacing:-.035em;line-height:1.08;outline:none}
h1:focus-visible{outline:2px solid #9b6bf0;outline-offset:6px;border-radius:3px}
.message{margin:18px 0 0;color:#eeeaf7;font-size:1.04rem;line-height:1.6}
.guidance{margin:12px 0 0;color:#bdb7c9;font-size:.95rem;line-height:1.55}
.return-link{display:block;width:100%;margin-top:26px;border:1px solid #8c5ef0;border-radius:12px;background:#6b35e8;color:#fff;padding:13px 18px;font:inherit;font-weight:700;text-align:center;text-decoration:none;cursor:pointer}
.return-link:hover{border-color:#c4a8ff}.return-link:focus-visible{outline:3px solid #c4a8ff;outline-offset:3px}.return-link:active{background:#5d28d3}
@keyframes card-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;scroll-behavior:auto!important;transition:none!important}}
@media (max-width:380px){.card{padding:28px 22px 24px;border-radius:16px}}
""".trimIndent()
private fun cspHash(value: String): String = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.toByteString()
.base64()
private val CALLBACK_CSP = buildString {
append("default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; ")
append("img-src 'none'; connect-src 'none'; object-src 'none'; script-src 'none'; ")
append("style-src 'sha256-").append(cspHash(CALLBACK_STYLE)).append("'")
}
}
}
@@ -0,0 +1,102 @@
package com.hermesandroid.relay.network.upstream
/** Canonical reasoning-effort values accepted by upstream Hermes. */
object ReasoningEfforts {
const val DEFAULT = "medium"
val canonical: List<String> =
listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
fun normalize(value: String?): String {
val normalized = value?.trim()?.lowercase().orEmpty()
return normalized.takeIf { it in canonical } ?: DEFAULT
}
}
/** Provider/model capability row advertised by upstream `model.options`. */
data class GatewayModelCapabilities(
/** Legacy capability flag. Null means the server did not advertise support either way. */
val reasoning: Boolean? = null,
/** Exact selectable values on newer servers. Null means use the canonical compatibility list. */
val reasoningEfforts: List<String>? = null,
/** Explicit false means the advertised list is advisory rather than selectable. */
val reasoningEffortsExact: Boolean? = null,
)
data class ReasoningEffortAvailability(
val supported: Boolean?,
val choices: List<String>,
val exact: Boolean,
) {
fun accepts(effort: String): Boolean = supported != false && (!exact || effort in choices)
}
/** Exact provider/model identity to which a confirmed effort belongs. */
data class ReasoningEffortIdentity(val provider: String, val model: String)
/**
* Resolve the active provider/model's advertised reasoning contract.
*
* Older servers expose either no capability entry or only `reasoning: true`;
* those receive the full canonical compatibility list. An explicit false
* disables the control. A `reasoning_efforts` array is authoritative.
*/
fun resolveReasoningEffortAvailability(
providers: List<GatewayModelProvider>,
provider: String?,
model: String?,
relayCapabilities: Map<ReasoningEffortIdentity, GatewayModelCapabilities> = emptyMap(),
): ReasoningEffortAvailability {
val normalizedProvider = provider?.trim().orEmpty()
val normalizedModel = model?.trim().orEmpty()
if (normalizedProvider.isEmpty() || normalizedModel.isEmpty()) {
return ReasoningEffortAvailability(
supported = null,
choices = ReasoningEfforts.canonical,
exact = false,
)
}
val providerRow = providers.firstOrNull {
it.slug.equals(normalizedProvider, ignoreCase = true)
}
val upstream = providerRow?.capabilities?.get(normalizedModel)
?: providerRow?.capabilities?.entries?.firstOrNull {
it.key.equals(normalizedModel, ignoreCase = true)
}?.value
val identity = ReasoningEffortIdentity(
provider = normalizedProvider.lowercase(),
model = normalizedModel,
)
val relay = relayCapabilities[identity]
fun exactAvailability(capabilities: GatewayModelCapabilities): ReasoningEffortAvailability {
val choices = capabilities.reasoningEfforts.orEmpty()
.map { it.trim().lowercase() }
.filter { it in ReasoningEfforts.canonical }
.distinct()
return ReasoningEffortAvailability(
supported = capabilities.reasoning ?: choices.isNotEmpty(),
choices = choices,
exact = true,
)
}
// Contract precedence: authoritative upstream, authoritative Relay overlay,
// explicit upstream suppression, then compatibility fallback.
if (upstream?.reasoningEfforts != null && upstream.reasoningEffortsExact == true) {
return exactAvailability(upstream)
}
if (relay?.reasoningEfforts != null && relay.reasoningEffortsExact == true) {
return exactAvailability(relay)
}
if (upstream?.reasoning == false) {
return ReasoningEffortAvailability(supported = false, choices = emptyList(), exact = false)
}
return ReasoningEffortAvailability(
supported = upstream?.reasoning,
choices = ReasoningEfforts.canonical,
exact = false,
)
}
@@ -165,6 +165,9 @@ data class SessionItem(
@SerialName("input_tokens") val inputTokens: Int? = null,
@SerialName("output_tokens") val outputTokens: Int? = null,
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
/** Durable flags returned by current Dashboard and API-server session resources. */
val pinned: Boolean = false,
val archived: Boolean = false,
) {
val resolvedLastActivity: Double?
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
@@ -180,6 +180,8 @@ internal class HermesRuntimeBinder(
chat.setDashboardConfigLoader { connection.loadActiveDashboardConfig() }
chat.profileSessionDeleter = connection::deleteProfileScopedSession
chat.profileSessionRenamer = connection::renameProfileScopedSession
chat.profileSessionPinner = connection::setProfileScopedSessionPinned
chat.profileSessionArchiver = connection::setProfileScopedSessionArchived
chat.onSessionChanged = connection::saveLastSessionId
chat.setDemoModeWiring(
isDemo = { connection.isDemoMode.value },
@@ -272,6 +272,20 @@ internal fun shouldShowConnectionFooter(
presentationMode: VoicePresentationMode,
): Boolean = !voiceMode || presentationMode == VoicePresentationMode.Conversation
/**
* Full-screen setup owns the whole window. Keeping connection-dependent app
* chrome composed behind it makes that chrome rebuild while Add connection
* swaps to its placeholder, visibly resizing the entering setup surface.
*/
internal fun shouldSuppressGlobalChrome(
onboardingCompleted: Boolean,
isDemoMode: Boolean,
currentRoute: String?,
): Boolean =
(!onboardingCompleted && !isDemoMode) ||
currentRoute == Screen.Onboarding.route ||
currentRoute == Screen.Pair.route
internal const val APP_STATUS_PET_WALK_REGION = "app-status-footer"
/**
@@ -907,8 +921,11 @@ fun RelayApp() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val isOnboarding = currentRoute == Screen.Onboarding.route
val suppressGlobalChrome = (!onboardingCompleted && !isDemoMode) || isOnboarding
val suppressGlobalChrome = shouldSuppressGlobalChrome(
onboardingCompleted = onboardingCompleted,
isDemoMode = isDemoMode,
currentRoute = currentRoute,
)
// Safety net: landing on a real connect surface (onboarding or the
// Connect/Pair wizard) while demo is still active — via the banner's
@@ -167,11 +167,12 @@ fun ChatInputBar(
onModelOptionSelected: (ChatInputPickerOption) -> Unit = {},
onModelPickerClick: (() -> Unit)? = null,
effortControl: ChatInputPickerControl? = null,
onEffortOptionSelected: (ChatInputPickerOption) -> Unit = {},
onEffortPickerClick: (() -> Unit)? = null,
topContent: (@Composable () -> Unit)? = null,
topContentVisible: Boolean = topContent != null,
suppressVoiceTrailing: Boolean = false,
modifier: Modifier = Modifier,
surfaceModifier: Modifier = Modifier,
enabled: Boolean = true,
) {
// Keep the last caption around so the AnimatedVisibility exit doesn't
@@ -255,7 +256,8 @@ fun ChatInputBar(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
.padding(horizontal = 8.dp, vertical = 6.dp)
.then(surfaceModifier),
) {
Column {
if (topContent != null) {
@@ -397,8 +399,9 @@ fun ChatInputBar(
if (effortControl != null) {
ChatInputPickerChip(
control = effortControl,
onSelect = onEffortOptionSelected,
onSelect = {},
modifier = Modifier.widthIn(max = 104.dp),
onClickOverride = onEffortPickerClick,
)
}
@@ -5,9 +5,11 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -15,6 +17,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -32,6 +35,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Language
@@ -83,6 +87,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@@ -694,7 +699,6 @@ fun AgentInfoSheet(
onNavigateToConnections: () -> Unit,
onNavigateToProfileInspector: (String) -> Unit = {},
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val resolvedProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
@@ -707,12 +711,14 @@ fun AgentInfoSheet(
val personalityNames by chatViewModel.personalityNames.collectAsState()
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
val selectedModel by chatViewModel.selectedModelOverride.collectAsState()
val selectedProvider by chatViewModel.selectedProviderOverride.collectAsState()
val serverModel by chatViewModel.serverModelName.collectAsState()
val gatewayModel by chatViewModel.gatewayCurrentModel.collectAsState()
val gatewayProvider by chatViewModel.gatewayCurrentProvider.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
val reasoningCapabilityRevision by chatViewModel.reasoningCapabilityRevision.collectAsState()
val selectedReasoning by chatViewModel.selectedReasoningEffort.collectAsState()
val approvalMode by chatViewModel.approvalMode.collectAsState()
val approvalCapability by chatViewModel.approvalModeCapability.collectAsState()
@@ -742,6 +748,18 @@ fun AgentInfoSheet(
connectionViewModel.connectionStore.activeConnectionId.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val reasoningAvailability = remember(
selectedModel,
selectedProvider,
gatewayModel,
gatewayProvider,
modelProviders,
apiModelOptions,
reasoningCapabilityRevision,
) {
chatViewModel.reasoningEffortAvailability()
}
var selectedTab by remember { mutableStateOf(0) }
var activePicker by remember { mutableStateOf<AgentPassportPicker?>(null) }
var showIdentityEditor by remember { mutableStateOf(false) }
@@ -832,6 +850,7 @@ fun AgentInfoSheet(
apiModelOptions,
agentProfiles,
selectedModel,
selectedProvider,
modelLabel,
unavailableModelLabel,
modelNeedsSetupLabel,
@@ -872,7 +891,8 @@ fun AgentInfoSheet(
provider.warning ?: modelNeedsSetupLabel
else -> null
},
selected = selectedModel == model,
selected = selectedModel == model &&
selectedProvider.equals(provider.slug, ignoreCase = true),
enabled = !unavailable,
),
)
@@ -912,28 +932,10 @@ fun AgentInfoSheet(
showIdentityEditor = false
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
sheetGesturesEnabled = false,
dragHandle = null,
AgentPassportSheetHost(
scrollState = passportScrollState,
onDismiss = onDismiss,
) {
Column(
modifier = Modifier
.fillMaxHeight(0.94f)
.verticalScroll(
state = passportScrollState,
overscrollEffect = null,
)
.padding(horizontal = 20.dp, vertical = 4.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.conn_info_agent_passport),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
AgentPassportHeader(
agentName = agentName,
profileLabel = profileLabel,
@@ -1014,15 +1016,21 @@ fun AgentInfoSheet(
enabled = !isStreaming,
onClick = { activePicker = AgentPassportPicker.Model },
)
PassportDivider()
PassportConfigRow(
icon = Icons.Filled.Psychology,
title = stringResource(R.string.chat_select_reasoning_effort),
value = reasoningLabel(selectedReasoning),
expanded = false,
enabled = gatewayControlsAvailable && !isStreaming,
onClick = { activePicker = AgentPassportPicker.Reasoning },
)
if (
reasoningAvailability.supported != false &&
reasoningAvailability.choices.isNotEmpty()
) {
PassportDivider()
PassportConfigRow(
icon = Icons.Filled.Psychology,
title = stringResource(R.string.chat_select_reasoning_effort),
value = selectedReasoning?.let { reasoningEffortLabel(it) }
?: serverDefaultLabel,
expanded = false,
enabled = gatewayControlsAvailable && !isStreaming,
onClick = { activePicker = AgentPassportPicker.Reasoning },
)
}
}
}
@@ -1120,7 +1128,7 @@ fun AgentInfoSheet(
sessionProfileName = effectiveSessionProfileName,
modelLabel = sessionModelLabel,
providerLabel = sessionProviderLabel,
reasoningLabel = selectedReasoning?.let(::reasoningLabel)
reasoningLabel = selectedReasoning?.let { reasoningEffortLabel(it) }
?: stringResource(R.string.conn_info_server_default),
approvalMode = approvalMode,
approvalCapability = approvalCapability,
@@ -1144,7 +1152,6 @@ fun AgentInfoSheet(
)
}
}
}
}
when (activePicker) {
@@ -1232,29 +1239,38 @@ fun AgentInfoSheet(
},
onDismiss = { activePicker = null },
)
AgentPassportPicker.Reasoning -> OptionPickerSheet(
title = stringResource(R.string.chat_select_reasoning_effort),
options = listOf(
"none" to stringResource(R.string.chat_reasoning_none),
"minimal" to stringResource(R.string.chat_reasoning_minimal),
"low" to stringResource(R.string.chat_reasoning_low),
"medium" to stringResource(R.string.chat_reasoning_medium),
"high" to stringResource(R.string.chat_reasoning_high),
"xhigh" to "XHigh",
).map { (value, label) ->
AgentPassportPicker.Reasoning -> {
val reasoningOptions = reasoningAvailability.choices.map { value ->
ChatInputPickerOption(
label = label,
label = reasoningEffortLabel(value),
value = value,
selected = selectedReasoning == value,
enabled = gatewayControlsAvailable && !isStreaming,
enabled = reasoningAvailability.supported != false &&
gatewayControlsAvailable && !isStreaming,
)
},
onSelect = { option ->
option.value?.let(chatViewModel::selectReasoningEffort)
activePicker = null
},
onDismiss = { activePicker = null },
)
}
OptionPickerSheet(
title = stringResource(R.string.chat_select_reasoning_effort),
subtitle = when {
reasoningAvailability.exact &&
selectedReasoning != null &&
selectedReasoning !in reasoningAvailability.choices -> stringResource(
R.string.reasoning_effort_current_outside_supported,
reasoningEffortLabel(selectedReasoning),
reasoningOptions.joinToString { it.label },
)
!reasoningAvailability.exact ->
stringResource(R.string.reasoning_effort_standard_levels_notice)
else -> null
},
options = reasoningOptions,
onSelect = { option ->
option.value?.let(chatViewModel::selectReasoningEffort)
activePicker = null
},
onDismiss = { activePicker = null },
)
}
null -> Unit
}
@@ -1271,6 +1287,75 @@ fun AgentInfoSheet(
}
}
internal const val AGENT_PASSPORT_SHEET_TAG = "agentPassportSheet"
internal const val AGENT_PASSPORT_SCROLL_TAG = "agentPassportScroll"
internal const val AGENT_PASSPORT_CLOSE_TAG = "agentPassportClose"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AgentPassportSheetHost(
scrollState: ScrollState,
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
// Use Material's gesture owner instead of custom pointer input. The
// verticalScroll child consumes downward movement until its top
// boundary, then hands the remainder to the sheet; Material also
// follows the platform animator scale for reduced-motion users.
sheetGesturesEnabled = true,
) {
Column(
modifier = Modifier
.fillMaxHeight(0.94f)
.testTag(AGENT_PASSPORT_SHEET_TAG),
) {
// Keep the close action visible while compact or large-font content
// scrolls independently below it.
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = 20.dp, end = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.conn_info_agent_passport),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
IconButton(
onClick = onDismiss,
modifier = Modifier.testTag(AGENT_PASSPORT_CLOSE_TAG),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.common_close),
)
}
}
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(
state = scrollState,
overscrollEffect = null,
)
.testTag(AGENT_PASSPORT_SCROLL_TAG)
.padding(horizontal = 20.dp, vertical = 4.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(10.dp),
content = content,
)
}
}
}
@Composable
private fun AgentPassportIdentityEditor(
connectionViewModel: ConnectionViewModel,
@@ -1722,8 +1807,17 @@ private fun PassportDivider() {
)
}
internal const val PASSPORT_APPROVAL_CONTROL_TAG = "passportApproval"
internal const val PASSPORT_CHAT_OVERRIDE_CONTROL_TAG = "passportChatOverride"
internal const val PASSPORT_FAST_CONTROL_TAG = "passportFast"
internal data class PassportSegmentOption(
val label: String,
val description: String,
)
@Composable
private fun AgentPassportSafetyCard(
internal fun AgentPassportSafetyCard(
approvalMode: GatewayApprovalMode?,
approvalCapability: GatewayApprovalModeCapability,
approvalWritable: Boolean,
@@ -1737,9 +1831,18 @@ private fun AgentPassportSafetyCard(
onFast: (Boolean) -> Unit,
) {
val approvalModes = listOf(
GatewayApprovalMode.Manual to stringResource(R.string.conn_info_approval_mode_manual),
GatewayApprovalMode.Smart to stringResource(R.string.conn_info_approval_mode_smart),
GatewayApprovalMode.Off to stringResource(R.string.conn_info_approval_mode_off),
GatewayApprovalMode.Manual to PassportSegmentOption(
label = stringResource(R.string.conn_info_approval_mode_manual),
description = stringResource(R.string.conn_info_approval_mode_manual_desc),
),
GatewayApprovalMode.Smart to PassportSegmentOption(
label = stringResource(R.string.conn_info_approval_mode_smart),
description = stringResource(R.string.conn_info_approval_mode_smart_desc),
),
GatewayApprovalMode.Off to PassportSegmentOption(
label = stringResource(R.string.conn_info_approval_mode_off),
description = stringResource(R.string.conn_info_approval_mode_off_desc),
),
)
Surface(
shape = RoundedCornerShape(22.dp),
@@ -1782,11 +1885,12 @@ private fun AgentPassportSafetyCard(
}
GatewayApprovalModeCapability.Supported -> {
PassportSegmentedControl(
labels = approvalModes.map { it.second },
options = approvalModes.map { it.second },
selected = approvalModes.indexOfFirst { it.first == approvalMode }
.takeIf { it >= 0 },
enabled = controlsAvailable && gatewayReady &&
approvalWritable && !isStreaming,
testTagPrefix = PASSPORT_APPROVAL_CONTROL_TAG,
onSelected = { index -> onApprovalMode(approvalModes[index].first) },
)
}
@@ -1804,15 +1908,26 @@ private fun AgentPassportSafetyCard(
},
) {
PassportSegmentedControl(
labels = listOf(
stringResource(R.string.conn_info_inherited),
stringResource(R.string.conn_info_bypassed),
options = listOf(
PassportSegmentOption(
label = stringResource(R.string.conn_info_inherited),
description = if (approvalMode == GatewayApprovalMode.Off) {
stringResource(R.string.conn_info_profile_already_bypasses)
} else {
stringResource(R.string.conn_info_chat_override_inherited_desc)
},
),
PassportSegmentOption(
label = stringResource(R.string.conn_info_bypassed),
description = stringResource(R.string.conn_info_yolo_mode_desc_ephemeral),
),
),
selected = if (
approvalMode != GatewayApprovalMode.Off && yoloEnabled == true
) 1 else 0,
enabled = controlsAvailable && gatewayReady && !isStreaming &&
approvalMode != GatewayApprovalMode.Off,
testTagPrefix = PASSPORT_CHAT_OVERRIDE_CONTROL_TAG,
onSelected = { onYolo(it == 1) },
)
}
@@ -1824,12 +1939,19 @@ private fun AgentPassportSafetyCard(
description = stringResource(R.string.conn_info_fast_mode_desc),
) {
PassportSegmentedControl(
labels = listOf(
stringResource(R.string.conn_info_fast),
stringResource(R.string.appearance_font_normal),
options = listOf(
PassportSegmentOption(
label = stringResource(R.string.conn_info_fast),
description = stringResource(R.string.conn_info_fast_mode_desc),
),
PassportSegmentOption(
label = stringResource(R.string.appearance_font_normal),
description = stringResource(R.string.conn_info_normal_tier_desc),
),
),
selected = if (fastEnabled == true) 0 else 1,
enabled = controlsAvailable && gatewayReady && !isStreaming,
testTagPrefix = PASSPORT_FAST_CONTROL_TAG,
onSelected = { onFast(it == 0) },
)
}
@@ -1844,91 +1966,119 @@ private fun PassportSafetyRow(
description: String,
control: @Composable () -> Unit,
) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Surface(
modifier = Modifier.size(38.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.primary,
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp))
Surface(
modifier = Modifier.size(40.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.primary,
) {
Box(contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp))
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Column(modifier = Modifier.weight(1.25f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
Box(modifier = Modifier.weight(1f)) {
control()
}
control()
}
}
@Composable
private fun PassportSegmentedControl(
labels: List<String>,
internal fun PassportSegmentedControl(
options: List<PassportSegmentOption>,
selected: Int?,
enabled: Boolean,
testTagPrefix: String,
onSelected: (Int) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectableGroup()
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(3.dp)
.alpha(if (enabled) 1f else 0.52f),
) {
labels.forEachIndexed { index, label ->
Surface(
modifier = Modifier
.weight(1f)
.selectable(
selected = selected == index,
enabled = enabled,
role = Role.RadioButton,
onClick = { onSelected(index) },
),
shape = RoundedCornerShape(11.dp),
color = if (selected == index) {
MaterialTheme.colorScheme.primary
} else {
Color.Transparent
},
contentColor = if (selected == index) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectableGroup()
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(3.dp),
) {
options.forEachIndexed { index, option ->
Surface(
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.testTag("$testTagPrefix-$index")
.selectable(
selected = selected == index,
enabled = enabled,
role = Role.RadioButton,
onClick = { onSelected(index) },
)
.semantics {
contentDescription = "${option.label}. ${option.description}"
},
shape = RoundedCornerShape(11.dp),
color = when {
selected != index -> Color.Transparent
enabled -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.primaryContainer
},
contentColor = when {
selected == index && enabled -> MaterialTheme.colorScheme.onPrimary
selected == index -> MaterialTheme.colorScheme.onPrimaryContainer
enabled -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
},
) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 6.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = option.label,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected == index) {
FontWeight.SemiBold
} else {
FontWeight.Medium
},
maxLines = 2,
)
}
}
}
}
selected?.let(options::getOrNull)?.let { option ->
Text(
text = option.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
}
}
@@ -2094,9 +2244,6 @@ private fun compactTokenCount(value: Int): String = when {
else -> value.toString()
}
private fun reasoningLabel(value: String?): String =
value?.replaceFirstChar { it.uppercase() } ?: "Medium"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LegacyAgentInfoSheet(
@@ -10,7 +10,9 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
@@ -38,12 +40,15 @@ import androidx.compose.material.icons.automirrored.outlined.MenuBook
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.PhonelinkLock
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
@@ -76,7 +81,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
@@ -201,6 +205,8 @@ fun ConnectionWizard(
val currentRelayUrl by connectionViewModel.relayUrl.collectAsState()
val currentDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val accessibleMotion = rememberAccessibleMotionState()
val animateWizardTransitions = shouldAnimateWizardTransitions(accessibleMotion)
var step by remember { mutableStateOf(WizardStep.Nearby) }
var chosenMethod by remember { mutableStateOf(PairMethod.Standard) }
@@ -222,7 +228,7 @@ fun ConnectionWizard(
mutableStateOf<ConnectionViewModel.DashboardSetupResult?>(null)
}
var dashboardSuggestedHostname by remember { mutableStateOf<String?>(null) }
var connectionGuideExpanded by rememberSaveable { mutableStateOf(showSkip) }
var dashboardEntryIntent by rememberSaveable { mutableStateOf(DashboardEntryIntent.Server) }
var pendingDashboardDraft by remember { mutableStateOf<DashboardConnectionDraft?>(null) }
val relayScopedFlow = autoStart == "relay"
val pairingHome = if (relayScopedFlow) WizardStep.RelayChoice else WizardStep.Method
@@ -527,18 +533,27 @@ fun ConnectionWizard(
}
}
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
WizardStepIndicator(currentStep = step.indicatorIndex, method = chosenMethod)
if (!showQrScanner) {
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (step != WizardStep.Nearby) {
WizardStepIndicator(currentStep = step.indicatorIndex, method = chosenMethod)
}
AnimatedContent(
targetState = step,
transitionSpec = { fadeIn() togetherWith fadeOut() },
transitionSpec = {
if (animateWizardTransitions) {
fadeIn(tween(200)) togetherWith fadeOut(tween(180))
} else {
EnterTransition.None togetherWith ExitTransition.None
}
},
label = "wizard-step",
) { current ->
when (current) {
@@ -553,7 +568,14 @@ fun ConnectionWizard(
inspectDashboard(dashboardUrl, candidate.hostname)
}
},
onManual = {
onCloud = {
dashboardEntryIntent = DashboardEntryIntent.Cloud
dashboardProbeError = null
dashboardAddress = ""
step = WizardStep.DashboardManual
},
onManualServer = {
dashboardEntryIntent = DashboardEntryIntent.Server
dashboardProbeError = null
step = WizardStep.DashboardManual
},
@@ -569,11 +591,10 @@ fun ConnectionWizard(
onOtherWays = { step = WizardStep.Method },
onSkip = if (showSkip) onCancel else null,
onTryDemo = onTryDemo,
guideExpanded = connectionGuideExpanded,
onGuideExpandedChange = { connectionGuideExpanded = it },
)
WizardStep.DashboardManual -> DashboardManualStep(
intent = dashboardEntryIntent,
address = dashboardAddress,
onAddressChange = {
dashboardAddress = it
@@ -582,7 +603,7 @@ fun ConnectionWizard(
busy = dashboardProbeBusy,
error = dashboardProbeError,
onBack = { step = WizardStep.Nearby },
onSubmit = { inspectDashboard(dashboardAddress, null) },
onSubmit = { resolvedAddress -> inspectDashboard(resolvedAddress, null) },
)
WizardStep.DashboardFound -> dashboardProbeResult?.let { result ->
@@ -728,47 +749,65 @@ fun ConnectionWizard(
// role — otherwise Retry would drop the user's
// "Prefer" choice on every failure.
pendingPayload = reorderedPayload
// Pre-pair duplicate check — stops the flow
// on the Confirm screen if the scanned
// serverUrl already matches an existing
// connection. User can then re-pair that
// existing entry in place (see the
// DuplicateConnectionDialog at the bottom
// of this composable) or cancel out. If no
// duplicate, proceed to Verify as before.
val existing = if (relayScopedFlow) {
null
} else {
findDuplicateFor(
reorderedPayload.serverUrl,
reorderedPayload.relay?.url.orEmpty(),
reorderedPayload.dashboardUrl,
)
}
if (existing != null) {
duplicatePrompt = existing
} else if (reorderedPayload.relay == null) {
launchStandardConnect(
reorderedPayload.serverUrl,
reorderedPayload.key,
"",
reorderedPayload.dashboardUrl.orEmpty(),
reorderedPayload.endpoints,
)
} else {
wizardScope.launch {
connectionViewModel.ensureActiveConnectionForSetup(
apiServerUrl = reorderedPayload.serverUrl,
relayUrl = reorderedPayload.relay.url,
routeCandidates = reorderedPayload.endpoints,
val dispatch = setupQrDispatch(reorderedPayload)
when (dispatch) {
SetupQrDispatch.Dashboard -> {
// Dashboard-only setup belongs to the same
// upstream probe/auth owner as manual and LAN
// Dashboard setup. DashboardFound performs the
// existing duplicate check before persistence,
// and applyDashboardConnect launches native
// sign-in when the advertised status requires it.
// Move to the manual Dashboard surface before
// probing so a contract failure is visible and
// retryable instead of being hidden by Confirm.
dashboardEntryIntent = DashboardEntryIntent.Server
dashboardAddress = reorderedPayload.dashboardUrl.orEmpty()
step = WizardStep.DashboardManual
inspectDashboard(
reorderedPayload.dashboardUrl.orEmpty(),
null,
)
connectionViewModel.applyPairingPayload(
reorderedPayload,
ttlSeconds,
preserveStandardConfig = relayScopedFlow,
)
step = WizardStep.Verify
verifyAttempt += 1
}
SetupQrDispatch.StandardApi,
SetupQrDispatch.Relay -> {
// API and Relay payloads retain their existing
// duplicate/persistence behavior unchanged.
val existing = if (relayScopedFlow) {
null
} else {
findDuplicateFor(
reorderedPayload.serverUrl,
reorderedPayload.relay?.url.orEmpty(),
reorderedPayload.dashboardUrl,
)
}
if (existing != null) {
duplicatePrompt = existing
} else if (dispatch == SetupQrDispatch.StandardApi) {
launchStandardConnect(
reorderedPayload.serverUrl,
reorderedPayload.key,
"",
reorderedPayload.dashboardUrl.orEmpty(),
reorderedPayload.endpoints,
)
} else {
wizardScope.launch {
connectionViewModel.ensureActiveConnectionForSetup(
apiServerUrl = reorderedPayload.serverUrl,
relayUrl = reorderedPayload.relay?.url.orEmpty(),
routeCandidates = reorderedPayload.endpoints,
)
connectionViewModel.applyPairingPayload(
reorderedPayload,
ttlSeconds,
preserveStandardConfig = relayScopedFlow,
)
step = WizardStep.Verify
verifyAttempt += 1
}
}
}
}
},
@@ -839,6 +878,7 @@ fun ConnectionWizard(
}
}
}
}
if (showQrScanner) {
QrPairingScanner(
@@ -1096,6 +1136,44 @@ private enum class WizardStep {
private enum class PairMethod { Standard, Scan, EnterCode, ShowCode }
private enum class DashboardEntryIntent { Cloud, Server }
internal enum class SetupQrDispatch { Dashboard, StandardApi, Relay }
/** Keep setup QR routing aligned with the surface that owns its credentials. */
internal fun setupQrDispatch(payload: HermesPairingPayload): SetupQrDispatch = when {
payload.relay != null -> SetupQrDispatch.Relay
!payload.hasApiServer && !payload.dashboardUrl.isNullOrBlank() -> SetupQrDispatch.Dashboard
else -> SetupQrDispatch.StandardApi
}
/** Wizard motion follows the shared OS-animation and touch-exploration policy. */
internal fun shouldAnimateWizardTransitions(state: AccessibleMotionState): Boolean =
state.osAnimations && !state.touchExploration
private const val NOUS_CLOUD_HOST_SUFFIX = ".agents.nousresearch.com"
private val NOUS_CLOUD_SLUG = Regex("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
internal fun resolveNousCloudDashboardAddress(value: String): String {
val trimmed = value.trim()
if (trimmed.contains("://")) return trimmed
val slug = trimmed.removeSuffix(NOUS_CLOUD_HOST_SUFFIX)
return if (NOUS_CLOUD_SLUG.matches(slug)) {
"https://$slug$NOUS_CLOUD_HOST_SUFFIX"
} else {
trimmed
}
}
internal fun isValidNousCloudSlug(value: String): Boolean =
NOUS_CLOUD_SLUG.matches(value.trim())
internal fun isValidNousCloudAddressInput(value: String): Boolean {
val trimmed = value.trim()
if (trimmed.contains("://")) return false
return isValidNousCloudSlug(trimmed.removeSuffix(NOUS_CLOUD_HOST_SUFFIX))
}
private data class StandardConnectionDraft(
val apiUrl: String,
val apiKey: String,
@@ -1201,19 +1279,50 @@ private fun NearbyHermesStep(
message: String?,
onSearchAgain: () -> Unit,
onSelect: (HermesLanDiscoveryResult) -> Unit,
onManual: () -> Unit,
onCloud: () -> Unit,
onManualServer: () -> Unit,
onPairRelayQr: () -> Unit,
onPairRelayCode: () -> Unit,
onOtherWays: () -> Unit,
onSkip: (() -> Unit)?,
onTryDemo: (() -> Unit)?,
guideExpanded: Boolean,
onGuideExpandedChange: (Boolean) -> Unit,
) {
NewNearbyHermesStep(
busy = busy,
setupReady = setupReady,
results = results,
message = message,
onSearchAgain = onSearchAgain,
onSelect = onSelect,
onCloud = onCloud,
onManualServer = onManualServer,
onScanQr = onPairRelayQr,
onAdvanced = onOtherWays,
onSkip = onSkip,
onTryDemo = onTryDemo,
)
}
@Composable
private fun NewNearbyHermesStep(
busy: Boolean,
setupReady: Boolean,
results: List<HermesLanDiscoveryResult>,
message: String?,
onSearchAgain: () -> Unit,
onSelect: (HermesLanDiscoveryResult) -> Unit,
onCloud: () -> Unit,
onManualServer: () -> Unit,
onScanQr: () -> Unit,
onAdvanced: () -> Unit,
onSkip: (() -> Unit)?,
onTryDemo: (() -> Unit)?,
) {
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.cw_connect_to_hermes),
@@ -1221,183 +1330,140 @@ private fun NearbyHermesStep(
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(R.string.cw_nearby_description),
text = stringResource(R.string.cw_connection_chooser_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f),
shape = RoundedCornerShape(16.dp),
Card(
modifier = Modifier
.fillMaxWidth()
.animateContentSize(),
.clickable(enabled = setupReady, role = Role.Button, onClick = onScanQr),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
shape = RoundedCornerShape(18.dp),
) {
Column(
modifier = Modifier.fillMaxWidth(),
Row(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
role = Role.Button,
onClick = { onGuideExpandedChange(!guideExpanded) },
)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(34.dp),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = stringResource(R.string.cw_before_connecting),
text = stringResource(R.string.cw_scan_setup_qr_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
modifier = Modifier
.size(22.dp)
.rotate(if (guideExpanded) 90f else 0f),
Text(
text = stringResource(R.string.cw_recommended),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Text(
text = stringResource(R.string.cw_scan_setup_qr_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.82f),
)
}
if (guideExpanded) {
Column(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ConnectionGuideStep(
number = "1",
title = stringResource(R.string.cw_connect_step_server_title),
body = stringResource(R.string.cw_connect_step_server_body),
)
ConnectionGuideStep(
number = "2",
title = stringResource(R.string.cw_connect_step_network_title),
body = stringResource(R.string.cw_connect_step_network_body),
)
ConnectionGuideStep(
number = "3",
title = stringResource(R.string.cw_connect_step_phone_title),
body = stringResource(R.string.cw_connect_step_phone_body),
)
TextButton(
onClick = { openExternalUrl(context, SetupGuideUrl) },
modifier = Modifier.align(Alignment.End),
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.size(6.dp))
Text(stringResource(R.string.cw_setup_guide))
}
}
}
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
if (busy) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
Column {
Text(
stringResource(
if (setupReady) R.string.cw_nearby_searching
else R.string.cw_preparing_connection,
),
style = MaterialTheme.typography.titleSmall,
)
Text(
stringResource(
if (setupReady) R.string.cw_nearby_searching_hint
else R.string.cw_preparing_connection_hint,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (results.isNotEmpty()) {
Text(
text = stringResource(R.string.cw_nearby_heading),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() },
)
results.forEach { candidate ->
NearbyHermesResult(candidate = candidate, onClick = { onSelect(candidate) })
}
}
message?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
)
Text(
text = stringResource(R.string.cw_nearby_empty_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!busy) {
OutlinedButton(
onClick = onSearchAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
) {
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.cw_nearby_search_again))
}
}
Button(
onClick = onManual,
enabled = setupReady,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
) {
Text(stringResource(R.string.cw_nearby_enter_address))
}
HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp))
Text(
text = stringResource(R.string.cw_relay_entry_title),
text = stringResource(R.string.cw_other_ways_to_connect),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.semantics { heading() },
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f),
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.fillMaxWidth()) {
ConnectionChooserRow(
icon = Icons.Filled.Cloud,
title = stringResource(R.string.cw_cloud_title),
subtitle = stringResource(R.string.cw_cloud_subtitle),
onClick = onCloud,
enabled = setupReady,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
ConnectionChooserRow(
icon = Icons.Filled.Dns,
title = stringResource(R.string.cw_server_vps_title),
subtitle = stringResource(R.string.cw_server_vps_subtitle),
onClick = onManualServer,
enabled = setupReady,
)
if (results.isNotEmpty()) {
results.forEach { candidate ->
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
ConnectionChooserRow(
icon = Icons.Filled.Wifi,
title = stringResource(R.string.cw_nearby_heading),
subtitle = candidate.dashboardUrl.orEmpty(),
onClick = { onSelect(candidate) },
enabled = setupReady,
status = stringResource(R.string.cw_ready),
)
}
} else {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
ConnectionChooserRow(
icon = Icons.Filled.Wifi,
title = stringResource(R.string.cw_nearby_heading),
subtitle = when {
busy -> stringResource(R.string.cw_nearby_searching)
!message.isNullOrBlank() -> message
else -> stringResource(R.string.cw_nearby_empty_hint)
},
onClick = onSearchAgain,
enabled = setupReady && !busy,
showProgress = busy,
)
}
}
}
Text(
text = stringResource(R.string.cw_relay_entry_desc),
text = stringResource(R.string.cw_relay_optional_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
MethodTile(
icon = Icons.Filled.QrCodeScanner,
title = stringResource(R.string.cw_relay_pair_qr),
subtitle = stringResource(R.string.cw_relay_pair_qr_desc),
onClick = onPairRelayQr,
enabled = setupReady,
)
TextButton(onClick = onPairRelayCode, enabled = setupReady, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.cw_relay_enter_code))
}
TextButton(onClick = onOtherWays, enabled = setupReady, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.cw_other_connection_methods))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = { openExternalUrl(context, SetupGuideUrl) }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.MenuBook,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.size(6.dp))
Text(stringResource(R.string.cw_setup_guide))
}
TextButton(onClick = onAdvanced, enabled = setupReady) {
Text(stringResource(R.string.cw_advanced))
}
}
if (onTryDemo != null) {
TextButton(onClick = onTryDemo, modifier = Modifier.fillMaxWidth()) {
@@ -1413,33 +1479,55 @@ private fun NearbyHermesStep(
}
@Composable
private fun ConnectionGuideStep(
number: String,
private fun ConnectionChooserRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
body: String,
subtitle: String,
onClick: () -> Unit,
enabled: Boolean,
status: String? = null,
showProgress: Boolean = false,
) {
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shape = CircleShape,
modifier = Modifier.size(28.dp),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(12.dp),
) {
Box(contentAlignment = Alignment.Center) {
Text(number, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
}
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(10.dp).size(24.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(
body,
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
)
}
status?.let {
Text(
it,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
if (showProgress) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Filled.ChevronRight, contentDescription = null)
}
}
}
@@ -1488,42 +1576,103 @@ private fun NearbyHermesResult(
@Composable
private fun DashboardManualStep(
intent: DashboardEntryIntent,
address: String,
onAddressChange: (String) -> Unit,
busy: Boolean,
error: String?,
onBack: () -> Unit,
onSubmit: () -> Unit,
onSubmit: (String) -> Unit,
) {
val context = LocalContext.current
val fieldError = optionalHttpUrlError(address, context)
var useCustomAddress by rememberSaveable(intent) { mutableStateOf(false) }
val cloudSlugMode = intent == DashboardEntryIntent.Cloud && !useCustomAddress
val fieldError = if (cloudSlugMode) {
when {
address.isBlank() -> null
!isValidNousCloudAddressInput(address) -> context.getString(R.string.cw_cloud_slug_error)
else -> null
}
} else {
optionalHttpUrlError(address, context)
}
val resolvedAddress = if (cloudSlugMode) resolveNousCloudDashboardAddress(address) else address.trim()
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(
text = stringResource(R.string.cw_manual_hermes_title),
text = stringResource(
if (intent == DashboardEntryIntent.Cloud) R.string.cw_cloud_entry_title
else R.string.cw_manual_hermes_title,
),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(R.string.cw_manual_hermes_description),
text = stringResource(
if (intent == DashboardEntryIntent.Cloud) R.string.cw_cloud_entry_description
else R.string.cw_manual_hermes_description,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = address,
onValueChange = onAddressChange,
label = { Text(stringResource(R.string.cw_hermes_address)) },
placeholder = { Text(stringResource(R.string.cw_hermes_address_placeholder)) },
onValueChange = { updated ->
if (cloudSlugMode && (updated.contains("://") || updated.contains('/'))) {
useCustomAddress = true
}
onAddressChange(updated)
},
label = {
Text(
stringResource(
if (cloudSlugMode) R.string.cw_cloud_agent_name
else R.string.cw_hermes_address,
)
)
},
placeholder = {
Text(
stringResource(
if (cloudSlugMode) R.string.cw_cloud_slug_placeholder
else if (intent == DashboardEntryIntent.Cloud) R.string.cw_cloud_address_placeholder
else R.string.cw_hermes_address_placeholder,
)
)
},
supportingText = {
Text(fieldError ?: stringResource(R.string.cw_hermes_address_hint))
Text(
fieldError ?: stringResource(
if (cloudSlugMode) R.string.cw_cloud_slug_hint
else if (intent == DashboardEntryIntent.Cloud) R.string.cw_cloud_address_hint
else R.string.cw_hermes_address_hint,
)
)
},
isError = fieldError != null || error != null,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go, autoCorrectEnabled = false),
keyboardActions = KeyboardActions(onGo = {
if (address.isNotBlank() && fieldError == null && !busy) onSubmit()
if (address.isNotBlank() && fieldError == null && !busy) onSubmit(resolvedAddress)
}),
modifier = Modifier.fillMaxWidth(),
)
if (intent == DashboardEntryIntent.Cloud) {
TextButton(
onClick = {
useCustomAddress = !useCustomAddress
onAddressChange("")
},
enabled = !busy,
modifier = Modifier.align(Alignment.End),
) {
Text(
stringResource(
if (useCustomAddress) R.string.cw_cloud_use_nous_address
else R.string.cw_cloud_use_custom_address,
)
)
}
}
error?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
@@ -1532,7 +1681,7 @@ private fun DashboardManualStep(
Text(stringResource(R.string.cw_back))
}
Button(
onClick = onSubmit,
onClick = { onSubmit(resolvedAddress) },
enabled = address.isNotBlank() && fieldError == null && !busy,
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
) {
@@ -3093,11 +3242,13 @@ private fun ConfirmStep(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
LabeledLine(
label = stringResource(R.string.cw_label_api_server),
value = payload.serverUrl,
hint = stringResource(R.string.cw_hint_chat),
)
if (payload.hasApiServer) {
LabeledLine(
label = stringResource(R.string.cw_label_api_server),
value = payload.serverUrl,
hint = stringResource(R.string.cw_hint_chat),
)
}
if (relayUrl == null) {
LabeledLine(
label = stringResource(R.string.cw_label_dashboard),
@@ -3108,14 +3259,16 @@ private fun ConfirmStep(
?: stringResource(R.string.cw_value_derived),
hint = stringResource(R.string.cw_hint_manage),
)
LabeledLine(
label = stringResource(R.string.cw_label_api_key),
value = if (payload.key.isBlank()) {
stringResource(R.string.cw_value_not_included)
} else {
stringResource(R.string.cw_value_included)
},
)
if (payload.hasApiServer) {
LabeledLine(
label = stringResource(R.string.cw_label_api_key),
value = if (payload.key.isBlank()) {
stringResource(R.string.cw_value_not_included)
} else {
stringResource(R.string.cw_value_included)
},
)
}
}
if (relayUrl != null) {
LabeledLine(
@@ -106,9 +106,27 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
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.url?.let { MetaRow("URL", 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,
)
}
Spacer(Modifier.height(14.dp))
val body = entry.stacktrace ?: entry.detail
@@ -269,9 +287,15 @@ private fun DiagnosticLogEntry.toPlainText(): String = buildString {
appendLine("Severity: ${severity.name}")
appendLine("Time: ${DateFormat.format("yyyy-MM-dd HH:mm:ss", timestampMs)}")
appendLine("App: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE}) ${BuildConfig.FLAVOR}")
operation?.let { appendLine("Operation: $it") }
endpointRole?.let { appendLine("Route: $it") }
url?.let { appendLine("URL: $it") }
configuredUrl?.let { appendLine("Configured URL: $it") }
requestUrl?.let { appendLine("Request: $it") }
if (configuredUrl == null && requestUrl == null) {
url?.let { appendLine("URL: $it") }
}
elapsedMs?.let { appendLine("Elapsed: ${it}ms") }
suggestion?.let { appendLine("Suggested next step: $it") }
detail?.let {
appendLine()
appendLine("Detail:")
@@ -218,8 +218,10 @@ private fun severityColor(severity: DiagnosticSeverity): Color = when (severity)
private fun DiagnosticLogEntry.detailLine(): String? {
val pieces = listOfNotNull(
detail,
suggestion,
operation,
endpointRole?.let { "route=$it" },
url,
primaryUrl,
elapsedMs?.let { "${it}ms" },
)
return pieces.joinToString(" - ").takeIf { it.isNotBlank() }
@@ -18,6 +18,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
@@ -54,6 +55,7 @@ import com.hermesandroid.relay.data.PetBehaviorPreferences
import com.hermesandroid.relay.data.PetTemperament
import com.hermesandroid.relay.ui.components.avatar.AgentAvatar
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalPetGroundOpaqueBottom
import com.hermesandroid.relay.ui.components.avatar.PetLocomotion
import com.hermesandroid.relay.ui.components.pet.LocalPetSafeAreaRegistry
import com.hermesandroid.relay.ui.components.pet.PetLayoutDirection
@@ -106,6 +108,7 @@ import kotlin.math.roundToInt
import kotlin.math.sqrt
internal const val FLOATING_PET_COMPACT_HEIGHT_DP = 700
internal const val FLOATING_PET_SUPPORTED_RAIL_CLEARANCE_DP = 0f
internal const val CHAT_PET_WALK_REGION = "chat-composer-perch"
internal const val CHAT_PET_MESSAGE_PERCH_PREFIX = "chat-message-perch:"
internal const val CHAT_PET_ASSISTANT_MESSAGE_PERCH_PREFIX = "${CHAT_PET_MESSAGE_PERCH_PREFIX}assistant:"
@@ -220,6 +223,18 @@ internal fun floatingPetAcceptsPointerInput(
surfaceScrolling: Boolean,
): Boolean = positioned && !surfaceScrolling
/** The overlay's pre-measure zero size is not a valid coordinate space. */
internal fun shouldInitializeFloatingPet(
positioned: Boolean,
viewportWidth: Int,
viewportHeight: Int,
terrainReady: Boolean = true,
): Boolean = !positioned && viewportWidth > 0 && viewportHeight > 0 && terrainReady
/** Collision geometry must contain both the pointer target and rendered art. */
internal fun floatingPetCollisionSizePx(targetSizePx: Float, visualSizePx: Float): Float =
maxOf(targetSizePx, visualSizePx)
internal fun floatingPetRoamDelayMs(
hasMoved: Boolean,
roamIntervalMs: Long = PET_ROAM_REPEAT_DELAY_MS,
@@ -490,12 +505,17 @@ fun FloatingPetCompanion(
val visualSize = dimensions.visualSizeDp.dp
val targetSizePx = with(density) { targetSize.toPx() }
val visualSizePx = with(density) { visualSize.toPx() }
val collisionSizePx = floatingPetCollisionSizePx(targetSizePx, visualSizePx)
val collisionSize = with(density) { collisionSizePx.toDp() }
val heldLiftPx = with(density) { 6.dp.toPx() }
val scrollReactionLiftPx = with(density) { PET_SCROLL_REACTION_LIFT_DP.dp.toPx() }
val maximumDirectHopPx = with(density) { PET_MAX_DIRECT_HOP_DP.dp.toPx() }
val maximumMessageHopPx = with(density) { PET_MAX_CLEAR_MESSAGE_HOP_DP.dp.toPx() }
val safeMarginPx = with(density) { 12.dp.toPx() }
val perchClearancePx = with(density) { 6.dp.toPx() }
// A supported collision box sits directly on its measured rail. Obstacle
// clearance remains part of [footprint]; adding it again here makes the
// visible pet hover above the surface.
val perchClearancePx = with(density) { FLOATING_PET_SUPPORTED_RAIL_CLEARANCE_DP.dp.toPx() }
val topClearancePx = with(density) { 76.dp.toPx() }
val bottomClearancePx = with(density) { (if (compact) 84.dp else 104.dp).toPx() }
val radius = targetSizePx / 2f
@@ -514,8 +534,8 @@ fun FloatingPetCompanion(
PetSafeBounds(left, top, right, bottom)
}
val registry = LocalPetSafeAreaRegistry.current
val footprint = remember(targetSizePx, safeMarginPx) {
PetFootprint(targetSizePx, targetSizePx, safeMarginPx / 2f)
val footprint = remember(collisionSizePx, safeMarginPx) {
PetFootprint(collisionSizePx, collisionSizePx, safeMarginPx / 2f)
}
// Bubble-edge hops are visual traversal, not persistent placement. Keep
// the larger accessible target for controls and viewport containment while
@@ -1583,8 +1603,26 @@ fun FloatingPetCompanion(
return true
}
LaunchedEffect(pet.id, homePoint) {
if (!positioned) {
// Chat roaming owns a measured composer rail. Publishing before that rail
// exists lets initialization race route registration and can strand the
// pet on transient fallback geometry until direct manipulation.
val initialTerrainReady = route != "chat" || !roamingEnabled || !roamingAllowed ||
composerRails.isNotEmpty()
LaunchedEffect(
pet.id,
homePoint,
viewportWidth,
viewportHeight,
initialTerrainReady,
) {
if (
shouldInitializeFloatingPet(
positioned = positioned,
viewportWidth = viewportWidth,
viewportHeight = viewportHeight,
terrainReady = initialTerrainReady,
)
) {
x.snapTo(homePoint.x)
y.snapTo(homePoint.y)
positioned = true
@@ -2211,11 +2249,14 @@ fun FloatingPetCompanion(
.offset {
val displayed = draggedPoint ?: PetPoint(x.value, y.value)
IntOffset(
(displayed.x - targetSizePx / 2f).roundToInt(),
(displayed.y - targetSizePx / 2f).roundToInt(),
(displayed.x - collisionSizePx / 2f).roundToInt(),
(displayed.y - collisionSizePx / 2f).roundToInt(),
)
}
.size(targetSize)
// The host, route footprint, and visible art share one center.
// A smaller pointer-only host shifts bottom-aligned art upward
// whenever the authored visual is larger than its touch target.
.size(collisionSize)
.alpha(if (positioned) 1f else 0f)
.graphicsLayer {
val scale = 1f + heldProgress * 0.10f
@@ -2354,22 +2395,26 @@ fun FloatingPetCompanion(
),
)
key(pet.id) {
pet.Render(
state = state.copy(
petLocomotion = presentedPetLocomotion(
dragging = dragging,
dropping = pendingDrop != null,
agentState = state.state,
movement = locomotion,
CompositionLocalProvider(LocalPetGroundOpaqueBottom provides true) {
pet.Render(
state = state.copy(
petLocomotion = presentedPetLocomotion(
dragging = dragging,
dropping = pendingDrop != null,
agentState = state.state,
movement = locomotion,
),
paused = shouldPauseFloatingPet(
alreadyPaused = state.paused || !accessibleMotion.osAnimations ||
accessibleMotion.touchExploration,
animationEnabled = animationEnabled,
),
),
paused = shouldPauseFloatingPet(
alreadyPaused = state.paused || !accessibleMotion.osAnimations ||
accessibleMotion.touchExploration,
animationEnabled = animationEnabled,
),
),
modifier = Modifier.size(visualSize),
)
modifier = Modifier
.align(Alignment.BottomCenter)
.size(visualSize),
)
}
}
DropdownMenu(
@@ -58,6 +58,7 @@ import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@@ -87,9 +88,10 @@ fun MessageBubble(
isFirstInGroup: Boolean = true,
isLastInGroup: Boolean = true,
/**
* Keeps a just-completed live tail on its stable Text layout. The owning
* list releases this once another row becomes the tail, allowing full
* Markdown to render without disturbing the visible bottom anchor.
* Keeps the current live tail on its stable Text layout while a final
* streaming frame commits. The owning list releases it immediately after
* completion so the same row transitions to full Markdown with its bottom
* anchor preserved.
*/
retainStreamingLayout: Boolean = false,
onCopyMessage: (String) -> Unit = {},
@@ -539,11 +541,10 @@ fun MessageBubble(
color = textColor
)
} else {
// Keep one plain Text node stable while content grows
// and while this completed response remains the visible
// tail. Full Markdown renders once another row becomes
// the tail (or the session is revisited), where its
// remeasure cannot reset the active viewport.
// Keep one plain Text node stable only while content is
// incomplete (plus the final committed live frame).
// Completion releases this flag and selects the full
// Markdown tree on the same stable message row.
if (markdownBody.isNotEmpty()) {
StreamingMarkdownContent(
content = markdownBody,
@@ -748,16 +749,21 @@ fun MessageBubble(
// burst of fragments doesn't stack three near-touching time labels.
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
// Keep the live bubble's footer structurally quiet. Showing a
// timestamp while text is still growing makes it chase every
// token and exaggerates any single-frame layout lag. Reveal it
// once the message settles into its final layout.
if (isLastInGroup && !message.isStreaming) {
// Reserve the footer from the first streaming frame so
// completion is a color-only transition and cannot resize the
// row. Hide the reserved timestamp from accessibility until it
// becomes visible.
if (isLastInGroup) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = timeFormat.format(Date(message.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.6f)
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
modifier = if (message.isStreaming) {
Modifier.clearAndSetSemantics { }
} else {
Modifier
},
)
}
@@ -85,7 +85,12 @@ fun ModelPickerSheet(
// (the caller sorts is_current to the front).
val otherGroupLabel = stringResource(R.string.model_picker_other)
val grouped = remember(filtered, otherGroupLabel) {
filtered.groupBy { it.group?.takeIf { g -> g.isNotBlank() } ?: otherGroupLabel }
filtered.groupBy { option ->
ModelPickerGroupIdentity(
provider = option.provider?.trim()?.lowercase().orEmpty(),
label = option.group?.takeIf { it.isNotBlank() } ?: otherGroupLabel,
)
}
}
ModalBottomSheet(
@@ -180,10 +185,10 @@ fun ModelPickerSheet(
}
}
grouped.forEach { (provider, opts) ->
item(key = "header_$provider") {
grouped.forEach { (group, opts) ->
item(key = modelPickerHeaderKey(group)) {
Text(
text = provider,
text = group.label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
@@ -191,7 +196,7 @@ fun ModelPickerSheet(
),
)
}
items(items = opts, key = { "$provider:${it.value}" }) { opt ->
items(items = opts, key = ::modelPickerOptionKey) { opt ->
ModelPickerRow(option = opt, onClick = { onSelect(opt) })
}
}
@@ -217,6 +222,18 @@ fun ModelPickerSheet(
}
}
internal data class ModelPickerGroupIdentity(
val provider: String,
val label: String,
)
internal fun modelPickerHeaderKey(group: ModelPickerGroupIdentity): String =
"provider:${group.provider}:${group.label}"
/** Exact domain identity; duplicate provider/model rows must be repaired upstream. */
internal fun modelPickerOptionKey(option: ChatInputPickerOption): String =
"model:${option.provider?.trim()?.lowercase().orEmpty()}:${option.value}"
/**
* Compact sibling of [ModelPickerSheet] for short, ungrouped control lists.
* Passport selectors use this so profile, personality, and reasoning choices
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.ui.components
import android.util.Log
import android.view.ViewGroup
import androidx.activity.compose.BackHandler
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
@@ -13,6 +14,7 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -52,6 +54,11 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
@@ -63,6 +70,7 @@ import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint
@@ -80,6 +88,8 @@ import java.net.URI
import java.util.concurrent.Executors
import kotlin.math.max
internal fun scannerSettleDelayMs(motionEnabled: Boolean): Long = if (motionEnabled) 200L else 0L
/**
* Parsed result from a Hermes pairing QR code.
*
@@ -182,7 +192,11 @@ data class HermesPairingPayload(
) {
/** Build the full API server URL from host, port, and tls flag. */
val serverUrl: String
get() = "${if (tls) "https" else "http"}://$host:$port"
get() = if (host.isBlank()) "" else "${if (tls) "https" else "http"}://$host:$port"
/** Whether this setup payload explicitly configures the optional API server. */
val hasApiServer: Boolean
get() = host.isNotBlank()
}
/**
@@ -239,14 +253,17 @@ private val json = Json {
* Try to parse a scanned string as a Hermes connection QR payload.
*
* Accepts v1, v2, and v3 (or anything without a `hermes` field — we default
* to `1`). Returns null when the payload is not valid JSON, has no `host`
* field, or fails strict decoding.
* to `1`). Returns null when the payload is not valid JSON, configures neither
* a recognized API nor Dashboard surface, or fails strict decoding.
*
* For standard Hermes setup, also accepts generic API-only QRs:
* - a plain `http://host:8642` or `https://host:8642` URL
* - JSON with `api_url`, `apiUrl`, `server_url`, `serverUrl`, or `url`, plus
* optional `api_key`, `apiKey`, or `key`, and optional `dashboard_url` or
* `dashboardUrl`
* - JSON with only `dashboard_url` or `dashboardUrl`, containing a fully
* qualified HTTP(S) Dashboard/Gateway URL. This represents the upstream
* standard surface without inventing an API server or Relay endpoint.
*
* **Endpoint synthesis (ADR 24):** when the payload has no `endpoints`
* array (v1/v2 QRs), a single priority-0 [EndpointCandidate] is materialized
@@ -261,6 +278,7 @@ fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
val trimmed = raw.trim()
return parseHermesRelayQr(trimmed)
?: parseGenericApiJsonQr(trimmed)
?: parseGenericDashboardJsonQr(trimmed)
?: parseGenericApiUrlQr(trimmed)
}
@@ -327,6 +345,38 @@ private fun parseGenericApiUrlQr(raw: String): HermesPairingPayload? {
return payloadFromApiUrl(raw, apiKey = "")
}
private fun parseGenericDashboardJsonQr(raw: String): HermesPairingPayload? {
return try {
val obj = json.decodeFromString<JsonObject>(raw)
// Do not silently downgrade a malformed API setup into Dashboard-only
// setup. When an API alias is present, parseGenericApiJsonQr owns the
// payload and must either accept or reject it as a whole.
val apiAliases = setOf("api_url", "apiUrl", "server_url", "serverUrl", "url")
if (obj.keys.any { it in apiAliases }) return null
val dashboardUrl = firstString(obj, "dashboard_url", "dashboardUrl")
?.trimEnd('/')
?: return null
val uri = runCatching { URI(dashboardUrl) }.getOrNull() ?: return null
if (uri.scheme?.lowercase() !in setOf("http", "https") || uri.host.isNullOrBlank()) {
return null
}
HermesPairingPayload(
dashboardUrl = dashboardUrl,
endpoints = listOf(
EndpointCandidate(
role = Connection.inferRouteRole(dashboardUrl),
priority = 0,
dashboard = DashboardEndpoint(dashboardUrl),
),
),
)
} catch (_: Exception) {
null
}
}
private fun firstString(obj: JsonObject, vararg names: String): String? {
return names.firstNotNullOfOrNull { name ->
obj[name]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
@@ -547,6 +597,12 @@ fun QrPairingScanner(
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val motion = rememberAccessibleMotionState()
val motionEnabled = motion.osAnimations && !motion.touchExploration
val scannerPaneTitle = stringResource(
if (relayOnly) R.string.qr_scanner_relay_title else R.string.qr_scanner_title,
)
BackHandler(onBack = onDismiss)
// Pre-resolve strings used inside non-Composable contexts (CameraX
// listeners, exception handlers, etc. don't have a Composable scope).
val cameraErrorMsg = stringResource(R.string.qr_scanner_camera_error)
@@ -609,7 +665,8 @@ fun QrPairingScanner(
// ~450ms so the user perceives the lock-on, then forward to onPairingDetected.
LaunchedEffect(lockedPayload) {
val payload = lockedPayload ?: return@LaunchedEffect
kotlinx.coroutines.delay(450)
val settleDelayMs = scannerSettleDelayMs(motionEnabled)
if (settleDelayMs > 0) kotlinx.coroutines.delay(settleDelayMs)
onPairingDetected(payload)
}
@@ -617,6 +674,7 @@ fun QrPairingScanner(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.95f))
.semantics { paneTitle = scannerPaneTitle }
) {
Column(
modifier = Modifier.fillMaxSize(),
@@ -645,7 +703,7 @@ fun QrPairingScanner(
),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.Center)
modifier = Modifier.align(Alignment.Center).semantics { heading() }
)
}
@@ -796,6 +854,7 @@ fun QrPairingScanner(
ScannerCornersOverlay(
detected = detectedBox,
locked = lockedPayload != null,
motionEnabled = motionEnabled,
modifier = Modifier.fillMaxSize(),
)
}
@@ -833,6 +892,19 @@ fun QrPairingScanner(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Text(
text = stringResource(
if (lockedPayload == null) R.string.qr_scanner_ready
else R.string.qr_scanner_found,
),
style = MaterialTheme.typography.labelMedium,
color = if (lockedPayload == null) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.primary
},
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
)
}
}
}
@@ -905,6 +977,7 @@ private fun CameraUnavailableCard(
private fun ScannerCornersOverlay(
detected: ViewportRect?,
locked: Boolean,
motionEnabled: Boolean,
modifier: Modifier = Modifier,
) {
// Themed idle: two-tone gradient between primary (top-left/bottom-right)
@@ -922,22 +995,28 @@ private fun ScannerCornersOverlay(
// Slow breathing pulse on alpha when idle. Locked state stays solid +
// gets its own one-shot ramp so the green burst is unmistakable.
val infiniteTransition = rememberInfiniteTransition(label = "scan-corners")
val idlePulse by infiniteTransition.animateFloat(
initialValue = 0.45f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "idle-pulse",
)
val idlePulse = if (motionEnabled) {
val infiniteTransition = rememberInfiniteTransition(label = "scan-corners")
infiniteTransition.animateFloat(
initialValue = 0.45f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "idle-pulse",
).value
} else {
1f
}
// One-shot ramp that fires when `locked` flips true. Drives the
// outward scale pop on the corners + the green tint flash overlay.
val lockRamp by animateFloatAsState(
targetValue = if (locked) 1f else 0f,
animationSpec = if (locked) {
animationSpec = if (!motionEnabled) {
snap()
} else if (locked) {
spring(dampingRatio = 0.55f, stiffness = 220f)
} else {
tween(180)
@@ -973,14 +1052,15 @@ private fun ScannerCornersOverlay(
targetBottom = 0f
}
val springSpec = spring<Float>(
dampingRatio = 0.7f,
stiffness = 280f,
)
val animLeft by animateFloatAsState(targetLeft, springSpec, label = "snap-l")
val animTop by animateFloatAsState(targetTop, springSpec, label = "snap-t")
val animRight by animateFloatAsState(targetRight, springSpec, label = "snap-r")
val animBottom by animateFloatAsState(targetBottom, springSpec, label = "snap-b")
val positionSpec = if (motionEnabled) {
spring<Float>(dampingRatio = 0.7f, stiffness = 280f)
} else {
snap()
}
val animLeft by animateFloatAsState(targetLeft, positionSpec, label = "snap-l")
val animTop by animateFloatAsState(targetTop, positionSpec, label = "snap-t")
val animRight by animateFloatAsState(targetRight, positionSpec, label = "snap-r")
val animBottom by animateFloatAsState(targetBottom, positionSpec, label = "snap-b")
Canvas(
modifier = modifier.onSizeChanged { size = it }
@@ -1100,7 +1180,7 @@ private fun ScannerCornersOverlay(
// Lock flash — brief green tint over the entire viewport that fades
// out as lockRamp settles. Driven by the same spring as the corner
// pop so they read as one event.
if (lockRamp > 0f) {
if (motionEnabled && lockRamp > 0f) {
drawRect(
color = successCore.copy(alpha = 0.18f * lockRamp),
topLeft = Offset.Zero,
@@ -0,0 +1,20 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.ReasoningEfforts
/** One localized label source shared by the composer and Agent Passport. */
@Composable
fun reasoningEffortLabel(value: String?): String = when (ReasoningEfforts.normalize(value)) {
"none" -> stringResource(R.string.chat_reasoning_none)
"minimal" -> stringResource(R.string.chat_reasoning_minimal)
"low" -> stringResource(R.string.chat_reasoning_low)
"medium" -> stringResource(R.string.chat_reasoning_medium)
"high" -> stringResource(R.string.chat_reasoning_high)
"xhigh" -> stringResource(R.string.chat_reasoning_xhigh)
"max" -> stringResource(R.string.chat_reasoning_max)
"ultra" -> stringResource(R.string.chat_reasoning_ultra)
else -> error("ReasoningEfforts.normalize returned a non-canonical value")
}
@@ -1,7 +1,15 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.SweepGradient
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -33,6 +41,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -57,26 +66,52 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private enum class SessionDrawerFilter {
internal enum class SessionDrawerFilter {
All,
Threads,
Pinned,
Archive,
}
internal const val SESSION_DRAWER_LIST_TAG = "session-drawer-list"
internal const val UNPINNED_STAR_ALPHA = 0.45f
internal fun sessionPinIcon(pinned: Boolean) =
if (pinned) Icons.Filled.Star else Icons.Outlined.StarBorder
internal fun resolveSessionDrawerFilter(
filter: SessionDrawerFilter,
showThreads: Boolean,
archiveSupported: Boolean,
): SessionDrawerFilter = when {
filter == SessionDrawerFilter.Threads && !showThreads -> SessionDrawerFilter.All
filter == SessionDrawerFilter.Archive && !archiveSupported -> SessionDrawerFilter.All
else -> filter
}
@Composable
fun SessionDrawerContent(
sessions: List<ChatSession>,
@@ -85,12 +120,17 @@ fun SessionDrawerContent(
scopeSubtitle: String? = null,
isLoading: Boolean = false,
isOpen: Boolean = true,
activityStates: Map<String, SessionActivityState> = emptyMap(),
animationEnabled: Boolean = true,
autoTitlesSupported: Boolean = true,
archiveSupported: Boolean = true,
onRefresh: (() -> Unit)? = null,
onNewChat: () -> Unit,
onSelectSession: (String) -> Unit,
onDeleteSession: (String) -> Unit,
onRenameSession: (String, String) -> Unit,
onSetSessionPinned: (String, Boolean) -> Unit = { _, _ -> },
onSetSessionArchived: (String, Boolean) -> Unit = { _, _ -> },
onCopySessionId: ((String) -> Unit)? = null,
/**
* When true, the Threads affordance (header spool + filter chip) shows even with no
@@ -115,21 +155,15 @@ fun SessionDrawerContent(
var sourceFilterOpen by remember { mutableStateOf(false) }
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
var query by remember { mutableStateOf("") }
var searchExpanded by remember { mutableStateOf(false) }
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
var pinnedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var archivedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
val listState = rememberLazyListState()
var scrollToTopPending by remember { mutableStateOf(false) }
val trimmedQuery = query.trim()
// Threads affordance shows when the capability is active OR there's already at least one
// agent Thread (source=phone) in the list. If the filter is on Threads but they've
// vanished, fall back to All so the drawer never gets stuck on an empty hidden filter.
val showThreads = threadsCapabilityActive || sessions.any { isThreadSource(it.source) }
val activeFilter = if (filter == SessionDrawerFilter.Threads && !showThreads) {
SessionDrawerFilter.All
} else {
filter
}
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, archiveSupported)
// External gateway sources present (discord/telegram/cron/…) for the source
// filter dropdown. Own chats (tui/api_server) + phone Threads aren't listed.
val presentSources = sessions
@@ -141,14 +175,13 @@ fun SessionDrawerContent(
.asSequence()
.filter { session ->
when (activeFilter) {
SessionDrawerFilter.All -> session.sessionId !in archivedSessionIds
SessionDrawerFilter.All -> !session.archived
SessionDrawerFilter.Threads ->
isThreadSource(session.source) &&
session.sessionId !in archivedSessionIds
!session.archived
SessionDrawerFilter.Pinned ->
session.sessionId in pinnedSessionIds &&
session.sessionId !in archivedSessionIds
SessionDrawerFilter.Archive -> session.sessionId in archivedSessionIds
session.pinned && !session.archived
SessionDrawerFilter.Archive -> session.archived
}
}
.filter { session ->
@@ -166,7 +199,7 @@ fun SessionDrawerContent(
session.model.orEmpty().contains(needle, ignoreCase = true)
}
.sortedWith(
compareByDescending<ChatSession> { it.sessionId in pinnedSessionIds }
compareByDescending<ChatSession> { it.pinned }
.thenByDescending { it.activityTimestamp }
.thenByDescending { it.startTimestamp }
.thenBy { it.title.orEmpty().lowercase(locale = Locale.ROOT) }
@@ -174,27 +207,12 @@ fun SessionDrawerContent(
.toList()
val topVisibleSessionId = visibleSessions.firstOrNull()?.sessionId
LaunchedEffect(isOpen) {
scrollToTopPending = isOpen
if (isOpen && visibleSessions.isNotEmpty()) {
listState.scrollToItem(0)
scrollToTopPending = false
}
}
LaunchedEffect(filter, trimmedQuery) {
if (isOpen && visibleSessions.isNotEmpty()) {
listState.scrollToItem(0)
scrollToTopPending = false
} else if (isOpen) {
scrollToTopPending = true
}
}
LaunchedEffect(isOpen, topVisibleSessionId, visibleSessions.size) {
if (isOpen && scrollToTopPending && visibleSessions.isNotEmpty()) {
listState.scrollToItem(0)
scrollToTopPending = false
LaunchedEffect(isOpen, activeFilter, trimmedQuery, topVisibleSessionId) {
if (isOpen && topVisibleSessionId != null) {
// A drawer-open refresh can reorder rows after the initial scroll.
// Override LazyColumn's normal key anchoring so the new leading
// session is visible instead of being retained above the viewport.
listState.requestScrollToItem(0)
}
}
@@ -298,6 +316,21 @@ fun SessionDrawerContent(
)
}
}
IconButton(
onClick = { searchExpanded = !searchExpanded },
modifier = Modifier.size(36.dp),
) {
Icon(
Icons.Filled.Search,
contentDescription = stringResource(R.string.drawer_search_sessions),
tint = if (searchExpanded || query.isNotBlank()) {
RelayRefresh.Relay
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
}
// Manual re-pull: the server titles a session asynchronously after
// the first turn (and never pushes a rename), so a refresh is the
// way to pick up a title the auto-reconcile window missed.
@@ -335,24 +368,29 @@ fun SessionDrawerContent(
Text(stringResource(R.string.drawer_new_chat))
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
)
if (searchExpanded || query.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
SessionDrawerFilter.entries
.filter { it != SessionDrawerFilter.Threads || showThreads }
.filter { item ->
(item != SessionDrawerFilter.Threads || showThreads) &&
(item != SessionDrawerFilter.Archive || archiveSupported)
}
.forEach { item ->
FilterChip(
selected = activeFilter == item,
@@ -462,27 +500,25 @@ fun SessionDrawerContent(
)
}
} else {
LazyColumn(state = listState) {
LazyColumn(
state = listState,
modifier = Modifier.testTag(SESSION_DRAWER_LIST_TAG),
) {
items(visibleSessions, key = { it.sessionId }) { session ->
SessionItem(
session = session,
isActive = session.sessionId == currentSessionId,
pinned = session.sessionId in pinnedSessionIds,
archived = session.sessionId in archivedSessionIds,
activityState = activityStates[session.sessionId],
animationEnabled = animationEnabled,
pinned = session.pinned,
archived = session.archived,
archiveSupported = archiveSupported,
onClick = { onSelectSession(session.sessionId) },
onTogglePinned = {
pinnedSessionIds = if (session.sessionId in pinnedSessionIds) {
pinnedSessionIds - session.sessionId
} else {
pinnedSessionIds + session.sessionId
}
onSetSessionPinned(session.sessionId, !session.pinned)
},
onToggleArchived = {
archivedSessionIds = if (session.sessionId in archivedSessionIds) {
archivedSessionIds - session.sessionId
} else {
archivedSessionIds + session.sessionId
}
onSetSessionArchived(session.sessionId, !session.archived)
},
onRename = { renameDialogSession = session },
onCopySessionId = { onCopySessionId?.invoke(session.sessionId) },
@@ -590,8 +626,11 @@ fun SessionDrawerContent(
private fun SessionItem(
session: ChatSession,
isActive: Boolean,
activityState: SessionActivityState?,
animationEnabled: Boolean,
pinned: Boolean,
archived: Boolean,
archiveSupported: Boolean,
onClick: () -> Unit,
onTogglePinned: () -> Unit,
onToggleArchived: () -> Unit,
@@ -603,6 +642,12 @@ private fun SessionItem(
val locale = LocalLocale.current.platformLocale
val context = LocalContext.current
val untitledLabel = stringResource(R.string.drawer_untitled)
val activityLabel = when (activityState) {
SessionActivityState.Working -> stringResource(R.string.drawer_activity_working)
SessionActivityState.NeedsInput -> stringResource(R.string.drawer_activity_needs_input)
null -> null
}
val motion = rememberAccessibleMotionState()
val backgroundColor = if (isActive) {
MaterialTheme.colorScheme.secondaryContainer
} else {
@@ -613,6 +658,13 @@ private fun SessionItem(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.sessionActivityBorder(
state = activityState,
animated = animationEnabled && motion.osAnimations && !motion.touchExploration,
)
.semantics {
activityLabel?.let { stateDescription = it }
}
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
@@ -637,6 +689,17 @@ private fun SessionItem(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (activityState != null && activityLabel != null) {
SessionActivityIndicator(activityState, activityLabel)
}
if (pinned) {
Icon(
Icons.Filled.Star,
contentDescription = null,
tint = RelayRefresh.Amber,
modifier = Modifier.size(12.dp),
)
}
// Agent Thread tag — the clean spool + "Thread", so a source=phone
// conversation reads as its own lane in the unified session list (ADR 12).
if (isThreadSource(session.source)) {
@@ -687,18 +750,6 @@ private fun SessionItem(
}
}
IconButton(
onClick = onTogglePinned,
modifier = Modifier.size(36.dp),
) {
Icon(
Icons.Filled.Star,
contentDescription = if (pinned) stringResource(R.string.drawer_unpin_session) else stringResource(R.string.drawer_pin_session),
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(19.dp),
)
}
Box {
IconButton(
onClick = { menuOpen = true },
@@ -715,6 +766,32 @@ private fun SessionItem(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
) {
DropdownMenuItem(
text = {
Text(
if (pinned) {
stringResource(R.string.drawer_unpin_session)
} else {
stringResource(R.string.drawer_pin_session)
}
)
},
leadingIcon = {
Icon(
sessionPinIcon(pinned),
contentDescription = null,
tint = if (pinned) {
RelayRefresh.Amber
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = UNPINNED_STAR_ALPHA)
},
)
},
onClick = {
menuOpen = false
onTogglePinned()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_copy_session_id)) },
leadingIcon = {
@@ -735,24 +812,26 @@ private fun SessionItem(
onRename()
},
)
DropdownMenuItem(
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
leadingIcon = {
Icon(
Icons.Filled.Archive,
contentDescription = null,
tint = if (archived) {
RelayRefresh.Relay
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
onClick = {
menuOpen = false
onToggleArchived()
},
)
if (archiveSupported) {
DropdownMenuItem(
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
leadingIcon = {
Icon(
Icons.Filled.Archive,
contentDescription = null,
tint = if (archived) {
RelayRefresh.Relay
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
onClick = {
menuOpen = false
onToggleArchived()
},
)
}
DropdownMenuItem(
text = {
Text(
@@ -777,6 +856,147 @@ private fun SessionItem(
}
}
@Composable
private fun SessionActivityIndicator(state: SessionActivityState, label: String) {
val color = when (state) {
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(RoundedCornerShape(50))
.background(color),
)
Text(
text = label,
style = relayMetadataStyle(),
color = color,
maxLines = 1,
)
}
}
@Composable
private fun Modifier.sessionActivityBorder(
state: SessionActivityState?,
animated: Boolean,
): Modifier {
if (state == null) return this
val color = when (state) {
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
}
val shouldRotate = animated && state == SessionActivityState.Working
val phase = if (shouldRotate) {
val transition = rememberInfiniteTransition(label = "session-activity")
transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2_230, easing = LinearEasing),
),
label = "session-activity-glow",
)
} else {
null
}
return drawWithCache {
val coreWidth = 1.25.dp.toPx()
val coreInset = coreWidth / 2f
val haloWidth = 5.dp.toPx()
val haloInset = haloWidth / 2f
val radius = 12.dp.toPx()
// Matching transparent endpoints make phase 0 and 1 identical, so the
// full shader rotation loops without the dash-pattern reset of the old ring.
val animatedShader = SweepGradient(
size.width / 2f,
size.height / 2f,
intArrayOf(
color.copy(alpha = 0f).toArgb(),
color.copy(alpha = 0f).toArgb(),
color.copy(alpha = 0.16f).toArgb(),
color.copy(alpha = 0.72f).toArgb(),
color.toArgb(),
color.copy(alpha = 0.34f).toArgb(),
color.copy(alpha = 0f).toArgb(),
),
floatArrayOf(0f, 0.52f, 0.66f, 0.78f, 0.84f, 0.92f, 1f),
)
val shaderMatrix = Matrix()
val haloPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = haloWidth
alpha = 88
shader = animatedShader
}
val middlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 2.75.dp.toPx()
alpha = 150
shader = animatedShader
}
val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = coreWidth
shader = animatedShader
}
val haloRect = RectF(
haloInset,
haloInset,
size.width - haloInset,
size.height - haloInset,
)
val middleInset = middlePaint.strokeWidth / 2f
val middleRect = RectF(
middleInset,
middleInset,
size.width - middleInset,
size.height - middleInset,
)
val coreRect = RectF(
coreInset,
coreInset,
size.width - coreInset,
size.height - coreInset,
)
onDrawWithContent {
drawContent()
if (phase != null) {
shaderMatrix.setRotate(
phase.value * 360f - 90f,
size.width / 2f,
size.height / 2f,
)
animatedShader.setLocalMatrix(shaderMatrix)
drawContext.canvas.nativeCanvas.apply {
drawRoundRect(haloRect, radius, radius, haloPaint)
drawRoundRect(middleRect, radius, radius, middlePaint)
drawRoundRect(coreRect, radius, radius, corePaint)
}
} else {
drawRoundRect(
brush = Brush.linearGradient(
listOf(color.copy(alpha = 0.72f), color.copy(alpha = 0.28f))
),
topLeft = androidx.compose.ui.geometry.Offset(coreInset, coreInset),
size = androidx.compose.ui.geometry.Size(
width = size.width - coreWidth,
height = size.height - coreWidth,
),
cornerRadius = CornerRadius(radius),
style = Stroke(width = coreWidth),
)
}
}
}
}
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
val timestamp = session.activityTimestamp
if (timestamp <= 0L) return null
@@ -40,7 +40,6 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -69,15 +68,20 @@ fun ToolProgressCard(
* message timestamp. Null hides the time.
*/
messageTimestamp: Long? = null,
/**
* Reports explicit user expansion so the owning transcript can yield its
* live-tail auto-follow while the user inspects arguments or results.
*/
onExpandedChange: (Boolean) -> Unit = {},
) {
// Preparing (tool.generating) starts collapsed — the args preview line
// under the header is the whole story until tool.start lands.
// rememberSaveable (keyed per tool call, namespaced by the message item's
// key in the chat LazyColumn) so a manual expand survives scroll-off /
// re-render instead of snapping shut every time the row recomposes.
var expanded by rememberSaveable(
key = "toolcard:" + (toolCall.id ?: "${toolCall.name}:${toolCall.startedAt}"),
) { mutableStateOf(!toolCall.isComplete && !toolCall.isGenerating) }
// Use the call identity as an input, not a custom saveable key: custom keys
// bypass positional scoping and can share state between nested lazy items.
// uiKey survives generating -> running ID adoption and ordinary copies.
var expanded by rememberSaveable(toolCall.uiKey) {
mutableStateOf(!toolCall.isComplete && !toolCall.isGenerating)
}
val isPreparing = toolCall.isGenerating && !toolCall.isComplete
val timeMillis = toolCall.completedAt ?: messageTimestamp
val locale = LocalLocale.current.platformLocale
@@ -88,11 +92,6 @@ fun ToolProgressCard(
}
}
// Auto-collapse when tool completes
LaunchedEffect(toolCall.isComplete) {
if (toolCall.isComplete) expanded = false
}
val statusIcon: ImageVector
val statusColor = when {
toolCall.isComplete && toolCall.success == true -> {
@@ -162,7 +161,10 @@ fun ToolProgressCard(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
.clickable {
expanded = !expanded
onExpandedChange(expanded)
},
verticalAlignment = Alignment.CenterVertically
) {
// Tool type icon
@@ -194,27 +194,29 @@ fun VoiceModeOverlay(
modifier = modifier
.fillMaxSize()
.background(if (focusMode) surface.copy(alpha = 0.96f) else Color.Transparent)
// Click-through fix: in focus mode the overlay is a true modal.
// Consume any pointer event a child (mic button, transcript,
// chips, pill) didn't handle so stray taps/swipes don't fall
// through to the chat + session drawer behind it. Children run on
// the same Main pass leaf-first, so this only catches the gaps.
// In Conversation the overlay is intentionally transparent and the
// chat stays interactive, so no scrim is installed.
.then(
if (focusMode) {
Modifier.pointerInput(Unit) {
) {
if (focusMode) {
// Focus is modal, but its click-through guard must be a sibling
// behind the controls. A consuming pointerInput on the root is an
// ancestor of every button and cancels their gesture detectors on
// later pointer passes, leaving ripples without firing callbacks.
// This scrim wins hit-testing only where no foreground control did,
// so blank-space gestures stay off the chat underneath while mic,
// close, pill, and expanded-panel controls remain interactive.
Box(
Modifier
.fillMaxSize()
.testTag("voiceFocusInputScrim")
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent().changes.forEach { it.consume() }
}
}
}
} else {
Modifier
}
},
)
) {
}
if (focusMode) {
VoiceSessionPill(
uiState = uiState,
@@ -143,3 +143,11 @@ val LocalPetPlaybackSpeed = staticCompositionLocalOf { 1f }
* on; the sphere ignores it.
*/
val LocalPetStabilize = staticCompositionLocalOf { true }
/**
* Grounds decoded pet art on the bottom of its render canvas. The floating
* companion enables this at its host boundary so transparent atlas padding
* cannot make a supported pet appear to hover. Pickers, previews, and message
* avatars retain their centered rendering.
*/
val LocalPetGroundOpaqueBottom = staticCompositionLocalOf { false }
@@ -214,10 +214,10 @@ class PetAvatar(
private val decodedSheets = LinkedHashMap<String, DecodedSheet>(4, 0.75f, true)
private val decodedClips = LinkedHashMap<PetDecodeKey, PetFrames>(8, 0.75f, true)
private fun decode(clip: PetClip, stabilize: Boolean): PetFrames? {
val key = PetDecodeKey(clip, stabilize)
private fun decode(clip: PetClip, stabilize: Boolean, groundOpaqueBottom: Boolean): PetFrames? {
val key = PetDecodeKey(clip, stabilize, groundOpaqueBottom)
synchronized(decodedClips) { decodedClips[key] }?.let { return it }
val decoded = decodeClip(clip, stabilize, decodedSheets) ?: return null
val decoded = decodeClip(clip, stabilize, groundOpaqueBottom, decodedSheets) ?: return null
synchronized(decodedClips) {
decodedClips[key] = decoded
while (decodedClips.size > PET_DECODED_CLIP_CACHE_ENTRIES) {
@@ -363,18 +363,21 @@ class PetAvatar(
// character that floats/jumps cell-to-cell). Global Appearance toggle;
// flipping it selects a separately cached decode.
val stabilize = LocalPetStabilize.current
val groundOpaqueBottom = LocalPetGroundOpaqueBottom.current
// Decode the active clip off the main thread. Keep the last complete
// visual while a new state is loading so greet/done/locomotion swaps do
// not expose the Canvas as a blank frame.
var displayedClip by remember(id, stabilize) {
var displayedClip by remember(id, stabilize, groundOpaqueBottom) {
mutableStateOf<DisplayedPetClip?>(null)
}
LaunchedEffect(id, clip, stabilize, mirrorHorizontally) {
LaunchedEffect(id, clip, stabilize, groundOpaqueBottom, mirrorHorizontally) {
val decoded = if (clip == null) {
null
} else {
withContext(Dispatchers.IO) { runCatching { decode(clip, stabilize) }.getOrNull() }
withContext(Dispatchers.IO) {
runCatching { decode(clip, stabilize, groundOpaqueBottom) }.getOrNull()
}
?.let { DisplayedPetClip(it, mirrorHorizontally) }
}
displayedClip = retainPetFrameDuringDecode(
@@ -484,6 +487,7 @@ private data class DisplayedPetClip(
private data class PetDecodeKey(
val clip: PetClip,
val stabilize: Boolean,
val groundOpaqueBottom: Boolean,
)
/** One decoded atlas per selected pet; all of its row clips share these pixels. */
@@ -492,6 +496,7 @@ private data class DecodedSheet(val bitmap: Bitmap, val image: ImageBitmap)
private fun decodeClip(
clip: PetClip,
stabilize: Boolean,
groundOpaqueBottom: Boolean,
decodedSheets: MutableMap<String, DecodedSheet>,
): PetFrames? = when (clip) {
is FrameSequenceClip -> {
@@ -530,7 +535,13 @@ private fun decodeClip(
frameHeight = bitmaps.first().height,
frameCount = bitmaps.size,
fps = clip.fps,
centerOffsets = if (stabilize) bitmaps.map { bitmapRecenter(it) } else null,
centerOffsets = if (stabilize || groundOpaqueBottom) {
bitmaps.map { bitmap ->
bitmapContentAlignment(bitmap, stabilize, groundOpaqueBottom)
}
} else {
null
},
)
}
}
@@ -583,8 +594,17 @@ private fun decodeClip(
frameCount = count,
fps = clip.fps,
startFrame = clip.startFrame,
centerOffsets = if (stabilize) {
sheetRecenter(bmp, cols, clip.frameWidth, clip.frameHeight, clip.startFrame, count)
centerOffsets = if (stabilize || groundOpaqueBottom) {
sheetContentAlignment(
bmp,
cols,
clip.frameWidth,
clip.frameHeight,
clip.startFrame,
count,
stabilize,
groundOpaqueBottom,
)
} else {
null
},
@@ -593,33 +613,65 @@ private fun decodeClip(
}
}
/** Per-cell recenter offsets for a sprite sheet (see [contentRecenter]). */
private fun sheetRecenter(
/** Per-cell alignment offsets for a sprite sheet (see [contentAlignment]). */
private fun sheetContentAlignment(
bmp: Bitmap,
cols: Int,
fw: Int,
fh: Int,
startFrame: Int,
count: Int,
stabilize: Boolean,
groundOpaqueBottom: Boolean,
): List<IntOffset> {
val buf = IntArray(fw * fh)
return (0 until count).map { i ->
val cell = startFrame + i
contentRecenter(bmp, (cell % cols) * fw, (cell / cols) * fh, fw, fh, buf)
contentAlignment(
bmp,
(cell % cols) * fw,
(cell / cols) * fh,
fw,
fh,
buf,
stabilize,
groundOpaqueBottom,
)
}
}
/** Recenter offset for one standalone frame bitmap. */
private fun bitmapRecenter(bmp: Bitmap): IntOffset =
contentRecenter(bmp, 0, 0, bmp.width, bmp.height, IntArray(bmp.width * bmp.height))
/** Content alignment offset for one standalone frame bitmap. */
private fun bitmapContentAlignment(
bmp: Bitmap,
stabilize: Boolean,
groundOpaqueBottom: Boolean,
): IntOffset = contentAlignment(
bmp,
0,
0,
bmp.width,
bmp.height,
IntArray(bmp.width * bmp.height),
stabilize,
groundOpaqueBottom,
)
/**
* Scan the [w]×[h] region at ([x0],[y0]) of [bmp] for opaque pixels and return the
* offset (source px) that moves their bounding-box center to the region center —
* cancelling per-frame positional drift. [buf] (size ≥ [w]×[h]) is reused scratch.
* Returns [IntOffset.Zero] for a fully-transparent region.
* offset (source px) that horizontally stabilizes the content and, when asked,
* grounds its opaque bottom. [buf] (size ≥ [w]×[h]) is reused scratch. Returns
* [IntOffset.Zero] for a fully-transparent region.
*/
private fun contentRecenter(bmp: Bitmap, x0: Int, y0: Int, w: Int, h: Int, buf: IntArray): IntOffset {
private fun contentAlignment(
bmp: Bitmap,
x0: Int,
y0: Int,
w: Int,
h: Int,
buf: IntArray,
stabilize: Boolean,
groundOpaqueBottom: Boolean,
): IntOffset {
bmp.getPixels(buf, 0, w, x0, y0, w, h)
var minX = w
var minY = h
@@ -638,7 +690,44 @@ private fun contentRecenter(bmp: Bitmap, x0: Int, y0: Int, w: Int, h: Int, buf:
}
}
if (maxX < 0) return IntOffset.Zero
return IntOffset(w / 2 - (minX + maxX) / 2, h / 2 - (minY + maxY) / 2)
return petContentAlignmentOffset(
width = w,
height = h,
minX = minX,
minY = minY,
maxX = maxX,
maxY = maxY,
stabilize = stabilize,
groundOpaqueBottom = groundOpaqueBottom,
)
}
/** Pure alignment policy shared by atlas and standalone frames. */
internal fun petContentAlignmentOffset(
width: Int,
height: Int,
minX: Int,
minY: Int,
maxX: Int,
maxY: Int,
stabilize: Boolean,
groundOpaqueBottom: Boolean,
): IntOffset {
if (
width <= 0 || height <= 0 || minX < 0 || minY < 0 ||
maxX < minX || maxY < minY || maxX >= width || maxY >= height
) {
return IntOffset.Zero
}
val x = if (stabilize) width / 2 - (minX + maxX) / 2 else 0
val y = if (groundOpaqueBottom) {
height - 1 - maxY
} else if (stabilize) {
height / 2 - (minY + maxY) / 2
} else {
0
}
return IntOffset(x, y)
}
/** Draw frame [index] of [f], contain-fit + centered, scaled by [bounce]. */
@@ -87,6 +87,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
@@ -122,9 +123,11 @@ import com.hermesandroid.relay.ui.theme.radialNavyBackground
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ReasoningEfforts
import com.hermesandroid.relay.network.relay.RelayVoiceClient
import com.hermesandroid.relay.network.relay.RealtimeVoiceConfig
import com.hermesandroid.relay.network.relay.VoiceOutputConfig
import com.hermesandroid.relay.ui.components.reasoningEffortLabel
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@@ -163,6 +166,7 @@ import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
import com.hermesandroid.relay.ui.components.AgentInfoSheet
@@ -178,6 +182,7 @@ import com.hermesandroid.relay.ui.components.ChatInputTrailing
import com.hermesandroid.relay.ui.components.CommandPalette
import com.hermesandroid.relay.ui.components.KeepScreenOnWhile
import com.hermesandroid.relay.ui.components.ModelPickerSheet
import com.hermesandroid.relay.ui.components.OptionPickerSheet
import com.hermesandroid.relay.ui.components.ConnectionStatusBadge
import com.hermesandroid.relay.ui.components.CommandRow
import com.hermesandroid.relay.ui.components.CompactToolCall
@@ -249,10 +254,24 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val DEFAULT_CHAR_LIMIT = 4096
private const val CHAT_SCROLL_TO_BOTTOM_PET_PERCH = "chat-scroll-to-bottom-perch"
private const val CHAT_SCROLL_TO_BOTTOM_PET_OBSTACLE = "chat-scroll-to-bottom-obstacle"
private val CHAT_PET_ROUTES = setOf("chat")
internal fun resolveSessionActivityStates(
background: Map<String, SessionActivityState>,
currentSessionId: String?,
isStreaming: Boolean,
needsInput: Boolean,
): Map<String, SessionActivityState> = background.toMutableMap().apply {
currentSessionId?.let { sessionId ->
when {
needsInput -> put(sessionId, SessionActivityState.NeedsInput)
isStreaming -> put(sessionId, SessionActivityState.Working)
else -> remove(sessionId)
}
}
}
internal fun resolveChatHeaderSubtitle(
isStreaming: Boolean,
statusText: String,
@@ -305,27 +324,133 @@ internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?)
previous.messageCount == messageCount &&
previous.lastMessageUiKey == lastMessageUiKey
internal fun releaseRetainedLiveTail(
internal fun retainedLiveTailAfterTransition(
retainedUiKey: String?,
completedUiKey: String?,
): String? = retainedUiKey?.takeUnless { it == completedUiKey }
internal fun tailEndScrollOffset(
tailSizePx: Int,
footerSizePx: Int,
viewportSizePx: Int,
): Int = (tailSizePx + footerSizePx - viewportSizePx).coerceAtLeast(0)
streamStarted: Boolean,
streamCompleted: Boolean,
lastMessageUiKey: String?,
): String? = when {
streamStarted -> lastMessageUiKey
streamCompleted && retainedUiKey == lastMessageUiKey -> null
retainedUiKey != null && retainedUiKey != lastMessageUiKey -> null
else -> retainedUiKey
}
private class ChatTailTransitionRef(
var snapshot: ChatScrollSnapshot? = null,
)
private data class ChatTailLayoutSnapshot(
val uiKey: String?,
val measuredSizePx: Int?,
val shouldFollowGrowth: Boolean,
internal data class ChatViewportFollowSnapshot(
val totalItemsCount: Int,
val tailUiKey: String?,
val tailSizePx: Int?,
val viewportHeightPx: Int,
val visibleBottomDistancePx: Int?,
val followTailGrowth: Boolean,
val followViewportResize: Boolean,
)
internal fun shouldCorrectConversationBottomAfterLayout(
previous: ChatViewportFollowSnapshot?,
current: ChatViewportFollowSnapshot,
atExactBottom: Boolean,
userScrolledAway: Boolean,
userDragging: Boolean,
isStreaming: Boolean,
smoothAutoScroll: Boolean,
viewportFollowAllowed: Boolean,
): Boolean {
val old = previous ?: return false
if (
atExactBottom || userScrolledAway || userDragging || !viewportFollowAllowed ||
(isStreaming && !smoothAutoScroll)
) {
return false
}
if (
old.totalItemsCount != current.totalItemsCount ||
old.tailUiKey == null ||
old.tailUiKey != current.tailUiKey
) {
return false
}
return old.tailSizePx != current.tailSizePx ||
old.viewportHeightPx != current.viewportHeightPx ||
old.visibleBottomDistancePx != current.visibleBottomDistancePx
}
internal fun requiredBottomFollowScroll(
previous: ChatViewportFollowSnapshot?,
current: ChatViewportFollowSnapshot,
): Int {
if (!current.followTailGrowth && !current.followViewportResize) return 0
// When the footer is visible, its trailing edge is the authoritative
// distance to the exact bottom. This also consumes rounding and any small
// non-tail layout changes that a tail-height delta cannot represent.
current.visibleBottomDistancePx?.let { return it.coerceAtLeast(0) }
val previousSnapshot = previous ?: return 0
val tailGrowthPx = if (
current.followTailGrowth &&
previousSnapshot.tailUiKey == current.tailUiKey
) {
((current.tailSizePx ?: 0) - (previousSnapshot.tailSizePx ?: 0)).coerceAtLeast(0)
} else {
0
}
val viewportLossPx = if (current.followViewportResize) {
(previousSnapshot.viewportHeightPx - current.viewportHeightPx).coerceAtLeast(0)
} else {
0
}
return maxOf(tailGrowthPx, viewportLossPx)
}
internal fun ownedBottomFollowScroll(
previous: ChatViewportFollowSnapshot?,
current: ChatViewportFollowSnapshot,
): Int {
val sameTranscript = previous != null &&
previous.totalItemsCount == current.totalItemsCount &&
previous.tailUiKey == current.tailUiKey
// A structural/new-tail transition belongs to the existing streaming
// owner. Ordinary restore-layout following must never opt a reader into
// new-message auto-follow when smooth auto-scroll is disabled.
if (!sameTranscript && !current.followTailGrowth) return 0
return requiredBottomFollowScroll(previous, current)
}
internal fun shouldFollowImeAfterInsetChange(
wasFollowing: Boolean,
previousImeBottomPx: Int,
currentImeBottomPx: Int,
wasAtBottom: Boolean,
userDragging: Boolean,
): Boolean = when {
currentImeBottomPx == 0 || userDragging -> false
previousImeBottomPx == 0 -> wasAtBottom
else -> wasFollowing
}
internal fun shouldExactlySettleConversation(
autoFollowEnabled: Boolean,
userScrolledAway: Boolean,
userDragging: Boolean,
hasMessages: Boolean,
): Boolean = autoFollowEnabled && hasMessages && !userScrolledAway && !userDragging
internal fun shouldFollowConversationViewportResize(
userScrolledAway: Boolean,
userDragging: Boolean,
imeBottomPx: Int,
followImeResize: Boolean,
voiceDockAnchorTransitionActive: Boolean,
): Boolean = !userScrolledAway &&
!userDragging &&
!voiceDockAnchorTransitionActive &&
(followImeResize || imeBottomPx == 0)
private fun LazyListState.isAtConversationBottom(slopPx: Int): Boolean {
val layout = layoutInfo
if (layout.totalItemsCount == 0) return true
@@ -334,6 +459,14 @@ private fun LazyListState.isAtConversationBottom(slopPx: Int): Boolean {
(last.offset + last.size) - layout.viewportEndOffset <= slopPx
}
private fun LazyListState.visibleConversationBottomDistancePx(): Int? {
val layout = layoutInfo
val lastIndex = layout.totalItemsCount - 1
if (lastIndex < 0) return 0
val footer = layout.visibleItemsInfo.firstOrNull { it.index == lastIndex } ?: return null
return ((footer.offset + footer.size) - layout.viewportEndOffset).coerceAtLeast(0)
}
private suspend fun LazyListState.scrollToConversationBottom(
animated: Boolean,
slopPx: Int,
@@ -619,8 +752,25 @@ fun ChatScreen(
val chatMode by connectionViewModel.chatMode.collectAsState()
val error by chatViewModel.error.collectAsState()
val sessions by chatViewModel.sessions.collectAsState()
val backgroundSessionActivityStates by
chatViewModel.backgroundSessionActivityStates.collectAsState()
val serverAutoTitles by chatViewModel.serverAutoTitles.collectAsState()
val sessionArchivingSupported by chatViewModel.sessionArchivingSupported.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
val sessionActivityStates = remember(
backgroundSessionActivityStates,
currentSessionId,
isStreaming,
pendingAsk,
) {
resolveSessionActivityStates(
background = backgroundSessionActivityStates,
currentSessionId = currentSessionId,
isStreaming = isStreaming,
needsInput = pendingAsk != null,
)
}
val backgroundProcesses by chatViewModel.backgroundProcesses.collectAsState()
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
@@ -645,8 +795,11 @@ fun ChatScreen(
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
val reasoningCapabilityRevision by chatViewModel.reasoningCapabilityRevision.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
val selectedProviderOverride by chatViewModel.selectedProviderOverride.collectAsState()
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val gatewayCurrentProvider by chatViewModel.gatewayCurrentProvider.collectAsState()
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
val showThinking by connectionViewModel.showThinking.collectAsState()
@@ -796,6 +949,7 @@ fun ChatScreen(
var inputText by remember { mutableStateOf("") }
var showCommandPalette by remember { mutableStateOf(false) }
var showModelSheet by remember { mutableStateOf(false) }
var showEffortSheet by remember { mutableStateOf(false) }
var showAgentInfo by remember { mutableStateOf(false) }
var showBackgroundProcesses by remember { mutableStateOf(false) }
@@ -1169,7 +1323,25 @@ fun ChatScreen(
var isUserDragging by remember(currentSessionId) { mutableStateOf(false) }
var programmaticBottomScroll by remember { mutableStateOf(false) }
var retainedLiveTailUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
var completionSettlingUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density)
val latestImeBottomPx by rememberUpdatedState(imeBottomPx)
var previousImeBottomPx by remember(currentSessionId) { mutableStateOf(imeBottomPx) }
var followImeResize by remember(currentSessionId) { mutableStateOf(false) }
SideEffect {
followImeResize = shouldFollowImeAfterInsetChange(
wasFollowing = followImeResize,
previousImeBottomPx = previousImeBottomPx,
currentImeBottomPx = imeBottomPx,
// At the first non-zero IME inset, LazyColumn still exposes the
// pre-resize layout. Capture bottom ownership before the viewport
// starts losing height.
wasAtBottom = !userScrolledAway &&
listState.isAtConversationBottom(atBottomSlopPx),
userDragging = isUserDragging,
)
previousImeBottomPx = imeBottomPx
}
val currentUnreadSnapshot = remember(messages) { messages.toUnreadSnapshot() }
var lastReadSnapshot by remember(currentSessionId) {
mutableStateOf(currentUnreadSnapshot)
@@ -1194,7 +1366,9 @@ fun ChatScreen(
try {
listState.scrollToConversationBottom(
animated = animated,
slopPx = atBottomSlopPx,
// Slop preserves follow ownership during motion; an explicit
// settlement must reach the real LazyColumn boundary.
slopPx = 0,
)
userScrolledAway = false
} finally {
@@ -1216,6 +1390,7 @@ fun ChatScreen(
when (interaction) {
is DragInteraction.Start -> {
isUserDragging = true
followImeResize = false
}
is DragInteraction.Stop, is DragInteraction.Cancel -> {
isUserDragging = false
@@ -1225,6 +1400,7 @@ fun ChatScreen(
}
}
var voiceDockAnchorGuardReady by remember { mutableStateOf(false) }
var voiceDockAnchorTransitionActive by remember { mutableStateOf(false) }
LaunchedEffect(conversationVoiceDockVisible) {
if (!voiceDockAnchorGuardReady) {
voiceDockAnchorGuardReady = true
@@ -1238,14 +1414,19 @@ fun ChatScreen(
// every animation frame and appears to scroll when voice mode opens.
val anchorIndex = listState.firstVisibleItemIndex
val anchorOffset = listState.firstVisibleItemScrollOffset
var firstFrameNanos = 0L
var frameNanos: Long
do {
if (isUserDragging) return@LaunchedEffect
listState.requestScrollToItem(anchorIndex, anchorOffset)
frameNanos = withFrameNanos { it }
if (firstFrameNanos == 0L) firstFrameNanos = frameNanos
} while (frameNanos - firstFrameNanos < 300_000_000L)
voiceDockAnchorTransitionActive = true
try {
var firstFrameNanos = 0L
var frameNanos: Long
do {
if (isUserDragging) return@LaunchedEffect
listState.requestScrollToItem(anchorIndex, anchorOffset)
frameNanos = withFrameNanos { it }
if (firstFrameNanos == 0L) firstFrameNanos = frameNanos
} while (frameNanos - firstFrameNanos < 300_000_000L)
} finally {
voiceDockAnchorTransitionActive = false
}
}
// Reaching the bottom by any means (user, follow-pin, content shrank)
// always re-arms auto-follow.
@@ -1253,6 +1434,27 @@ fun ChatScreen(
if (isAtBottom) userScrolledAway = false
}
// IME insets arrive as an animation, not one layout. Wait until inset
// updates pause, then remove any rounding/late-measurement residue if the
// conversation still owns bottom-follow. This runs for both opening and
// closing without moving a transcript whose reader scrolled away.
var lastImeSettleTargetPx by remember(currentSessionId) { mutableStateOf(imeBottomPx) }
LaunchedEffect(imeBottomPx) {
if (imeBottomPx == lastImeSettleTargetPx) return@LaunchedEffect
lastImeSettleTargetPx = imeBottomPx
delay(96)
if (
shouldExactlySettleConversation(
autoFollowEnabled = true,
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
hasMessages = messages.isNotEmpty(),
)
) {
scrollConversationToBottom(animated = false)
}
}
// Scroll-to-bottom FAB visibility. The button means "you've scrolled up —
// tap to catch up", so it must NOT flash while we're auto-pinning to the
// bottom. Suppress it when:
@@ -1267,12 +1469,10 @@ fun ChatScreen(
derivedStateOf {
val retainingVisibleTail = retainedLiveTailUiKey != null &&
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
val settlingVisibleTail = completionSettlingUiKey != null &&
messages.lastOrNull()?.uiKey == completionSettlingUiKey
messages.isNotEmpty() &&
!isAtBottom &&
!programmaticBottomScroll &&
!((isStreaming || retainingVisibleTail || settlingVisibleTail) &&
!((isStreaming || retainingVisibleTail) &&
smoothAutoScroll &&
!userScrolledAway)
}
@@ -1422,6 +1622,22 @@ fun ChatScreen(
}
}
// The drawer and composer share this screen's focus owner. Clear the
// composer's input focus as soon as an open transition is committed so
// menu activation, accessibility activation, and edge swipes all dismiss
// the IME without leaving the obscured composer ready for hardware input.
// Observe the target rather than isOpen so the keyboard closes alongside
// the drawer animation, not after it settles.
LaunchedEffect(drawerState, focusManager) {
snapshotFlow { drawerState.targetValue }
.distinctUntilChanged()
.collect { target ->
if (target == DrawerValue.Open) {
focusManager.clearFocus(force = true)
}
}
}
// Opening the drawer re-syncs the list — so a session created on another
// device (or one whose optimistic row was dropped on a profile switch)
// shows up without a manual reload. Cheap dashboard read; the optimistic
@@ -1450,13 +1666,14 @@ fun ChatScreen(
)
val tailTransitionRef = remember(currentSessionId) { ChatTailTransitionRef() }
// New rows and streaming -> final Markdown are structural transitions.
// Anchor their trailing spacer in SideEffect so the request participates in
// the very next remeasure instead of correcting an already-drawn frame.
// A live tail owns one stable Text renderer while content is incomplete.
// Completion first commits that final live frame, then this SideEffect
// releases the retained renderer and anchors the same uiKey for the next
// remeasure, where the row deterministically becomes rich Markdown.
SideEffect {
val previous = tailTransitionRef.snapshot
val streamStarted = tailTransition.isStreaming && previous?.isStreaming != true
val completed = tailTransition.isCompletionAfter(previous)
val streamCompleted = tailTransition.isCompletionAfter(previous)
val tailStructureChanged = tailTransition.lastMessageUiKey != null &&
(previous == null ||
previous.messageCount != tailTransition.messageCount ||
@@ -1467,21 +1684,18 @@ fun ChatScreen(
// transcript had previously been left above the bottom. Do not
// clear isUserDragging: a real finger keeps priority until release.
userScrolledAway = false
retainedLiveTailUiKey = tailTransition.lastMessageUiKey
} else if (completed) {
completionSettlingUiKey = tailTransition.lastMessageUiKey
} else if (
tailStructureChanged &&
retainedLiveTailUiKey != null &&
retainedLiveTailUiKey != tailTransition.lastMessageUiKey
) {
retainedLiveTailUiKey = null
}
retainedLiveTailUiKey = retainedLiveTailAfterTransition(
retainedUiKey = retainedLiveTailUiKey,
streamStarted = streamStarted,
streamCompleted = streamCompleted,
lastMessageUiKey = tailTransition.lastMessageUiKey,
)
val shouldAnchor = smoothAutoScroll &&
!isUserDragging &&
(!userScrolledAway || streamStarted) &&
(streamStarted || completed || tailStructureChanged)
(streamStarted || streamCompleted || tailStructureChanged)
if (shouldAnchor) {
listState.requestScrollToItem(tailTransition.messageCount + 1)
}
@@ -1489,163 +1703,102 @@ fun ChatScreen(
tailTransitionRef.snapshot = tailTransition
}
// Completion adds the timestamp/footer after the final token. Keep the
// stable live renderer, then consume any small remaining forward range for
// two settled frames. scrollBy preserves the current item anchor and is
// visually inert when already at the exact bottom; unlike scrollToItem it
// cannot align the top of a tall response with the viewport.
LaunchedEffect(
completionSettlingUiKey,
smoothAutoScroll,
userScrolledAway,
isUserDragging,
) {
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
// Retention is only a completion-transition aid. Never leave the
// finalized tail on the plain streaming renderer just because the
// user disabled follow-scroll or is reading above the bottom.
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
completionSettlingUiKey = null
return@LaunchedEffect
}
var settledFrames = 0
var previousMarkdownTailSize: Int? = null
var previousMarkdownFooterSize: Int? = null
val markdownWasAlreadyReleased = retainedLiveTailUiKey != settlingKey
repeat(60) completionFrame@{
withFrameNanos { }
if (messages.lastOrNull()?.uiKey != settlingKey) {
completionSettlingUiKey = null
return@LaunchedEffect
}
if (!markdownWasAlreadyReleased && retainedLiveTailUiKey == settlingKey) {
if (listState.canScrollForward) {
settledFrames = 0
val viewportHeight = listState.layoutInfo.viewportSize.height
if (viewportHeight > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(viewportHeight.toFloat())
}
}
return@completionFrame
}
settledFrames += 1
if (settledFrames < 2) return@completionFrame
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
settledFrames = 0
return@completionFrame
}
// Once Markdown owns the row, position its measured trailing edge
// explicitly. `canScrollForward` is insufficient here: LazyColumn
// may preserve the leading edge of a tall item while reporting an
// otherwise valid item anchor. Repeating catches deferred parsing,
// highlighted code, and attachment measurement without competing
// with the ordinary streaming-growth coroutine.
val layout = listState.layoutInfo
val tailIndex = messages.size // header item + zero-based messages
val footerIndex = tailIndex + 1
val tailInfo = layout.visibleItemsInfo.firstOrNull { it.index == tailIndex }
val footerInfo = layout.visibleItemsInfo.firstOrNull { it.index == footerIndex }
if (tailInfo == null) {
listState.scrollToItem(tailIndex)
settledFrames = 0
return@completionFrame
}
val viewportHeight = layout.viewportSize.height
if (viewportHeight <= 0) return@completionFrame
val desiredOffset = tailEndScrollOffset(
tailSizePx = tailInfo.size,
footerSizePx = footerInfo?.size ?: 0,
viewportSizePx = viewportHeight,
)
if (desiredOffset == 0) {
listState.scrollToItem(footerIndex)
} else {
listState.scrollToItem(tailIndex, desiredOffset)
}
val footerSize = footerInfo?.size ?: 0
settledFrames = if (
previousMarkdownTailSize == tailInfo.size &&
previousMarkdownFooterSize == footerSize
) {
settledFrames + 1
} else {
0
}
previousMarkdownTailSize = tailInfo.size
previousMarkdownFooterSize = footerSize
if (settledFrames >= 12) {
completionSettlingUiKey = null
return@LaunchedEffect
}
}
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
completionSettlingUiKey = null
}
// Ordinary streaming growth keeps the same row and Text node. Advance the
// existing scroll position by exactly the measured positive height delta;
// never replace the logical anchor with scrollToItem(). User input has a
// higher mutation priority and cancels this work naturally.
LaunchedEffect(listState, smoothAutoScroll, userScrolledAway, isUserDragging) {
if (!smoothAutoScroll || userScrolledAway || isUserDragging) return@LaunchedEffect
var previousLayout: ChatTailLayoutSnapshot? = null
// One owner follows every bottom-preserving viewport transition. Streaming
// growth advances by its measured delta, while any ordinary viewport loss
// (IME, late composer controls, status text, or top chrome hydration)
// advances by the lost height. This matters on restore: model and effort
// controls can finish resolving after history has already reached the
// footer. The voice dock's measured 300 ms anchor transition temporarily
// owns both follow paths; ordinary late layout correction resumes after it
// settles. A visible footer supplies the authoritative final distance. No
// transition replaces the logical item anchor.
val latestMessages = rememberUpdatedState(messages)
LaunchedEffect(listState, currentSessionId, smoothAutoScroll) {
var previousLayout: ChatViewportFollowSnapshot? = null
snapshotFlow {
val tail = messages.lastOrNull()
// The effect deliberately survives each streamed list replacement.
// Read through rememberUpdatedState so its long-lived coroutine does
// not keep the message list captured when the effect first launched.
val tail = latestMessages.value.lastOrNull()
val tailSize = tail?.uiKey?.let { uiKey ->
listState.layoutInfo.visibleItemsInfo
.firstOrNull { item -> item.key == uiKey }
?.size
}
ChatTailLayoutSnapshot(
uiKey = tail?.uiKey,
measuredSizePx = tailSize,
shouldFollowGrowth = tail?.isStreaming == true ||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey),
ChatViewportFollowSnapshot(
totalItemsCount = listState.layoutInfo.totalItemsCount,
tailUiKey = tail?.uiKey,
tailSizePx = tailSize,
viewportHeightPx = listState.layoutInfo.viewportSize.height,
visibleBottomDistancePx = listState.visibleConversationBottomDistancePx(),
followTailGrowth = !voiceDockAnchorTransitionActive &&
!isUserDragging &&
smoothAutoScroll &&
!userScrolledAway &&
(tail?.isStreaming == true ||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey)),
followViewportResize = shouldFollowConversationViewportResize(
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
imeBottomPx = latestImeBottomPx,
followImeResize = followImeResize,
voiceDockAnchorTransitionActive = voiceDockAnchorTransitionActive,
),
)
}
.distinctUntilChanged()
.collect { current ->
val previous = previousLayout
val scrollPx = ownedBottomFollowScroll(previous, current)
val correctLateLayout = shouldCorrectConversationBottomAfterLayout(
previous = previous,
current = current,
atExactBottom = listState.isAtConversationBottom(0),
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
isStreaming = latestMessages.value.lastOrNull()?.isStreaming == true,
smoothAutoScroll = smoothAutoScroll,
viewportFollowAllowed = current.followViewportResize,
)
previousLayout = current
val previousSize = previous?.measuredSizePx ?: return@collect
val currentSize = current.measuredSizePx ?: return@collect
if (!current.shouldFollowGrowth || previous.uiKey != current.uiKey) return@collect
val growthPx = currentSize - previousSize
if (growthPx > 0) {
if (scrollPx > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(growthPx.toFloat())
scrollBy(scrollPx.toFloat())
}
} else if (correctLateLayout) {
val lastIndex = listState.layoutInfo.totalItemsCount - 1
if (lastIndex >= 0) {
programmaticBottomScroll = true
try {
listState.scrollToItem(lastIndex)
userScrolledAway = false
} finally {
programmaticBottomScroll = false
}
}
}
}
}
// Completion haptic only; scroll ownership remains with the transition and
// measured-growth paths above.
var observedActiveStream by remember { mutableStateOf(false) }
// Completion settles the owned transcript after the final live frame. The
// SideEffect above separately anchors the same row's Markdown remeasure.
var observedActiveStream by remember(currentSessionId) { mutableStateOf(false) }
LaunchedEffect(isStreaming) {
if (isStreaming) {
observedActiveStream = true
} else if (observedActiveStream) {
observedActiveStream = false
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
if (
shouldExactlySettleConversation(
autoFollowEnabled = smoothAutoScroll,
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
hasMessages = messages.isNotEmpty(),
)
) {
scrollConversationToBottom(animated = false)
}
}
}
@@ -1730,7 +1883,10 @@ fun ChatScreen(
scopeSubtitle = drawerSubtitle,
isLoading = isLoadingSessions,
isOpen = drawerState.isOpen,
activityStates = sessionActivityStates,
animationEnabled = animationEnabled,
autoTitlesSupported = serverAutoTitles,
archiveSupported = sessionArchivingSupported,
onRefresh = { chatViewModel.refreshSessions() },
onNewChat = {
chatViewModel.createNewChat()
@@ -1746,6 +1902,8 @@ fun ChatScreen(
onRenameSession = { sessionId, title ->
chatViewModel.renameSession(sessionId, title)
},
onSetSessionPinned = chatViewModel::setSessionPinned,
onSetSessionArchived = chatViewModel::setSessionArchived,
onCopySessionId = { sessionId ->
scope.launch {
clipboard.setClipEntry(
@@ -2727,6 +2885,15 @@ fun ChatScreen(
else -> ToolProgressCard(
toolCall = toolCall,
messageTimestamp = message.timestamp,
onExpandedChange = { expanded ->
// Expanding a live-tail card is reading intent,
// not new stream output. Yield bottom-follow so
// the row's height change stays under the user's
// finger instead of being scrolled away.
if (expanded && isStreaming) {
userScrolledAway = true
}
},
)
}
}
@@ -2777,14 +2944,14 @@ fun ChatScreen(
) {
Box(
modifier = Modifier
// The visible FAB is narrower than the pet's
// footprint. Measure a transparent landing
// ledge around it so the pet can stand above
// the control without covering its touch area.
// The complete control envelope is forbidden
// terrain. Registering it as a perch invited
// the pet onto the button and let sibling
// composer routes treat it as walkable terrain.
.width(72.dp)
.height(48.dp)
.petPerchSurface(
key = CHAT_SCROLL_TO_BOTTOM_PET_PERCH,
.petObstacleSurface(
key = CHAT_SCROLL_TO_BOTTOM_PET_OBSTACLE,
routes = CHAT_PET_ROUTES,
),
contentAlignment = Alignment.Center,
@@ -2792,14 +2959,6 @@ fun ChatScreen(
SmallFloatingActionButton(
modifier = Modifier
.size(48.dp)
// The surrounding box is a landing ledge, but
// the real control remains forbidden space so
// composer and bubble routes cannot pass the
// pet's complete scaled footprint over it.
.petObstacleSurface(
key = CHAT_SCROLL_TO_BOTTOM_PET_OBSTACLE,
routes = CHAT_PET_ROUTES,
)
.semantics {
contentDescription = if (unreadMessageCount > 0) {
"Scroll to bottom, $unreadMessageCount unread " +
@@ -3147,6 +3306,7 @@ fun ChatScreen(
modelProviders,
sseModelOptions,
selectedModelOverride,
selectedProviderOverride,
gatewayCurrentModel,
fallbackModelDetail,
serverDefaultModelDetail,
@@ -3186,7 +3346,8 @@ fun ChatScreen(
!provider.authenticated -> provider.warning ?: needsSetupLabel
else -> null
},
selected = selectedModelOverride == model,
selected = selectedModelOverride == model &&
selectedProviderOverride.equals(provider.slug, ignoreCase = true),
enabled = !unavailable,
),
)
@@ -3228,22 +3389,37 @@ fun ChatScreen(
)
}
val normalizedEffort = normalizeReasoningEffortForInput(selectedReasoningEffort)
val effortLabels = mapOf(
"none" to stringResource(R.string.chat_reasoning_none),
"minimal" to stringResource(R.string.chat_reasoning_minimal),
"low" to stringResource(R.string.chat_reasoning_low),
"medium" to stringResource(R.string.chat_reasoning_medium),
"high" to stringResource(R.string.chat_reasoning_high),
"xhigh" to stringResource(R.string.chat_reasoning_high),
)
val effortPickerOptions = remember(normalizedEffort) {
CHAT_INPUT_REASONING_EFFORTS.map { effort ->
// Reads the same provider/model resolver used by session.create.
// Collected identity flows above keep this synchronous view reactive.
val effortAvailability = remember(
selectedModelOverride,
selectedProviderOverride,
gatewayCurrentModel,
gatewayCurrentProvider,
modelProviders,
apiModelOptions,
reasoningCapabilityRevision,
) {
chatViewModel.reasoningEffortAvailability()
}
val effortPickerOptions = effortAvailability.choices.map { effort ->
ChatInputPickerOption(
label = reasoningEffortChipLabel(effort, effortLabels),
label = reasoningEffortLabel(effort),
value = effort,
selected = effort == normalizedEffort,
selected = selectedReasoningEffort != null && effort == normalizedEffort,
)
}
}
val effortPickerSubtitle = when {
effortAvailability.exact &&
selectedReasoningEffort != null &&
normalizedEffort !in effortAvailability.choices -> stringResource(
R.string.reasoning_effort_current_outside_supported,
reasoningEffortLabel(normalizedEffort),
effortPickerOptions.joinToString { it.label },
)
!effortAvailability.exact ->
stringResource(R.string.reasoning_effort_standard_levels_notice)
else -> null
}
// Show the effort chip as soon as the gateway IS the transport or is
// still being probed (Unknown) — so it appears alongside the model
@@ -3254,9 +3430,14 @@ fun ChatScreen(
// shows the current effort but disabled. Hidden only when the gateway
// is definitively unreachable (SSE-only) — the agent sheet carries the
// disabled-with-reason version there.
val effortControl = if (chatGatewayAvailability != GatewayAvailability.Unreachable) {
val effortControl = if (
chatGatewayAvailability != GatewayAvailability.Unreachable &&
effortAvailability.supported != false &&
effortPickerOptions.isNotEmpty()
) {
ChatInputPickerControl(
value = reasoningEffortChipLabel(normalizedEffort, effortLabels),
value = selectedReasoningEffort?.let { reasoningEffortLabel(it) }
?: stringResource(R.string.conn_info_server_default),
contentDescription = stringResource(R.string.chat_select_reasoning_effort),
options = effortPickerOptions,
enabled = isGatewayTransport && chatReady && !isStreaming,
@@ -3364,9 +3545,7 @@ fun ChatScreen(
}
},
effortControl = effortControl,
onEffortOptionSelected = { option ->
option.value?.let { chatViewModel.selectReasoningEffort(it) }
},
onEffortPickerClick = { showEffortSheet = true },
topContent = {
ConversationVoiceDock(
uiState = voiceUiState,
@@ -3391,10 +3570,10 @@ fun ChatScreen(
},
topContentVisible = conversationVoiceDockVisible,
suppressVoiceTrailing = conversationVoiceDockVisible,
// Measure the existing composer as a Desktop-style ledge. The
// floating host stands on its top edge; no transcript space is
// reserved and the composer controls remain unobstructed.
modifier = Modifier.petPerchSurface(
// Measure the visible composer Surface as a Desktop-style
// ledge. Registering the outer input column includes its 6dp
// visual margin and makes a correctly grounded pet look raised.
surfaceModifier = Modifier.petPerchSurface(
key = CHAT_PET_WALK_REGION,
routes = CHAT_PET_ROUTES,
),
@@ -3418,6 +3597,20 @@ fun ChatScreen(
onDismiss = { showModelSheet = false },
)
}
if (showEffortSheet) {
OptionPickerSheet(
title = stringResource(R.string.chat_select_reasoning_effort),
subtitle = effortPickerSubtitle,
options = effortPickerOptions.map { option ->
option.copy(enabled = effortControl?.enabled == true)
},
onSelect = { option ->
showEffortSheet = false
option.value?.let(chatViewModel::selectReasoningEffort)
},
onDismiss = { showEffortSheet = false },
)
}
} // end Column
// Mic permission denied banner — title + body + Open Settings action.
@@ -4179,8 +4372,6 @@ private fun createCameraCaptureUri(context: android.content.Context): Uri {
)
}
private val CHAT_INPUT_REASONING_EFFORTS = listOf("none", "minimal", "low", "medium", "high", "xhigh")
internal fun shouldShowCleanViewHint(
hasMessages: Boolean,
ambientMode: Boolean,
@@ -4195,13 +4386,9 @@ private fun compactModelChipLabel(model: String?, defaultLabel: String): String
}
private fun normalizeReasoningEffortForInput(value: String?): String {
val normalized = value?.trim()?.lowercase().orEmpty()
return normalized.takeIf { it in CHAT_INPUT_REASONING_EFFORTS } ?: "medium"
return ReasoningEfforts.normalize(value)
}
private fun reasoningEffortChipLabel(value: String, labels: Map<String, String>): String =
labels[value] ?: labels["medium"] ?: value
private fun isSameDay(ts1: Long, ts2: Long): Boolean {
val d1 = java.time.Instant.ofEpochMilli(ts1).atZone(java.time.ZoneId.systemDefault()).toLocalDate()
val d2 = java.time.Instant.ofEpochMilli(ts2).atZone(java.time.ZoneId.systemDefault()).toLocalDate()
@@ -2833,7 +2833,7 @@ internal fun parseModelOptions(root: JsonObject): List<ModelProviderOption> {
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank) }
.map { it.lowercase() }
.toSet()
return providers.mapNotNull { element ->
val parsed = providers.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val id = obj.stringField("slug")
?: obj.stringField("id")
@@ -2860,10 +2860,25 @@ internal fun parseModelOptions(root: JsonObject): List<ModelProviderOption> {
// Absent hint field: a row with models is assumed usable; an empty
// row can only be an unconfigured skeleton, so grey it.
authenticated = obj.booleanField("authenticated") ?: models.isNotEmpty(),
models = models,
models = models.distinct(),
setupHint = obj.stringField("warning"),
)
}.sortedByDescending { it.authenticated }
}
val merged = linkedMapOf<String, ModelProviderOption>()
parsed.forEach { row ->
val identity = row.id.trim().lowercase()
val existing = merged[identity]
merged[identity] = if (existing == null) {
row.copy(id = row.id.trim())
} else {
existing.copy(
authenticated = existing.authenticated || row.authenticated,
models = (existing.models + row.models).distinct(),
setupHint = existing.setupHint ?: row.setupHint,
)
}
}
return merged.values.sortedByDescending { it.authenticated }
}
@Composable
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.screens
import android.util.Log
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
@@ -46,6 +47,9 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.ui.components.ConnectionSetupTimeline
import com.hermesandroid.relay.ui.components.ConnectionSetupTimelineStep
import com.hermesandroid.relay.network.upstream.DashboardApiClient
@@ -58,6 +62,8 @@ import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
import com.hermesandroid.relay.network.upstream.androidDashboardRedirectAuthMode
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
import com.hermesandroid.relay.network.upstream.nativeDashboardSignInFailureDiagnostic
import com.hermesandroid.relay.network.upstream.nativeDashboardSignInFailureStage
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -66,6 +72,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
private const val NATIVE_DASHBOARD_AUTH_LOG_TAG = "HermesNativeAuth"
/**
* Connection-level Dashboard authentication flow. It is deliberately outside
* Manage so onboarding, connection setup, Voice, Chat, and Manage can all use
@@ -246,8 +254,24 @@ fun DashboardSignInScreen(
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Exception) {
actionMessage = error.message
?: resources.getString(R.string.dashboard_signin_failed)
val failureStage = nativeDashboardSignInFailureStage(error)
val failureDetail = nativeDashboardSignInFailureDiagnostic(error)
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Error,
title = resources.getString(R.string.dashboard_signin_failed),
detail = failureDetail,
operation = "dashboard_native_pkce",
)
Log.w(NATIVE_DASHBOARD_AUTH_LOG_TAG, failureDetail)
actionMessage = nativeDashboardSignInActionMessage(
failureStage = failureStage,
errorMessage = error.message,
fallbackMessage = resources.getString(R.string.dashboard_signin_failed),
transportRetryMessage = resources.getString(
R.string.dashboard_native_signin_transport_retry,
),
)
actionIsError = true
} finally {
actionInFlight = false
@@ -338,6 +362,17 @@ fun DashboardSignInScreen(
}
}
internal fun nativeDashboardSignInActionMessage(
failureStage: String,
errorMessage: String?,
fallbackMessage: String,
transportRetryMessage: String,
): String = if (failureStage.startsWith("token_transport")) {
transportRetryMessage
} else {
errorMessage ?: fallbackMessage
}
@Composable
private fun DashboardAuthenticationComplete(onContinue: () -> Unit) {
Column(
@@ -323,7 +323,7 @@ internal fun buildStatusChecks(
it.category == category && it.severity == DiagnosticSeverity.Error
}
fun DiagnosticLogEntry.message(): String = detail ?: title
fun DiagnosticLogEntry.message(): String = suggestion ?: detail ?: title
val checks = mutableListOf<StatusCheck>()
@@ -4,12 +4,15 @@ package com.hermesandroid.relay.ui.screens
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -17,9 +20,14 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.ConnectionWizard
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
@@ -88,21 +96,47 @@ fun PairScreen(
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
ConnectionWizard(
connectionViewModel = connectionViewModel,
onComplete = {
Toast.makeText(context, context.getString(R.string.pair_connection_updated), Toast.LENGTH_SHORT).show()
onComplete()
},
onCancel = onCancel,
onManageSignIn = onManageSignIn,
showSkip = false,
autoStart = autoStart,
setupReady = setupReady,
onTryDemo = onTryDemo,
)
if (!setupReady) {
// Add connection navigates before its connection-scoped auth
// store is ready. Keep one stable surface on screen instead of
// composing a disabled wizard that is immediately invalidated
// by the placeholder switch's URL/auth/connection emissions.
Column(
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator()
Text(
text = stringResource(R.string.cw_preparing_connection),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.cw_preparing_connection_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
ConnectionWizard(
connectionViewModel = connectionViewModel,
onComplete = {
Toast.makeText(context, context.getString(R.string.pair_connection_updated), Toast.LENGTH_SHORT).show()
onComplete()
},
onCancel = onCancel,
onManageSignIn = onManageSignIn,
showSkip = false,
autoStart = autoStart,
setupReady = true,
onTryDemo = onTryDemo,
)
}
}
}
}
@@ -28,6 +28,9 @@ object DiagnosticIssuePrefill {
private const val MAX_TRACE_FOR_URL = 3000
private const val DEFAULT_WHAT_HAPPENED = "Captured diagnostic from the in-app activity log."
private const val CONFIGURED_URL_MARKER = "[diagnostic-configured-url]"
private const val REQUEST_URL_MARKER = "[diagnostic-request-url]"
private const val LEGACY_URL_MARKER = "[diagnostic-url]"
/** `[Bug]:` for Error entries, `[Diagnostic]:` for Info/Warning. */
fun issueTitle(entry: DiagnosticLogEntry): String = when (entry.severity) {
@@ -53,7 +56,7 @@ object DiagnosticIssuePrefill {
*/
fun connectionMode(entry: DiagnosticLogEntry): String =
entry.endpointRole
?: entry.url?.let { Connection.inferRouteRole(it) }
?: entry.primaryUrl?.let { Connection.inferRouteRole(it) }
?: "unknown"
/**
@@ -77,6 +80,9 @@ object DiagnosticIssuePrefill {
val whatHappened = DiagnosticsLog.redactReportText(expectation)
?.takeIf { it.isNotBlank() }
?: DEFAULT_WHAT_HAPPENED
val configuredUrl = reportSafeUrl(entry.configuredUrl)
val requestUrl = reportSafeUrl(entry.requestUrl)
val legacyUrl = reportSafeUrl(entry.url)
val body = buildString {
appendLine(
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
@@ -100,9 +106,15 @@ object DiagnosticIssuePrefill {
appendLine("- Title: ${entry.title}")
appendLine("- Category: ${entry.category.label}")
appendLine("- Severity: ${entry.severity.name}")
entry.operation?.let { appendLine("- Operation: $it") }
entry.endpointRole?.let { appendLine("- Route: $it") }
entry.url?.let { appendLine("- URL: $it") }
configuredUrl?.let { appendLine("- Configured URL: $CONFIGURED_URL_MARKER") }
requestUrl?.let { appendLine("- Request: $REQUEST_URL_MARKER") }
if (configuredUrl == null && requestUrl == null) {
legacyUrl?.let { appendLine("- URL: $LEGACY_URL_MARKER") }
}
entry.elapsedMs?.let { appendLine("- Elapsed: ${it}ms") }
entry.suggestion?.let { appendLine("- Suggested next step: $it") }
if (trace.isNotBlank()) {
appendLine()
appendLine("```")
@@ -113,5 +125,17 @@ object DiagnosticIssuePrefill {
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
}
return DiagnosticsLog.redactReportText(body).orEmpty()
.replace(CONFIGURED_URL_MARKER, configuredUrl.orEmpty())
.replace(REQUEST_URL_MARKER, requestUrl.orEmpty())
.replace(LEGACY_URL_MARKER, legacyUrl.orEmpty())
}
private fun reportSafeUrl(value: String?): String? {
val sanitized = DiagnosticsLog.sanitizeUrl(value) ?: return null
return if (sanitized.contains("://")) {
sanitized
} else {
DiagnosticsLog.redactReportText(sanitized)
}
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
@@ -226,11 +227,14 @@ fun classifyError(t: Throwable?, context: String? = null, ctx: Context? = null):
// Record after classification so the clean title and the raw trace both
// reach the log. Defensive: never let logging turn a handled error fatal.
runCatching {
val category = categoryForContext(context)
DiagnosticsLog.recordError(
category = categoryForContext(context),
category = category,
title = human.title,
detail = human.body,
throwable = t,
operation = context?.diagnosticOperation(),
suggestion = NetworkDiagnosticGuidance.forThrowable(t, category.label),
reliabilityContext = context,
)
}
@@ -238,6 +242,16 @@ fun classifyError(t: Throwable?, context: String? = null, ctx: Context? = null):
return human
}
private fun String.diagnosticOperation(): String =
trim()
.split('_', '-', ' ')
.filter { it.isNotBlank() }
.joinToString(" ") { word ->
word.replaceFirstChar { character ->
if (character.isLowerCase()) character.titlecase() else character.toString()
}
}
private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?): HumanError {
if (t == null) return nullFallback(context, ctx)
File diff suppressed because it is too large Load Diff
@@ -1485,6 +1485,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
return profileController.renameProfileScopedSession(sessionId, title)
}
suspend fun setProfileScopedSessionPinned(
sessionId: String,
pinned: Boolean,
expectedContextKey: String?,
): Boolean = profileController.setProfileScopedSessionPinned(sessionId, pinned, expectedContextKey)
suspend fun setProfileScopedSessionArchived(
sessionId: String,
archived: Boolean,
expectedContextKey: String?,
): Boolean = profileController.setProfileScopedSessionArchived(sessionId, archived, expectedContextKey)
val selectedProfile: StateFlow<Profile?> get() = profileController.selectedProfile
val profilePresentation: StateFlow<ProfilePresentation> get() = profileController.profilePresentation
@@ -5267,12 +5279,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
return@launch
}
val diagnosticApiUrl = effectiveApiServerUrlSnapshot()
_apiServerHealth.value = HealthStatus.Probing
DiagnosticsLog.record(
category = DiagnosticCategory.Api,
severity = DiagnosticSeverity.Info,
title = ctx.getString(R.string.conn_status_testing_standard),
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API health check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
)
val health = client.checkHealthDetailed()
@@ -5284,7 +5299,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
severity = DiagnosticSeverity.Error,
title = ctx.getString(R.string.conn_status_setup_health_failed),
detail = health.message,
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API health check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
)
onResult(
StandardApiSetupResult(
@@ -5365,7 +5382,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
severity = if (reachable) DiagnosticSeverity.Info else DiagnosticSeverity.Error,
title = if (reachable) ctx.getString(R.string.conn_status_hermes_ok) else ctx.getString(R.string.conn_status_auth_failed),
detail = message,
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API sessions authentication check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/api/sessions",
)
onResult(
StandardApiSetupResult(
@@ -5587,12 +5606,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
return@launch
}
val diagnosticApiUrl = effectiveApiServerUrlSnapshot()
_apiServerHealth.value = HealthStatus.Probing
DiagnosticsLog.record(
category = DiagnosticCategory.Api,
severity = DiagnosticSeverity.Info,
title = ctx.getString(R.string.conn_status_testing_api),
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API health check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
)
val health = client.checkHealthDetailed()
if (health is com.hermesandroid.relay.network.upstream.HealthCheckResult.Unhealthy) {
@@ -5603,7 +5625,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
severity = DiagnosticSeverity.Error,
title = ctx.getString(R.string.conn_status_api_health_failed),
detail = health.message,
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API health check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
)
onResult(false, health.message)
return@launch
@@ -5624,7 +5648,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
severity = if (reachable) DiagnosticSeverity.Info else DiagnosticSeverity.Error,
title = if (reachable) ctx.getString(R.string.conn_status_api_test_ok) else ctx.getString(R.string.conn_status_api_test_failed),
detail = message,
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API sessions authentication check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/api/sessions",
)
onResult(reachable, message)
}
@@ -332,7 +332,7 @@ class ProfileController(
val dashboardUrl = activeDashboardUrlProvider() ?: return null
val profileName = resolveSessionProfileName()
return dashboardClientFactory(connectionId, dashboardUrl)
.listSessions(profile = profileName, limit = limit)
.listSessions(profile = profileName, limit = limit, archived = "include")
}
/**
@@ -364,6 +364,40 @@ class ProfileController(
.isSuccess
}
suspend fun setProfileScopedSessionPinned(
sessionId: String,
pinned: Boolean,
expectedContextKey: String? = null,
): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrlProvider() ?: return false
val profileName = resolveSessionProfileName()
if (
expectedContextKey != null &&
AgentDisplay.profileContextKey(connectionId, profileName) != expectedContextKey
) return false
return dashboardClientFactory(connectionId, dashboardUrl)
.setSessionPinned(sessionId, pinned, profileName)
.isSuccess
}
suspend fun setProfileScopedSessionArchived(
sessionId: String,
archived: Boolean,
expectedContextKey: String? = null,
): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrlProvider() ?: return false
val profileName = resolveSessionProfileName()
if (
expectedContextKey != null &&
AgentDisplay.profileContextKey(connectionId, profileName) != expectedContextKey
) return false
return dashboardClientFactory(connectionId, dashboardUrl)
.setSessionArchived(sessionId, archived, profileName)
.isSuccess
}
fun setProfileDisplayAlias(alias: String?) {
val connectionId = activeConnectionId.value ?: return
val profileName = AgentDisplay.profileRequestName(_selectedProfile.value?.name)
@@ -107,6 +107,8 @@
<string name="chat_placeholder_message">Mensagem…</string>
<string name="chat_edit_busy_snackbar">Não é possível editar agora — aguarde o turno atual terminar</string>
<string name="chat_select_reasoning_effort">Selecionar nível de raciocínio</string>
<string name="reasoning_effort_standard_levels_notice">O Hermes não informa níveis exatos de esforço para este modelo, então as opções padrão são exibidas.</string>
<string name="reasoning_effort_current_outside_supported">Sessão atual: %1$s (informado pelo Hermes). Novas seleções para este modelo estão limitadas a %2$s.</string>
<!-- Voice toasts -->
<string name="voice_toast_signin_route">A voz exige um login único pela rota %1$s — abra Gerenciar</string>
<string name="voice_toast_signin_default">A voz exige login no painel — abra Gerenciar para entrar</string>
@@ -270,6 +272,27 @@
<!-- Connection wizard — Connect page essentials -->
<string name="cw_connect_to_hermes">Conectar ao Hermes</string>
<string name="cw_nearby_description">Vamos procurar o Hermes nesta rede. O Painel e o Gateway fornecem a conexão padrão para Chat, Gerenciar e Voz.</string>
<string name="cw_connection_chooser_description">Escolha a configuração que você tem. O Hermes Relay é opcional.</string>
<string name="cw_scan_setup_qr_title">Ler QR de configuração do Hermes</string>
<string name="cw_scan_setup_qr_subtitle">Configure rotas locais, remotas e do Relay disponíveis</string>
<string name="cw_recommended">Recomendado</string>
<string name="cw_other_ways_to_connect">Outras formas de conectar</string>
<string name="cw_advanced">Avançado</string>
<string name="cw_cloud_title">Hermes hospedado pela Nous</string>
<string name="cw_cloud_subtitle">Conecte ao seu agente hospedado</string>
<string name="cw_server_vps_title">Servidor ou VPS</string>
<string name="cw_server_vps_subtitle">Insira um endereço seguro do Painel</string>
<string name="cw_relay_optional_note">Chat, Gerenciar e Voz padrão funcionam sem o Relay. QRs de configuração compatíveis adicionam recursos avançados do Relay quando disponíveis.</string>
<string name="cw_cloud_entry_title">Conectar ao Hermes hospedado pela Nous</string>
<string name="cw_cloud_entry_description">Insira o endereço do agente mostrado no Nous Portal. Você entrará com segurança depois que o Hermes for encontrado.</string>
<string name="cw_cloud_agent_name">Endereço do agente</string>
<string name="cw_cloud_slug_placeholder">seu-agente</string>
<string name="cw_cloud_slug_hint">Conectaremos a seu-agente.agents.nousresearch.com.</string>
<string name="cw_cloud_slug_error">Insira exatamente as letras, os números e os hifens antes de .agents.nousresearch.com.</string>
<string name="cw_cloud_address_placeholder">https://seu-agente.example.com</string>
<string name="cw_cloud_address_hint">Use o endereço HTTPS completo exibido para seu agente hospedado.</string>
<string name="cw_cloud_use_custom_address">Usar URL personalizada</string>
<string name="cw_cloud_use_nous_address">Usar endereço hospedado pela Nous</string>
<string name="cw_before_connecting">Antes de conectar</string>
<string name="cw_connect_step_server_title">Inicie o Hermes no computador</string>
<string name="cw_connect_step_server_body">Inicie o Painel no computador com Hermes. No primeiro acesso pela rede local, use o guia abaixo para torná-lo acessível pelo celular e ativar o login.</string>
@@ -806,6 +829,9 @@
<string name="drawer_refresh_sessions">Atualizar sessões</string>
<string name="drawer_new_chat">Novo Chat</string>
<string name="drawer_search_placeholder">Pesquisar sessões ou id...</string>
<string name="drawer_search_sessions">Pesquisar sessões</string>
<string name="drawer_activity_working">Em andamento</string>
<string name="drawer_activity_needs_input">Precisa de resposta</string>
<string name="drawer_new_thread">Nova Thread</string>
<string name="drawer_chats_not_named">Os chats não recebem nomes automáticos nesta conexão — use ⋮ → Renomear.</string>
<string name="drawer_loading_sessions">Carregando sessões…</string>
@@ -2162,8 +2188,10 @@
<string name="conn_info_customize_identity">Personalizar identidade</string>
<string name="conn_info_agent_passport">Passaporte do agente</string>
<string name="conn_info_chat_override">Substituição do chat</string>
<string name="conn_info_chat_override_inherited_desc">Use a política de aprovação do perfil para este chat.</string>
<string name="conn_info_fast_tier">Nível rápido</string>
<string name="conn_info_fast">Rápido</string>
<string name="conn_info_normal_tier_desc">Use o nível de processamento padrão.</string>
<string name="conn_info_context">Contexto</string>
<string name="conn_info_profile_already_bypasses">As aprovações já são ignoradas pelo perfil.</string>
<string name="conn_info_start_new_chat">Iniciar novo chat</string>
@@ -2535,6 +2563,8 @@
<string name="qr_scanner_title">Ler QR do Hermes</string>
<string name="qr_scanner_instruction">Leia um QR de configuração do Hermes</string>
<string name="qr_scanner_subtext">Escaneie um QR de configuração do Hermes ou de pareamento do Relay. Conexões padrão pelo Dashboard não exigem uma chave de API.</string>
<string name="qr_scanner_ready">Pronto para ler</string>
<string name="qr_scanner_found">QR de configuração do Hermes encontrado</string>
<string name="qr_scanner_relay_title">Escanear QR do Relay</string>
<string name="qr_scanner_relay_instruction">Escaneie um QR de pareamento do Relay</string>
<string name="qr_scanner_relay_subtext">Abra o QR de pareamento do Relay no seu servidor Hermes. A conexão do Dashboard e as rotas existentes não serão alteradas.</string>
@@ -2729,6 +2759,9 @@
<string name="chat_reasoning_low">Baixo</string>
<string name="chat_reasoning_medium">Médio</string>
<string name="chat_reasoning_high">Alto</string>
<string name="chat_reasoning_xhigh">Extra alto</string>
<string name="chat_reasoning_max">Máximo</string>
<string name="chat_reasoning_ultra">Ultra</string>
<string name="chat_date_today">Hoje</string>
<string name="chat_date_yesterday">Ontem</string>
<string name="chat_share_subject">Conversa do Hermes</string>
@@ -3250,6 +3283,7 @@
<string name="dashboard_native_signin_cancelled">Login pelo navegador cancelado.</string>
<string name="dashboard_native_signin_requires_https">O login seguro pelo navegador exige um endereço HTTPS do painel.</string>
<string name="dashboard_native_signin_unavailable">O login seguro pelo navegador não está disponível para esta conexão. Atualize a conexão e tente novamente.</string>
<string name="dashboard_native_signin_transport_retry">A conexão segura foi interrompida. Toque no botão de login para tentar novamente.</string>
<string name="conn_info_yolo_mode_desc_ephemeral">Ignore as solicitações de aprovação somente neste chat. A opção é redefinida quando a sessão muda.</string>
<string name="conn_info_yolo_mode_profile_off">As aprovações do perfil estão desativadas, então este chat já ignora as solicitações. Escolha Manual ou Inteligente antes de usar a exceção por chat.</string>
<string name="conn_info_approval_mode_title">Modo de aprovação do perfil</string>
@@ -118,6 +118,8 @@
<string name="chat_placeholder_message">输入消息…</string>
<string name="chat_edit_busy_snackbar">暂时无法编辑——请等当前这一轮结束</string>
<string name="chat_select_reasoning_effort">选择推理强度</string>
<string name="reasoning_effort_standard_levels_notice">Hermes 未公布此模型的确切推理强度级别,因此显示标准选项。</string>
<string name="reasoning_effort_current_outside_supported">当前会话:%1$s(由 Hermes 报告)。此模型的新选择仅限于 %2$s。</string>
<!-- 语音提示 -->
<string name="voice_toast_signin_route">语音需要在 %1$s 路由上登录一次——打开管理</string>
@@ -291,6 +293,27 @@
<!-- 连接向导 —— Connect 页核心文案 -->
<string name="cw_connect_to_hermes">连接 Hermes</string>
<string name="cw_nearby_description">我们将在此网络中查找 Hermes。仪表板和网关提供聊天、管理和语音的标准连接。</string>
<string name="cw_connection_chooser_description">请选择你现有的设置方式。Hermes Relay 为可选功能。</string>
<string name="cw_scan_setup_qr_title">扫描 Hermes 设置二维码</string>
<string name="cw_scan_setup_qr_subtitle">配置本地、远程及可用的 Relay 路由</string>
<string name="cw_recommended">推荐</string>
<string name="cw_other_ways_to_connect">其他连接方式</string>
<string name="cw_advanced">高级</string>
<string name="cw_cloud_title">Nous 托管的 Hermes</string>
<string name="cw_cloud_subtitle">连接到你的托管智能体</string>
<string name="cw_server_vps_title">服务器或 VPS</string>
<string name="cw_server_vps_subtitle">输入安全的 Dashboard 地址</string>
<string name="cw_relay_optional_note">标准聊天、管理和语音功能无需 Relay 即可使用。兼容的设置二维码会在可用时添加 Relay 高级功能。</string>
<string name="cw_cloud_entry_title">连接到 Nous 托管的 Hermes</string>
<string name="cw_cloud_entry_description">输入 Nous Portal 中显示的智能体地址。找到 Hermes 后,你将安全登录。</string>
<string name="cw_cloud_agent_name">智能体地址</string>
<string name="cw_cloud_slug_placeholder">your-agent</string>
<string name="cw_cloud_slug_hint">我们将连接到 your-agent.agents.nousresearch.com。</string>
<string name="cw_cloud_slug_error">请准确输入 .agents.nousresearch.com 前的字母、数字和连字符。</string>
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
<string name="cw_cloud_address_hint">请使用为托管智能体显示的完整 HTTPS 地址。</string>
<string name="cw_cloud_use_custom_address">使用自定义网址</string>
<string name="cw_cloud_use_nous_address">使用 Nous 托管地址</string>
<string name="cw_before_connecting">连接之前</string>
<string name="cw_connect_step_server_title">在电脑上启动 Hermes</string>
<string name="cw_connect_step_server_body">在 Hermes 电脑上启动仪表板。首次通过局域网访问时,请使用下方的设置指南,让手机能够访问并启用登录。</string>
@@ -852,6 +875,9 @@
<string name="drawer_refresh_sessions">刷新会话</string>
<string name="drawer_new_chat">新对话</string>
<string name="drawer_search_placeholder">搜索会话或 ID…</string>
<string name="drawer_search_sessions">搜索会话</string>
<string name="drawer_activity_working">正在处理</string>
<string name="drawer_activity_needs_input">需要输入</string>
<string name="drawer_new_thread">新话题</string>
<string name="drawer_chats_not_named">此连接上的对话不会自动命名——使用 ⋮ → 重命名。</string>
<string name="drawer_loading_sessions">正在加载会话…</string>
@@ -2256,8 +2282,10 @@
<string name="conn_info_customize_identity">自定义身份</string>
<string name="conn_info_agent_passport">智能体档案</string>
<string name="conn_info_chat_override">对话覆盖</string>
<string name="conn_info_chat_override_inherited_desc">此聊天使用配置文件的审批策略。</string>
<string name="conn_info_fast_tier">快速层级</string>
<string name="conn_info_fast">快速</string>
<string name="conn_info_normal_tier_desc">使用标准处理层级。</string>
<string name="conn_info_context">上下文</string>
<string name="conn_info_profile_already_bypasses">此配置已绕过审批。</string>
<string name="conn_info_start_new_chat">开始新对话</string>
@@ -2654,6 +2682,8 @@
<string name="qr_scanner_title">扫描 Hermes QR</string>
<string name="qr_scanner_instruction">扫描 Hermes 设置二维码</string>
<string name="qr_scanner_subtext">扫描 Hermes 设置二维码或 Relay 配对二维码。标准 Dashboard 连接不需要 API 密钥。</string>
<string name="qr_scanner_ready">准备扫描</string>
<string name="qr_scanner_found">已找到 Hermes 设置二维码</string>
<string name="qr_scanner_relay_title">扫描 Relay 二维码</string>
<string name="qr_scanner_relay_instruction">扫描 Relay 配对二维码</string>
<string name="qr_scanner_relay_subtext">在 Hermes 服务器上打开 Relay 配对二维码。现有 Dashboard 连接和路由将保持不变。</string>
@@ -2860,6 +2890,9 @@
<string name="chat_reasoning_low">低</string>
<string name="chat_reasoning_medium">中</string>
<string name="chat_reasoning_high">高</string>
<string name="chat_reasoning_xhigh">特高</string>
<string name="chat_reasoning_max">最高</string>
<string name="chat_reasoning_ultra">超高</string>
<string name="chat_date_today">今天</string>
<string name="chat_date_yesterday">昨天</string>
<string name="chat_share_subject">Hermes 对话</string>
@@ -3343,6 +3376,7 @@
<string name="dashboard_native_signin_cancelled">已取消浏览器登录。</string>
<string name="dashboard_native_signin_requires_https">安全浏览器登录需要 HTTPS 控制面板地址。</string>
<string name="dashboard_native_signin_unavailable">此连接无法使用安全浏览器登录。请刷新连接后重试。</string>
<string name="dashboard_native_signin_transport_retry">安全连接已中断。请点按登录按钮重试。</string>
<string name="conn_info_yolo_mode_desc_ephemeral">仅在此聊天中跳过批准提示。会在会话更改时重置。</string>
<string name="conn_info_yolo_mode_profile_off">配置文件批准已关闭,因此此聊天已经会跳过提示。使用单聊天例外前,请选择手动或智能。</string>
<string name="conn_info_approval_mode_title">配置文件批准模式</string>
+34
View File
@@ -118,6 +118,8 @@
<string name="chat_placeholder_message">Nachricht…</string>
<string name="chat_edit_busy_snackbar">Bearbeiten derzeit nicht möglich — warte, bis der aktuelle Durchlauf beendet ist</string>
<string name="chat_select_reasoning_effort">Denkaufwand auswählen</string>
<string name="reasoning_effort_standard_levels_notice">Hermes gibt für dieses Modell keine genauen Aufwandsstufen an. Daher werden Standardoptionen angezeigt.</string>
<string name="reasoning_effort_current_outside_supported">Aktuelle Sitzung: %1$s (von Hermes gemeldet). Neue Auswahlen für dieses Modell sind auf %2$s beschränkt.</string>
<!-- Voice toasts -->
<string name="voice_toast_signin_route">Sprache erfordert eine einmalige Anmeldung über die Route %1$s — öffne Verwalten</string>
@@ -291,6 +293,27 @@
<!-- Connection wizard — Connect page essentials -->
<string name="cw_connect_to_hermes">Mit Hermes verbinden</string>
<string name="cw_nearby_description">Wir suchen in diesem Netzwerk nach Hermes. Dashboard und Gateway stellen die Standardverbindung für Chat, Verwaltung und Sprache bereit.</string>
<string name="cw_connection_chooser_description">Wähle deine vorhandene Einrichtung. Hermes Relay ist optional.</string>
<string name="cw_scan_setup_qr_title">Hermes-Einrichtungs-QR-Code scannen</string>
<string name="cw_scan_setup_qr_subtitle">Lokale, entfernte und verfügbare Relay-Routen konfigurieren</string>
<string name="cw_recommended">Empfohlen</string>
<string name="cw_other_ways_to_connect">Weitere Verbindungsmöglichkeiten</string>
<string name="cw_advanced">Erweitert</string>
<string name="cw_cloud_title">Von Nous gehostetes Hermes</string>
<string name="cw_cloud_subtitle">Mit deinem gehosteten Agenten verbinden</string>
<string name="cw_server_vps_title">Server oder VPS</string>
<string name="cw_server_vps_subtitle">Sichere Dashboard-Adresse eingeben</string>
<string name="cw_relay_optional_note">Standard-Chat, Verwaltung und Sprache funktionieren ohne Relay. Kompatible Einrichtungs-QR-Codes fügen verfügbare Relay-Power-Funktionen hinzu.</string>
<string name="cw_cloud_entry_title">Mit von Nous gehostetem Hermes verbinden</string>
<string name="cw_cloud_entry_description">Gib die im Nous Portal angezeigte Agentenadresse ein. Nach dem Auffinden von Hermes meldest du dich sicher an.</string>
<string name="cw_cloud_agent_name">Agentenadresse</string>
<string name="cw_cloud_slug_placeholder">dein-agent</string>
<string name="cw_cloud_slug_hint">Wir verbinden dich mit dein-agent.agents.nousresearch.com.</string>
<string name="cw_cloud_slug_error">Gib exakt die Buchstaben, Zahlen und Bindestriche vor .agents.nousresearch.com ein.</string>
<string name="cw_cloud_address_placeholder">https://dein-agent.example.com</string>
<string name="cw_cloud_address_hint">Verwende die vollständige HTTPS-Adresse, die für deinen gehosteten Agenten angezeigt wird.</string>
<string name="cw_cloud_use_custom_address">Benutzerdefinierte URL verwenden</string>
<string name="cw_cloud_use_nous_address">Von Nous gehostete Adresse verwenden</string>
<string name="cw_before_connecting">Vor dem Verbinden</string>
<string name="cw_connect_step_server_title">Hermes auf dem Computer starten</string>
<string name="cw_connect_step_server_body">Starte das Dashboard auf dem Hermes-Computer. Verwende für den ersten LAN-Zugriff die Anleitung unten, um es vom Smartphone erreichbar zu machen und die Anmeldung einzurichten.</string>
@@ -855,6 +878,9 @@
<string name="drawer_refresh_sessions">Sitzungen aktualisieren</string>
<string name="drawer_new_chat">Neuer Chat</string>
<string name="drawer_search_placeholder">Sitzungen oder ID suchen...</string>
<string name="drawer_search_sessions">Sitzungen durchsuchen</string>
<string name="drawer_activity_working">Wird bearbeitet</string>
<string name="drawer_activity_needs_input">Eingabe erforderlich</string>
<string name="drawer_new_thread">Neuer Thread</string>
<string name="drawer_chats_not_named">Chats werden bei dieser Verbindung nicht automatisch benannt — verwende ⋮ → Umbenennen.</string>
<string name="drawer_loading_sessions">Sitzungen werden geladen…</string>
@@ -2261,8 +2287,10 @@
<string name="conn_info_customize_identity">Identität anpassen</string>
<string name="conn_info_agent_passport">Agentenpass</string>
<string name="conn_info_chat_override">Chat-Überschreibung</string>
<string name="conn_info_chat_override_inherited_desc">Verwende die Genehmigungsrichtlinie des Profils für diesen Chat.</string>
<string name="conn_info_fast_tier">Schnellstufe</string>
<string name="conn_info_fast">Schnell</string>
<string name="conn_info_normal_tier_desc">Verwende die Standardverarbeitungsstufe.</string>
<string name="conn_info_context">Kontext</string>
<string name="conn_info_profile_already_bypasses">Genehmigungen werden bereits vom Profil umgangen.</string>
<string name="conn_info_start_new_chat">Neuen Chat starten</string>
@@ -2658,6 +2686,8 @@
<string name="qr_scanner_title">Hermes-QR-Code scannen</string>
<string name="qr_scanner_instruction">Hermes-Einrichtungs-QR-Code scannen</string>
<string name="qr_scanner_subtext">Scanne einen Hermes-Einrichtungs- oder Relay-Kopplungs-QR-Code. Standard-Dashboard-Verbindungen benötigen keinen API-Schlüssel.</string>
<string name="qr_scanner_ready">Bereit zum Scannen</string>
<string name="qr_scanner_found">Hermes-Einrichtungs-QR-Code gefunden</string>
<string name="qr_scanner_relay_title">Relay-QR scannen</string>
<string name="qr_scanner_relay_instruction">Einen Relay-Kopplungs-QR-Code scannen</string>
<string name="qr_scanner_relay_subtext">Öffne den Relay-Kopplungs-QR-Code auf deinem Hermes-Server. Deine bestehende Dashboard-Verbindung und Routen bleiben unverändert.</string>
@@ -2864,6 +2894,9 @@
<string name="chat_reasoning_low">Niedrig</string>
<string name="chat_reasoning_medium">Mittel</string>
<string name="chat_reasoning_high">Hoch</string>
<string name="chat_reasoning_xhigh">Extra hoch</string>
<string name="chat_reasoning_max">Maximum</string>
<string name="chat_reasoning_ultra">Ultra</string>
<string name="chat_date_today">Heute</string>
<string name="chat_date_yesterday">Gestern</string>
<string name="chat_share_subject">Hermes-Unterhaltung</string>
@@ -3410,6 +3443,7 @@
<string name="dashboard_native_signin_cancelled">Browser-Anmeldung abgebrochen.</string>
<string name="dashboard_native_signin_requires_https">Die sichere Browser-Anmeldung erfordert eine HTTPS-Dashboard-Adresse.</string>
<string name="dashboard_native_signin_unavailable">Die sichere Browser-Anmeldung ist für diese Verbindung nicht verfügbar. Aktualisiere die Verbindung und versuche es erneut.</string>
<string name="dashboard_native_signin_transport_retry">Die sichere Verbindung wurde unterbrochen. Tippe auf die Anmeldeschaltfläche, um es erneut zu versuchen.</string>
<string name="conn_info_yolo_mode_desc_ephemeral">Umgehe Bestätigungsabfragen nur für diesen Chat. Wird beim Sitzungswechsel zurückgesetzt.</string>
<string name="conn_info_yolo_mode_profile_off">Profilbestätigungen sind deaktiviert, daher umgeht dieser Chat bereits Abfragen. Wähle Manuell oder Smart, bevor du die chatbezogene Ausnahme verwendest.</string>
<string name="conn_info_approval_mode_title">Profil-Bestätigungsmodus</string>
+34
View File
@@ -101,6 +101,8 @@
<string name="chat_placeholder_message">Mensaje…</string>
<string name="chat_edit_busy_snackbar">No se puede editar en este momento: espera a que termine el turno actual</string>
<string name="chat_select_reasoning_effort">Seleccionar esfuerzo de razonamiento</string>
<string name="reasoning_effort_standard_levels_notice">Hermes no anuncia niveles de esfuerzo exactos para este modelo, por lo que se muestran las opciones estándar.</string>
<string name="reasoning_effort_current_outside_supported">Sesión actual: %1$s (informado por Hermes). Las nuevas selecciones para este modelo se limitan a %2$s.</string>
<string name="voice_toast_signin_route">Voice necesita un inicio de sesión único en la ruta %1$s: abra Administrar</string>
<string name="voice_toast_signin_default">Voice necesita iniciar sesión en el panel: abra Administrar para iniciar sesión</string>
<string name="voice_toast_unsupported">Esta compilación Hermes no tiene rutas de voz: actualice el agente hermes o empareje Relay</string>
@@ -253,6 +255,27 @@
<string name="error_classify_open_settings">Abrir configuración</string>
<string name="cw_connect_to_hermes">Conéctese a Hermes</string>
<string name="cw_nearby_description">Buscaremos Hermes en esta red. El panel y la puerta de enlace proporcionan la conexión estándar para Chat, Administrar y Voz.</string>
<string name="cw_connection_chooser_description">Elige la configuración que tengas. Hermes Relay es opcional.</string>
<string name="cw_scan_setup_qr_title">Escanear QR de configuración de Hermes</string>
<string name="cw_scan_setup_qr_subtitle">Configura rutas locales, remotas y de Relay disponibles</string>
<string name="cw_recommended">Recomendado</string>
<string name="cw_other_ways_to_connect">Otras formas de conectarse</string>
<string name="cw_advanced">Avanzado</string>
<string name="cw_cloud_title">Hermes alojado por Nous</string>
<string name="cw_cloud_subtitle">Conéctate a tu agente alojado</string>
<string name="cw_server_vps_title">Servidor o VPS</string>
<string name="cw_server_vps_subtitle">Introduce una dirección segura del panel</string>
<string name="cw_relay_optional_note">Chat, Administrar y Voz estándar funcionan sin Relay. Los QR de configuración compatibles añaden funciones avanzadas de Relay cuando están disponibles.</string>
<string name="cw_cloud_entry_title">Conectarse a Hermes alojado por Nous</string>
<string name="cw_cloud_entry_description">Introduce la dirección del agente que aparece en Nous Portal. Iniciarás sesión de forma segura después de encontrar Hermes.</string>
<string name="cw_cloud_agent_name">Dirección del agente</string>
<string name="cw_cloud_slug_placeholder">tu-agente</string>
<string name="cw_cloud_slug_hint">Nos conectaremos a tu-agente.agents.nousresearch.com.</string>
<string name="cw_cloud_slug_error">Introduce exactamente las letras, números y guiones anteriores a .agents.nousresearch.com.</string>
<string name="cw_cloud_address_placeholder">https://tu-agente.example.com</string>
<string name="cw_cloud_address_hint">Usa la dirección HTTPS completa que aparece para tu agente alojado.</string>
<string name="cw_cloud_use_custom_address">Usar URL personalizada</string>
<string name="cw_cloud_use_nous_address">Usar dirección alojada por Nous</string>
<string name="cw_before_connecting">Antes de conectar</string>
<string name="cw_connect_step_server_title">Inicia Hermes en tu ordenador</string>
<string name="cw_connect_step_server_body">Inicia el panel en el ordenador con Hermes. Para el primer acceso por LAN, usa la guía inferior para hacerlo accesible desde el teléfono y habilitar el inicio de sesión.</string>
@@ -770,6 +793,9 @@
<string name="drawer_refresh_sessions">Actualizar sesiones</string>
<string name="drawer_new_chat">Nuevo chat</string>
<string name="drawer_search_placeholder">Buscar sesiones o id...</string>
<string name="drawer_search_sessions">Buscar sesiones</string>
<string name="drawer_activity_working">En curso</string>
<string name="drawer_activity_needs_input">Requiere intervención</string>
<string name="drawer_new_thread">Nuevo hilo</string>
<string name="drawer_chats_not_named">Los chats no tienen nombre automático en esta conexión. Utiliza «→Renombrar».</string>
<string name="drawer_loading_sessions">Cargando sesiones…</string>
@@ -2066,8 +2092,10 @@
<string name="conn_info_customize_identity">Personalizar identidad</string>
<string name="conn_info_agent_passport">Pasaporte del agente</string>
<string name="conn_info_chat_override">Anulación del chat</string>
<string name="conn_info_chat_override_inherited_desc">Usa la política de aprobación del perfil para este chat.</string>
<string name="conn_info_fast_tier">Nivel rápido</string>
<string name="conn_info_fast">Rápido</string>
<string name="conn_info_normal_tier_desc">Usa el nivel de procesamiento estándar.</string>
<string name="conn_info_context">Contexto</string>
<string name="conn_info_profile_already_bypasses">El perfil ya omite las aprobaciones.</string>
<string name="conn_info_start_new_chat">Iniciar nuevo chat</string>
@@ -2409,6 +2437,8 @@
<string name="qr_scanner_title">Escanear Hermes QR</string>
<string name="qr_scanner_instruction">Escanear una configuración Hermes QR</string>
<string name="qr_scanner_subtext">Escanee un QR de configuración de Hermes o de vinculación con Relay. Las conexiones estándar mediante Dashboard no necesitan una clave API.</string>
<string name="qr_scanner_ready">Listo para escanear</string>
<string name="qr_scanner_found">QR de configuración de Hermes encontrado</string>
<string name="qr_scanner_relay_title">Escanear QR de Relay</string>
<string name="qr_scanner_relay_instruction">Escanee un QR de vinculación con Relay</string>
<string name="qr_scanner_relay_subtext">Abra el QR de vinculación con Relay en su servidor Hermes. La conexión de Dashboard y las rutas existentes no cambiarán.</string>
@@ -2591,6 +2621,9 @@
<string name="chat_reasoning_low">Bajo</string>
<string name="chat_reasoning_medium">Medio</string>
<string name="chat_reasoning_high">Alto</string>
<string name="chat_reasoning_xhigh">Muy alto</string>
<string name="chat_reasoning_max">Máximo</string>
<string name="chat_reasoning_ultra">Ultra</string>
<string name="chat_date_today">Hoy</string>
<string name="chat_date_yesterday">Ayer</string>
<string name="chat_share_subject">Conversación Hermes</string>
@@ -3095,6 +3128,7 @@
<string name="dashboard_native_signin_cancelled">Inicio de sesión en el navegador cancelado.</string>
<string name="dashboard_native_signin_requires_https">El inicio de sesión seguro en el navegador requiere una dirección HTTPS del panel.</string>
<string name="dashboard_native_signin_unavailable">El inicio de sesión seguro en el navegador no está disponible para esta conexión. Actualiza la conexión e inténtalo de nuevo.</string>
<string name="dashboard_native_signin_transport_retry">Se interrumpió la conexión segura. Toca el botón de inicio de sesión para volver a intentarlo.</string>
<string name="conn_info_yolo_mode_desc_ephemeral">Omite las solicitudes de aprobación solo para este chat. Se restablece al cambiar de sesión.</string>
<string name="conn_info_yolo_mode_profile_off">Las aprobaciones del perfil están desactivadas, por lo que este chat ya omite las solicitudes. Elige Manual o Inteligente antes de usar la excepción por chat.</string>
<string name="conn_info_approval_mode_title">Modo de aprobación del perfil</string>
+34
View File
@@ -118,6 +118,8 @@
<string name="chat_placeholder_message">メッセージ…</string>
<string name="chat_edit_busy_snackbar">現在編集できません - 現在のターンが終了するまで待ちます</string>
<string name="chat_select_reasoning_effort">推論努力を選択する</string>
<string name="reasoning_effort_standard_levels_notice">Hermes はこのモデルの正確な推論レベルを公開していないため、標準オプションを表示しています。</string>
<string name="reasoning_effort_current_outside_supported">現在のセッション:%1$s(Hermes から報告)。このモデルで新たに選択できるのは %2$s です。</string>
<!-- Voice toasts -->
<string name="voice_toast_signin_route">Voice には %1$s ルートでの 1 回限りのサインインが必要です — [管理] を開きます</string>
@@ -291,6 +293,27 @@
<!-- Connection wizard — Connect page essentials -->
<string name="cw_connect_to_hermes">Hermes に接続します</string>
<string name="cw_nearby_description">このネットワーク上の Hermes を検索します。ダッシュボードとゲートウェイが、チャット、管理、音声の標準接続を提供します。</string>
<string name="cw_connection_chooser_description">利用するセットアップを選択してください。Hermes Relay は任意です。</string>
<string name="cw_scan_setup_qr_title">Hermes セットアップ QR をスキャン</string>
<string name="cw_scan_setup_qr_subtitle">ローカル、リモート、および利用可能な Relay ルートを設定します</string>
<string name="cw_recommended">推奨</string>
<string name="cw_other_ways_to_connect">その他の接続方法</string>
<string name="cw_advanced">詳細設定</string>
<string name="cw_cloud_title">Nous ホスト版 Hermes</string>
<string name="cw_cloud_subtitle">ホスト済みエージェントに接続します</string>
<string name="cw_server_vps_title">サーバーまたは VPS</string>
<string name="cw_server_vps_subtitle">安全な Dashboard アドレスを入力します</string>
<string name="cw_relay_optional_note">標準のチャット、管理、音声は Relay なしで動作します。互換性のあるセットアップ QR では、利用可能な場合に Relay の拡張機能も追加されます。</string>
<string name="cw_cloud_entry_title">Nous ホスト版 Hermes に接続</string>
<string name="cw_cloud_entry_description">Nous Portal に表示されるエージェントのアドレスを入力してください。Hermes が見つかった後、安全にサインインします。</string>
<string name="cw_cloud_agent_name">エージェントのアドレス</string>
<string name="cw_cloud_slug_placeholder">your-agent</string>
<string name="cw_cloud_slug_hint">your-agent.agents.nousresearch.com に接続します。</string>
<string name="cw_cloud_slug_error">.agents.nousresearch.com の前にある英字、数字、ハイフンを正確に入力してください。</string>
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
<string name="cw_cloud_address_hint">ホスト済みエージェントに表示される完全な HTTPS アドレスを使用してください。</string>
<string name="cw_cloud_use_custom_address">カスタム URL を使用</string>
<string name="cw_cloud_use_nous_address">Nous ホスト版アドレスを使用</string>
<string name="cw_before_connecting">接続する前に</string>
<string name="cw_connect_step_server_title">パソコンで Hermes を起動</string>
<string name="cw_connect_step_server_body">Hermes のパソコンでダッシュボードを起動します。初めて LAN から接続する場合は、下のセットアップガイドでスマートフォンからアクセス可能にし、サインインを有効にしてください。</string>
@@ -868,6 +891,9 @@
<string name="drawer_refresh_sessions">セッションを更新する</string>
<string name="drawer_new_chat">新しいチャット</string>
<string name="drawer_search_placeholder">セッションまたは ID を検索...</string>
<string name="drawer_search_sessions">セッションを検索</string>
<string name="drawer_activity_working">処理中</string>
<string name="drawer_activity_needs_input">入力が必要</string>
<string name="drawer_new_thread">新しいスレッド</string>
<string name="drawer_chats_not_named">この接続ではチャットの名前は自動的に付けられません。「⋮」→「名前の変更」を使用してください。</string>
<string name="drawer_loading_sessions">セッションを読み込み中…</string>
@@ -2272,8 +2298,10 @@
<string name="conn_info_customize_identity">アイデンティティをカスタマイズ</string>
<string name="conn_info_agent_passport">エージェントパスポート</string>
<string name="conn_info_chat_override">チャットの上書き</string>
<string name="conn_info_chat_override_inherited_desc">このチャットではプロフィールの承認ポリシーを使用します。</string>
<string name="conn_info_fast_tier">高速ティア</string>
<string name="conn_info_fast">高速</string>
<string name="conn_info_normal_tier_desc">標準の処理ティアを使用します。</string>
<string name="conn_info_context">コンテキスト</string>
<string name="conn_info_profile_already_bypasses">承認はプロファイルですでにバイパスされています。</string>
<string name="conn_info_start_new_chat">新しいチャットを開始</string>
@@ -2668,6 +2696,8 @@
<string name="qr_scanner_title">Hermes QR をスキャン</string>
<string name="qr_scanner_instruction">Hermes セットアップ QR をスキャンします</string>
<string name="qr_scanner_subtext">Hermes セットアップ QR または Relay ペアリング QR をスキャンします。標準の Dashboard 接続に API キーは必要ありません。</string>
<string name="qr_scanner_ready">スキャン準備完了</string>
<string name="qr_scanner_found">Hermes セットアップ QR が見つかりました</string>
<string name="qr_scanner_relay_title">Relay QR をスキャン</string>
<string name="qr_scanner_relay_instruction">Relay ペアリング QR をスキャンします</string>
<string name="qr_scanner_relay_subtext">Hermes サーバーで Relay ペアリング QR を開きます。既存の Dashboard 接続とルートは変更されません。</string>
@@ -2874,6 +2904,9 @@
<string name="chat_reasoning_low">低い</string>
<string name="chat_reasoning_medium">中くらい</string>
<string name="chat_reasoning_high">高い</string>
<string name="chat_reasoning_xhigh">非常に高い</string>
<string name="chat_reasoning_max">最大</string>
<string name="chat_reasoning_ultra">ウルトラ</string>
<string name="chat_date_today">今日</string>
<string name="chat_date_yesterday">昨日</string>
<string name="chat_share_subject">Hermes の会話</string>
@@ -3409,6 +3442,7 @@
<string name="dashboard_native_signin_cancelled">ブラウザでのサインインをキャンセルしました。</string>
<string name="dashboard_native_signin_requires_https">安全なブラウザサインインには、HTTPSのダッシュボードアドレスが必要です。</string>
<string name="dashboard_native_signin_unavailable">この接続では安全なブラウザサインインを利用できません。接続を更新して、もう一度お試しください。</string>
<string name="dashboard_native_signin_transport_retry">安全な接続が中断されました。サインインボタンをタップして、もう一度お試しください。</string>
<string name="conn_info_yolo_mode_desc_ephemeral">このチャットでのみ承認確認を省略します。セッションが変わるとリセットされます。</string>
<string name="conn_info_yolo_mode_profile_off">プロフィールの承認がオフのため、このチャットではすでに確認を省略しています。チャット単位の例外を使う前に、手動またはスマートを選択してください。</string>
<string name="conn_info_approval_mode_title">プロフィール承認モード</string>
+34
View File
@@ -118,6 +118,8 @@
<string name="chat_placeholder_message">Сообщение…</string>
<string name="chat_edit_busy_snackbar">Нельзя редактировать сейчас — подождите, пока завершится текущий ход</string>
<string name="chat_select_reasoning_effort">Выберите усилия по рассуждению</string>
<string name="reasoning_effort_standard_levels_notice">Hermes не сообщает точные уровни усилий для этой модели, поэтому показаны стандартные варианты.</string>
<string name="reasoning_effort_current_outside_supported">Текущий сеанс: %1$s (по данным Hermes). Для этой модели новые варианты ограничены: %2$s.</string>
<string name="voice_toast_signin_route">Голосу требуется одноразовый вход на маршруте %1$s — откройте Управление</string>
<string name="voice_toast_signin_default">Голосу требуется вход в панель управления — откройте Управление для входа</string>
<string name="voice_toast_unsupported">Эта сборка Гермеса не имеет голосовых маршрутов — обновите Агента Гермеса или сопрягите плагин Relay</string>
@@ -274,6 +276,27 @@
<string name="error_classify_open_settings">Открыть настройки</string>
<string name="cw_connect_to_hermes">Подключение к Гермесу</string>
<string name="cw_nearby_description">Мы будем искать Гермеса в этой сети. Панель управления и шлюз обеспечивают стандартное подключение для чата, управления и голоса.</string>
<string name="cw_connection_chooser_description">Выберите доступный способ настройки. Hermes Relay необязателен.</string>
<string name="cw_scan_setup_qr_title">Сканировать QR-код настройки Hermes</string>
<string name="cw_scan_setup_qr_subtitle">Настройте локальные, удалённые и доступные маршруты Relay</string>
<string name="cw_recommended">Рекомендуется</string>
<string name="cw_other_ways_to_connect">Другие способы подключения</string>
<string name="cw_advanced">Расширенные настройки</string>
<string name="cw_cloud_title">Hermes на хостинге Nous</string>
<string name="cw_cloud_subtitle">Подключитесь к своему размещённому агенту</string>
<string name="cw_server_vps_title">Сервер или VPS</string>
<string name="cw_server_vps_subtitle">Введите защищённый адрес Dashboard</string>
<string name="cw_relay_optional_note">Стандартные чат, управление и голос работают без Relay. Совместимые QR-коды настройки добавляют расширенные функции Relay, когда они доступны.</string>
<string name="cw_cloud_entry_title">Подключиться к Hermes на хостинге Nous</string>
<string name="cw_cloud_entry_description">Введите адрес агента, указанный в Nous Portal. После обнаружения Hermes вы безопасно войдёте в систему.</string>
<string name="cw_cloud_agent_name">Адрес агента</string>
<string name="cw_cloud_slug_placeholder">ваш-агент</string>
<string name="cw_cloud_slug_hint">Будет выполнено подключение к ваш-агент.agents.nousresearch.com.</string>
<string name="cw_cloud_slug_error">Точно введите буквы, цифры и дефисы перед .agents.nousresearch.com.</string>
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
<string name="cw_cloud_address_hint">Используйте полный HTTPS-адрес, указанный для вашего размещённого агента.</string>
<string name="cw_cloud_use_custom_address">Использовать собственный URL</string>
<string name="cw_cloud_use_nous_address">Использовать адрес хостинга Nous</string>
<string name="cw_before_connecting">Перед подключением</string>
<string name="cw_connect_step_server_title">Запустите Гермеса на вашем компьютере</string>
<string name="cw_connect_step_server_body">Запустите панель управления на компьютере с Гермесом. Для первого доступа к локальной сети используйте руководство по настройке ниже, чтобы сделать его доступным с телефона и включить вход.</string>
@@ -878,6 +901,9 @@
<string name="drawer_refresh_sessions">Обновить сессии</string>
<string name="drawer_new_chat">Новый чат</string>
<string name="drawer_search_placeholder">Поиск сессий или id...</string>
<string name="drawer_search_sessions">Поиск сессий</string>
<string name="drawer_activity_working">Выполняется</string>
<string name="drawer_activity_needs_input">Требуется ввод</string>
<string name="drawer_new_thread">Новая ветка</string>
<string name="drawer_chats_not_named">Чаты не автоматически именуются на этом соединении — используйте ⋮ → Переименовать.</string>
<string name="drawer_loading_sessions">Загрузка сессий…</string>
@@ -1792,6 +1818,7 @@
<string name="dashboard_native_signin_cancelled">Вход в браузере отменен.</string>
<string name="dashboard_native_signin_requires_https">Безопасный вход в браузере требует HTTPS-адреса панели управления.</string>
<string name="dashboard_native_signin_unavailable">Безопасный вход в браузере недоступен для этого подключения. Обновите подключение и попробуйте снова.</string>
<string name="dashboard_native_signin_transport_retry">Защищённое соединение было прервано. Нажмите кнопку входа, чтобы повторить попытку.</string>
<string name="dashboard_close_signin">Закрыть вход</string>
<string name="dashboard_error_title">Панель управления недоступна</string>
<string name="dashboard_error_target">Не удалось загрузить из %1$s</string>
@@ -2250,8 +2277,10 @@
<string name="conn_info_customize_identity">Настроить идентичность</string>
<string name="conn_info_agent_passport">Паспорт агента</string>
<string name="conn_info_chat_override">Переопределение чата</string>
<string name="conn_info_chat_override_inherited_desc">Использовать политику подтверждений профиля для этого чата.</string>
<string name="conn_info_fast_tier">Быстрый уровень</string>
<string name="conn_info_fast">Быстрый</string>
<string name="conn_info_normal_tier_desc">Использовать стандартный уровень обработки.</string>
<string name="conn_info_context">Контекст</string>
<string name="conn_info_profile_already_bypasses">Утверждения уже пропущены профилем.</string>
<string name="conn_info_start_new_chat">Начать новый чат</string>
@@ -2616,6 +2645,8 @@
<string name="qr_scanner_title">Сканировать QR-код Гермеса</string>
<string name="qr_scanner_instruction">Сканируйте QR-код настройки Гермеса</string>
<string name="qr_scanner_subtext">Сканируйте QR-код настройки Гермеса или пары Relay. Стандартные подключения к панели управления не требуют API-ключа.</string>
<string name="qr_scanner_ready">Готово к сканированию</string>
<string name="qr_scanner_found">QR-код настройки Hermes найден</string>
<string name="qr_scanner_relay_title">Сканируйте QR-код Relay</string>
<string name="qr_scanner_relay_instruction">Сканируйте QR-код пары Relay</string>
<string name="qr_scanner_relay_subtext">Откройте QR-код пары Relay на вашем сервере Гермеса. Ваши существующие подключения к панели управления и маршруты останутся без изменений.</string>
@@ -2799,6 +2830,9 @@
<string name="chat_reasoning_low">Низкий</string>
<string name="chat_reasoning_medium">Средний</string>
<string name="chat_reasoning_high">Высокий</string>
<string name="chat_reasoning_xhigh">Очень высокий</string>
<string name="chat_reasoning_max">Максимальный</string>
<string name="chat_reasoning_ultra">Ультра</string>
<string name="chat_date_today">Сегодня</string>
<string name="chat_date_yesterday">Вчера</string>
<string name="chat_share_subject">Разговор с Гермес</string>
+34
View File
@@ -118,6 +118,8 @@
<string name="chat_placeholder_message">Message…</string>
<string name="chat_edit_busy_snackbar">Can\'t edit right now — wait for the current turn to finish</string>
<string name="chat_select_reasoning_effort">Select reasoning effort</string>
<string name="reasoning_effort_standard_levels_notice">Hermes doesn\'t advertise exact effort levels for this model, so standard options are shown.</string>
<string name="reasoning_effort_current_outside_supported">Current session: %1$s (reported by Hermes). New selections for this model are limited to %2$s.</string>
<!-- Voice toasts -->
<string name="voice_toast_signin_route">Voice needs a one-time sign-in on the %1$s route — open Manage</string>
@@ -293,6 +295,27 @@
<!-- Connection wizard — Connect page essentials -->
<string name="cw_connect_to_hermes">Connect to Hermes</string>
<string name="cw_nearby_description">We’ll look for Hermes on this network. Dashboard and Gateway provide the standard Chat, Manage, and Voice connection.</string>
<string name="cw_connection_chooser_description">Choose the setup you have. Hermes Relay is optional.</string>
<string name="cw_scan_setup_qr_title">Scan Hermes setup QR</string>
<string name="cw_scan_setup_qr_subtitle">Configure local, remote, and available Relay routes</string>
<string name="cw_recommended">Recommended</string>
<string name="cw_other_ways_to_connect">Other ways to connect</string>
<string name="cw_advanced">Advanced</string>
<string name="cw_cloud_title">Nous-hosted Hermes</string>
<string name="cw_cloud_subtitle">Connect to your hosted agent</string>
<string name="cw_server_vps_title">Server or VPS</string>
<string name="cw_server_vps_subtitle">Enter a secure Dashboard address</string>
<string name="cw_relay_optional_note">Standard Chat, Manage, and Voice work without Relay. Compatible setup QRs add Relay power features when available.</string>
<string name="cw_cloud_entry_title">Connect to Nous-hosted Hermes</string>
<string name="cw_cloud_entry_description">Enter the agent address shown in Nous Portal. You’ll sign in securely after Hermes is found.</string>
<string name="cw_cloud_agent_name">Agent address</string>
<string name="cw_cloud_slug_placeholder">your-agent</string>
<string name="cw_cloud_slug_hint">We’ll connect to your-agent.agents.nousresearch.com.</string>
<string name="cw_cloud_slug_error">Enter the exact letters, numbers, and hyphens before .agents.nousresearch.com.</string>
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
<string name="cw_cloud_address_hint">Use the complete HTTPS address shown for your hosted agent.</string>
<string name="cw_cloud_use_custom_address">Use custom URL</string>
<string name="cw_cloud_use_nous_address">Use Nous-hosted address</string>
<string name="cw_before_connecting">Before you connect</string>
<string name="cw_connect_step_server_title">Start Hermes on your computer</string>
<string name="cw_connect_step_server_body">Start the Dashboard on the Hermes computer. For first-time LAN access, use Setup Guide below to make it phone-reachable and enable sign-in.</string>
@@ -951,6 +974,9 @@
<string name="drawer_refresh_sessions">Refresh sessions</string>
<string name="drawer_new_chat">New Chat</string>
<string name="drawer_search_placeholder">Search sessions or id...</string>
<string name="drawer_search_sessions">Search sessions</string>
<string name="drawer_activity_working">Working</string>
<string name="drawer_activity_needs_input">Needs input</string>
<string name="drawer_new_thread">New Thread</string>
<string name="drawer_chats_not_named">Chats aren\'t auto-named on this connection — use ⋮ → Rename.</string>
<string name="drawer_loading_sessions">Loading sessions…</string>
@@ -1879,6 +1905,7 @@
<string name="dashboard_native_signin_cancelled">Browser sign-in cancelled.</string>
<string name="dashboard_native_signin_requires_https">Secure browser sign-in requires an HTTPS dashboard address.</string>
<string name="dashboard_native_signin_unavailable">Secure browser sign-in is unavailable for this connection. Refresh the connection and try again.</string>
<string name="dashboard_native_signin_transport_retry">The secure connection was interrupted. Tap the sign-in button to try again.</string>
<string name="dashboard_close_signin">Close sign-in</string>
<!-- Error body -->
@@ -2381,6 +2408,7 @@
<string name="conn_info_customize_identity">Customize identity</string>
<string name="conn_info_agent_passport">Agent Passport</string>
<string name="conn_info_chat_override">Chat override</string>
<string name="conn_info_chat_override_inherited_desc">Use the profile approval policy for this chat.</string>
<string name="conn_info_fast_tier">Fast tier</string>
<string name="conn_info_fast">Fast</string>
<string name="conn_info_context">Context</string>
@@ -2401,6 +2429,7 @@
<string name="conn_info_expires">Expires</string>
<string name="conn_info_failed_reason">Failed: %1$s</string>
<string name="conn_info_fast_mode_desc">Priority processing tier (model-gated).</string>
<string name="conn_info_normal_tier_desc">Use the standard processing tier.</string>
<string name="conn_info_fast_mode_title">Fast mode</string>
<string name="conn_info_gateway_idle">Gateway idle</string>
<string name="conn_info_gateway_running">Gateway running</string>
@@ -2790,6 +2819,8 @@
<string name="qr_scanner_title">Scan Hermes QR</string>
<string name="qr_scanner_instruction">Scan a Hermes setup QR</string>
<string name="qr_scanner_subtext">Scan a Hermes setup or Relay pairing QR. Standard Dashboard connections do not require an API key.</string>
<string name="qr_scanner_ready">Ready to scan</string>
<string name="qr_scanner_found">Hermes setup QR found</string>
<string name="qr_scanner_relay_title">Scan Relay QR</string>
<string name="qr_scanner_relay_instruction">Scan a Relay pairing QR</string>
<string name="qr_scanner_relay_subtext">Open the Relay pairing QR on your Hermes server. Your existing Dashboard connection and routes will stay unchanged.</string>
@@ -2996,6 +3027,9 @@
<string name="chat_reasoning_low">Low</string>
<string name="chat_reasoning_medium">Medium</string>
<string name="chat_reasoning_high">High</string>
<string name="chat_reasoning_xhigh">Extra high</string>
<string name="chat_reasoning_max">Max</string>
<string name="chat_reasoning_ultra">Ultra</string>
<string name="chat_date_today">Today</string>
<string name="chat_date_yesterday">Yesterday</string>
<string name="chat_share_subject">Hermes conversation</string>
@@ -160,6 +160,14 @@ class ChatTurnCheckpointStoreTest {
cardKey = "approval-1",
receivedAt = 1_004L,
),
queuedMessages = listOf(
ChatQueuedMessageCheckpoint(
id = "queue-1",
text = "follow up in this session",
transport = "gateway",
ownerRunId = "user-1",
),
),
startedAt = 1_001L,
updatedAt = now,
)
@@ -239,6 +239,56 @@ class ConnectionDashboardFieldsTest {
)
}
@Test
fun cloudDashboardDefaultLabel_usesOfficialAgentSlug() {
assertEquals(
"agent-1",
Connection.extractDefaultLabel(
dashboardUrl = "https://agent-1.agents.nousresearch.com/chat",
apiServerUrl = "",
relayUrl = "",
),
)
}
@Test
fun cloudDashboardDefaultLabel_requiresExactSingleLabelOfficialOrigin() {
assertEquals(
"agent-1.agents.nousresearch.com.example.test",
Connection.extractDefaultLabel(
dashboardUrl = "https://agent-1.agents.nousresearch.com.example.test",
apiServerUrl = "",
relayUrl = "",
),
)
assertEquals(
"nested.agent-1.agents.nousresearch.com",
Connection.extractDefaultLabel(
dashboardUrl = "https://nested.agent-1.agents.nousresearch.com",
apiServerUrl = "",
relayUrl = "",
),
)
}
@Test
fun cloudDashboardDefaultLabel_doesNotReplaceUserEditedName() {
val automatic = Connection.extractDefaultLabel(
dashboardUrl = "https://agent-1.agents.nousresearch.com",
apiServerUrl = "",
relayUrl = "",
)
assertEquals(
"My research agent",
Connection.chooseDiscoveredLabel(
currentLabel = "My research agent",
primaryHost = automatic,
discoveredHostname = null,
),
)
}
private fun sampleConnection(
dashboardUrl: String? = null,
): Connection = Connection(
@@ -0,0 +1,62 @@
package com.hermesandroid.relay.diagnostics
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLHandshakeException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NetworkDiagnosticGuidanceTest {
@Test
fun connectionRefusalPointsToTheOwningListener() {
val guidance = NetworkDiagnosticGuidance.forThrowable(
ConnectException("Failed to connect"),
target = "Relay",
)
assertEquals(
"Verify Relay is running and listening on the configured host and port.",
guidance,
)
}
@Test
fun wrappedNetworkFailuresUseTheRootCause() {
val guidance = NetworkDiagnosticGuidance.forThrowable(
IllegalStateException("probe failed", UnknownHostException("missing.example")),
target = "Dashboard",
)
assertTrue(guidance.orEmpty().contains("hostname"))
}
@Test
fun transportFailuresHaveDistinctNextSteps() {
assertTrue(
NetworkDiagnosticGuidance.forThrowable(SocketTimeoutException(), "API")
.orEmpty().contains("routing or firewall"),
)
assertTrue(
NetworkDiagnosticGuidance.forThrowable(NoRouteToHostException(), "API")
.orEmpty().contains("network path"),
)
assertTrue(
NetworkDiagnosticGuidance.forThrowable(SSLHandshakeException("bad cert"), "Relay")
.orEmpty().contains("TLS"),
)
}
@Test
fun httpStatusGuidanceIsSpecificAndUnknownSuccessHasNone() {
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(401, "API").orEmpty().contains("credentials"))
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(404, "Relay").orEmpty().contains("route"))
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(429, "Relay").orEmpty().contains("backoff"))
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(503, "API").orEmpty().contains("server logs"))
assertNull(NetworkDiagnosticGuidance.forHttpStatus(204, "API"))
}
}
@@ -0,0 +1,49 @@
package com.hermesandroid.relay.network.relay
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import java.net.ServerSocket
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class RelayHttpClientDiagnosticsTest {
@Before
fun setUp() {
DiagnosticsLog.clear()
}
@After
fun tearDown() {
DiagnosticsLog.clear()
}
@Test
fun refusedHealthProbeExplainsProtocolConversionAndOwningListener() = runTest {
val unusedPort = ServerSocket(0).use { it.localPort }
val configuredRelay = "ws://127.0.0.1:$unusedPort"
val client = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { configuredRelay },
sessionTokenProvider = { null },
)
val result = client.probeHealth(configuredRelay)
assertTrue(result.isFailure)
val entry = DiagnosticsLog.recent(setOf(DiagnosticCategory.Relay)).first()
assertEquals("Relay health probe before WebSocket connection", entry.operation)
assertEquals("ws://[host]", entry.configuredUrl)
assertEquals("http://[host]/health", entry.requestUrl)
assertEquals(
"Verify Relay is running and listening on the configured host and port.",
entry.suggestion,
)
assertFalse(entry.detail.orEmpty().contains(unusedPort.toString()))
}
}
@@ -0,0 +1,95 @@
package com.hermesandroid.relay.network.relay
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class RelayHttpClientModelCapabilitiesTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun postsBoundedProfileScopedPairsAndParsesExactOverlay() = runTest {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""{
"schema_version":1,
"contract_version":"relay-reasoning-v1",
"capabilities":[{
"provider":"opencode",
"model":"deepseek-v3",
"reasoning":true,
"reasoning_efforts":["low","max"],
"reasoning_efforts_exact":true,
"source":"relay-provider-policy"
}]
}""".trimIndent(),
),
)
val client = client(token = "paired-token")
val rows = (0..130).map {
RelayHttpClient.ModelCapabilityRequestRow("provider-$it", "model-$it")
}
val response = client.fetchModelCapabilities(rows, profile = "research").getOrThrow()
val request = server.takeRequest()
val body = Json.parseToJsonElement(request.body.readUtf8()).jsonObject
assertEquals("/relay/model-capabilities", request.path)
assertEquals("Bearer paired-token", request.getHeader("Authorization"))
assertEquals("research", body.getValue("profile").jsonPrimitive.content)
assertEquals(
RelayHttpClient.MAX_MODEL_CAPABILITY_ROWS,
body.getValue("models").jsonArray.size,
)
assertEquals(listOf("low", "max"), response?.capabilities?.single()?.reasoningEfforts)
assertEquals(true, response?.capabilities?.single()?.reasoningEffortsExact)
}
@Test
fun olderRelay404IsOptional() = runTest {
server.enqueue(MockResponse().setResponseCode(404))
val response = client(token = "paired-token").fetchModelCapabilities(
listOf(RelayHttpClient.ModelCapabilityRequestRow("openai", "gpt-5.5")),
).getOrThrow()
assertNull(response)
}
@Test
fun vanillaUnpairedPathDoesNotCallRelay() = runTest {
val response = client(token = null).fetchModelCapabilities(
listOf(RelayHttpClient.ModelCapabilityRequestRow("openai", "gpt-5.5")),
).getOrThrow()
assertNull(response)
assertEquals(0, server.requestCount)
}
private fun client(token: String?) = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { server.url("/").toString() },
sessionTokenProvider = { token },
)
}
@@ -4,6 +4,8 @@ import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
@@ -40,6 +42,7 @@ class EndpointResolverTest {
@Before
fun setUp() {
DiagnosticsLog.clear()
clockMillis.set(0L)
reachableServer = MockWebServer().apply {
dispatcher = healthDispatcher(statusCode = 200)
@@ -61,6 +64,7 @@ class EndpointResolverTest {
@After
fun tearDown() {
DiagnosticsLog.clear()
runCatching { reachableServer.shutdown() }
runCatching { secondReachableServer.shutdown() }
}
@@ -370,6 +374,11 @@ class EndpointResolverTest {
assertNotNull(request)
assertEquals("GET", request!!.method)
assertEquals("/api/status", request.path)
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Endpoint))
.first { it.operation != null }
assertEquals("Dashboard or API route health probe", diagnostic.operation)
assertEquals("http://[host]", diagnostic.configuredUrl)
assertEquals("http://[host]/api/status", diagnostic.requestUrl)
}
@Test
@@ -266,7 +266,7 @@ class ChatHandlerTest {
@Test
fun onToolCallStart_createsToolCallEntry() {
handler.onTextDelta("assist-1", "")
handler.onToolCallStart("assist-1", "call-1", "read_file")
handler.onToolCallStart("assist-1", "call-1", "read_file", "README.md")
val msg = handler.messages.value.find { it.id == "assist-1" }
assertNotNull(msg)
@@ -275,6 +275,7 @@ class ChatHandlerTest {
val toolCall = msg.toolCalls[0]
assertEquals("call-1", toolCall.id)
assertEquals("read_file", toolCall.name)
assertEquals("README.md", toolCall.args)
assertFalse(toolCall.isComplete)
assertNull(toolCall.success)
}
@@ -549,6 +550,33 @@ class ChatHandlerTest {
assertEquals(0, handler.sessions.value[0].messageCount)
}
@Test
fun updateSessions_restoresServerOwnedFlagsAfterFreshHandlerCreation() {
val persisted = listOf(
SessionItem(id = "pinned", pinned = true),
SessionItem(id = "archived", archived = true),
)
val restartedHandler = ChatHandler()
restartedHandler.updateSessions(persisted)
assertTrue(restartedHandler.sessions.value.first { it.sessionId == "pinned" }.pinned)
assertTrue(restartedHandler.sessions.value.first { it.sessionId == "archived" }.archived)
}
@Test
fun setSessionFlagsLocal_supportsOptimisticUpdateAndRollback() {
handler.updateSessions(listOf(SessionItem(id = "s1")))
handler.setSessionFlagsLocal("s1", pinned = true, archived = true)
assertTrue(handler.sessions.value.single().pinned)
assertTrue(handler.sessions.value.single().archived)
handler.setSessionFlagsLocal("s1", pinned = false, archived = false)
assertFalse(handler.sessions.value.single().pinned)
assertFalse(handler.sessions.value.single().archived)
}
@Test
fun updateSessions_sortsByLastActivityAndKeepsStartedAt() {
handler.updateSessions(
@@ -1196,6 +1196,26 @@ class DashboardApiClientTest {
assertFalse(body.contains("profile"))
}
@Test
fun setSessionPinned_patchesPinnedScopedToProfile() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("""{"ok":true,"pinned":true}"""),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
client.setSessionPinned("sess-keep", pinned = true, profile = "mizu").getOrThrow()
val request = server.takeRequest()
assertEquals("PATCH", request.method)
assertEquals("/api/sessions/sess-keep", request.requestUrl!!.encodedPath)
assertEquals("mizu", request.requestUrl!!.queryParameter("profile"))
val body = request.body.readUtf8()
assertTrue(body.contains(""""pinned":true"""))
assertTrue(body.contains(""""profile":"mizu"""))
}
@Test
fun renameSession_carriesProfileInBodyAndQuery() = runTest {
server.enqueue(
@@ -289,6 +289,12 @@ class GatewayClientHarness(
"slug": "openai",
"name": "OpenAI",
"models": ["gpt-5.5"],
"capabilities": {
"gpt-5.5": {
"reasoning": true,
"reasoning_efforts": ["minimal", "medium", "ultra"]
}
},
"is_current": true,
"authenticated": true
}
@@ -524,7 +530,7 @@ class GatewayChatClientTest {
val sessionIds = ConcurrentLinkedQueue<String>()
val errors = ConcurrentLinkedQueue<String>()
val interactions = ConcurrentLinkedQueue<GatewayAsk>()
val interactionResolutions = ConcurrentLinkedQueue<GatewayAskExpiry>()
val interactionExpiries = ConcurrentLinkedQueue<GatewayAskExpiry>()
val toolStarts = ConcurrentLinkedQueue<Pair<String, String>>()
val toolDone = ConcurrentLinkedQueue<Pair<String, String?>>()
@@ -542,7 +548,7 @@ class GatewayChatClientTest {
onStart = { starts.incrementAndGet() },
onTextDelta = { textDeltas += it },
onThinkingDelta = { thinkingDeltas += it },
onToolCallStart = { id, name -> toolStarts += id to name },
onToolCallStart = { id, name, _ -> toolStarts += id to name },
onToolCallDone = { id, result -> toolDone += id to result },
onToolCallFailed = { _, _ -> },
onTurnComplete = { },
@@ -554,8 +560,7 @@ class GatewayChatClientTest {
onSubagentEvent = { subagentEvents += it },
onMoaReference = { moaReferences += it },
onInteractionRequest = { interactions += it },
onInteractionExpired = { },
onInteractionResolved = { interactionResolutions += it },
onInteractionExpired = { interactionExpiries += it },
onStatusUpdate = { _, _ -> },
onStatusClear = { },
)
@@ -858,6 +863,10 @@ class GatewayChatClientTest {
val normal = client.modelOptions().getOrThrow()
val normalParams = harness.awaitRpc("model.options")
assertEquals("gpt-5.5", normal.currentModel)
assertEquals(
listOf("minimal", "medium", "ultra"),
normal.providers.single().capabilities.getValue("gpt-5.5").reasoningEfforts,
)
assertFalse((normalParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
val refreshed = client.modelOptions(refresh = true).getOrThrow()
@@ -1234,6 +1243,8 @@ class GatewayChatClientTest {
harness.eventFrame("message.complete", buildJsonObject { put("text", "done") }, "live-1"),
)
assertFalse(r.completeLatch.await(100, TimeUnit.MILLISECONDS))
serverWs.send(harness.eventFrame("approval.expire", buildJsonObject { }, "live-1"))
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
val ask = r.interactions.single()
assertEquals(GatewayAsk.Kind.APPROVAL, ask.kind)
@@ -1824,18 +1835,54 @@ class GatewayChatClientTest {
@Test
fun `reasoning settings fetch uses config get`() {
harness.reasoningEffort = "high"
harness.reasoningEffort = "ultra"
harness.reasoningDisplay = "show"
val result = runBlocking { client.getReasoningSettings() }
assertTrue(result.isSuccess)
assertEquals("high", result.getOrThrow().effort)
assertEquals("ultra", result.getOrThrow().effort)
assertEquals("show", result.getOrThrow().display)
val rpc = harness.awaitRpc("config.get")
assertEquals("reasoning", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `session info preserves coherent provider model and max effort`() {
val recorder = Recorder()
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
recorder.preflightFailures += it
}
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
serverWs.send(
harness.eventFrame(
"session.info",
buildJsonObject {
put("model", "deepseek-v3")
put("provider", "opencode")
put("reasoning_effort", "max")
},
"live-resumed",
),
)
waitUntil { client.serverReasoningEffort.value == "max" }
assertEquals(
GatewayModelIdentity(model = "deepseek-v3", provider = "opencode"),
client.serverModelIdentity.value,
)
assertEquals(
GatewayReasoningIdentity(
identity = GatewayModelIdentity(model = "deepseek-v3", provider = "opencode"),
effort = "max",
),
client.serverReasoningIdentity.value,
)
}
@Test
fun `approval mode get and set use profile config without session yolo scope`() {
harness.approvalMode = "smart"
@@ -2730,7 +2777,7 @@ class GatewayChatClientTest {
}
@Test
fun `detached interaction is retained and replayed when exact session reopens`() {
fun `detached interaction survives activity and expires only from explicit event`() {
val original = Recorder()
val backgroundEvents = ConcurrentLinkedQueue<GatewayBackgroundInteractionEvent>()
client.setBackgroundInteractionListener(backgroundEvents::add)
@@ -2780,14 +2827,29 @@ class GatewayChatClientTest {
"live-1",
),
)
serverWs.send(
harness.eventFrame(
"message.complete",
buildJsonObject { put("text", "continued") },
"live-1",
),
)
Thread.sleep(100)
assertEquals(1, resumed.interactions.size)
assertTrue(resumed.interactionExpiries.isEmpty())
assertEquals(1L, resumed.completeLatch.count)
assertTrue(harness.rpcLog.none { it.first == "approval.respond" })
serverWs.send(harness.eventFrame("approval.expire", buildJsonObject { }, "live-1"))
repeat(50) {
if (resumed.interactionResolutions.isNotEmpty()) return@repeat
if (resumed.interactionExpiries.isNotEmpty()) return@repeat
Thread.sleep(20)
}
assertEquals(
GatewayAskExpiry(GatewayAsk.Kind.APPROVAL, null),
resumed.interactionResolutions.single(),
resumed.interactionExpiries.single(),
)
assertTrue(resumed.completeLatch.await(5, TimeUnit.SECONDS))
}
@Test
@@ -22,6 +22,7 @@ class GatewayEventMapperTest {
val reconciledInterims = mutableListOf<String>()
val thinkingDeltas = mutableListOf<String>()
val toolStarts = mutableListOf<Pair<String, String>>()
val toolStartArgs = mutableListOf<Pair<String, String?>>()
val toolDones = mutableListOf<Pair<String, String?>>()
val toolFails = mutableListOf<Pair<String, String?>>()
val toolOutputRisks = mutableListOf<GatewayToolOutputRisk>()
@@ -30,7 +31,6 @@ class GatewayEventMapperTest {
val moaReferences = mutableListOf<GatewayMoaReference>()
val interactions = mutableListOf<GatewayAsk>()
val interactionExpiries = mutableListOf<GatewayAskExpiry>()
val interactionResolutions = mutableListOf<GatewayAskExpiry>()
val statusUpdates = mutableListOf<Pair<String?, String>>()
val statusClears = mutableListOf<String>()
val sessionIds = mutableListOf<String>()
@@ -49,7 +49,10 @@ class GatewayEventMapperTest {
onInterimMessage = { text, alreadyStreamed -> interimMessages += text to alreadyStreamed },
onInterimReconciled = { text -> reconciledInterims += text },
onThinkingDelta = { thinkingDeltas += it },
onToolCallStart = { id, name -> toolStarts += id to name },
onToolCallStart = { id, name, args ->
toolStarts += id to name
toolStartArgs += id to args
},
onToolCallDone = { id, preview -> toolDones += id to preview },
onToolCallFailed = { id, err -> toolFails += id to err },
onToolOutputRisk = { toolOutputRisks += it },
@@ -63,7 +66,6 @@ class GatewayEventMapperTest {
onMoaReference = { moaReferences += it },
onInteractionRequest = { interactions += it },
onInteractionExpired = { interactionExpiries += it },
onInteractionResolved = { interactionResolutions += it },
onStatusUpdate = { kind, text -> statusUpdates += kind to text },
onStatusClear = { statusClears += it },
)
@@ -450,6 +452,37 @@ class GatewayEventMapperTest {
assertEquals(listOf("t1" to "ran fine"), r.toolDones)
}
@Test
fun `tool cards retain live arguments and results for expansion`() {
val verbose = Recorder()
val verboseMapper = mapperWith(verbose)
verboseMapper.onEvent(
"tool.start",
obj("""{"tool_id":"t1","name":"terminal","context":"echo fallback","args_text":"{\"command\":\"echo live\"}"}"""),
)
verboseMapper.onEvent(
"tool.complete",
obj("""{"tool_id":"t1","name":"terminal","summary":"finished","result_text":"live output"}"""),
)
assertEquals(listOf("t1" to "{\"command\":\"echo live\"}"), verbose.toolStartArgs)
assertEquals(listOf("t1" to "live output"), verbose.toolDones)
val standard = Recorder()
val standardMapper = mapperWith(standard)
standardMapper.onEvent(
"tool.start",
obj("""{"tool_id":"t2","name":"skill","context":"review repository"}"""),
)
standardMapper.onEvent(
"tool.complete",
obj("""{"tool_id":"t2","name":"skill","summary":"completed skill"}"""),
)
assertEquals(listOf("t2" to "review repository"), standard.toolStartArgs)
assertEquals(listOf("t2" to "completed skill"), standard.toolDones)
}
@Test
fun `tool complete with error routes to failed`() {
val r = Recorder()
@@ -796,7 +829,7 @@ class GatewayEventMapperTest {
}
@Test
fun `replayed request is deduplicated and resumed turn resolves it`() {
fun `duplicate request and later turn activity keep decision pending`() {
val r = Recorder()
val mapper = mapperWith(r)
val request = obj("""{"request_id":"r1","question":"Choose","choices":["A","B"]}""")
@@ -804,12 +837,17 @@ class GatewayEventMapperTest {
mapper.onEvent("clarify.request", request)
mapper.onEvent("clarify.request", request)
mapper.onEvent("message.delta", obj("""{"text":"Continuing"}"""))
mapper.onEvent("tool.complete", obj("""{"tool_id":"other","name":"read_file"}"""))
mapper.onEvent("message.complete", obj("""{"text":"Continuing"}"""))
assertEquals(1, r.interactions.size)
assertEquals(
GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, "r1"),
r.interactionResolutions.single(),
)
assertEquals(r.interactions.single(), mapper.currentInteraction)
assertFalse(mapper.turnEnded)
mapper.onEvent("clarify.expire", obj("""{"request_id":"r1"}"""))
assertNull(mapper.currentInteraction)
assertEquals(GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, "r1"), r.interactionExpiries.single())
assertTrue(mapper.turnEnded)
}
@Test
@@ -273,6 +273,13 @@ class HermesApiClientTest {
"authenticated": true,
"is_current": true,
"models": ["grok-4.3", "grok-4.2"],
"capabilities": {
"grok-4.3": {
"reasoning": true,
"reasoning_efforts": ["low", "high", "max"],
"reasoning_efforts_exact": true
}
},
"unavailable_models": ["grok-4.2"],
"free_tier": true,
"total_models": 2
@@ -294,6 +301,14 @@ class HermesApiClientTest {
assertEquals(listOf("grok-4.2"), parsed?.providers?.first()?.unavailableModels)
assertTrue(parsed?.providers?.first()?.authenticated == true)
assertFalse(parsed?.providers?.last()?.authenticated == true)
assertEquals(
listOf("low", "high", "max"),
parsed?.providers?.first()?.capabilities?.get("grok-4.3")?.reasoningEfforts,
)
assertEquals(
true,
parsed?.providers?.first()?.capabilities?.get("grok-4.3")?.reasoningEffortsExact,
)
}
@Test
@@ -468,6 +483,16 @@ class HermesApiClientTest {
assertEquals("Routes to gpt-5-mini", parsed?.single()?.routeDetail)
}
@Test
fun modelOptionsDeduplicateRepeatedAliasRowsByRequestIdentity() {
val parsed = parseModelOptionsBody(
Json { ignoreUnknownKeys = true },
"""{"data":[{"id":"fast","root":"gpt-5-mini"},{"id":"fast","root":"gpt-5-mini"}]}""",
)
assertEquals(listOf("fast"), parsed?.map { it.id })
}
@Test
fun modelOptionsIgnoreMalformedRowsButKeepCompatibilityShape() {
val parsed = parseModelOptionsBody(
@@ -0,0 +1,104 @@
package com.hermesandroid.relay.network.upstream
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ModelInventoryNormalizationTest {
@Test
fun exactReportedIdentityIsPublishedOnceWithinItsProvider() {
val provider = parseGatewayModelProvider(
Json.parseToJsonElement(
"""
{
"slug": "deepseek",
"name": "DeepSeek",
"models": ["deepseek-v4-flash", "deepseek-v4-flash", " deepseek-v4-flash "]
}
""".trimIndent(),
).jsonObject,
)
assertEquals(listOf("deepseek-v4-flash"), provider?.models)
}
@Test
fun repeatedProviderRowsMergeModelsAndReasoningCapabilities() {
val providers = normalizeGatewayModelProviders(
listOf(
provider(
slug = "DeepSeek",
models = listOf("deepseek-v4-flash"),
capabilities = mapOf(
"deepseek-v4-flash" to GatewayModelCapabilities(reasoning = true),
),
authenticated = false,
),
provider(
slug = "deepseek",
models = listOf("deepseek-v4-flash", "deepseek-v4-pro"),
capabilities = mapOf(
"deepseek-v4-flash" to GatewayModelCapabilities(
reasoningEfforts = listOf("low", "high"),
reasoningEffortsExact = true,
),
),
authenticated = true,
),
),
)
assertEquals(1, providers.size)
assertEquals(listOf("deepseek-v4-flash", "deepseek-v4-pro"), providers.single().models)
assertTrue(providers.single().authenticated)
val capability = providers.single().capabilities.getValue("deepseek-v4-flash")
assertEquals(true, capability.reasoning)
assertEquals(listOf("low", "high"), capability.reasoningEfforts)
assertEquals(true, capability.reasoningEffortsExact)
}
@Test
fun sameModelUnderDistinctProvidersRemainsTwoChoices() {
val providers = normalizeGatewayModelProviders(
listOf(
provider("deepseek", listOf("deepseek-v4-flash")),
provider("opencode-go", listOf("deepseek-v4-flash")),
),
)
assertEquals(listOf("deepseek", "opencode-go"), providers.map { it.slug })
assertTrue(providers.all { it.models == listOf("deepseek-v4-flash") })
}
@Test
fun refreshNormalizationIsDeterministicAndDoesNotAccumulateRows() {
val payload = listOf(
provider("deepseek", listOf("deepseek-v4-flash", "deepseek-v4-flash")),
provider("deepseek", listOf("deepseek-v4-pro")),
)
val first = normalizeGatewayModelProviders(payload)
val reloaded = normalizeGatewayModelProviders(first + payload)
assertEquals(first, reloaded)
assertEquals(listOf("deepseek-v4-flash", "deepseek-v4-pro"), reloaded.single().models)
}
private fun provider(
slug: String,
models: List<String>,
capabilities: Map<String, GatewayModelCapabilities> = emptyMap(),
authenticated: Boolean = true,
) = GatewayModelProvider(
name = "DeepSeek",
slug = slug,
models = models,
isCurrent = false,
warning = null,
authenticated = authenticated,
capabilities = capabilities,
)
}
@@ -1,14 +1,23 @@
package com.hermesandroid.relay.network.upstream
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.InetAddress
import java.net.SocketException
import java.net.UnknownHostException
import java.security.MessageDigest
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import javax.net.ssl.SSLHandshakeException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.mockwebserver.MockResponse
import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
@@ -17,6 +26,7 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -355,6 +365,153 @@ class NativeDashboardAuthTest {
assertEquals("rotated-refresh", shared.get()?.refreshToken)
}
@Test
fun nativeSignInFailureStagesAreSecretFreeAndActionable() {
assertEquals(
"callback_error",
nativeDashboardSignInFailureStage(NativeDashboardCallbackException("rejected")),
)
assertEquals(
"token_http_400",
nativeDashboardSignInFailureStage(NativeDashboardAuthHttpException(400)),
)
assertEquals(
"token_shape",
nativeDashboardSignInFailureStage(
NativeDashboardTokenShapeException("Dashboard token response was malformed"),
),
)
assertEquals(
"inactive_generation",
nativeDashboardSignInFailureStage(NativeDashboardInactiveAuthorizationException()),
)
assertEquals(
"token_transport",
nativeDashboardSignInFailureStage(IOException("connection reset")),
)
assertEquals(
"token_store",
nativeDashboardSignInFailureStage(IllegalStateException("keystore unavailable")),
)
val secret = "code=secret-code&state=secret-state&access_token=secret-token"
val diagnostic = nativeDashboardSignInFailureDiagnostic(IOException(secret))
assertEquals("dashboard_native_pkce_failed stage=token_transport", diagnostic)
assertFalse(diagnostic.contains("secret-code"))
assertFalse(diagnostic.contains("secret-state"))
assertFalse(diagnostic.contains("secret-token"))
val classified = listOf(
InterruptedIOException(secret) to "token_transport_timeout",
UnknownHostException(secret) to "token_transport_dns",
ConnectException(secret) to "token_transport_connect",
SSLHandshakeException(secret) to "token_transport_tls",
SocketException(secret) to "token_transport_socket",
)
classified.forEach { (cause, expectedStage) ->
val wrapped = IOException(secret, cause)
val safeDiagnostic = nativeDashboardSignInFailureDiagnostic(wrapped)
assertEquals("dashboard_native_pkce_failed stage=$expectedStage", safeDiagnostic)
assertFalse(safeDiagnostic.contains("secret-code"))
assertFalse(safeDiagnostic.contains("secret-state"))
assertFalse(safeDiagnostic.contains("secret-token"))
}
}
@Test
fun exchangeCallback_dnsFailureThenSuccess_sendsTokenPostOnce() {
server.enqueue(
MockResponse().setBody(
"""{"access_token":"access","refresh_token":"refresh","expires_at":2000,"provider":"nous","user_id":"u"}""",
),
)
val calls = AtomicInteger(0)
val backoffs = mutableListOf<Long>()
val delegate = Dns { _ ->
if (calls.incrementAndGet() == 1) {
throw UnknownHostException("secret-first-lookup")
}
listOf(InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)))
}
val httpClient = OkHttpClient.Builder()
.dns(
RetryingNativeAuthDns(
delegate = delegate,
backoffMillis = 7L,
sleeper = backoffs::add,
),
)
.retryOnConnectionFailure(false)
.build()
val baseUrl = server.url("/").newBuilder().host("native-auth.test").build().toString()
val client = NativeDashboardAuthClient(baseUrl, store, client = httpClient)
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
val tokens = client.exchangeCallback(
authorization,
"/callback?code=one-time-code&state=${authorization.state}",
)
assertEquals("access", tokens.accessToken)
assertEquals(2, calls.get())
assertEquals(listOf(7L), backoffs)
assertEquals(1, server.requestCount)
assertEquals("/auth/native/token", server.takeRequest().path)
}
@Test
fun exchangeCallback_dnsRetryExhausted_reportsTypedDnsStage_withoutHttpRequest() {
val secret = "secret-host-detail"
val calls = AtomicInteger(0)
val delegate = Dns { _ ->
calls.incrementAndGet()
throw UnknownHostException(secret)
}
val httpClient = OkHttpClient.Builder()
.dns(RetryingNativeAuthDns(delegate, backoffMillis = 0L))
.retryOnConnectionFailure(false)
.build()
val client = NativeDashboardAuthClient(
"https://native-auth.invalid",
store,
client = httpClient,
)
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
val failure = runCatching {
client.exchangeCallback(
authorization,
"/callback?code=one-time-code&state=${authorization.state}",
)
}.exceptionOrNull()
assertEquals(2, calls.get())
assertEquals(0, server.requestCount)
assertEquals("token_transport_dns", nativeDashboardSignInFailureStage(failure!!))
assertFalse(nativeDashboardSignInFailureDiagnostic(failure).contains(secret))
}
@Test
fun retryingNativeAuthDns_nonDnsFailure_isNotRetriedOrChanged() {
val expected = IllegalStateException("non-dns-secret")
val calls = AtomicInteger(0)
val backoffs = mutableListOf<Long>()
val dns = RetryingNativeAuthDns(
delegate = Dns { _ ->
calls.incrementAndGet()
throw expected
},
backoffMillis = 7L,
sleeper = backoffs::add,
)
val actual = runCatching { dns.lookup("native-auth.test") }.exceptionOrNull()
assertSame(expected, actual)
assertEquals(1, calls.get())
assertTrue(backoffs.isEmpty())
}
private fun dashboardClientWithBearer(store: NativeDashboardTokenStore): DashboardApiClient =
DashboardApiClient(
server.url("/").toString(),
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.BuildConfig
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.Socket
@@ -67,22 +68,75 @@ class NativeDashboardSignInCoordinatorTest {
val redirect = URI(query(authorize)["redirect_uri"]!!)
val state = query(authorize)["state"]!!
val rejected = sendCallback(
val rejected = sendCallbackResponse(
redirect,
"/callback?code=attacker&state=wrong",
"/callback?code=attacker-secret&state=wrong-secret",
)
assertTrue(rejected.startsWith("HTTP/1.1 400"))
assertTrue(rejected.contains("Callback not accepted"))
assertTrue(rejected.contains("Content-Security-Policy:"))
assertTrue(rejected.contains("Cache-Control: no-store"))
assertReturnLinkIsFixedAndPrivate(rejected)
assertFalse(rejected.contains("attacker-secret"))
assertFalse(rejected.contains("wrong-secret"))
assertFalse(result.isCompleted)
val accepted = sendCallback(
val accepted = sendCallbackResponse(
redirect,
"/callback?code=code-1&state=$state",
)
assertTrue(accepted.startsWith("HTTP/1.1 200"))
assertTrue(accepted.contains("Content-Type: text/html; charset=utf-8"))
assertTrue(accepted.contains("Sign-in complete"))
assertReturnLinkIsFixedAndPrivate(accepted)
assertTrue(accepted.contains("prefers-reduced-motion:reduce"))
assertTrue(accepted.contains("frame-ancestors 'none'"))
assertTrue(accepted.contains("style-src 'sha256-"))
assertTrue(accepted.contains("script-src 'none'"))
assertFalse(accepted.contains("'unsafe-inline'"))
assertFalse(accepted.contains("window.close"))
assertFalse(accepted.contains("code-1"))
assertFalse(accepted.contains(state))
assertEquals("access-1", result.await().accessToken)
}
}
@Test
fun signIn_tokenExchangeFailure_returnsPrivateActionablePage() = runBlocking {
server.enqueue(MockResponse().setResponseCode(400).setBody("sensitive-upstream-detail"))
val coordinator = NativeDashboardSignInCoordinator(
NativeDashboardAuthClient(server.url("/").toString(), store),
)
coroutineScope {
val authorizationUrl = CompletableDeferred<String>()
val result = async {
runCatching {
coordinator.signIn("nous") { authorizationUrl.complete(it) }
}
}
val authorize = URI(authorizationUrl.await())
val query = query(authorize)
val redirect = URI(query.getValue("redirect_uri"))
val response = sendCallbackResponse(
redirect,
"/callback?code=one-time-secret&state=${query.getValue("state")}",
)
assertTrue(response.startsWith("HTTP/1.1 400"))
assertTrue(response.contains("Sign-in needs another try"))
assertTrue(response.contains("No session details were saved"))
assertTrue(response.contains("Referrer-Policy: no-referrer"))
assertTrue(response.contains("X-Content-Type-Options: nosniff"))
assertTrue(response.contains("Permissions-Policy:"))
assertReturnLinkIsFixedAndPrivate(response)
assertFalse(response.contains("one-time-secret"))
assertFalse(response.contains(query.getValue("state")))
assertFalse(response.contains("sensitive-upstream-detail"))
assertTrue(result.await().exceptionOrNull() is NativeDashboardAuthHttpException)
}
}
@Test
fun signIn_timeoutClosesEphemeralListener() = runBlocking {
val coordinator = NativeDashboardSignInCoordinator(
@@ -139,6 +193,18 @@ class NativeDashboardSignInCoordinatorTest {
)
}
@Test
fun returnUri_usesTheExactFlavorApplicationId_withoutAuthMaterial() {
val returnUri = URI(NATIVE_SIGN_IN_RETURN_URI)
assertEquals(BuildConfig.APPLICATION_ID, returnUri.scheme)
assertEquals("return", returnUri.host)
assertEquals("", returnUri.path)
assertEquals(null, returnUri.userInfo)
assertEquals(null, returnUri.query)
assertEquals(null, returnUri.fragment)
}
private suspend fun completeSignIn(
coordinator: NativeDashboardSignInCoordinator,
provider: String,
@@ -154,7 +220,7 @@ class NativeDashboardSignInCoordinatorTest {
val redirect = URI(authorizeQuery["redirect_uri"]!!)
assertEquals("127.0.0.1", redirect.host)
assertTrue(redirect.port > 0)
val response = sendCallback(
val response = sendCallbackResponse(
redirect,
"/callback?code=code-1&state=${authorizeQuery["state"]}",
)
@@ -162,16 +228,28 @@ class NativeDashboardSignInCoordinatorTest {
result.await()
}
private fun sendCallback(redirect: URI, target: String): String =
private fun sendCallbackResponse(redirect: URI, target: String): String =
Socket("127.0.0.1", redirect.port).use { socket ->
socket.getOutputStream().write(
"GET $target HTTP/1.1\r\nHost: 127.0.0.1:${redirect.port}\r\n\r\n"
.toByteArray(StandardCharsets.US_ASCII),
)
socket.getOutputStream().flush()
BufferedReader(InputStreamReader(socket.getInputStream())).readLine()
BufferedReader(InputStreamReader(socket.getInputStream())).readText()
}
private fun assertReturnLinkIsFixedAndPrivate(response: String) {
assertTrue(
response.contains(
"<a class=\"return-link\" href=\"$NATIVE_SIGN_IN_RETURN_URI\">" +
"Return to Hermes Relay</a>",
),
)
assertFalse(NATIVE_SIGN_IN_RETURN_URI.contains('?'))
assertFalse(NATIVE_SIGN_IN_RETURN_URI.contains('#'))
assertFalse(NATIVE_SIGN_IN_RETURN_URI.contains('@'))
}
private fun query(uri: URI): Map<String, String> =
uri.rawQuery.orEmpty()
.split('&')
@@ -0,0 +1,246 @@
package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.viewmodel.reconcilePendingReasoningEffort
import com.hermesandroid.relay.viewmodel.isCurrentReasoningResponse
import com.hermesandroid.relay.viewmodel.isCurrentReasoningCapabilityOverlay
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 ReasoningEffortSupportTest {
@Test
fun canonicalNormalizationPreservesMaxAndUltra() {
assertEquals(
listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"),
ReasoningEfforts.canonical,
)
assertEquals("max", ReasoningEfforts.normalize(" MAX "))
assertEquals("ultra", ReasoningEfforts.normalize("Ultra"))
assertEquals("medium", ReasoningEfforts.normalize("future-value"))
}
@Test
fun exactProviderModelEffortsAreAuthoritative() {
val provider = provider(
slug = "opencode",
model = "deepseek-v3",
capabilities = GatewayModelCapabilities(
reasoning = true,
reasoningEfforts = listOf("low", "high", "max", "max", "unknown"),
reasoningEffortsExact = true,
),
)
val result = resolveReasoningEffortAvailability(
providers = listOf(provider),
provider = "opencode",
model = "deepseek-v3",
)
assertEquals(listOf("low", "high", "max"), result.choices)
assertTrue(result.exact)
assertTrue(result.accepts("max"))
assertFalse(result.accepts("ultra"))
}
@Test
fun explicitUpstreamReasoningFalseDisablesWithoutAnExactOverlay() {
val result = resolveReasoningEffortAvailability(
providers = listOf(
provider(
slug = "local",
model = "plain-model",
capabilities = GatewayModelCapabilities(
reasoning = false,
),
),
),
provider = "local",
model = "plain-model",
)
assertEquals(false, result.supported)
assertTrue(result.choices.isEmpty())
assertFalse(result.accepts("low"))
}
@Test
fun relayExactOverlayPrecedesUpstreamFalseButNotUpstreamExact() {
val identity = ReasoningEffortIdentity(provider = "opencode", model = "deepseek-v3")
val upstreamFalse = provider(
slug = identity.provider,
model = identity.model,
capabilities = GatewayModelCapabilities(reasoning = false),
)
val relayOverlay = mapOf(
identity to GatewayModelCapabilities(
reasoning = true,
reasoningEfforts = listOf("low", "max"),
reasoningEffortsExact = true,
),
)
val relayWins = resolveReasoningEffortAvailability(
providers = listOf(upstreamFalse),
provider = identity.provider,
model = identity.model,
relayCapabilities = relayOverlay,
)
assertEquals(listOf("low", "max"), relayWins.choices)
assertTrue(relayWins.exact)
val upstreamExact = upstreamFalse.copy(
capabilities = mapOf(
identity.model to GatewayModelCapabilities(
reasoning = true,
reasoningEfforts = listOf("high", "ultra"),
reasoningEffortsExact = true,
),
),
)
val upstreamWins = resolveReasoningEffortAvailability(
providers = listOf(upstreamExact),
provider = identity.provider,
model = identity.model,
relayCapabilities = relayOverlay,
)
assertEquals(listOf("high", "ultra"), upstreamWins.choices)
}
@Test
fun missingOrNonExactCapabilityFallsBackToCanonicalUnknownContract() {
val missing = resolveReasoningEffortAvailability(emptyList(), "openai", "gpt-5.5")
assertNull(missing.supported)
assertEquals(ReasoningEfforts.canonical, missing.choices)
val advisory = resolveReasoningEffortAvailability(
providers = listOf(
provider(
slug = "openai",
model = "gpt-5.5",
capabilities = GatewayModelCapabilities(
reasoningEfforts = listOf("high", "max"),
reasoningEffortsExact = false,
),
),
),
provider = "openai",
model = "gpt-5.5",
)
assertFalse(advisory.exact)
assertEquals(ReasoningEfforts.canonical, advisory.choices)
}
@Test
fun capabilityLookupNeverBorrowsSameModelFromAnotherProvider() {
val openCode = provider(
slug = "opencode",
model = "deepseek-v3",
capabilities = GatewayModelCapabilities(
reasoning = true,
reasoningEfforts = listOf("low", "max"),
reasoningEffortsExact = true,
),
)
val openRouter = provider(
slug = "openrouter",
model = "deepseek-v3",
capabilities = GatewayModelCapabilities(
reasoning = true,
reasoningEfforts = listOf("low", "ultra"),
reasoningEffortsExact = true,
),
)
val result = resolveReasoningEffortAvailability(
providers = listOf(openCode, openRouter),
provider = "openrouter",
model = "deepseek-v3",
)
assertEquals(listOf("low", "ultra"), result.choices)
val incompleteIdentity = resolveReasoningEffortAvailability(
providers = listOf(openCode, openRouter),
provider = null,
model = "deepseek-v3",
)
assertNull(incompleteIdentity.supported)
assertEquals(ReasoningEfforts.canonical, incompleteIdentity.choices)
}
@Test
fun modelChangeClearsOnlyIncompatiblePendingEffort() {
val availability = ReasoningEffortAvailability(
supported = true,
choices = listOf("low", "high", "max"),
exact = true,
)
val modelA = ReasoningEffortIdentity(provider = "openrouter", model = "model-a")
val modelB = ReasoningEffortIdentity(provider = "opencode", model = "model-b")
assertNull(
reconcilePendingReasoningEffort(
value = "ultra",
confirmedIdentity = null,
activeIdentity = modelB,
availability = availability,
),
)
assertFalse(
isCurrentReasoningResponse(
capturedRevision = 7L,
currentRevision = 8L,
capturedIdentity = modelA,
activeIdentity = modelB,
),
)
assertEquals(
"max",
reconcilePendingReasoningEffort(
value = "max",
confirmedIdentity = null,
activeIdentity = modelB,
availability = availability,
),
)
assertEquals(
"ultra",
reconcilePendingReasoningEffort(
value = "ultra",
confirmedIdentity = modelB,
activeIdentity = modelB,
availability = availability,
),
)
assertNull(
reconcilePendingReasoningEffort(
value = "ultra",
confirmedIdentity = modelA,
activeIdentity = modelB,
availability = availability,
),
)
}
@Test
fun relayOverlayResponseRequiresCurrentGenerationAndProfileContext() {
assertTrue(isCurrentReasoningCapabilityOverlay(4L, 4L, "conn::profile", "conn::profile"))
assertFalse(isCurrentReasoningCapabilityOverlay(3L, 4L, "conn::profile", "conn::profile"))
assertFalse(isCurrentReasoningCapabilityOverlay(4L, 4L, "conn::a", "conn::b"))
}
private fun provider(
slug: String,
model: String,
capabilities: GatewayModelCapabilities,
) = GatewayModelProvider(
name = slug,
slug = slug,
models = listOf(model),
isCurrent = false,
warning = null,
capabilities = mapOf(model to capabilities),
)
}
@@ -84,6 +84,16 @@ class SessionModelsTest {
assertEquals(2, item.toolCallCount)
}
@Test
fun sessionItem_deserializesDurableSessionFlags() {
val item = json.decodeFromString<SessionItem>(
"""{"id":"s1","pinned":true,"archived":true}""",
)
assertTrue(item.pinned)
assertTrue(item.archived)
}
@Test
fun sessionItem_deserialization_acceptsIsoUpdatedAtFallback() {
val jsonStr = """
@@ -121,6 +131,8 @@ class SessionModelsTest {
assertNull(item.toolCallCount)
assertNull(item.inputTokens)
assertNull(item.outputTokens)
assertEquals(false, item.pinned)
assertEquals(false, item.archived)
}
// --- SessionListResponse ---
@@ -56,6 +56,38 @@ class RelayAppStatusTest {
assertFalse(shouldShowConnectionFooter(voiceMode = true, VoicePresentationMode.Focus))
}
@Test
fun `full-screen connect routes suppress connection-dependent app chrome`() {
assertTrue(
shouldSuppressGlobalChrome(
onboardingCompleted = true,
isDemoMode = false,
currentRoute = Screen.Pair.route,
),
)
assertTrue(
shouldSuppressGlobalChrome(
onboardingCompleted = true,
isDemoMode = false,
currentRoute = Screen.Onboarding.route,
),
)
assertFalse(
shouldSuppressGlobalChrome(
onboardingCompleted = true,
isDemoMode = false,
currentRoute = Screen.ConnectionsSettings.route,
),
)
assertFalse(
shouldSuppressGlobalChrome(
onboardingCompleted = false,
isDemoMode = true,
currentRoute = Screen.Chat.route,
),
)
}
@Test
fun `dashboard-only connection counts as configured startup chat`() {
assertTrue(hasConfiguredStartupChat(connection(dashboardUrl = "https://host.ts.net:9119")))
@@ -0,0 +1,218 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.v2.createComposeRule
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.test.swipeDown
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.network.upstream.GatewayApprovalMode
import com.hermesandroid.relay.network.upstream.GatewayApprovalModeCapability
import org.junit.Assert.assertEquals
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-xxhdpi")
class AgentPassportSheetTest {
@get:Rule
val compose = createComposeRule()
@Test
fun safetyControls_haveLargeTargets_selectedSemantics_andPlainLanguageMeaning() {
var selectedMode: GatewayApprovalMode? = null
var darkTheme by mutableStateOf(false)
compose.setContent {
MaterialTheme(
colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(),
) {
AgentPassportSafetyCard(
approvalMode = GatewayApprovalMode.Smart,
approvalCapability = GatewayApprovalModeCapability.Supported,
approvalWritable = true,
yoloEnabled = false,
fastEnabled = false,
controlsAvailable = true,
gatewayReady = true,
isStreaming = false,
onApprovalMode = { selectedMode = it },
onYolo = {},
onFast = {},
)
}
}
compose.onNodeWithTag("$PASSPORT_APPROVAL_CONTROL_TAG-0")
.assertHeightIsAtLeast(48.dp)
.assertIsNotSelected()
compose.onNodeWithTag("$PASSPORT_APPROVAL_CONTROL_TAG-1")
.assertHeightIsAtLeast(48.dp)
.assertIsSelected()
compose.onNodeWithText("Ask only when Hermes detects elevated risk.")
.assertIsDisplayed()
compose.onNodeWithContentDescription(
"Smart. Ask only when Hermes detects elevated risk.",
).assertIsSelected()
compose.onNodeWithTag("$PASSPORT_CHAT_OVERRIDE_CONTROL_TAG-0")
.assertHeightIsAtLeast(48.dp)
.assertIsSelected()
compose.onNodeWithTag("$PASSPORT_FAST_CONTROL_TAG-1")
.assertHeightIsAtLeast(48.dp)
.assertIsSelected()
compose.onNodeWithText("Use the standard processing tier.")
.assertIsDisplayed()
compose.onNodeWithTag("$PASSPORT_APPROVAL_CONTROL_TAG-0").performClick()
compose.runOnIdle { assertEquals(GatewayApprovalMode.Manual, selectedMode) }
compose.runOnIdle { darkTheme = true }
compose.onNodeWithTag("$PASSPORT_APPROVAL_CONTROL_TAG-1")
.assertIsSelected()
compose.onNodeWithText("Ask only when Hermes detects elevated risk.")
.assertIsDisplayed()
}
@Test
fun explicitCloseControl_dismissesSheet() {
var dismissals = 0
compose.setContent {
MaterialTheme {
AgentPassportSheetHost(
scrollState = rememberScrollState(),
onDismiss = { dismissals += 1 },
) {
Spacer(Modifier.height(800.dp))
}
}
}
compose.onNodeWithTag(AGENT_PASSPORT_CLOSE_TAG)
.assertIsDisplayed()
.performClick()
compose.runOnIdle { assertEquals(1, dismissals) }
}
@Test
fun downwardGesture_scrollsNestedContentBeforeDismissingSheet() {
var dismissals = 0
lateinit var nestedScrollState: ScrollState
compose.setContent {
MaterialTheme {
nestedScrollState = rememberScrollState()
AgentPassportSheetHost(
scrollState = nestedScrollState,
onDismiss = { dismissals += 1 },
) {
repeat(30) { index ->
Spacer(
Modifier
.fillMaxWidth()
.height(80.dp)
.testTag("passportItem-$index"),
)
}
}
}
}
compose.runOnIdle {
nestedScrollState.dispatchRawDelta(nestedScrollState.maxValue.toFloat())
assertTrue(nestedScrollState.value > 0)
}
compose.onNodeWithTag(AGENT_PASSPORT_SCROLL_TAG)
.performTouchInput { swipeDown() }
compose.runOnIdle {
assertEquals(0, dismissals)
assertTrue(nestedScrollState.value > 0)
}
compose.onNodeWithTag(AGENT_PASSPORT_SHEET_TAG).assertIsDisplayed()
}
@Test
fun downwardGestureAtTop_dismissesSheet() {
var dismissals = 0
compose.setContent {
MaterialTheme {
AgentPassportSheetHost(
scrollState = rememberScrollState(),
onDismiss = { dismissals += 1 },
) {
Spacer(Modifier.height(900.dp))
}
}
}
compose.onNodeWithTag(AGENT_PASSPORT_SCROLL_TAG)
.performTouchInput { swipeDown() }
compose.waitUntil(timeoutMillis = 5_000) { dismissals == 1 }
}
@Test
@Config(qualifiers = "w320dp-h480dp-xxhdpi")
fun compactHeightAndLargeFont_keepContentScrollableAndCloseVisible() {
lateinit var compactScrollState: ScrollState
compose.setContent {
val currentDensity = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(currentDensity.density, fontScale = 2f),
) {
MaterialTheme {
compactScrollState = rememberScrollState()
AgentPassportSheetHost(
scrollState = compactScrollState,
onDismiss = {},
) {
repeat(12) { index ->
Spacer(
Modifier
.fillMaxWidth()
.height(64.dp)
.testTag("largeFontItem-$index"),
)
}
}
}
}
}
compose.onNodeWithTag(AGENT_PASSPORT_CLOSE_TAG).assertIsDisplayed()
compose.runOnIdle {
assertTrue(compactScrollState.maxValue > 0)
compactScrollState.dispatchRawDelta(compactScrollState.maxValue.toFloat())
}
compose.onNodeWithTag("largeFontItem-11").assertIsDisplayed()
compose.onNodeWithTag(AGENT_PASSPORT_CLOSE_TAG).assertIsDisplayed()
}
}
@@ -0,0 +1,69 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionWizardDispatchPolicyTest {
@Test
fun dashboardOnlyQrUsesDashboardOwner() {
val payload = HermesPairingPayload(
dashboardUrl = "https://agent.example.com/hermes",
)
assertEquals(SetupQrDispatch.Dashboard, setupQrDispatch(payload))
}
@Test
fun apiQrKeepsStandardApiOwnerEvenWhenDashboardIsIncluded() {
val payload = HermesPairingPayload(
host = "api.example.com",
tls = true,
dashboardUrl = "https://agent.example.com/hermes",
)
assertEquals(SetupQrDispatch.StandardApi, setupQrDispatch(payload))
}
@Test
fun relayQrKeepsRelayOwnerWithOrWithoutApi() {
val relay = RelayPairing(
url = "wss://relay.example.com:8767",
code = "ABC123",
)
assertEquals(
SetupQrDispatch.Relay,
setupQrDispatch(HermesPairingPayload(host = "api.example.com", relay = relay)),
)
assertEquals(
SetupQrDispatch.Relay,
setupQrDispatch(
HermesPairingPayload(
dashboardUrl = "https://agent.example.com/hermes",
relay = relay,
),
),
)
}
@Test
fun wizardMotionRequiresOsAnimationsWithoutTouchExploration() {
assertTrue(
shouldAnimateWizardTransitions(
AccessibleMotionState(osAnimations = true, touchExploration = false),
),
)
assertFalse(
shouldAnimateWizardTransitions(
AccessibleMotionState(osAnimations = false, touchExploration = false),
),
)
assertFalse(
shouldAnimateWizardTransitions(
AccessibleMotionState(osAnimations = true, touchExploration = true),
),
)
}
}
@@ -19,6 +19,11 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class FloatingPetCompanionTest {
@Test
fun `supported floating pet rails add no visible vertical clearance`() {
assertEquals(0f, FLOATING_PET_SUPPORTED_RAIL_CLEARANCE_DP, 0f)
}
@Test
fun `terrain lookahead plans multiple levels before movement starts`() {
val composer = PetRoamingRail(
@@ -109,6 +114,35 @@ class FloatingPetCompanionTest {
assertFalse(floatingPetAcceptsPointerInput(positioned = false, surfaceScrolling = false))
}
@Test
fun `pet waits for a measured viewport before publishing its position`() {
assertFalse(shouldInitializeFloatingPet(false, viewportWidth = 0, viewportHeight = 0))
assertFalse(shouldInitializeFloatingPet(false, viewportWidth = 400, viewportHeight = 0))
assertFalse(shouldInitializeFloatingPet(false, viewportWidth = 0, viewportHeight = 800))
assertTrue(shouldInitializeFloatingPet(false, viewportWidth = 400, viewportHeight = 800))
assertFalse(shouldInitializeFloatingPet(true, viewportWidth = 400, viewportHeight = 800))
}
@Test
fun `chat pet waits for owner terrain before publishing its first position`() {
assertFalse(
shouldInitializeFloatingPet(
positioned = false,
viewportWidth = 400,
viewportHeight = 800,
terrainReady = false,
),
)
assertTrue(
shouldInitializeFloatingPet(
positioned = false,
viewportWidth = 400,
viewportHeight = 800,
terrainReady = true,
),
)
}
@Test
fun `roaming starts immediately then uses the normal repeat delay`() {
assertEquals(0L, floatingPetRoamDelayMs(hasMoved = false))
@@ -429,6 +463,16 @@ class FloatingPetCompanionTest {
assertEquals(60f, compactMaximum.visualSizeDp, 0.001f)
assertEquals(72f, compactMaximum.targetSizeDp, 0.001f)
assertEquals(FloatingPetDimensions(60f, 70f), floatingPetDimensions(false, Float.NaN))
listOf(
70f to 60f,
48f to 72f,
84f to 84f,
).forEach { (target, visual) ->
val collision = floatingPetCollisionSizePx(target, visual)
assertTrue(collision >= target)
assertTrue(collision >= visual)
}
}
@Test
@@ -0,0 +1,144 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.click
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardAction
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 HermesApprovalCardInteractionTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `vertical swipes and nested scrolling never choose a decision`() {
val actions = mutableListOf<String>()
compose.setContent {
MaterialTheme {
LazyColumn(Modifier.fillMaxSize()) {
item {
HermesCardBubble(
card = approvalCard(),
cardKey = CARD_KEY,
dispatches = emptyList(),
onActionTap = { _, action -> actions += action.value },
onInputSubmit = { _, value -> actions += value },
)
}
item { Spacer(Modifier.height(600.dp)) }
}
}
}
compose.onNodeWithText("Approve").performTouchInput { swipeUp() }
compose.onNodeWithText("Deny").performTouchInput { swipeDown() }
compose.onNodeWithText("A deliberately long command that must be read").performTouchInput {
swipeUp()
}
compose.runOnIdle { assertEquals(emptyList<String>(), actions) }
}
@Test
fun `removal restoration and recomposition are passive`() {
var visible by mutableStateOf(true)
var revision by mutableStateOf(0)
val actions = mutableListOf<String>()
compose.setContent {
MaterialTheme {
if (visible) {
HermesCardBubble(
card = approvalCard().copy(footer = "revision $revision"),
cardKey = CARD_KEY,
dispatches = emptyList(),
onActionTap = { _, action -> actions += action.value },
onInputSubmit = { _, value -> actions += value },
)
}
}
}
compose.runOnIdle { revision += 1 }
compose.runOnIdle { visible = false }
compose.runOnIdle { visible = true }
compose.onNodeWithText("Approve").assertExists()
compose.onNodeWithText("Deny").assertExists()
compose.runOnIdle { assertEquals(emptyList<String>(), actions) }
}
@Test
fun `only explicit labeled taps choose approve or deny`() {
val actions = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = approvalCard(),
cardKey = CARD_KEY,
dispatches = emptyList(),
onActionTap = { _, action -> actions += action.value },
onInputSubmit = { _, value -> actions += value },
)
}
}
compose.onNodeWithText("Approve").performTouchInput { click() }
compose.onNodeWithText("Deny").performTouchInput { click() }
compose.runOnIdle { assertEquals(listOf("once", "deny"), actions) }
}
private fun approvalCard() = HermesCard(
type = HermesCard.BuiltInTypes.ASK_APPROVAL,
title = "Approval requested",
fields = listOf(
com.hermesandroid.relay.data.HermesCardField(
label = "Command",
value = "A deliberately long command that must be read",
),
),
actions = listOf(
HermesCardAction(
label = "Approve",
value = "once",
style = HermesCardAction.Styles.PRIMARY,
mode = HermesCardAction.Modes.SUBMIT_ASK,
),
HermesCardAction(
label = "Deny",
value = "deny",
style = HermesCardAction.Styles.DANGER,
mode = HermesCardAction.Modes.SUBMIT_ASK,
),
),
id = CARD_KEY,
)
private companion object {
const val CARD_KEY = "approval-session-a"
}
}
@@ -336,6 +336,74 @@ class HermesPairingPayloadTest {
assertEquals("lan", parsed.endpoints.orEmpty()[0].role)
}
@Test
fun dashboardOnlyGenericJson_buildsStandardPayloadWithoutOptionalSurfaces() {
val raw = """
{
"dashboard_url": "https://agent.example.com/hermes/"
}
""".trimIndent()
val payload = parseHermesPairingQr(raw)
assertNotNull(payload)
val parsed = payload!!
assertFalse(parsed.hasApiServer)
assertEquals("", parsed.serverUrl)
assertEquals("https://agent.example.com/hermes", parsed.dashboardUrl)
assertNull(parsed.relay)
assertEquals("", parsed.key)
assertEquals(1, parsed.endpoints.orEmpty().size)
assertEquals("public", parsed.endpoints.orEmpty()[0].role)
assertNull(parsed.endpoints.orEmpty()[0].api)
assertNull(parsed.endpoints.orEmpty()[0].relay)
assertEquals(
"https://agent.example.com/hermes",
parsed.endpoints.orEmpty()[0].dashboard?.url,
)
}
@Test
fun dashboardOnlyGenericJson_acceptsCamelCaseAliasAndLanOrigin() {
val payload = parseHermesPairingQr(
"""{ "dashboardUrl": "http://192.168.1.50:9119" }""",
)
assertNotNull(payload)
assertEquals("lan", payload!!.endpoints.orEmpty().single().role)
assertEquals("http://192.168.1.50:9119", payload.dashboardUrl)
}
@Test
fun dashboardOnlyGenericJson_rejectsInvalidOriginsAndMalformedApiFallback() {
assertNull(parseHermesPairingQr("""{ "dashboard_url": "ftp://agent.example.com" }"""))
assertNull(parseHermesPairingQr("""{ "dashboard_url": "agent.example.com" }"""))
assertNull(
parseHermesPairingQr(
"""{
"api_url": "ftp://agent.example.com",
"dashboard_url": "https://agent.example.com"
}""".trimIndent(),
),
)
}
@Test
fun genericApiJson_withDashboard_stillUsesLegacyApiPayloadContract() {
val payload = parseHermesPairingQr(
"""{
"url": "https://api.example.com:8642",
"dashboard_url": "https://agent.example.com/hermes"
}""".trimIndent(),
)
assertNotNull(payload)
assertTrue(payload!!.hasApiServer)
assertEquals("https://api.example.com:8642", payload.serverUrl)
assertEquals("https://agent.example.com/hermes", payload.dashboardUrl)
assertEquals("api.example.com", payload.endpoints.orEmpty().single().api?.host)
}
@Test
fun missingHost_rejectsPayload() {
// The parser's minimum contract: a payload without `host` is not
@@ -78,4 +78,41 @@ class MessageBubbleActionTest {
assertNotEquals(initialLive, settledMarkdown)
assertNotEquals(settledMarkdown, revisedMarkdown)
}
@Test
fun completedAssistantSyntax_selectsMarkdownRendererWithoutChangingContent() {
val completedBodies = listOf(
"```kotlin\nval answer = 42\n```",
"- first\n- second",
"**bold** and *emphasis*",
"[Hermes](https://example.com)",
)
completedBodies.forEach { body ->
assertEquals(
MessageSelectionTopologyKey(renderer = "markdown", markdownBody = body),
messageSelectionTopologyKey(
isPlainText = false,
isStreaming = false,
retainStreamingLayout = false,
markdownBody = body,
),
)
}
}
@Test
fun interruptedPartialReply_selectsSettledMarkdownRenderer() {
val partialFence = "```kotlin\nval partial ="
assertEquals(
MessageSelectionTopologyKey(renderer = "markdown", markdownBody = partialFence),
messageSelectionTopologyKey(
isPlainText = false,
isStreaming = false,
retainStreamingLayout = false,
markdownBody = partialFence,
),
)
}
}
@@ -0,0 +1,91 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
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 MessageBubbleCompletionLayoutTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `completion does not resize a retained live tail`() {
val streaming = mutableStateOf(true)
val message = ChatMessage(
id = "assistant-live",
role = MessageRole.ASSISTANT,
content = "A reply whose final text keeps the live renderer.",
timestamp = 1_700_000_000_000L,
)
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
MessageBubble(
message = message.copy(isStreaming = streaming.value),
retainStreamingLayout = true,
modifier = Modifier.testTag("retained-live-tail"),
)
}
}
compose.waitForIdle()
val streamingHeight = compose.onNodeWithTag("retained-live-tail")
.fetchSemanticsNode().boundsInRoot.height
compose.runOnIdle { streaming.value = false }
compose.mainClock.advanceTimeBy(1_000L)
compose.waitForIdle()
val completedHeight = compose.onNodeWithTag("retained-live-tail")
.fetchSemanticsNode().boundsInRoot.height
assertEquals(streamingHeight, completedHeight, 0.01f)
}
@Test
fun `releasing a completed live tail renders markdown without navigation`() {
val streaming = mutableStateOf(true)
val retainStreamingLayout = mutableStateOf(true)
val message = ChatMessage(
id = "assistant-live",
role = MessageRole.ASSISTANT,
content = "**bold**",
timestamp = 1_700_000_000_000L,
)
compose.setContent {
MaterialTheme {
MessageBubble(
message = message.copy(isStreaming = streaming.value),
retainStreamingLayout = retainStreamingLayout.value,
modifier = Modifier.testTag("completion-tail"),
)
}
}
compose.onNodeWithText("**bold**").assertExists()
compose.runOnIdle {
streaming.value = false
retainStreamingLayout.value = false
}
compose.waitForIdle()
compose.onNodeWithText("**bold**").assertDoesNotExist()
compose.onNodeWithTag("completion-tail").assertExists()
}
}
@@ -0,0 +1,35 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ModelPickerIdentityTest {
@Test
fun distinctProviderSlugsDoNotCollapseWhenLabelsAndModelsMatch() {
val direct = option(provider = "deepseek")
val reseller = option(provider = "opencode-go")
assertNotEquals(modelPickerOptionKey(direct), modelPickerOptionKey(reseller))
assertNotEquals(
modelPickerHeaderKey(ModelPickerGroupIdentity("deepseek", "DeepSeek")),
modelPickerHeaderKey(ModelPickerGroupIdentity("opencode-go", "DeepSeek")),
)
}
@Test
fun duplicateExactDomainIdentityStillHasTheSameKey() {
val first = option(provider = "DeepSeek")
val duplicate = option(provider = " deepseek ")
assertEquals(modelPickerOptionKey(first), modelPickerOptionKey(duplicate))
}
private fun option(provider: String) = ChatInputPickerOption(
label = "deepseek-v4-flash",
value = "deepseek-v4-flash",
provider = provider,
group = "DeepSeek",
)
}
@@ -0,0 +1,42 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NousCloudDashboardAddressTest {
@Test
fun hostedSlugResolvesToCanonicalHttpsOrigin() {
assertEquals(
"https://demo-agent-3974.agents.nousresearch.com",
resolveNousCloudDashboardAddress("demo-agent-3974"),
)
}
@Test
fun pastedHostedHostnameDoesNotDuplicateSuffix() {
assertEquals(
"https://demo-agent-3974.agents.nousresearch.com",
resolveNousCloudDashboardAddress("demo-agent-3974.agents.nousresearch.com"),
)
}
@Test
fun explicitCustomUrlRemainsAuthoritative() {
assertEquals(
"https://agents.example.com/hermes",
resolveNousCloudDashboardAddress("https://agents.example.com/hermes"),
)
}
@Test
fun hostedSlugValidationRejectsUnsafeOrAmbiguousInputs() {
assertTrue(isValidNousCloudSlug("demo-agent-3974"))
assertTrue(isValidNousCloudAddressInput("demo-agent-3974.agents.nousresearch.com"))
assertFalse(isValidNousCloudSlug("https://example.com"))
assertFalse(isValidNousCloudSlug("agent.example.com"))
assertFalse(isValidNousCloudSlug("-agent"))
assertFalse(isValidNousCloudSlug("agent-"))
}
}
@@ -0,0 +1,16 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Test
class QrPairingScannerMotionTest {
@Test
fun regularMotionUsesBriefSuccessSettle() {
assertEquals(200L, scannerSettleDelayMs(motionEnabled = true))
}
@Test
fun reducedMotionCompletesWithoutAnimationDelay() {
assertEquals(0L, scannerSettleDelayMs(motionEnabled = false))
}
}
@@ -0,0 +1,95 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
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 SessionDrawerTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `unpinned action uses a distinct lighter outlined star`() {
assertNotEquals(sessionPinIcon(pinned = true), sessionPinIcon(pinned = false))
assertTrue(sessionPinIcon(pinned = true).name.contains("Star"))
assertTrue(sessionPinIcon(pinned = false).name.contains("StarBorder"))
assertEquals(0.45f, UNPINNED_STAR_ALPHA, 0.0f)
}
@Test
fun `archive filter resets when connection cannot restore archived sessions`() {
assertEquals(
SessionDrawerFilter.All,
resolveSessionDrawerFilter(
filter = SessionDrawerFilter.Archive,
showThreads = true,
archiveSupported = false,
),
)
}
@Test
fun `reordered leading session remains visible after drawer refresh`() {
var sessions by mutableStateOf(
List(15) { index ->
ChatSession(
sessionId = "session-$index",
title = "Session $index",
model = null,
lastActivityAt = 100L - index,
)
},
)
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = sessions,
currentSessionId = null,
isOpen = true,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithTag(SESSION_DRAWER_LIST_TAG)
.performScrollToNode(hasText("Session 14"))
compose.onNodeWithText("Session 14").assertIsDisplayed()
compose.runOnIdle {
sessions = sessions.map { session ->
if (session.sessionId == "session-10") {
session.copy(title = "Now latest", lastActivityAt = 1_000L)
} else {
session
}
}
}
compose.onNodeWithText("Now latest").assertIsDisplayed()
}
}
@@ -0,0 +1,102 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ToolCall
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 ToolProgressCardInteractionTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `completed card expands during an active session and survives updates`() {
var call by mutableStateOf(
ToolCall(
id = "completed-terminal",
name = "terminal",
args = "pwd",
result = "workspace",
success = true,
isComplete = true,
),
)
val expansionChanges = mutableListOf<Boolean>()
compose.setContent {
MaterialTheme {
ToolProgressCard(
toolCall = call,
onExpandedChange = { expansionChanges += it },
)
}
}
compose.onNodeWithText("workspace").assertDoesNotExist()
compose.onNodeWithContentDescription("Expand").performTouchInput { click() }
compose.onNodeWithText("workspace").assertExists()
compose.runOnIdle { call = call.copy(outputRisk = "high") }
compose.onNodeWithText("workspace").assertExists()
compose.runOnIdle { assertEquals(listOf(true), expansionChanges) }
}
@Test
fun `running card remains expanded when the live tool completes`() {
var call by mutableStateOf(
ToolCall(
id = "generating:running-skill",
name = "skill",
args = "inspect active session",
result = null,
success = null,
isComplete = false,
),
)
compose.setContent {
MaterialTheme { ToolProgressCard(toolCall = call) }
}
compose.onNodeWithText("inspect active session").assertExists()
compose.onNodeWithContentDescription("Collapse").performTouchInput { click() }
compose.onNodeWithText("inspect active session").assertDoesNotExist()
compose.onNodeWithContentDescription("Expand").performTouchInput { click() }
compose.onNodeWithText("inspect active session").assertExists()
// A live placeholder can adopt the real Gateway ID during
// reconciliation. Expansion belongs to the logical call, not the ID.
compose.runOnIdle { call = call.copy(id = "reconciled-running-skill") }
compose.onNodeWithText("inspect active session").assertExists()
compose.runOnIdle {
call = call.copy(
result = "skill completed",
success = true,
isComplete = true,
completedAt = System.currentTimeMillis(),
)
}
compose.onNodeWithText("inspect active session").assertExists()
compose.onNodeWithText("skill completed").assertExists()
}
}
@@ -0,0 +1,115 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.click
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.viewmodel.InteractionMode
import com.hermesandroid.relay.viewmodel.VoiceState
import com.hermesandroid.relay.viewmodel.VoiceUiState
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-xxhdpi")
class VoiceModeOverlayInteractionTest {
@get:Rule
val compose = createComposeRule()
@Test
fun focusMode_childControlsReceiveRealPointerClicks() {
var micTaps = 0
var dismissals = 0
val modeChanges = mutableListOf<InteractionMode>()
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
VoiceModeOverlay(
uiState = idleVoiceState(),
onMicTap = { micTaps += 1 },
onMicRelease = {},
onInterrupt = {},
onDismiss = { dismissals += 1 },
onModeChange = { modeChanges += it },
onClearError = {},
)
}
}
compose.onNodeWithContentDescription("Voice mic")
.performTouchInput { click() }
compose.onNodeWithContentDescription("Expand voice controls")
.performTouchInput { click() }
compose.mainClock.advanceTimeBy(500)
compose.onNodeWithText("Hold")
.performTouchInput { click() }
compose.onNodeWithContentDescription("Collapse voice controls")
.performTouchInput { click() }
compose.onNodeWithContentDescription("Exit voice mode")
.performTouchInput { click() }
compose.runOnIdle {
assertEquals(1, micTaps)
assertEquals(1, dismissals)
assertEquals(listOf(InteractionMode.HoldToTalk), modeChanges)
}
}
@Test
fun focusMode_emptySpaceDoesNotClickThroughToChat() {
var backgroundTaps = 0
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
Box(
Modifier
.fillMaxSize()
.testTag("backgroundChat")
.clickable { backgroundTaps += 1 },
)
VoiceModeOverlay(
uiState = idleVoiceState(),
onMicTap = {},
onMicRelease = {},
onInterrupt = {},
onDismiss = {},
onModeChange = {},
onClearError = {},
)
}
}
compose.onNodeWithTag(FOCUS_INPUT_SCRIM_TAG)
.performTouchInput { click(Offset(1f, center.y)) }
compose.runOnIdle { assertEquals(0, backgroundTaps) }
}
private fun idleVoiceState() = VoiceUiState(
voiceMode = true,
state = VoiceState.Idle,
interactionMode = InteractionMode.TapToTalk,
)
private companion object {
const val FOCUS_INPUT_SCRIM_TAG = "voiceFocusInputScrim"
}
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components.avatar
import androidx.compose.ui.unit.IntOffset
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -7,6 +8,53 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PetAvatarRuntimeTest {
@Test
fun `floating alignment removes transparent bottom padding while preserving horizontal stabilization`() {
assertEquals(
IntOffset(4, 11),
petContentAlignmentOffset(
width = 64,
height = 64,
minX = 13,
minY = 9,
maxX = 44,
maxY = 52,
stabilize = true,
groundOpaqueBottom = true,
),
)
}
@Test
fun `non-floating alignment retains centered preview behavior`() {
assertEquals(
IntOffset(4, 2),
petContentAlignmentOffset(
width = 64,
height = 64,
minX = 13,
minY = 9,
maxX = 44,
maxY = 52,
stabilize = true,
groundOpaqueBottom = false,
),
)
assertEquals(
IntOffset.Zero,
petContentAlignmentOffset(
width = 64,
height = 64,
minX = 13,
minY = 9,
maxX = 44,
maxY = 52,
stabilize = false,
groundOpaqueBottom = false,
),
)
}
@Test
fun `clip transition retains the previous complete visual until decode finishes`() {
assertEquals(
@@ -674,6 +674,36 @@ class PetRoamingGeometryTest {
assertEquals(180f, segments[1].top, 0f)
}
@Test
fun `scroll control envelope is excluded from every composer rail segment`() {
val outer = PetSafeBounds(0f, 0f, 400f, 400f)
val footprint = PetFootprint(width = 70f, height = 70f, clearance = 6f)
val composer = PetMeasuredPerch(
key = "chat-composer",
bounds = PetObstacle(0f, 260f, 400f, 340f),
)
val controlEnvelope = PetMeasuredObstacle(
key = "chat-scroll-to-bottom-obstacle",
bounds = PetObstacle(292f, 200f, 364f, 248f),
)
val segments = petPerchSegments(
perch = composer,
obstacles = listOf(controlEnvelope),
footprint = footprint,
outer = outer,
)
val expandedControl = controlEnvelope.bounds.expanded(
footprint.horizontalRadius,
footprint.verticalRadius,
)
assertTrue(segments.isNotEmpty())
segments.forEach { segment ->
assertFalse(segment.left < expandedControl.right && segment.right > expandedControl.left)
}
}
@Test
fun `sibling perch segments choose an above-control hop`() {
val safe = PetSafeBounds(20f, 20f, 280f, 220f)

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