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
95 changed files with 4896 additions and 797 deletions
+20
View File
@@ -11,6 +11,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **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
+71
View File
@@ -1,5 +1,76 @@
# 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
+5 -10
View File
@@ -1,22 +1,17 @@
# Hermes-Relay-Server v__VERSION__
**Release Date:** August 6, 2026
**Release Date:** August 8, 2026
This patch adds an optional Relay capability overlay so clients can present the reasoning effort levels supported by an exact provider and model without changing upstream model selection or chat behavior.
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.
## What's changed
### Added
### Fixed
- **Profile-aware reasoning capability resolution.** `POST /relay/model-capabilities` accepts exact provider/model pairs and returns ordered effort choices with explicit exactness and source metadata.
- **Bounded live discovery for local and authenticated providers.** Relay can query OpenAI Codex, GitHub Copilot, LM Studio, and Ollama Cloud capability surfaces with short timeouts, concurrency limits, isolated caches, and an explicit refresh option.
- **Capability advertisement.** `/relay/info` now advertises `model_reasoning_capabilities_v1` so clients can detect the optional overlay before using it.
### Security
- **Provider credentials remain host-local.** Remote requests require a paired session with the chat grant, and responses never serialize provider credentials.
- **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
+26 -32
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.7.0
# Hermes-Relay-Android v1.7.1
**Release Date:** August 6, 2026
**Release Date:** August 8, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.7.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.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,39 +12,33 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch makes model reasoning controls provider-aware, keeps active and
restored conversations stable, and adds private, actionable support information.
## Added
- Reasoning effort choices now follow the selected provider and model when an
exact list is available. Standard choices remain available as an advisory
fallback on unmodified Hermes or without the optional Relay capability overlay.
- The session drawer can search sessions and shows which conversations are
working or waiting for input.
- Diagnostics now provides a locally redacted support report that can be
reviewed before copy, share, or opening GitHub. Nothing uploads automatically.
This patch tightens Android chat ownership, interaction safety, session
durability, and hosted-Hermes onboarding after the 1.7.0 release.
## Fixed
- Restored and completed conversations remain at the exact bottom through late
message, composer, keyboard, and status-row layout changes without overriding
someone who intentionally scrolls up.
- Chat recovery keeps one stable rendered row through checkpoint restore,
streaming callbacks, server reconciliation, and replay, preventing recurring
duplicate-row crashes in Chat and Voice.
- Focus Voice controls receive taps again while the modal background continues
to block interaction with the chat behind it.
- Connection diagnostics identify the attempted route and operation, redact host
details, and give targeted guidance for DNS, timeout, TLS, authentication,
rate-limit, missing-route, and server failures.
- The session drawer dismisses the composer keyboard when opening, preserves the
newest row during refresh, and keeps floating pets on measured safe terrain.
- 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.7.0** (versionCode **39**).
- Standard Chat, model selection, and Vanilla Hermes voice continue to work
- App version: **1.7.1** (versionCode **40**).
- Standard Chat, sessions, Manage, and Vanilla Hermes voice continue to work
against unmodified upstream Hermes.
- Relay capability discovery is optional and fail-soft; unavailable or older
Relay installations retain the standard reasoning choices.
- The optional Relay plugin is not required for standard Android chat or hosted
Dashboard authentication.
@@ -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 @@
Smarter reasoning controls now match the selected provider and model, with standard choices when exact levels are unavailable. The session drawer shows working and needs-input chats. Restored and completed conversations stay bottom-pinned without overriding scrollback. Redacted local support information and clearer connection diagnostics make problems easier to resolve. Focus Voice controls and recovered chat rows are 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.
+12
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>
+28
View File
@@ -1,5 +1,33 @@
{
"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",
+6 -6
View File
@@ -1,7 +1,7 @@
v1.7.0 - Smarter controls, steadier sessions
v1.7.1 - Safer, steadier conversations
* Match reasoning levels to the selected provider and model, with a standard fallback.
* See working and needs-input activity in the searchable session drawer.
* Keep restored and completed chats bottom-pinned without overriding scrollback.
* Review redacted local support information and actionable connection guidance.
* Restore Focus Voice controls and stabilize recovered chat rows.
* 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 {
@@ -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
@@ -1122,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
@@ -1164,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(
@@ -1173,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))
}
}
}
/**
@@ -1426,10 +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
parseGatewayModelProvider(obj)
}
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") ?: "",
@@ -2141,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,
@@ -2163,6 +2189,7 @@ class GatewayChatClient(
unmatchedTurnCompleteListener?.invoke(
GatewayBackgroundTurnCompletion(
storedSessionId = backgroundTurn.storedSessionId,
liveSessionId = eventSessionId,
profile = backgroundTurn.profile,
expectedAssistantText = expectedText,
),
@@ -2237,6 +2264,7 @@ class GatewayChatClient(
unmatchedTurnCompleteListener?.invoke(
GatewayBackgroundTurnCompletion(
storedSessionId = storedId,
liveSessionId = eventSessionId,
profile = liveSessionProfile,
expectedAssistantText = expectedText,
),
@@ -2645,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
@@ -2974,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) } },
@@ -2988,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
@@ -173,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?,
)
@@ -189,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,
@@ -364,11 +366,14 @@ internal fun parseGatewayModelProvider(obj: JsonObject): GatewayModelProvider? {
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 }
effortsElement
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotEmpty) }
.distinct()
} else {
null
}
model to GatewayModelCapabilities(
val modelId = model.trim().takeIf { it.isNotEmpty() } ?: return@mapNotNull null
modelId to GatewayModelCapabilities(
reasoning = (row["reasoning"] as? JsonPrimitive)?.booleanOrNull,
reasoningEfforts = efforts,
reasoningEffortsExact =
@@ -379,18 +384,92 @@ internal fun parseGatewayModelProvider(obj: JsonObject): GatewayModelProvider? {
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
slug = slug,
models = (obj["models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
.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 },
.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?,
@@ -488,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. */
@@ -521,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,10 +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
parseGatewayModelProvider(obj)
}
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(),
@@ -323,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
@@ -682,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")
@@ -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("'")
}
}
}
@@ -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
@@ -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()
@@ -846,6 +850,7 @@ fun AgentInfoSheet(
apiModelOptions,
agentProfiles,
selectedModel,
selectedProvider,
modelLabel,
unavailableModelLabel,
modelNeedsSetupLabel,
@@ -886,7 +891,8 @@ fun AgentInfoSheet(
provider.warning ?: modelNeedsSetupLabel
else -> null
},
selected = selectedModel == model,
selected = selectedModel == model &&
selectedProvider.equals(provider.slug, ignoreCase = true),
enabled = !unavailable,
),
)
@@ -926,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,
@@ -1164,7 +1152,6 @@ fun AgentInfoSheet(
)
}
}
}
}
when (activePicker) {
@@ -1300,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,
@@ -1751,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,
@@ -1766,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),
@@ -1811,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) },
)
}
@@ -1833,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) },
)
}
@@ -1853,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) },
)
}
@@ -1873,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),
)
}
}
}
@@ -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(
@@ -88,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 = {},
@@ -540,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,
@@ -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,
@@ -41,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
@@ -88,7 +89,7 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private enum class SessionDrawerFilter {
internal enum class SessionDrawerFilter {
All,
Threads,
Pinned,
@@ -96,6 +97,20 @@ private enum class SessionDrawerFilter {
}
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(
@@ -108,11 +123,14 @@ fun SessionDrawerContent(
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
@@ -139,19 +157,13 @@ fun SessionDrawerContent(
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()
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
@@ -163,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 ->
@@ -188,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) }
@@ -376,7 +387,10 @@ fun SessionDrawerContent(
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,
@@ -496,22 +510,15 @@ fun SessionDrawerContent(
isActive = session.sessionId == currentSessionId,
activityState = activityStates[session.sessionId],
animationEnabled = animationEnabled,
pinned = session.sessionId in pinnedSessionIds,
archived = session.sessionId in archivedSessionIds,
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) },
@@ -623,6 +630,7 @@ private fun SessionItem(
animationEnabled: Boolean,
pinned: Boolean,
archived: Boolean,
archiveSupported: Boolean,
onClick: () -> Unit,
onTogglePinned: () -> Unit,
onToggleArchived: () -> Unit,
@@ -770,12 +778,12 @@ private fun SessionItem(
},
leadingIcon = {
Icon(
Icons.Filled.Star,
sessionPinIcon(pinned),
contentDescription = null,
tint = if (pinned) {
RelayRefresh.Amber
} else {
MaterialTheme.colorScheme.onSurfaceVariant
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = UNPINNED_STAR_ALPHA)
},
)
},
@@ -804,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(
@@ -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
@@ -318,12 +318,20 @@ internal data class ChatScrollSnapshot(
val isStreaming: Boolean
)
internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?): Boolean =
previous?.isStreaming == true &&
!isStreaming &&
previous.messageCount == messageCount &&
previous.lastMessageUiKey == lastMessageUiKey
internal fun retainedLiveTailAfterTransition(
retainedUiKey: String?,
streamStarted: Boolean,
streamCompleted: Boolean,
lastMessageUiKey: String?,
): String? = when {
streamStarted -> lastMessageUiKey
streamCompleted && retainedUiKey == lastMessageUiKey -> null
retainedUiKey != null && retainedUiKey != lastMessageUiKey -> null
else -> retainedUiKey
}
@@ -747,6 +755,7 @@ fun ChatScreen(
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(
@@ -1657,12 +1666,14 @@ fun ChatScreen(
)
val tailTransitionRef = remember(currentSessionId) { ChatTailTransitionRef() }
// A live tail owns its stable renderer until another row becomes the tail.
// Completion is deliberately not a structural transition: changing the
// renderer or list anchor at that boundary caused a visible scroll jump.
// 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 streamCompleted = tailTransition.isCompletionAfter(previous)
val tailStructureChanged = tailTransition.lastMessageUiKey != null &&
(previous == null ||
previous.messageCount != tailTransition.messageCount ||
@@ -1677,13 +1688,14 @@ fun ChatScreen(
retainedLiveTailUiKey = retainedLiveTailAfterTransition(
retainedUiKey = retainedLiveTailUiKey,
streamStarted = streamStarted,
streamCompleted = streamCompleted,
lastMessageUiKey = tailTransition.lastMessageUiKey,
)
val shouldAnchor = smoothAutoScroll &&
!isUserDragging &&
(!userScrolledAway || streamStarted) &&
(streamStarted || tailStructureChanged)
(streamStarted || streamCompleted || tailStructureChanged)
if (shouldAnchor) {
listState.requestScrollToItem(tailTransition.messageCount + 1)
}
@@ -1700,10 +1712,14 @@ fun ChatScreen(
// 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 }
@@ -1740,7 +1756,7 @@ fun ChatScreen(
atExactBottom = listState.isAtConversationBottom(0),
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
isStreaming = messages.lastOrNull()?.isStreaming == true,
isStreaming = latestMessages.value.lastOrNull()?.isStreaming == true,
smoothAutoScroll = smoothAutoScroll,
viewportFollowAllowed = current.followViewportResize,
)
@@ -1764,8 +1780,8 @@ fun ChatScreen(
}
}
// Completion keeps the stable live renderer, then settles the owned
// transcript to the exact boundary after its final layout pass.
// 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) {
@@ -1870,6 +1886,7 @@ fun ChatScreen(
activityStates = sessionActivityStates,
animationEnabled = animationEnabled,
autoTitlesSupported = serverAutoTitles,
archiveSupported = sessionArchivingSupported,
onRefresh = { chatViewModel.refreshSessions() },
onNewChat = {
chatViewModel.createNewChat()
@@ -1885,6 +1902,8 @@ fun ChatScreen(
onRenameSession = { sessionId, title ->
chatViewModel.renameSession(sessionId, title)
},
onSetSessionPinned = chatViewModel::setSessionPinned,
onSetSessionArchived = chatViewModel::setSessionArchived,
onCopySessionId = { sessionId ->
scope.launch {
clipboard.setClipEntry(
@@ -2866,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
}
},
)
}
}
@@ -3278,6 +3306,7 @@ fun ChatScreen(
modelProviders,
sseModelOptions,
selectedModelOverride,
selectedProviderOverride,
gatewayCurrentModel,
fallbackModelDetail,
serverDefaultModelDetail,
@@ -3317,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,
),
)
@@ -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(
@@ -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,
)
}
}
}
}
@@ -13,6 +13,7 @@ import com.hermesandroid.relay.data.AttachmentState
import com.hermesandroid.relay.data.BackgroundTaskPhase
import com.hermesandroid.relay.data.BackgroundTaskState
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.ChatQueuedMessageCheckpoint
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.ChatTurnAskCheckpoint
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
@@ -212,6 +213,8 @@ class ChatViewModel : ViewModel() {
/** Durable UI checkpoints for recoverable, session-backed turns. */
private var chatTurnCheckpointStore: ChatTurnCheckpointStore? = null
private var activeTurnCheckpointSeed: ActiveTurnCheckpointSeed? = null
/** Stable local run owner for queued sends, including unsolicited Gateway turns. */
private var activeQueueOwnerRunId: String? = null
private data class TurnCheckpointKey(val contextKey: String, val sessionId: String)
private val backgroundTurnCheckpoints =
ConcurrentHashMap<TurnCheckpointKey, ChatTurnCheckpoint>()
@@ -478,6 +481,25 @@ class ChatViewModel : ViewModel() {
}
// --- Message queue ---
/**
* A queued send is owned by the exact turn it was composed behind. Keeping
* only its text here used to let whichever session happened to be visible
* at completion drain it (issue #315). The destination and all composer
* payload are therefore captured once and never inferred at dispatch time.
*/
private data class QueuedMessage(
val id: String,
val text: String,
val contextKey: String,
val sessionId: String,
val transport: String,
val ownerRunId: String,
val attachments: List<Attachment>,
val interfaceContextPrompt: String?,
)
private val queuedMessageItems = mutableListOf<QueuedMessage>()
private val completedQueueOwnerRuns = ConcurrentHashMap.newKeySet<String>()
private val _queuedMessages = MutableStateFlow<List<String>>(emptyList())
val queuedMessages: StateFlow<List<String>> = _queuedMessages.asStateFlow()
@@ -1482,6 +1504,8 @@ class ChatViewModel : ViewModel() {
* "chats aren't auto-named here" hint in the session drawer.
*/
val serverAutoTitles: StateFlow<Boolean> = _serverAutoTitles.asStateFlow()
private val _sessionArchivingSupported = MutableStateFlow(false)
val sessionArchivingSupported: StateFlow<Boolean> = _sessionArchivingSupported.asStateFlow()
/**
* Streaming endpoint to use for the next chat turn. Always one of
@@ -1503,6 +1527,10 @@ class ChatViewModel : ViewModel() {
// and the drawer note. Mirror the capability so the UI can explain
// why chats stay untitled on those transports (issue #133).
_serverAutoTitles.value = value == "gateway"
// Current Dashboard lists archived rows explicitly; the API-server
// list does not, so enabling archive there would make restore
// disappear after process recreation.
_sessionArchivingSupported.value = value == "gateway"
}
/**
@@ -1630,7 +1658,7 @@ class ChatViewModel : ViewModel() {
}
maybeNotifyInteraction(event.storedSessionId, event.ask, event.profile)
}
is GatewayBackgroundInteractionEvent.Resolved -> {
is GatewayBackgroundInteractionEvent.Expired -> {
if (key != null) {
backgroundNeedsInputKeys -= key
backgroundPendingInteractions.computeIfPresent(key) { _, current ->
@@ -1706,10 +1734,14 @@ class ChatViewModel : ViewModel() {
private fun settleBackgroundTurnCheckpoint(completion: GatewayBackgroundTurnCompletion) {
val profileKey = AgentDisplay.profileSessionKey(completion.profile)
val matching = backgroundTurnCheckpoints.keys.filter { key ->
val checkpoint = backgroundTurnCheckpoints[key]
key.sessionId == completion.storedSessionId &&
key.contextKey.substringAfterLast("::") == profileKey
key.contextKey.substringAfterLast("::") == profileKey &&
checkpoint?.liveSessionId == completion.liveSessionId
}
if (matching.isEmpty()) return
matching.mapNotNull(backgroundTurnCheckpoints::get)
.mapTo(completedQueueOwnerRuns) { it.user.id }
matching.forEach(backgroundTurnCheckpoints::remove)
matching.forEach { key ->
backgroundNeedsInputKeys -= key
@@ -1726,6 +1758,7 @@ class ChatViewModel : ViewModel() {
}
}
}
drainQueue()
}
/**
@@ -1770,6 +1803,7 @@ class ChatViewModel : ViewModel() {
}
accepted = false
boundHandle = null
activeQueueOwnerRunId = null
}
val callbacks = GatewayTurnCallbacks(
@@ -1805,8 +1839,10 @@ class ChatViewModel : ViewModel() {
onThinkingDelta = { delta ->
if (acceptsEvent()) handler.onThinkingDelta(messageId, delta)
},
onToolCallStart = { toolCallId, toolName ->
if (acceptsEvent()) handler.onToolCallStart(messageId, toolCallId, toolName)
onToolCallStart = { toolCallId, toolName, argsPreview ->
if (acceptsEvent()) {
handler.onToolCallStart(messageId, toolCallId, toolName, argsPreview)
}
},
onToolCallDone = { toolCallId, preview ->
if (acceptsEvent()) handler.onToolCallComplete(messageId, toolCallId, preview)
@@ -1872,8 +1908,7 @@ class ChatViewModel : ViewModel() {
handler.onStreamError(message)
emitError(Exception(message), context = "send_message")
settleBoundTurnState()
_queuedMessages.value = emptyList()
clearPendingAsk(approvalStamp = "deny")
clearQueue()
scheduleGatewayHistoryReconcile(
storedSessionId = storedSessionId,
baselineAssistantCount = baselineAssistantCount,
@@ -1897,9 +1932,6 @@ class ChatViewModel : ViewModel() {
onInteractionExpired = { expiry ->
if (acceptsEvent()) expirePendingAsk(expiry)
},
onInteractionResolved = { resolved ->
if (acceptsEvent()) resolvePendingAsk(resolved)
},
onStatusUpdate = { kind, text ->
if (acceptsEvent()) {
handler.setTurnStatus(text, kind)
@@ -1948,6 +1980,7 @@ class ChatViewModel : ViewModel() {
accepted = true
activeStream = handle
activeStreamIsGateway = true
activeQueueOwnerRunId = "gateway-inbound:$messageId"
_steerableTurn.value = true
queuedRecovery?.let { handoff ->
val now = System.currentTimeMillis()
@@ -2159,7 +2192,8 @@ class ChatViewModel : ViewModel() {
* The live server-side interactive ask (clarify/approval/sudo/secret).
* One at a time — the upstream agent thread blocks until the answer
* arrives, so a second ask can't exist while the first is pending.
* Cleared on answer, turn end, cancel, and connection switch.
* Cleared only by an explicit answer, authoritative expiry, or explicit
* interrupt. Navigation and unrelated turn events must preserve it.
*/
private val _pendingAsk = MutableStateFlow<PendingAsk?>(null)
val pendingAsk: StateFlow<PendingAsk?> = _pendingAsk.asStateFlow()
@@ -2410,6 +2444,13 @@ class ChatViewModel : ViewModel() {
*/
var profileSessionRenamer: (suspend (String, String) -> Boolean)? = null
/** Profile-scoped durable session metadata writers; never cross to the shared API DB. */
var profileSessionPinner: (suspend (String, Boolean, String?) -> Boolean)? = null
var profileSessionArchiver: (suspend (String, Boolean, String?) -> Boolean)? = null
private val sessionFlagMutationRevisions = mutableMapOf<String, Int>()
private val sessionFlagMutationLocks = ConcurrentHashMap<String, Mutex>()
/**
* Loads a session's transcript scoped to the active profile (dashboard
* `/api/sessions/{id}/messages?profile=`). Twin of [profileSessionLister]:
@@ -3000,14 +3041,20 @@ class ChatViewModel : ViewModel() {
connectionSwitchJob?.cancel()
connectionSwitchJob = viewModelScope.launch {
events.collect { newConnectionId ->
// A connection navigation must detach a Gateway turn, never
// interrupt it: session.interrupt force-denies approvals.
// Persist the exact profile/session checkpoint before the old
// client is replaced so an unresolved ask remains recoverable.
chatHandler?.let(::releaseTurnForNavigation) ?: run {
intentionallyCancelled = true
activeStream?.cancel()
activeStream = null
}
clearTurnCheckpoint()
historyLoadGeneration.incrementAndGet()
sessionRefreshGeneration.incrementAndGet()
intentionallyCancelled = true
activeStreamDeltas?.discard()
activeStreamDeltas = null
activeStream?.cancel()
activeStream = null
cancelAnswerRecovery(settleUi = false)
sessionRefreshJob?.cancel()
_isLoadingSessions.value = false
@@ -3016,7 +3063,9 @@ class ChatViewModel : ViewModel() {
relayReasoningCapabilities.value = emptyMap()
_reasoningCapabilityRevision.value += 1L
publishBackgroundSessionActivity()
_queuedMessages.value = emptyList()
queuedMessageItems.clear()
completedQueueOwnerRuns.clear()
publishQueuedMessages()
_pendingAttachments.value = emptyList()
_steerableTurn.value = false
_steerNotice.value = null
@@ -3059,6 +3108,7 @@ class ChatViewModel : ViewModel() {
activeProfileContextKey == contextKey &&
handler.currentSessionId.value == sessionId
) {
publishQueuedMessages()
_initialChatSettled.value = true
return
}
@@ -3074,6 +3124,7 @@ class ChatViewModel : ViewModel() {
activeTurnCheckpointSeed?.contextKey = contextKey
scheduleCheckpointWrite(immediate = true)
selectBackgroundProcessSession(sessionId, contextKey)
publishQueuedMessages()
_initialChatSettled.value = true
return
}
@@ -3090,7 +3141,6 @@ class ChatViewModel : ViewModel() {
activeProfileContextKey = contextKey
refreshRelayReasoningCapabilities()
publishBackgroundSessionActivity()
_queuedMessages.value = emptyList()
_pendingAttachments.value = emptyList()
_steerableTurn.value = false
_steerNotice.value = null
@@ -3102,6 +3152,7 @@ class ChatViewModel : ViewModel() {
_fastEnabled.value = null
resetApprovalModeState()
pendingYolo = null
publishQueuedMessages()
// SSE profile switch: reset the reasoning chip to unknown (a sessionless
// config.get reads the wrong profile's effort) and the personality to
// default so composeInjectedContext can't inject the previous profile's
@@ -3120,6 +3171,7 @@ class ChatViewModel : ViewModel() {
pendingTruncateOrdinal = null
handler.clearSessions()
handler.setSessionId(sessionId)
publishQueuedMessages()
selectBackgroundProcessSession(sessionId, contextKey)
if (sessionId != null) {
onSessionChanged?.invoke(sessionId)
@@ -3509,6 +3561,7 @@ class ChatViewModel : ViewModel() {
val loadGeneration = historyLoadGeneration.incrementAndGet()
handler.setSessionId(sessionId)
publishQueuedMessages()
if (streamingEndpoint != "gateway") {
val session = handler.sessions.value.firstOrNull { it.sessionId == sessionId }
if (session?.hasModelConfig == true && !session.model.isNullOrBlank()) {
@@ -3557,6 +3610,7 @@ class ChatViewModel : ViewModel() {
}
if (historyLoadGeneration.get() == loadGeneration) {
_isLoadingHistory.value = false
drainQueue()
}
}
}
@@ -3576,6 +3630,16 @@ class ChatViewModel : ViewModel() {
// Save reference before removing (for rollback on failure)
val removedSession = handler.sessions.value.find { it.sessionId == sessionId }
val removedQueued = removeQueuedMessagesFor(activeProfileContextKey, sessionId)
if (removedQueued > 0) {
_transientNotice.tryEmit(
if (removedQueued == 1) {
"A queued message was canceled because its chat was deleted."
} else {
"$removedQueued queued messages were canceled because their chat was deleted."
},
)
}
// Optimistic removal
handler.removeSession(sessionId)
@@ -3648,6 +3712,85 @@ class ChatViewModel : ViewModel() {
}
}
fun setSessionPinned(sessionId: String, pinned: Boolean) {
val expectedContextKey = activeProfileContextKey
mutateSessionFlag(
sessionId = sessionId,
target = pinned,
localUpdate = { handler, value ->
handler.setSessionFlagsLocal(sessionId, pinned = value)
},
profileWrite = {
profileSessionPinner?.invoke(sessionId, pinned, expectedContextKey) ?: false
},
apiWrite = { apiClient?.setSessionPinned(sessionId, pinned) == true },
errorContext = "pin_profile_session",
)
}
fun setSessionArchived(sessionId: String, archived: Boolean) {
if (!_sessionArchivingSupported.value) {
emitError(
UnsupportedOperationException("Archive and restore require Dashboard sessions"),
context = "archive_session_unsupported",
)
return
}
val expectedContextKey = activeProfileContextKey
mutateSessionFlag(
sessionId = sessionId,
target = archived,
localUpdate = { handler, value ->
handler.setSessionFlagsLocal(sessionId, archived = value)
},
profileWrite = {
profileSessionArchiver?.invoke(sessionId, archived, expectedContextKey) ?: false
},
apiWrite = { apiClient?.setSessionArchived(sessionId, archived) == true },
errorContext = "archive_profile_session",
)
}
private fun mutateSessionFlag(
sessionId: String,
target: Boolean,
localUpdate: (ChatHandler, Boolean) -> Unit,
profileWrite: suspend () -> Boolean,
apiWrite: suspend () -> Boolean,
errorContext: String,
) {
val handler = chatHandler ?: return
if (streamingEndpoint != "gateway" && apiClient == null) return
val generation = historyLoadGeneration.get()
val revisionKey = "$errorContext:$sessionId"
val revision = (sessionFlagMutationRevisions[revisionKey] ?: 0) + 1
sessionFlagMutationRevisions[revisionKey] = revision
localUpdate(handler, target)
viewModelScope.launch {
val success = sessionFlagMutationLocks
.getOrPut(revisionKey) { Mutex() }
.withLock {
if (historyLoadGeneration.get() != generation) return@withLock false
if (streamingEndpoint == "gateway") profileWrite() else apiWrite()
}
// A connection/profile transition owns a different session namespace.
// Never apply completion or rollback state to a newly selected scope.
if (historyLoadGeneration.get() != generation) return@launch
// A newer tap owns the visible state. Its server write and refresh
// will reconcile the row, so an older completion must not roll it back.
if (sessionFlagMutationRevisions[revisionKey] != revision) return@launch
if (success) {
refreshSessions()
} else {
localUpdate(handler, !target)
emitError(
IllegalStateException("Server rejected the session metadata update"),
context = errorContext,
)
}
}
}
// --- Message sending ---
fun sendMessage(text: String) {
@@ -3694,7 +3837,7 @@ class ChatViewModel : ViewModel() {
if (_steerableTurn.value && streamingEndpoint == "gateway") {
steerActiveTurn(text.trim())
} else {
_queuedMessages.update { it + text.trim() }
enqueueMessage(text.trim())
}
return
}
@@ -3712,7 +3855,7 @@ class ChatViewModel : ViewModel() {
val handler = chatHandler ?: return
val gateway = gatewayClient
if (gateway == null) {
_queuedMessages.update { it + text }
enqueueMessage(text)
return
}
viewModelScope.launch {
@@ -3729,7 +3872,7 @@ class ChatViewModel : ViewModel() {
}
SteerResult.Rejected, SteerResult.Failed -> {
if (activeStream != null) {
_queuedMessages.update { it + text }
enqueueMessage(text)
_steerNotice.value = "Queued — sends after this turn"
} else {
// Turn ended while the correction RPC was in flight —
@@ -3860,9 +4003,7 @@ class ChatViewModel : ViewModel() {
private fun dismissPendingAskNotification() {
val pending = _pendingAsk.value ?: return
chatHandler?.currentSessionId?.value?.let {
cancelInteractionNotification(it, pending.ask)
}
pending.sessionId?.let { cancelInteractionNotification(it, pending.ask) }
}
/**
@@ -3878,8 +4019,11 @@ class ChatViewModel : ViewModel() {
restored: ChatTurnAskCheckpoint? = null,
) {
val sessionId = handler.currentSessionId.value
val contextKey = activeProfileContextKey
val existing = _pendingAsk.value
if (existing != null &&
existing.contextKey == contextKey &&
existing.sessionId == sessionId &&
existing.ask.kind == ask.kind &&
existing.ask.requestId == ask.requestId
) {
@@ -3887,7 +4031,7 @@ class ChatViewModel : ViewModel() {
return
}
existing?.let { pending ->
sessionId?.let { cancelInteractionNotification(it, pending.ask) }
pending.sessionId?.let { cancelInteractionNotification(it, pending.ask) }
}
val activeKey = activeTurnCheckpointKey()
if (activeKey != null) {
@@ -3992,6 +4136,8 @@ class ChatViewModel : ViewModel() {
ask = ask,
messageId = messageId,
cardKey = cardKey,
contextKey = contextKey,
sessionId = sessionId,
receivedAt = now,
)
activeKey?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), true) }
@@ -4045,7 +4191,11 @@ class ChatViewModel : ViewModel() {
fun answerAsk(messageId: String, cardKey: String, value: String) {
val handler = chatHandler ?: return
val pending = _pendingAsk.value
if (pending == null || pending.cardKey != cardKey) {
if (pending == null ||
pending.cardKey != cardKey ||
pending.contextKey != activeProfileContextKey ||
pending.sessionId != handler.currentSessionId.value
) {
handler.addSystemNotice("This request is no longer active.")
return
}
@@ -4095,9 +4245,7 @@ class ChatViewModel : ViewModel() {
// must leave the card answerable for a retry.
handler.recordCardDispatch(pending.messageId, cardKey, stampValue)
if (_pendingAsk.value === pending) {
handler.currentSessionId.value?.let {
cancelInteractionNotification(it, pending.ask)
}
pending.sessionId?.let { cancelInteractionNotification(it, pending.ask) }
_pendingAsk.value = null
activeTurnCheckpointKey()?.let {
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
@@ -4126,9 +4274,7 @@ class ChatViewModel : ViewModel() {
val requestId = expiry.requestId?.takeIf { it.isNotBlank() } ?: return
if (pending.ask.requestId != requestId) return
}
chatHandler?.currentSessionId?.value?.let {
cancelInteractionNotification(it, pending.ask)
}
pending.sessionId?.let { cancelInteractionNotification(it, pending.ask) }
_pendingAsk.value = null
activeTurnCheckpointKey()?.let {
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
@@ -4142,36 +4288,22 @@ class ChatViewModel : ViewModel() {
)
}
private fun resolvePendingAsk(resolution: GatewayAskExpiry) {
val pending = _pendingAsk.value ?: return
if (pending.ask.kind != resolution.kind) return
if (resolution.kind != GatewayAsk.Kind.APPROVAL &&
pending.ask.requestId != resolution.requestId
) {
return
}
clearPendingAsk(approvalStamp = "Resolved")
}
/**
* Drop pending-ask UI state when the turn is torn down, stamping a
* still-open approval card with [approvalStamp] so its buttons don't
* outlive the ask — "deny" after an interrupt (`session.interrupt`
* force-denies server-side), "Resolved" when the turn completed without
* an answer. Timed asks self-collapse via their countdown.
* Drop pending-ask UI state after an explicit interrupt, stamping a
* still-open approval card as denied because `session.interrupt`
* force-denies server-side. Timed asks self-collapse only from
* authoritative expiry.
*/
private fun clearPendingAsk(approvalStamp: String) {
private fun clearPendingAskAfterInterrupt() {
val pending = _pendingAsk.value ?: return
chatHandler?.currentSessionId?.value?.let {
cancelInteractionNotification(it, pending.ask)
}
pending.sessionId?.let { cancelInteractionNotification(it, pending.ask) }
_pendingAsk.value = null
activeTurnCheckpointKey()?.let {
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
}
scheduleCheckpointWrite(immediate = true)
if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, approvalStamp)
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, "deny")
}
}
@@ -4586,6 +4718,7 @@ class ChatViewModel : ViewModel() {
},
startedAt = assistantTimestamp,
)
activeQueueOwnerRunId = userMessageId
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.acquire(it.keepAliveKey()) }
// Remove a prior turn for THIS session immediately. Detached sibling
// turns retain their own durable checkpoints while this chat starts a
@@ -4622,6 +4755,8 @@ class ChatViewModel : ViewModel() {
baselineAssistantCount = checkpoint.baselineAssistantCount,
startedAt = checkpoint.startedAt,
)
activeQueueOwnerRunId = checkpoint.user.id
restoreQueuedMessages(checkpoint)
activeTurnCheckpointKey()?.let { key ->
ActiveTurnKeepAliveRegistry.acquire(key.keepAliveKey())
ActiveTurnKeepAliveRegistry.setWaiting(key.keepAliveKey(), checkpoint.pendingAsk != null)
@@ -4756,11 +4891,54 @@ class ChatViewModel : ViewModel() {
receivedAt = ask.receivedAt,
)
},
queuedMessages = queuedMessageItems
.filter {
it.contextKey == contextKey &&
it.sessionId == sessionId &&
it.ownerRunId == seed.userMessageId
}
.map { item ->
ChatQueuedMessageCheckpoint(
id = item.id,
text = item.text.take(MAX_CHECKPOINT_TEXT_CHARS),
transport = item.transport,
ownerRunId = item.ownerRunId,
interfaceContextPrompt = item.interfaceContextPrompt,
hadAttachments = item.attachments.isNotEmpty(),
)
},
startedAt = seed.startedAt,
updatedAt = now,
)
}
private fun restoreQueuedMessages(checkpoint: ChatTurnCheckpoint) {
var droppedAttachmentQueue = false
checkpoint.queuedMessages.forEach { saved ->
if (queuedMessageItems.any { it.id == saved.id }) return@forEach
if (saved.hadAttachments) {
droppedAttachmentQueue = true
return@forEach
}
queuedMessageItems += QueuedMessage(
id = saved.id,
text = saved.text,
contextKey = checkpoint.contextKey,
sessionId = checkpoint.sessionId,
transport = saved.transport,
ownerRunId = saved.ownerRunId,
attachments = emptyList(),
interfaceContextPrompt = saved.interfaceContextPrompt,
)
}
publishQueuedMessages()
if (droppedAttachmentQueue) {
_transientNotice.tryEmit(
"A queued attachment message could not be restored. Review the attachment and send it again.",
)
}
}
private fun scheduleCheckpointWrite(immediate: Boolean = false) {
val store = chatTurnCheckpointStore ?: return
if (activeTurnCheckpointSeed == null) return
@@ -4792,6 +4970,7 @@ class ChatViewModel : ViewModel() {
seed.contextKey?.let { TurnCheckpointKey(it, seed.sessionId) }
}
activeTurnCheckpointSeed = null
activeQueueOwnerRunId = null
val generation = checkpointGeneration.incrementAndGet()
checkpointWriteJob?.cancel()
checkpointWriteJob = null
@@ -4815,6 +4994,7 @@ class ChatViewModel : ViewModel() {
/** Drop visible ownership while preserving a detached sibling's checkpoint. */
private fun releaseActiveTurnCheckpoint() {
activeTurnCheckpointSeed = null
activeQueueOwnerRunId = null
checkpointGeneration.incrementAndGet()
checkpointWriteJob?.cancel()
checkpointWriteJob = null
@@ -4854,6 +5034,9 @@ class ChatViewModel : ViewModel() {
return
}
handler.currentSessionId.value?.let { sessionId ->
removeQueuedMessagesFor(activeProfileContextKey, sessionId)
}
clearTurnCheckpoint()
intentionallyCancelled = true
activeStream?.cancel()
@@ -5074,9 +5257,9 @@ class ChatViewModel : ViewModel() {
onThinkingDelta = { delta ->
if (owns()) handler.onThinkingDelta(messageId, delta)
},
onToolCallStart = { toolCallId, toolName ->
onToolCallStart = { toolCallId, toolName, argsPreview ->
if (owns()) {
handler.onToolCallStart(messageId, toolCallId, toolName)
handler.onToolCallStart(messageId, toolCallId, toolName, argsPreview)
scheduleCheckpointWrite(immediate = true)
}
},
@@ -5152,7 +5335,6 @@ class ChatViewModel : ViewModel() {
activeStream = null
_steerableTurn.value = false
_steerNotice.value = null
clearPendingAsk(approvalStamp = "Resolved")
} else {
activeStream = null
_steerableTurn.value = false
@@ -5183,9 +5365,6 @@ class ChatViewModel : ViewModel() {
onInteractionExpired = { expiry ->
if (owns()) expirePendingAsk(expiry)
},
onInteractionResolved = { resolved ->
if (owns()) resolvePendingAsk(resolved)
},
onStatusUpdate = { kind, text ->
if (owns()) {
handler.setTurnStatus(text, kind)
@@ -5269,8 +5448,7 @@ class ChatViewModel : ViewModel() {
handler.onStreamError(message)
clearTurnCheckpoint()
emitError(Exception(message), context = "send_message")
_queuedMessages.value = emptyList()
clearPendingAsk(approvalStamp = "Resolved")
removeQueuedMessagesForOwner(checkpoint.user.id)
}
},
)
@@ -5283,16 +5461,18 @@ class ChatViewModel : ViewModel() {
* finalizes with exactly the completion semantics.
*/
private fun finalizeTurnSideEffects(handler: ChatHandler, messageId: String) {
val completedOwner = activeQueueOwnerRunId
handler.onStreamComplete(messageId)
if (completedOwner != null && queuedMessageItems.any { it.ownerRunId == completedOwner }) {
completedQueueOwnerRuns += completedOwner
}
clearTurnCheckpoint()
activeStream = null
_steerableTurn.value = false
_steerNotice.value = null
// Turn over — any blocked ask has been resolved server-side
// (answer, timeout, or interrupt). Timed cards self-collapse;
// an unanswered approval gets a neutral "Resolved" stamp so its
// buttons don't dead-end in "no longer active" notices.
clearPendingAsk(approvalStamp = "Resolved")
// A terminal turn event is not proof of a user's decision. Keep any
// unanswered card intact; only a labeled response, authoritative
// expiry, or explicit interrupt owns its transition.
// Notify when the turn finished while the app is backgrounded —
// never for cancelled streams; errors end via onErrorCb instead.
@@ -5315,7 +5495,6 @@ class ChatViewModel : ViewModel() {
activeStream = null
_steerableTurn.value = false
_steerNotice.value = null
clearPendingAsk(approvalStamp = "Resolved")
AppAnalytics.onStreamError()
}
@@ -5461,25 +5640,102 @@ class ChatViewModel : ViewModel() {
AppAnalytics.onStreamError()
handler.onStreamError(message)
emitError(Exception(message), context = "send_message")
_queuedMessages.value = emptyList()
// Parity with the sibling stream-error branch: the turn is
// over server-side, so force-deny any still-blocked approval
// card instead of leaving its buttons dead-ended.
clearPendingAsk(approvalStamp = "deny")
clearQueue()
}
},
)
}
private fun enqueueMessage(text: String) {
val seed = activeTurnCheckpointSeed
val contextKey = seed?.contextKey ?: activeProfileContextKey
val sessionId = seed?.sessionId?.takeIf { it.isNotBlank() }
?: chatHandler?.currentSessionId?.value
val activeOwnerRunId = activeQueueOwnerRunId
if (contextKey == null || sessionId == null || activeOwnerRunId == null) {
_transientNotice.tryEmit(
"This message could not be queued because the active chat destination is not available.",
)
return
}
// Queue order is a run-generation chain: the first item waits for the
// active turn; each later item waits for the queued turn before it.
// sendMessageInternal reuses each item's stable id as that turn's user
// id, so this ownership also survives checkpoint persistence.
val ownerRunId = queuedMessageItems.lastOrNull {
it.contextKey == contextKey && it.sessionId == sessionId
}?.id ?: activeOwnerRunId
val item = QueuedMessage(
id = UUID.randomUUID().toString(),
text = text,
contextKey = contextKey,
sessionId = sessionId,
transport = streamingEndpoint,
ownerRunId = ownerRunId,
attachments = _pendingAttachments.value,
interfaceContextPrompt = nextInterfaceContextPrompt,
)
_pendingAttachments.value = emptyList()
nextInterfaceContextPrompt = null
queuedMessageItems += item
publishQueuedMessages()
scheduleCheckpointWrite(immediate = true)
}
private fun currentQueueDestination(): Pair<String, String>? {
val contextKey = activeProfileContextKey ?: return null
val sessionId = chatHandler?.currentSessionId?.value ?: return null
return contextKey to sessionId
}
private fun publishQueuedMessages() {
val destination = currentQueueDestination()
_queuedMessages.value = if (destination == null) {
emptyList()
} else {
queuedMessageItems.filter {
it.contextKey == destination.first && it.sessionId == destination.second
}.map(QueuedMessage::text)
}
}
private fun removeQueuedMessagesFor(contextKey: String?, sessionId: String): Int {
if (contextKey == null) return 0
val removedOwners = queuedMessageItems
.filter { it.contextKey == contextKey && it.sessionId == sessionId }
.mapTo(mutableSetOf(), QueuedMessage::ownerRunId)
val before = queuedMessageItems.size
queuedMessageItems.removeAll { it.contextKey == contextKey && it.sessionId == sessionId }
removedOwners.forEach { owner ->
if (queuedMessageItems.none { it.ownerRunId == owner }) completedQueueOwnerRuns -= owner
}
publishQueuedMessages()
return before - queuedMessageItems.size
}
private fun removeQueuedMessagesForOwner(ownerRunId: String) {
queuedMessageItems.removeAll { it.ownerRunId == ownerRunId }
completedQueueOwnerRuns -= ownerRunId
publishQueuedMessages()
}
fun clearQueue() {
_queuedMessages.value = emptyList()
val destination = currentQueueDestination() ?: return
removeQueuedMessagesFor(destination.first, destination.second)
}
/** Drop a single queued message by index (no-op if out of range). */
fun removeQueuedAt(index: Int) {
_queuedMessages.update { list ->
if (index in list.indices) list.toMutableList().apply { removeAt(index) } else list
val destination = currentQueueDestination() ?: return
val visible = queuedMessageItems.filter {
it.contextKey == destination.first && it.sessionId == destination.second
}
val item = visible.getOrNull(index) ?: return
queuedMessageItems.removeAll { it.id == item.id }
if (queuedMessageItems.none { it.ownerRunId == item.ownerRunId }) {
completedQueueOwnerRuns -= item.ownerRunId
}
publishQueuedMessages()
}
/**
@@ -5487,12 +5743,18 @@ class ChatViewModel : ViewModel() {
* the composer can prefill it. Null if the index is out of range.
*/
fun takeQueuedForEdit(index: Int): String? {
val current = _queuedMessages.value
if (index !in current.indices) return null
_queuedMessages.update { list ->
if (index in list.indices) list.toMutableList().apply { removeAt(index) } else list
val destination = currentQueueDestination() ?: return null
val item = queuedMessageItems.filter {
it.contextKey == destination.first && it.sessionId == destination.second
}.getOrNull(index) ?: return null
queuedMessageItems.removeAll { it.id == item.id }
if (queuedMessageItems.none { it.ownerRunId == item.ownerRunId }) {
completedQueueOwnerRuns -= item.ownerRunId
}
return current[index]
_pendingAttachments.value = item.attachments
nextInterfaceContextPrompt = item.interfaceContextPrompt
publishQueuedMessages()
return item.text
}
private fun drainQueue() {
@@ -5500,9 +5762,27 @@ class ChatViewModel : ViewModel() {
val client = apiClient
if (streamingEndpoint != "gateway" && client == null) return
if (streamingEndpoint == "gateway" && gatewayClient == null && client == null) return
val next = _queuedMessages.value.firstOrNull() ?: return
_queuedMessages.update { it.drop(1) }
sendMessageInternal(client, handler, next, queuedFollowUp = true)
if (activeStream != null || streamRecovery != null) return
val destination = currentQueueDestination() ?: return
val next = queuedMessageItems.firstOrNull {
it.contextKey == destination.first &&
it.sessionId == destination.second &&
it.ownerRunId in completedQueueOwnerRuns
} ?: return
if (next.transport != streamingEndpoint) {
queuedMessageItems.removeAll { it.id == next.id }
publishQueuedMessages()
_transientNotice.tryEmit(
"A queued message was not sent because this chat's route changed.",
)
return
}
queuedMessageItems.removeAll { it.id == next.id }
if (queuedMessageItems.none { it.ownerRunId == next.ownerRunId }) {
completedQueueOwnerRuns -= next.ownerRunId
}
publishQueuedMessages()
sendMessageInternal(client, handler, next.text, queuedFollowUp = true, queuedMessage = next)
}
private fun sendMessageInternal(
@@ -5511,18 +5791,19 @@ class ChatViewModel : ViewModel() {
text: String,
transportText: String = text,
queuedFollowUp: Boolean = false,
queuedMessage: QueuedMessage? = null,
) {
AppAnalytics.onMessageSent()
val displayText = text.trim()
val outboundText = transportText.trim()
val interfaceContextPrompt = nextInterfaceContextPrompt
nextInterfaceContextPrompt = null
val interfaceContextPrompt = queuedMessage?.interfaceContextPrompt ?: nextInterfaceContextPrompt
if (queuedMessage == null) nextInterfaceContextPrompt = null
// Snapshot and clear pending attachments
val attachments = _pendingAttachments.value.ifEmpty { null }
_pendingAttachments.value = emptyList()
val attachments = (queuedMessage?.attachments ?: _pendingAttachments.value).ifEmpty { null }
if (queuedMessage == null) _pendingAttachments.value = emptyList()
val messageId = UUID.randomUUID().toString()
val messageId = queuedMessage?.id ?: UUID.randomUUID().toString()
// Add user message locally (with attachments for display)
handler.addUserMessage(
@@ -6668,17 +6949,24 @@ class ChatViewModel : ViewModel() {
scheduleCheckpointWrite(immediate = true)
}
val observedImageToolStates = mutableMapOf<String, String>()
val onToolCallStartCb = { toolCallId: String, toolName: String ->
val handleToolCallStart = { toolCallId: String, toolName: String, argsPreview: String? ->
ensurePostInterimMessage()
streamDeltas.flushNow()
val alreadyObserved =
toolName == "image_generate" &&
observedImageToolStates.putIfAbsent(toolCallId, "running") != null
if (!alreadyObserved) {
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
handler.onToolCallStart(currentMessageId, toolCallId, toolName, argsPreview)
}
scheduleCheckpointWrite(immediate = true)
}
val onToolCallStartCb = { toolCallId: String, toolName: String ->
handleToolCallStart(toolCallId, toolName, null)
}
val onGatewayToolCallStartCb =
{ toolCallId: String, toolName: String, argsPreview: String? ->
handleToolCallStart(toolCallId, toolName, argsPreview)
}
val onToolCallDoneCb = { toolCallId: String, resultPreview: String? ->
ensurePostInterimMessage()
streamDeltas.flushNow()
@@ -6877,10 +7165,9 @@ class ChatViewModel : ViewModel() {
handler.messages.value.findLast { it.isStreaming }
?.let { handler.onStreamComplete(it.id) }
activeStream = null
_queuedMessages.value = emptyList()
removeQueuedMessagesForOwner(userMessageId)
_steerableTurn.value = false
_steerNotice.value = null
clearPendingAsk(approvalStamp = "deny")
clearTurnCheckpoint()
} else if (
dispatchedSseEndpoint == "sessions" &&
@@ -6950,10 +7237,9 @@ class ChatViewModel : ViewModel() {
}
}
activeStream = null
_queuedMessages.value = emptyList()
removeQueuedMessagesForOwner(userMessageId)
_steerableTurn.value = false
_steerNotice.value = null
clearPendingAsk(approvalStamp = "deny")
clearTurnCheckpoint()
}
}
@@ -6970,10 +7256,9 @@ class ChatViewModel : ViewModel() {
handler.onStreamError(errorMsg)
emitError(error, context = "send_message")
activeStream = null
_queuedMessages.value = emptyList()
removeQueuedMessagesForOwner(userMessageId)
_steerableTurn.value = false
_steerNotice.value = null
clearPendingAsk(approvalStamp = "deny")
clearTurnCheckpoint()
}
@@ -7297,7 +7582,7 @@ class ChatViewModel : ViewModel() {
onInterimMessage = onInterimMessageCb,
onInterimReconciled = onInterimReconciledCb,
onThinkingDelta = onThinkingDeltaCb,
onToolCallStart = onToolCallStartCb,
onToolCallStart = onGatewayToolCallStartCb,
onToolCallDone = onToolCallDoneCb,
onToolCallFailed = onToolCallFailedCb,
onToolOutputRisk = { risk ->
@@ -7336,9 +7621,6 @@ class ChatViewModel : ViewModel() {
onInteractionExpired = { expiry ->
expirePendingAsk(expiry)
},
onInteractionResolved = { resolved ->
resolvePendingAsk(resolved)
},
onStatusUpdate = { kind, text ->
handler.setTurnStatus(text, kind)
// The server prefixes terminal failures with ❌ —
@@ -7460,12 +7742,12 @@ class ChatViewModel : ViewModel() {
activeStream = null
imageActivityJob?.cancel()
imageActivityJob = null
_queuedMessages.value = emptyList()
clearQueue()
_steerableTurn.value = false
_steerNotice.value = null
// Gateway cancel issues session.interrupt, which force-denies any
// blocked approval server-side — stamp the card to match.
clearPendingAsk(approvalStamp = "deny")
clearPendingAskAfterInterrupt()
clearTurnCheckpoint()
AppAnalytics.onStreamCancelled()
chatHandler?.let { handler ->
@@ -8149,6 +8431,10 @@ data class PendingAsk(
/** ChatMessage id of the local ask-card message (`ask-<cardKey>`). */
val messageId: String,
val cardKey: String,
/** Exact connection/profile namespace that owns this request. */
val contextKey: String?,
/** Stored session id within [contextKey]; approvals are session-scoped upstream. */
val sessionId: String?,
val receivedAt: Long = System.currentTimeMillis(),
)
@@ -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
@@ -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)
@@ -272,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>
@@ -2167,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>
@@ -2540,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>
@@ -3258,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>
@@ -293,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>
@@ -2261,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>
@@ -2659,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>
@@ -3351,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>
+26
View File
@@ -293,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>
@@ -2266,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>
@@ -2663,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>
@@ -3418,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>
+26
View File
@@ -255,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>
@@ -2071,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>
@@ -2414,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>
@@ -3103,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>
+26
View File
@@ -293,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>
@@ -2277,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>
@@ -2673,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>
@@ -3417,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>
+26
View File
@@ -276,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>
@@ -1797,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>
@@ -2255,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>
@@ -2621,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>
+26
View File
@@ -295,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>
@@ -1884,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 -->
@@ -2386,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>
@@ -2406,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>
@@ -2795,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>
@@ -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(
@@ -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(
@@ -530,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?>>()
@@ -548,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 = { },
@@ -560,8 +560,7 @@ class GatewayChatClientTest {
onSubagentEvent = { subagentEvents += it },
onMoaReference = { moaReferences += it },
onInteractionRequest = { interactions += it },
onInteractionExpired = { },
onInteractionResolved = { interactionResolutions += it },
onInteractionExpired = { interactionExpiries += it },
onStatusUpdate = { _, _ -> },
onStatusClear = { },
)
@@ -1244,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)
@@ -2776,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)
@@ -2826,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
@@ -483,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('&')
@@ -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),
),
)
}
}
@@ -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,
),
)
}
}
@@ -6,6 +6,7 @@ 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
@@ -55,4 +56,36 @@ class MessageBubbleCompletionLayoutTest {
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))
}
}
@@ -14,6 +14,9 @@ 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
@@ -26,6 +29,26 @@ 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(
@@ -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()
}
}
@@ -341,29 +341,60 @@ class ChatScrollSnapshotTest {
retainedLiveTailAfterTransition(
retainedUiKey = null,
streamStarted = true,
streamCompleted = false,
lastMessageUiKey = "assistant-live",
),
)
}
@Test
fun `same-tail completion keeps the stable live renderer`() {
assertEquals(
"assistant-live",
fun `same-tail completion releases the live renderer for markdown`() {
assertNull(
retainedLiveTailAfterTransition(
retainedUiKey = "assistant-live",
streamStarted = false,
streamCompleted = true,
lastMessageUiKey = "assistant-live",
),
)
}
@Test
fun `late completion survives server id adoption on the same ui row`() {
val live = snapshot().copy(
lastMessageId = "assistant-client-id",
lastContentLength = 40,
isStreaming = true,
)
val completed = live.copy(
lastMessageId = "assistant-server-id",
lastContentLength = 120,
isStreaming = false,
)
assertEquals(true, completed.isCompletionAfter(live))
assertNull(
retainedLiveTailAfterTransition(
retainedUiKey = "assistant-ui-key",
streamStarted = false,
streamCompleted = completed.isCompletionAfter(live),
lastMessageUiKey = completed.lastMessageUiKey,
),
)
}
@Test
fun `restored settled history does not invent a live completion transition`() {
assertEquals(false, snapshot().isCompletionAfter(previous = null))
}
@Test
fun `new tail releases the retained renderer`() {
assertNull(
retainedLiveTailAfterTransition(
retainedUiKey = "assistant-live",
streamStarted = false,
streamCompleted = false,
lastMessageUiKey = "next-row",
),
)
@@ -371,6 +402,7 @@ class ChatScrollSnapshotTest {
retainedLiveTailAfterTransition(
retainedUiKey = null,
streamStarted = false,
streamCompleted = false,
lastMessageUiKey = "next-row",
),
)
@@ -0,0 +1,39 @@
package com.hermesandroid.relay.ui.screens
import org.junit.Assert.assertEquals
import org.junit.Test
class DashboardSignInPolicyTest {
@Test
fun transportFailure_usesActionableRetryCopy_withoutChangingOtherFailures() {
val retry = "Tap sign in to retry"
assertEquals(
retry,
nativeDashboardSignInActionMessage(
failureStage = "token_transport_socket",
errorMessage = "secret transport detail",
fallbackMessage = "fallback",
transportRetryMessage = retry,
),
)
assertEquals(
"HTTP 400",
nativeDashboardSignInActionMessage(
failureStage = "token_http_400",
errorMessage = "HTTP 400",
fallbackMessage = "fallback",
transportRetryMessage = retry,
),
)
assertEquals(
"fallback",
nativeDashboardSignInActionMessage(
failureStage = "token_shape",
errorMessage = null,
fallbackMessage = "fallback",
transportRetryMessage = retry,
),
)
}
}
@@ -177,6 +177,37 @@ class ModelOptionsParserTest {
assertEquals(listOf("z-auth", "b-auth", "a-skel"), options.map { it.id })
}
@Test
fun repeatedProviderAndModelRowsMergeAtTheDashboardInventoryBoundary() {
val options = parse(
"""
{
"providers": [
{
"slug": "deepseek",
"name": "DeepSeek",
"models": ["deepseek-v4-flash", "deepseek-v4-flash"],
"authenticated": false
},
{
"slug": "DeepSeek",
"name": "DeepSeek",
"models": ["deepseek-v4-flash", "deepseek-v4-pro"],
"authenticated": true
}
]
}
""".trimIndent(),
)
assertEquals(1, options.size)
assertTrue(options.single().authenticated)
assertEquals(
listOf("deepseek-v4-flash", "deepseek-v4-pro"),
options.single().models,
)
}
@Test
fun disabledAndExcludedProvidersAreHiddenWhenUpstreamMarksThemExplicitly() {
val options = parse(
@@ -23,6 +23,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
@@ -74,18 +75,23 @@ class ChatViewModelGatewayInboundTurnTest {
private var persistedHistory: List<MessageItem> = emptyList()
@Volatile
private var holdCompletionsStream = false
private val apiCompletionsRequestCount = AtomicInteger(0)
@Before
fun setUp() {
gatewayHarness = GatewayClientHarness()
apiServer = MockWebServer().apply {
dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
if (holdCompletionsStream && request.path == "/v1/chat/completions") {
override fun dispatch(request: RecordedRequest): MockResponse {
if (request.path == "/v1/chat/completions") {
apiCompletionsRequestCount.incrementAndGet()
}
return if (holdCompletionsStream && request.path == "/v1/chat/completions") {
MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)
} else {
MockResponse().setResponseCode(404)
}
}
}
start()
}
@@ -106,6 +112,7 @@ class ChatViewModelGatewayInboundTurnTest {
handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
persistedHistory = emptyList()
holdCompletionsStream = false
apiCompletionsRequestCount.set(0)
viewModel = ChatViewModel().also {
it.initialize(
HermesApiClient(apiServer.url("/").toString(), "test-key"),
@@ -198,7 +205,7 @@ class ChatViewModelGatewayInboundTurnTest {
)
handler.onTurnComplete(cardMessageId)
val card = handler.messages.value.single { it.id == cardMessageId }.cards.single()
val apiRequestsBeforeAction = apiServer.requestCount
val apiRequestsBeforeAction = apiCompletionsRequestCount.get()
viewModel.dispatchCardAction(
messageId = cardMessageId,
@@ -208,7 +215,7 @@ class ChatViewModelGatewayInboundTurnTest {
val submit = gatewayHarness.awaitRpc("prompt.submit")
assertEquals("approve", (submit["text"] as JsonPrimitive).content)
assertEquals(apiRequestsBeforeAction, apiServer.requestCount)
assertEquals(apiRequestsBeforeAction, apiCompletionsRequestCount.get())
}
@Test
@@ -516,7 +523,9 @@ class ChatViewModelGatewayInboundTurnTest {
gatewayHarness.awaitRpc("session.activate")
awaitCondition {
handler.messages.value.any { it.id == "pending-assistant" } &&
handler.messages.value.any {
it.id == "pending-assistant" && it.content == "Partial answer from upstream"
} &&
handler.isStreaming.value
}
val restored = handler.messages.value.single { it.id == "pending-assistant" }
@@ -526,6 +535,8 @@ class ChatViewModelGatewayInboundTurnTest {
assertFalse(restored.toolCalls.single().isComplete)
assertEquals("Running terminal", handler.turnStatus.value)
assertEquals("approval-1", viewModel.pendingAsk.value?.cardKey)
assertEquals(PROFILE_CONTEXT, viewModel.pendingAsk.value?.contextKey)
assertEquals(STORED_SESSION_ID, viewModel.pendingAsk.value?.sessionId)
val restoredApproval = handler.messages.value
.single { it.id == "ask-approval-1" }
.cards
@@ -564,6 +575,10 @@ class ChatViewModelGatewayInboundTurnTest {
content = JsonPrimitive("Partial answer from upstream and finished"),
),
)
assertTrue(handler.isStreaming.value)
assertEquals("approval-1", checkpointStore.checkpoint?.pendingAsk?.cardKey)
assertTrue(gatewayHarness.rpcLog.none { it.first == "approval.respond" })
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
@@ -572,13 +587,83 @@ class ChatViewModelGatewayInboundTurnTest {
),
)
awaitCondition { !handler.isStreaming.value }
shadowOf(Looper.getMainLooper()).idle()
awaitCondition {
handler.messages.value.any {
it.role == MessageRole.ASSISTANT &&
it.content == "Partial answer from upstream and finished"
}
}
awaitCondition { !handler.isStreaming.value }
// Neither replayed activity nor generic completion proves consent.
// The client-only card remains actionable until an explicit response
// or authoritative expiry event owns the transition.
assertEquals("approval-1", viewModel.pendingAsk.value?.cardKey)
assertTrue(
handler.messages.value
.single { it.id == "ask-approval-1" }
.cardDispatches
.isEmpty(),
)
val pending = requireNotNull(viewModel.pendingAsk.value)
viewModel.answerAsk(pending.messageId, pending.cardKey, "once")
gatewayHarness.awaitRpc("approval.respond")
awaitCondition { !handler.isStreaming.value }
awaitCondition { checkpointStore.checkpoint == null }
assertTrue(handler.messages.value.any {
it.role == MessageRole.ASSISTANT &&
it.content == "Partial answer from upstream and finished"
})
assertEquals(
"once",
handler.messages.value.single { it.id == "ask-approval-1" }
.cardDispatches.single().actionValue,
)
}
@Test
fun explicitApprovalActionAloneEmitsResponseAndCollapsesCard() {
viewModel.sendMessage("Run the guarded command")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"approval.request",
buildJsonObject { put("command", "guarded command") },
"live-resumed",
),
)
awaitCondition { viewModel.pendingAsk.value != null }
val pending = requireNotNull(viewModel.pendingAsk.value)
viewModel.answerAsk(pending.messageId, pending.cardKey, "once")
val response = gatewayHarness.awaitRpc("approval.respond")
assertEquals(JsonPrimitive("live-resumed"), response["session_id"])
assertEquals(JsonPrimitive("once"), response["choice"])
awaitCondition { viewModel.pendingAsk.value == null }
val cardMessage = handler.messages.value.single { it.id == pending.messageId }
assertEquals("once", cardMessage.cardDispatches.single().actionValue)
}
@Test
fun explicitDenialActionEmitsResponseAndCollapsesCard() {
viewModel.sendMessage("Run the guarded command")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"approval.request",
buildJsonObject { put("command", "guarded command") },
"live-resumed",
),
)
awaitCondition { viewModel.pendingAsk.value != null }
val pending = requireNotNull(viewModel.pendingAsk.value)
viewModel.answerAsk(pending.messageId, pending.cardKey, "deny")
val response = gatewayHarness.awaitRpc("approval.respond")
assertEquals(JsonPrimitive("live-resumed"), response["session_id"])
assertEquals(JsonPrimitive("deny"), response["choice"])
awaitCondition { viewModel.pendingAsk.value == null }
val cardMessage = handler.messages.value.single { it.id == pending.messageId }
assertEquals("deny", cardMessage.cardDispatches.single().actionValue)
}
@Test
@@ -856,6 +941,7 @@ class ChatViewModelGatewayInboundTurnTest {
@Test
fun queuedMessageDrainsAfterUnsolicitedTurnCompletes() {
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
gatewayHarness.redirectStatus = "rejected"
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(
@@ -890,6 +976,276 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(viewModel.queuedMessages.value.isEmpty())
}
@Test
fun multipleQueuedMessagesDrainAsAnOwnedRunChain() {
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", null),
STORED_SESSION_ID,
)
gatewayHarness.redirectStatus = "rejected"
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
awaitCondition { handler.isStreaming.value }
viewModel.sendMessage("Queued first")
gatewayHarness.awaitRpc("session.redirect")
viewModel.sendMessage("Queued second")
gatewayHarness.awaitRpcCount("session.redirect", 2)
awaitCondition {
viewModel.queuedMessages.value == listOf("Queued first", "Queued second")
}
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Original complete") },
"live-resumed",
),
)
awaitCondition {
gatewayHarness.rpcLog.count { it.first == "prompt.submit" } == 1
}
assertEquals(listOf("Queued second"), viewModel.queuedMessages.value)
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "First queued complete") },
"live-resumed",
),
)
awaitCondition {
gatewayHarness.rpcLog.filter { it.first == "prompt.submit" }
.map { it.second["text"] } ==
listOf(JsonPrimitive("Queued first"), JsonPrimitive("Queued second"))
}
assertTrue(viewModel.queuedMessages.value.isEmpty())
}
@Test
fun queuedMessageStaysWithOriginWhileAnotherRunningSessionCompletes() {
val secondSession = "stored-session-b"
val contextKey = AgentDisplay.profileContextKey("connection-a", null)
gatewayHarness.resumeLiveSessionIds[secondSession] = "live-b"
gatewayHarness.redirectStatus = "rejected"
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
viewModel.sendMessage("Run task A")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", "Partial A") },
"live-resumed",
),
)
awaitCondition { handler.isStreaming.value }
viewModel.sendMessage("Follow up A")
gatewayHarness.awaitRpc("session.redirect")
awaitCondition { viewModel.queuedMessages.value == listOf("Follow up A") }
viewModel.switchSession(secondSession)
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition { handler.currentSessionId.value == secondSession && !handler.isStreaming.value }
assertTrue("session B must not show A's queue", viewModel.queuedMessages.value.isEmpty())
viewModel.sendMessage("Run task B")
gatewayHarness.awaitRpcCount("prompt.submit", 2)
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Task B complete") },
"live-b",
),
)
awaitCondition { !handler.isStreaming.value }
assertEquals(
0,
gatewayHarness.rpcLog.count { (method, params) ->
method == "prompt.submit" && params["text"] == JsonPrimitive("Follow up A")
},
)
// A finishes late while B remains visible. Its queue becomes eligible,
// but must not be dispatched through B's mutable visible route.
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Task A complete") },
"live-resumed",
),
)
Thread.sleep(100)
shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS)
assertTrue(viewModel.queuedMessages.value.isEmpty())
assertEquals(
0,
gatewayHarness.rpcLog.count { (method, params) ->
method == "prompt.submit" && params["text"] == JsonPrimitive("Follow up A")
},
)
viewModel.switchSession(STORED_SESSION_ID)
awaitCondition {
gatewayHarness.rpcLog.any { (method, params) ->
method == "prompt.submit" &&
params["text"] == JsonPrimitive("Follow up A") &&
params["queued"] == JsonPrimitive(true)
}
}
assertTrue(viewModel.queuedMessages.value.isEmpty())
}
@Test
fun queuedMessageVisibilityFollowsItsOriginProfile() {
val defaultContext = AgentDisplay.profileContextKey("connection-a", null)
val otherContext = AgentDisplay.profileContextKey("connection-a", "profile-b")
gatewayHarness.redirectStatus = "rejected"
viewModel.switchProfileContext(defaultContext, STORED_SESSION_ID)
viewModel.sendMessage("Run in default profile")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", "Default profile partial") },
"live-resumed",
),
)
awaitCondition { handler.isStreaming.value }
viewModel.sendMessage("Default profile follow-up")
gatewayHarness.awaitRpc("session.redirect")
awaitCondition { viewModel.queuedMessages.value.size == 1 }
viewModel.switchProfileContext(otherContext, STORED_SESSION_ID)
awaitCondition { viewModel.queuedMessages.value.isEmpty() }
gatewayHarness.recoveryRunning = true
gatewayHarness.recoveryAssistant = "Default profile partial"
viewModel.switchProfileContext(defaultContext, STORED_SESSION_ID)
gatewayHarness.awaitRpc("session.activate")
awaitCondition {
handler.isStreaming.value &&
viewModel.queuedMessages.value == listOf("Default profile follow-up")
}
}
@Test
fun connectionChangeCancelsOwnedQueueAndLateCompletionCannotResendIt() {
val switches = MutableSharedFlow<String>(extraBufferCapacity = 1)
gatewayHarness.redirectStatus = "rejected"
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", null),
STORED_SESSION_ID,
)
viewModel.observeConnectionSwitches(switches)
viewModel.sendMessage("Run before connection change")
gatewayHarness.awaitRpc("prompt.submit")
viewModel.sendMessage("Must stay on the old connection")
awaitCondition { viewModel.queuedMessages.value.size == 1 }
switches.tryEmit("connection-b")
awaitCondition { viewModel.queuedMessages.value.isEmpty() }
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Late old-connection completion") },
"live-resumed",
),
)
Thread.sleep(100)
shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS)
assertFalse(
gatewayHarness.rpcLog.any { (method, params) ->
method == "prompt.submit" &&
params["text"] == JsonPrimitive("Must stay on the old connection")
},
)
}
@Test
fun deletingDestinationCancelsItsQueueAndEditRestoresOnlyThatItem() {
gatewayHarness.redirectStatus = "rejected"
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", null),
STORED_SESSION_ID,
)
handler.addSession(
com.hermesandroid.relay.data.ChatSession(
sessionId = STORED_SESSION_ID,
title = "Owned session",
model = null,
updatedAt = System.currentTimeMillis(),
),
)
viewModel.profileSessionDeleter = { true }
viewModel.sendMessage("Run before delete")
gatewayHarness.awaitRpc("prompt.submit")
viewModel.sendMessage("Edit this queued message")
awaitCondition { viewModel.queuedMessages.value == listOf("Edit this queued message") }
assertEquals("Edit this queued message", viewModel.takeQueuedForEdit(0))
assertTrue(viewModel.queuedMessages.value.isEmpty())
viewModel.sendMessage("Cancel when destination is deleted")
awaitCondition { viewModel.queuedMessages.value == listOf("Cancel when destination is deleted") }
viewModel.deleteSession(STORED_SESSION_ID)
assertTrue(viewModel.queuedMessages.value.isEmpty())
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Late deleted-session completion") },
"live-resumed",
),
)
Thread.sleep(100)
shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS)
assertFalse(
gatewayHarness.rpcLog.any { (method, params) ->
method == "prompt.submit" &&
params["text"] == JsonPrimitive("Cancel when destination is deleted")
},
)
}
@Test
fun retryQueuedForActiveRunIsCancelledWithThatRun() {
gatewayHarness.redirectStatus = "rejected"
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", null),
STORED_SESSION_ID,
)
viewModel.sendMessage("Retry this turn")
gatewayHarness.awaitRpc("prompt.submit")
viewModel.retryLastMessage()
gatewayHarness.awaitRpc("session.redirect")
awaitCondition { viewModel.queuedMessages.value == listOf("Retry this turn") }
viewModel.cancelStream()
gatewayHarness.awaitRpc("session.interrupt")
assertTrue(viewModel.queuedMessages.value.isEmpty())
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Late completion after cancel") },
"live-resumed",
),
)
Thread.sleep(100)
shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS)
assertEquals(
1,
gatewayHarness.rpcLog.count { (method, params) ->
method == "prompt.submit" && params["text"] == JsonPrimitive("Retry this turn")
},
)
}
@Test
fun coldForegroundPrewarmReloadsACompletionMissedWhileDisconnected() {
persistedHistory = persistedAnswerHistory()
@@ -182,12 +182,18 @@ class ProfileControllerLockTest {
coEvery { dashboardClient.listProfiles() } returns Result.success(
listOf(serverDefault, pinned),
)
coEvery { dashboardClient.listSessions(profile = "pinned", limit = 200) } returns
coEvery {
dashboardClient.listSessions(profile = "pinned", limit = 200, archived = "include")
} returns
Result.success(emptyList<SessionItem>())
coEvery { dashboardClient.deleteSession("session-1", profile = "pinned") } returns
Result.success(buildJsonObject { })
coEvery { dashboardClient.renameSession("session-1", "Renamed", profile = "pinned") } returns
Result.success(buildJsonObject { })
coEvery { dashboardClient.setSessionPinned("session-1", true, profile = "pinned") } returns
Result.success(buildJsonObject { })
coEvery { dashboardClient.setSessionArchived("session-1", true, profile = "pinned") } returns
Result.success(buildJsonObject { })
controller.setPendingConnectionId(connectionId)
controller.setPendingName(null)
@@ -200,11 +206,30 @@ class ProfileControllerLockTest {
runBlocking { controller.listProfileScopedSessions()?.getOrThrow() }
assertTrue(runBlocking { controller.deleteProfileScopedSession("session-1") })
assertTrue(runBlocking { controller.renameProfileScopedSession("session-1", "Renamed") })
coVerify(exactly = 1) { dashboardClient.listSessions(profile = "pinned", limit = 200) }
assertFalse(
runBlocking {
controller.setProfileScopedSessionPinned(
"session-1",
true,
expectedContextKey = "another-connection-profile",
)
},
)
assertTrue(runBlocking { controller.setProfileScopedSessionPinned("session-1", true) })
assertTrue(runBlocking { controller.setProfileScopedSessionArchived("session-1", true) })
coVerify(exactly = 1) {
dashboardClient.listSessions(profile = "pinned", limit = 200, archived = "include")
}
coVerify(exactly = 1) { dashboardClient.deleteSession("session-1", profile = "pinned") }
coVerify(exactly = 1) {
dashboardClient.renameSession("session-1", "Renamed", profile = "pinned")
}
coVerify(exactly = 1) {
dashboardClient.setSessionPinned("session-1", true, profile = "pinned")
}
coVerify(exactly = 1) {
dashboardClient.setSessionArchived("session-1", true, profile = "pinned")
}
}
// --- selectProfile gating while locked ----------------------------------
+92 -1
View File
@@ -2561,6 +2561,11 @@ Relay mandatory would break the Vanilla Hermes path.
- Upstream `model.options` remains the source of provider/model identity. Relay
never invents models or gates model selection or chat.
- Android normalizes that inventory before publication: repeated rows with the
same case-insensitive provider slug are merged, repeated exact model IDs
within that provider are collapsed, and different provider slugs remain
separate choices even when their labels and model IDs match. Compose keys use
the provider slug plus exact model ID, never a display label or list position.
- When available, the optional Relay `POST /relay/model-capabilities` overlay
resolves capability metadata for the exact, profile-scoped `{provider, model}`
pairs supplied by the client. A remote caller needs a paired session with an
@@ -2588,4 +2593,90 @@ Relay mandatory would break the Vanilla Hermes path.
**Consequences.** Vanilla Hermes remains complete without Relay. A connected
Relay can narrow choices after capability discovery without changing the model
inventory. Old Relay versions, a missing `chat` grant, network failures, and
unsupported providers fail soft to the same stable advisory behavior.
unsupported providers fail soft to the same stable advisory behavior. Cached,
dynamic, and refreshed catalogs are idempotent at the Android publication
boundary, while provider-specific aliases and reasoning capabilities retain
their exact route identity.
---
## ADR 46 — Session pin and archive state follows upstream profile storage
**Status:** Accepted (2026-08-07).
**Context.** Android's session drawer originally kept pin and archive sets in
Compose `remember` state. The controls appeared to work until the drawer or app
was recreated, at which point neither set had a durable owner. Current upstream
Hermes exposes both fields on stored session rows, returns them from the
Dashboard session list, and accepts `pinned` or `archived` through the existing
profile-aware session PATCH route. The official client treats the server row as
durable truth. A separate Android registry would drift across clients and could
collide when two connections or profiles contain the same session id.
**Decision.**
- Android maps upstream `pinned` and `archived` fields into `ChatSession`; the
drawer is a stateless renderer of those fields.
- Gateway-mode lists request `archived=include` through the active connection
and effective profile's Dashboard route so archived rows survive recreation
and remain available for restore.
- Pin/unpin and archive/restore use the same profile-scoped Dashboard PATCH
contract as rename. API-server mode uses its official session PATCH for pins.
Its current list cannot include archived rows, so Android does not offer an
archive action there that would become unrestorable after restart. Relay
endpoints are never required.
- Mutations update the visible row optimistically, roll back on a failed write,
and refresh from server truth after success. Revision and scope-generation
fences prevent older completions or a profile/connection transition from
changing the newly selected namespace.
- Deleting a session needs no metadata cleanup on Android because there is no
local flag index; the next server list simply omits the deleted row.
**Consequences.** Pin state, and archive state on the standard Dashboard path,
now survive Android process and drawer recreation and agree with other official
clients. Identical session ids in different profiles or installations remain
isolated by their server database. Hosts predating the upstream fields fail the
mutation visibly and retain their previous row rather than reporting local-only
success.
---
## ADR 47 — Android follow-up queues are destination- and run-owned
**Status:** Accepted (2026-08-08).
**Context.** Android previously represented queued composer submissions as one
ViewModel-wide list of strings. Queue presentation and completion dispatch both
consulted the mutable visible session, so switching between two running Gateway
sessions could display and submit a follow-up in the wrong server conversation.
Composer attachments and voice context were also read at dispatch time rather
than captured with the queued text.
**Decision.**
- Each queued item snapshots its connection/profile context key, stored session,
configured transport, originating local run id, attachments, voice context,
and stable queue id at submission.
- The queue tray projects only items owned by the visible context and session.
Session/profile switching does not rebind or globally expose queued items.
- A run must complete before its own items become eligible. Detached Gateway
completion carries the exact live session generation and reconciles only the
checkpoint with that same durable and live identity, preventing a late older
completion from releasing a newer same-session queue.
- Dispatch occurs only while the exact destination is visible and idle. A route
change, deleted session, connection replacement, cancellation, or unavailable
destination removes only the affected queue and reports the loss where user
action is required.
- In-flight checkpoints persist bounded queued text and voice context. Attachment
bytes are deliberately excluded from Preferences DataStore; restoration drops
such an item with a visible review-and-resend notice rather than sending an
incomplete prompt.
- Durable session metadata and live tool-card identity remain independent. Queue
ownership consumes their existing connection/profile and checkpoint seams but
does not move metadata or Compose expansion state into the queue.
**Consequences.** Concurrent Gateway sessions retain independent queues and no
completion can redirect a follow-up through the currently visible chat. Queue
editing restores only the selected item's composer payload. Process restoration
is deterministic for bounded text queues, while attachment restoration fails
closed instead of silently changing the submitted turn.
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"main": "29a0586276fb503b4917eefe5cfd00a19118969b1aaaafc2fdb297c6f180f98b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"main": "29a0586276fb503b4917eefe5cfd00a19118969b1aaaafc2fdb297c6f180f98b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"main": "29a0586276fb503b4917eefe5cfd00a19118969b1aaaafc2fdb297c6f180f98b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"main": "29a0586276fb503b4917eefe5cfd00a19118969b1aaaafc2fdb297c6f180f98b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"main": "29a0586276fb503b4917eefe5cfd00a19118969b1aaaafc2fdb297c6f180f98b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"main": "29a0586276fb503b4917eefe5cfd00a19118969b1aaaafc2fdb297c6f180f98b",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+6 -5
View File
@@ -89,12 +89,13 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.7.0 - Smarter controls, steadier sessions
v1.7.1 - Safer, steadier conversations
* Provider-aware reasoning levels with a standard fallback.
* Working and needs-input states in the searchable session drawer.
* Restored chats stay bottom-pinned without overriding scrollback.
* Private support information and clearer connection diagnostics.
* Growing replies stay visible without overriding intentional scrollback.
* Completed Markdown renders immediately and queued follow-ups stay with their chat.
* Session pins and archives persist across restarts.
* Approval cards require an explicit decision.
* Hosted Hermes setup follows the official Dashboard sign-in flow.
* Reliable Focus Voice controls and recovered chat rows.
```
+2 -2
View File
@@ -390,9 +390,9 @@ Bottom navigation bar with 4 tabs:
1. **Connection chip** — tap to open `ConnectionSwitcherSheet` (all paired servers + health indicator). Auto-hidden when you only have one Connection. See `docs/decisions.md` §19.
2. **Agent name + tappable region** — tap to open the consolidated **agent sheet** (bottom sheet) holding Profile + Personality selection and per-session info/analytics (message count, tokens in/out, avg TTFT). Sheet is scrollable. Toast confirmations fire on Profile/Personality switch. Replaces the separate `ProfilePicker` and `PersonalityPicker` top-bar chips that shipped in intermediate v0.5.x builds.
3. Remaining top-bar actions (session drawer hamburger, ambient toggle, etc.).
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible turn without sending `session.interrupt`; each running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Explicit Stop still interrupts. SSE fallback stays single-stream and cancels on navigation.
- **Queued Gateway follow-ups** — messages drained from Android's local send queue add `queued: true` to `prompt.submit`; ordinary sends omit the field. Gateway rejection `4090` is authoritative active-session-cap feedback and is shown to the user without falling through to API-server SSE.
- **Queued Gateway follow-ups** — every local queued item is immutably scoped to its originating connection, profile, stored session, transport, and run generation; only that run's completion can make it eligible, and switching sessions shows only that session's queue. Restored text queues retain the same scope, while unavailable/deleted destinations and non-restorable attachment queues fail visibly instead of following the current composer. Drained messages add `queued: true` to `prompt.submit`; ordinary sends omit the field. Gateway rejection `4090` is authoritative active-session-cap feedback and is shown to the user without falling through to API-server SSE.
- **Gateway command privacy** — `command.dispatch` skill/send results render the server's bounded `display` text (or the literal command when absent). Expanded skill bodies remain transport-only and are not copied into bubbles, titles, retries, or recovery checkpoints.
- **Chat view** — message bubbles with markdown rendering, streaming text, tool call cards (Off/Compact/Detailed display modes)
- **Three independent visual roles** — the profile image or letter fallback identifies the agent and appears on the first assistant message in a group; the optional Sphere is an ambient background visualization; and an optional pet is an app-level floating companion. A pet never replaces profile identity or the Sphere.
+4 -3
View File
@@ -1,6 +1,6 @@
# Hermes-Relay Surface Matrix
Updated: 2026-08-05
Updated: 2026-08-07
This matrix records the v1.0.0 route ownership contract. It is meant to keep
future app, plugin, and agent work honest about what is vanilla upstream
@@ -9,8 +9,9 @@ Hermes, what belongs to the Relay plugin, and what is only legacy compatibility.
Verified upstream source snapshot:
- Repository: `NousResearch/hermes-agent`
- Commit: `55cb4103beba5822303c06b662635e1491ae72f5`
- Commit: `7307f88993fc0bcc827d15eeab079cd8905c3e53`
- Primary files checked: `gateway/platforms/api_server.py`,
`hermes_cli/web_routers/sessions.py`, `apps/desktop/src/store/session-pin-sync.ts`,
`hermes_cli/web_server.py`, `hermes_cli/dashboard_auth/routes.py`,
`tui_gateway/server.py`, `hermes_cli/plugins.py`,
`hermes_cli/plugins_cmd.py`
@@ -24,7 +25,7 @@ Verified upstream source snapshot:
| `/v1/capabilities` | Upstream API server | No | Optional fallback capability probe | Source of truth for API-server features; current upstream advertises no audio API. |
| `/v1/chat/completions` | Upstream API server | No | Chat fallback | OpenAI-compatible streaming. Tool events may degrade to inline annotations. |
| `/v1/runs`, `/v1/runs/{id}/events` | Upstream API server | No | Chat fallback | Structured run events and stop/approval support. |
| `/api/sessions/*` | Upstream Dashboard/Gateway and API server | No | Primary Gateway session control or optional SSE fallback | Native upstream session list/create/read/update/delete/messages/fork/chat/chat-stream. Newer hosts also expose single-session JSON export via `GET /api/sessions/{id}/export`, soft archive via `PATCH /api/sessions/{id}`, and guarded bulk cleanup via `POST /api/sessions/prune`; Android must dry-run prune first and show the matched count/span before destructive apply. The bootstrap no longer injects any session CRUD/messages/fork routes (retired in favor of native #33134); only `/api/sessions/search` remains a bootstrap compatibility route. |
| `/api/sessions/*` | Upstream Dashboard/Gateway and API server | No | Primary Gateway session control or optional SSE fallback | Native upstream session list/create/read/update/delete/messages/fork/chat/chat-stream. Dashboard lists expose profile-stamped `pinned`/`archived`, accept `archived=exclude\|only\|include`, and PATCH either durable flag in the owning profile DB. The API-server resource also exposes and patches both fields, but its current list omits archived rows and has no archive filter; Android therefore offers restart-safe archive/restore only on the Dashboard path while API-only pinning remains valid. Newer Dashboard hosts also expose single-session JSON export and guarded bulk cleanup; Android must dry-run prune first. The bootstrap no longer injects session CRUD/messages/fork routes; only `/api/sessions/search` remains a compatibility route. |
| `/v1/skills`, `/v1/toolsets` | Upstream API server | No | Discovery | Authenticated read-only API-server skill/toolset inventory; Android Diagnostics summarizes enabled toolsets and Relay tool visibility. |
| Dashboard `/api/status`, `/api/auth/me` | Upstream dashboard | No | Manage auth | Dashboard cookie/session path; separate from API bearer. Optional status diagnostics include Nous bootstrap validity and profile/gateway topology; these do not gate transport selection. |
| Dashboard `/api/auth/ws-ticket`, `/api/ws` | Upstream dashboard/tui_gateway | No | Preferred chat transport | Vanilla Hermes gateway chat path with live reasoning/thinking events. |
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.7.0"
appVersionCode = "39"
appVersionName = "1.7.1"
appVersionCode = "40"
agp = "9.3.1"
kotlin = "2.4.10"
compose-bom = "2026.06.01"
+6
View File
@@ -6,6 +6,12 @@ inspection, and remote-access setup. The build output (`dist/index.js`) is
**committed to git** because the dashboard `<script src=...>` loads it verbatim
— operators never run the build.
The header's **Connect mobile app** action is intentionally independent of the
Relay service. It renders a tokenless setup QR containing only
`{"dashboard_url":"<canonical dashboard base>"}` so Android can add and verify the
standard Dashboard/Gateway connection. Relay pairing remains a separate,
explicit **Pair new device** flow.
## Requirements
- Node.js 18+
+7 -7
View File
File diff suppressed because one or more lines are too long
+30
View File
@@ -124,6 +124,36 @@
height: auto;
}
.hermes-relay-plugin .hr-selectable {
user-select: all;
overflow-wrap: anywhere;
}
.hermes-relay-plugin .hr-mobile-setup-qr {
transition:
opacity 180ms ease,
transform 180ms ease;
animation: hr-mobile-setup-ready 180ms ease-out both;
}
@keyframes hr-mobile-setup-ready {
from {
opacity: 0;
transform: scale(0.985);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.hermes-relay-plugin .hr-mobile-setup-qr {
transition: none;
animation: none;
}
}
.hermes-relay-plugin .space-y-1 > * + * {
margin-top: 0.25rem;
}
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Relay",
"description": "Paired devices, bridge activity, media inspection, and remote access for hermes-relay",
"icon": "Activity",
"version": "1.6.0",
"version": "1.6.1",
"tab": {
"path": "/relay",
"position": "after:skills"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hermes-relay-dashboard",
"version": "1.6.0",
"version": "1.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hermes-relay-dashboard",
"version": "1.6.0",
"version": "1.6.1",
"devDependencies": {
"esbuild": "^0.25.12",
"qrcode": "^1.5.4"
+2 -1
View File
@@ -1,10 +1,11 @@
{
"name": "hermes-relay-dashboard",
"version": "1.6.0",
"version": "1.6.1",
"private": true,
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
"scripts": {
"build": "esbuild src/index.jsx --bundle --format=iife --target=es2020 --minify --outfile=dist/index.js --loader:.jsx=jsx --jsx=transform --jsx-factory=React.createElement --jsx-fragment=React.Fragment && node -e \"require('fs').copyFileSync('src/styles.css','dist/style.css')\"",
"test": "node --test test/*.test.mjs",
"watch": "node -e \"require('fs').copyFileSync('src/styles.css','dist/style.css')\" && esbuild src/index.jsx --bundle --format=iife --target=es2020 --outfile=dist/index.js --loader:.jsx=jsx --jsx=transform --jsx-factory=React.createElement --jsx-fragment=React.Fragment --watch"
},
"devDependencies": {
@@ -0,0 +1,150 @@
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { useState, useEffect, useMemo, useRef, useCallback } = SDK.hooks;
import QRCode from "qrcode";
import { Button, Badge } from "../lib/ui-shims.jsx";
import {
buildDashboardSetupPayload,
canonicalDashboardOrigin,
} from "../lib/mobile-setup.mjs";
import { activateModal } from "../lib/modal-focus.mjs";
export default function MobileConnectDialog({ open, onClose }) {
const dialogRef = useRef(null);
const canvasRef = useRef(null);
const [copyStatus, setCopyStatus] = useState("");
const origin = useMemo(
() => canonicalDashboardOrigin(window.location),
[open],
);
const payload = useMemo(() => {
if (!origin) return null;
return buildDashboardSetupPayload(origin);
}, [origin]);
useEffect(() => {
if (!open || !payload || !canvasRef.current) return;
QRCode.toCanvas(canvasRef.current, payload, {
width: 280,
margin: 2,
errorCorrectionLevel: "M",
}).catch(() => { /* canvas failure is shown by the address fallback */ });
}, [open, payload]);
useEffect(() => {
if (open) setCopyStatus("");
}, [open]);
useEffect(() => {
if (!open || !dialogRef.current) return undefined;
return activateModal({
dialog: dialogRef.current,
documentRef: window.document,
onClose,
});
}, [open, onClose]);
const copyAddress = useCallback(async () => {
if (!origin) return;
try {
await navigator.clipboard.writeText(origin);
setCopyStatus("Copied dashboard address");
} catch (_err) {
setCopyStatus("Copy failed; select the address below");
}
}, [origin]);
if (!open) return null;
return (
<div
className="hermes-relay-plugin hr-modal-backdrop"
>
<div
ref={dialogRef}
className="hr-modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="hr-mobile-connect-title"
aria-describedby="hr-mobile-connect-description"
tabIndex={-1}
>
<div className="hr-modal-header">
<div>
<h2 id="hr-mobile-connect-title" className="hr-modal-title">
Connect mobile app
</h2>
<p id="hr-mobile-connect-description" className="text-sm text-muted-foreground mt-1">
Scan this from Hermes Relay to add this Dashboard.
</p>
</div>
<Button
variant="ghost"
size="sm"
className="hr-modal-close"
onClick={onClose}
data-modal-initial-focus
>
Close
</Button>
</div>
<div className="hr-modal-body space-y-3">
{payload ? (
<>
<div className="hr-qr-frame hr-mobile-setup-qr">
<canvas ref={canvasRef} className="block" aria-label="Dashboard setup QR code" />
</div>
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Dashboard address</span>
<Badge variant="outline">No credentials included</Badge>
</div>
<div className="font-mono text-xs break-all hr-selectable">{origin}</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={copyAddress}>
Copy address
</Button>
{copyStatus ? (
<span className="text-xs text-muted-foreground" role="status">
{copyStatus}
</span>
) : null}
</div>
</div>
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm space-y-2">
<div className="font-medium">Standard Hermes connection</div>
<p className="text-xs text-muted-foreground">
The app will verify this Dashboard, then use its advertised sign-in flow for
Chat, Manage, sessions, and standard voice.
</p>
<p className="text-xs text-muted-foreground">
This QR does not pair Relay or enable Terminal, Bridge, device tools, or Relay
sessions. Use <strong>Pair new device</strong> separately when a reachable Relay
server is available.
</p>
</div>
<p className="text-xs text-muted-foreground">
The Dashboard address must be reachable from the phone. Sign-in still happens in
the app; this QR contains no token, cookie, API key, or pairing code.
</p>
<div className="flex justify-end pt-1">
<Button size="sm" onClick={onClose}>Done</Button>
</div>
</>
) : (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
This page does not have a valid HTTP(S) Dashboard origin. Enter the Dashboard
address manually in the mobile app.
</div>
)}
</div>
</div>
</div>
);
}
+25 -8
View File
@@ -7,7 +7,8 @@ import BridgeActivity from "./tabs/BridgeActivity.jsx";
import MediaInspector from "./tabs/MediaInspector.jsx";
import RemoteAccess from "./tabs/RemoteAccess.jsx";
import RelayStatusSlot from "./components/RelayStatusSlot.jsx";
import { Switch } from "./lib/ui-shims.jsx";
import MobileConnectDialog from "./components/MobileConnectDialog.jsx";
import { Button, Switch } from "./lib/ui-shims.jsx";
const { Label } = SDK.components;
@@ -52,6 +53,7 @@ function TabButton({ active, onClick, children }) {
function RelayPluginRoot() {
const [tab, setTab] = useState("management");
const [mobileConnectOpen, setMobileConnectOpen] = useState(false);
const [autoRefresh, setAutoRefreshState] = useState(readAutoRefresh);
const setAutoRefresh = useCallback((next) => {
@@ -64,6 +66,9 @@ function RelayPluginRoot() {
writeAutoRefresh(autoRefresh);
}, [autoRefresh]);
const openMobileConnect = useCallback(() => setMobileConnectOpen(true), []);
const closeMobileConnect = useCallback(() => setMobileConnectOpen(false), []);
return (
<div className="hermes-relay-plugin space-y-4 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -73,13 +78,21 @@ function RelayPluginRoot() {
Paired devices, bridge activity, media, and remote access for hermes-relay.
</p>
</div>
<div className="flex items-center gap-2">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label htmlFor="auto-refresh">Auto-refresh</Label>
<div className="flex flex-wrap items-center gap-3">
<Button
size="sm"
onClick={openMobileConnect}
>
Connect mobile app
</Button>
<div className="flex items-center gap-2">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label htmlFor="auto-refresh">Auto-refresh</Label>
</div>
</div>
</div>
@@ -101,6 +114,10 @@ function RelayPluginRoot() {
{tab === "media" && <MediaInspector autoRefresh={autoRefresh} />}
{tab === "remote" && <RemoteAccess autoRefresh={autoRefresh} />}
</div>
<MobileConnectDialog
open={mobileConnectOpen}
onClose={closeMobileConnect}
/>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Return the canonical HTTP(S) base for a Dashboard location.
*
* Setup QR payloads deliberately carry only this public connection identity:
* The active Relay tab route (`/relay`) is removed while a reverse-proxy base
* path is preserved. Query, fragment, userinfo, cookie, and bearer material
* never cross into the QR. The Android client owns probing and sign-in.
*/
export function canonicalDashboardOrigin(locationOrOrigin) {
const isLocation = typeof locationOrOrigin === "object" && locationOrOrigin !== null;
const raw = typeof locationOrOrigin === "string"
? locationOrOrigin
: locationOrOrigin && (locationOrOrigin.href || locationOrOrigin.origin);
if (!raw) return null;
try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
let basePath = url.pathname.replace(/\/+$/, "");
const hashRouted = isLocation && String(locationOrOrigin.hash || "").startsWith("#/");
if (!hashRouted && (basePath === "/relay" || basePath.endsWith("/relay"))) {
basePath = basePath.slice(0, -"/relay".length);
}
return `${url.origin}${basePath === "/" ? "" : basePath}`;
} catch (_err) {
return null;
}
}
/** Build the tokenless Android setup payload for this Dashboard. */
export function buildDashboardSetupPayload(locationOrOrigin) {
const origin = canonicalDashboardOrigin(locationOrOrigin);
if (!origin) {
throw new Error("This Dashboard does not have a valid HTTP(S) origin.");
}
return JSON.stringify({ dashboard_url: origin });
}
+107
View File
@@ -0,0 +1,107 @@
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[contenteditable=\"true\"]",
"[tabindex]:not([tabindex=\"-1\"])",
].join(",");
function isAvailable(element) {
return !element.hidden && element.getAttribute("aria-hidden") !== "true";
}
export function focusableElements(dialog) {
return Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR)).filter(isAvailable);
}
export function containTabKey(event, dialog, documentRef) {
if (event.key !== "Tab") return;
const focusable = focusableElements(dialog);
if (focusable.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const activeElement = documentRef.activeElement;
const activeIndex = focusable.indexOf(activeElement);
const movingBeforeStart = event.shiftKey && activeIndex <= 0;
const movingPastEnd = !event.shiftKey && (
activeIndex === -1 || activeIndex === focusable.length - 1
);
if (movingBeforeStart || movingPastEnd) {
event.preventDefault();
(movingBeforeStart ? focusable[focusable.length - 1] : focusable[0]).focus();
}
}
/**
* Make every sibling outside the modal's ancestor branch unavailable to
* pointer, keyboard, and assistive-technology navigation.
*/
export function isolateModalBackground(dialog, documentRef) {
const changed = [];
let branch = dialog;
while (branch && branch.parentElement && branch.parentElement !== documentRef.documentElement) {
const parent = branch.parentElement;
for (const sibling of parent.children) {
if (sibling === branch) continue;
changed.push({
element: sibling,
inert: sibling.inert,
inertAttribute: sibling.getAttribute("inert"),
ariaHidden: sibling.getAttribute("aria-hidden"),
});
sibling.inert = true;
sibling.setAttribute("inert", "");
sibling.setAttribute("aria-hidden", "true");
}
branch = parent;
}
return () => {
for (const previous of changed.reverse()) {
const { element } = previous;
if (previous.inertAttribute === null) element.removeAttribute("inert");
else element.setAttribute("inert", previous.inertAttribute);
element.inert = previous.inert;
if (previous.ariaHidden === null) element.removeAttribute("aria-hidden");
else element.setAttribute("aria-hidden", previous.ariaHidden);
}
};
}
/** Activate a modal and return the complete focus/background cleanup. */
export function activateModal({ dialog, documentRef, onClose }) {
const opener = documentRef.activeElement;
const restoreBackground = isolateModalBackground(dialog, documentRef);
const initialFocus = dialog.querySelector("[data-modal-initial-focus]")
|| focusableElements(dialog)[0]
|| dialog;
const onKeyDown = (event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
return;
}
containTabKey(event, dialog, documentRef);
};
documentRef.addEventListener("keydown", onKeyDown, true);
initialFocus.focus();
return () => {
documentRef.removeEventListener("keydown", onKeyDown, true);
restoreBackground();
if (opener && opener.isConnected !== false && typeof opener.focus === "function") {
opener.focus();
}
};
}
+30
View File
@@ -124,6 +124,36 @@
height: auto;
}
.hermes-relay-plugin .hr-selectable {
user-select: all;
overflow-wrap: anywhere;
}
.hermes-relay-plugin .hr-mobile-setup-qr {
transition:
opacity 180ms ease,
transform 180ms ease;
animation: hr-mobile-setup-ready 180ms ease-out both;
}
@keyframes hr-mobile-setup-ready {
from {
opacity: 0;
transform: scale(0.985);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.hermes-relay-plugin .hr-mobile-setup-qr {
transition: none;
animation: none;
}
}
.hermes-relay-plugin .space-y-1 > * + * {
margin-top: 0.25rem;
}
@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildDashboardSetupPayload,
canonicalDashboardOrigin,
} from "../src/lib/mobile-setup.mjs";
test("canonicalDashboardOrigin keeps the HTTP(S) dashboard base", () => {
assert.equal(
canonicalDashboardOrigin("https://user:pass@Agent.Example:9443/hermes?token=secret#part"),
"https://agent.example:9443/hermes",
);
assert.equal(
canonicalDashboardOrigin({ origin: "http://192.168.1.50:9119" }),
"http://192.168.1.50:9119",
);
});
test("active Relay tab is removed while a proxy prefix is preserved", () => {
assert.equal(
canonicalDashboardOrigin({
href: "https://agent.example/hermes/relay?token=secret",
hash: "",
}),
"https://agent.example/hermes",
);
assert.equal(
canonicalDashboardOrigin({
href: "https://agent.example/hermes/#/relay",
hash: "#/relay",
}),
"https://agent.example/hermes",
);
});
test("buildDashboardSetupPayload carries only dashboard_url", () => {
const parsed = JSON.parse(
buildDashboardSetupPayload("https://cloud-agent.example/hermes?token=secret"),
);
assert.deepEqual(parsed, { dashboard_url: "https://cloud-agent.example/hermes" });
assert.deepEqual(Object.keys(parsed), ["dashboard_url"]);
});
test("invalid and non-HTTP origins are rejected", () => {
assert.equal(canonicalDashboardOrigin("file:///tmp/dashboard.html"), null);
assert.equal(canonicalDashboardOrigin("not a URL"), null);
assert.throws(
() => buildDashboardSetupPayload("file:///tmp/dashboard.html"),
/valid HTTP\(S\) origin/,
);
});
+159
View File
@@ -0,0 +1,159 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
activateModal,
containTabKey,
isolateModalBackground,
} from "../src/lib/modal-focus.mjs";
class FakeElement {
constructor(name, documentRef) {
this.name = name;
this.ownerDocument = documentRef;
this.parentElement = null;
this.children = [];
this.attributes = new Map();
this.inert = false;
this.hidden = false;
this.isConnected = true;
this.focusables = [];
this.initialFocus = null;
}
append(...children) {
for (const child of children) {
child.parentElement = this;
this.children.push(child);
}
}
getAttribute(name) {
return this.attributes.has(name) ? this.attributes.get(name) : null;
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
removeAttribute(name) {
this.attributes.delete(name);
}
querySelector(selector) {
return selector === "[data-modal-initial-focus]" ? this.initialFocus : null;
}
querySelectorAll() {
return this.focusables;
}
focus() {
this.ownerDocument.activeElement = this;
}
}
class FakeDocument {
constructor() {
this.activeElement = null;
this.listeners = new Map();
this.documentElement = new FakeElement("html", this);
}
addEventListener(type, listener) {
this.listeners.set(type, listener);
}
removeEventListener(type, listener) {
if (this.listeners.get(type) === listener) this.listeners.delete(type);
}
}
function keyEvent(key, shiftKey = false) {
return {
key,
shiftKey,
prevented: false,
propagationStopped: false,
preventDefault() { this.prevented = true; },
stopPropagation() { this.propagationStopped = true; },
};
}
test("Tab and Shift+Tab remain inside the dialog", () => {
const documentRef = new FakeDocument();
const dialog = new FakeElement("dialog", documentRef);
const first = new FakeElement("first", documentRef);
const last = new FakeElement("last", documentRef);
dialog.focusables = [first, last];
documentRef.activeElement = last;
const forward = keyEvent("Tab");
containTabKey(forward, dialog, documentRef);
assert.equal(forward.prevented, true);
assert.equal(documentRef.activeElement, first);
const backward = keyEvent("Tab", true);
containTabKey(backward, dialog, documentRef);
assert.equal(backward.prevented, true);
assert.equal(documentRef.activeElement, last);
});
test("background isolation is restored exactly", () => {
const documentRef = new FakeDocument();
const body = new FakeElement("body", documentRef);
const shell = new FakeElement("shell", documentRef);
const sidebar = new FakeElement("sidebar", documentRef);
const plugin = new FakeElement("plugin", documentRef);
const content = new FakeElement("content", documentRef);
const dialog = new FakeElement("dialog", documentRef);
documentRef.documentElement.append(body);
body.append(shell, sidebar);
shell.append(plugin);
plugin.append(content, dialog);
sidebar.setAttribute("aria-hidden", "false");
const restore = isolateModalBackground(dialog, documentRef);
assert.equal(content.inert, true);
assert.equal(sidebar.inert, true);
assert.equal(sidebar.getAttribute("aria-hidden"), "true");
restore();
assert.equal(content.inert, false);
assert.equal(content.getAttribute("aria-hidden"), null);
assert.equal(sidebar.inert, false);
assert.equal(sidebar.getAttribute("aria-hidden"), "false");
});
test("activation focuses the close action, Escape closes, and cleanup restores the opener", () => {
const documentRef = new FakeDocument();
const body = new FakeElement("body", documentRef);
const opener = new FakeElement("opener", documentRef);
const dialog = new FakeElement("dialog", documentRef);
const close = new FakeElement("close", documentRef);
documentRef.documentElement.append(body);
body.append(opener, dialog);
dialog.focusables = [close];
dialog.initialFocus = close;
documentRef.activeElement = opener;
let closeCalls = 0;
const cleanup = activateModal({
dialog,
documentRef,
onClose: () => { closeCalls += 1; },
});
assert.equal(documentRef.activeElement, close);
assert.equal(opener.inert, true);
const escape = keyEvent("Escape");
documentRef.listeners.get("keydown")(escape);
assert.equal(escape.prevented, true);
assert.equal(escape.propagationStopped, true);
assert.equal(closeCalls, 1);
cleanup();
assert.equal(documentRef.activeElement, opener);
assert.equal(opener.inert, false);
assert.equal(documentRef.listeners.has("keydown"), false);
});
+1 -1
View File
@@ -1,6 +1,6 @@
name: hermes-relay
manifest_version: 1
version: 1.6.0
version: 1.6.1
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
author: Axiom Labs
# All three are OPTIONAL — only needed if you use the relay's extra /
+1 -1
View File
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
# Desktop releases use desktop/package.json and desktop-v* tags. The /health endpoint
# reports this plugin version, and stale values make live diagnosis harder than
# it should be.
__version__ = "1.6.0"
__version__ = "1.6.1"
from .server import create_app, main # noqa: E402 — must come after __version__
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-relay"
version = "1.6.0"
version = "1.6.1"
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
requires-python = ">=3.11"
dependencies = [