Compare commits

...
Author SHA1 Message Date
Bailey Dixon 6b32c7aeef Merge remote-tracking branch 'origin/dev' into codex/pr-423-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 20:25:44 -04:00
Bailey Dixon 29706e1548 Merge pull request #422 from Codename-11/feature/android-bot-mode
feat(android): add multi-gateway bot mode
2026-08-24 20:24:58 -04:00
Bailey Dixon 6579b621ff Merge pull request #420 from Codename-11/fix/android-power-audit-377
fix(android): animate visible idle Sphere efficiently
2026-08-24 20:23:29 -04:00
Bailey Dixon befe8399ab Merge remote-tracking branch 'origin/dev' into codex/pr-420-integration
# Conflicts:
#	CHANGELOG.md
2026-08-24 20:13:41 -04:00
Bailey Dixon c10b87b94c Merge pull request #398 from JackHunzicker/fix/gateway-history-attachments
fix: retry Windows media paths and honor HERMES_HOME
2026-08-24 20:12:20 -04:00
Bailey Dixon 5e9d8840ae fix(android): clear stale chat busy state 2026-08-24 19:17:22 -04:00
Bailey Dixon e3512b9fa1 merge: refresh Android bot mode with dev
# Conflicts:
#	TODO.md
#	docs/localization-status.json
2026-08-24 18:16:29 -04:00
Bailey Dixon 40eff9c5c6 feat(android): add multi-gateway bot mode 2026-08-24 18:15:07 -04:00
Bailey Dixon c7c24b2874 merge: refresh Android idle sphere fix with dev 2026-08-24 16:28:31 -04:00
Bailey Dixon 3e8e0728db merge: refresh PR #398 with current dev
# Conflicts:
#	CHANGELOG.md
#	DEVLOG.md
2026-08-24 15:58:11 -04:00
Bailey Dixon b12712a79a Merge pull request #415 from Codename-11/docs/origin-dev-integration-authority
docs: make origin dev the integration authority
2026-08-24 12:34:55 -04:00
Bailey Dixon 1658439d05 fix(android): animate visible idle sphere efficiently 2026-08-24 11:57:26 -04:00
Jack 90ab705a88 docs: record attachment and relay config fixes 2026-08-23 18:47:51 -05:00
Jack 054aab1c09 fix(server): honor HERMES_HOME for relay config 2026-08-23 18:47:51 -05:00
Jack bae1762951 fix(android): retry Windows media paths by path 2026-08-23 18:47:50 -05:00
Bailey Dixon 27705d8291 merge: align desktop CUA runtime contract 2026-08-22 17:09:35 -04:00
44 changed files with 3770 additions and 33 deletions
+5
View File
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
### Changed
@@ -18,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **Android fresh chats no longer inherit a stale busy composer.** New-chat navigation settles visible streaming ownership even when a Gateway turn has already lost its live handle, and Stop remains an immediate escape hatch after the terminal bubble has settled. (#416, #418)
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
- **Android retries Windows-hosted `MEDIA:` attachments through Relay's by-path route.** A document deferred on cellular no longer treats `C:\...` as an opaque media token and reports it as expired.
- **Relay profile discovery follows `HERMES_HOME` by default.** Custom Hermes installations surface their real default profile and persist Relay sessions beside the active config while retaining the explicit `RELAY_HERMES_CONFIG` override.
- **Desktop daemon connections recover instead of exiting after an interrupted Relay socket.** Healthy daemons retry through Relay restarts and repeated failed reconnect attempts, oversized desktop-tool results fail within a bounded response instead of closing the shared WebSocket, and terminal failures leave an accurate stopped status for the tray.
- **Desktop computer control follows Hermes' current CUA Driver contract.** CUA Driver 0.20 and newer are accepted when their manifest, daemon/MCP arguments, required tools, and canonical path remain compatible, and Windows sessions use the manifest-declared direct standard-mode runtime instead of a potentially stale machine-wide daemon. Current 0.21 installations no longer fall back solely because of an obsolete upper version pin or daemon contract.
+12
View File
@@ -62,6 +62,18 @@ automotive device verified foreground preservation, AssistStructure and screensh
delivery, immediate listening, contextual response, and one-shot consumption;
broader firmware certification remains tracked in `TODO.md`.
## 2026-08-23 — Windows attachment retry and Hermes-home resolution
Android now recognizes Windows absolute paths during manual inbound-media retry.
Cellular-deferred `MEDIA:C:\...` documents use Relay's authenticated
`/media/by-path` route instead of being sent to the opaque-token route and
misreported as expired. A Robolectric/MockWebServer regression covers a spaced
Markdown filename and asserts the exact route and decoded path query.
Relay configuration now derives its default `config.yaml` and session-persistence
paths from `HERMES_HOME` when present. `RELAY_HERMES_CONFIG` remains the explicit
override. Focused Python tests cover both resolution paths.
## 2026-08-23 — GitHub Discussions community surface
GitHub Discussions is enabled as the repository's lightweight community surface.
+15
View File
@@ -6,6 +6,21 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Bot Mode follow-ups after multi-gateway aggregation
Android Bot Mode now has an all-gateway roster, typed `(connectionId, profile)`
ownership, install-identity collapse, source-qualified handles, offline cache,
route-pooled Gateway clients, and dedicated owner-routed Bot Chats without a
foreground connection switch. Keep autonomous cross-gateway delivery on
upstream peer/server authority rather than making Android an unreliable
background courier. Writable group rooms stay blocked until upstream publishes
one canonical room read/write/control contract; do not reproduce Desktop's
local orchestrator in the phone. Route-scoped outbound attachments, Relay media,
voice, and proactive completion notifications can be added independently when
their credential and lifecycle ownership is explicit.
---
## Certify Android assistant screen context on physical firmware
Host-side coverage and one Android 15 automotive device prove the primary flow.
@@ -0,0 +1,122 @@
package com.hermesandroid.relay.data
data class BotGatewayRouteKey(
val connectionId: String,
val profileName: String,
) {
init {
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
require(profileName.isNotBlank()) { "profileName must not be blank" }
}
}
class BotGatewayRoute(
val key: BotGatewayRouteKey,
val connectionLabel: String,
val installId: String? = null,
) {
val connectionId: String get() = key.connectionId
val profileName: String get() = key.profileName
override fun equals(other: Any?): Boolean = other is BotGatewayRoute && key == other.key
override fun hashCode(): Int = key.hashCode()
override fun toString(): String = "BotGatewayRoute(key=$key, label=$connectionLabel)"
}
/** Bounded session summary published by upstream `profiles.list`. */
data class BotSessionSummary(
val id: String,
val resolvedId: String = id,
val title: String = "",
val rootTitle: String = "",
val preview: String = "",
val startedAtMs: Long = 0L,
val lastActiveAtMs: Long = 0L,
val messageCount: Int = 0,
)
data class BotRosterEntry(
val profile: Profile,
val displayName: String,
val route: BotGatewayRoute? = null,
val handle: String = profile.name,
val stale: Boolean = false,
val botTitle: String = "",
val hidden: Boolean = false,
val lastSession: BotSessionSummary? = null,
val workerSession: BotSessionSummary? = null,
val canonicalSession: BotSessionSummary? = null,
) {
val latestActivityAtMs: Long
get() = maxOf(
canonicalSession?.lastActiveAtMs ?: 0L,
lastSession?.lastActiveAtMs ?: 0L,
)
val presenceActivityAtMs: Long
get() = maxOf(latestActivityAtMs, workerSession?.lastActiveAtMs ?: 0L)
val latestPreview: String
get() = canonicalSession?.preview?.takeIf(String::isNotBlank)
?: lastSession?.preview.orEmpty()
}
data class BotGroupMember(
val name: String,
val handle: String? = null,
val connectionId: String? = null,
val connectionLabel: String? = null,
)
data class BotGroupMessage(
val id: String? = null,
val senderName: String,
val senderKind: String,
val senderSource: String? = null,
val text: String,
val atMs: Long,
)
data class BotGroupRoom(
val key: String,
val roomId: String? = null,
val name: String,
val revision: Long = 0L,
val members: List<BotGroupMember> = emptyList(),
val messages: List<BotGroupMessage> = emptyList(),
val sourceConnectionIds: Set<String> = emptySet(),
val stale: Boolean = false,
) {
val latestMessage: BotGroupMessage? get() = messages.maxByOrNull(BotGroupMessage::atMs)
val latestActivityAtMs: Long get() = latestMessage?.atMs ?: 0L
}
data class BotModeRoster(
val bots: List<BotRosterEntry> = emptyList(),
val groups: List<BotGroupRoom> = emptyList(),
val botModeProtocolSupported: Boolean = false,
)
data class BotGatewayRosterStatus(
val connectionId: String,
val label: String,
val installId: String? = null,
val loading: Boolean = false,
val stale: Boolean = false,
val error: String? = null,
val botCount: Int = 0,
)
data class BotChatTarget(
/** Durable registry-row identity. */
val storedSessionId: String,
/** Compression-lineage tip that should be resumed. */
val resolvedSessionId: String = storedSessionId,
)
data class BotModeState(
val loading: Boolean = false,
val roster: BotModeRoster = BotModeRoster(),
val gateways: List<BotGatewayRosterStatus> = emptyList(),
val error: String? = null,
)
@@ -56,6 +56,7 @@ import okio.BufferedSink
@Serializable
data class DashboardStatus(
val authRequired: Boolean,
@SerialName("install_id") val installId: String? = null,
val authProviders: List<String> = emptyList(),
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
@@ -1464,8 +1465,16 @@ class DashboardApiClient(
fun authLoginUrl(provider: String, next: String = "/"): String =
authLoginUrl(baseUrl = baseUrl, provider = provider, next = next)
fun gatewayWebSocketUrl(ticket: String, path: String = "/api/ws"): String? =
gatewayWebSocketUrl(baseUrl = baseUrl, ticket = ticket, path = path)
fun gatewayWebSocketUrl(
ticket: String,
path: String = "/api/ws",
profile: String? = null,
): String? = gatewayWebSocketUrl(
baseUrl = baseUrl,
ticket = ticket,
path = path,
profile = profile,
)
fun shutdown() = shutdownOffMainThread("DashboardApiClient-shutdown") {
okHttpClient.dispatcher.executorService.shutdown()
@@ -1722,6 +1731,7 @@ class DashboardApiClient(
authRequired = root.booleanField("auth_required")
?: authObject.booleanField("required")
?: false,
installId = root.stringField("install_id")?.trim()?.takeIf(String::isNotEmpty)?.take(256),
authProviders = providers.map { it.name },
authProviderDetails = providers,
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
@@ -14,6 +14,13 @@ import com.hermesandroid.relay.data.GatewayProfilePatch
import com.hermesandroid.relay.data.GatewayProfileSection
import com.hermesandroid.relay.data.GatewayProfileSkill
import com.hermesandroid.relay.data.GatewayProfileToolset
import com.hermesandroid.relay.data.BotChatTarget
import com.hermesandroid.relay.data.BotGroupMember
import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotModeRoster
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.BotSessionSummary
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.isSafeProfileUiMeta
import com.hermesandroid.relay.network.upstream.models.MessageItem
@@ -91,6 +98,7 @@ import java.util.concurrent.atomic.AtomicLong
*/
class GatewayChatClient(
initialDashboardClient: DashboardApiClient,
private val fixedSessionProfile: String? = null,
okHttpClient: OkHttpClient? = null,
private val callbackDispatcher: (block: () -> Unit) -> Unit = MainThreadDispatcher,
/** Surface for "this server has no usable /api/ws" — flips availability to Unsupported. */
@@ -123,6 +131,7 @@ class GatewayChatClient(
private var profileSetAssetSupported: Boolean? = null
companion object {
private const val TAG = "GatewayChatClient"
private const val BOT_CHAT_TITLE = "Bot Chat"
/**
* Idle-progress turn watchdog — reset on EVERY received gateway event
@@ -389,7 +398,8 @@ class GatewayChatClient(
var sessionProfileProvider: () -> String? = { null }
private fun currentSessionProfile(): String? =
sessionProfileProvider().takeIf { !it.isNullOrBlank() }
fixedSessionProfile?.trim()?.takeIf(String::isNotBlank)
?: sessionProfileProvider().takeIf { !it.isNullOrBlank() }
/**
* Supplies non-model overrides for each fresh `session.create`. Model and
@@ -1480,6 +1490,102 @@ class GatewayChatClient(
}.onSuccess { profileListSupported = true }
}
/**
* Rich Bot Mode roster from the upstream Gateway. Kept separate from
* [listProfiles] because session previews and room projections are useful
* to the messenger surface but needlessly expensive for ordinary profile
* selectors.
*/
suspend fun listBotModeRoster(): Result<BotModeRoster> {
if (profileListSupported == false) {
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.list"))
}
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
val response = rpc(
"profiles.list",
buildJsonObject { put("include_sessions", true) },
)
if (response.exceptionOrNull().isMethodNotFound()) {
profileListSupported = false
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.list"))
}
return response.mapCatching(::parseBotModeRoster)
.onSuccess { profileListSupported = true }
}
/**
* Resolve the profile's one canonical hidden `Bot Chat`, creating it only
* after an authoritative exact-title lookup returned no row. Lookup errors
* fail closed so a transient connection problem can never fork the bot's
* durable conversation.
*/
suspend fun ensureCanonicalBotChat(profileName: String): Result<BotChatTarget> = runCatching {
val profile = profileName.trim().takeIf(String::isNotEmpty)
?: throw IllegalArgumentException("profile name required")
connectMutex.withLock {
ensureConnected()
val existing = rpc(
"session.list",
buildJsonObject {
put("profile", profile)
put("title", BOT_CHAT_TITLE)
put("include_hidden", true)
put("limit", 200)
},
).getOrElse { error ->
throw GatewayPreflightException(
"Could not check $profile's Bot Chat registry: ${error.message}",
)
}
val row = (existing["sessions"] as? JsonArray)
?.firstOrNull() as? JsonObject
if (row != null) {
val stored = row.stringField("id")?.takeIf(String::isNotBlank)
?: throw GatewayPreflightException("Bot Chat registry returned no session id")
val resolved = row.stringField("resolved_id")?.takeIf(String::isNotBlank) ?: stored
return@withLock BotChatTarget(storedSessionId = stored, resolvedSessionId = resolved)
}
if (hasActiveTurn()) {
throw GatewayPreflightException("Wait for the current Hermes turn to finish before creating Bot Chat")
}
val created = rpc(
"session.create",
buildJsonObject {
put("cols", DEFAULT_COLS)
put("source", sessionSource)
put("profile", profile)
put("title", BOT_CHAT_TITLE)
put("hidden", true)
},
).getOrElse { error ->
throw GatewayPreflightException("Bot Chat creation failed: ${error.message}")
}
requireConfirmedSessionProfile(created, profile)
val live = created.stringField("session_id")
?: throw GatewayPreflightException("Bot Chat creation returned no session id")
val stored = created.stringField("stored_session_id") ?: live
// `session.create` is lazy. Title the live runtime immediately so
// the durable exact-title registry exists before navigation or a
// second tap; newer upstream materializes the row here.
rpc(
"session.title",
buildJsonObject {
put("session_id", live)
put("title", BOT_CHAT_TITLE)
},
).getOrElse { error ->
throw GatewayPreflightException("Bot Chat could not be materialized: ${error.message}")
}
BotChatTarget(storedSessionId = stored, resolvedSessionId = stored)
}
}
/** Create through the Gateway so auth behavior is explicit and server-owned. */
suspend fun createProfile(request: GatewayProfileCreateRequest): Result<GatewayProfileCreateResult> {
if (profileCreateSupported == false) {
@@ -2383,7 +2489,10 @@ class GatewayChatClient(
throw GatewayConnectAttemptException("ws-ticket mint failed: ${e.message}")
}
val ticketMs = (System.nanoTime() - connectStart) / 1_000_000
val url = dashboardClient.gatewayWebSocketUrl(ticket.ticket)
val url = dashboardClient.gatewayWebSocketUrl(
ticket = ticket.ticket,
profile = currentSessionProfile(),
)
?: throw GatewayConnectAttemptException("could not build /api/ws URL")
_connectionState.value = GatewayConnectionState.Connecting
@@ -4149,6 +4258,122 @@ data class GatewayCompressResult(
get() = messages.isNotEmpty()
}
internal fun parseBotModeRoster(payload: JsonObject): BotModeRoster {
val rawRows = (payload["profiles"] as? JsonArray).orEmpty()
val rows = rawRows.mapNotNull { it as? JsonObject }
val bots = rows.mapNotNull(::parseBotRosterEntry)
val defaultRow = rows.firstOrNull {
(it["is_default"] as? JsonPrimitive)?.booleanOrNull == true
} ?: rows.firstOrNull { it.stringField("name") == "default" }
return BotModeRoster(
bots = bots,
groups = parseBotGroupRooms(defaultRow),
botModeProtocolSupported =
(payload["bot_mode_protocol"] as? JsonPrimitive)?.booleanOrNull == true,
)
}
private fun parseBotRosterEntry(row: JsonObject): BotRosterEntry? {
val name = row.stringField("name")?.trim()?.takeIf(String::isNotEmpty) ?: return null
val uiMeta = (row["ui_meta"] as? JsonObject)
?.takeIf { it.toString().toByteArray(Charsets.UTF_8).size <= 65_536 }
?: JsonObject(emptyMap())
val botMeta = uiMeta["hermes-bots"] as? JsonObject
val title = botMeta?.stringField("title")?.trim()?.take(128).orEmpty()
val displayName = title.takeIf(String::isNotBlank)
?: row.stringField("display_name")?.trim()?.takeIf(String::isNotBlank)?.take(128)
?: name
return BotRosterEntry(
profile = Profile(
name = name,
model = row.stringField("model").orEmpty(),
provider = row.stringField("provider").orEmpty(),
description = row.stringField("description")?.take(512).orEmpty(),
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
),
displayName = displayName,
botTitle = title,
hidden = (botMeta?.get("hidden") as? JsonPrimitive)?.booleanOrNull == true,
lastSession = parseBotSessionSummary(row["last_session"] as? JsonObject),
workerSession = parseBotSessionSummary(row["worker_session"] as? JsonObject),
canonicalSession = parseBotSessionSummary(row["canonical_session"] as? JsonObject),
)
}
private fun parseBotSessionSummary(row: JsonObject?): BotSessionSummary? {
row ?: return null
val id = row.stringField("id")?.trim()?.takeIf(String::isNotEmpty) ?: return null
return BotSessionSummary(
id = id,
resolvedId = row.stringField("resolved_id")?.trim()?.takeIf(String::isNotEmpty) ?: id,
title = row.stringField("title")?.take(256).orEmpty(),
rootTitle = row.stringField("root_title")?.take(256).orEmpty(),
preview = row.stringField("preview")?.take(512).orEmpty(),
startedAtMs = normalizeHermesEpoch(row.longField("started_at")),
lastActiveAtMs = normalizeHermesEpoch(row.longField("last_active")),
messageCount = (row["message_count"] as? JsonPrimitive)?.intOrNull ?: 0,
)
}
private fun parseBotGroupRooms(defaultRow: JsonObject?): List<BotGroupRoom> {
val uiMeta = defaultRow?.get("ui_meta") as? JsonObject ?: return emptyList()
if (uiMeta.toString().toByteArray(Charsets.UTF_8).size > 65_536) return emptyList()
val snapshot = uiMeta["hermes-bots-groups"] as? JsonObject ?: return emptyList()
val rooms = snapshot["rooms"] as? JsonObject ?: return emptyList()
return rooms.entries.take(64).mapNotNull { (key, raw) ->
val room = raw as? JsonObject ?: return@mapNotNull null
val name = room.stringField("name")?.trim()?.takeIf(String::isNotEmpty)?.take(128)
?: key.substringAfter(':').take(128)
val members = (room["members"] as? JsonArray).orEmpty().take(6).mapNotNull { memberRaw ->
val member = memberRaw as? JsonObject ?: return@mapNotNull null
val memberName = member.stringField("name")?.trim()?.takeIf(String::isNotEmpty)
?: return@mapNotNull null
BotGroupMember(
name = memberName.take(128),
handle = member.stringField("handle")?.take(128),
connectionId = member.stringField("connectionId")?.take(128),
connectionLabel = member.stringField("connectionLabel")?.take(128),
)
}
val messages = (room["log"] as? JsonArray).orEmpty().takeLast(16).mapNotNull { messageRaw ->
val message = messageRaw as? JsonObject ?: return@mapNotNull null
val from = message["from"] as? JsonObject ?: JsonObject(emptyMap())
val text = message.stringField("text")?.trim()?.takeIf(String::isNotEmpty)?.take(1_200)
?: return@mapNotNull null
BotGroupMessage(
id = message.stringField("id")?.take(160),
senderName = from.stringField("name")?.trim()?.takeIf(String::isNotEmpty)?.take(128)
?: "Bot",
senderKind = from.stringField("kind")?.take(32) ?: "member",
senderSource = from.stringField("source")?.take(128),
text = text,
atMs = normalizeHermesEpoch(message.longField("at")),
)
}
BotGroupRoom(
key = key.take(256),
roomId = room.stringField("roomId")?.take(128),
name = name,
revision = room.longField("revision"),
members = members,
messages = messages,
)
}.sortedByDescending(BotGroupRoom::latestActivityAtMs)
}
private fun JsonObject.longField(key: String): Long =
(get(key) as? JsonPrimitive)?.longOrNull
?: (get(key) as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.toLong()
?: 0L
private fun normalizeHermesEpoch(value: Long): Long = when {
value <= 0L -> 0L
value < 10_000_000_000L -> value * 1_000L
else -> value
}
private fun Throwable?.isMethodNotFound(): Boolean {
val rpcError = this as? GatewayRpcException ?: return false
if (rpcError.code == JSONRPC_METHOD_NOT_FOUND) return true
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.MaterialTheme
@@ -153,6 +154,9 @@ import com.hermesandroid.relay.ui.screens.DiagnosticsScreen
import com.hermesandroid.relay.ui.screens.BridgeScreen
// === PHASE3-safety-rails: bridge safety route ===
import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
import com.hermesandroid.relay.ui.screens.BotGroupDetailScreen
import com.hermesandroid.relay.ui.screens.BotChatScreen
import com.hermesandroid.relay.ui.screens.BotModeScreen
// === END PHASE3-safety-rails ===
import com.hermesandroid.relay.ui.screens.ChatScreen
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
@@ -397,6 +401,28 @@ sealed class Screen(
return if (params.isEmpty()) "chat" else "chat?${params.joinToString("&")}"
}
}
data object BotMode : Screen("bot_mode", "Bot Mode", Icons.Filled.Groups)
data object BotGroup : Screen(
"bot_mode/groups/{roomKey}",
"Bot group",
Icons.Filled.Groups,
) {
const val ARG_ROOM_KEY: String = "roomKey"
fun route(roomKey: String): String =
"bot_mode/groups/${android.net.Uri.encode(roomKey)}"
}
data object BotChat : Screen(
"bot_mode/chat/{connectionId}/{profileName}/{sessionId}",
"Bot Chat",
Icons.AutoMirrored.Filled.Chat,
) {
const val ARG_CONNECTION_ID: String = "connectionId"
const val ARG_PROFILE_NAME: String = "profileName"
const val ARG_SESSION_ID: String = "sessionId"
fun route(connectionId: String, profileName: String, sessionId: String): String =
"bot_mode/chat/${android.net.Uri.encode(connectionId)}/" +
"${android.net.Uri.encode(profileName)}/${android.net.Uri.encode(sessionId)}"
}
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
@@ -672,6 +698,7 @@ fun RelayApp() {
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
val connections by connectionViewModel.connections.collectAsState()
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val relayVoiceReady by connectionViewModel.relayVoiceReady.collectAsState()
@@ -2027,8 +2054,108 @@ fun RelayApp() {
launchSingleTop = true
}
},
onNavigateToBotMode = {
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
},
)
}
composable(Screen.BotMode.route) {
BotModeScreen(
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
onOpenBotChat = { route, sessionId ->
navController.navigate(
Screen.BotChat.route(
connectionId = route.connectionId,
profileName = route.profileName,
sessionId = sessionId,
),
)
},
onOpenGroup = { roomKey ->
navController.navigate(Screen.BotGroup.route(roomKey))
},
)
}
composable(
route = Screen.BotGroup.route,
arguments = listOf(
navArgument(Screen.BotGroup.ARG_ROOM_KEY) { type = NavType.StringType },
),
) { entry ->
val roomKey = entry.arguments?.getString(Screen.BotGroup.ARG_ROOM_KEY)
val botModeState by connectionViewModel.botModeState.collectAsState()
BotGroupDetailScreen(
room = botModeState.roster.groups.firstOrNull { it.key == roomKey },
onBack = { navController.popBackStack() },
)
}
composable(
route = Screen.BotChat.route,
arguments = listOf(
navArgument(Screen.BotChat.ARG_CONNECTION_ID) { type = NavType.StringType },
navArgument(Screen.BotChat.ARG_PROFILE_NAME) { type = NavType.StringType },
navArgument(Screen.BotChat.ARG_SESSION_ID) { type = NavType.StringType },
),
) { entry ->
val connectionId = entry.arguments?.getString(Screen.BotChat.ARG_CONNECTION_ID).orEmpty()
val profileName = entry.arguments?.getString(Screen.BotChat.ARG_PROFILE_NAME).orEmpty()
val sessionId = entry.arguments?.getString(Screen.BotChat.ARG_SESSION_ID).orEmpty()
val botModeState by connectionViewModel.botModeState.collectAsState()
val connection = connections.firstOrNull { it.id == connectionId }
val bot = botModeState.roster.bots.firstOrNull {
it.route?.connectionId == connectionId && it.profile.name == profileName
}
val route = bot?.route ?: connection?.let {
com.hermesandroid.relay.data.BotGatewayRoute(
key = com.hermesandroid.relay.data.BotGatewayRouteKey(connectionId, profileName),
connectionLabel = it.label,
)
}
val currentRouteUrl = connection?.let {
if (it.id == activeConnectionId) effectiveDashboardUrl else it.resolvedDashboardUrl
}.orEmpty()
val lease = remember(route?.key, currentRouteUrl) {
route?.let(connectionViewModel::acquireBotGateway)?.getOrNull()
}
val botDashboardClient = remember(route?.key, currentRouteUrl) {
route?.let(connectionViewModel::botDashboardClient)?.getOrNull()
}
DisposableEffect(lease, botDashboardClient) {
onDispose {
lease?.close()
botDashboardClient?.shutdown()
}
}
if (
route == null || bot == null || lease == null ||
botDashboardClient == null || sessionId.isBlank()
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
stringResource(R.string.bot_mode_chat_open_failed),
color = MaterialTheme.colorScheme.error,
)
}
} else {
val botChatViewModel: ChatViewModel = viewModel(
key = "bot-chat:${route.connectionId}:${route.profileName}:$sessionId",
)
BotChatScreen(
route = route,
bot = bot,
sessionId = sessionId,
gatewayClient = lease.client,
dashboardClient = botDashboardClient,
chatViewModel = botChatViewModel,
connectionViewModel = connectionViewModel,
onBack = { navController.popBackStack() },
)
}
}
composable(Screen.Manage.route) {
if (isDemoMode) {
// Demo is offline — Manage talks to the live dashboard,
@@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
@@ -25,6 +26,8 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.theme.LocalBrand
import kotlinx.coroutines.delay
import kotlin.math.sin
/**
* ASCII morphing sphere — the visual embodiment of the AI agent.
@@ -53,6 +56,32 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
private const val SPHERE_TIME_UNITS_PER_SEC = 1f
private const val SPHERE_TWO_PI = 6.2832f
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
private const val SPHERE_IDLE_BREATH_RADIANS_PER_SEC = 0.72f
private const val SPHERE_IDLE_BREATH_SCALE = 0.012f
private const val SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS = 184L
internal enum class SphereMotionMode {
Still,
AmbientLayer,
Procedural,
}
internal fun sphereMotionMode(
state: SphereState,
voiceMode: Boolean,
motionVisible: Boolean,
fixedTime: Float?,
fixedColorPhase: Float?,
): SphereMotionMode {
if (!motionVisible || fixedTime != null || fixedColorPhase != null) {
return SphereMotionMode.Still
}
return if (state == SphereState.Idle && !voiceMode) {
SphereMotionMode.AmbientLayer
} else {
SphereMotionMode.Procedural
}
}
@Composable
fun MorphingSphere(
@@ -64,7 +93,8 @@ fun MorphingSphere(
voiceMode: Boolean = false,
skin: SphereSkin = LocalSphereSkin.current,
fixedTime: Float? = null,
fixedColorPhase: Float? = null
fixedColorPhase: Float? = null,
motionVisible: Boolean = true,
) {
val brand = LocalBrand.current
// Gate reactive inputs on what the skin declares it honors — this is the
@@ -104,16 +134,21 @@ fun MorphingSphere(
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
// Continuous motion runs only for active agent/voice states. Idle is a
// stable frame: the 58x34 text grid is expensive enough that even a
// throttled cosmetic drift dominated measured screen-on CPU. Active states
// retain full display-rate motion and dt-based timing.
// Active states retain the full procedural animation. Visible Idle uses a
// lightweight graphics-layer breath: redrawing the 58x34 glyph grid just
// for ambient drift was the measured screen-on hotspot, while transforming
// its cached layer preserves the intended living Sphere at far lower cost.
val animatedTime = remember { mutableFloatStateOf(0f) }
val animatedColorPhase = remember { mutableFloatStateOf(0f) }
val fullFrameRate = state != SphereState.Idle || effVoiceMode
val driveAnimation = (fixedTime == null || fixedColorPhase == null) && fullFrameRate
if (driveAnimation) {
LaunchedEffect(fullFrameRate) {
val motionMode = sphereMotionMode(
state = state,
voiceMode = effVoiceMode,
motionVisible = motionVisible,
fixedTime = fixedTime,
fixedColorPhase = fixedColorPhase,
)
if (motionMode == SphereMotionMode.Procedural) {
LaunchedEffect(motionMode) {
var lastNanos = withFrameNanos { it }
while (true) {
val now = withFrameNanos { it }
@@ -127,6 +162,25 @@ fun MorphingSphere(
}
}
}
val idleBreathPhase = remember { mutableFloatStateOf(0f) }
LaunchedEffect(motionMode) {
if (motionMode != SphereMotionMode.AmbientLayer) {
idleBreathPhase.floatValue = 0f
return@LaunchedEffect
}
var lastNanos = withFrameNanos { it }
while (true) {
val now = withFrameNanos { it }
val dtSec = (now - lastNanos).coerceAtLeast(0L) / 1_000_000_000f
lastNanos = now
idleBreathPhase.floatValue =
(idleBreathPhase.floatValue + dtSec * SPHERE_IDLE_BREATH_RADIANS_PER_SEC) %
SPHERE_TWO_PI
// The frame wait plus this delay caps the gentle layer-only pulse
// near 5fps while active procedural states retain display-rate motion.
delay(SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS)
}
}
val time = fixedTime ?: animatedTime.floatValue
val colorPhase = fixedColorPhase ?: animatedColorPhase.floatValue
@@ -138,7 +192,18 @@ fun MorphingSphere(
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
val glyphStrings = remember { HashMap<Char, String>(32) }
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
Canvas(
modifier = modifier
.fillMaxSize()
.graphicsLayer {
if (motionMode == SphereMotionMode.AmbientLayer) {
val scale = 1f + sin(idleBreathPhase.floatValue) * SPHERE_IDLE_BREATH_SCALE
scaleX = scale
scaleY = scale
}
}
.clipToBounds(),
) {
val canvasW = size.width
val canvasH = size.height
val cellW = canvasW / cols
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
@@ -203,6 +204,8 @@ fun SessionDrawerContent(
autoTitlesSupported: Boolean = true,
archiveSupported: Boolean = true,
onRefresh: (() -> Unit)? = null,
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
onOpenBotMode: (() -> Unit)? = null,
onNewChat: () -> Unit,
onNewDefaultChat: (() -> Unit)? = null,
onSelectSession: (String) -> Unit,
@@ -526,6 +529,28 @@ fun SessionDrawerContent(
Text(stringResource(R.string.drawer_new_chat))
}
onOpenBotMode?.let { openBotMode ->
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = openBotMode,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Groups, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.Start,
) {
Text(stringResource(R.string.bot_mode_title))
Text(
stringResource(R.string.bot_mode_drawer_summary),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (searchExpanded || query.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
@@ -1,9 +1,12 @@
package com.hermesandroid.relay.ui.components.avatar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.hermesandroid.relay.ui.components.MorphingSphere
import com.hermesandroid.relay.ui.components.SphereReactivity
import com.hermesandroid.relay.util.AppForegroundTracker
/**
* Default ambient visualization — the ASCII [MorphingSphere].
@@ -34,6 +37,7 @@ object SphereAvatar : AgentAvatar {
@Composable
override fun Render(state: AvatarRenderState, modifier: Modifier) {
val appForeground by AppForegroundTracker.isForeground.collectAsState()
MorphingSphere(
modifier = modifier,
state = state.state,
@@ -46,6 +50,7 @@ object SphereAvatar : AgentAvatar {
// call did with fixedTime/fixedColorPhase = 0f.
fixedTime = if (state.paused) 0f else null,
fixedColorPhase = if (state.paused) 0f else null,
motionVisible = appForeground,
)
}
}
@@ -0,0 +1,310 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.ui.components.MessageBubble
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.viewmodel.ChatViewModel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BotChatScreen(
route: BotGatewayRoute,
bot: BotRosterEntry,
sessionId: String,
gatewayClient: GatewayChatClient,
dashboardClient: DashboardApiClient,
chatViewModel: ChatViewModel,
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
) {
val handler = remember(route.key) { ChatHandler() }
val context = LocalContext.current
val messages by chatViewModel.messages.collectAsState()
val isStreaming by chatViewModel.isStreaming.collectAsState()
val isLoading by chatViewModel.isLoadingHistory.collectAsState()
val error by chatViewModel.error.collectAsState()
val iconPath by connectionViewModel
.profileIconFlow(route.connectionId, route.profileName)
.collectAsState(initial = null)
val listState = rememberLazyListState()
var composer by remember(route.key, sessionId) { mutableStateOf("") }
DisposableEffect(chatViewModel, gatewayClient, dashboardClient, route.key) {
chatViewModel.initialize(apiClient = null, chatHandler = handler)
chatViewModel.initializeGatewayOnly(context)
chatViewModel.streamingEndpoint = "gateway"
chatViewModel.sseFallbackEndpoint = "sessions"
chatViewModel.setSelectedProfileProvider { bot.profile }
chatViewModel.setSessionProfileNameProvider { route.profileName }
chatViewModel.setEffectiveProfileProvider { bot.profile }
chatViewModel.setDisplayProfileProvider { bot.profile }
chatViewModel.setDisplayAliasProvider { bot.displayName }
chatViewModel.setIsolatedProfileApiProvider { false }
chatViewModel.setProfileSelectionHandler { selected ->
selected?.name == route.profileName
}
chatViewModel.setProfileMessageLoaderWithMode { _, storedSessionId, mode ->
dashboardClient.getSessionMessages(
sessionId = storedSessionId,
profile = route.profileName,
mode = mode,
)
}
chatViewModel.updateApiClient(null)
chatViewModel.updateGatewayClient(gatewayClient)
chatViewModel.setCanonicalBotChatMode(true)
chatViewModel.setChatVisible(true)
chatViewModel.openProfileSession(
profileName = route.profileName,
profile = bot.profile,
contextKey = AgentDisplay.profileContextKey(route.connectionId, route.profileName),
sessionId = sessionId,
)
onDispose {
chatViewModel.setChatVisible(false)
chatViewModel.setCanonicalBotChatMode(false)
chatViewModel.updateGatewayClient(null)
}
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
}
Scaffold(
containerColor = RelayRefresh.Background,
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.bot_mode_back_to_bots),
)
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
modifier = Modifier.size(40.dp),
shape = CircleShape,
color = RelayRefresh.Navy3,
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
) {
if (!iconPath.isNullOrBlank()) {
AsyncImage(
model = File(iconPath.orEmpty()),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(contentAlignment = Alignment.Center) {
Text(
bot.displayName.firstOrNull()?.uppercase() ?: "H",
color = RelayRefresh.Relay,
fontWeight = FontWeight.Bold,
)
}
}
}
Spacer(Modifier.width(10.dp))
Column {
Text(
bot.displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"${route.connectionLabel} · @${bot.handle}",
style = MaterialTheme.typography.labelSmall,
color = if (bot.stale) {
MaterialTheme.colorScheme.error
} else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = RelayRefresh.Background,
),
)
},
bottomBar = {
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
modifier = Modifier.imePadding(),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Bottom,
) {
OutlinedTextField(
value = composer,
onValueChange = { composer = it },
placeholder = { Text(stringResource(R.string.chat_placeholder_message)) },
modifier = Modifier.weight(1f),
minLines = 1,
maxLines = 6,
)
Spacer(Modifier.width(6.dp))
IconButton(
onClick = {
if (isStreaming) {
chatViewModel.cancelStream()
} else {
val text = composer.trim()
if (text.isNotEmpty()) {
composer = ""
chatViewModel.sendMessage(text)
}
}
},
enabled = isStreaming || composer.isNotBlank(),
) {
Icon(
if (isStreaming) Icons.Filled.Stop else Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(
if (isStreaming) R.string.chat_input_stop_streaming
else R.string.chat_input_send_message,
),
)
}
}
}
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
when {
isLoading && messages.isEmpty() -> CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.size(28.dp),
strokeWidth = 2.dp,
)
messages.isEmpty() -> Column(
modifier = Modifier
.align(Alignment.Center)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
stringResource(R.string.bot_mode_no_messages),
style = MaterialTheme.typography.titleMedium,
)
Text(
bot.profile.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 14.dp,
vertical = 10.dp,
),
) {
itemsIndexed(messages, key = { _, message -> message.uiKey }) { index, message ->
val first = index == 0 || messages[index - 1].role != message.role
val last = index == messages.lastIndex || messages[index + 1].role != message.role
MessageBubble(
message = message,
modifier = Modifier.padding(top = if (first) 6.dp else 1.dp),
maxBubbleWidth = 344.dp,
isFirstInGroup = first,
isLastInGroup = last,
onAttachmentRetry = chatViewModel::manualFetchAttachment,
onAttachmentManualFetch = chatViewModel::manualFetchAttachment,
onCardAction = chatViewModel::dispatchCardAction,
onCardInput = chatViewModel::answerAsk,
)
}
}
}
error?.takeIf(String::isNotBlank)?.let { message ->
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(10.dp),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(12.dp),
) {
Text(
message,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(10.dp),
)
}
}
}
}
}
@@ -0,0 +1,937 @@
package com.hermesandroid.relay.ui.screens
import android.text.format.DateUtils
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotModeState
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import java.io.File
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
internal enum class BotModeFilter { All, Bots, Groups }
private sealed interface BotModeRow {
val activityAtMs: Long
data class Bot(val value: BotRosterEntry) : BotModeRow {
override val activityAtMs: Long = value.latestActivityAtMs
}
data class Group(val value: BotGroupRoom) : BotModeRow {
override val activityAtMs: Long = value.latestActivityAtMs
}
}
@Composable
fun BotModeScreen(
connectionViewModel: ConnectionViewModel,
onBack: () -> Unit,
onOpenBotChat: (route: BotGatewayRoute, sessionId: String) -> Unit,
onOpenGroup: (roomKey: String) -> Unit,
) {
val state by connectionViewModel.botModeState.collectAsState()
val connections by connectionViewModel.connections.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val scope = rememberCoroutineScope()
val resources = LocalResources.current
val snackbar = remember { SnackbarHostState() }
var openingProfile by remember { mutableStateOf<String?>(null) }
var showCreateBot by remember { mutableStateOf(false) }
var creatingBot by remember { mutableStateOf(false) }
var selectedGatewayId by rememberSaveable { mutableStateOf<String?>(null) }
val chatOpenFailed = stringResource(R.string.bot_mode_chat_open_failed)
val botCreateFailed = stringResource(R.string.bot_mode_create_failed)
LaunchedEffect(activeConnection?.id) {
while (true) {
connectionViewModel.refreshBotMode()
delay(BOT_MODE_REFRESH_MS)
}
}
LaunchedEffect(connections, selectedGatewayId) {
if (selectedGatewayId != null && connections.none { it.id == selectedGatewayId }) {
selectedGatewayId = null
}
}
BotModeContent(
state = state,
connections = connections,
activeConnection = activeConnection,
selectedGatewayId = selectedGatewayId,
onBack = onBack,
onRefresh = connectionViewModel::refreshBotMode,
onSelectGateway = { selectedGatewayId = it },
openingProfile = openingProfile,
onOpenBot = { bot ->
val route = bot.route ?: return@BotModeContent
openingProfile = bot.profile.name
scope.launch {
val result = connectionViewModel.ensureCanonicalBotChat(route)
.map { it.resolvedSessionId }
result.fold(
onSuccess = { onOpenBotChat(route, it) },
onFailure = { snackbar.showSnackbar(it.message ?: chatOpenFailed) },
)
openingProfile = null
}
},
onOpenGroup = { onOpenGroup(it.key) },
onNewBot = { showCreateBot = true },
snackbarHost = { SnackbarHost(snackbar) },
botAvatar = { bot, size ->
BotProfileAvatar(
connectionViewModel = connectionViewModel,
bot = bot,
size = size,
)
},
)
if (showCreateBot) {
CreateBotDialog(
saving = creatingBot,
onDismiss = { showCreateBot = false },
onCreate = { name, title, description ->
scope.launch {
creatingBot = true
val targetConnectionId = selectedGatewayId ?: activeConnection?.id
val createResult = if (targetConnectionId == null) {
Result.failure(IllegalStateException(botCreateFailed))
} else {
connectionViewModel.createBot(
targetConnectionId,
name,
title,
description,
)
}
createResult.fold(
onSuccess = {
showCreateBot = false
snackbar.showSnackbar(
resources.getString(R.string.bot_mode_created, title),
)
},
onFailure = { snackbar.showSnackbar(it.message ?: botCreateFailed) },
)
creatingBot = false
}
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun BotModeContent(
state: BotModeState,
connections: List<Connection>,
activeConnection: Connection?,
selectedGatewayId: String? = null,
onBack: () -> Unit,
onRefresh: () -> Unit,
onSelectGateway: (String?) -> Unit,
openingProfile: String? = null,
onOpenBot: (BotRosterEntry) -> Unit,
onOpenGroup: (BotGroupRoom) -> Unit,
onNewBot: () -> Unit,
snackbarHost: @Composable () -> Unit = {},
nowMs: Long = System.currentTimeMillis(),
botAvatar: @Composable (BotRosterEntry, Dp) -> Unit = { bot, size ->
BotFallbackAvatar(bot.displayName, size)
},
) {
var filter by remember { mutableStateOf(BotModeFilter.All) }
var searchOpen by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
var gatewayMenuOpen by remember { mutableStateOf(false) }
val visibleBots = state.roster.bots
.filterNot(BotRosterEntry::hidden)
.filter { selectedGatewayId == null || it.route?.connectionId == selectedGatewayId }
val visibleGroups = state.roster.groups.filter { group ->
selectedGatewayId == null || selectedGatewayId in group.sourceConnectionIds
}
val activeBots = visibleBots.filter { bot ->
!bot.stale && bot.presenceActivityAtMs >= nowMs - ACTIVE_WINDOW_MS
}.sortedByDescending(BotRosterEntry::presenceActivityAtMs).take(6)
val needle = query.trim()
val rows = buildList<BotModeRow> {
if (filter != BotModeFilter.Groups) {
addAll(visibleBots.filter { bot ->
needle.isBlank() || listOf(
bot.displayName,
bot.profile.name,
bot.profile.description,
bot.latestPreview,
).any { it.contains(needle, ignoreCase = true) }
}.map(BotModeRow::Bot))
}
if (filter != BotModeFilter.Bots) {
addAll(visibleGroups.filter { room ->
needle.isBlank() || room.name.contains(needle, ignoreCase = true) ||
room.latestMessage?.text.orEmpty().contains(needle, ignoreCase = true)
}.map(BotModeRow::Group))
}
}.sortedByDescending(BotModeRow::activityAtMs)
Scaffold(
containerColor = RelayRefresh.Background,
snackbarHost = snackbarHost,
topBar = {
TopAppBar(
title = {
Text(
stringResource(R.string.bot_mode_title),
fontWeight = FontWeight.Bold,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.onboarding_back),
)
}
},
actions = {
IconButton(onClick = { searchOpen = !searchOpen }) {
Icon(
Icons.Filled.Search,
contentDescription = stringResource(R.string.bot_mode_search),
tint = if (searchOpen || query.isNotBlank()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = RelayRefresh.Background),
)
},
floatingActionButton = {
FloatingActionButton(
onClick = onNewBot,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.bot_mode_new_bot))
}
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
if (searchOpen || query.isNotBlank()) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
placeholder = { Text(stringResource(R.string.bot_mode_search_hint)) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) {
Surface(
onClick = { gatewayMenuOpen = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHighest,
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
Text(
activeConnection?.label?.trim()?.firstOrNull()?.uppercase() ?: "H",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
}
}
Spacer(Modifier.width(10.dp))
Text(
selectedGatewayId
?.let { id -> connections.firstOrNull { it.id == id }?.label }
?: stringResource(R.string.bot_mode_all_gateways),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(Icons.Filled.ExpandMore, contentDescription = null)
}
}
DropdownMenu(
expanded = gatewayMenuOpen,
onDismissRequest = { gatewayMenuOpen = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.bot_mode_all_gateways)) },
leadingIcon = if (selectedGatewayId == null) {
{ Icon(Icons.Filled.Check, contentDescription = null) }
} else null,
onClick = {
gatewayMenuOpen = false
onSelectGateway(null)
},
)
connections.forEach { connection ->
val selected = connection.id == selectedGatewayId
val gatewayStatus = state.gateways.firstOrNull { it.connectionId == connection.id }
DropdownMenuItem(
text = {
Column {
Text(connection.label)
if (gatewayStatus?.stale == true || gatewayStatus?.error != null) {
Text(
stringResource(R.string.bot_mode_offline),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
},
leadingIcon = if (selected) {
{ Icon(Icons.Filled.Check, contentDescription = null) }
} else null,
onClick = {
gatewayMenuOpen = false
if (!selected) onSelectGateway(connection.id)
},
)
}
}
}
BotModeFilterBar(filter = filter, onFilter = { filter = it })
if (activeBots.isNotEmpty() && filter != BotModeFilter.Groups && needle.isBlank()) {
Text(
stringResource(R.string.bot_mode_active_now),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 8.dp),
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
items(activeBots, key = { it.profile.name }) { bot ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(78.dp)
.clickable(enabled = openingProfile == null) { onOpenBot(bot) },
) {
Box {
botAvatar(bot, 56.dp)
Surface(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(14.dp),
shape = CircleShape,
color = ACTIVE_GREEN,
border = BorderStroke(2.dp, RelayRefresh.Background),
) {}
}
Spacer(Modifier.height(6.dp))
Text(
bot.displayName,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
bot.route?.connectionLabel.orEmpty(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Spacer(Modifier.height(12.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
when {
state.loading && rows.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 2.dp)
}
rows.isEmpty() -> BotModeEmptyState(error = state.error, onRefresh = onRefresh)
else -> LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 96.dp, top = 4.dp),
) {
itemsIndexed(
items = rows,
key = { _, row -> when (row) {
is BotModeRow.Bot -> "bot:${row.value.profile.name}"
is BotModeRow.Group -> "group:${row.value.key}"
} },
) { index, row ->
when (row) {
is BotModeRow.Bot -> BotConversationRow(
bot = row.value,
connectionLabel = row.value.route?.connectionLabel,
opening = openingProfile == row.value.profile.name,
onClick = { onOpenBot(row.value) },
avatar = { botAvatar(row.value, 56.dp) },
nowMs = nowMs,
)
is BotModeRow.Group -> BotGroupRow(
room = row.value,
onClick = { onOpenGroup(row.value) },
nowMs = nowMs,
)
}
if (index != rows.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(start = 88.dp, end = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f),
)
}
}
}
}
}
}
}
@Composable
private fun BotModeFilterBar(filter: BotModeFilter, onFilter: (BotModeFilter) -> Unit) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.28f)),
) {
Row(modifier = Modifier.padding(4.dp)) {
BotModeFilter.entries.forEach { item ->
val selected = item == filter
Surface(
onClick = { onFilter(item) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(9.dp),
color = if (selected) {
RelayRefresh.ElectricMuted.copy(alpha = 0.56f)
} else Color.Transparent,
) {
Text(
text = when (item) {
BotModeFilter.All -> stringResource(R.string.bot_mode_filter_all)
BotModeFilter.Bots -> stringResource(R.string.bot_mode_filter_bots)
BotModeFilter.Groups -> stringResource(R.string.bot_mode_filter_groups)
},
modifier = Modifier.padding(vertical = 8.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
color = if (selected) {
RelayRefresh.Ink
} else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
@Composable
private fun BotConversationRow(
bot: BotRosterEntry,
connectionLabel: String?,
opening: Boolean,
onClick: () -> Unit,
avatar: @Composable () -> Unit,
nowMs: Long,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !opening, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
avatar()
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
bot.displayName,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
bot.latestActivityAtMs.toBotModeTime(nowMs),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!connectionLabel.isNullOrBlank()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"$connectionLabel · @${bot.handle}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (bot.stale) {
Spacer(Modifier.width(6.dp))
Text(
stringResource(R.string.bot_mode_offline),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
Text(
when {
opening -> stringResource(R.string.bot_mode_opening_chat)
bot.latestPreview.isNotBlank() -> bot.latestPreview
bot.profile.description.isNotBlank() -> bot.profile.description
else -> stringResource(R.string.bot_mode_no_messages)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun BotGroupRow(room: BotGroupRoom, onClick: () -> Unit, nowMs: Long) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GroupAvatar(56.dp)
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
room.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
room.latestActivityAtMs.toBotModeTime(nowMs),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(13.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
Text(
stringResource(R.string.bot_mode_read_only),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (room.stale) {
Spacer(Modifier.width(6.dp))
Text(
stringResource(R.string.bot_mode_offline),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
val latest = room.latestMessage
Text(
if (latest == null) {
stringResource(R.string.bot_mode_group_no_messages)
} else {
"${latest.senderName}: ${latest.text}"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun BotModeEmptyState(
error: String?,
onRefresh: () -> Unit,
actionLabel: String? = null,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
Icons.Filled.Groups,
contentDescription = null,
modifier = Modifier.size(36.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
error ?: stringResource(R.string.bot_mode_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onRefresh) {
Text(actionLabel ?: stringResource(R.string.chat_retry))
}
}
}
@Composable
private fun BotProfileAvatar(
connectionViewModel: ConnectionViewModel,
bot: BotRosterEntry,
size: Dp,
) {
val pathFlow = bot.route?.let { route ->
connectionViewModel.profileIconFlow(route.connectionId, route.profileName)
} ?: connectionViewModel.profileIconFlow(bot.profile.name)
val path by pathFlow.collectAsState(initial = null)
if (path.isNullOrBlank()) {
BotFallbackAvatar(bot.displayName, size)
} else {
Surface(
modifier = Modifier.size(size),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
) {
AsyncImage(
model = File(path.orEmpty()),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Composable
internal fun BotFallbackAvatar(label: String, size: Dp) {
Surface(
modifier = Modifier.size(size),
shape = CircleShape,
color = RelayRefresh.Navy3,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
) {
Box(contentAlignment = Alignment.Center) {
Text(
label.trim().firstOrNull()?.uppercase() ?: "H",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = RelayRefresh.Relay,
)
}
}
}
@Composable
private fun GroupAvatar(size: Dp) {
Surface(
modifier = Modifier.size(size),
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Filled.Groups,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(size * 0.52f),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BotGroupDetailScreen(room: BotGroupRoom?, onBack: () -> Unit) {
Scaffold(
containerColor = RelayRefresh.Background,
topBar = {
TopAppBar(
title = {
Column {
Text(room?.name ?: stringResource(R.string.bot_mode_group_title))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
Text(
stringResource(R.string.bot_mode_read_only),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.onboarding_back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = RelayRefresh.Background),
)
},
) { padding ->
if (room == null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
BotModeEmptyState(
error = stringResource(R.string.bot_mode_group_missing),
onRefresh = onBack,
actionLabel = stringResource(R.string.onboarding_back),
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(
stringResource(R.string.bot_mode_group_read_only_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(room.messages, key = { it.id ?: "${it.atMs}:${it.senderName}:${it.text.hashCode()}" }) { message ->
BotGroupMessageBubble(message)
}
}
}
}
}
@Composable
private fun BotGroupMessageBubble(message: BotGroupMessage) {
val user = message.senderKind == "user"
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (user) Arrangement.End else Arrangement.Start,
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = if (user) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainer
},
modifier = Modifier.fillMaxWidth(0.86f),
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
Text(
message.senderName,
style = MaterialTheme.typography.labelMedium,
color = if (user) {
MaterialTheme.colorScheme.onPrimaryContainer
} else MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(3.dp))
Text(message.text, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(3.dp))
Text(
message.atMs.toBotModeTime(System.currentTimeMillis()),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End),
)
}
}
}
}
@Composable
private fun CreateBotDialog(
saving: Boolean,
onDismiss: () -> Unit,
onCreate: (name: String, title: String, description: String) -> Unit,
) {
var name by remember { mutableStateOf("") }
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { if (!saving) onDismiss() },
title = { Text(stringResource(R.string.bot_mode_new_bot)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it.lowercase().replace(' ', '-').take(64) },
label = { Text(stringResource(R.string.bot_mode_bot_name)) },
singleLine = true,
)
OutlinedTextField(
value = title,
onValueChange = { title = it.take(128) },
label = { Text(stringResource(R.string.bot_mode_bot_title)) },
singleLine = true,
)
OutlinedTextField(
value = description,
onValueChange = { description = it.take(512) },
label = { Text(stringResource(R.string.bot_mode_bot_description)) },
minLines = 2,
maxLines = 4,
)
Text(
stringResource(R.string.bot_mode_create_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(
onClick = { onCreate(name, title.ifBlank { name }, description) },
enabled = name.isNotBlank() && !saving,
) { Text(stringResource(R.string.bot_mode_create)) }
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !saving) {
Text(stringResource(R.string.dashboard_cancel))
}
},
)
}
private fun Long.toBotModeTime(nowMs: Long): String {
if (this <= 0L) return ""
return DateUtils.getRelativeTimeSpanString(
this,
nowMs,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
}
private const val ACTIVE_WINDOW_MS = 90_000L
private const val BOT_MODE_REFRESH_MS = 30_000L
private val ACTIVE_GREEN = Color(0xFF4DD675)
@@ -726,6 +726,7 @@ fun ChatScreen(
// existing test/preview call sites keep compiling.
onNavigateToVoiceSettings: () -> Unit = {},
onNavigateToProfileInspector: (String) -> Unit = {},
onNavigateToBotMode: () -> Unit = {},
) {
val voiceUiState by voiceViewModel.uiState.collectAsState()
val responseSpeechActive by voiceViewModel.responseSpeechActive.collectAsState()
@@ -2344,6 +2345,10 @@ fun ChatScreen(
autoTitlesSupported = serverAutoTitles,
archiveSupported = sessionArchivingSupported,
onRefresh = { chatViewModel.refreshSessions() },
onOpenBotMode = {
scope.launch { drawerState.close() }
onNavigateToBotMode()
},
onNewChat = {
chatViewModel.createNewChat()
scope.launch { drawerState.close() }
@@ -444,6 +444,7 @@ class ChatViewModel : ViewModel() {
// === END PHASE3-status ===
const val MEDIA_TAP_TO_DOWNLOAD = "Tap to download"
private const val MEDIA_FETCH_TIMEOUT_MS = 120_000L
private val WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX = Regex("""^[A-Za-z]:[\\/].+""")
/** Upper bound on the rolling tool-call history flow. */
const val TOOL_CALL_HISTORY_LIMIT = 10
@@ -2575,6 +2576,12 @@ class ChatViewModel : ViewModel() {
/** Server slash-command catalog (`commands.catalog`) — 4th allCommands source. */
private val _serverCommands = MutableStateFlow<List<SlashCommand>>(emptyList())
val serverCommands: StateFlow<List<SlashCommand>> = _serverCommands.asStateFlow()
@Volatile
private var canonicalBotChatMode = false
fun setCanonicalBotChatMode(enabled: Boolean) {
canonicalBotChatMode = enabled
}
/**
* True while the in-flight turn is actually running on the gateway
@@ -3530,6 +3537,15 @@ class ChatViewModel : ViewModel() {
}
}
/** Route-owned Gateway chat setup without borrowing the active connection's Relay/media clients. */
fun initializeGatewayOnly(context: Context) {
appContext = context.applicationContext
if (chatTurnCheckpointStore == null) {
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
}
ensureCheckpointObservers()
}
/** JVM-test seam; production is wired to the app-wide relay DataStore. */
internal fun setChatTurnCheckpointStore(store: ChatTurnCheckpointStore?) {
chatTurnCheckpointStore = store
@@ -5303,6 +5319,14 @@ class ChatViewModel : ViewModel() {
return true
}
if (shouldCompactCanonicalBotChat(normalizedName, canonicalBotChatMode)) {
handler.addSystemNotice("Bot Chat stays in one conversation — compacting its context instead.")
viewModelScope.launch {
runServerCompressCommand(gateway, handler, focusTopic = null)
}
return true
}
mobileBlockedSlashNotice(normalizedName)?.let { notice ->
handler.addSystemNotice(notice)
return true
@@ -5935,6 +5959,13 @@ class ChatViewModel : ViewModel() {
activeStream?.cancel()
activeStream = null
activeStreamIsGateway = false
// Navigation owns the visible composer even when the live handle has
// already ended or could not be detached. Do not wait for a late
// cancel callback to clear a handler-wide busy bit after the new
// transcript has replaced its streaming bubble.
handler.clearStreamingStatus()
_steerableTurn.value = false
_steerNotice.value = null
}
/** Last-chance synchronous flush before the ViewModel scope is cancelled. */
@@ -8781,6 +8812,11 @@ class ChatViewModel : ViewModel() {
if (streamingMsg != null) {
handler.markStopped(streamingMsg.id)
handler.onStreamComplete(streamingMsg.id)
} else {
// The terminal bubble can settle before the handler-wide busy
// flag (or navigation can already have cleared the transcript).
// Stop must still be an unconditional escape hatch.
handler.clearStreamingStatus()
}
}
}
@@ -8867,11 +8903,10 @@ class ChatViewModel : ViewModel() {
* Re-run the fetch for an attachment that's in the "Tap to download"
* deferred state. Used by the inbound-media card's CTA on cellular.
*
* Works for both flavors of inbound attachment: if the stored key starts
* with `/` it's an absolute path (bare-media form, use
* [RelayHttpClient.fetchMediaByPath]); otherwise it's a relay token
* (use [RelayHttpClient.fetchMedia]). `secrets.token_urlsafe` never
* produces `/` so the prefix check is unambiguous.
* Works for both flavors of inbound attachment: POSIX paths start with `/`
* and Windows paths match `C:\...`; both use
* [RelayHttpClient.fetchMediaByPath]. Everything else is an opaque relay
* token and uses [RelayHttpClient.fetchMedia].
*/
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
val handler = chatHandler ?: return
@@ -8904,7 +8939,10 @@ class ChatViewModel : ViewModel() {
settings,
expectedRole = expectedRole,
) {
if (fetchKey.startsWith("/")) {
if (
fetchKey.startsWith("/") ||
WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX.matches(fetchKey)
) {
relay.fetchMediaByPath(fetchKey)
} else {
relay.fetchMedia(fetchKey)
@@ -8999,9 +9037,9 @@ class ChatViewModel : ViewModel() {
},
content = "",
state = AttachmentState.LOADING,
// Reuse relayToken as a generic inbound-fetch key. Paths always
// start with `/`, real tokens never do — downstream helpers
// that need to distinguish can check the prefix.
// Reuse relayToken as a generic inbound-fetch key. Downstream
// helpers distinguish POSIX or Windows absolute paths from opaque
// relay tokens.
relayToken = originalPath,
fileName = originalPath.substringAfterLast('/').substringAfterLast('\\').ifBlank { null }
)
@@ -9635,6 +9673,9 @@ private fun isUnsupportedMobileCommand(name: String, pair: JsonArray): Boolean {
return cliOnly && gatewayGate.isNullOrBlank()
}
internal fun shouldCompactCanonicalBotChat(commandName: String, canonicalBotChatMode: Boolean): Boolean =
canonicalBotChatMode && commandName.lowercase() in setOf("new", "reset")
private fun normalizeSlashCommandName(rawName: String): String? {
val normalized = rawName
.trim()
@@ -56,6 +56,8 @@ import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.ConnectionValidation
import com.hermesandroid.relay.data.computeConnectionSecurity
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.BotChatTarget
import com.hermesandroid.relay.data.BotModeState
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProfilePresentation
import com.hermesandroid.relay.data.SessionTransport
@@ -119,6 +121,7 @@ import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.util.AppForegroundTracker
import com.hermesandroid.relay.util.MediaCacheWriter
import com.hermesandroid.relay.viewmodel.connection.PairingController
import com.hermesandroid.relay.viewmodel.connection.BotModeController
import com.hermesandroid.relay.viewmodel.connection.ProfileController
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
import okhttp3.OkHttpClient
@@ -800,6 +803,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
tokenStoreKeyProvider = { cid ->
connectionStore.connections.value.firstOrNull { it.id == cid }?.tokenStoreKey
},
trustedDashboardUrlProvider = { cid ->
if (connectionStore.activeConnectionId.value == cid) {
activeDashboardUrl()
} else {
connectionStore.connections.value.firstOrNull { it.id == cid }
?.resolvedDashboardUrl
?.takeIf(String::isNotBlank)
}
},
pinnedClientProvider = { url, base ->
pluginProxyClientForUrl(url, base, includeRelaySessionHeader = false)
},
@@ -827,6 +839,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
gatewayClientProvider = { upstreamTransport.activeGatewayChatClient() },
)
private val botModeController = BotModeController(
scope = viewModelScope,
connections = connectionStore.connections,
activeConnectionId = connectionStore.activeConnectionId,
dashboardUrlProvider = { connection ->
if (connectionStore.activeConnectionId.value == connection.id) {
activeDashboardUrl().orEmpty()
} else {
connection.resolvedDashboardUrl
}
},
dashboardClientFactory = upstreamTransport::dashboardClientFor,
gatewayLeaseFactory = upstreamTransport::acquireGatewayRoute,
)
// --- Relay connection state ---
val relayConnectionState: StateFlow<ConnectionState> = connectionManager.connectionState
@@ -1624,6 +1651,28 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// down, calling profileController.* in their original order.
val agentProfiles: StateFlow<List<Profile>> get() = profileController.agentProfiles
val botModeState: StateFlow<BotModeState> get() = botModeController.state
fun refreshBotMode() = botModeController.refresh()
suspend fun ensureCanonicalBotChat(route: com.hermesandroid.relay.data.BotGatewayRoute): Result<BotChatTarget> =
botModeController.ensureCanonicalBotChat(route)
suspend fun createBot(
connectionId: String,
name: String,
title: String,
description: String,
): Result<String> = botModeController.createBot(connectionId, name, title, description)
fun acquireBotGateway(
route: com.hermesandroid.relay.data.BotGatewayRoute,
): Result<com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController.RouteGatewayLease> =
botModeController.acquireGateway(route)
fun botDashboardClient(
route: com.hermesandroid.relay.data.BotGatewayRoute,
): Result<DashboardApiClient> = botModeController.dashboardClient(route)
/**
* Session namespace after resolving the Server-default UI sentinel through
@@ -1765,6 +1814,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
/** A local icon path for a specific profile identity on the active connection. */
fun profileIconFlow(profileName: String?) = profileController.profileIconFlow(profileName)
fun profileIconFlow(connectionId: String, profileName: String) =
profileController.profileIconFlow(connectionId, profileName)
val hostProfileIconImportState: StateFlow<ProfileController.HostIconImportState>
get() = profileController.hostIconImportState
@@ -3529,7 +3580,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
scrubConnectionArtifacts(removed, removedDeviceId)
upstreamTransport.disposeConnectionRouteClients(connectionId)
connectionStore.removeConnection(connectionId)
botModeController.connectionRemoved(connectionId)
// Clear the persisted profile selection for the removed connection
// AFTER the switch-away above has finished. Ordering matters: if
// we cleared first, any in-flight hydration from the just-swapped
@@ -7296,6 +7349,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
connectionManager.shutdown()
_apiClient.value?.shutdown()
profileChatApiClient?.shutdown()
upstreamTransport.disposeAllRouteClients()
tailscaleDetector.shutdown()
// Release the cached VirtualDisplay + ImageReader + HandlerThread
// built by ScreenCapture on the first /screenshot call. Without
@@ -0,0 +1,353 @@
package com.hermesandroid.relay.viewmodel.connection
import com.hermesandroid.relay.data.BotChatTarget
import com.hermesandroid.relay.data.BotGatewayRosterStatus
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotModeRoster
import com.hermesandroid.relay.data.BotModeState
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.GatewayProfileAuthChoice
import com.hermesandroid.relay.data.GatewayProfileCreateRequest
import com.hermesandroid.relay.data.GatewayProfilePatch
import com.hermesandroid.relay.data.GatewayProfileSection
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
internal data class BotModeGatewaySnapshot(
val connection: Connection,
val dashboardUrl: String,
val installId: String?,
val roster: BotModeRoster,
val stale: Boolean,
val error: String?,
)
class BotModeController(
private val scope: CoroutineScope,
private val connections: StateFlow<List<Connection>>,
private val activeConnectionId: StateFlow<String?>,
private val dashboardUrlProvider: (Connection) -> String,
private val dashboardClientFactory: (connectionId: String, dashboardUrl: String) -> DashboardApiClient,
private val gatewayLeaseFactory: (
connectionId: String,
dashboardUrl: String,
profileName: String,
retain: Boolean,
) -> UpstreamTransportController.RouteGatewayLease,
) {
private val refreshMutex = Mutex()
private val refreshGeneration = AtomicLong(0L)
private val snapshots = linkedMapOf<String, BotModeGatewaySnapshot>()
private val _state = MutableStateFlow(BotModeState())
val state: StateFlow<BotModeState> = _state.asStateFlow()
fun refresh() {
scope.launch { refreshNow() }
}
suspend fun refreshNow() {
refreshMutex.withLock {
val generation = refreshGeneration.incrementAndGet()
val fleet = connections.value.toList()
val liveIds = fleet.mapTo(linkedSetOf(), Connection::id)
snapshots.keys.retainAll(liveIds)
_state.value = aggregateForTest(
fleet = fleet,
snapshots = snapshots,
loading = fleet.isNotEmpty(),
)
if (fleet.isEmpty()) {
_state.value = BotModeState(error = "Connect to Hermes to use Bot Mode")
return
}
val limiter = Semaphore(3)
val results = coroutineScope {
fleet.map { connection ->
async {
limiter.withPermit { loadConnection(connection) }
}
}.awaitAll()
}
if (refreshGeneration.get() != generation) return
val currentIds = connections.value.mapTo(linkedSetOf(), Connection::id)
results.filter { it.connection.id in currentIds }.forEach { result ->
snapshots[result.connection.id] = result
}
snapshots.keys.retainAll(currentIds)
_state.value = aggregateForTest(
fleet = connections.value,
snapshots = snapshots,
loading = false,
)
}
}
private suspend fun loadConnection(connection: Connection): BotModeGatewaySnapshot {
val dashboardUrl = dashboardUrlProvider(connection).trim()
val prior = snapshots[connection.id]
if (dashboardUrl.isBlank()) {
return prior?.copy(
connection = connection,
stale = true,
error = "Gateway is not configured",
) ?: BotModeGatewaySnapshot(
connection = connection,
dashboardUrl = dashboardUrl,
installId = null,
roster = BotModeRoster(),
stale = true,
error = "Gateway is not configured",
)
}
val statusClient = dashboardClientFactory(connection.id, dashboardUrl)
val installId = try {
statusClient.getStatus().getOrNull()?.installId?.trim()?.takeIf(String::isNotEmpty)
} finally {
statusClient.shutdown()
}
val rosterResult = gatewayLeaseFactory(connection.id, dashboardUrl, "default", false).use { lease ->
lease.client.listBotModeRoster()
}
return rosterResult.fold(
onSuccess = { roster ->
BotModeGatewaySnapshot(
connection = connection,
dashboardUrl = dashboardUrl,
installId = installId ?: prior?.installId,
roster = roster,
stale = false,
error = null,
)
},
onFailure = { error ->
prior?.copy(
connection = connection,
dashboardUrl = dashboardUrl,
installId = installId ?: prior.installId,
stale = true,
error = error.message ?: "Gateway unavailable",
) ?: BotModeGatewaySnapshot(
connection = connection,
dashboardUrl = dashboardUrl,
installId = installId,
roster = BotModeRoster(),
stale = true,
error = error.message ?: "Gateway unavailable",
)
},
)
}
suspend fun ensureCanonicalBotChat(route: BotGatewayRoute): Result<BotChatTarget> {
val connection = connectionFor(route)
?: return Result.failure(IllegalStateException("The Bot's gateway was removed"))
val dashboardUrl = dashboardUrlProvider(connection).trim()
if (dashboardUrl.isBlank()) {
return Result.failure(IllegalStateException("The Bot's gateway is not configured"))
}
return gatewayLeaseFactory(connection.id, dashboardUrl, route.profileName, false).use { lease ->
lease.client.ensureCanonicalBotChat(route.profileName)
}.mapCatching { target ->
check(connectionFor(route) != null) { "The Bot's gateway was removed while opening Bot Chat" }
target
}
}
fun acquireGateway(route: BotGatewayRoute): Result<UpstreamTransportController.RouteGatewayLease> {
val connection = connectionFor(route)
?: return Result.failure(IllegalStateException("The Bot's gateway was removed"))
val dashboardUrl = dashboardUrlProvider(connection).trim()
if (dashboardUrl.isBlank()) {
return Result.failure(IllegalStateException("The Bot's gateway is not configured"))
}
return Result.success(gatewayLeaseFactory(connection.id, dashboardUrl, route.profileName, true))
}
fun dashboardClient(route: BotGatewayRoute): Result<DashboardApiClient> {
val connection = connectionFor(route)
?: return Result.failure(IllegalStateException("The Bot's gateway was removed"))
val dashboardUrl = dashboardUrlProvider(connection).trim()
if (dashboardUrl.isBlank()) {
return Result.failure(IllegalStateException("The Bot's gateway is not configured"))
}
return Result.success(dashboardClientFactory(connection.id, dashboardUrl))
}
suspend fun createBot(
connectionId: String,
name: String,
title: String,
description: String,
): Result<String> {
val connection = connections.value.firstOrNull { it.id == connectionId }
?: return Result.failure(IllegalStateException("The target gateway was removed"))
val dashboardUrl = dashboardUrlProvider(connection).trim()
if (dashboardUrl.isBlank()) {
return Result.failure(IllegalStateException("The target gateway is not configured"))
}
val lease = gatewayLeaseFactory(connection.id, dashboardUrl, "default", false)
val client = lease.client
val cleanTitle = title.trim().ifBlank { name.trim() }.take(128)
val result = try {
client.createProfile(
GatewayProfileCreateRequest(
name = name.trim(),
description = description.trim().takeIf(String::isNotBlank),
cloneFrom = "default",
authChoice = GatewayProfileAuthChoice.Shared,
),
).mapCatching { created ->
check(connections.value.any { it.id == connectionId }) {
"The target gateway was removed while creating the Bot"
}
val configured = client.configureProfile(
created.name,
GatewayProfilePatch(
uiMeta = buildJsonObject {
put("hermes-bots", buildJsonObject {
put("title", cleanTitle)
put("created", System.currentTimeMillis())
})
},
),
).getOrThrow()
check(GatewayProfileSection.UiMeta in configured.applied) {
"The profile was created, but Bot Mode metadata was not saved"
}
created.name
}
} finally {
lease.close()
}
if (result.isSuccess) refreshNow()
return result
}
fun connectionRemoved(connectionId: String) {
snapshots.remove(connectionId)
refreshGeneration.incrementAndGet()
_state.value = aggregateForTest(connections.value, snapshots, loading = false)
}
private fun connectionFor(route: BotGatewayRoute): Connection? =
connections.value.firstOrNull { it.id == route.connectionId }
internal fun aggregateForTest(
fleet: List<Connection>,
snapshots: Map<String, BotModeGatewaySnapshot>,
loading: Boolean,
): BotModeState {
val order = fleet.mapIndexed { index, connection -> connection.id to index }.toMap()
val activeId = activeConnectionId.value
val routed = snapshots.values.flatMap { snapshot ->
snapshot.roster.bots.map { bot ->
bot.copy(
route = BotGatewayRoute(
key = BotGatewayRouteKey(
connectionId = snapshot.connection.id,
profileName = bot.profile.name,
),
connectionLabel = snapshot.connection.label,
installId = snapshot.installId,
),
stale = snapshot.stale,
)
}
}
val collapsed = routed
.groupBy { bot ->
val route = checkNotNull(bot.route)
"${route.installId ?: "connection:${route.connectionId}"}::${bot.profile.name}"
}
.values
.map { candidates ->
candidates.sortedWith(
compareByDescending<BotRosterEntry> { it.route?.connectionId == activeId }
.thenBy { it.stale }
.thenBy { order[it.route?.connectionId] ?: Int.MAX_VALUE },
).first()
}
val duplicateNames = collapsed.groupingBy { it.profile.name }.eachCount()
val bots = collapsed.map { bot ->
val route = checkNotNull(bot.route)
bot.copy(
handle = if ((duplicateNames[bot.profile.name] ?: 0) > 1) {
"${handleSlug(bot.profile.name)}-${handleSlug(route.connectionLabel)}"
} else {
handleSlug(bot.profile.name)
},
)
}.sortedByDescending(BotRosterEntry::latestActivityAtMs)
val groups = snapshots.values
.flatMap { snapshot ->
snapshot.roster.groups.map { group -> Triple(snapshot, group, group.roomId ?: group.key) }
}
.groupBy { it.third }
.values
.map { candidates ->
val selected = candidates.maxWithOrNull(
compareBy<Triple<BotModeGatewaySnapshot, BotGroupRoom, String>> { it.second.revision }
.thenBy { it.second.latestActivityAtMs },
) ?: error("group candidate list cannot be empty")
selected.second.copy(
sourceConnectionIds = candidates.mapTo(linkedSetOf()) { it.first.connection.id },
stale = candidates.all { it.first.stale },
)
}
.sortedByDescending(BotGroupRoom::latestActivityAtMs)
val statuses = fleet.map { connection ->
val snapshot = snapshots[connection.id]
BotGatewayRosterStatus(
connectionId = connection.id,
label = connection.label,
installId = snapshot?.installId,
loading = loading && snapshot == null,
stale = snapshot?.stale == true,
error = snapshot?.error,
botCount = snapshot?.roster?.bots?.size ?: 0,
)
}
val errors = statuses.mapNotNull(BotGatewayRosterStatus::error)
return BotModeState(
loading = loading,
roster = BotModeRoster(
bots = bots,
groups = groups,
botModeProtocolSupported = snapshots.values.any {
it.roster.botModeProtocolSupported
},
),
gateways = statuses,
error = errors.takeIf { it.size == statuses.size && bots.isEmpty() }
?.firstOrNull(),
)
}
private fun handleSlug(value: String): String = value
.trim()
.lowercase()
.replace(Regex("[^a-z0-9]+"), "-")
.trim('-')
.take(64)
.ifBlank { "bot" }
}
@@ -388,6 +388,13 @@ class ProfileController(
) { server, fallback, override -> preferredProfileIcon(server, fallback, override) }
}
/** Exact profile identity on any saved connection; never consults active state. */
fun profileIconFlow(connectionId: String, profileName: String): Flow<String?> = combine(
profileIconStore.serverAvatarFlow(connectionId, profileName),
profileIconStore.iconFlow(connectionId, profileName),
profileIconStore.localOverrideFlow(connectionId, profileName),
) { server, local, localOverride -> preferredProfileIcon(server, local, localOverride) }
data class HostIconImportState(
val loading: Boolean = false,
val error: String? = null,
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.viewmodel.connection
import android.content.Context
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
@@ -18,6 +19,7 @@ import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreferen
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
import com.hermesandroid.relay.network.shutdownOffMainThread
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -78,6 +80,8 @@ class UpstreamTransportController(
* `hermes_dashboard_<id>` file (original behavior).
*/
private val tokenStoreKeyProvider: (String) -> String? = { null },
/** Exact trusted Dashboard base for any saved connection, active or not. */
private val trustedDashboardUrlProvider: (String) -> String? = { null },
/** Applies pairing-bound TLS to a standard authenticated client when needed. */
private val pinnedClientProvider: (String, okhttp3.OkHttpClient) -> okhttp3.OkHttpClient? =
{ _, _ -> null },
@@ -96,6 +100,25 @@ class UpstreamTransportController(
ConcurrentHashMap<String, EncryptedNativeDashboardTokenStore>()
private var dashboardHttpClientCache:
Triple<String, String, okhttp3.OkHttpClient>? = null
private data class RouteGatewayEntry(
var dashboardUrl: String,
var dashboardClient: DashboardApiClient,
val client: GatewayChatClient,
var activeRequests: Int = 0,
var retained: Int = 0,
var retired: Boolean = false,
)
private val routeGatewayClients = mutableMapOf<BotGatewayRouteKey, RouteGatewayEntry>()
class RouteGatewayLease internal constructor(
val client: GatewayChatClient,
private val releaseAction: () -> Unit,
) : AutoCloseable {
private val closed = AtomicBoolean(false)
override fun close() {
if (closed.compareAndSet(false, true)) releaseAction()
}
}
/**
* Cookie store for [connectionId] — ONE instance per connection,
@@ -137,9 +160,10 @@ class UpstreamTransportController(
connectionId: String,
dashboardUrl: String,
): DashboardBearerAuth? {
if (activeConnectionIdProvider() != connectionId) return null
if (!isNativeDashboardTransportEligible(dashboardUrl)) return null
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
val trustedDashboardUrl = trustedDashboardUrlProvider(connectionId)
?: (if (activeConnectionIdProvider() == connectionId) dashboardUrlProvider() else null)
?: return null
return trustedDashboardBearerAuthOrNull(
candidate = dashboardUrl,
trusted = trustedDashboardUrl,
@@ -252,6 +276,7 @@ class UpstreamTransportController(
if (gatewayClientCache?.first == connectionId) {
gatewayClientCache = null
}
disposeConnectionRouteClients(connectionId)
}
private fun disposeDashboardHttpClient(client: okhttp3.OkHttpClient) {
@@ -333,6 +358,99 @@ class UpstreamTransportController(
return client
}
/**
* Bot/agent Gateway client owned by one immutable connection + profile.
* It never consults or changes the foreground connection and never shares
* live-session state with the standard Chat client's dynamic profile.
*/
@Synchronized
fun acquireGatewayRoute(
connectionId: String,
dashboardUrl: String,
profileName: String,
retain: Boolean = false,
): RouteGatewayLease {
val profile = profileName.trim().ifBlank { "default" }
val key = BotGatewayRouteKey(connectionId.trim(), profile)
var entry = routeGatewayClients[key]
entry?.let { cached ->
if (cached.dashboardUrl == dashboardUrl) {
if (retain) cached.retained += 1 else cached.activeRequests += 1
return routeLease(key, cached, retain)
}
if (cached.client.hasActiveTurn()) {
val replacementDashboard = dashboardClientFor(connectionId, dashboardUrl)
val previousDashboard = cached.dashboardClient
cached.client.retarget(replacementDashboard)
cached.dashboardClient = replacementDashboard
cached.dashboardUrl = dashboardUrl
previousDashboard.shutdown()
if (retain) cached.retained += 1 else cached.activeRequests += 1
return routeLease(key, cached, retain)
}
cached.retired = true
routeGatewayClients.remove(key)
if (cached.activeRequests == 0 && cached.retained == 0) shutdownRouteEntry(cached)
}
val dashboardClient = dashboardClientFor(connectionId, dashboardUrl)
entry = RouteGatewayEntry(
dashboardUrl = dashboardUrl,
dashboardClient = dashboardClient,
client = GatewayChatClient(
initialDashboardClient = dashboardClient,
fixedSessionProfile = profile,
).also { it.setKeepAliveInBackground(gatewayKeepAliveProvider()) },
)
if (retain) entry.retained = 1 else entry.activeRequests = 1
routeGatewayClients[key] = entry
return routeLease(key, entry, retain)
}
private fun routeLease(
key: BotGatewayRouteKey,
entry: RouteGatewayEntry,
retained: Boolean,
): RouteGatewayLease = RouteGatewayLease(entry.client) {
releaseGatewayRoute(key, entry, retained)
}
@Synchronized
private fun releaseGatewayRoute(
key: BotGatewayRouteKey,
entry: RouteGatewayEntry,
retained: Boolean,
) {
if (retained) entry.retained = (entry.retained - 1).coerceAtLeast(0)
else entry.activeRequests = (entry.activeRequests - 1).coerceAtLeast(0)
if ((entry.retired || routeGatewayClients[key] !== entry) &&
entry.activeRequests == 0 && entry.retained == 0
) {
shutdownRouteEntry(entry)
}
}
private fun shutdownRouteEntry(entry: RouteGatewayEntry) {
entry.client.shutdown()
entry.dashboardClient.shutdown()
}
@Synchronized
fun disposeConnectionRouteClients(connectionId: String) {
routeGatewayClients.entries
.filter { it.key.connectionId == connectionId }
.forEach { (key, entry) ->
entry.retired = true
shutdownRouteEntry(entry)
routeGatewayClients.remove(key)
}
}
@Synchronized
fun disposeAllRouteClients() {
routeGatewayClients.values.forEach(::shutdownRouteEntry)
routeGatewayClients.clear()
}
/**
* Apply the keep-alive-in-background flag to the cached gateway client, if
* one exists. Driven by the ViewModel's `gatewayKeepAlive` collector.
@@ -341,6 +459,9 @@ class UpstreamTransportController(
*/
fun applyGatewayKeepAlive(enabled: Boolean) {
gatewayClientCache?.third?.setKeepAliveInBackground(enabled)
synchronized(this) {
routeGatewayClients.values.forEach { it.client.setKeepAliveInBackground(enabled) }
}
}
/**
@@ -838,6 +838,36 @@
<string name="detail_tab_advanced">Avançado</string>
<string name="detail_tab_security">Segurança</string>
<!-- P0: SessionDrawer -->
<string name="bot_mode_title">Modo Bot</string>
<string name="bot_mode_drawer_summary">Bots e salas em grupo</string>
<string name="bot_mode_search">Pesquisar no Modo Bot</string>
<string name="bot_mode_search_hint">Pesquisar Bots e grupos</string>
<string name="bot_mode_refresh">Atualizar Modo Bot</string>
<string name="bot_mode_new_bot">Novo Bot</string>
<string name="bot_mode_gateway_unavailable">Gateway indisponível</string>
<string name="bot_mode_all_gateways">Todos os gateways</string>
<string name="bot_mode_offline">Offline</string>
<string name="bot_mode_filter_all">Todos</string>
<string name="bot_mode_filter_bots">Bots</string>
<string name="bot_mode_filter_groups">Grupos</string>
<string name="bot_mode_active_now">Ativos agora</string>
<string name="bot_mode_opening_chat">Abrindo o Bot Chat…</string>
<string name="bot_mode_no_messages">Iniciar o Bot Chat</string>
<string name="bot_mode_read_only">Somente leitura</string>
<string name="bot_mode_group_no_messages">Ainda não há mensagens na sala</string>
<string name="bot_mode_empty">Ainda não há Bots nem salas em grupo</string>
<string name="bot_mode_group_title">Sala em grupo</string>
<string name="bot_mode_group_missing">Esta sala em grupo não está mais disponível.</string>
<string name="bot_mode_group_read_only_help">Este é o histórico limitado e somente leitura compartilhado pelo Hermes Desktop. Por enquanto, continue com um Bot individual.</string>
<string name="bot_mode_bot_name">Nome do perfil</string>
<string name="bot_mode_bot_title">Nome do Bot</string>
<string name="bot_mode_bot_description">Função e descrição</string>
<string name="bot_mode_create_help">O novo Bot começa com o perfil padrão e compartilha o login dele. Você pode ajustar habilidades e modelo em Gerenciar.</string>
<string name="bot_mode_create">Criar Bot</string>
<string name="bot_mode_back_to_bots">Voltar ao Modo Bot</string>
<string name="bot_mode_chat_open_failed">Não foi possível abrir o Bot Chat</string>
<string name="bot_mode_created">%1$s criado</string>
<string name="bot_mode_create_failed">Não foi possível criar o Bot</string>
<string name="drawer_filter_by_source">Filtrar por origem</string>
<string name="drawer_show_sources">Mostrar origens</string>
<string name="drawer_refresh_sessions">Atualizar sessões</string>
@@ -884,6 +884,36 @@
<string name="detail_tab_security">安全</string>
<!-- P0: SessionDrawer -->
<string name="bot_mode_title">Bot 模式</string>
<string name="bot_mode_drawer_summary">Bot 和群组房间</string>
<string name="bot_mode_search">搜索 Bot 模式</string>
<string name="bot_mode_search_hint">搜索 Bot 和群组</string>
<string name="bot_mode_refresh">刷新 Bot 模式</string>
<string name="bot_mode_new_bot">新建 Bot</string>
<string name="bot_mode_gateway_unavailable">网关不可用</string>
<string name="bot_mode_all_gateways">所有网关</string>
<string name="bot_mode_offline">离线</string>
<string name="bot_mode_filter_all">全部</string>
<string name="bot_mode_filter_bots">Bot</string>
<string name="bot_mode_filter_groups">群组</string>
<string name="bot_mode_active_now">当前活跃</string>
<string name="bot_mode_opening_chat">正在打开 Bot Chat…</string>
<string name="bot_mode_no_messages">开始 Bot Chat</string>
<string name="bot_mode_read_only">只读</string>
<string name="bot_mode_group_no_messages">房间中还没有消息</string>
<string name="bot_mode_empty">还没有 Bot 或群组房间</string>
<string name="bot_mode_group_title">群组房间</string>
<string name="bot_mode_group_missing">此群组房间已不可用。</string>
<string name="bot_mode_group_read_only_help">这是 Hermes Desktop 共享的有限只读房间历史记录。目前请先与单个 Bot 继续对话。</string>
<string name="bot_mode_bot_name">配置文件名称</string>
<string name="bot_mode_bot_title">Bot 名称</string>
<string name="bot_mode_bot_description">角色和说明</string>
<string name="bot_mode_create_help">新 Bot 基于默认配置文件创建并共享其登录。你可以在“管理”中调整技能和模型。</string>
<string name="bot_mode_create">创建 Bot</string>
<string name="bot_mode_back_to_bots">返回 Bot 模式</string>
<string name="bot_mode_chat_open_failed">无法打开 Bot Chat</string>
<string name="bot_mode_created">已创建 %1$s</string>
<string name="bot_mode_create_failed">无法创建 Bot</string>
<string name="drawer_filter_by_source">按来源筛选</string>
<string name="drawer_show_sources">显示来源</string>
<string name="drawer_refresh_sessions">刷新会话</string>
+30
View File
@@ -887,6 +887,36 @@
<string name="detail_tab_security">Sicherheit</string>
<!-- P0: SessionDrawer -->
<string name="bot_mode_title">Bot-Modus</string>
<string name="bot_mode_drawer_summary">Bots und Gruppenräume</string>
<string name="bot_mode_search">Bot-Modus durchsuchen</string>
<string name="bot_mode_search_hint">Bots und Gruppen durchsuchen</string>
<string name="bot_mode_refresh">Bot-Modus aktualisieren</string>
<string name="bot_mode_new_bot">Neuer Bot</string>
<string name="bot_mode_gateway_unavailable">Gateway nicht verfügbar</string>
<string name="bot_mode_all_gateways">Alle Gateways</string>
<string name="bot_mode_offline">Offline</string>
<string name="bot_mode_filter_all">Alle</string>
<string name="bot_mode_filter_bots">Bots</string>
<string name="bot_mode_filter_groups">Gruppen</string>
<string name="bot_mode_active_now">Jetzt aktiv</string>
<string name="bot_mode_opening_chat">Bot-Chat wird geöffnet…</string>
<string name="bot_mode_no_messages">Bot-Chat starten</string>
<string name="bot_mode_read_only">Schreibgeschützt</string>
<string name="bot_mode_group_no_messages">Noch keine Raumnachrichten</string>
<string name="bot_mode_empty">Noch keine Bots oder Gruppenräume</string>
<string name="bot_mode_group_title">Gruppenraum</string>
<string name="bot_mode_group_missing">Dieser Gruppenraum ist nicht mehr verfügbar.</string>
<string name="bot_mode_group_read_only_help">Dies ist der begrenzte, schreibgeschützte Raumverlauf aus Hermes Desktop. Fahre vorerst mit einem einzelnen Bot fort.</string>
<string name="bot_mode_bot_name">Profilname</string>
<string name="bot_mode_bot_title">Bot-Name</string>
<string name="bot_mode_bot_description">Rolle und Beschreibung</string>
<string name="bot_mode_create_help">Der neue Bot basiert auf dem Standardprofil und teilt dessen Anmeldung. Fähigkeiten und Modell kannst du unter Verwalten anpassen.</string>
<string name="bot_mode_create">Bot erstellen</string>
<string name="bot_mode_back_to_bots">Zurück zum Bot-Modus</string>
<string name="bot_mode_chat_open_failed">Bot-Chat konnte nicht geöffnet werden</string>
<string name="bot_mode_created">%1$s erstellt</string>
<string name="bot_mode_create_failed">Bot konnte nicht erstellt werden</string>
<string name="drawer_filter_by_source">Nach Quelle filtern</string>
<string name="drawer_show_sources">Quellen anzeigen</string>
<string name="drawer_refresh_sessions">Sitzungen aktualisieren</string>
+30
View File
@@ -802,6 +802,36 @@
<string name="detail_tab_routes">Rutas</string>
<string name="detail_tab_advanced">Avanzado</string>
<string name="detail_tab_security">Seguridad</string>
<string name="bot_mode_title">Modo Bot</string>
<string name="bot_mode_drawer_summary">Bots y salas de grupo</string>
<string name="bot_mode_search">Buscar en Modo Bot</string>
<string name="bot_mode_search_hint">Buscar Bots y grupos</string>
<string name="bot_mode_refresh">Actualizar Modo Bot</string>
<string name="bot_mode_new_bot">Nuevo Bot</string>
<string name="bot_mode_gateway_unavailable">Gateway no disponible</string>
<string name="bot_mode_all_gateways">Todos los gateways</string>
<string name="bot_mode_offline">Sin conexión</string>
<string name="bot_mode_filter_all">Todo</string>
<string name="bot_mode_filter_bots">Bots</string>
<string name="bot_mode_filter_groups">Grupos</string>
<string name="bot_mode_active_now">Activos ahora</string>
<string name="bot_mode_opening_chat">Abriendo Bot Chat…</string>
<string name="bot_mode_no_messages">Iniciar el Bot Chat</string>
<string name="bot_mode_read_only">Solo lectura</string>
<string name="bot_mode_group_no_messages">Aún no hay mensajes en la sala</string>
<string name="bot_mode_empty">Aún no hay Bots ni salas de grupo</string>
<string name="bot_mode_group_title">Sala de grupo</string>
<string name="bot_mode_group_missing">Esta sala de grupo ya no está disponible.</string>
<string name="bot_mode_group_read_only_help">Este es el historial limitado y de solo lectura compartido por Hermes Desktop. Por ahora, continúa con un Bot individual.</string>
<string name="bot_mode_bot_name">Nombre del perfil</string>
<string name="bot_mode_bot_title">Nombre del Bot</string>
<string name="bot_mode_bot_description">Rol y descripción</string>
<string name="bot_mode_create_help">El nuevo Bot parte del perfil predeterminado y comparte su inicio de sesión. Puedes ajustar sus habilidades y modelo en Administrar.</string>
<string name="bot_mode_create">Crear Bot</string>
<string name="bot_mode_back_to_bots">Volver a Modo Bot</string>
<string name="bot_mode_chat_open_failed">No se pudo abrir el Bot Chat</string>
<string name="bot_mode_created">Se creó %1$s</string>
<string name="bot_mode_create_failed">No se pudo crear el Bot</string>
<string name="drawer_filter_by_source">Filtrar por fuente</string>
<string name="drawer_show_sources">Mostrar fuentes</string>
<string name="drawer_refresh_sessions">Actualizar sesiones</string>
+30
View File
@@ -900,6 +900,36 @@
<string name="detail_overview_summary">Chat、Manage、Voice では標準の Hermes を使用します。Relay は高度な端末機能を追加するオプションの拡張機能です。</string>
<!-- P0: SessionDrawer -->
<string name="bot_mode_title">Botモード</string>
<string name="bot_mode_drawer_summary">Botとグループルーム</string>
<string name="bot_mode_search">Botモードを検索</string>
<string name="bot_mode_search_hint">Botとグループを検索</string>
<string name="bot_mode_refresh">Botモードを更新</string>
<string name="bot_mode_new_bot">新しいBot</string>
<string name="bot_mode_gateway_unavailable">ゲートウェイを利用できません</string>
<string name="bot_mode_all_gateways">すべてのゲートウェイ</string>
<string name="bot_mode_offline">オフライン</string>
<string name="bot_mode_filter_all">すべて</string>
<string name="bot_mode_filter_bots">Bot</string>
<string name="bot_mode_filter_groups">グループ</string>
<string name="bot_mode_active_now">現在アクティブ</string>
<string name="bot_mode_opening_chat">Bot Chatを開いています…</string>
<string name="bot_mode_no_messages">Bot Chatを開始</string>
<string name="bot_mode_read_only">読み取り専用</string>
<string name="bot_mode_group_no_messages">ルームのメッセージはまだありません</string>
<string name="bot_mode_empty">Botまたはグループルームはまだありません</string>
<string name="bot_mode_group_title">グループルーム</string>
<string name="bot_mode_group_missing">このグループルームは利用できなくなりました。</string>
<string name="bot_mode_group_read_only_help">これはHermes Desktopが共有する範囲限定の読み取り専用ルーム履歴です。当面は個別のBotで会話を続けてください。</string>
<string name="bot_mode_bot_name">プロファイル名</string>
<string name="bot_mode_bot_title">Bot名</string>
<string name="bot_mode_bot_description">役割と説明</string>
<string name="bot_mode_create_help">新しいBotは既定のプロファイルを基に作成され、サインインを共有します。スキルとモデルは管理画面で調整できます。</string>
<string name="bot_mode_create">Botを作成</string>
<string name="bot_mode_back_to_bots">Botモードに戻る</string>
<string name="bot_mode_chat_open_failed">Bot Chatを開けませんでした</string>
<string name="bot_mode_created">%1$sを作成しました</string>
<string name="bot_mode_create_failed">Botを作成できませんでした</string>
<string name="drawer_filter_by_source">ソースによるフィルター</string>
<string name="drawer_show_sources">ソースを表示</string>
<string name="drawer_refresh_sessions">セッションを更新する</string>
+30
View File
@@ -910,6 +910,36 @@
<string name="active_section_unencrypted_transport">Это соединение использует незашифрованный транспорт</string>
<string name="active_section_actions">Действия</string>
<string name="active_section_revoke_relay">Отозвать сопряжение Relay</string>
<string name="bot_mode_title">Режим ботов</string>
<string name="bot_mode_drawer_summary">Боты и групповые комнаты</string>
<string name="bot_mode_search">Поиск в режиме ботов</string>
<string name="bot_mode_search_hint">Поиск ботов и групп</string>
<string name="bot_mode_refresh">Обновить режим ботов</string>
<string name="bot_mode_new_bot">Новый бот</string>
<string name="bot_mode_gateway_unavailable">Шлюз недоступен</string>
<string name="bot_mode_all_gateways">Все шлюзы</string>
<string name="bot_mode_offline">Не в сети</string>
<string name="bot_mode_filter_all">Все</string>
<string name="bot_mode_filter_bots">Боты</string>
<string name="bot_mode_filter_groups">Группы</string>
<string name="bot_mode_active_now">Сейчас активны</string>
<string name="bot_mode_opening_chat">Открывается Bot Chat…</string>
<string name="bot_mode_no_messages">Начать Bot Chat</string>
<string name="bot_mode_read_only">Только чтение</string>
<string name="bot_mode_group_no_messages">В комнате пока нет сообщений</string>
<string name="bot_mode_empty">Пока нет ботов или групповых комнат</string>
<string name="bot_mode_group_title">Групповая комната</string>
<string name="bot_mode_group_missing">Эта групповая комната больше недоступна.</string>
<string name="bot_mode_group_read_only_help">Это ограниченная история комнаты только для чтения, предоставленная Hermes Desktop. Пока продолжайте общение с отдельным ботом.</string>
<string name="bot_mode_bot_name">Имя профиля</string>
<string name="bot_mode_bot_title">Имя бота</string>
<string name="bot_mode_bot_description">Роль и описание</string>
<string name="bot_mode_create_help">Новый бот создаётся на основе профиля по умолчанию и использует его вход. Навыки и модель можно настроить в разделе управления.</string>
<string name="bot_mode_create">Создать бота</string>
<string name="bot_mode_back_to_bots">Назад в режим ботов</string>
<string name="bot_mode_chat_open_failed">Не удалось открыть Bot Chat</string>
<string name="bot_mode_created">%1$s создан</string>
<string name="bot_mode_create_failed">Не удалось создать бота</string>
<string name="drawer_filter_by_source">Фильтр по источнику</string>
<string name="drawer_show_sources">Показать источники</string>
<string name="drawer_refresh_sessions">Обновить сессии</string>
+30
View File
@@ -1002,6 +1002,36 @@
<string name="active_section_revoke_relay">Revoke Relay pairing</string>
<!-- P0: SessionDrawer -->
<string name="bot_mode_title">Bot Mode</string>
<string name="bot_mode_drawer_summary">Bots and group rooms</string>
<string name="bot_mode_search">Search Bot Mode</string>
<string name="bot_mode_search_hint">Search Bots and groups</string>
<string name="bot_mode_refresh">Refresh Bot Mode</string>
<string name="bot_mode_new_bot">New Bot</string>
<string name="bot_mode_gateway_unavailable">Gateway unavailable</string>
<string name="bot_mode_all_gateways">All gateways</string>
<string name="bot_mode_offline">Offline</string>
<string name="bot_mode_filter_all">All</string>
<string name="bot_mode_filter_bots">Bots</string>
<string name="bot_mode_filter_groups">Groups</string>
<string name="bot_mode_active_now">Active now</string>
<string name="bot_mode_opening_chat">Opening Bot Chat…</string>
<string name="bot_mode_no_messages">Start the Bot Chat</string>
<string name="bot_mode_read_only">Read only</string>
<string name="bot_mode_group_no_messages">No room messages yet</string>
<string name="bot_mode_empty">No Bots or group rooms yet</string>
<string name="bot_mode_group_title">Group room</string>
<string name="bot_mode_group_missing">This group room is no longer available.</string>
<string name="bot_mode_group_read_only_help">This is the bounded read-only room history shared by Hermes Desktop. Continue with an individual Bot for now.</string>
<string name="bot_mode_bot_name">Profile name</string>
<string name="bot_mode_bot_title">Bot name</string>
<string name="bot_mode_bot_description">Role and description</string>
<string name="bot_mode_create_help">The new Bot starts from the default profile and shares its sign-in. You can refine its skills and model in Manage.</string>
<string name="bot_mode_create">Create Bot</string>
<string name="bot_mode_back_to_bots">Back to Bot Mode</string>
<string name="bot_mode_chat_open_failed">Bot Chat could not open</string>
<string name="bot_mode_created">%1$s created</string>
<string name="bot_mode_create_failed">Bot could not be created</string>
<string name="drawer_filter_by_source">Filter by source</string>
<string name="drawer_show_sources">Show sources</string>
<string name="drawer_refresh_sessions">Refresh sessions</string>
@@ -76,6 +76,7 @@ class DashboardApiClientTest {
"""
{
"version": "0.16.0",
"install_id": " install-a ",
"auth_required": true,
"auth_providers": ["basic", "nous"]
}
@@ -92,6 +93,7 @@ class DashboardApiClientTest {
assertEquals(listOf("basic", "nous"), status.authProviders)
assertEquals("basic", status.authProviderDetails.first().name)
assertEquals("0.16.0", status.version)
assertEquals("install-a", status.installId)
}
@Test
@@ -289,6 +291,7 @@ class DashboardApiClientTest {
val wsUrl = DashboardApiClient.gatewayWebSocketUrl(
baseUrl = "https://example.com/hermes/",
ticket = "abc/123",
profile = "research bot",
)
val landingPath = DashboardApiClient.authLandingPath("https://example.com/hermes/")
@@ -297,7 +300,7 @@ class DashboardApiClientTest {
authUrl,
)
assertEquals(
"wss://example.com/hermes/api/ws?ticket=abc%2F123",
"wss://example.com/hermes/api/ws?ticket=abc%2F123&profile=research%20bot",
wsUrl,
)
assertEquals("/hermes/", landingPath)
@@ -190,6 +190,11 @@ class GatewayClientHarness(
})))
}
@Volatile
var sessionListPayload: JsonObject = buildJsonObject {
put("sessions", JsonArray(emptyList()))
}
@Volatile
var profileCreatePayload: JsonObject = buildJsonObject {
put("ok", true)
@@ -310,6 +315,8 @@ class GatewayClientHarness(
"session.activate" -> recoveryPayload(
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
)
"session.list" -> sessionListPayload
"session.title" -> buildJsonObject { put("ok", true) }
"prompt.submit" -> promptSubmitPayload
"session.interrupt" -> buildJsonObject { put("ok", true) }
"process.list" -> buildJsonObject {
@@ -891,6 +898,165 @@ class GatewayChatClientTest {
assertEquals(false, (harness.awaitRpc("profiles.list")["include_sessions"] as JsonPrimitive).booleanOrNull)
}
@Test
fun `bot roster parses canonical chats activity and read only room projection`() = runBlocking {
harness.profilesListPayload = buildJsonObject {
put("bot_mode_protocol", true)
put("profiles", JsonArray(listOf(buildJsonObject {
put("name", "default")
put("display_name", "Hermes")
put("model", "gpt-5.6")
put("is_default", true)
put("canonical_session", buildJsonObject {
put("id", "bot-root")
put("resolved_id", "bot-tip")
put("root_title", "Bot Chat")
put("preview", "Release plan ready")
put("last_active", 1_777_000_000)
put("message_count", 8)
})
put("worker_session", buildJsonObject {
put("id", "worker-1")
put("title", "Build")
put("last_active", 1_777_000_030)
})
put("ui_meta", buildJsonObject {
put("hermes-bots", buildJsonObject { put("title", "Lucy") })
put("hermes-bots-groups", buildJsonObject {
put("version", 3)
put("rooms", buildJsonObject {
put("id:launch", buildJsonObject {
put("name", "Launch Council")
put("roomId", "launch")
put("revision", 4)
put("members", JsonArray(listOf(buildJsonObject {
put("name", "default")
put("handle", "hermes")
})))
put("log", JsonArray(listOf(buildJsonObject {
put("id", "message-1")
put("from", buildJsonObject {
put("kind", "member")
put("name", "Lucy")
})
put("text", "Rollout is clear")
put("at", 1_777_000_020)
})))
})
})
})
})
})))
}
val roster = client.listBotModeRoster().getOrThrow()
assertTrue(roster.botModeProtocolSupported)
assertEquals("Lucy", roster.bots.single().displayName)
assertEquals("bot-tip", roster.bots.single().canonicalSession?.resolvedId)
assertEquals(1_777_000_030_000L, roster.bots.single().workerSession?.lastActiveAtMs)
assertEquals("Launch Council", roster.groups.single().name)
assertEquals("Rollout is clear", roster.groups.single().latestMessage?.text)
assertEquals(true, (harness.awaitRpc("profiles.list")["include_sessions"] as JsonPrimitive).booleanOrNull)
}
@Test
fun `canonical bot chat adopts exact title registry without creating`() = runBlocking {
harness.sessionListPayload = buildJsonObject {
put("sessions", JsonArray(listOf(buildJsonObject {
put("id", "bot-root")
put("resolved_id", "bot-tip")
put("root_title", "Bot Chat")
})))
}
val target = client.ensureCanonicalBotChat("operator").getOrThrow()
assertEquals("bot-root", target.storedSessionId)
assertEquals("bot-tip", target.resolvedSessionId)
assertTrue(harness.rpcLog.none { it.first == "session.create" })
val lookup = harness.awaitRpc("session.list")
assertEquals("operator", (lookup["profile"] as JsonPrimitive).content)
assertEquals("Bot Chat", (lookup["title"] as JsonPrimitive).content)
assertEquals(true, (lookup["include_hidden"] as JsonPrimitive).booleanOrNull)
}
@Test
fun `canonical bot chat creates hidden row only after authoritative empty lookup`() = runBlocking {
harness.createdSessionProfileName = "operator"
val target = client.ensureCanonicalBotChat("operator").getOrThrow()
assertEquals("20260612_120000_abc123", target.storedSessionId)
val create = harness.awaitRpc("session.create")
assertEquals("operator", (create["profile"] as JsonPrimitive).content)
assertEquals("Bot Chat", (create["title"] as JsonPrimitive).content)
assertEquals(true, (create["hidden"] as JsonPrimitive).booleanOrNull)
val title = harness.awaitRpc("session.title")
assertEquals("live-1", (title["session_id"] as JsonPrimitive).content)
assertEquals("Bot Chat", (title["title"] as JsonPrimitive).content)
}
@Test
fun `canonical bot chat lookup failure never creates replacement`() = runBlocking {
harness.rpcErrors["session.list"] = 5006 to "profile db unavailable"
assertTrue(client.ensureCanonicalBotChat("operator").isFailure)
assertTrue(harness.rpcLog.none { it.first == "session.create" })
}
@Test
fun `fixed route profile rides websocket URL and canonical RPC`() = runBlocking {
val routeClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = harness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
fixedSessionProfile = "research bot",
okHttpClient = OkHttpClient(),
callbackDispatcher = { it() },
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
)
try {
routeClient.ensureCanonicalBotChat("research bot").getOrThrow()
val requests = List(2) { harness.server.takeRequest(5, TimeUnit.SECONDS) }
assertTrue(requests.filterNotNull().any {
it.path?.contains("profile=research%20bot") == true
})
assertEquals(
"research bot",
(harness.awaitRpc("session.list")["profile"] as JsonPrimitive).content,
)
} finally {
routeClient.shutdown()
}
}
@Test
fun `canonical bot lookup on remote route never reaches active gateway`() = runBlocking {
val remoteHarness = GatewayClientHarness()
val remoteScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val remoteClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = remoteHarness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
fixedSessionProfile = "default",
okHttpClient = OkHttpClient(),
callbackDispatcher = { it() },
scope = remoteScope,
)
try {
remoteClient.ensureCanonicalBotChat("default").getOrThrow()
assertTrue(remoteHarness.rpcLog.any { it.first == "session.list" })
assertTrue(harness.rpcLog.none { it.first == "session.list" })
} finally {
remoteClient.shutdown()
remoteScope.cancel()
remoteHarness.shutdown()
}
}
@Test
fun `profile list drops oversized ui meta without dropping profile`() = runBlocking {
harness.profilesListPayload = buildJsonObject {
@@ -0,0 +1,130 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotModeRoster
import com.hermesandroid.relay.data.BotModeState
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.BotSessionSummary
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.ui.components.LocalSphereSkin
import com.hermesandroid.relay.ui.components.SphereRegistry
import com.hermesandroid.relay.ui.screens.BotModeContent
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import java.io.File
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w390dp-h844dp-432dpi")
class BotModeScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun approvedBotModeHome() {
val output = File("build/ui-evidence/bot-mode-home.png")
output.parentFile?.mkdirs()
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
CompositionLocalProvider(LocalSphereSkin provides SphereRegistry.Adaptive) {
BotModeContent(
state = fixtureState(),
connections = listOf(
fixtureConnection("hermes", "Hermes"),
fixtureConnection("lab", "Lab server"),
),
activeConnection = fixtureConnection("hermes", "Hermes"),
onBack = {},
onRefresh = {},
onSelectGateway = {},
onOpenBot = {},
onOpenGroup = {},
onNewBot = {},
nowMs = NOW,
)
}
}
}
compose.onRoot().captureRoboImage(output.absolutePath)
}
private fun fixtureState() = BotModeState(
roster = BotModeRoster(
bots = listOf(
bot("hermes", "Hermes", "default", "Lucy", "Drafted a rollout plan for the new flow.", NOW - 60_000L),
bot("lab", "Lab server", "researcher", "Researcher", "Here are the latest findings.", NOW - 18 * 60_000L),
bot("hermes", "Hermes", "builder", "Builder", "Build complete. 3 tests added.", NOW - 43 * 60_000L),
),
groups = listOf(
room("id:launch", "Launch Council", "Maya", "Please review the deck when you can.", NOW - 86_400_000L),
room("id:home", "Home Lab", "Alex", "Benchmarked the new model.", NOW - 3 * 86_400_000L),
),
botModeProtocolSupported = true,
),
)
private fun bot(
connectionId: String,
connectionLabel: String,
name: String,
title: String,
preview: String,
activeAt: Long,
) = BotRosterEntry(
profile = Profile(name = name, model = "gpt-5.6", description = title),
displayName = title,
route = com.hermesandroid.relay.data.BotGatewayRoute(
key = com.hermesandroid.relay.data.BotGatewayRouteKey(connectionId, name),
connectionLabel = connectionLabel,
),
canonicalSession = BotSessionSummary(
id = "$name-bot-chat",
preview = preview,
lastActiveAtMs = activeAt,
messageCount = 8,
),
workerSession = BotSessionSummary(
id = "$name-worker",
lastActiveAtMs = NOW - 30_000L,
),
)
private fun room(key: String, name: String, sender: String, text: String, at: Long) = BotGroupRoom(
key = key,
roomId = key.substringAfter(':'),
name = name,
messages = listOf(
BotGroupMessage(
id = "$key-message",
senderName = sender,
senderKind = "member",
text = text,
atMs = at,
),
),
)
private fun fixtureConnection(id: String, label: String) = Connection(
id = id,
label = label,
apiServerUrl = "",
relayUrl = "",
dashboardUrl = "https://example.invalid",
tokenStoreKey = "test-$id",
)
private companion object {
const val NOW = 1_777_000_000_000L
}
}
@@ -0,0 +1,44 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Test
class MorphingSphereMotionPolicyTest {
@Test
fun `visible idle sphere uses lightweight ambient motion`() {
assertEquals(
SphereMotionMode.AmbientLayer,
sphereMotionMode(
state = SphereState.Idle,
voiceMode = false,
motionVisible = true,
fixedTime = null,
fixedColorPhase = null,
),
)
}
@Test
fun `hidden or paused idle sphere is still`() {
assertEquals(
SphereMotionMode.Still,
sphereMotionMode(SphereState.Idle, false, false, null, null),
)
assertEquals(
SphereMotionMode.Still,
sphereMotionMode(SphereState.Idle, false, true, 0f, 0f),
)
}
@Test
fun `visible active and voice states keep procedural motion`() {
assertEquals(
SphereMotionMode.Procedural,
sphereMotionMode(SphereState.Thinking, false, true, null, null),
)
assertEquals(
SphereMotionMode.Procedural,
sphereMotionMode(SphereState.Idle, true, true, null, null),
)
}
}
@@ -0,0 +1,163 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotModeRoster
import com.hermesandroid.relay.data.BotModeState
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.BotSessionSummary
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w390dp-h844dp-432dpi")
class BotModeScreenTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `tabs filter bots and read only groups without changing workspace`() {
render()
compose.onNodeWithText("Lucy").assertExists()
compose.onNodeWithText("Launch Council").assertExists()
compose.onNodeWithText("Groups").performClick()
compose.onNodeWithText("Lucy").assertDoesNotExist()
compose.onNodeWithText("Launch Council").assertExists()
compose.onNodeWithText("Read only").assertExists()
}
@Test
fun `bot and group rows dispatch their exact owners`() {
var botOwner: String? = null
var groupOwner: String? = null
render(
onOpenBot = { botOwner = it.profile.name },
onOpenGroup = { groupOwner = it.key },
)
compose.onNodeWithText("Lucy").performClick()
compose.onNodeWithText("Launch Council").performClick()
assertEquals("default", botOwner)
assertEquals("id:launch", groupOwner)
}
@Test
fun `gateway scope filters union without switching active connection`() {
render(selectedGatewayId = "lab")
compose.onNodeWithText("Researcher").assertExists()
compose.onNodeWithText("Lucy").assertDoesNotExist()
}
private fun render(
onOpenBot: (BotRosterEntry) -> Unit = {},
onOpenGroup: (BotGroupRoom) -> Unit = {},
selectedGatewayId: String? = null,
) {
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
BotModeContent(
state = state(),
connections = listOf(connection(), labConnection()),
activeConnection = connection(),
selectedGatewayId = selectedGatewayId,
onBack = {},
onRefresh = {},
onSelectGateway = {},
onOpenBot = onOpenBot,
onOpenGroup = onOpenGroup,
onNewBot = {},
nowMs = NOW + 200_000L,
)
}
}
}
private fun state() = BotModeState(
roster = BotModeRoster(
bots = listOf(
BotRosterEntry(
profile = Profile(name = "default", model = "gpt-5.6", description = "Operator"),
displayName = "Lucy",
route = com.hermesandroid.relay.data.BotGatewayRoute(
key = com.hermesandroid.relay.data.BotGatewayRouteKey("home", "default"),
connectionLabel = "Hermes",
),
canonicalSession = BotSessionSummary(
id = "bot-root",
preview = "Drafted a rollout plan",
lastActiveAtMs = NOW - 10_000L,
),
),
BotRosterEntry(
profile = Profile(name = "researcher", model = "gpt-5.6", description = "Research"),
displayName = "Researcher",
route = com.hermesandroid.relay.data.BotGatewayRoute(
key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "researcher"),
connectionLabel = "Lab server",
),
canonicalSession = BotSessionSummary(
id = "researcher-root",
preview = "Findings ready",
lastActiveAtMs = NOW - 30_000L,
),
),
),
groups = listOf(
BotGroupRoom(
key = "id:launch",
roomId = "launch",
name = "Launch Council",
messages = listOf(
BotGroupMessage(
id = "message-1",
senderName = "Lucy",
senderKind = "member",
text = "Rollout is clear",
atMs = NOW - 20_000L,
),
),
sourceConnectionIds = setOf("home", "lab"),
),
),
),
)
private fun connection() = Connection(
id = "home",
label = "Hermes",
apiServerUrl = "",
relayUrl = "",
dashboardUrl = "https://example.invalid",
tokenStoreKey = "test",
)
private fun labConnection() = Connection(
id = "lab",
label = "Lab server",
apiServerUrl = "",
relayUrl = "",
dashboardUrl = "https://lab.invalid",
tokenStoreKey = "test-lab",
)
private companion object {
const val NOW = 1_777_000_000_000L
}
}
@@ -58,6 +58,15 @@ class ChatViewModelCommandCatalogTest {
)
}
@Test
fun canonicalBotChat_rewritesOnlyConversationForkCommands() {
assertTrue(shouldCompactCanonicalBotChat("new", canonicalBotChatMode = true))
assertTrue(shouldCompactCanonicalBotChat("RESET", canonicalBotChatMode = true))
assertFalse(shouldCompactCanonicalBotChat("new", canonicalBotChatMode = false))
assertFalse(shouldCompactCanonicalBotChat("compact", canonicalBotChatMode = true))
assertFalse(shouldCompactCanonicalBotChat("title", canonicalBotChatMode = true))
}
@Test
fun parseCommandsCatalog_ranksOnlySkillSlotsAndBoundsMetadata() {
val catalog = buildJsonObject {
@@ -1003,6 +1003,33 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(handler.messages.value.any { "Stopped" in it.badges })
}
@Test
fun stopClearsStaleBusyStateAfterTerminalBubbleAlreadySettled() {
handler.onTextDelta("stale-answer", "Finished answer")
handler.onTurnComplete("stale-answer")
assertTrue(handler.isStreaming.value)
assertFalse(handler.messages.value.single().isStreaming)
viewModel.cancelStream()
assertFalse(handler.isStreaming.value)
assertNull(handler.turnStatus.value)
}
@Test
fun newChatClearsStaleBusyStateWhenNoLiveGatewayTurnRemains() {
handler.onTextDelta("stale-answer", "Finished answer")
handler.onTurnComplete("stale-answer")
assertTrue(handler.isStreaming.value)
assertFalse(gatewayClient.hasActiveTurn())
viewModel.createNewChat()
assertFalse(handler.isStreaming.value)
assertTrue(handler.messages.value.isEmpty())
assertNull(handler.currentSessionId.value)
}
@Test
fun reopenedChatRestoresRichStateAndReattachesLiveGatewayTurn() {
val now = System.currentTimeMillis()
@@ -103,6 +103,54 @@ class ChatViewModelMediaStateTest {
assertEquals("content://com.axiomlabs.hermesrelay.fileprovider/hermes-media/photo.jpg", loaded.cachedUri)
}
@Test
fun assistantWindowsPathCellularRetryUsesByPathEndpoint() {
val windowsPath =
"C:\\Users\\Example\\AppData\\Local\\Temp\\Sovereign Intelligence copy.md"
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-file-1",
role = "assistant",
content = JsonPrimitive("MEDIA:$windowsPath"),
)
)
)
val deferred = awaitMessage {
it.attachments.singleOrNull()?.errorMessage == "Tap to download"
}
assertEquals(AttachmentState.FAILED, deferred.attachments.single().state)
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "text/markdown")
.setHeader(
"Content-Disposition",
"inline; filename=\"Sovereign Intelligence copy.md\"",
)
.setBody("# copy")
)
viewModel.cellularNetworkOverride = false
viewModel.manualFetchAttachment("assistant-file-1", 0)
val loaded = awaitMessage {
it.attachments.singleOrNull()?.state == AttachmentState.LOADED
}.attachments.single()
assertEquals("text/markdown", loaded.contentType)
assertEquals("Sovereign Intelligence copy.md", loaded.fileName)
val request = server.takeRequest()
assertEquals("/media/by-path", request.requestUrl?.encodedPath)
assertEquals(
"path=C%3A%5CUsers%5CExample%5CAppData%5CLocal%5CTemp%5C" +
"Sovereign%20Intelligence%20copy.md",
request.requestUrl?.encodedQuery,
)
assertEquals(windowsPath, request.requestUrl?.queryParameter("path"))
}
private fun awaitMessage(predicate: (ChatMessage) -> Boolean): ChatMessage {
val deadline = System.nanoTime() + 5_000_000_000L
while (System.nanoTime() < deadline) {
@@ -0,0 +1,103 @@
package com.hermesandroid.relay.viewmodel.connection
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayClientHarness
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class BotModeControllerTest {
@Test
fun refreshAggregatesTwoGatewaysPreservesOfflineCacheAndNeverSwitchesActive() = runBlocking {
val aHarness = GatewayClientHarness()
val bHarness = GatewayClientHarness()
val routeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val clients = mapOf(
"a" to routeClient(aHarness, routeScope),
"b" to routeClient(bHarness, routeScope),
)
aHarness.profilesListPayload = rosterPayload("default")
bHarness.profilesListPayload = rosterPayload("researcher")
val connections = MutableStateFlow(
listOf(
connection("a", "Laptop", aHarness),
connection("b", "Home Lab", bHarness),
),
)
val active = MutableStateFlow<String?>("a")
val controller = BotModeController(
scope = this,
connections = connections,
activeConnectionId = active,
dashboardUrlProvider = Connection::resolvedDashboardUrl,
dashboardClientFactory = { _, url -> DashboardApiClient(url, OkHttpClient()) },
gatewayLeaseFactory = { connectionId, _, _, _ ->
UpstreamTransportController.RouteGatewayLease(checkNotNull(clients[connectionId])) {}
},
)
try {
controller.refreshNow()
assertEquals(setOf("default", "researcher"), controller.state.value.roster.bots.map { it.profile.name }.toSet())
assertEquals("a", active.value)
bHarness.rpcErrors["profiles.list"] = 5006 to "offline"
controller.refreshNow()
assertTrue(
controller.state.value.roster.bots
.first { it.profile.name == "researcher" }
.stale,
)
assertEquals("a", active.value)
connections.value = connections.value.filterNot { it.id == "b" }
controller.connectionRemoved("b")
assertEquals(listOf("default"), controller.state.value.roster.bots.map { it.profile.name })
} finally {
clients.values.forEach(GatewayChatClient::shutdown)
routeScope.cancel()
aHarness.shutdown()
bHarness.shutdown()
}
}
private fun routeClient(harness: GatewayClientHarness, scope: CoroutineScope) = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
harness.server.url("/").toString().trimEnd('/'),
OkHttpClient(),
),
fixedSessionProfile = "default",
okHttpClient = OkHttpClient(),
callbackDispatcher = { it() },
scope = scope,
)
private fun rosterPayload(profile: String) = buildJsonObject {
put("profiles", JsonArray(listOf(buildJsonObject {
put("name", profile)
put("model", "gpt")
put("is_default", profile == "default")
})))
put("bot_mode_protocol", true)
}
private fun connection(id: String, label: String, harness: GatewayClientHarness) = Connection(
id = id,
label = label,
apiServerUrl = "",
relayUrl = "",
dashboardUrl = harness.server.url("/").toString().trimEnd('/'),
tokenStoreKey = "token-$id",
)
}
@@ -0,0 +1,133 @@
package com.hermesandroid.relay.viewmodel.connection
import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotModeRoster
import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BotModeRosterAggregationTest {
private val a = connection("a", "Laptop")
private val b = connection("b", "Home Lab")
private val connections = MutableStateFlow(listOf(a, b))
private val active = MutableStateFlow<String?>("a")
private val controller = BotModeController(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
connections = connections,
activeConnectionId = active,
dashboardUrlProvider = Connection::resolvedDashboardUrl,
dashboardClientFactory = { _, url -> DashboardApiClient(url) },
gatewayLeaseFactory = { _, _, _, _ -> error("not used by aggregation tests") },
)
@Test
fun sameInstallCollapsesBeforeHandleCountingAndActiveRouteWins() {
val state = aggregate(
snapshot(a, "install-1", bots = listOf(bot("default"))),
snapshot(b, "install-1", bots = listOf(bot("default"))),
)
assertEquals(1, state.roster.bots.size)
assertEquals("a", state.roster.bots.single().route?.connectionId)
assertEquals("default", state.roster.bots.single().handle)
}
@Test
fun sameNameOnDifferentInstallsUsesSourceQualifiedHandles() {
val state = aggregate(
snapshot(a, "install-a", bots = listOf(bot("default"), bot("unique"))),
snapshot(b, "install-b", bots = listOf(bot("default"))),
)
assertEquals(
setOf("default-laptop", "default-home-lab", "unique"),
state.roster.bots.mapTo(linkedSetOf(), BotRosterEntry::handle),
)
}
@Test
fun missingInstallIdsNeverCollapseAndOfflineRowsStayStale() {
val state = aggregate(
snapshot(a, null, bots = listOf(bot("worker")), stale = true),
snapshot(b, null, bots = listOf(bot("worker"))),
)
assertEquals(2, state.roster.bots.size)
assertTrue(state.roster.bots.first { it.route?.connectionId == "a" }.stale)
assertFalse(state.roster.bots.first { it.route?.connectionId == "b" }.stale)
}
@Test
fun groupProjectionDeduplicatesByRoomIdentityAndKeepsAllSources() {
val old = room(revision = 1, text = "old")
val fresh = room(revision = 2, text = "fresh")
val state = aggregate(
snapshot(a, "install-a", groups = listOf(old)),
snapshot(b, "install-b", groups = listOf(fresh)),
)
assertEquals(1, state.roster.groups.size)
assertEquals("fresh", state.roster.groups.single().latestMessage?.text)
assertEquals(setOf("a", "b"), state.roster.groups.single().sourceConnectionIds)
}
private fun aggregate(vararg snapshots: BotModeGatewaySnapshot) = controller.aggregateForTest(
fleet = connections.value,
snapshots = snapshots.associateBy { it.connection.id },
loading = false,
)
private fun snapshot(
connection: Connection,
installId: String?,
bots: List<BotRosterEntry> = emptyList(),
groups: List<BotGroupRoom> = emptyList(),
stale: Boolean = false,
) = BotModeGatewaySnapshot(
connection = connection,
dashboardUrl = connection.resolvedDashboardUrl,
installId = installId,
roster = BotModeRoster(bots = bots, groups = groups, botModeProtocolSupported = true),
stale = stale,
error = if (stale) "offline" else null,
)
private fun bot(name: String) = BotRosterEntry(
profile = Profile(name = name, model = "gpt"),
displayName = name,
)
private fun room(revision: Long, text: String) = BotGroupRoom(
key = "id:room-1",
roomId = "room-1",
name = "Council",
revision = revision,
messages = listOf(
BotGroupMessage(
senderName = "Bot",
senderKind = "member",
text = text,
atMs = revision,
),
),
)
private fun connection(id: String, label: String) = Connection(
id = id,
label = label,
apiServerUrl = "",
relayUrl = "",
dashboardUrl = "http://$id.invalid",
tokenStoreKey = "token-$id",
)
}
@@ -0,0 +1,76 @@
package com.hermesandroid.relay.viewmodel.connection
import android.content.Context
import io.mockk.mockk
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Test
class UpstreamTransportControllerRoutePoolTest {
private val controller = UpstreamTransportController(
context = mockk<Context>(relaxed = true),
activeConnectionIdProvider = { "a" },
dashboardUrlProvider = { "http://a.invalid" },
gatewayKeepAliveProvider = { false },
trustedDashboardUrlProvider = { id -> "http://$id.invalid" },
)
@Test
fun exactConnectionAndProfileOwnOneClient() {
val first = controller.acquireGatewayRoute("a", "http://a.invalid", "default")
val repeated = controller.acquireGatewayRoute("a", "http://a.invalid", "default")
val sibling = controller.acquireGatewayRoute("a", "http://a.invalid", "writer")
val remoteTwin = controller.acquireGatewayRoute("b", "http://b.invalid", "default")
assertSame(first.client, repeated.client)
assertNotSame(first.client, sibling.client)
assertNotSame(first.client, remoteTwin.client)
first.close()
repeated.close()
sibling.close()
remoteTwin.close()
}
@Test
fun connectionRemovalDisposesOnlyThatConnectionsRoutes() {
val a = controller.acquireGatewayRoute("a", "http://a.invalid", "default", retain = true)
val b = controller.acquireGatewayRoute("b", "http://b.invalid", "default", retain = true)
val oldA = a.client
val oldB = b.client
controller.disposeConnectionRouteClients("a")
a.close()
val newA = controller.acquireGatewayRoute("a", "http://a.invalid", "default")
val sameB = controller.acquireGatewayRoute("b", "http://b.invalid", "default")
assertNotSame(oldA, newA.client)
assertSame(oldB, sameB.client)
b.close()
newA.close()
sameB.close()
}
@Test
fun staleLeaseCannotEvictRouteReplacement() {
val old = controller.acquireGatewayRoute("a", "http://a.invalid", "default", retain = true)
val replacement = controller.acquireGatewayRoute(
"a",
"http://a.invalid/alternate",
"default",
)
assertNotSame(old.client, replacement.client)
old.close()
val repeated = controller.acquireGatewayRoute(
"a",
"http://a.invalid/alternate",
"default",
)
assertSame(replacement.client, repeated.client)
replacement.close()
repeated.close()
}
}
+48
View File
@@ -55,6 +55,54 @@ No open P0, P1, or P2 findings.
final result: passed
# Android Bot Mode messenger workspace
**Comparison target**
- Source visual truth: `docs/mockups/bot-mode/bot-mode-home-approved.png`
- Rendered implementation: `app/build/ui-evidence/bot-mode-home.png`
- Combined evidence: `app/build/ui-evidence/bot-mode-comparison.png`
- Viewport: Android Compose at `390dp x 844dp`, Robolectric qualifier `w390dp-h844dp-432dpi`
- Pixels and normalization: source `853 x 1844`; implementation `1053 x 2278` at Android density `2.7`. The combined evidence downsamples the implementation to `853 x 1844` and places it beside the source at equal visible bounds.
- State: dark Hermes Relay theme, active Hermes gateway, All filter, three active Bots, three Bot conversations, and two read-only group rooms.
**Findings**
- No actionable P0, P1, or P2 differences remain.
- Fonts and typography: the implementation uses the app's established Material typography and optical weights. The title, selector labels, active roster, conversation titles, metadata, and one-line previews preserve the source hierarchy without clipped primary labels.
- Spacing and layout rhythm: the implementation preserves the full-screen messenger composition, compact gateway selector, equal-width filters, horizontal active roster, divided chronological list, and one bottom-end primary action. Native touch targets make the selector and filters slightly taller than the generated source; all five rows remain visible at the target viewport.
- Colors and visual tokens: the first capture overused the strong electric `primaryContainer`. The final capture uses the softer theme-scoped relay/periwinkle tokens for selected and identity surfaces, graphite containers, semantic green presence, and restrained hairlines.
- Image quality and asset fidelity: production rows render the real cached upstream profile avatar when present. The deterministic fixture exercises the app's real initial fallback instead of shipping the mock's illustrative anime/crystal/robot images as product assets. Group rows use the app's Material group/lock icons; no placeholder bitmap or custom-drawn icon was introduced.
- Copy and content: Bot Mode, All gateways, All/Bots/Groups, Active now, source-qualified rows, previews, and Read only match the selected workflow. Relative timestamps follow the app's existing locale-aware convention rather than the mock's fixed clock values.
- Interaction: gateway selection, All/Bots/Groups filtering, Bot owner dispatch, group owner dispatch, search, New Bot, canonical Bot Chat open, read-only room detail, drawer entry, and Back to Bot Mode are wired. The fixture does not invent unread dots because current upstream supplies activity but no canonical mobile read-state contract.
**Comparison history**
1. Initial capture found P2 accent-weight drift: the selected tab, FAB, and fallback avatars used saturated electric fills; control borders were stronger than the source; an extra refresh action crowded the app bar.
2. Replaced large fills with the softer theme-scoped relay/electric-muted tokens, reduced control-border opacity and vertical padding, and removed the extra refresh action while retaining automatic load and error-state retry.
3. Re-rendered at the same `390dp x 844dp` state and normalized both artifacts into `bot-mode-comparison.png`. The corrected surface has no remaining P0/P1/P2 mismatch.
4. After multi-gateway aggregation landed, re-rendered the final All gateways state with per-row source-qualified handles. The gateway scope now matches the approved mock literally while preserving readable row density; no new P0/P1/P2 mismatch appeared.
**Open Questions**
- None blocking. A physical-device pass may refine P3 density at the user's font/display scale, but the deterministic supported viewport is complete.
**Implementation Checklist**
- [x] Keep Bot Mode outside the ordinary session taxonomy.
- [x] Match the approved list hierarchy and native return path.
- [x] Render a truthful all-gateway union without changing the foreground connection.
- [x] Render upstream group projection as visibly read-only.
- [x] Verify the primary filters and owner-routing interactions.
- [x] Capture and compare the actual Compose surface.
**Follow-up Polish**
- P3: physical-device review can tune handle truncation for unusually long gateway labels.
- P3: add canonical read-state dots only if upstream publishes a durable read-state contract.
final result: passed
## Assistant overlay
- Reference: `C:\Users\Bailey\.codex\generated_images\019fb003-68ea-7052-85c7-3745fa7e838e\call_jPwpDr9QfaCDA6k2c01StBYe.png`
+54
View File
@@ -3683,3 +3683,57 @@ upstream drift, Android state-machine defects, rendered lifecycle failures, and
physical-device failures are reported as distinct evidence lanes. A physical
pass is never inferred from JVM or source checks, and scheduled execution can
be considered later without being silently introduced now.
---
## ADR 66 — Android Bot Mode is a separate upstream-owned messaging workspace
**Status:** Accepted (2026-08-24).
**Context.** Upstream Hermes Desktop presents profiles as Bots with one durable
canonical `Bot Chat`, profile-scoped routines, group rooms, and source-qualified
multi-gateway ownership. Folding those rows into Android's ordinary session
drawer would mix a standing identity/room roster with scratch conversations and
repeat the category error avoided for platform Threads. Desktop also owns live
group orchestration locally while publishing a bounded recent-history projection
through Gateway `ui_meta` for other clients.
**Decision.** The existing drawer gains one `Bot Mode` entry and otherwise keeps
its session behavior. Bot Mode is a full-screen messenger list with gateway,
All/Bots/Groups, search, activity, individual Bot, and group-room affordances.
It aggregates every saved gateway with bounded concurrency and keeps the
foreground connection unchanged. Last-good rows remain visible as offline.
Upstream `install_id` collapses two saved routes to the same installation before
same-profile handle disambiguation.
Android consumes `profiles.list {include_sessions:true}` only for this surface.
The Gateway's `canonical_session` wins. When absent, Android performs the exact
hidden-title lookup for `Bot Chat`, fails closed on lookup error, and creates and
materializes a hidden canonical row only after authoritative absence. Opening it
uses the existing profile/session chat owner and exposes a direct return to Bot
Mode. `/new` and `/reset` in that canonical surface run the existing bounded
compression path instead of forking the relationship. New Bot creates an upstream profile with shared authentication and writes
only the small `hermes-bots` metadata marker; profile skills/model remain managed
through the established Hermes surfaces.
Every Bot owner is the immutable `(connectionId, profile)` pair. A typed route
pool holds separate clients for separate owners, validates bearer authority
against that connection's exact trusted Dashboard base, adds the profile to the
WebSocket URL, mints a fresh one-use ticket on every dial, and uses request or
retained leases so a stale release cannot evict a replacement. Opening a Bot
uses a dedicated Gateway-only Chat destination and Dashboard history reader;
it never switches the globally active connection or lets API fallback consult a
different database.
Group rooms parse the bounded v3 `hermes-bots-groups` projection without its
optional embedded image. They render read-only and cannot dispatch or mutate
room state. Android does not copy Desktop's round-robin coordinator or local
plugin store.
**Consequences.** Vanilla upstream owns Bot identity, history, auth, and prompt
protocol; Relay remains optional. Ordinary sessions remain legible, same-named
profiles on different gateways cannot alias, a remote Bot Chat cannot leak into
the active connection, and a group cannot gain a competing mobile writer.
Route-scoped Relay media, voice, background delivery notifications, autonomous
peer delivery, and writable room control remain separate capabilities rather
than implicit authority gained from appearing in the union roster.
+19
View File
@@ -68,6 +68,25 @@ Rule of thumb: where a surface is CI-gateable, write the **failing test first**
is the deliberate exception — CI only covers lint + unit there, so on-device
verification stays a manual maintainer step and a fix is never "done" from CI alone.
### Emulator UI evidence
Hermes Android is dark-mode-first. Before emulator screenshots, animation
review, or renderer performance measurements, explicitly enable Android dark
mode and restart the app so evidence is not captured in the emulator's light
default:
```bash
adb -s <emulator-serial> shell cmd uimode night yes
adb -s <emulator-serial> shell am force-stop com.axiomlabs.hermesrelay.sideload
adb -s <emulator-serial> shell am start -n \
com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity
adb -s <emulator-serial> shell cmd uimode night
```
Confirm the final command reports `Night mode: yes` before capturing evidence.
Use host GPU acceleration where available; software rendering is useful for
compatibility but is not representative performance evidence.
## Local bridge: `scripts/start-issue.sh`
```bash
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "4e85f24ba762bb8f3361656fc8b79c978af6c6fd1134359af92f03bb07dd0664",
"main": "fa94ad2e674f670dfe96f4c75de6f92e0db518b66594fa44aadbae8317ed022e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+3
View File
@@ -472,6 +472,9 @@ Bottom navigation bar with 4 tabs:
- **Profile creation** — Manage uses `profiles.create` on current Gateways and labels authentication as shared sign-in, copied credential snapshot, or isolated/no-copy. Android serializes `mirror_credentials` and `share_auth` explicitly, reports best-effort SOUL/model/credential results without claiming full success, and never receives or logs credentials. The user may explicitly enable the authenticated Dashboard create route as an older-host fallback only for the legacy shared/default choice; explicit isolation never degrades to an ambiguous older mutation.
- **Deletion boundary** — Hermes exposes no `profiles.delete` Gateway RPC. Android continues to delete profiles only through authenticated Dashboard `DELETE /api/profiles/{name}`.
- **Profile switch lifecycle** — selecting an inactive profile never changes Hermes' sticky server default and never hot-swaps a live session. Android switches connection/profile context, restores that profile's last session only from the compatible Gateway or SSE transport slot, or opens a fresh draft. Gateway turns detach and reconcile in their original durable session; live SSE switching is disabled. Model/provider, personality, reasoning, approval, Fast, and YOLO state are cleared before destination session truth re-seeds them.
- **Bot Mode workspace** — the session drawer exposes one entry into a separate full-screen messenger surface; it does not add Bot or group rows to the ordinary session taxonomy. Android refreshes every saved Dashboard/Gateway with bounded concurrency, preserves last-good rows as visibly offline, and collapses duplicate routes by upstream `install_id` before assigning source-qualified handles. Every Bot carries an immutable `(connectionId, profile)` owner; labels, installation metadata, and the currently resolved URL are presentation/routing data rather than identity. All gateways and one-gateway filters never mutate the foreground connection.
- **Canonical Bot Chat** — each individual row resolves the exact hidden session titled `Bot Chat` on its owning Gateway. Lookup failure is not absence, so Android creates and materializes the lazy row with `session.title` only after an authoritative empty exact-title result. The dedicated Bot Chat destination retains that route's pooled Gateway client, loads history through the same connection/profile Dashboard, sends only through Gateway, and returns directly to Bot Mode without rebinding Standard Chat or the global connection. `/new` or `/reset` compacts the canonical conversation instead of forking it. The route pool mints a fresh WebSocket ticket per dial, includes the immutable profile in the WebSocket URL, isolates credentials by exact trusted connection origin, and tears down only the removed connection's clients.
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. The process-owned conversation binding is the single connection/profile/session identity for Chat; selecting an All Profiles row atomically makes its owner the selected agent and persists that profile/session, while merely browsing All Profiles changes no agent state. Lifecycle or locale-driven Activity recreation cannot replace an explicit binding with stale persisted state, and asynchronous list/history/mutation work is accepted only for the binding's exact namespace. A profile lock hides All Profiles and rejects stale/deep-linked cross-profile opens. The All Profiles browser mode otherwise survives Activity state restoration and refetches its rows after recreation. 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** — 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. Authoritative submit rejections (`4004`, `4018`, `4028`, `4029`, `4030`, `4090`, `5008`, `5070`, and `5071`) preserve the server message and never fall through to API-server SSE.
+7 -1
View File
@@ -160,6 +160,12 @@ class RelayConfig:
@classmethod
def from_env(cls) -> RelayConfig:
"""Build config from environment variables, falling back to defaults."""
hermes_home = (os.getenv("HERMES_HOME") or "").strip()
default_hermes_config = (
str(Path(hermes_home).expanduser() / "config.yaml")
if hermes_home
else cls.hermes_config_path
)
config = cls(
host=os.getenv("RELAY_HOST", cls.host),
port=int(os.getenv("RELAY_PORT", str(cls.port))),
@@ -167,7 +173,7 @@ class RelayConfig:
ssl_key=os.getenv("RELAY_SSL_KEY"),
webapi_url=os.getenv("RELAY_WEBAPI_URL", cls.webapi_url),
hermes_config_path=os.getenv(
"RELAY_HERMES_CONFIG", cls.hermes_config_path
"RELAY_HERMES_CONFIG", default_hermes_config
),
log_level=os.getenv("RELAY_LOG_LEVEL", cls.log_level),
terminal_shell=os.getenv("RELAY_TERMINAL_SHELL") or None,
+56
View File
@@ -0,0 +1,56 @@
"""Relay environment-resolution regression tests."""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from plugin.relay.config import RelayConfig
class RelayConfigEnvironmentTests(unittest.TestCase):
def test_hermes_home_supplies_default_config_and_session_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
with mock.patch.dict(os.environ, {"HERMES_HOME": str(home)}, clear=False):
os.environ.pop("RELAY_HERMES_CONFIG", None)
os.environ.pop("RELAY_SESSIONS_FILE", None)
config = RelayConfig.from_env()
self.assertEqual(str(home / "config.yaml"), config.hermes_config_path)
self.assertEqual(
str(home / "hermes-relay-sessions.json"),
config.session_persistence_path,
)
def test_explicit_relay_config_override_wins_over_hermes_home(self) -> None:
with tempfile.TemporaryDirectory() as home_tmp, tempfile.TemporaryDirectory() as override_tmp:
home = Path(home_tmp)
override = Path(override_tmp) / "relay-config.yaml"
override.write_text("{}\n", encoding="utf-8")
with mock.patch.dict(
os.environ,
{
"HERMES_HOME": str(home),
"RELAY_HERMES_CONFIG": str(override),
},
clear=False,
):
os.environ.pop("RELAY_SESSIONS_FILE", None)
config = RelayConfig.from_env()
self.assertEqual(str(override), config.hermes_config_path)
self.assertEqual(
str(override.parent / "hermes-relay-sessions.json"),
config.session_persistence_path,
)
if __name__ == "__main__":
unittest.main()