fix(android): preserve chat activity and correct feedback ownership

This commit is contained in:
Bailey Dixon
2026-09-09 16:57:40 -04:00
parent df0fe59b0b
commit 22c8f76c72
81 changed files with 4579 additions and 1067 deletions
+4
View File
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- Android feedback uses themed banners and action cards instead of platform toasts and default snackbars. Dashboard errors no longer misidentify missing resources as an outdated Relay. Developer settings includes local-only message previews.
- Missing chat attachments show their error and retry in the attachment card without repeated global popups. Global action messages occupy the top message area instead of covering the composer.
- Chat distinguishes session preparation from response streaming and retains initialization errors that arrive before the session acknowledgement. Long-press the agent header to open a live session-diagnostics drawer.
- Delegated-agent activity survives parent replies and leaves compact history entries for later read-only review. The activity strip appears only while work runs; historical process views cannot stop or dismiss live work. (#447)
- **`android_*` tools resolve bridge credentials written after host startup.** Requests retry profile-scoped env and active bridge-session credentials after a stale token is rejected, and vision navigation now shares the same current Relay transport instead of the retired standalone default.
- **`android_setup` accepts both its canonical and legacy schema keys.** `bridge_session_token` and `pairing_code` are accepted, while a missing token returns a structured error.
- **Android tool setup tests use a temporary Hermes home.** Test runs no longer write bridge settings into a developer environment.
@@ -0,0 +1,65 @@
package com.hermesandroid.relay.ui.screens
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
import com.hermesandroid.relay.ui.components.ChatDebugOverlay
import com.hermesandroid.relay.ui.components.chatDebugHeaderGesture
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class ChatDebugDrawerInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
@Test
fun longPressOpensDiagnosticsBelowHeaderAndCloseRestoresChat() {
compose.setContent {
var open by remember { mutableStateOf(false) }
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Box(Modifier.fillMaxSize()) {
Text("Hermes", Modifier.fillMaxWidth().height(64.dp).testTag("header")
.chatDebugHeaderGesture(true, onClick = {}, onHold = { open = true }))
ChatDebugOverlay(open, 64.dp, onClose = { open = false }) {
ChatDebugDrawer(
profile = "Server Default", model = "Example", sessionId = "session",
gateway = true, signedIn = true, signInRequired = false,
socketState = GatewayConnectionState.Ready, preparing = false,
streaming = false, loadingHistory = false, directoryUnavailable = false,
failure = null, onClose = { open = false }, onConnections = {},
)
}
}
}
}
val header = compose.onNodeWithTag("header")
val before = header.fetchSemanticsNode().boundsInRoot
header.performTouchInput { longClick() }
compose.onNodeWithText("Session diagnostics").assertIsDisplayed()
assertEquals(before, header.fetchSemanticsNode().boundsInRoot)
compose.onNodeWithContentDescription("Close session diagnostics").performClick()
compose.onNodeWithText("Session diagnostics").assertDoesNotExist()
header.assertIsDisplayed()
}
}
@@ -0,0 +1,183 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.data.projectChatActivityReceipts
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/** Real Gateway callbacks drive production activity surfaces through Android lifecycle changes. */
class ChatActivityReceiptInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: AndroidGatewayContractFixture
private lateinit var gatewayScope: CoroutineScope
private lateinit var gateway: GatewayChatClient
private lateinit var handler: ChatHandler
private lateinit var viewModel: ChatViewModel
private lateinit var socket: WebSocket
private val owner = AgentDisplay.profileContextKey("fixture-connection", "research")
@Before
fun setUp() {
fixture = AndroidGatewayContractFixture().also { it.profileName = "research" }
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val http = OkHttpClient()
gateway = GatewayChatClient(
initialDashboardClient = DashboardApiClient(fixture.server.url("/").toString().trimEnd('/'), okHttpClient = http),
okHttpClient = http,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
viewModel = ChatViewModel().also {
it.initialize(HermesApiClient(fixture.server.url("/").toString(), "fixture-key"), handler)
it.streamingEndpoint = "gateway"
it.setSessionProfileNameProvider { "research" }
it.setProfileMessageLoader { Result.success(emptyList()) }
it.setChatActivityStore(InMemoryChatActivityStore())
it.switchProfileContext(owner, STORED_SESSION_ID)
it.updateGatewayClient(gateway)
it.setChatVisible(true)
}
compose.setContent {
val messages by viewModel.messages.collectAsStateWithLifecycle()
val records by viewModel.activityRecords.collectAsStateWithLifecycle()
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
val retained by viewModel.retainedActivityPreview.collectAsStateWithLifecycle()
val childPreview by viewModel.subagentChildPreview.collectAsStateWithLifecycle()
val session by viewModel.currentSessionId.collectAsStateWithLifecycle()
var sheetOpen by remember { mutableStateOf(false) }
MaterialTheme {
Column {
GatewayBackgroundProcessStrip(
processes = emptyList(), subagentActivities = children,
subagentPreviewVisibility = SubagentPreviewVisibility(), loading = false,
onClick = { viewModel.openCurrentActivityPreview(); sheetOpen = true },
modifier = Modifier.testTag("active-activity"),
)
projectChatActivityReceipts(messages, records, owner, session).forEach { message ->
message.activityRecord?.let { record ->
ChatActivityReceipt(
record = record,
onClick = { sheetOpen = viewModel.openRetainedActivity(record) },
modifier = Modifier.testTag("activity-receipt"),
)
}
}
}
if (sheetOpen) {
GatewayBackgroundProcessSheet(
processes = retained?.processes.orEmpty(),
subagentActivities = retained?.record?.previewActivities() ?: children,
subagentChildPreview = childPreview,
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = false, stoppingProcessIds = emptySet(),
onRefresh = viewModel::refreshBackgroundProcesses,
onStop = viewModel::stopBackgroundProcess,
onDismissProcess = viewModel::dismissBackgroundProcess,
onOpenSubagentChild = viewModel::openSubagentChildPreview,
onDismiss = { viewModel.closeActivityPreview(); sheetOpen = false },
readOnlyHistory = retained != null,
historyNotice = "Recorded activity. Available child history is read-only.",
)
}
}
}
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
socket = fixture.awaitServerSocket()
fixture.awaitRpc("session.resume")
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
gateway.shutdown()
gatewayScope.cancel()
fixture.shutdown()
}
@Test
fun detachedCompletionLeavesReopenableReceiptAcrossActivityResume() {
viewModel.sendMessage("Delegate a background task")
fixture.awaitRpc("prompt.submit")
socket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
socket.send(fixture.event("subagent.start", buildJsonObject {
put("subagent_id", "receipt-child")
put("delegation_id", "receipt-delegation")
put("task_count", 1)
put("goal", "Inspect activity lifecycle")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.subagentActivities.value.size == 1 }
compose.onNodeWithTag("active-activity").assertIsDisplayed()
socket.send(fixture.event("message.complete", buildJsonObject { put("text", "Launched") }, LIVE_SESSION_ID))
compose.waitUntil(5_000) { !handler.isStreaming.value }
compose.onNodeWithTag("active-activity").assertIsDisplayed()
socket.send(fixture.event("subagent.complete", buildJsonObject {
put("subagent_id", "receipt-child")
put("delegation_id", "receipt-delegation")
put("status", "completed")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.COMPLETE }
compose.onNodeWithTag("active-activity").assertDoesNotExist()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed().performClick()
compose.onNodeWithText("Chat activity").assertIsDisplayed()
compose.onNodeWithText("Recorded activity. Available child history is read-only.").assertIsDisplayed()
compose.onNodeWithText("Stop").assertDoesNotExist()
compose.onNodeWithContentDescription("Close activity preview").performClick()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed()
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.onNodeWithTag("active-activity").assertDoesNotExist()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed().performClick()
compose.onNodeWithText("Chat activity").assertIsDisplayed()
}
private companion object {
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val LIVE_SESSION_ID = "fixture-live-1"
}
}
@@ -28,6 +28,8 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -115,9 +117,18 @@ class GatewayForegroundRecoveryInstrumentedTest {
compose.setContent {
val messages by viewModel.messages.collectAsStateWithLifecycle()
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
MaterialTheme {
Column(Modifier.testTag("contract-transcript")) {
GatewayBackgroundProcessStrip(
processes = emptyList(),
subagentActivities = children,
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = false,
onClick = {},
modifier = Modifier.testTag("child-activity"),
)
Text(
text = if (streaming) "STREAMING" else "IDLE",
modifier = Modifier.testTag("stream-state"),
@@ -153,6 +164,37 @@ class GatewayForegroundRecoveryInstrumentedTest {
fixture.shutdown()
}
@Test
fun detachedChildActivity_survivesParentTerminalAndActivityResume() {
viewModel.sendMessage("Delegate a background task")
fixture.awaitRpc("prompt.submit")
serverSocket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
serverSocket.send(fixture.event("subagent.start", buildJsonObject {
put("subagent_id", "detached-child")
put("goal", "Inspect")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.subagentActivities.value.size == 1 }
compose.onNodeWithTag("child-activity").assertIsDisplayed()
serverSocket.send(fixture.event("message.complete", buildJsonObject { put("text", "Launched") }, LIVE_SESSION_ID))
compose.waitUntil(5_000) { !handler.isStreaming.value }
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
serverSocket.send(fixture.event("subagent.progress", buildJsonObject {
put("subagent_id", "detached-child")
put("text", "Still working")
}, LIVE_SESSION_ID))
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.waitUntil(5_000) { viewModel.subagentActivities.value.single().events.last().text == "Still working" }
compose.onNodeWithTag("child-activity").assertIsDisplayed()
assertFalse(viewModel.subagentActivities.value.single().isTerminal)
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
serverSocket.send(fixture.event("subagent.complete", buildJsonObject {
put("subagent_id", "detached-child")
put("status", "completed")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.subagentActivities.value.single().isTerminal }
compose.onNodeWithTag("child-activity").assertDoesNotExist()
}
@Test
fun terminalGapActivate_recoversForegroundTurnWithoutNavigationOrCrossSessionLeak() {
viewModel.sendMessage("Run a long foreground task")
@@ -0,0 +1,102 @@
package com.hermesandroid.relay.data
/** Presentation only: canonical rows retain their wire identity, role and content. */
internal fun projectChatActivityReceipts(
messages: List<ChatMessage>,
records: List<ChatActivityRecord>,
scopeKey: String?,
sessionId: String?,
): List<ChatMessage> {
val originals = messages.filterNot {
it.clientOnly && it.id.startsWith("activity:") && it.activityRecord != null
}
if (scopeKey.isNullOrBlank() || sessionId.isNullOrBlank()) {
return originals.map { it.copy(activityRecord = null) }
}
val owned = records.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
.sortedByDescending { it.updatedAt }
.distinctBy { it.id }
val represented = mutableSetOf<String>()
val canonical = originals.map { message ->
val process = message.hermesProcessNotificationOrNull()
val delegation = message.activitySourceId?.takeIf { it.startsWith("delegation:") }
?.removePrefix("delegation:")?.takeIf { it.isNotBlank() }
val kind = when {
message.activitySourceId != null -> ChatActivityKind.SUBAGENTS
process != null -> ChatActivityKind.PROCESS
else -> return@map message.copy(activityRecord = null)
}
val sourceId = delegation ?: process?.processId
val processTerminal = process?.let { notice ->
PROCESS_TERMINAL_HEADLINE.matchEntire(notice.headline)?.let { match ->
match.groupValues[2].toIntOrNull()?.let { code ->
val phase = when {
match.groupValues[1].startsWith("terminated by ") -> ChatActivityPhase.CANCELLED
code == 0 -> ChatActivityPhase.COMPLETE
else -> ChatActivityPhase.FAILED
}
phase to code
}
}
}
// A process id can be reused after a registry restart. A canonical row has
// no start-generation field, so multiple generations must remain unmatched.
val matching = if (sourceId == null) emptyList() else owned.filter {
it.kind == kind && it.sourceId == sourceId
}
val record = matching.singleOrNull()?.also { represented += it.id }
?: ChatActivityRecord(
id = "canonical:${message.id}",
scopeKey = scopeKey,
sessionId = sessionId,
kind = kind,
sourceId = sourceId ?: "unavailable:${message.id}",
title = process?.headline ?: message.content,
phase = when {
kind == ChatActivityKind.PROCESS -> processTerminal?.first ?: ChatActivityPhase.UNKNOWN
(message.activityFailedCount ?: 0) > 0 ->
if (message.activityFailedCount == message.activityTaskCount) {
ChatActivityPhase.FAILED
} else ChatActivityPhase.UNKNOWN
(message.activityTaskCount ?: 0) > 0 -> ChatActivityPhase.COMPLETE
else -> ChatActivityPhase.UNKNOWN
},
createdAt = message.timestamp,
updatedAt = message.timestamp,
taskCount = message.activityTaskCount?.coerceAtLeast(0) ?: 0,
processId = process?.processId,
exitCode = processTerminal?.second,
)
// Aggregate completion metadata never rewrites captured child phases.
message.copy(activityRecord = record)
}
val pending = owned.filter { it.phase != ChatActivityPhase.RUNNING && it.id !in represented }
.sortedWith(compareBy<ChatActivityRecord> { it.updatedAt }.thenBy { it.id })
.map { record ->
ChatMessage(
id = "activity:${record.id}",
role = MessageRole.SYSTEM,
content = record.title,
timestamp = record.updatedAt,
clientOnly = true,
activityRecord = record,
)
}
// Stable merge: history order is authoritative even if server timestamps
// regress. Only insert local receipts; never sort canonical messages.
var next = 0
return buildList {
canonical.forEach { message ->
while (next < pending.size && pending[next].timestamp < message.timestamp) {
add(pending[next++])
}
add(message)
}
while (next < pending.size) add(pending[next++])
}
}
/** Upstream completion envelope only; never search arbitrary command/output text. */
private val PROCESS_TERMINAL_HEADLINE = Regex(
"""Background process \S+ (completed normally|exited|terminated by [^\r\n]+|marked lost because the process backend disappeared|failed to start) \(exit code (-?\d+)(?:, SIGTERM)?\)\.""",
)
@@ -0,0 +1,222 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import java.io.IOException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.intOrNull
@Serializable
enum class ChatActivityKind { SUBAGENTS, PROCESS }
@Serializable
enum class ChatActivityPhase { RUNNING, COMPLETE, FAILED, CANCELLED, UNKNOWN }
@Serializable
data class ChatActivityChild(
val id: String,
val childSessionId: String? = null,
val goal: String = "",
val phase: ChatActivityPhase = ChatActivityPhase.UNKNOWN,
val summary: String? = null,
)
/** Local presentation metadata and exact references, never transcripts or process output. */
@Serializable
data class ChatActivityRecord(
val id: String,
val scopeKey: String,
val sessionId: String,
val kind: ChatActivityKind,
val sourceId: String,
val title: String,
val phase: ChatActivityPhase,
val createdAt: Long,
val updatedAt: Long,
val children: List<ChatActivityChild> = emptyList(),
val taskCount: Int = 0,
val processId: String? = null,
val processStartedAt: String? = null,
val exitCode: Int? = null,
)
interface ChatActivityStore {
/** Recovery is not live evidence: RUNNING becomes UNKNOWN, including child phases. */
suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord>
suspend fun upsert(record: ChatActivityRecord)
suspend fun removeRecord(scopeKey: String, sessionId: String, id: String)
suspend fun removeSession(scopeKey: String, sessionId: String)
}
/**
* Bounded app-private history references in the shared settings DataStore.
* Retains 30 days, 128 records overall, 32 per exact owner/session, and 32 children
* per record. Titles are 160 characters; goals/summaries 512. Identity fields are
* rejected above 512 characters (scope 2048), never truncated into another owner.
* The complete encoded envelope is capped at 1 MiB, evicting oldest records first.
* Five minutes of future skew allows monotonic local revisions within one clock tick.
* All read/modify/write operations occur inside DataStore's serialized edit.
*/
class DataStoreChatActivityStore(
private val dataStore: DataStore<Preferences>,
private val now: () -> Long = System::currentTimeMillis,
) : ChatActivityStore {
constructor(context: Context) : this(context.applicationContext.relayDataStore)
override suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord> {
val raw = try {
dataStore.data.first()[CHAT_ACTIVITY_KEY]
} catch (_: IOException) {
return emptyList()
}
return boundChatActivities(decodeChatActivities(raw), now())
.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
.map(ChatActivityRecord::recovered)
}
override suspend fun upsert(record: ChatActivityRecord) {
dataStore.edit { preferences ->
val records = mergeChatActivity(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), record, now())
preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(records)
}
}
override suspend fun removeSession(scopeKey: String, sessionId: String) {
dataStore.edit { preferences ->
val remaining = boundChatActivities(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId }
if (remaining.isEmpty()) preferences.remove(CHAT_ACTIVITY_KEY)
else preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(remaining)
}
}
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) {
dataStore.edit { preferences ->
val remaining = boundChatActivities(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId && it.id == id }
if (remaining.isEmpty()) preferences.remove(CHAT_ACTIVITY_KEY)
else preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(remaining)
}
}
}
/** Test/ephemeral implementation with the same bounds and recovery semantics. */
class InMemoryChatActivityStore(
private val now: () -> Long = System::currentTimeMillis,
) : ChatActivityStore {
private val mutex = Mutex()
private var records = emptyList<ChatActivityRecord>()
override suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord> = mutex.withLock {
records = boundChatActivities(records, now())
records.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
.map(ChatActivityRecord::recovered)
}
override suspend fun upsert(record: ChatActivityRecord) = mutex.withLock {
records = mergeChatActivity(records, record, now())
}
override suspend fun removeSession(scopeKey: String, sessionId: String) = mutex.withLock {
records = boundChatActivities(records, now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId }
}
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) = mutex.withLock {
records = boundChatActivities(records, now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId && it.id == id }
}
}
internal const val CHAT_ACTIVITY_MAX_AGE_MS = 30L * 24L * 60L * 60L * 1_000L
private const val MAX_RECORDS = 128
private const val MAX_SESSION_RECORDS = 32
private const val MAX_PAYLOAD_BYTES = 1_048_576
private val CHAT_ACTIVITY_KEY = stringPreferencesKey("chat_activity_records_v1")
private val activityJson = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Serializable
private data class ChatActivityEnvelope(val version: Int = 1, val records: List<ChatActivityRecord>)
private fun encodeChatActivities(records: List<ChatActivityRecord>): String =
activityJson.encodeToString(ChatActivityEnvelope(records = records))
internal fun decodeChatActivities(raw: String?): List<ChatActivityRecord> {
if (raw == null || raw.length > MAX_PAYLOAD_BYTES || raw.toByteArray().size > MAX_PAYLOAD_BYTES) {
return emptyList()
}
val envelope = runCatching { activityJson.parseToJsonElement(raw) as? JsonObject }.getOrNull()
?: return emptyList()
val version = runCatching { envelope["version"]?.jsonPrimitive?.intOrNull }.getOrNull()
if (version != 1) return emptyList()
val rows = envelope["records"] as? JsonArray ?: return emptyList()
// One corrupt or newer row must not hide independently valid records.
return rows.mapNotNull { row ->
runCatching { activityJson.decodeFromJsonElement(ChatActivityRecord.serializer(), row) }.getOrNull()
}
}
private fun ChatActivityRecord.identity() = Triple(scopeKey, sessionId, id)
private fun mergeChatActivity(
existing: List<ChatActivityRecord>,
record: ChatActivityRecord,
now: Long,
): List<ChatActivityRecord> {
// Sorting first also rejects a late write for an older generation of the same record.
return boundChatActivities(listOf(record) + existing, now)
}
internal fun boundChatActivities(records: List<ChatActivityRecord>, now: Long): List<ChatActivityRecord> {
val counts = mutableMapOf<Pair<String, String>, Int>()
val bounded = records.mapNotNull { it.bounded(now) }
.sortedByDescending(ChatActivityRecord::updatedAt)
.distinctBy { it.identity() }
.filter {
val owner = it.scopeKey to it.sessionId
val count = counts.getOrDefault(owner, 0)
counts[owner] = count + 1
count < MAX_SESSION_RECORDS
}.take(MAX_RECORDS).toMutableList()
while (bounded.isNotEmpty() && encodeChatActivities(bounded).toByteArray().size > MAX_PAYLOAD_BYTES) {
bounded.removeAt(bounded.lastIndex)
}
return bounded
}
private fun validIdentity(value: String, max: Int = 512) = value.isNotBlank() && value.length <= max
private fun ChatActivityRecord.bounded(now: Long): ChatActivityRecord? {
if (!validIdentity(scopeKey, 2048) || !validIdentity(sessionId) || !validIdentity(id) ||
!validIdentity(sourceId) || (processId != null && !validIdentity(processId)) ||
(processStartedAt != null && !validIdentity(processStartedAt)) ||
createdAt < 0 || updatedAt < createdAt || updatedAt > now + 300_000L ||
now - updatedAt > CHAT_ACTIVITY_MAX_AGE_MS
) return null
return copy(
title = title.take(160),
taskCount = taskCount.coerceIn(0, 10_000),
children = children.asSequence().filter {
validIdentity(it.id) && (it.childSessionId == null || validIdentity(it.childSessionId))
}.distinctBy(ChatActivityChild::id).take(32)
.map { it.copy(goal = it.goal.take(512), summary = it.summary?.take(512)) }.toList(),
)
}
private fun ChatActivityRecord.recovered() = copy(
phase = if (phase == ChatActivityPhase.RUNNING) ChatActivityPhase.UNKNOWN else phase,
children = children.map {
if (it.phase == ChatActivityPhase.RUNNING) it.copy(phase = ChatActivityPhase.UNKNOWN) else it
},
)
@@ -170,6 +170,12 @@ data class ChatMessage(
* but server history never owns these presentation blocks.
*/
val moaReferences: List<MoaReference> = emptyList(),
/** Exact upstream identity on a persisted activity-completion marker. */
val activitySourceId: String? = null,
val activityTaskCount: Int? = null,
val activityFailedCount: Int? = null,
/** Read-only UI projection; never sent as model history or voice input. */
val activityRecord: ChatActivityRecord? = null,
)
data class MessageReaction(
@@ -1451,6 +1451,12 @@ class ChatHandler {
val loaded = renderedItems.mapNotNull { item ->
val displayKind = item.displayKind?.trim()?.lowercase()
if (displayKind == "hidden") return@mapNotNull null
val activitySourceId = if (displayKind == "async_delegation_complete") {
item.displayMetadata.stringField("delegation_id")?.let { "delegation:$it" }
?: "unavailable:${item.id}"
} else null
val activityTaskCount = if (activitySourceId != null) item.displayMetadata.intField("task_count") else null
val activityFailedCount = if (activitySourceId != null) item.displayMetadata.intField("failed_count") else null
val role = when {
displayKind == "model_switch" ||
displayKind == "async_delegation_complete" ||
@@ -1611,6 +1617,9 @@ class ChatHandler {
// this as the same visible row across the post-turn reload.
prior.copy(
id = messageId,
activitySourceId = activitySourceId,
activityTaskCount = activityTaskCount,
activityFailedCount = activityFailedCount,
rowId = item.resolvedRowId,
reactions = item.reactions,
role = role,
@@ -1643,6 +1652,9 @@ class ChatHandler {
// nothing local to carry).
ChatMessage(
id = messageId,
activitySourceId = activitySourceId,
activityTaskCount = activityTaskCount,
activityFailedCount = activityFailedCount,
rowId = item.resolvedRowId,
reactions = item.reactions,
role = role,
@@ -431,6 +431,9 @@ class GatewayChatClient(
private val lazyLiveSessions = ConcurrentHashMap.newKeySet<String>()
private val readyLiveSessions = ConcurrentHashMap.newKeySet<String>()
private val sessionReadyWaiters = ConcurrentHashMap<String, CompletableDeferred<Unit>>()
private val sessionReadyFailures = ConcurrentHashMap<String, String>()
private val _preparingSessionId = MutableStateFlow<String?>(null)
val preparingSessionId: StateFlow<String?> = _preparingSessionId.asStateFlow()
/** Monotonic client-local fence for lazy child watch open/close races. */
private val childWatchGeneration = AtomicLong(0)
@@ -633,6 +636,13 @@ class GatewayChatClient(
@Volatile
private var processEventListener: ((GatewayProcessEvent) -> Unit)? = null
@Volatile
private var subagentEventListener: ((String, String?, GatewaySubagentEvent) -> Unit)? = null
fun setSubagentEventListener(listener: ((String, String?, GatewaySubagentEvent) -> Unit)?) {
subagentEventListener = listener
}
/** Process-wide durable-session invalidation/liveness edge. */
@Volatile
private var sessionDirectoryInvalidationListener: (() -> Unit)? = null
@@ -1563,7 +1573,10 @@ class GatewayChatClient(
.orEmpty()
.also { recoveryEvents = null }
}
buffered.forEach { event -> boundTurn?.onEvent(event.type, event.payload) }
buffered.forEach { event ->
dispatchSubagentEvent(event.type, event.payload, event.sessionId)
boundTurn.onEvent(event.type, event.payload)
}
queued?.let { queuedTurn ->
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
boundTurn.installQueuedSuccessor(registration)
@@ -2950,6 +2963,7 @@ class GatewayChatClient(
unmatchedTurnCompleteListener = null
backgroundInteractionListener = null
processEventListener = null
subagentEventListener = null
sessionDirectoryInvalidationListener = null
closeSocket("client shutdown")
backgroundCloseJob?.cancel()
@@ -3485,6 +3499,7 @@ class GatewayChatClient(
liveId: String,
sessionResult: JsonObject? = null,
) {
sessionReadyFailures[liveId]?.let { throw GatewayPreflightException(it) }
val lazy = (sessionResult?.get("info") as? JsonObject)?.booleanField("lazy") == true
if (lazy) lazyLiveSessions += liveId
if (liveId !in lazyLiveSessions) return
@@ -3494,6 +3509,9 @@ class GatewayChatClient(
}
val waiter = sessionReadyWaiters.computeIfAbsent(liveId) { CompletableDeferred() }
if (readyLiveSessions.remove(liveId)) waiter.complete(Unit)
sessionReadyFailures[liveId]?.let { waiter.completeExceptionally(GatewayRpcException(it)) }
val preparingStoredId = storedSessionId
_preparingSessionId.value = preparingStoredId
try {
withTimeout(sessionReadyTimeoutMs) { waiter.await() }
lazyLiveSessions.remove(liveId)
@@ -3502,11 +3520,14 @@ class GatewayChatClient(
error.message ?: "Hermes session initialization timed out",
)
} finally {
if (_preparingSessionId.value == preparingStoredId) _preparingSessionId.value = null
sessionReadyWaiters.remove(liveId, waiter)
}
}
private fun failSessionReadyWaiters(message: String) {
_preparingSessionId.value = null
sessionReadyFailures.clear()
sessionReadyWaiters.values.forEach {
it.completeExceptionally(GatewayRpcException(message))
}
@@ -3696,6 +3717,7 @@ class GatewayChatClient(
if (type == "session.info" && !eventSessionId.isNullOrBlank() &&
payload?.booleanField("lazy") != true
) {
sessionReadyFailures.remove(eventSessionId)
readyLiveSessions += eventSessionId
sessionReadyWaiters.remove(eventSessionId)?.complete(Unit)
}
@@ -3705,10 +3727,16 @@ class GatewayChatClient(
// immediately so the optimistic prompt remains retryable/Not sent
// instead of waiting for the five-minute readiness timeout.
if (type == "error" && !eventSessionId.isNullOrBlank() &&
(eventSessionId in lazyLiveSessions || sessionReadyWaiters.containsKey(eventSessionId))
(eventSessionId in lazyLiveSessions || sessionReadyWaiters.containsKey(eventSessionId) ||
payload?.stringField("message")?.startsWith("agent init failed:") == true)
) {
val message = payload?.stringField("message")
?: "Hermes session initialization failed"
// A fast deferred build can fail before the lazy RPC acknowledgement.
// Retain the exact runtime failure so the subsequent readiness wait
// cannot lose that edge and hang until its deadline.
if (sessionReadyFailures.size >= 64) sessionReadyFailures.keys.firstOrNull()?.let(sessionReadyFailures::remove)
sessionReadyFailures[eventSessionId] = message.take(2_000)
lazyLiveSessions.remove(eventSessionId)
readyLiveSessions.remove(eventSessionId)
sessionReadyWaiters.remove(eventSessionId)
@@ -3915,6 +3943,7 @@ class GatewayChatClient(
dispatchProcessEvent(type, payload, eventSessionId)
if (consumeCancelledTurnEvent(type, eventSessionId)) return
if (consumeSettledTurnTerminal(type, eventSessionId)) return
dispatchSubagentEvent(type, payload, eventSessionId)
var turn = activeTurn
if (turn == null && type == "message.start") {
// Unsolicited turns are accepted only with an explicit exact live-
@@ -3976,10 +4005,25 @@ class GatewayChatClient(
}
/**
* Deliver session-scoped process events before the active-turn gate. The
* gateway socket is process-wide, so an exact non-blank live id match is
* required; missing/foreign ids must never leak another window's process.
* Detached child updates belong to the session even between parent turns.
* Recheck socket, session, profile and listener ownership on UI dispatch.
*/
private fun dispatchSubagentEvent(type: String, payload: JsonObject?, eventSessionId: String?) {
val liveId = liveSessionId ?: return
val storedId = storedSessionId ?: return
if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return
val event = GatewayEventMapper.parseSubagentEvent(type, payload) ?: return
val profile = liveSessionProfile
val socket = webSocket
val listener = subagentEventListener ?: return
callbackDispatcher {
if (webSocket === socket && liveSessionId == liveId && storedSessionId == storedId &&
liveSessionProfile == profile && subagentEventListener === listener
) listener(storedId, profile, event)
}
}
/** Process updates likewise require an exact live-session match before turn admission. */
private fun dispatchProcessEvent(type: String, payload: JsonObject?, eventSessionId: String?) {
val liveId = liveSessionId ?: return
if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return
@@ -292,34 +292,7 @@ class GatewayEventMapper(
"subagent.progress", "subagent.complete",
-> {
clearActivityStatuses()
val phase = when (type) {
"subagent.spawn_requested" -> GatewaySubagentEvent.Phase.SPAWN_REQUESTED
"subagent.start" -> GatewaySubagentEvent.Phase.START
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
else -> GatewaySubagentEvent.Phase.COMPLETE
}
callbacks.onSubagentEvent(
GatewaySubagentEvent(
phase = phase,
taskIndex = payload.int("task_index") ?: 0,
taskCount = payload.int("task_count") ?: 1,
goal = payload.string("goal") ?: "",
status = payload.string("status"),
summary = payload.string("summary"),
toolName = payload.string("tool_name"),
// subagent.tool sets tool_preview AND mirrors it into
// text; thinking/progress carry text only.
preview = payload.string("tool_preview") ?: payload.string("text"),
durationSeconds = payload.double("duration_seconds"),
subagentId = payload.string("subagent_id"),
childSessionId = payload.string("child_session_id"),
parentId = payload.string("parent_id"),
depth = payload.int("depth"),
model = payload.string("model"),
),
)
parseSubagentEvent(type, payload)?.let(callbacks.onSubagentEvent)
}
"tool.output_risk" -> {
@@ -467,6 +440,42 @@ class GatewayEventMapper(
}
companion object {
/** Shared by turn transcripts and the session-owned activity stream. */
fun parseSubagentEvent(type: String, payload: JsonObject?): GatewaySubagentEvent? {
if (type !in setOf(
"subagent.spawn_requested", "subagent.start", "subagent.thinking",
"subagent.tool", "subagent.progress", "subagent.complete",
)) return null
val phase = when (type) {
"subagent.spawn_requested" -> GatewaySubagentEvent.Phase.SPAWN_REQUESTED
"subagent.start" -> GatewaySubagentEvent.Phase.START
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
else -> GatewaySubagentEvent.Phase.COMPLETE
}
return GatewaySubagentEvent(
phase = phase,
taskIndex = payload.int("task_index") ?: 0,
taskCount = payload.int("task_count") ?: 1,
goal = payload.string("goal") ?: "",
status = payload.string("status"),
summary = payload.string("summary"),
toolName = payload.string("tool_name"),
// subagent.tool sets tool_preview AND mirrors it into
// text; thinking/progress carry text only.
preview = payload.string("tool_preview") ?: payload.string("text"),
durationSeconds = payload.double("duration_seconds"),
subagentId = payload.string("subagent_id"),
childSessionId = payload.string("child_session_id"),
parentId = payload.string("parent_id"),
depth = payload.int("depth"),
model = payload.string("model"),
delegationId = payload.string("delegation_id"),
)
}
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
const val COMPACTION_STATUS_KIND = "compacting"
const val ERROR_STATUS_KIND = "error"
@@ -293,6 +293,8 @@ data class GatewaySubagentEvent(
val depth: Int? = null,
/** Effective child model, when the emitter exposes it. */
val model: String? = null,
/** Exact delegation group id shared with persisted async completion metadata. */
val delegationId: String? = null,
) {
enum class Phase { SPAWN_REQUESTED, START, THINKING, TOOL, PROGRESS, COMPLETE }
}
@@ -36,7 +36,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
@@ -230,11 +230,7 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
// Short-lived snackbar by default; retryable errors get Long so users have
// time to tap the action before it auto-dismisses.
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
return showSnackbar(
message = err.body,
actionLabel = err.actionLabel,
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
)
return showSnackbar(com.hermesandroid.relay.ui.components.HumanErrorVisuals(err))
}
/** Startup chrome should wait for either standard chat surface, not Relay. */
@@ -1952,6 +1948,8 @@ fun RelayApp() {
// child TopAppBar doesn't double-pad when this banner owns the top edge.
val activeMessageCount by UiMessageBus.activeCount.collectAsState()
val showMessageBanner = activeMessageCount > 0
val modalMessageHostActive by UiMessageBus.modalHostActive.collectAsState()
val showActionMessage = snackbarHostState.currentSnackbarData != null && !modalMessageHostActive
// Update availability (unified): googlePlay = Play In-App Update FLEXIBLE,
// sideload = GitHub releases. The handle filters dismissed versions +
// throttles checks internally, exposing a surfaceable status for the
@@ -2077,6 +2075,14 @@ fun RelayApp() {
includeStatusBarPadding =
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure,
)
// Action feedback owns layout space at the top, never the composer's
// touch area. Modal windows keep their own scoped host.
ThemedMessageHost(
snackbarHostState,
modifier = if (showActionMessage && !showMessageBanner &&
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure
) Modifier.windowInsetsPadding(WindowInsets.statusBars) else Modifier,
)
// The update banner AND the connection-status indicator now render as
// floating overlay TOASTS in the Box below (see the top-overlay Column
@@ -2117,7 +2123,7 @@ fun RelayApp() {
// participates in the top-inset accounting.
if (showUnattendedBanner || showDemoBanner || showHostResourcePressure ||
connectionChipVisible ||
showMessageBanner
showMessageBanner || showActionMessage
) {
Modifier.consumeWindowInsets(WindowInsets.statusBars)
} else {
@@ -2125,7 +2131,6 @@ fun RelayApp() {
}
),
contentWindowInsets = WindowInsets(0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (
!suppressGlobalChrome &&
@@ -2212,7 +2217,10 @@ fun RelayApp() {
}
}
) { innerPadding ->
CompositionLocalProvider(LocalSnackbarHost provides snackbarHostState) {
CompositionLocalProvider(
LocalSnackbarHost provides snackbarHostState,
com.hermesandroid.relay.ui.components.LocalMessageActionHost provides snackbarHostState,
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicLong
/** Visual tone of a transient banner message. */
enum class UiMessageSeverity { Info, Success, Status, Warning }
enum class UiMessageSeverity { Info, Success, Status, Warning, Error }
data class UiMessage(
val id: Long,
@@ -23,6 +23,7 @@ data class UiMessage(
sealed interface UiMessageEvent {
data class Show(val message: UiMessage) : UiMessageEvent
data class Clear(val key: String) : UiMessageEvent
data object ClearAll : UiMessageEvent
}
internal fun reduceUiMessages(
@@ -30,6 +31,7 @@ internal fun reduceUiMessages(
event: UiMessageEvent,
maxRetained: Int,
): List<UiMessage> = when (event) {
UiMessageEvent.ClearAll -> emptyList()
is UiMessageEvent.Clear -> current.filterNot { it.key == event.key }
is UiMessageEvent.Show -> {
val incoming = event.message
@@ -41,15 +43,14 @@ internal fun reduceUiMessages(
}
/**
* App-wide bus for transient, non-error status/confirmation messages that
* App-wide bus for transient status, confirmation, and error messages that
* surface in the top [com.hermesandroid.relay.ui.components.MessageBannerHost]
* — a thin banner that takes its own space (content slides down, no overlay),
* shows the newest line collapsed, expands to a few recent lines, auto-dismisses
* and coalesces duplicates.
*
* App-owned errors and persistent/actionable messages keep going to the
* snackbar so they demand acknowledgement. Upstream keyed AgentNotices may use
* the warning tone here because their own sticky/clear lifecycle owns them.
* Messages requiring Retry or Undo use ThemedMessageHost and retain their
* action-result contract. Upstream keyed AgentNotices own their sticky/clear lifecycle.
* Migrate frequent
* `snackbarHostState.showSnackbar("…")` confirmations/status to [info] /
* [success] / [status] here.
@@ -62,6 +63,23 @@ object UiMessageBus {
const val STATUS_TTL_MS = 6_000L
private val counter = AtomicLong(0L)
private val hosts = linkedMapOf<Long, Boolean>()
private val _activeHost = MutableStateFlow<Long?>(null)
internal val activeHost = _activeHost.asStateFlow()
private val _modalHostActive = MutableStateFlow(false)
internal val modalHostActive = _modalHostActive.asStateFlow()
internal fun registerHost(primary: Boolean): Long = synchronized(hosts) {
counter.incrementAndGet().also {
hosts[it] = primary
_activeHost.value = hosts.keys.lastOrNull()
_modalHostActive.value = hosts.values.any { primaryHost -> !primaryHost }
}
}
internal fun unregisterHost(id: Long) { synchronized(hosts) {
hosts.remove(id)
_activeHost.value = hosts.keys.lastOrNull()
_modalHostActive.value = hosts.values.any { !it }
} }
private val _events = MutableSharedFlow<UiMessageEvent>(extraBufferCapacity = 24)
val events: SharedFlow<UiMessageEvent> = _events.asSharedFlow()
@@ -97,6 +115,11 @@ object UiMessageBus {
key.trim().takeIf(String::isNotEmpty)?.let { _events.tryEmit(UiMessageEvent.Clear(it)) }
}
fun clearAll() { _events.tryEmit(UiMessageEvent.ClearAll) }
fun warning(text: String, ttlMillis: Long = STATUS_TTL_MS) = post(text, UiMessageSeverity.Warning, ttlMillis)
fun error(text: String, ttlMillis: Long = 10_000L) = post(text, UiMessageSeverity.Error, ttlMillis)
/** Neutral confirmation/info (e.g. "Pairing code copied"). */
fun info(text: String, ttlMillis: Long = DEFAULT_TTL_MS) =
post(text, UiMessageSeverity.Info, ttlMillis)
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -209,7 +209,7 @@ fun ActiveCardRelayStatusSection(
val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState()
// Pre-resolve strings for Toast (non-composable context)
// Pre-resolve strings for action callbacks (non-composable context).
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
val relayStatusText = when (relayRowState.phase) {
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
@@ -250,11 +250,7 @@ fun ActiveCardRelayStatusSection(
onClick = {
if (relayUiState == RelayUiState.Stale) {
connectionViewModel.reconnectIfStale()
Toast.makeText(
context,
reconnectingRelayToast,
Toast.LENGTH_SHORT,
).show()
UiMessageBus.status(reconnectingRelayToast)
} else {
onOpenRelayInfo()
}
@@ -817,7 +813,7 @@ private fun ManualUrlSubsection(
}
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
// Pre-resolve strings for Toast (non-composable context)
// Pre-resolve strings for action callbacks (non-composable context).
val apiHermesVoiceReachableToast = stringResource(R.string.active_section_api_hermes_voice_reachable)
val apiRelayVoiceReachableToast = stringResource(R.string.active_section_api_relay_voice_reachable)
val apiReachableVoiceReviewToast = stringResource(R.string.active_section_api_reachable_voice_review)
@@ -926,21 +922,21 @@ private fun ManualUrlSubsection(
relayOverrideVisible = true
result.relayUrl?.let { relayUrlInput = it }
}
Toast.makeText(
context,
when {
result.apiReachable && result.voiceConfigReachable ->
if (result.voiceRoute == "standard") {
apiHermesVoiceReachableToast
} else {
apiRelayVoiceReachableToast
}
result.apiReachable ->
apiReachableVoiceReviewToast
else -> cannotReachApiToast
},
Toast.LENGTH_SHORT,
).show()
val feedback = when {
result.apiReachable && result.voiceConfigReachable ->
if (result.voiceRoute == "standard") {
apiHermesVoiceReachableToast
} else {
apiRelayVoiceReachableToast
}
result.apiReachable -> apiReachableVoiceReviewToast
else -> cannotReachApiToast
}
when {
result.apiReachable && result.voiceConfigReachable -> UiMessageBus.success(feedback)
result.apiReachable -> UiMessageBus.warning(feedback)
else -> UiMessageBus.error(feedback)
}
}
},
enabled = apiUrlInput.isNotBlank() && !isTestingApi && inputApiKeyError == null,
@@ -9,7 +9,7 @@ import android.media.audiofx.Visualizer
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.annotation.OptIn
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -312,110 +312,112 @@ fun AttachmentViewer(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
MessageOverlayScope {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current ||
attachment.renderMode != AttachmentRenderMode.IMAGE
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val blurMode = LocalMediaBlurMode.current
var revealed by remember(attachment.cachedUri, attachment.relayToken) {
mutableStateOf(initiallyRevealed)
}
val blurred = !revealed &&
attachment.renderMode == AttachmentRenderMode.IMAGE &&
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
// --- One shared Share / Save / Open-externally action set ----------
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
scope.launch {
busy = true
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
busy = false
viewerToast(context, context.getString(R.string.inbound_attach_share_failed))
return@launch
}
action(bytes)
busy = false
val blurMode = LocalMediaBlurMode.current
var revealed by remember(attachment.cachedUri, attachment.relayToken) {
mutableStateOf(initiallyRevealed)
}
}
val blurred = !revealed &&
attachment.renderMode == AttachmentRenderMode.IMAGE &&
shouldBlurImage(blurMode, attachment.sensitive)
val onShare = {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
}
val onSave = {
runWithBytes { bytes ->
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
MediaSaver.saveImage(context, bytes, attachment.fileName, attachment.contentType)
} else {
MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)
}
when (result) {
is MediaSaver.SaveResult.Saved ->
viewerToast(context, context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
// --- One shared Share / Save / Open-externally action set ----------
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
scope.launch {
busy = true
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
busy = false
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
return@launch
}
is MediaSaver.SaveResult.Failed ->
viewerToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
action(bytes)
busy = false
}
}
}
val onOpenExternal = {
val cached = attachment.cachedUri
if (!cached.isNullOrBlank()) {
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
} else {
val onShare = {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.open(context, uri, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f))
.testTag("attachment-viewer"),
) {
// Body fills; toolbar floats on top. PDF/TEXT add their own top
// inset so the first line clears the toolbar.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (attachment.renderMode) {
AttachmentRenderMode.IMAGE -> ImageBody(
attachment = attachment,
blurred = blurred,
onReveal = { revealed = true },
)
AttachmentRenderMode.VIDEO -> VideoBody(attachment)
AttachmentRenderMode.AUDIO -> AudioBody(attachment)
AttachmentRenderMode.PDF -> PdfBody(attachment)
AttachmentRenderMode.TEXT -> TextBody(attachment)
AttachmentRenderMode.GENERIC -> GenericBody(attachment, onOpenExternal)
val onSave = {
runWithBytes { bytes ->
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
MediaSaver.saveImage(context, bytes, attachment.fileName, attachment.contentType)
} else {
MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)
}
when (result) {
is MediaSaver.SaveResult.Saved ->
UiMessageBus.success(context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
UiMessageBus.error(context.getString(R.string.inbound_attach_save_failed, result.message))
}
}
}
val onOpenExternal = {
val cached = attachment.cachedUri
if (!cached.isNullOrBlank()) {
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
} else {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.open(context, uri, attachment.contentType)
}
}
}
MediaViewerToolbar(
title = title,
busy = busy,
actionsEnabled = !blurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f))
.testTag("attachment-viewer"),
) {
// Body fills; toolbar floats on top. PDF/TEXT add their own top
// inset so the first line clears the toolbar.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (attachment.renderMode) {
AttachmentRenderMode.IMAGE -> ImageBody(
attachment = attachment,
blurred = blurred,
onReveal = { revealed = true },
)
AttachmentRenderMode.VIDEO -> VideoBody(attachment)
AttachmentRenderMode.AUDIO -> AudioBody(attachment)
AttachmentRenderMode.PDF -> PdfBody(attachment)
AttachmentRenderMode.TEXT -> TextBody(attachment)
AttachmentRenderMode.GENERIC -> GenericBody(attachment, onOpenExternal)
}
}
MediaViewerToolbar(
title = title,
busy = busy,
actionsEnabled = !blurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
}
}
@@ -452,164 +454,165 @@ internal fun AttachmentGalleryViewer(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val revealed = remember { mutableStateMapOf<String, Boolean>() }
LaunchedEffect(initiallyRevealedKeys) {
initiallyRevealedKeys.forEach { revealed[it] = true }
}
val pagerState = rememberPagerState(
initialPage = initialIndex.coerceIn(attachments.indices),
pageCount = { attachments.size },
)
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
val attachment = attachments[currentIndex]
val currentKey = galleryAttachmentKey(attachment, currentIndex)
val blurMode = LocalMediaBlurMode.current
val currentBlurred = revealed[currentKey] != true &&
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
// Capture the currently visible attachment in each click lambda. A
// swipe while IO is running must not redirect Save/Share to a new page.
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
if (currentBlurred || busy) return
val target = attachment
scope.launch {
busy = true
try {
val bytes = attachmentBytes(context, target)
if (bytes == null) {
viewerToast(context, "Couldn't read this image")
return@launch
}
action(target, bytes)
} catch (error: Exception) {
viewerToast(
context,
error.message?.takeIf { it.isNotBlank() }
?: "Couldn't complete that image action",
)
} finally {
busy = false
}
MessageOverlayScope {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val revealed = remember { mutableStateMapOf<String, Boolean>() }
LaunchedEffect(initiallyRevealedKeys) {
initiallyRevealedKeys.forEach { revealed[it] = true }
}
}
val pagerState = rememberPagerState(
initialPage = initialIndex.coerceIn(attachments.indices),
pageCount = { attachments.size },
)
val onShare = {
runWithBytes { target, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
)
MediaSaver.share(context, uri, target.contentType)
}
}
val onSave = {
runWithBytes { target, bytes ->
when (val result = MediaSaver.saveImage(
context,
bytes,
target.fileName,
target.contentType,
)) {
is MediaSaver.SaveResult.Saved ->
viewerToast(context, "Saved to ${result.location}")
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
val attachment = attachments[currentIndex]
val currentKey = galleryAttachmentKey(attachment, currentIndex)
val blurMode = LocalMediaBlurMode.current
val currentBlurred = revealed[currentKey] != true &&
shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
// Capture the currently visible attachment in each click lambda. A
// swipe while IO is running must not redirect Save/Share to a new page.
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
if (currentBlurred || busy) return
val target = attachment
scope.launch {
busy = true
try {
val bytes = attachmentBytes(context, target)
if (bytes == null) {
UiMessageBus.error("Couldn't read this image")
return@launch
}
action(target, bytes)
} catch (error: Exception) {
UiMessageBus.error(
error.message?.takeIf { it.isNotBlank() }
?: "Couldn't complete that image action",
)
MediaSaver.share(context, uri, target.contentType)
} finally {
busy = false
}
is MediaSaver.SaveResult.Failed ->
viewerToast(context, "Save failed: ${result.message}")
}
}
}
val onOpenExternal: () -> Unit = openExternal@{
if (currentBlurred || busy) return@openExternal
val target = attachment
val cached = target.cachedUri
if (!cached.isNullOrBlank()) {
runCatching {
MediaSaver.open(context, Uri.parse(cached), target.contentType)
}.onFailure {
viewerToast(context, "Couldn't open this image")
}
} else {
runWithBytes { item, bytes ->
val onShare = {
runWithBytes { target, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
item.fileName,
item.contentType,
target.fileName,
target.contentType,
)
MediaSaver.open(context, uri, item.contentType)
MediaSaver.share(context, uri, target.contentType)
}
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f)),
) {
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 0,
pageSpacing = 12.dp,
modifier = Modifier
.fillMaxSize()
.testTag("attachment-gallery-pager"),
) { page ->
val pageAttachment = attachments[page]
val pageKey = galleryAttachmentKey(pageAttachment, page)
val blurred = revealed[pageKey] != true && shouldBlurImage(
blurMode,
pageAttachment.sensitive,
)
ImageBody(
attachment = pageAttachment,
blurred = blurred,
onReveal = { revealed[pageKey] = true },
)
val onSave = {
runWithBytes { target, bytes ->
when (val result = MediaSaver.saveImage(
context,
bytes,
target.fileName,
target.contentType,
)) {
is MediaSaver.SaveResult.Saved ->
UiMessageBus.success("Saved to ${result.location}")
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
)
MediaSaver.share(context, uri, target.contentType)
}
is MediaSaver.SaveResult.Failed ->
UiMessageBus.error("Save failed: ${result.message}")
}
}
}
val onOpenExternal: () -> Unit = openExternal@{
if (currentBlurred || busy) return@openExternal
val target = attachment
val cached = target.cachedUri
if (!cached.isNullOrBlank()) {
runCatching {
MediaSaver.open(context, Uri.parse(cached), target.contentType)
}.onFailure {
UiMessageBus.error("Couldn't open this image")
}
} else {
runWithBytes { item, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
item.fileName,
item.contentType,
)
MediaSaver.open(context, uri, item.contentType)
}
}
}
MediaViewerToolbar(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f)),
) {
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 0,
pageSpacing = 12.dp,
modifier = Modifier
.fillMaxSize()
.testTag("attachment-gallery-pager"),
) { page ->
val pageAttachment = attachments[page]
val pageKey = galleryAttachmentKey(pageAttachment, page)
val blurred = revealed[pageKey] != true && shouldBlurImage(
blurMode,
pageAttachment.sensitive,
)
ImageBody(
attachment = pageAttachment,
blurred = blurred,
onReveal = { revealed[pageKey] = true },
)
}
Text(
text = "${currentIndex + 1} / ${attachments.size}",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 12.dp, vertical = 6.dp),
)
MediaViewerToolbar(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Text(
text = "${currentIndex + 1} / ${attachments.size}",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
}
@@ -1191,10 +1194,6 @@ private fun rememberPlayableUri(attachment: Attachment): Uri? {
return uri
}
private fun viewerToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
private const val MAX_TEXT_BYTES = 2 * 1024 * 1024
// --- Non-composable IO helpers ---------------------------------------------
@@ -0,0 +1,118 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
/** Stable transcript entry into the same read-only preview used by active work. */
@Composable
internal fun ChatActivityReceipt(
record: ChatActivityRecord,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val subagents = record.kind == ChatActivityKind.SUBAGENTS
val title = stringResource(
if (subagents) R.string.chat_activity_receipt_subagents else R.string.chat_activity_receipt_process,
)
val action = stringResource(
if (subagents) R.string.chat_activity_receipt_view_activity else R.string.chat_activity_receipt_view_output,
)
val status = if (subagents && record.children.isNotEmpty()) {
val groups = record.children.groupingBy { it.phase }.eachCount().toMutableMap()
val missing = (record.taskCount - record.children.size).coerceAtLeast(0)
if (missing > 0) groups[ChatActivityPhase.UNKNOWN] = (groups[ChatActivityPhase.UNKNOWN] ?: 0) + missing
val labels = groups.map { (phase, count) ->
stringResource(R.string.chat_activity_receipt_count_phase, count, activityPhaseLabel(phase))
}
labels.joinToString(" · ")
} else if (subagents && record.taskCount > 0) {
stringResource(R.string.chat_activity_receipt_count_phase, record.taskCount, activityPhaseLabel(record.phase))
} else {
activityPhaseLabel(record.phase)
}
Surface(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.semantics(mergeDescendants = true) { stateDescription = status }
.clickable(role = Role.Button, onClickLabel = action, onClick = onClick),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (subagents) Icons.Filled.AccountTree else Icons.Filled.Terminal,
contentDescription = null,
tint = if (record.phase == ChatActivityPhase.FAILED) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "$title · $status",
style = MaterialTheme.typography.labelMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = action,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
@Composable
private fun activityPhaseLabel(phase: ChatActivityPhase): String = stringResource(
when (phase) {
ChatActivityPhase.RUNNING -> R.string.bg_processes_running
ChatActivityPhase.COMPLETE -> R.string.agent_activity_status_completed
ChatActivityPhase.FAILED -> R.string.agent_activity_status_failed
ChatActivityPhase.CANCELLED -> R.string.task_status_cancelled
ChatActivityPhase.UNKNOWN -> R.string.agent_activity_status_unavailable
},
)
@@ -0,0 +1,197 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.activity.compose.BackHandler
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.viewmodel.ConnectionStepState
@Composable
internal fun Modifier.chatDebugHeaderGesture(enabled: Boolean, onClick: () -> Unit, onHold: () -> Unit): Modifier =
combinedClickable(enabled = enabled, onClick = onClick, onLongClick = onHold,
onLongClickLabel = stringResource(R.string.chat_debug_open))
@Composable
internal fun BoxScope.ChatDebugOverlay(
visible: Boolean,
headerHeight: Dp,
onClose: () -> Unit,
content: @Composable () -> Unit,
) {
BackHandler(enabled = visible, onBack = onClose)
if (visible) {
Box(Modifier.fillMaxSize().padding(top = headerHeight)
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.28f))
.clickable(onClick = onClose))
}
AnimatedVisibility(
visible = visible,
enter = slideInVertically { -it } + fadeIn(),
exit = slideOutVertically { -it } + fadeOut(),
modifier = Modifier.align(Alignment.TopCenter).padding(top = headerHeight).fillMaxWidth().clipToBounds(),
) { content() }
}
/** Read-only snapshot of the visible conversation; opening it sends no RPCs. */
@Composable
internal fun ChatDebugDrawer(
profile: String,
model: String,
sessionId: String?,
gateway: Boolean,
signedIn: Boolean,
signInRequired: Boolean,
socketState: GatewayConnectionState,
preparing: Boolean,
streaming: Boolean,
loadingHistory: Boolean,
directoryUnavailable: Boolean,
failure: String?,
onClose: () -> Unit,
onConnections: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = stringResource(R.string.chat_debug_title)
val safeFailure = DiagnosticsLog.redactReportText(failure)?.take(600)
val ready = socketState == GatewayConnectionState.Ready
val steps = buildList {
if (gateway) {
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_sign_in),
stringResource(when {
signInRequired -> R.string.chat_debug_sign_in_needed
signedIn -> R.string.chat_debug_authenticated
else -> R.string.chat_debug_auth_unknown
}),
when {
signInRequired -> ConnectionStepState.Failed
signedIn -> ConnectionStepState.Done
else -> ConnectionStepState.Pending
},
))
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_gateway),
stringResource(when (socketState) {
GatewayConnectionState.Ready -> R.string.chat_debug_socket_ready
GatewayConnectionState.MintingTicket -> R.string.chat_debug_ticket
GatewayConnectionState.Connecting -> R.string.chat_debug_socket_connecting
GatewayConnectionState.AwaitingReady -> R.string.chat_debug_socket_waiting
GatewayConnectionState.Idle -> R.string.chat_debug_socket_idle
}),
when {
ready -> ConnectionStepState.Done
signInRequired -> ConnectionStepState.Failed
socketState != GatewayConnectionState.Idle -> ConnectionStepState.Active
else -> ConnectionStepState.Pending
},
))
}
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_session),
stringResource(when {
preparing -> R.string.chat_debug_preparing_detail
loadingHistory -> R.string.chat_debug_history_loading
directoryUnavailable -> R.string.chat_debug_history_failed
sessionId != null -> R.string.chat_debug_session_selected
else -> R.string.chat_debug_session_new
}),
when {
preparing || loadingHistory -> ConnectionStepState.Active
directoryUnavailable -> ConnectionStepState.Failed
sessionId != null -> ConnectionStepState.Done
else -> ConnectionStepState.Pending
},
))
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_response),
stringResource(when {
safeFailure != null -> R.string.chat_debug_response_failed
preparing -> R.string.chat_debug_response_waiting
streaming -> R.string.chat_debug_response_active
else -> R.string.chat_debug_response_idle
}),
when {
safeFailure != null -> ConnectionStepState.Failed
preparing -> ConnectionStepState.Pending
streaming -> ConnectionStepState.Active
else -> ConnectionStepState.Pending
},
))
}
Surface(
modifier = modifier.fillMaxWidth().semantics { paneTitle = title },
shape = RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp,
) {
Column(Modifier.heightIn(max = 560.dp).verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleLarge)
Text(listOf(if (gateway) "Gateway" else "Direct API", profile, model)
.filter(String::isNotBlank).joinToString(" · "), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, stringResource(R.string.chat_debug_close))
}
}
ConnectionSetupTimeline(steps)
if (safeFailure != null) {
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = RoundedCornerShape(12.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(safeFailure, style = MaterialTheme.typography.bodySmall)
if (safeFailure.contains("agent init failed", ignoreCase = true)) {
Text(stringResource(R.string.chat_debug_init_recovery), style = MaterialTheme.typography.bodySmall)
}
}
}
}
HorizontalDivider()
Text(stringResource(R.string.chat_debug_session_id, sessionId ?: "—"),
style = MaterialTheme.typography.labelSmall, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton(onClick = onConnections) { Text(stringResource(R.string.chat_debug_connections)) }
}
}
}
@@ -2,7 +2,7 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -107,143 +107,141 @@ fun ChatImageViewer(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
MessageOverlayScope {
val context = LocalContext.current
val exportAllowed = LocalImageExportAllowed.current
AllowDeviceRotation()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
val blurMode = LocalMediaBlurMode.current
var revealed by remember(source) { mutableStateOf(initiallyRevealed) }
val blurred = !revealed && shouldBlurImage(blurMode, sensitive)
val blurMode = LocalMediaBlurMode.current
var revealed by remember(source) { mutableStateOf(initiallyRevealed) }
val blurred = !revealed && shouldBlurImage(blurMode, sensitive)
val gestureModifier = Modifier.fillMaxSize().zoomable()
val gestureModifier = Modifier.fillMaxSize().zoomable()
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.94f)),
contentAlignment = Alignment.Center,
) {
BlurredMedia(
blurred = blurred,
onReveal = { revealed = true },
modifier = Modifier.fillMaxSize(),
) {
when (source) {
is ChatImageViewerSource.Coil -> AsyncImage(
model = source.model,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
is ChatImageViewerSource.Bitmap -> Image(
bitmap = source.bitmap,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
}
}
if (busy) {
CircularProgressIndicator(color = Color.White)
}
// Control bar — top-right, inset past the status bar / notch.
Row(
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.94f)),
contentAlignment = Alignment.Center,
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
val cdClose = stringResource(R.string.cd_close_viewer)
val errorMsg = context.getString(R.string.image_viewer_error)
if (exportAllowed) {
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = try {
source.bytesProvider()
} catch (_: Exception) {
null
}
busy = false
if (bytes == null) {
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = try {
source.bytesProvider()
} catch (_: Exception) {
null
}
if (bytes == null) {
busy = false
toast(context, errorMsg)
return@launch
}
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
toast(context, savedFmt.format(result.location))
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
toast(context, failedFmt.format(result.message))
}
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = cdSave)
BlurredMedia(
blurred = blurred,
onReveal = { revealed = true },
modifier = Modifier.fillMaxSize(),
) {
when (source) {
is ChatImageViewerSource.Coil -> AsyncImage(
model = source.model,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
is ChatImageViewerSource.Bitmap -> Image(
bitmap = source.bitmap,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
}
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = cdClose)
if (busy) {
CircularProgressIndicator(color = Color.White)
}
// Control bar — top-right, inset past the status bar / notch.
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
val cdClose = stringResource(R.string.cd_close_viewer)
val errorMsg = context.getString(R.string.image_viewer_error)
if (exportAllowed) {
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = try {
source.bytesProvider()
} catch (_: Exception) {
null
}
busy = false
if (bytes == null) {
UiMessageBus.error(errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = try {
source.bytesProvider()
} catch (_: Exception) {
null
}
if (bytes == null) {
busy = false
UiMessageBus.error(errorMsg)
return@launch
}
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
is MediaSaver.SaveResult.Saved -> {
busy = false
UiMessageBus.success(savedFmt.format(result.location))
}
MediaSaver.SaveResult.UseShareInstead -> {
busy = false
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
UiMessageBus.error(failedFmt.format(result.message))
}
}
}
},
colors = tint,
) {
Icon(Icons.Filled.Download, contentDescription = cdSave)
}
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = cdClose)
}
}
}
}
}
}
private fun toast(context: android.content.Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.padding
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.upstream.GatewayAvailability
@@ -140,18 +139,13 @@ fun ChatTransportStatusBadge(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
val context = LocalContext.current
val textColor = status.textColor()
val background = status.backgroundColor()
Surface(
modifier = modifier.combinedClickable(
onClick = { onClick?.invoke() },
onLongClick = {
Toast.makeText(
context,
"${status.reason}: ${status.detail}",
Toast.LENGTH_LONG,
).show()
UiMessageBus.info("${status.reason}: ${status.detail}")
},
),
shape = RoundedCornerShape(999.dp),
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -20,10 +22,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.viewmodel.ConnectionStepState
data class ConnectionSetupTimelineStep(
val title: String,
val detail: String,
val state: ConnectionStepState = ConnectionStepState.Done,
)
/** Compact completed-state timeline shared by connection and auth flows. */
@@ -43,13 +47,21 @@ fun ConnectionSetupTimeline(
Box(
modifier = Modifier
.size(28.dp)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
.background(when (step.state) {
ConnectionStepState.Failed -> MaterialTheme.colorScheme.errorContainer
ConnectionStepState.Pending -> MaterialTheme.colorScheme.surfaceVariant
else -> MaterialTheme.colorScheme.primaryContainer
}, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Check,
if (step.state == ConnectionStepState.Active) {
CircularProgressIndicator(Modifier.size(17.dp), strokeWidth = 2.dp)
} else if (step.state == ConnectionStepState.Pending) {
Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else Icon(
imageVector = if (step.state == ConnectionStepState.Failed) Icons.Filled.Close else Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
tint = if (step.state == ConnectionStepState.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(17.dp),
)
}
@@ -6,7 +6,7 @@ import android.Manifest
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.annotation.StringRes
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -343,11 +343,7 @@ fun ConnectionWizard(
// user at the manual pairing paths (URL entry / 6-char code)
// instead of leaving them on a vanishing toast with no scanner.
step = WizardStep.Nearby
Toast.makeText(
context,
context.getString(R.string.cw_camera_denied),
Toast.LENGTH_LONG
).show()
UiMessageBus.warning(context.getString(R.string.cw_camera_denied))
}
}
@@ -1,7 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -104,120 +103,119 @@ private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit)
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.WarningAmber,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
MessageOverlayScope {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.WarningAmber,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.crash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.crash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
text = stringResource(R.string.crash_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.crash_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.crash_privacy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = stringResource(R.string.crash_privacy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
if (showDetails) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
if (showDetails) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
}
}
Spacer(Modifier.height(18.dp))
// FlowRow so the actions wrap instead of clipping on narrow /
// foldable cover screens now that a fourth (Share) action exists.
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
if (!showDetails) {
Button(onClick = { showDetails = true }) {
Text(stringResource(R.string.crash_review))
}
} else {
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, copiedMessage)
},
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
// until they choose to send it — same privacy posture as Copy.
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
reportSubject,
reportText,
chooserTitle = shareTitle,
)
if (!shared) {
Spacer(Modifier.height(18.dp))
// FlowRow so the actions wrap instead of clipping on narrow /
// foldable cover screens now that a fourth (Share) action exists.
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
if (!showDetails) {
Button(onClick = { showDetails = true }) {
Text(stringResource(R.string.crash_review))
}
} else {
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, noShareMessage)
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
IssueReport.copyToClipboard(context, reportText)
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
toast(context, if (opened) openedMessage else noBrowserMessage)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
UiMessageBus.success(copiedMessage)
},
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
// until they choose to send it — same privacy posture as Copy.
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
reportSubject,
reportText,
chooserTitle = shareTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, reportText)
UiMessageBus.warning(noShareMessage)
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
IssueReport.copyToClipboard(context, reportText)
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
if (opened) UiMessageBus.success(openedMessage)
else UiMessageBus.warning(noBrowserMessage)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
}
}
}
}
}
}
}
private fun toast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
@@ -1,7 +1,7 @@
package com.hermesandroid.relay.ui.components
import android.text.format.DateFormat
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -78,159 +78,158 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
DiagnosticSeverityChip(entry.severity)
Spacer(Modifier.width(10.dp))
Text(
text = entry.category.label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
MessageOverlayScope {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
DiagnosticSeverityChip(entry.severity)
Spacer(Modifier.width(10.dp))
Text(
text = entry.category.label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(10.dp))
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(10.dp))
// Metadata rows — only render the ones that are present.
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
MetaRow("Severity", severityName)
MetaRow("Category", entry.category.label)
entry.operation?.let { MetaRow("Operation", it) }
entry.endpointRole?.let { MetaRow("Route", it) }
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
entry.requestUrl?.let { MetaRow("Request", it) }
if (entry.configuredUrl == null && entry.requestUrl == null) {
entry.url?.let { MetaRow("URL", it) }
}
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
entry.suggestion?.let { suggestion ->
Spacer(Modifier.height(10.dp))
Text(
text = "Suggested next step",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = entry.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(14.dp))
val body = entry.stacktrace ?: entry.detail
if (body != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 320.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
) {
SelectionContainer {
Text(
text = body,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
Spacer(Modifier.height(10.dp))
// Metadata rows — only render the ones that are present.
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
MetaRow("Severity", severityName)
MetaRow("Category", entry.category.label)
entry.operation?.let { MetaRow("Operation", it) }
entry.endpointRole?.let { MetaRow("Route", it) }
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
entry.requestUrl?.let { MetaRow("Request", it) }
if (entry.configuredUrl == null && entry.requestUrl == null) {
entry.url?.let { MetaRow("URL", it) }
}
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
entry.suggestion?.let { suggestion ->
Spacer(Modifier.height(10.dp))
Text(
text = "Suggested next step",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
} else {
Text(
text = stringResource(R.string.diagnostic_no_detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (expectationVisible) {
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = expectation,
onValueChange = { expectation = it },
label = { Text("What were you expecting to happen?") },
supportingText = {
Text(stringResource(R.string.diagnostic_routine_hint))
},
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, plainText)
toast(context, copiedToast)
},
) { Text(stringResource(R.string.common_copy)) }
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
subject = "Hermes-Relay diagnostic — ${entry.title}",
text = plainText,
chooserTitle = exportChooserTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, plainText)
toast(context, "Copied — no app found to share to")
}
},
) { Text(stringResource(R.string.common_export)) }
Button(
enabled = !expectationVisible || expectation.isNotBlank(),
onClick = {
if (needsExpectation && !expectationVisible) {
expectationVisible = true
return@Button
}
// Copy full text first; the GitHub URL only carries the
// head of long traces, so the user can paste the rest.
IssueReport.copyToClipboard(context, plainText)
val opened = IssueReport.openUrl(
context,
IssueReport.buildGithubIssueUrl(
title = DiagnosticIssuePrefill.issueTitle(entry),
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
entry,
expectation = expectation.takeIf { expectationVisible },
),
labels = DiagnosticIssuePrefill.issueLabels(entry),
val body = entry.stacktrace ?: entry.detail
if (body != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 320.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
)
toast(
context,
if (opened) "Full diagnostic copied — paste it into the issue if truncated"
else "Copied — no browser found to open GitHub",
)
},
) { Text(stringResource(R.string.common_report)) }
) {
SelectionContainer {
Text(
text = body,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
} else {
Text(
text = stringResource(R.string.diagnostic_no_detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (expectationVisible) {
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = expectation,
onValueChange = { expectation = it },
label = { Text("What were you expecting to happen?") },
supportingText = {
Text(stringResource(R.string.diagnostic_routine_hint))
},
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, plainText)
UiMessageBus.success(copiedToast)
},
) { Text(stringResource(R.string.common_copy)) }
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
subject = "Hermes-Relay diagnostic — ${entry.title}",
text = plainText,
chooserTitle = exportChooserTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, plainText)
UiMessageBus.warning("Copied — no app found to share to")
}
},
) { Text(stringResource(R.string.common_export)) }
Button(
enabled = !expectationVisible || expectation.isNotBlank(),
onClick = {
if (needsExpectation && !expectationVisible) {
expectationVisible = true
return@Button
}
// Copy full text first; the GitHub URL only carries the
// head of long traces, so the user can paste the rest.
IssueReport.copyToClipboard(context, plainText)
val opened = IssueReport.openUrl(
context,
IssueReport.buildGithubIssueUrl(
title = DiagnosticIssuePrefill.issueTitle(entry),
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
entry,
expectation = expectation.takeIf { expectationVisible },
),
labels = DiagnosticIssuePrefill.issueLabels(entry),
),
)
if (opened) UiMessageBus.success("Full diagnostic copied — paste it into the issue if truncated")
else UiMessageBus.warning("Copied — no browser found to open GitHub")
},
) { Text(stringResource(R.string.common_report)) }
}
}
}
}
@@ -276,10 +275,6 @@ internal fun DiagnosticSeverityChip(severity: DiagnosticSeverity) {
}
}
private fun toast(context: android.content.Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
/** Full, copy/share-ready plain-text rendering of a single diagnostic entry. */
private fun DiagnosticLogEntry.toPlainText(): String = buildString {
appendLine("Hermes-Relay diagnostic")
@@ -24,12 +24,12 @@ import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material.icons.filled.PauseCircleOutline
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.PauseCircleOutline
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -66,7 +66,6 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.GatewayProcess
import com.hermesandroid.relay.viewmodel.SubagentActivity
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
import kotlinx.coroutines.launch
@@ -84,27 +83,18 @@ internal fun GatewayBackgroundProcessStrip(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Initial/switch refreshes are silent. The strip appears only after the
// session actually owns a process, avoiding a transient "Checking" row on
// every ordinary chat open.
// Completed work remains reachable from its transcript receipt. Only active
// work owns composer space; refreshing history must not resurrect the strip.
val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty()
if (processes.isEmpty() && visibleActivities.isEmpty()) return
val running = processes.count { it.isRunning }
val runningAgents = visibleActivities.count { !it.isTerminal }
val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
val failedAgents = visibleActivities.count { it.phase == SubagentActivityPhase.FAILED }
val interruptedAgents = visibleActivities.count {
it.phase == SubagentActivityPhase.INTERRUPTED ||
it.phase == SubagentActivityPhase.ENDED_WITH_PARENT
}
val failureCount = failed + failedAgents
if (running == 0 && runningAgents == 0) return
val status = when {
runningAgents > 0 && running > 0 -> stringResource(
R.string.current_chat_activity_summary, runningAgents, running,
)
runningAgents > 0 -> stringResource(R.string.subagent_lane_running_count, runningAgents)
running > 0 -> "$running ${stringResource(R.string.bg_processes_running)}"
failureCount > 0 -> "$failureCount ${stringResource(R.string.task_status_failed)}"
interruptedAgents > 0 -> stringResource(R.string.agent_activity_status_interrupted)
else -> stringResource(R.string.task_status_complete)
else -> "$running ${stringResource(R.string.bg_processes_running)}"
}
val openDescription = stringResource(R.string.current_chat_activity_open)
@@ -130,24 +120,7 @@ internal fun GatewayBackgroundProcessStrip(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (running > 0 || runningAgents > 0 || loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Icon(
imageVector = when {
failureCount > 0 -> Icons.Filled.ErrorOutline
interruptedAgents > 0 -> Icons.Filled.PauseCircleOutline
else -> Icons.Filled.CheckCircle
},
contentDescription = null,
modifier = Modifier.size(17.dp),
tint = when {
failureCount > 0 -> MaterialTheme.colorScheme.error
interruptedAgents > 0 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
},
)
}
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(9.dp))
Text(
text = stringResource(R.string.current_chat_activity_title),
@@ -157,11 +130,7 @@ internal fun GatewayBackgroundProcessStrip(
Text(
text = status,
style = MaterialTheme.typography.labelMedium,
color = if (failureCount > 0 && running == 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = Icons.Filled.Visibility,
@@ -190,6 +159,8 @@ internal fun GatewayBackgroundProcessSheet(
onDismissProcess: (String) -> Unit,
onOpenSubagentChild: (String) -> Unit,
onDismiss: () -> Unit,
readOnlyHistory: Boolean = false,
historyNotice: String? = null,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
@@ -246,17 +217,19 @@ internal fun GatewayBackgroundProcessSheet(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.current_chat_activity_title),
stringResource(
if (readOnlyHistory) R.string.chat_activity_history_title else R.string.current_chat_activity_title,
),
modifier = Modifier.semantics { heading() },
style = MaterialTheme.typography.titleLarge,
)
Text(
stringResource(R.string.current_chat_activity_subtitle),
historyNotice ?: stringResource(R.string.current_chat_activity_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (processes.isNotEmpty()) IconButton(onClick = onRefresh, enabled = !loading) {
if (processes.isNotEmpty() && !readOnlyHistory) IconButton(onClick = onRefresh, enabled = !loading) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
@@ -300,7 +273,9 @@ internal fun GatewayBackgroundProcessSheet(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(R.string.current_chat_activity_empty),
stringResource(
if (readOnlyHistory) R.string.chat_activity_history_empty else R.string.current_chat_activity_empty,
),
modifier = Modifier.padding(top = 12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -341,7 +316,7 @@ internal fun GatewayBackgroundProcessSheet(
GatewayProcessRow(
process = process,
stopping = process.id in stoppingProcessIds,
onStop = { onStop(process.id) },
onStop = if (readOnlyHistory) null else ({ onStop(process.id) }),
onDismiss = null,
)
}
@@ -356,7 +331,7 @@ internal fun GatewayBackgroundProcessSheet(
process = process,
stopping = false,
onStop = null,
onDismiss = { onDismissProcess(process.id) },
onDismiss = if (readOnlyHistory) null else ({ onDismissProcess(process.id) }),
)
}
}
@@ -384,7 +359,7 @@ private fun GatewayProcessRow(
onDismiss: (() -> Unit)?,
) {
var expanded by remember(process.id) { mutableStateOf(false) }
val failed = !process.isRunning && (process.exitCode ?: 0) != 0
val failed = processDisplayPhase(process) == ProcessDisplayPhase.FAILED
val output = sanitizeTerminalText(
process.outputTail.orEmpty().ifBlank { process.outputPreview.orEmpty() },
).trimEnd()
@@ -478,9 +453,11 @@ private fun GatewayProcessRow(
@Composable
private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping: Boolean) {
val phase = processDisplayPhase(process)
val tint: Color = when {
failed -> MaterialTheme.colorScheme.error
process.isRunning || stopping -> MaterialTheme.colorScheme.primary
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.tertiary
}
Surface(
@@ -501,6 +478,14 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
modifier = Modifier.size(18.dp),
tint = tint,
)
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> Icon(
imageVector = if (phase == ProcessDisplayPhase.UNKNOWN) Icons.Filled.HelpOutline else Icons.Filled.PauseCircleOutline,
contentDescription = stringResource(
if (phase == ProcessDisplayPhase.UNKNOWN) R.string.agent_activity_status_unavailable else R.string.task_status_cancelled,
),
modifier = Modifier.size(18.dp),
tint = tint,
)
else -> Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.tool_completed_a11y),
@@ -514,8 +499,11 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
@Composable
private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
val phase = processDisplayPhase(process)
val state = when {
process.isRunning -> stringResource(R.string.bg_processes_running)
phase == ProcessDisplayPhase.UNKNOWN -> stringResource(R.string.agent_activity_status_unavailable)
phase == ProcessDisplayPhase.CANCELLED -> stringResource(R.string.task_status_cancelled)
failed -> stringResource(R.string.task_status_failed) +
process.exitCode?.let { " · exit $it" }.orEmpty()
else -> stringResource(R.string.task_status_complete) +
@@ -525,6 +513,19 @@ private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
if (process.detached) " · recovered" else ""
}
internal enum class ProcessDisplayPhase { RUNNING, COMPLETE, FAILED, CANCELLED, UNKNOWN }
/** A recovered or unfamiliar state is not evidence of successful completion. */
internal fun processDisplayPhase(process: GatewayProcess): ProcessDisplayPhase = when {
process.isRunning -> ProcessDisplayPhase.RUNNING
process.status.equals("unknown", ignoreCase = true) -> ProcessDisplayPhase.UNKNOWN
process.status.equals("cancelled", ignoreCase = true) ||
process.status.equals("canceled", ignoreCase = true) -> ProcessDisplayPhase.CANCELLED
process.status.equals("failed", ignoreCase = true) || (process.exitCode ?: 0) != 0 -> ProcessDisplayPhase.FAILED
process.exitCode == 0 || process.status.lowercase() in setOf("complete", "completed", "exited") -> ProcessDisplayPhase.COMPLETE
else -> ProcessDisplayPhase.UNKNOWN
}
private val ansiTerminalEscape = Regex(
"\u001B(?:\\].*?(?:\u0007|\u001B\\\\)|\\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~])",
RegexOption.DOT_MATCHES_ALL,
@@ -5,7 +5,7 @@ import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
@@ -616,7 +616,7 @@ fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
private suspend fun shareAttachment(context: Context, attachment: Attachment) {
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
return
}
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
@@ -626,7 +626,7 @@ private suspend fun shareAttachment(context: Context, attachment: Attachment) {
suspend fun saveAttachment(context: Context, attachment: Attachment) {
val bytes = attachmentBytes(context, attachment)
if (bytes == null) {
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
return
}
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
@@ -636,13 +636,13 @@ suspend fun saveAttachment(context: Context, attachment: Attachment) {
}
when (result) {
is MediaSaver.SaveResult.Saved ->
attachmentToast(context, context.getString(R.string.inbound_attach_saved, result.location))
UiMessageBus.success(context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
attachmentToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
UiMessageBus.error(context.getString(R.string.inbound_attach_save_failed, result.message))
}
}
@@ -658,7 +658,7 @@ private suspend fun openAttachmentExternally(context: Context, attachment: Attac
if (uri != null) {
MediaSaver.open(context, uri, attachment.contentType)
} else {
attachmentToast(context, context.getString(R.string.inbound_attach_open_failed))
UiMessageBus.error(context.getString(R.string.inbound_attach_open_failed))
}
}
@@ -774,10 +774,6 @@ internal suspend fun attachmentBytes(context: Context, attachment: Attachment):
}
}
private fun attachmentToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@Composable
private fun emojiAndLabelFor(
mode: AttachmentRenderMode,
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -47,6 +48,7 @@ import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -64,21 +66,26 @@ private const val MAX_VISIBLE_EXPANDED = 3
private const val ROW_MIN_HEIGHT_DP = 34
/**
* Top, thin, info-only banner host. Collects [UiMessageBus] and renders the
* Top, thin, themed banner host. Collects [UiMessageBus] and renders the
* newest transient message on one line; tapping expands to the recent few
* (scrolling past three). It takes its own vertical space — the Scaffold below
* reflows, so content slides down smoothly instead of being covered by an
* overlay. Auto-dismisses (paused while expanded) and coalesces duplicates so a
* burst of the same status collapses to one refreshed row.
*
* App-owned errors stay on the snackbar. Keyed upstream AgentNotices may also
* use the warning tone because their sticky/clear lifecycle is server-owned.
* Actionable messages use ThemedMessageHost. Modal hosts render above their
* dialog while the primary host retains messages for the return to the app.
*/
@Composable
fun MessageBannerHost(
modifier: Modifier = Modifier,
includeStatusBarPadding: Boolean = true,
primary: Boolean = true,
) {
val hostId = remember { UiMessageBus.registerHost(primary) }
val activeHost by UiMessageBus.activeHost.collectAsState()
val active = activeHost == hostId
DisposableEffect(hostId) { onDispose { UiMessageBus.unregisterHost(hostId) } }
// Backing queue (oldest first; newest is last). expiresAt is kept in a
// parallel map so coalescing/auto-dismiss can address rows by id.
val shown = remember { mutableStateListOf<UiMessage>() }
@@ -122,12 +129,12 @@ fun MessageBannerHost(
}
// Collapse + report count to the scaffold (for inset accounting).
LaunchedEffect(shown.size) {
LaunchedEffect(shown.size, active) {
if (shown.isEmpty()) expanded = false
UiMessageBus.reportActiveCount(shown.size)
if (primary) UiMessageBus.reportActiveCount(if (active) shown.size else 0)
}
DisposableEffect(Unit) {
onDispose { UiMessageBus.reportActiveCount(0) }
onDispose { if (primary) UiMessageBus.reportActiveCount(0) }
}
// Mirror the live queue into a retained copy so the exit animation still
@@ -147,7 +154,7 @@ fun MessageBannerHost(
// under the notch. The smooth "slide" lives in animateContentSize below
// (collapsed↔expanded and message-count changes).
AnimatedVisibility(
visible = shown.isNotEmpty(),
visible = active && shown.isNotEmpty(),
enter = fadeIn(tween(180)),
exit = fadeOut(tween(160)),
modifier = modifier,
@@ -155,7 +162,7 @@ fun MessageBannerHost(
MessageBannerContent(
messages = rendered,
expanded = expanded,
onToggle = { if (rendered.size > 1) expanded = !expanded },
onToggle = { expanded = !expanded },
includeStatusBarPadding = includeStatusBarPadding,
)
}
@@ -170,6 +177,7 @@ private fun MessageBannerContent(
) {
val newest = messages.lastOrNull() ?: return
val multiple = messages.size > 1
val expandable = multiple || newest.text.length > 80 || newest.severity in setOf(UiMessageSeverity.Warning, UiMessageSeverity.Error)
val insetModifier = if (includeStatusBarPadding) {
Modifier.windowInsetsPadding(WindowInsets.statusBars)
} else {
@@ -190,14 +198,14 @@ private fun MessageBannerContent(
tonalElevation = 0.dp,
modifier = Modifier
.fillMaxWidth()
.then(if (multiple) Modifier.clickable(onClick = onToggle) else Modifier)
.then(if (expandable) Modifier.clickable(onClick = onToggle) else Modifier)
.animateContentSize(animationSpec = tween(durationMillis = 180)),
) {
if (!expanded) {
MessageRow(
message = newest,
trailing = {
if (multiple) {
if (expandable) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -236,6 +244,7 @@ private fun MessageBannerContent(
ordered.forEachIndexed { index, message ->
MessageRow(
message = message,
expanded = true,
trailing = {
if (index == 0) {
Icon(
@@ -256,6 +265,7 @@ private fun MessageBannerContent(
@Composable
private fun MessageRow(
message: UiMessage,
expanded: Boolean = false,
trailing: @Composable (() -> Unit)? = null,
) {
Row(
@@ -275,7 +285,7 @@ private fun MessageRow(
Text(
text = message.text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
maxLines = if (expanded) 12 else 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
@@ -284,22 +294,25 @@ private fun MessageRow(
}
@Composable
private fun severityContainer(severity: UiMessageSeverity): Color = when (severity) {
internal fun severityContainer(severity: UiMessageSeverity): Color = (when (severity) {
UiMessageSeverity.Error -> MaterialTheme.colorScheme.errorContainer
UiMessageSeverity.Success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
UiMessageSeverity.Status -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.72f)
UiMessageSeverity.Info -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.90f)
}
}).compositeOver(MaterialTheme.colorScheme.surface)
@Composable
private fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
internal fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
UiMessageSeverity.Error -> MaterialTheme.colorScheme.onErrorContainer
UiMessageSeverity.Success -> MaterialTheme.colorScheme.onTertiaryContainer
UiMessageSeverity.Status -> MaterialTheme.colorScheme.onSecondaryContainer
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.onErrorContainer
UiMessageSeverity.Info -> MaterialTheme.colorScheme.onSurfaceVariant
}
private fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
internal fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
UiMessageSeverity.Error -> Icons.Filled.Warning
UiMessageSeverity.Success -> Icons.Filled.CheckCircle
UiMessageSeverity.Status -> Icons.Filled.Sync
UiMessageSeverity.Warning -> Icons.Filled.Warning
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -92,68 +92,70 @@ fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.support_bundle_privacy, state.recordCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 160.dp, max = 420.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
) {
SelectionContainer {
Text(
text = state.text,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
)
MessageOverlayScope {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = appearanceRoundedCornerShape(24.dp),
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.support_bundle_privacy, state.recordCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 160.dp, max = 420.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
) {
SelectionContainer {
Text(
text = state.text,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
)
}
}
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
enabled = state.shareEnabled,
onClick = {
IssueReport.copyToClipboard(context, state.text)
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
},
) { Text(stringResource(R.string.common_copy)) }
Button(
enabled = state.shareEnabled,
onClick = {
if (!IssueReport.share(
context,
subject = chooser,
text = state.text,
chooserTitle = chooser,
)
) {
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
enabled = state.shareEnabled,
onClick = {
IssueReport.copyToClipboard(context, state.text)
Toast.makeText(context, noShare, Toast.LENGTH_LONG).show()
}
},
) { Text(stringResource(R.string.common_share)) }
UiMessageBus.success(copied)
},
) { Text(stringResource(R.string.common_copy)) }
Button(
enabled = state.shareEnabled,
onClick = {
if (!IssueReport.share(
context,
subject = chooser,
text = state.text,
chooserTitle = chooser,
)
) {
IssueReport.copyToClipboard(context, state.text)
UiMessageBus.warning(noShare)
}
},
) { Text(stringResource(R.string.common_share)) }
}
}
}
}
@@ -0,0 +1,88 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
import com.hermesandroid.relay.util.HumanError
val LocalMessageActionHost = staticCompositionLocalOf<SnackbarHostState?> { null }
data class HumanErrorVisuals(val error: HumanError) : SnackbarVisuals {
override val message get() = error.body
override val actionLabel get() = error.actionLabel
override val withDismissAction = true
override val duration get() = if (error.retryable) SnackbarDuration.Long else SnackbarDuration.Short
}
/** Uses the app banner's appearance while preserving existing suspend/action contracts. */
@Composable
fun ThemedMessageHost(hostState: SnackbarHostState, modifier: Modifier = Modifier, scoped: Boolean = false) {
val modalActive by UiMessageBus.modalHostActive.collectAsState()
if (!scoped && modalActive) return
SnackbarHost(hostState = hostState, modifier = modifier) { data ->
val error = (data.visuals as? HumanErrorVisuals)?.error
val severity = if (error != null) UiMessageSeverity.Error else UiMessageSeverity.Info
Surface(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = severityContainer(severity), contentColor = severityOnContainer(severity),
) {
Column(Modifier.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(severityIcon(severity), null, Modifier.size(18.dp))
Column(Modifier.weight(1f).padding(10.dp).heightIn(max = 200.dp).verticalScroll(rememberScrollState())) {
error?.title?.let { Text(it, style = MaterialTheme.typography.titleSmall) }
Text(data.visuals.message, style = MaterialTheme.typography.bodyMedium)
}
IconButton(onClick = data::dismiss) {
Icon(Icons.Filled.Close, stringResource(R.string.common_dismiss))
}
}
data.visuals.actionLabel?.let { label ->
TextButton(onClick = data::performAction, modifier = Modifier.align(Alignment.End)) { Text(label) }
}
}
}
}
}
/** Modal-window host; the root retains the same bus events until their normal expiry. */
@Composable
fun MessageOverlayScope(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val actions = LocalMessageActionHost.current
Box(modifier) {
content()
MessageBannerHost(Modifier.align(Alignment.TopCenter), includeStatusBarPadding = false, primary = false)
if (actions != null) ThemedMessageHost(actions, Modifier.align(Alignment.BottomCenter), scoped = true)
}
}
@@ -57,6 +57,18 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/** A completed dispatch call is not evidence that its detached child finished. */
internal fun isDetachedDelegationDispatch(toolCall: ToolCall): Boolean {
if (toolCall.name != "delegate_task" || !toolCall.isComplete || toolCall.success != true) return false
val result = toolCall.result ?: return false
val payload = runCatching { Json.parseToJsonElement(result) as? JsonObject }.getOrNull() ?: return false
return (payload["status"] as? JsonPrimitive)?.content == "dispatched" ||
(payload["mode"] as? JsonPrimitive)?.content == "background"
}
@Composable
fun ToolProgressCard(
@@ -116,7 +128,9 @@ fun ToolProgressCard(
}
val toolIcon = toolIcon(toolCall.name)
val detachedDispatch = isDetachedDelegationDispatch(toolCall)
val statusText = when {
detachedDispatch -> stringResource(R.string.tool_progress_status_dispatched)
toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed)
toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed)
isPreparing -> stringResource(R.string.tool_preparing_a11y)
@@ -187,7 +201,7 @@ fun ToolProgressCard(
)
// Duration + completion time ("3.1s · 5:32 PM")
val metaLabel = listOfNotNull(duration, timeLabel).joinToString(" · ")
val metaLabel = if (detachedDispatch) statusText else listOfNotNull(duration, timeLabel).joinToString(" · ")
if (metaLabel.isNotEmpty()) {
Text(
text = metaLabel,
@@ -4,7 +4,7 @@ package com.hermesandroid.relay.ui.screens
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -94,7 +94,7 @@ fun AboutScreen(
val scope = rememberCoroutineScope()
val isDarkTheme = LocalBrand.current.isDark
// Pre-resolved Toast messages (Toast is not a composable scope)
// Pre-resolved messages for non-composable action callbacks.
val devUnlockedMsg = stringResource(R.string.about_dev_options_unlocked)
val updateCopiedMsg = stringResource(R.string.about_update_copied)
val emDash = stringResource(R.string.about_em_dash)
@@ -235,17 +235,13 @@ fun AboutScreen(
versionTapCount = 0
scope.launch {
FeatureFlags.unlockDevOptions(context)
Toast.makeText(
context,
devUnlockedMsg,
Toast.LENGTH_SHORT,
).show()
UiMessageBus.success(devUnlockedMsg)
onUnlockDeveloperOptions()
}
}
remaining <= 3 -> {
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
Toast.makeText(context, tapsMsg, Toast.LENGTH_SHORT).show()
UiMessageBus.info(tapsMsg)
}
}
},
@@ -326,11 +322,7 @@ fun AboutScreen(
if (ru.updateAvailable && !ru.updateCommand.isNullOrBlank()) {
TextButton(onClick = {
clipboard.setText(AnnotatedString(ru.updateCommand))
Toast.makeText(
context,
updateCopiedMsg,
Toast.LENGTH_SHORT,
).show()
UiMessageBus.success(updateCopiedMsg)
}) {
Text(stringResource(R.string.about_copy_fix))
}
@@ -59,7 +59,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
@@ -268,7 +268,7 @@ fun AppearanceSettingsScreen(
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = { ThemedMessageHost(snackbarHostState) },
) { innerPadding ->
Column(
modifier = Modifier
@@ -41,7 +41,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -152,7 +152,7 @@ fun BotModeScreen(
},
onOpenGroup = { onOpenGroup(it.key) },
onNewBot = { showCreateBot = true },
snackbarHost = { SnackbarHost(snackbar) },
snackbarHost = { ThemedMessageHost(snackbar) },
botAvatar = { bot, size ->
BotProfileAvatar(
connectionViewModel = connectionViewModel,
@@ -12,6 +12,14 @@ import androidx.compose.foundation.MutatePriority
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.testTag
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
import com.hermesandroid.relay.data.projectChatActivityReceipts
import com.hermesandroid.relay.viewmodel.previewActivities
import com.hermesandroid.relay.ui.components.ChatDebugOverlay
import com.hermesandroid.relay.ui.components.chatDebugHeaderGesture
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -150,7 +158,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration
@@ -159,7 +167,7 @@ import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -870,9 +878,17 @@ fun ChatScreen(
val rawMessages by chatViewModel.messages.collectAsState()
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
if (!supervised) rawMessages
else rawMessages.map { message ->
val activityRecords by chatViewModel.activityRecords.collectAsState()
val activityOwner by chatViewModel.conversationBinding.collectAsState()
val activitySessionId by chatViewModel.currentSessionId.collectAsState()
val receiptMessages = remember(rawMessages, activityRecords, activityOwner, activitySessionId, supervised, supervisedVisibility) {
if (activityOwner.transport == com.hermesandroid.relay.data.SessionTransport.SSE ||
(supervised && !supervisedVisibility.showWorkingStatus)
) rawMessages else projectChatActivityReceipts(rawMessages, activityRecords, activityOwner.contextKey, activitySessionId)
}
val messages = remember(receiptMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
if (!supervised) receiptMessages
else receiptMessages.map { message ->
if (message.role == MessageRole.ASSISTANT) {
message.copy(
attachments = if (supervisedPolicy.capabilities.generatedImages) {
@@ -935,6 +951,10 @@ fun ChatScreen(
val sessionArchivingSupported by chatViewModel.sessionArchivingSupported.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val structuredChatFailure by chatViewModel.chatFailure.collectAsState()
val preparingGatewaySessionId by chatViewModel.gatewayPreparingSessionId.collectAsState()
val gatewaySocketState by chatViewModel.gatewaySocketState.collectAsState()
val preparingGatewaySession = isStreaming && currentSessionId != null &&
preparingGatewaySessionId == currentSessionId
val visibleChatFailure = scopedChatFailure(
structuredChatFailure,
currentSessionId,
@@ -961,6 +981,7 @@ fun ChatScreen(
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
val subagentActivities by chatViewModel.subagentActivities.collectAsState()
val retainedActivityPreview by chatViewModel.retainedActivityPreview.collectAsState()
val subagentChildPreview by chatViewModel.subagentChildPreview.collectAsState()
val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState()
val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState()
@@ -1199,7 +1220,10 @@ fun ChatScreen(
// RPC burst while the session directory was also trying to hydrate.
}
DisposableEffect(chatViewModel) {
onDispose { chatViewModel.setChatVisible(false) }
onDispose {
chatViewModel.setChatVisible(false)
chatViewModel.closeActivityPreview()
}
}
// Cold-open recovery: the dashboard probe that flips gatewayAvailability to
@@ -1370,14 +1394,10 @@ fun ChatScreen(
}
}
if (request.payload.omittedUriCount > 0) {
Toast.makeText(
context,
context.getString(
UiMessageBus.warning(context.getString(
R.string.chat_shared_files_limited,
com.hermesandroid.relay.util.MAX_SHARED_CONTENT_ATTACHMENTS,
),
Toast.LENGTH_LONG,
).show()
))
}
com.hermesandroid.relay.util.SharedContentRequest.consume(request.id)
}
@@ -1416,6 +1436,9 @@ fun ChatScreen(
var showEffortSheet by remember { mutableStateOf(false) }
var showAgentInfo by remember { mutableStateOf(false) }
var showProfileShelf by remember { mutableStateOf(false) }
var showChatDebug by remember { mutableStateOf(false) }
var chatHeaderHeightPx by remember { mutableStateOf(0) }
LaunchedEffect(currentSessionId, activeConnection?.id, supervised) { showChatDebug = false }
var showProfileSwitcher by remember { mutableStateOf(false) }
var showProfileManager by remember { mutableStateOf(false) }
var showBackgroundProcesses by remember { mutableStateOf(false) }
@@ -1432,7 +1455,7 @@ fun ChatScreen(
// A process inventory is scoped to one gateway session. Never leave a
// sheet opened onto a different chat after a drawer/profile switch.
LaunchedEffect(currentSessionId, selectedProfile?.name, activeConnection?.id) {
chatViewModel.closeSubagentChildPreview()
chatViewModel.closeActivityPreview()
showBackgroundProcesses = false
}
@@ -1799,7 +1822,7 @@ fun ChatScreen(
cameraLauncher.launch(uri)
}.onFailure {
pendingCameraUri = null
Toast.makeText(context, context.getString(R.string.chat_camera_open_failed), Toast.LENGTH_SHORT).show()
UiMessageBus.error(context.getString(R.string.chat_camera_open_failed))
}
}
var pendingCameraAfterPermission by remember { mutableStateOf(false) }
@@ -1811,11 +1834,7 @@ fun ChatScreen(
if (granted && wanted) {
launchCamera()
} else if (!granted) {
Toast.makeText(
context,
context.getString(R.string.chat_camera_perm_needed),
Toast.LENGTH_SHORT,
).show()
UiMessageBus.warning(context.getString(R.string.chat_camera_perm_needed))
}
}
val requestCameraCapture: () -> Unit = {
@@ -2613,11 +2632,7 @@ fun ChatScreen(
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(copySessionIdLabel, sessionId))
)
Toast.makeText(
context,
copiedToClipboardMsg,
Toast.LENGTH_SHORT,
).show()
UiMessageBus.success(copiedToClipboardMsg)
}
},
threadsCapabilityActive = threadsCapabilityActive,
@@ -2763,6 +2778,7 @@ fun ChatScreen(
) {
// Top bar — messaging app style with avatar, name, model subtitle
TopAppBar(
modifier = Modifier.onSizeChanged { chatHeaderHeightPx = it.height },
navigationIcon = {
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
@@ -2806,6 +2822,7 @@ fun ChatScreen(
val showStreamingState = isStreaming &&
(!supervised || supervisedVisibility.showWorkingStatus)
val statusText = when {
preparingGatewaySession -> stringResource(R.string.chat_debug_preparing)
headerChatReady -> if (showStreamingState) {
stringResource(R.string.chat_streaming)
} else {
@@ -2882,13 +2899,21 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.clickable(enabled = !supervised) {
.chatDebugHeaderGesture(enabled = !supervised,
onHold = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showProfileShelf = false
showChatDebug = !showChatDebug
},
onClick = {
showChatDebug = false
if (profileShelfAvailable) {
showProfileShelf = !showProfileShelf
} else {
showAgentInfo = true
}
}
})
.testTag("chat-agent-header")
.semantics {
contentDescription = if (profileShelfAvailable) {
context.getString(
@@ -3669,7 +3694,15 @@ fun ChatScreen(
)
}
if (processNotification != null) {
if (message.activityRecord != null && (!supervised || supervisedVisibility.showWorkingStatus)) {
ChatActivityReceipt(
record = message.activityRecord,
onClick = {
showBackgroundProcesses = chatViewModel.openRetainedActivity(message.activityRecord, processNotification?.detail)
},
modifier = Modifier.padding(vertical = 4.dp),
)
} else if (processNotification != null) {
val notificationModifier = Modifier.padding(
top = if (isFirstInGroup) 6.dp else 2.dp,
)
@@ -3911,6 +3944,9 @@ fun ChatScreen(
// optional diagnostic scaffolding. Keep lanes
// visible in Off, Compact, and Detailed modes.
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
val laneCalls = laneGroups.getValue(taskIndex)
val retainedIds = activityRecords.flatMap { it.children }.mapTo(HashSet()) { it.id }
if (laneCalls.all { it.subagentId != null && it.subagentId in retainedIds }) return@forEach
Spacer(modifier = Modifier.height(4.dp))
SubagentLane(
taskIndex = taskIndex,
@@ -4046,7 +4082,7 @@ fun ChatScreen(
}
// Copy feedback snackbar
SnackbarHost(
ThemedMessageHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -4061,7 +4097,7 @@ fun ChatScreen(
subagentActivities = subagentActivities,
subagentPreviewVisibility = subagentPreviewVisibility,
loading = backgroundProcessesLoading,
onClick = { showBackgroundProcesses = true },
onClick = { chatViewModel.openCurrentActivityPreview(); showBackgroundProcesses = true },
)
}
@@ -4569,17 +4605,11 @@ fun ChatScreen(
isDemoMode = isDemoMode,
voiceReady = voiceReady,
onDemoNotice = {
Toast.makeText(
context,
"Voice is unavailable in the offline demo — connect to Hermes to use it",
Toast.LENGTH_LONG,
).show()
UiMessageBus.warning("Voice is unavailable in the offline demo — connect to Hermes to use it")
},
onStartVoice = requestVoiceMode,
onSetupNotice = {
Toast.makeText(
context,
when (standardVoiceAvailability) {
UiMessageBus.warning(when (standardVoiceAvailability) {
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
standardVoiceSignInRouteHint?.let { route ->
"Voice needs a one-time sign-in on the $route route — open Manage"
@@ -4588,9 +4618,7 @@ fun ChatScreen(
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
else ->
context.getString(R.string.chat_voice_needs_route)
},
Toast.LENGTH_SHORT,
).show()
})
},
)
},
@@ -4814,6 +4842,30 @@ fun ChatScreen(
}
} // end Column
ChatDebugOverlay(
visible = showChatDebug && !supervised,
headerHeight = with(density) { chatHeaderHeightPx.toDp() },
onClose = { showChatDebug = false },
) {
ChatDebugDrawer(
profile = AgentDisplay.profileDisplayName(conversationProfile)
?: stringResource(R.string.chat_server_default),
model = AgentDisplay.displayModelName(sessionModelState.model).orEmpty(),
sessionId = currentSessionId,
gateway = isGatewayTransport,
signedIn = chatGatewayAvailability == GatewayAvailability.Ready,
signInRequired = chatGatewayAvailability == GatewayAvailability.SignInRequired,
socketState = gatewaySocketState,
preparing = preparingGatewaySession,
streaming = isStreaming,
loadingHistory = isLoadingHistory,
directoryUnavailable = sessionListUnavailable,
failure = visibleChatFailure?.rawError,
onClose = { showChatDebug = false },
onConnections = { showChatDebug = false; onNavigateToConnections() },
)
}
// Mic permission denied banner — title + body + Open Settings action.
// System "Don't ask again" gives no callback, so a toast would leave
// the user stranded. Banner + direct-to-app-details deep link is the
@@ -4953,8 +5005,8 @@ fun ChatScreen(
if (showBackgroundProcesses) {
GatewayBackgroundProcessSheet(
processes = backgroundProcesses,
subagentActivities = subagentActivities,
processes = retainedActivityPreview?.processes ?: backgroundProcesses,
subagentActivities = retainedActivityPreview?.record?.previewActivities() ?: subagentActivities,
subagentChildPreview = subagentChildPreview,
subagentPreviewVisibility = subagentPreviewVisibility,
loading = backgroundProcessesLoading,
@@ -4963,8 +5015,10 @@ fun ChatScreen(
onStop = chatViewModel::stopBackgroundProcess,
onDismissProcess = chatViewModel::dismissBackgroundProcess,
onOpenSubagentChild = chatViewModel::openSubagentChildPreview,
readOnlyHistory = retainedActivityPreview != null,
historyNotice = if (retainedActivityPreview != null) stringResource(R.string.chat_activity_history_notice) else null,
onDismiss = {
chatViewModel.closeSubagentChildPreview()
chatViewModel.closeActivityPreview()
showBackgroundProcesses = false
},
)
@@ -5505,13 +5559,9 @@ private suspend fun ingestAttachmentFromUri(
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: AttachmentTooLargeException) {
Toast.makeText(
context,
context.getString(R.string.chat_file_too_large, maxAttachmentMb),
Toast.LENGTH_SHORT,
).show()
UiMessageBus.warning(context.getString(R.string.chat_file_too_large, maxAttachmentMb))
} catch (e: Exception) {
Toast.makeText(context, context.getString(R.string.chat_failed_read_file), Toast.LENGTH_SHORT).show()
UiMessageBus.error(context.getString(R.string.chat_failed_read_file))
}
}
@@ -30,7 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -124,7 +124,7 @@ fun CustomPetGuideScreen(
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = { ThemedMessageHost(snackbarHostState) },
) { innerPadding ->
Column(
modifier = Modifier
@@ -2,7 +2,7 @@
package com.hermesandroid.relay.ui.screens
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.hermesandroid.relay.ui.theme.LocalBrand
@@ -92,11 +92,7 @@ fun DeveloperSettingsScreen(
) { uri ->
if (uri != null && backupJson != null) {
connectionViewModel.writeBackupToUri(uri, backupJson!!) { success ->
Toast.makeText(
context,
if (success) context.getString(R.string.dev_settings_exported) else context.getString(R.string.dev_settings_export_failed),
Toast.LENGTH_SHORT
).show()
UiMessageBus.post(if (success) context.getString(R.string.dev_settings_exported) else context.getString(R.string.dev_settings_export_failed), severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
backupJson = null
}
}
@@ -108,11 +104,7 @@ fun DeveloperSettingsScreen(
) { uri ->
if (uri != null) {
connectionViewModel.importFromUri(uri) { success ->
Toast.makeText(
context,
if (success) context.getString(R.string.dev_settings_imported) else context.getString(R.string.dev_settings_import_failed),
Toast.LENGTH_SHORT
).show()
UiMessageBus.post(if (success) context.getString(R.string.dev_settings_imported) else context.getString(R.string.dev_settings_import_failed), severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
}
}
}
@@ -184,15 +176,11 @@ fun DeveloperSettingsScreen(
}
IconButton(onClick = {
connectionViewModel.resetOnboarding { success ->
Toast.makeText(
context,
if (success) {
UiMessageBus.post(if (success) {
context.getString(R.string.dev_settings_onboarding_reset_toast)
} else {
context.getString(R.string.dev_settings_reset_failed)
},
Toast.LENGTH_SHORT,
).show()
}, severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
}
}) {
Icon(
@@ -433,11 +421,7 @@ fun DeveloperSettingsScreen(
IconButton(onClick = {
scope.launch {
FeatureFlags.lockDevOptions(context)
Toast.makeText(
context,
context.getString(R.string.dev_settings_locked_toast),
Toast.LENGTH_SHORT,
).show()
UiMessageBus.info(context.getString(R.string.dev_settings_locked_toast))
onBack()
}
}) {
@@ -455,6 +439,7 @@ fun DeveloperSettingsScreen(
// logged error, a live update). Gated by isDevBuild so it never
// ships in a release APK.
if (FeatureFlags.isDevBuild) {
MessagePreviewControls()
Text(
text = stringResource(R.string.dev_settings_test_harness),
style = MaterialTheme.typography.titleMedium,
@@ -501,7 +486,7 @@ fun DeveloperSettingsScreen(
context.getString(R.string.dev_settings_sample_stacktrace),
),
)
Toast.makeText(context, context.getString(R.string.dev_settings_diagnostics_emitted_toast), Toast.LENGTH_SHORT).show()
UiMessageBus.info(context.getString(R.string.dev_settings_diagnostics_emitted_toast))
},
)
@@ -518,7 +503,7 @@ fun DeveloperSettingsScreen(
is UpdateStatus.Downloaded -> context.getString(R.string.dev_settings_update_state_downloaded)
else -> context.getString(R.string.dev_settings_update_state_off)
}
Toast.makeText(context, context.getString(R.string.dev_settings_update_banner_preview_toast, state), Toast.LENGTH_SHORT).show()
UiMessageBus.info(context.getString(R.string.dev_settings_update_banner_preview_toast, state))
},
)
@@ -568,11 +553,7 @@ fun DeveloperSettingsScreen(
showExportDialog = false
connectionViewModel.exportSettings { json ->
if (json == null) {
Toast.makeText(
context,
context.getString(R.string.dev_settings_export_failed),
Toast.LENGTH_SHORT,
).show()
UiMessageBus.error(context.getString(R.string.dev_settings_export_failed))
} else {
backupJson = json
exportLauncher.launch("hermes-relay-sensitive-backup.json")
@@ -633,15 +614,11 @@ fun DeveloperSettingsScreen(
onClick = {
showResetDialog = false
connectionViewModel.resetAppData { success ->
Toast.makeText(
context,
if (success) {
UiMessageBus.post(if (success) {
context.getString(R.string.dev_settings_app_data_reset_toast)
} else {
context.getString(R.string.dev_settings_reset_failed)
},
Toast.LENGTH_SHORT,
).show()
}, severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
}
}
) {
@@ -658,7 +635,7 @@ fun DeveloperSettingsScreen(
}
@Composable
private fun TestHarnessRow(
internal fun TestHarnessRow(
title: String,
subtitle: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
@@ -3,7 +3,7 @@
package com.hermesandroid.relay.ui.screens
import android.content.Context
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -285,11 +285,7 @@ fun MediaSettingsScreen(
R.string.media_clear_cache_freed,
formatBytesHuman(context, freed)
)
Toast.makeText(
context,
freedMsg,
Toast.LENGTH_SHORT
).show()
UiMessageBus.info(freedMsg)
}
}
) {
@@ -0,0 +1,83 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Science
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
import com.hermesandroid.relay.ui.components.HumanErrorVisuals
import com.hermesandroid.relay.ui.components.LocalMessageActionHost
import com.hermesandroid.relay.util.HumanError
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/** Local-only samples; no network, diagnostics, or connection-state mutations. */
@Composable
internal fun MessagePreviewControls() {
val scope = rememberCoroutineScope()
val actionHost = LocalMessageActionHost.current
val jobs = remember { arrayOfNulls<Job>(1) }
val key = "developer-message-preview"
fun reset() {
jobs[0]?.cancel()
jobs[0] = null
UiMessageBus.clear(key)
}
DisposableEffect(Unit) { onDispose { reset() } }
Text(stringResource(R.string.dev_message_previews), style = MaterialTheme.typography.titleMedium)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.dev_message_previews_desc), style = MaterialTheme.typography.bodySmall)
val samples = listOf(
R.string.dev_message_info to UiMessageSeverity.Info,
R.string.dev_message_success to UiMessageSeverity.Success,
R.string.dev_message_progress to UiMessageSeverity.Status,
R.string.dev_message_warning to UiMessageSeverity.Warning,
R.string.dev_message_error to UiMessageSeverity.Error,
)
samples.forEach { (label, severity) ->
val text = stringResource(label)
TestHarnessRow(text, stringResource(R.string.dev_message_preview_sample), Icons.Filled.Science, onClick = {
reset()
UiMessageBus.post(text, severity, ttlMillis = if (severity == UiMessageSeverity.Status) 0L else 10_000L, key = key)
})
}
val errorTitle = stringResource(R.string.dev_message_action)
val errorBody = stringResource(R.string.dev_message_action_body)
val retryLabel = stringResource(R.string.dev_message_retry)
val retryResult = stringResource(R.string.dev_message_retry_result)
TestHarnessRow(errorTitle, errorBody, Icons.Filled.Science, onClick = {
reset()
jobs[0] = scope.launch {
val result = actionHost?.showSnackbar(HumanErrorVisuals(HumanError(
title = errorTitle, body = errorBody, retryable = true, actionLabel = retryLabel,
)))
if (result == SnackbarResult.ActionPerformed) {
UiMessageBus.post(retryResult, UiMessageSeverity.Success, key = key)
}
}
})
TestHarnessRow(
stringResource(R.string.dev_message_clear),
stringResource(R.string.dev_message_clear_desc),
Icons.Filled.Science,
onClick = { reset() },
)
}
}
}
@@ -2,7 +2,7 @@
package com.hermesandroid.relay.ui.screens
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -170,7 +170,7 @@ fun PairScreen(
ConnectionWizard(
connectionViewModel = connectionViewModel,
onComplete = {
Toast.makeText(context, context.getString(R.string.pair_connection_updated), Toast.LENGTH_SHORT).show()
UiMessageBus.success(context.getString(R.string.pair_connection_updated))
onComplete()
},
onCancel = onCancel,
@@ -38,7 +38,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -150,7 +150,7 @@ fun PairedDevicesScreen(
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
snackbarHost = { ThemedMessageHost(snackbarHostState) }
) { inner ->
Box(
modifier = Modifier
@@ -36,7 +36,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -189,7 +189,7 @@ fun PetdexBrowseScreen(
),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = { ThemedMessageHost(snackbarHostState) },
) { innerPadding ->
LazyVerticalGrid(
columns = GridCells.Adaptive(156.dp),
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.upstream.DashboardHttpException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
@@ -86,6 +87,8 @@ private fun nullFallback(context: String?, ctx: Context?): HumanError {
return HumanError(title = titlePrefix(context, ctx), body = base, retryable = false)
}
private val LEGACY_HTTP_STATUS = Regex("""^(?:http(?: error)?|api error|relay responded http) ([1-5]\d{2})(?=\s|:|$)""")
private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): HumanError? {
// Ordered most-specific-first; callers have already handled the typed
// SSL / timeout / connect exceptions so this only runs on generic IOs.
@@ -149,11 +152,9 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
"server. Update the relay, then try again.",
retryable = false,
)
"404" in msg -> HumanError(
LEGACY_HTTP_STATUS.find(msg.trim())?.groupValues?.get(1) == "404" -> HumanError(
title = ctx?.getString(R.string.error_classify_endpoint) ?: "Endpoint not found",
body = if (context == "voice_config")
"The relay doesn't have voice endpoints — it may be an older version"
else "The relay doesn't have this endpoint — it may be an older version",
body = "The requested resource or endpoint is unavailable on this server.",
retryable = false,
)
"413" in msg -> HumanError(
@@ -255,6 +256,30 @@ private fun String.diagnosticOperation(): String =
private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?): HumanError {
if (t == null) return nullFallback(context, ctx)
// Preserve the actual transport and status instead of inferring them from
// a response body, resource identifier, or unrelated number in an error.
if (t is DashboardHttpException) {
return when (t.statusCode) {
401 -> HumanError(
title = ctx?.getString(R.string.power_feature_dashboard_signin_label) ?: "Dashboard sign-in required",
body = "Sign in to the Hermes Dashboard to continue this action.",
)
403 -> HumanError(
title = ctx?.getString(R.string.error_classify_not_allowed) ?: "Not allowed",
body = "The Dashboard refused this action.",
)
404 -> HumanError(
title = ctx?.getString(R.string.error_classify_endpoint) ?: "Endpoint not found",
body = "The requested Dashboard resource or endpoint is unavailable.",
)
else -> HumanError(
title = titlePrefix(context, ctx),
body = "The Dashboard request failed (HTTP ${t.statusCode}).",
retryable = t.statusCode == 408 || t.statusCode == 429 || t.statusCode >= 500,
)
}
}
val msg = t.message.orEmpty().lowercase()
if ("cannot create audiorecord" in msg ||
@@ -0,0 +1,286 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.ChatActivityChild
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.data.ChatActivityStore
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.data.boundChatActivities
import com.hermesandroid.relay.network.upstream.GatewayProcess
import java.io.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** Session-owned history metadata. Call capture only with events/snapshots owned by the selected session. */
internal class ChatActivityController(
private val scope: CoroutineScope,
private var store: ChatActivityStore = InMemoryChatActivityStore(),
private val clock: () -> Long = System::currentTimeMillis,
) {
private val mutableRecords = MutableStateFlow<List<ChatActivityRecord>>(emptyList())
val records: StateFlow<List<ChatActivityRecord>> = mutableRecords.asStateFlow()
private var owner: Pair<String, String>? = null
private var generation = 0L
private var deletedOwner: Pair<String, String>? = null
private val retiredIds = mutableSetOf<String>()
private val writes = Mutex()
fun bindStore(store: ChatActivityStore) {
if (this.store === store) return
this.store = store
generation++
load(migrate = true)
}
fun selectSession(scopeKey: String?, sessionId: String?) {
val next = if (!scopeKey.isNullOrBlank() && !sessionId.isNullOrBlank()) scopeKey to sessionId else null
if (next != null && next == deletedOwner) return
if (next != null && next != deletedOwner) deletedOwner = null
if (next == owner) return
owner = next
generation++
mutableRecords.value = emptyList()
retiredIds.clear()
load()
}
private fun load(migrate: Boolean = false) {
val selected = owner ?: return
val selectedStore = store
val selectedGeneration = generation
scope.launch {
val loaded = try {
selectedStore.read(selected.first, selected.second)
} catch (_: IOException) {
emptyList()
}
if (selectedGeneration != generation || owner != selected || selectedStore !== store) return@launch
val fresh = mutableRecords.value.associateBy(ChatActivityRecord::id)
val merged = loaded.filter { it.scopeKey == selected.first && it.sessionId == selected.second && it.id !in retiredIds }
.map { restored -> fresh[restored.id]?.let { live -> mergeRestored(restored, live) } ?: restored }
.associateByTo(linkedMapOf(), ChatActivityRecord::id)
fresh.forEach { (id, record) -> if (id !in merged) merged[id] = record }
// A lazy disk read can reveal a fallback only after its live event supplied delegation identity.
merged.values.filter { it.kind == ChatActivityKind.SUBAGENTS && !it.isFallback() }.toList()
.forEach { target ->
val fallbacks = merged.values.filter { candidate ->
candidate.isFallback() && candidate.children.any { old ->
target.children.any { child -> old.id == child.id ||
(child.childSessionId != null && old.childSessionId == child.childSessionId) }
}
}
var consolidated = target
fallbacks.forEach { fallback ->
consolidated = mergeRestored(fallback, consolidated)
merged.remove(fallback.id)
retire(fallback)
}
merged[target.id] = consolidated
}
publish(merged.values.toList())
mutableRecords.value.filter { it.id in fresh }.forEach { record ->
if (migrate || fresh[record.id] != record) persist(record)
}
}
}
private fun mergeRestored(restored: ChatActivityRecord, fresh: ChatActivityRecord): ChatActivityRecord {
if (fresh.kind != ChatActivityKind.SUBAGENTS) return fresh.copy(createdAt = minOf(restored.createdAt, fresh.createdAt))
val children = mergeChildren(restored.children, fresh.children)
val count = maxOf(restored.taskCount, fresh.taskCount, children.size)
return fresh.copy(
createdAt = minOf(restored.createdAt, fresh.createdAt), children = children,
phase = aggregate(children, count), taskCount = count,
)
}
fun captureSubagents(activities: List<SubagentActivity>) {
val selected = owner ?: return
val identifiable = activities.mapNotNull { activity ->
val childId = activity.subagentId?.takeIf(String::isNotBlank)
?: activity.childSessionId?.takeIf(String::isNotBlank) ?: return@mapNotNull null
val delegation = activity.delegationId?.takeIf(String::isNotBlank)
val identityKind = when {
delegation != null -> "delegation"
!activity.subagentId.isNullOrBlank() -> "subagent"
else -> "session"
}
val existing = if (delegation == null) mutableRecords.value.firstOrNull { record ->
record.kind == ChatActivityKind.SUBAGENTS && record.children.any { it.matches(activity) }
} else null
val id = existing?.id ?: identity("subagents", identityKind, delegation ?: childId)
Triple(id, existing?.sourceId ?: delegation ?: childId, activity)
}
identifiable.groupBy { it.first }.forEach { (id, group) ->
val previous = mutableRecords.value.firstOrNull { it.id == id }
val migrated = mutableRecords.value.filter { record ->
record.id != id && record.isFallback() && group.any { (_, _, activity) ->
record.children.any { it.matches(activity) }
}
}
val inherited = mergeChildren(migrated.flatMap { it.children }, previous?.children.orEmpty())
val children = inherited.associateByTo(linkedMapOf(), ChatActivityChild::id)
group.forEach { (_, _, activity) ->
val childId = activity.subagentId?.takeIf(String::isNotBlank) ?: activity.childSessionId!!
val old = children.values.firstOrNull { it.matches(activity) }
if (old != null && old.id != childId) children.remove(old.id)
children[childId] = ChatActivityChild(
id = childId,
childSessionId = activity.childSessionId ?: old?.childSessionId,
goal = activity.goal.take(512).ifBlank { old?.goal.orEmpty() },
phase = activity.phase.toHistoryPhase(),
// Terminal summary is bounded metadata; progress/tool-event bodies are never copied.
summary = if (activity.isTerminal) activity.summary?.take(512) ?: old?.summary else old?.summary,
)
}
val now = clock()
val values = children.values.toList()
val count = maxOf(previous?.taskCount ?: 0, migrated.maxOfOrNull { it.taskCount } ?: 0,
group.maxOf { it.third.taskCount }, values.size).coerceIn(0, 10_000)
migrated.forEach(::retire)
update(ChatActivityRecord(
id = id, scopeKey = selected.first, sessionId = selected.second,
kind = ChatActivityKind.SUBAGENTS, sourceId = group.first().second,
title = "Subagents", phase = aggregate(values, count),
createdAt = minOf(previous?.createdAt ?: now, migrated.minOfOrNull { it.createdAt } ?: now),
updatedAt = previous?.updatedAt ?: now,
children = values, taskCount = count,
))
}
}
/** An authoritative session-scoped process.list snapshot, including an empty successful snapshot. */
fun captureProcesses(processes: List<GatewayProcess>) {
val selected = owner ?: return
val seen = mutableSetOf<String>()
processes.filter { it.id.isNotBlank() }.forEach { process ->
val id = identity("process", process.id, process.startedAt)
seen += id
val previous = mutableRecords.value.firstOrNull { it.id == id }
val now = clock()
update(ChatActivityRecord(
id = id, scopeKey = selected.first, sessionId = selected.second, kind = ChatActivityKind.PROCESS,
sourceId = process.id, title = "Background command", phase = process.toHistoryPhase(),
createdAt = previous?.createdAt ?: now, updatedAt = previous?.updatedAt ?: now,
processId = process.id, processStartedAt = process.startedAt, exitCode = process.exitCode,
))
}
mutableRecords.value.filter {
it.kind == ChatActivityKind.PROCESS && it.phase == ChatActivityPhase.RUNNING && it.id !in seen
}.forEach { update(it.copy(phase = ChatActivityPhase.UNKNOWN)) }
}
fun removeSession(scopeKey: String, sessionId: String) {
if (owner == (scopeKey to sessionId)) {
deletedOwner = owner
owner = null
generation++
mutableRecords.value = emptyList()
}
val selectedStore = store
scope.launch { writes.withLock { selectedStore.removeSession(scopeKey, sessionId) } }
}
fun markUnavailable() {
mutableRecords.value.toList().forEach { record ->
val children = record.children.map {
if (it.phase == ChatActivityPhase.RUNNING) it.copy(phase = ChatActivityPhase.UNKNOWN) else it
}
val phase = if (record.phase == ChatActivityPhase.RUNNING) ChatActivityPhase.UNKNOWN else record.phase
update(record.copy(phase = phase, children = children))
}
}
private fun retire(record: ChatActivityRecord) {
retiredIds += record.id
mutableRecords.value = mutableRecords.value.filterNot { it.id == record.id }
val selectedStore = store
scope.launch {
try {
writes.withLock { selectedStore.removeRecord(record.scopeKey, record.sessionId, record.id) }
} catch (_: IOException) {
// The live selection still suppresses the superseded entry.
}
}
}
private fun update(candidate: ChatActivityRecord) {
val previous = mutableRecords.value.firstOrNull { it.id == candidate.id }
if (previous == candidate) return
val updated = candidate.copy(updatedAt = maxOf(clock(), (previous?.updatedAt ?: -1L) + 1L))
publish(mutableRecords.value.filterNot { it.id == updated.id } + updated)
mutableRecords.value.firstOrNull { it.id == updated.id }?.let(::persist)
}
private fun publish(records: List<ChatActivityRecord>) {
// Logical timestamps can advance within one wall-clock millisecond.
mutableRecords.value = boundChatActivities(records, maxOf(clock(), records.maxOfOrNull { it.updatedAt } ?: 0L))
.sortedWith(compareBy<ChatActivityRecord> { it.createdAt }.thenBy { it.id })
}
private fun persist(record: ChatActivityRecord) {
val selectedStore = store
scope.launch {
try {
writes.withLock { selectedStore.upsert(record) }
} catch (_: IOException) {
// Keep the session's visible metadata when local persistence is temporarily unavailable.
}
}
}
}
private fun identity(vararg parts: String?): String = parts.joinToString("") {
if (it == null) "-1:" else "${it.length}:$it"
}
private fun SubagentActivityPhase.toHistoryPhase(): ChatActivityPhase = when (this) {
SubagentActivityPhase.STARTED, SubagentActivityPhase.THINKING, SubagentActivityPhase.TOOL,
SubagentActivityPhase.PROGRESS -> ChatActivityPhase.RUNNING
SubagentActivityPhase.COMPLETED -> ChatActivityPhase.COMPLETE
SubagentActivityPhase.FAILED -> ChatActivityPhase.FAILED
SubagentActivityPhase.INTERRUPTED -> ChatActivityPhase.CANCELLED
SubagentActivityPhase.ENDED_WITH_PARENT -> ChatActivityPhase.UNKNOWN
}
private fun aggregate(children: List<ChatActivityChild>, taskCount: Int): ChatActivityPhase = when {
children.any { it.phase == ChatActivityPhase.RUNNING } -> ChatActivityPhase.RUNNING
children.isEmpty() || children.size < taskCount || children.any { it.phase == ChatActivityPhase.UNKNOWN } -> ChatActivityPhase.UNKNOWN
children.any { it.phase == ChatActivityPhase.FAILED } -> ChatActivityPhase.FAILED
children.any { it.phase == ChatActivityPhase.CANCELLED } -> ChatActivityPhase.CANCELLED
else -> ChatActivityPhase.COMPLETE
}
private fun ChatActivityChild.matches(activity: SubagentActivity): Boolean =
(!activity.subagentId.isNullOrBlank() && id == activity.subagentId) ||
(!activity.childSessionId.isNullOrBlank() && childSessionId == activity.childSessionId)
private fun ChatActivityRecord.isFallback(): Boolean = kind == ChatActivityKind.SUBAGENTS &&
(id == identity("subagents", "subagent", sourceId) || id == identity("subagents", "session", sourceId))
private fun mergeChildren(old: List<ChatActivityChild>, fresh: List<ChatActivityChild>): List<ChatActivityChild> {
val merged = old.associateByTo(linkedMapOf(), ChatActivityChild::id)
fresh.forEach { child ->
val alias = merged.values.firstOrNull {
it.id == child.id || (child.childSessionId != null && it.childSessionId == child.childSessionId)
}
if (alias != null && alias.id != child.id) merged.remove(alias.id)
merged[child.id] = child
}
return merged.values.toList()
}
private fun GatewayProcess.toHistoryPhase(): ChatActivityPhase = when {
isRunning -> ChatActivityPhase.RUNNING
status.lowercase() in setOf("cancelled", "canceled", "killed", "interrupted") -> ChatActivityPhase.CANCELLED
status.equals("failed", ignoreCase = true) || (exitCode != null && exitCode != 0) -> ChatActivityPhase.FAILED
exitCode == 0 -> ChatActivityPhase.COMPLETE
status.lowercase() in setOf("completed", "complete", "exited", "finished", "done") -> ChatActivityPhase.COMPLETE
else -> ChatActivityPhase.UNKNOWN
}
@@ -0,0 +1,43 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.network.upstream.GatewayProcess
internal data class RetainedChatActivityPreview(
val record: ChatActivityRecord,
val processes: List<GatewayProcess> = emptyList(),
)
/** Historical rows contain references and bounded summaries, never an invented live transcript. */
internal fun ChatActivityRecord.previewActivities(): List<SubagentActivity> = children.mapIndexed { index, child ->
val phase = when (child.phase) {
ChatActivityPhase.RUNNING -> SubagentActivityPhase.STARTED
ChatActivityPhase.COMPLETE -> SubagentActivityPhase.COMPLETED
ChatActivityPhase.FAILED -> SubagentActivityPhase.FAILED
ChatActivityPhase.CANCELLED -> SubagentActivityPhase.INTERRUPTED
ChatActivityPhase.UNKNOWN -> SubagentActivityPhase.ENDED_WITH_PARENT
}
SubagentActivity(
laneId = 0,
turnId = "record:$id:${child.id}",
taskIndex = index,
taskCount = maxOf(taskCount, children.size),
goal = child.goal,
subagentId = child.id,
childSessionId = child.childSessionId,
profile = AgentDisplay.parseProfileContextKey(scopeKey)?.requestProfileName,
phase = phase,
summary = child.summary,
events = child.summary?.let { summary -> listOf(SubagentActivityEvent(
sequence = 0,
kind = SubagentActivityEventKind.COMPLETED,
text = summary,
phase = phase,
observedAtMillis = updatedAt,
)) }.orEmpty(),
partialAfterGap = true,
revision = updatedAt,
)
}
@@ -94,6 +94,10 @@ import com.hermesandroid.relay.network.upstream.GatewayModelOptions
import com.hermesandroid.relay.network.upstream.GatewayProcess
import com.hermesandroid.relay.network.upstream.GatewayProcessCapability
import com.hermesandroid.relay.network.upstream.GatewayProcessEvent
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityStore
import com.hermesandroid.relay.data.DataStoreChatActivityStore
import com.hermesandroid.relay.network.upstream.GatewaySessionModel
import com.hermesandroid.relay.network.upstream.ReasoningEffortAvailability
import com.hermesandroid.relay.network.upstream.ReasoningEffortIdentity
@@ -2235,6 +2239,11 @@ class ChatViewModel : ViewModel() {
private var gatewayProcessSource: GatewayProcessSource? = null
private val gatewayProcessController = GatewayProcessController(viewModelScope)
private val subagentActivityController = SubagentActivityController()
private val chatActivityController = ChatActivityController(viewModelScope)
internal val activityRecords = chatActivityController.records
private var activityStoreInitialized = false
private val _retainedActivityPreview = MutableStateFlow<RetainedChatActivityPreview?>(null)
internal val retainedActivityPreview = _retainedActivityPreview.asStateFlow()
private val subagentChildPreviewController = SubagentChildPreviewController(viewModelScope)
internal val subagentChildPreview: StateFlow<SubagentChildPreview?> =
subagentChildPreviewController.state
@@ -2254,7 +2263,10 @@ class ChatViewModel : ViewModel() {
subagentActivityController.activities
fun openSubagentChildPreview(activityKey: String) {
val activity = subagentActivities.value.firstOrNull { it.stableKey == activityKey } ?: return
if (supervisedModePolicy.enabled) return
val history = _retainedActivityPreview.value
val candidates = history?.record?.previewActivities() ?: subagentActivities.value
val activity = candidates.firstOrNull { it.stableKey == activityKey } ?: return
val parentSessionId = chatHandler?.currentSessionId?.value ?: return
val parentScopeKey = activeProfileContextKey
val client = gatewayClient
@@ -2276,6 +2288,49 @@ class ChatViewModel : ViewModel() {
subagentChildPreviewController.close()
}
internal fun setChatActivityStore(store: ChatActivityStore) {
activityStoreInitialized = true
chatActivityController.bindStore(store)
}
fun openCurrentActivityPreview() {
_retainedActivityPreview.value = null
closeSubagentChildPreview()
subagentActivityController.setPreviewOpen(true)
}
internal fun openRetainedActivity(record: ChatActivityRecord, processDetail: String? = null): Boolean {
if (record.scopeKey != activeProfileContextKey || record.sessionId != chatHandler?.currentSessionId?.value) return false
if (supervisedModePolicy.enabled && !supervisedModePolicy.visibility.resolved().showWorkingStatus) return false
closeSubagentChildPreview()
subagentActivityController.setPreviewOpen(false)
val process = if (record.kind == ChatActivityKind.PROCESS) {
val exact = backgroundProcesses.value.singleOrNull {
!supervisedModePolicy.enabled && record.processStartedAt != null &&
it.id == record.processId && it.startedAt == record.processStartedAt
}
exact ?: GatewayProcess(
id = record.processId ?: record.sourceId,
command = if (supervisedModePolicy.enabled) {
appContext?.getString(R.string.chat_activity_receipt_process) ?: "Background command"
} else record.title,
status = record.phase.name.lowercase(),
outputTail = if (supervisedModePolicy.enabled) null else processDetail?.take(4_000)
?: appContext?.getString(R.string.chat_activity_output_unavailable)
?: "Output is no longer available. This entry preserves the recorded process status.",
exitCode = record.exitCode,
)
} else null
_retainedActivityPreview.value = RetainedChatActivityPreview(record, listOfNotNull(process))
return true
}
fun closeActivityPreview() {
closeSubagentChildPreview()
subagentActivityController.setPreviewOpen(false)
_retainedActivityPreview.value = null
}
private val _messageReactionsSupported = MutableStateFlow(true)
val messageReactionsSupported: StateFlow<Boolean> = _messageReactionsSupported.asStateFlow()
@@ -2691,10 +2746,12 @@ class ChatViewModel : ViewModel() {
previousClient?.setColdPrewarmSessionReadyListener(null)
previousClient?.setUnmatchedTurnCompleteListener(null)
previousClient?.setBackgroundInteractionListener(null)
previousClient?.setSubagentEventListener(null)
previousClient?.setSessionDirectoryInvalidationListener(null)
}
gatewayClient = client
if (changed) {
chatActivityController.markUnavailable()
dismissChatFailure()
resetApprovalModeState()
_messageReactionsSupported.value = true
@@ -2737,6 +2794,29 @@ class ChatViewModel : ViewModel() {
client?.setUnsolicitedTurnProvider { storedSessionId ->
createGatewayInboundTurnRegistration(client, storedSessionId)
}
client?.setSubagentEventListener { sessionId, profile, event ->
if (gatewayClient === client && streamingEndpoint == "gateway" &&
chatHandler?.currentSessionId?.value == sessionId &&
currentSessionProfileName() == profile
) {
val owner = AgentDisplay.parseProfileContextKey(activeProfileContextKey)
val checkpointProfileKey = activeTurnCheckpointSeed?.takeIf {
it.sessionId == sessionId && it.contextKey == activeProfileContextKey
}?.profileKey
val previousRevisions = subagentActivities.value.associate { it.stableKey to it.revision }
subagentActivityController.onSessionEvent(
sessionId, activeProfileContextKey, event,
when {
checkpointProfileKey != null -> AgentDisplay.profileRequestName(checkpointProfileKey)
owner != null -> owner.requestProfileName
else -> profile
},
)
chatActivityController.captureSubagents(subagentActivities.value.filter {
previousRevisions[it.stableKey] != it.revision
})
}
}
client?.setSessionDirectoryInvalidationListener {
// Gateway emits this only after durable session state changes. It
// is also a useful liveness edge after a Dashboard timeout: retry
@@ -2826,6 +2906,8 @@ class ChatViewModel : ViewModel() {
if (changed) {
gatewayStateSyncJob?.cancel()
gatewayStateSyncJob = null
_gatewayPreparingSessionId.value = null
_gatewaySocketState.value = GatewayConnectionState.Idle
client?.let { startGatewayStateSync(it) }
if (client != null) {
gatewayVisibleReattachJob = viewModelScope.launch {
@@ -2969,7 +3051,6 @@ class ChatViewModel : ViewModel() {
): GatewayInboundTurnRegistration? {
val handler = chatHandler ?: return null
val eventScopeKey = activeProfileContextKey
val eventProfile = currentSessionProfileName()
fun matchesAdmissionContext(): Boolean =
gatewayClient === client &&
streamingEndpoint == "gateway" &&
@@ -3128,13 +3209,6 @@ class ChatViewModel : ViewModel() {
},
onSubagentEvent = { event ->
if (acceptsEvent()) {
subagentActivityController.onEvent(
sessionId = storedSessionId,
eventScopeKey = eventScopeKey,
turnId = messageId,
event = event,
profile = eventProfile,
)
handler.onSubagentEvent(messageId, event)
}
},
@@ -3873,6 +3947,10 @@ class ChatViewModel : ViewModel() {
sessionId: String?,
scopeKey: String? = activeProfileContextKey,
) {
_retainedActivityPreview.value?.record?.let {
if (it.sessionId != sessionId || it.scopeKey != scopeKey) closeActivityPreview()
}
chatActivityController.selectSession(scopeKey, sessionId)
subagentChildPreview.value?.let { preview ->
if (preview.parentSessionId != sessionId || preview.parentScopeKey != scopeKey) {
closeSubagentChildPreview()
@@ -4136,6 +4214,10 @@ class ChatViewModel : ViewModel() {
}
private var gatewayStateSyncJob: Job? = null
private val _gatewayPreparingSessionId = MutableStateFlow<String?>(null)
val gatewayPreparingSessionId = _gatewayPreparingSessionId.asStateFlow()
private val _gatewaySocketState = MutableStateFlow(GatewayConnectionState.Idle)
val gatewaySocketState = _gatewaySocketState.asStateFlow()
/**
* Last credential_warning already surfaced as a system notice, so the
@@ -4158,7 +4240,18 @@ class ChatViewModel : ViewModel() {
private fun startGatewayStateSync(client: GatewayChatClient) {
gatewayStateSyncJob?.cancel()
lastSurfacedCredentialWarning = null
gatewayProcessController.setSnapshotListener { processes ->
val sessionId = chatHandler?.currentSessionId?.value
if (gatewayClient === client && sessionId != null &&
gatewayProcessController.ownsSnapshot(sessionId, activeProfileContextKey)
) chatActivityController.captureProcesses(processes)
}
gatewayStateSyncJob = viewModelScope.launch {
launch {
client.preparingSessionId.collect {
if (gatewayClient === client) _gatewayPreparingSessionId.value = it
}
}
launch {
backgroundProcesses.collect {
if (gatewayClient !== client) return@collect
@@ -4172,6 +4265,10 @@ class ChatViewModel : ViewModel() {
launch {
client.connectionState.collect { state ->
if (gatewayClient !== client) return@collect
if (_gatewaySocketState.value == GatewayConnectionState.Ready && state != GatewayConnectionState.Ready) {
chatActivityController.markUnavailable()
}
_gatewaySocketState.value = state
subagentActivityController.onConnectionReady(
state == com.hermesandroid.relay.network.upstream.GatewayConnectionState.Ready,
)
@@ -4662,6 +4759,7 @@ class ChatViewModel : ViewModel() {
dashboardMediaClientProvider: () -> DashboardApiClient? = { null },
) {
this.appContext = context.applicationContext
if (!activityStoreInitialized) setChatActivityStore(DataStoreChatActivityStore(context.applicationContext))
if (chatTurnCheckpointStore == null) {
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
}
@@ -4689,6 +4787,7 @@ class ChatViewModel : ViewModel() {
/** Route-owned Gateway chat setup without borrowing the active connection's Relay/media clients. */
fun initializeGatewayOnly(context: Context) {
appContext = context.applicationContext
if (!activityStoreInitialized) setChatActivityStore(DataStoreChatActivityStore(context.applicationContext))
if (chatTurnCheckpointStore == null) {
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
}
@@ -6159,6 +6258,7 @@ class ChatViewModel : ViewModel() {
} else {
client?.deleteSession(sessionId) == true
}
if (success && contextKey != null) chatActivityController.removeSession(contextKey, sessionId)
if (
activeProfileContextKey != contextKey ||
currentSessionProfileName() != profileName
@@ -8121,18 +8221,6 @@ class ChatViewModel : ViewModel() {
},
onSubagentEvent = { event ->
if (owns()) {
subagentActivityController.onEvent(
sessionId = checkpoint.sessionId,
eventScopeKey = checkpoint.contextKey,
turnId = messageId,
event = event,
profile = if (checkpoint.profileKey != null) {
AgentDisplay.profileRequestName(checkpoint.profileKey)
} else {
AgentDisplay.parseProfileContextKey(checkpoint.contextKey)
?.requestProfileName
},
)
handler.onSubagentEvent(messageId, event)
scheduleCheckpointWrite(immediate = true)
}
@@ -10664,13 +10752,6 @@ class ChatViewModel : ViewModel() {
onSubagentEvent = { event ->
ensurePostInterimMessage()
streamDeltas.flushNow()
subagentActivityController.onEvent(
sessionId = handler.currentSessionId.value,
eventScopeKey = activeProfileContextKey,
turnId = currentMessageId,
event = event,
profile = currentSessionProfileName(),
)
handler.onSubagentEvent(currentMessageId, event)
scheduleCheckpointWrite(immediate = true)
},
@@ -10831,6 +10912,10 @@ class ChatViewModel : ViewModel() {
fun cancelStream() {
intentionallyCancelled = true
if (activeStream != null) {
subagentActivityController.interrupt()
chatActivityController.captureSubagents(subagentActivities.value)
}
currentQueueDestination()?.let { destination ->
if (queuedMessageItems.any { it.contextKey to it.sessionId == destination }) {
pausedQueueDestinations += destination
@@ -11366,9 +11451,8 @@ class ChatViewModel : ViewModel() {
)
}
} catch (e: Exception) {
// Classifier produces a specific label (disk full, bad
// URI, permission, …) for both the in-card text and the
// global snackbar — same event, two surfaces.
// Attachment owns failure and retry. History hydration can
// fetch many files; never queue a global popup per file.
val human = classifyError(e, context = "media_fetch", ctx = appContext)
updateAttachmentByToken(handler, messageId, fetchKey, expectedRole = expectedRole) { att ->
att.copy(
@@ -11376,7 +11460,6 @@ class ChatViewModel : ViewModel() {
errorMessage = human.body
)
}
_errorEvents.tryEmit(human)
}
},
onFailure = { err ->
@@ -11396,7 +11479,6 @@ class ChatViewModel : ViewModel() {
errorMessage = human.body
)
}
_errorEvents.tryEmit(human)
}
)
}
@@ -11661,6 +11743,7 @@ class ChatViewModel : ViewModel() {
gatewayClient?.setColdPrewarmSessionReadyListener(null)
gatewayClient?.setUnmatchedTurnCompleteListener(null)
gatewayClient?.setBackgroundInteractionListener(null)
gatewayClient?.setSubagentEventListener(null)
backgroundPendingInteractions.clear()
backgroundNeedsInputKeys.clear()
publishBackgroundSessionActivity()
@@ -62,6 +62,10 @@ internal class GatewayProcessController(
private var generation = 0L
private var refreshSequence = 0L
private var allProcesses: List<GatewayProcess> = emptyList()
private var snapshotListener: ((List<GatewayProcess>) -> Unit)? = null
/** Only successful process.list results, never cached output-tail changes. */
fun setSnapshotListener(listener: ((List<GatewayProcess>) -> Unit)?) { snapshotListener = listener }
/** A dismissal applies only to this concrete process identity. */
private val dismissedIdentities = mutableMapOf<String, ProcessIdentity>()
@@ -179,6 +183,7 @@ internal class GatewayProcessController(
}
fun close() {
snapshotListener = null
source?.setEventListener(null)
source = null
capabilityJob?.cancel()
@@ -229,6 +234,7 @@ internal class GatewayProcessController(
allProcesses = coalesced
publishVisibleSnapshot()
updatePoller()
snapshotListener?.invoke(coalesced)
}
private fun publishVisibleSnapshot() {
@@ -30,9 +30,8 @@ internal data class SubagentActivityEvent(
/**
* A bounded, ephemeral projection of parent-session `subagent.*` events.
*
* This is intentionally not a child transcript. Upstream currently exposes no
* durable child-session key or child-history route, so the projection is owned
* by the exact profile-scoped parent session and parent turn that emitted it.
* The owning profile/session outlives individual parent turns. Child identity
* keeps detached work visible until its own terminal event arrives.
*/
internal data class SubagentActivity(
val laneId: Long,
@@ -41,6 +40,7 @@ internal data class SubagentActivity(
val taskCount: Int,
val goal: String,
val subagentId: String? = null,
val delegationId: String? = null,
val childSessionId: String? = null,
val parentId: String? = null,
val depth: Int? = null,
@@ -91,6 +91,9 @@ internal class SubagentActivityController(
private var laneSequence = 0L
private var connectionWasReady = false
private var pendingGap = false
private var interrupted = false
private var previewOpen = false
private val retiredChildIds = linkedSetOf<String>()
fun selectSession(sessionId: String?, newScopeKey: String?) {
if (storedSessionId == sessionId && scopeKey == newScopeKey) return
@@ -101,10 +104,16 @@ internal class SubagentActivityController(
laneSequence = 0L
connectionWasReady = false
pendingGap = false
interrupted = false
previewOpen = false
retiredChildIds.clear()
_activities.value = emptyList()
}
fun resetConnection() {
interrupted = false
previewOpen = false
retiredChildIds.clear()
activeTurnId = null
sequence = 0L
laneSequence = 0L
@@ -133,9 +142,34 @@ internal class SubagentActivityController(
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
if (activeTurnId == turnId) return
activeTurnId = turnId
sequence = 0L
laneSequence = 0L
_activities.value = emptyList()
interrupted = false
_activities.value.filter { it.isTerminal }.forEach { activity ->
activity.subagentId?.let(retiredChildIds::add)
activity.childSessionId?.let(retiredChildIds::add)
}
while (retiredChildIds.size > 256) retiredChildIds.remove(retiredChildIds.first())
val visibleTerminalKeys = _activities.value.filter { it.isTerminal }.takeLast(32)
.mapTo(HashSet()) { it.stableKey }
_activities.value = _activities.value.filter {
(previewOpen && it.stableKey in visibleTerminalKeys) ||
(!it.isTerminal && (!it.subagentId.isNullOrBlank() || !it.childSessionId.isNullOrBlank()))
}
}
fun setPreviewOpen(open: Boolean) {
previewOpen = open
if (!open) _activities.value = _activities.value.filterNot { it.isTerminal }
}
fun onSessionEvent(sessionId: String, eventScopeKey: String?, event: GatewaySubagentEvent, profile: String?) {
onEvent(sessionId, eventScopeKey, activeTurnId ?: "session", event, profile)
}
fun interrupt() {
interrupted = true
_activities.value = _activities.value.map {
if (it.isTerminal) it else it.copy(phase = SubagentActivityPhase.INTERRUPTED, revision = it.revision + 1)
}
}
fun onEvent(
@@ -146,18 +180,21 @@ internal class SubagentActivityController(
profile: String? = null,
) {
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
if (activeTurnId != turnId) return
if (interrupted) return
val taskIndex = event.taskIndex.coerceAtLeast(0)
val eventIdentity = event.subagentId?.takeIf(String::isNotBlank)
?: event.childSessionId?.takeIf(String::isNotBlank)
if (eventIdentity != null && eventIdentity in retiredChildIds) return
val identityMatch = eventIdentity?.let { identity ->
_activities.value.firstOrNull {
it.subagentId == identity || it.childSessionId == identity
}
}
val compatibleIndexMatches = _activities.value.filter { activity ->
activity.taskIndex == taskIndex &&
activity.turnId == turnId && activity.taskIndex == taskIndex &&
(event.delegationId.isNullOrBlank() || activity.delegationId.isNullOrBlank() ||
event.delegationId == activity.delegationId) &&
(event.subagentId.isNullOrBlank() || activity.subagentId.isNullOrBlank() ||
event.subagentId == activity.subagentId) &&
(event.childSessionId.isNullOrBlank() || activity.childSessionId.isNullOrBlank() ||
@@ -167,13 +204,14 @@ internal class SubagentActivityController(
(event.depth == null || activity.depth == null || event.depth == activity.depth)
}
val current = identityMatch ?: compatibleIndexMatches.singleOrNull()
if (
current?.isTerminal == true &&
event.phase != GatewaySubagentEvent.Phase.SPAWN_REQUESTED &&
if (activeTurnId != turnId && identityMatch == null && eventIdentity == null) return
if (current?.isTerminal == true) return
// Progress and completion cannot invent children when their start was missed.
if (current == null && event.phase != GatewaySubagentEvent.Phase.SPAWN_REQUESTED &&
event.phase != GatewaySubagentEvent.Phase.START
) return
val base = if (current?.isTerminal == true) null else current
val base = current
val phase = event.toActivityPhase()
val goal = sanitize(event.goal, MAX_GOAL_CHARS)
val preview = sanitize(event.preview, MAX_EVENT_TEXT_CHARS).ifBlank { null }
@@ -206,16 +244,17 @@ internal class SubagentActivityController(
val (boundedEvents, truncated) = boundEvents(appended)
val next = SubagentActivity(
laneId = base?.laneId ?: laneSequence++,
turnId = turnId,
turnId = base?.turnId ?: turnId,
taskIndex = taskIndex,
taskCount = maxOf(1, event.taskCount, base?.taskCount ?: 1),
goal = goal.ifBlank { base?.goal.orEmpty() },
subagentId = event.subagentId?.takeIf(String::isNotBlank) ?: base?.subagentId,
delegationId = event.delegationId?.takeIf(String::isNotBlank) ?: base?.delegationId,
childSessionId = event.childSessionId?.takeIf(String::isNotBlank) ?: base?.childSessionId,
parentId = event.parentId?.takeIf(String::isNotBlank) ?: base?.parentId,
depth = event.depth ?: base?.depth,
model = event.model?.takeIf(String::isNotBlank) ?: base?.model,
profile = profile?.takeIf(String::isNotBlank) ?: base?.profile,
profile = if (base != null) base.profile else profile?.takeIf(String::isNotBlank),
phase = phase,
summary = summary ?: base?.summary,
durationSeconds = event.durationSeconds ?: base?.durationSeconds,
@@ -231,7 +270,9 @@ internal class SubagentActivityController(
fun endTurn(turnId: String) {
if (activeTurnId != turnId) return
_activities.value = _activities.value.map { activity ->
if (activity.isTerminal) activity else activity.copy(
if (activity.isTerminal || !activity.subagentId.isNullOrBlank() ||
!activity.childSessionId.isNullOrBlank()
) activity else activity.copy(
phase = SubagentActivityPhase.ENDED_WITH_PARENT,
partialAfterGap = true,
revision = activity.revision + 1,
@@ -261,9 +302,9 @@ private fun GatewaySubagentEvent.toActivityPhase(): SubagentActivityPhase = when
GatewaySubagentEvent.Phase.TOOL -> SubagentActivityPhase.TOOL
GatewaySubagentEvent.Phase.PROGRESS -> SubagentActivityPhase.PROGRESS
GatewaySubagentEvent.Phase.COMPLETE -> when (status?.trim()?.lowercase()) {
"failed", "error" -> SubagentActivityPhase.FAILED
"completed", "complete" -> SubagentActivityPhase.COMPLETED
"interrupted", "cancelled", "canceled" -> SubagentActivityPhase.INTERRUPTED
else -> SubagentActivityPhase.COMPLETED
else -> SubagentActivityPhase.FAILED
}
}
@@ -4,7 +4,8 @@ import android.app.Application
import android.content.Context
import android.os.SystemClock
import android.util.Log
import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.hermesandroid.relay.R
@@ -2639,28 +2640,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// route is unavailable. Runs outside the turn state machine so it
// doesn't disturb uiState.
//
// Three toasts so the user knows what's happening: "Testing voice…" on
// trigger, "Voice test successful" on completion, "Voice test failed" on
// any error. The trigger toast is held in [triggerToast] so it can be
// cancelled the moment the result toast fires — without that the two
// would briefly overlap on screen. viewModelScope.launch defaults to
// Main.immediate so Toast.show() is safe inline without a dispatcher
// switch.
// Keyed progress feedback is cleared before an outcome replaces it.
// Classified failures retain their existing error event/overlay flow.
fun testVoice(
sample: String = "Hello, this is Hermes. Voice mode is working.",
onResult: (Result<Unit>) -> Unit = {},
) {
val app = getApplication<Application>()
val audioClient = voiceAudioClient
val relayClient = voiceClient
val p = player
if (audioClient == null || p == null) {
onResult(Result.failure(IllegalStateException("Voice pipeline not initialized")))
Toast.makeText(app, "Voice test failed: pipeline not initialized", Toast.LENGTH_SHORT).show()
UiMessageBus.error("Voice test failed: pipeline not initialized")
setError("Voice pipeline not initialized")
return
}
val triggerToast = Toast.makeText(app, "Testing voice…", Toast.LENGTH_SHORT).also { it.show() }
val feedbackKey = "voice-test-${System.nanoTime()}"
UiMessageBus.post("Testing voice…", severity = UiMessageSeverity.Status, ttlMillis = 0L, key = feedbackKey)
viewModelScope.launch {
val profileAwareResult = if (audioClient.route == VoiceAudioRoute.Relay && relayClient != null) {
testVoiceViaVoiceOutput(relayClient, sample)
@@ -2669,9 +2665,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
val result = if (profileAwareResult != null) {
if (profileAwareResult.isSuccess) {
triggerToast.cancel()
UiMessageBus.clear(feedbackKey)
onResult(Result.success(Unit))
Toast.makeText(app, "Voice test successful", Toast.LENGTH_SHORT).show()
UiMessageBus.success("Voice test successful")
return@launch
}
Log.w(TAG, "profile-aware voice test failed; falling back to legacy synthesize: ${profileAwareResult.exceptionOrNull()?.message}")
@@ -2680,34 +2676,35 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
audioClient.synthesize(sample)
}
if (result.isFailure) {
triggerToast.cancel()
UiMessageBus.clear(feedbackKey)
val msg = result.exceptionOrNull()?.message ?: "synthesize failed"
onResult(Result.failure(result.exceptionOrNull() ?: IllegalStateException(msg)))
Toast.makeText(app, "Voice test failed: $msg", Toast.LENGTH_LONG).show()
surfaceError(result.exceptionOrNull(), context = "synthesize")
return@launch
}
val file = result.getOrNull()
if (file == null) {
triggerToast.cancel()
UiMessageBus.clear(feedbackKey)
onResult(Result.failure(IllegalStateException("No audio returned")))
Toast.makeText(app, "Voice test failed: no audio returned", Toast.LENGTH_LONG).show()
UiMessageBus.error("Voice test failed: no audio returned")
return@launch
}
trackTtsFile(file)
try {
p.play(file)
p.awaitCompletion()
triggerToast.cancel()
UiMessageBus.clear(feedbackKey)
onResult(Result.success(Unit))
Toast.makeText(app, "Voice test successful", Toast.LENGTH_SHORT).show()
UiMessageBus.success("Voice test successful")
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
Log.w(TAG, "test playback failed: ${e.message}")
triggerToast.cancel()
UiMessageBus.clear(feedbackKey)
onResult(Result.failure(e))
Toast.makeText(app, "Voice test failed: ${e.message ?: "playback error"}", Toast.LENGTH_LONG).show()
UiMessageBus.error("Voice test failed: ${e.message ?: "playback error"}")
}
}
}.invokeOnCompletion { UiMessageBus.clear(feedbackKey) }
}
/**
@@ -2930,17 +2927,16 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
sample: String = "Say a short confirmation that Hermes Realtime Agent is working.",
onResult: (Result<Unit>) -> Unit = {},
) {
val app = getApplication<Application>()
val client = voiceClient
val pcmPlayer = realtimePcmPlayer
if (client == null || pcmPlayer == null) {
onResult(Result.failure(IllegalStateException("Voice pipeline not initialized")))
Toast.makeText(app, "Realtime test failed: pipeline not initialized", Toast.LENGTH_SHORT).show()
UiMessageBus.error("Realtime test failed: pipeline not initialized")
setError("Voice pipeline not initialized")
return
}
val triggerToast = Toast.makeText(app, "Testing Realtime Agent...", Toast.LENGTH_SHORT)
.also { it.show() }
val feedbackKey = "realtime-test-${System.nanoTime()}"
UiMessageBus.post("Testing Realtime Agent...", severity = UiMessageSeverity.Status, ttlMillis = 0L, key = feedbackKey)
viewModelScope.launch {
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
@@ -2970,12 +2966,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
audioBytes.addAndGet(audio.size)
pcmPlayer.write(audio, rate)
}
triggerToast.cancel()
UiMessageBus.clear(feedbackKey)
if (result.isFailure) {
pcmPlayer.stop()
val msg = result.exceptionOrNull()?.message ?: "realtime agent failed"
onResult(Result.failure(result.exceptionOrNull() ?: IllegalStateException(msg)))
Toast.makeText(app, "Realtime test failed: $msg", Toast.LENGTH_LONG).show()
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Error,
@@ -2988,7 +2983,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
if (audioBytes.get() <= 0) {
pcmPlayer.stop()
onResult(Result.failure(IllegalStateException("Provider returned no audio")))
Toast.makeText(app, "Realtime test failed: no audio returned", Toast.LENGTH_LONG).show()
UiMessageBus.error("Realtime test failed: no audio returned")
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Error,
@@ -3002,14 +2997,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
delay(drainMs)
pcmPlayer.stop()
onResult(Result.success(Unit))
Toast.makeText(app, "Realtime test successful", Toast.LENGTH_SHORT).show()
UiMessageBus.success("Realtime test successful")
DiagnosticsLog.record(
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Info,
title = getApplication<Application>().getString(R.string.voice_status_test_complete),
detail = "${audioBytes.get()} bytes streamed",
)
}
}.invokeOnCompletion { UiMessageBus.clear(feedbackKey) }
}
private suspend fun testVoiceViaVoiceOutput(
@@ -6673,7 +6668,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update {
voiceNoSpeechState(it)
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
UiMessageBus.warning(message)
}
/**
@@ -4429,4 +4429,14 @@
<string name="chat_queue_paused">Fila pausada</string>
<string name="chat_queue_resume">Retomar</string>
<string name="chat_queue_remove">Remover mensagem da fila</string>
<string name="chat_activity_receipt_subagents">Subagentes</string>
<string name="chat_activity_receipt_process">Comando em segundo plano</string>
<string name="chat_activity_receipt_view_activity">Ver atividade</string>
<string name="chat_activity_receipt_view_output">Ver saída</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">Atividade do chat</string>
<string name="chat_activity_history_empty">A atividade detalhada não foi mantida neste dispositivo. O registro de conclusão permanece no chat.</string>
<string name="tool_progress_status_dispatched">Enviado</string>
<string name="chat_activity_history_notice">Atividade registrada. As atualizações de progresso não são mantidas; o histórico disponível dos subagentes pode ser aberto somente para leitura.</string>
<string name="chat_activity_output_unavailable">A saída não está mais disponível. Este registro preserva o estado registrado do processo.</string>
</resources>
@@ -4510,4 +4510,14 @@
<string name="chat_queue_paused">队列已暂停</string>
<string name="chat_queue_resume">继续</string>
<string name="chat_queue_remove">移除队列消息</string>
<string name="chat_activity_receipt_subagents">子代理</string>
<string name="chat_activity_receipt_process">后台命令</string>
<string name="chat_activity_receipt_view_activity">查看活动</string>
<string name="chat_activity_receipt_view_output">查看输出</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">聊天活动</string>
<string name="chat_activity_history_empty">此设备未保留详细活动。完成记录仍保留在聊天中。</string>
<string name="tool_progress_status_dispatched">已分派</string>
<string name="chat_activity_history_notice">已记录的活动。进度更新不会保留;可用的子代理历史记录可以以只读方式打开。</string>
<string name="chat_activity_output_unavailable">输出已不可用。此条目保留了记录的进程状态。</string>
</resources>
+10
View File
@@ -4586,4 +4586,14 @@
<string name="chat_queue_paused">Warteschlange pausiert</string>
<string name="chat_queue_resume">Fortsetzen</string>
<string name="chat_queue_remove">Nachricht aus Warteschlange entfernen</string>
<string name="chat_activity_receipt_subagents">Unteragenten</string>
<string name="chat_activity_receipt_process">Hintergrundbefehl</string>
<string name="chat_activity_receipt_view_activity">Aktivität anzeigen</string>
<string name="chat_activity_receipt_view_output">Ausgabe anzeigen</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">Chat-Aktivität</string>
<string name="chat_activity_history_empty">Detaillierte Aktivitäten wurden auf diesem Gerät nicht gespeichert. Der Abschlusseintrag bleibt im Chat.</string>
<string name="tool_progress_status_dispatched">Übergeben</string>
<string name="chat_activity_history_notice">Gespeicherte Aktivität. Fortschrittsmeldungen werden nicht gespeichert; der verfügbare Verlauf der Unteragenten kann schreibgeschützt geöffnet werden.</string>
<string name="chat_activity_output_unavailable">Die Ausgabe ist nicht mehr verfügbar. Dieser Eintrag enthält den gespeicherten Prozessstatus.</string>
</resources>
+10
View File
@@ -4277,4 +4277,14 @@
<string name="chat_queue_paused">Cola pausada</string>
<string name="chat_queue_resume">Reanudar</string>
<string name="chat_queue_remove">Quitar mensaje de la cola</string>
<string name="chat_activity_receipt_subagents">Subagentes</string>
<string name="chat_activity_receipt_process">Comando en segundo plano</string>
<string name="chat_activity_receipt_view_activity">Ver actividad</string>
<string name="chat_activity_receipt_view_output">Ver salida</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">Actividad del chat</string>
<string name="chat_activity_history_empty">La actividad detallada no se guardó en este dispositivo. La entrada de finalización permanece en el chat.</string>
<string name="tool_progress_status_dispatched">Enviado</string>
<string name="chat_activity_history_notice">Actividad registrada. No se conservan las actualizaciones de progreso; el historial disponible de los subagentes se puede abrir en modo de solo lectura.</string>
<string name="chat_activity_output_unavailable">La salida ya no está disponible. Esta entrada conserva el estado registrado del proceso.</string>
</resources>
+10
View File
@@ -4581,4 +4581,14 @@
<string name="chat_queue_paused">キューを一時停止中</string>
<string name="chat_queue_resume">再開</string>
<string name="chat_queue_remove">キューからメッセージを削除</string>
<string name="chat_activity_receipt_subagents">サブエージェント</string>
<string name="chat_activity_receipt_process">バックグラウンドコマンド</string>
<string name="chat_activity_receipt_view_activity">アクティビティを表示</string>
<string name="chat_activity_receipt_view_output">出力を表示</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">チャットのアクティビティ</string>
<string name="chat_activity_history_empty">詳細なアクティビティはこのデバイスに保存されていません。完了の記録はチャットに残っています。</string>
<string name="tool_progress_status_dispatched">ディスパッチ済み</string>
<string name="chat_activity_history_notice">記録されたアクティビティです。進捗の更新は保存されません。利用可能なサブエージェントの履歴は読み取り専用で開けます。</string>
<string name="chat_activity_output_unavailable">出力は利用できなくなりました。この項目には記録されたプロセスの状態が残っています。</string>
</resources>
+10
View File
@@ -4325,4 +4325,14 @@
<string name="chat_queue_paused">Очередь приостановлена</string>
<string name="chat_queue_resume">Продолжить</string>
<string name="chat_queue_remove">Удалить сообщение из очереди</string>
<string name="chat_activity_receipt_subagents">Субагенты</string>
<string name="chat_activity_receipt_process">Фоновая команда</string>
<string name="chat_activity_receipt_view_activity">Посмотреть активность</string>
<string name="chat_activity_receipt_view_output">Посмотреть вывод</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">Активность чата</string>
<string name="chat_activity_history_empty">Подробная активность не сохранена на этом устройстве. Запись о завершении остаётся в чате.</string>
<string name="tool_progress_status_dispatched">Отправлено</string>
<string name="chat_activity_history_notice">Сохранённая активность. Обновления хода работы не сохраняются; доступную историю субагентов можно открыть только для чтения.</string>
<string name="chat_activity_output_unavailable">Вывод больше недоступен. Эта запись сохраняет зафиксированное состояние процесса.</string>
</resources>
+52
View File
@@ -1,5 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chat_debug_title" translatable="false">Session diagnostics</string>
<string name="chat_debug_open" translatable="false">Open session diagnostics</string>
<string name="chat_debug_close" translatable="false">Close session diagnostics</string>
<string name="chat_debug_preparing" translatable="false">Preparing session…</string>
<string name="chat_debug_sign_in" translatable="false">Dashboard sign-in</string>
<string name="chat_debug_sign_in_needed" translatable="false">Sign in to continue this conversation.</string>
<string name="chat_debug_authenticated" translatable="false">Authenticated for this connection.</string>
<string name="chat_debug_auth_unknown" translatable="false">Sign-in has not been confirmed.</string>
<string name="chat_debug_gateway" translatable="false">Live connection</string>
<string name="chat_debug_socket_ready" translatable="false">Gateway is ready to receive requests.</string>
<string name="chat_debug_ticket" translatable="false">Requesting a secure connection ticket…</string>
<string name="chat_debug_socket_connecting" translatable="false">Opening the Gateway connection…</string>
<string name="chat_debug_socket_waiting" translatable="false">Waiting for the Gateway to become ready…</string>
<string name="chat_debug_socket_idle" translatable="false">No live Gateway connection.</string>
<string name="chat_debug_session" translatable="false">Conversation session</string>
<string name="chat_debug_preparing_detail" translatable="false">Hermes is initializing this session. The message has not been sent yet.</string>
<string name="chat_debug_history_loading" translatable="false">Loading saved messages…</string>
<string name="chat_debug_history_failed" translatable="false">Saved conversations could not be refreshed.</string>
<string name="chat_debug_session_selected" translatable="false">Session selected.</string>
<string name="chat_debug_session_new" translatable="false">A session will be created when you send.</string>
<string name="chat_debug_response" translatable="false">Agent response</string>
<string name="chat_debug_response_failed" translatable="false">The request failed. Details are below.</string>
<string name="chat_debug_response_waiting" translatable="false">Waiting for session initialization.</string>
<string name="chat_debug_response_active" translatable="false">Waiting for the current reply to finish.</string>
<string name="chat_debug_response_idle" translatable="false">No reply is currently in progress.</string>
<string name="chat_debug_init_recovery" translatable="false">Hermes could not initialize the agent. Check the server runtime and logs before retrying; signing in again may not resolve this.</string>
<string name="chat_debug_session_id" translatable="false">Session · %1$s</string>
<string name="chat_debug_connections" translatable="false">Connection settings</string>
<string name="app_name">Hermes-Relay</string>
<string name="app_title">Hermes-Relay</string>
<string name="agent_interface">agent interface</string>
@@ -4602,4 +4630,28 @@
<string name="chat_queue_paused">Queue paused</string>
<string name="chat_queue_resume">Resume</string>
<string name="chat_queue_remove">Remove queued message</string>
<string name="chat_activity_receipt_subagents">Subagents</string>
<string name="chat_activity_receipt_process">Background command</string>
<string name="chat_activity_receipt_view_activity">View activity</string>
<string name="chat_activity_receipt_view_output">View output</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">Chat activity</string>
<string name="chat_activity_history_empty">Detailed activity was not retained on this device. The completion entry remains in chat.</string>
<string name="tool_progress_status_dispatched">Dispatched</string>
<string name="chat_activity_history_notice">Recorded activity. Progress updates are not retained; available child history can be opened read-only.</string>
<string name="chat_activity_output_unavailable">Output is no longer available. This entry preserves the recorded process status.</string>
<string name="dev_message_previews" translatable="false">Message previews</string>
<string name="dev_message_previews_desc" translatable="false">Local-only samples using the app’s real message surfaces. No requests are sent. Samples clear when you leave this screen.</string>
<string name="dev_message_info" translatable="false">Info preview</string>
<string name="dev_message_success" translatable="false">Success preview</string>
<string name="dev_message_progress" translatable="false">Progress preview</string>
<string name="dev_message_warning" translatable="false">Warning preview</string>
<string name="dev_message_error" translatable="false">Error preview</string>
<string name="dev_message_preview_sample" translatable="false">Show a themed sample. Progress stays until replaced or cleared.</string>
<string name="dev_message_action" translatable="false">Actionable error preview</string>
<string name="dev_message_action_body" translatable="false">This is a simulated error with a longer explanation. Expand or scroll to check readability. Retry only confirms the preview action; it does not contact a server.</string>
<string name="dev_message_retry" translatable="false">Retry</string>
<string name="dev_message_retry_result" translatable="false">Preview retry selected — no request sent.</string>
<string name="dev_message_clear" translatable="false">Clear previews</string>
<string name="dev_message_clear_desc" translatable="false">Dismiss only these samples; real app messages are preserved.</string>
</resources>
@@ -0,0 +1,207 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatActivityProjectionTest {
private fun record(
id: String = "record",
sourceId: String = "delegation-a",
phase: ChatActivityPhase = ChatActivityPhase.COMPLETE,
at: Long = 20,
) = ChatActivityRecord(
id, "connection::profile", "session", ChatActivityKind.SUBAGENTS,
sourceId, "Research", phase, 10, at,
children = listOf(ChatActivityChild("child-a", "child-session", phase = phase)),
taskCount = 1,
)
private fun message(id: String, at: Long = 30) =
ChatMessage(id, MessageRole.ASSISTANT, "Reply $id", at)
private fun completion(id: String = "server-row", source: String? = "delegation:delegation-a") =
message(id).copy(
role = MessageRole.SYSTEM,
content = "1 background task completed",
activitySourceId = source,
activityTaskCount = 1,
)
private fun project(messages: List<ChatMessage>, records: List<ChatActivityRecord>) =
projectChatActivityReceipts(messages, records, "connection::profile", "session")
@Test
fun `canonical completion preserves identity and original payload`() {
val row = completion().copy(uiKey = "live-key", rowId = 77)
val result = project(listOf(row), listOf(record())).single()
assertEquals(row, result.copy(activityRecord = null))
assertEquals("record", result.activityRecord?.id)
}
@Test
fun `completion wake replaces synthetic receipt without dropping ordinary messages`() {
val before = project(listOf(message("first", 1)), listOf(record()))
assertEquals(listOf("first", "activity:record"), before.map { it.id })
val after = project(before + completion() + message("wake", 40), listOf(record()))
assertEquals(listOf("first", "server-row", "wake"), after.map { it.id })
assertEquals(after, project(after, listOf(record())))
}
@Test
fun `duplicate group completion rows retain separate identities and live sibling state`() {
val running = record(phase = ChatActivityPhase.RUNNING).copy(
children = listOf(
ChatActivityChild("done", phase = ChatActivityPhase.COMPLETE),
ChatActivityChild("working", phase = ChatActivityPhase.RUNNING),
),
taskCount = 2,
)
val result = project(listOf(completion("group-one"), completion("group-two")), listOf(running))
assertEquals(listOf("group-one", "group-two"), result.map { it.id })
result.forEach {
assertEquals(ChatActivityPhase.RUNNING, it.activityRecord?.phase)
assertEquals(ChatActivityPhase.RUNNING, it.activityRecord?.children?.last()?.phase)
}
}
@Test
fun `missing and unknown delegation IDs never select archive by count or recency`() {
listOf("unavailable:server-row", "delegation:", "delegation:other").forEach { source ->
val result = project(listOf(completion(source = source)), listOf(record()))
val fallback = result.single { it.id == "server-row" }.activityRecord!!
assertTrue(fallback.children.isEmpty())
assertEquals("canonical:server-row", fallback.id)
assertEquals(1, fallback.taskCount)
assertEquals(2, result.size)
}
}
@Test
fun `incomplete metadata exposes unknown detail without fabricated task count`() {
val row = completion(source = "unavailable:server-row").copy(activityTaskCount = null)
val fallback = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(ChatActivityPhase.UNKNOWN, fallback.phase)
assertEquals(0, fallback.taskCount)
assertTrue(fallback.children.isEmpty())
}
@Test
fun `partial failure count never labels every missing child failed`() {
val row = completion().copy(activityTaskCount = 3, activityFailedCount = 1)
val result = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(ChatActivityPhase.UNKNOWN, result.phase)
assertEquals(3, result.taskCount)
assertTrue(result.children.isEmpty())
}
@Test
fun `archive ownership excludes sibling profiles and sessions`() {
val result = project(
listOf(completion()),
listOf(record().copy(scopeKey = "connection::other"), record().copy(sessionId = "other")),
)
assertEquals(1, result.size)
assertTrue(result.single().activityRecord!!.children.isEmpty())
}
@Test
fun `same snapshot identity chooses newest and restored unknown remains unknown`() {
val result = project(emptyList(), listOf(
record(phase = ChatActivityPhase.COMPLETE, at = 15),
record(phase = ChatActivityPhase.UNKNOWN, at = 20),
)).single()
assertEquals(ChatActivityPhase.UNKNOWN, result.activityRecord?.phase)
assertEquals(ChatActivityPhase.UNKNOWN, result.activityRecord?.children?.single()?.phase)
}
@Test
fun `running archive has no synthetic receipt and timestamp merge preserves history order`() {
val rows = listOf(message("first", 40), message("second", 5), message("third", 50))
val result = project(rows, listOf(record(), record(id = "running", phase = ChatActivityPhase.RUNNING)))
assertEquals(listOf("activity:record", "first", "second", "third"), result.map { it.id })
assertEquals(rows, result.filter { !it.clientOnly })
}
private fun processRow() = message("process-row").copy(
role = MessageRole.USER,
content = "[IMPORTANT: Background process proc-1 completed normally (exit code 0).\nCommand: build\nOutput:\nOK]",
)
private fun processRecord(id: String, startedAt: String) = record(id, "proc-1").copy(
kind = ChatActivityKind.PROCESS,
processId = "proc-1",
processStartedAt = startedAt,
children = emptyList(),
)
@Test
fun `exact process ID joins receipt and retains authoritative output in source row`() {
val row = processRow()
val result = project(listOf(row), listOf(processRecord("process", "start-1"))).single()
assertEquals(row, result.copy(activityRecord = null))
assertEquals("process", result.activityRecord?.id)
assertEquals("proc-1", result.activityRecord?.processId)
}
@Test
fun `reused process ID does not choose a generation by timestamps`() {
val result = project(listOf(processRow()), listOf(
processRecord("generation-1", "start-1"),
processRecord("generation-2", "start-2").copy(updatedAt = 25),
))
val fallback = result.single { it.id == "process-row" }.activityRecord!!
assertEquals("canonical:process-row", fallback.id)
assertNull(fallback.processStartedAt)
assertEquals(ChatActivityPhase.COMPLETE, fallback.phase)
assertEquals(0, fallback.exitCode)
}
@Test
fun `canonical process headline supplies terminal outcome without a retained process`() {
listOf(
Triple("completed normally (exit code 0).", ChatActivityPhase.COMPLETE, 0),
Triple("exited (exit code 1).", ChatActivityPhase.FAILED, 1),
Triple("terminated by Hermes (exit code -15, SIGTERM).", ChatActivityPhase.CANCELLED, -15),
).forEach { (status, phase, code) ->
val row = processRow().copy(content = "[IMPORTANT: Background process proc-1 $status\nOutput:\nresult]")
val receipt = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(phase, receipt.phase)
assertEquals(code, receipt.exitCode)
}
}
@Test
fun `watch and malformed headlines cannot borrow exit codes from output`() {
listOf(
"matched watch pattern \"done\".",
"exited (exit code ?).",
"exited (exit code 0). trailing text",
"exited (exit code 99999999999999999999).",
).forEach { status ->
val row = processRow().copy(
content = "[IMPORTANT: Background process proc-1 $status\nOutput:\nBackground process proc-1 completed normally (exit code 0).]",
)
val receipt = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(ChatActivityPhase.UNKNOWN, receipt.phase)
assertNull(receipt.exitCode)
}
}
@Test
fun `ordinary important messages never become activity receipts`() {
val row = message("important").copy(role = MessageRole.USER, content = "[IMPORTANT: Read this carefully]")
assertEquals(listOf(row), project(listOf(row), emptyList()))
}
@Test
fun `missing owner clears previously projected detail and synthetic rows`() {
val projected = project(listOf(completion()), listOf(record()))
val result = projectChatActivityReceipts(projected, listOf(record()), null, "session")
assertEquals(1, result.size)
assertNull(result.single().activityRecord)
assertFalse(result.single().clientOnly)
}
}
@@ -0,0 +1,197 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferencesSerializer
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import okio.Path.Companion.toPath
import okio.FileSystem
class ChatActivityStoreTest {
@get:Rule val tempFolder = TemporaryFolder()
private lateinit var scope: CoroutineScope
private lateinit var dataStore: DataStore<Preferences>
private lateinit var store: DataStoreChatActivityStore
private var now = CHAT_ACTIVITY_MAX_AGE_MS * 2
@Before
fun setUp() {
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Okio performs a real atomic replacement on Windows; File.renameTo cannot replace
// an existing destination there. Android production keeps its shared platform store.
dataStore = PreferenceDataStoreFactory.create(
storage = OkioStorage(FileSystem.SYSTEM, PreferencesSerializer) {
tempFolder.root.resolve("activity.preferences_pb").absolutePath.toPath()
},
scope = scope,
)
store = DataStoreChatActivityStore(dataStore) { now }
}
@After
fun tearDown() = runBlocking { scope.coroutineContext[Job]?.cancelAndJoin(); Unit }
@Test
fun completedMetadataAndReferencesSurviveStoreRecreation() = runTest {
val record = sample().copy(children = listOf(ChatActivityChild(
id = "child", childSessionId = "child-session", goal = "Review", phase = ChatActivityPhase.COMPLETE,
summary = "Reviewed",
)))
store.upsert(record)
val recreated = DataStoreChatActivityStore(dataStore) { now }
assertEquals(listOf(record), recreated.read("scope", "session"))
}
@Test
fun recoveredRunningStateNeverClaimsLiveness() = runTest {
store.upsert(sample().copy(
phase = ChatActivityPhase.RUNNING,
children = listOf(ChatActivityChild("child", phase = ChatActivityPhase.RUNNING)),
))
val recovered = store.read("scope", "session").single()
assertEquals(ChatActivityPhase.UNKNOWN, recovered.phase)
assertEquals(ChatActivityPhase.UNKNOWN, recovered.children.single().phase)
}
@Test
fun exactOwnerKeysDoNotAliasAndRemovalIsScoped() = runTest {
val first = sample().copy(scopeKey = "a::b", sessionId = "c")
val second = sample().copy(scopeKey = "a", sessionId = "b::c")
store.upsert(first)
store.upsert(second)
assertEquals(listOf(first), store.read("a::b", "c"))
store.removeSession("a::b", "c")
assertTrue(store.read("a::b", "c").isEmpty())
assertEquals(listOf(second), store.read("a", "b::c"))
}
@Test
fun concurrentUpsertsPreserveDistinctRecordsAndOlderUpdatesCannotRegressState() = runTest {
coroutineScope { repeat(20) { index -> launch { store.upsert(sample("$index")) } } }
assertEquals(20, store.read("scope", "session").size)
store.upsert(sample("0").copy(updatedAt = now - 1, createdAt = now - 2, phase = ChatActivityPhase.RUNNING))
assertEquals(ChatActivityPhase.COMPLETE, store.read("scope", "session").first { it.id == "0" }.phase)
}
@Test
fun invalidEnvelopeFailsClosedAndMalformedRowsDoNotHideValidSiblings() = runTest {
val key = stringPreferencesKey("chat_activity_records_v1")
for (raw in listOf("{broken", "{\"version\":2,\"records\":[]}", "[]")) {
dataStore.edit { it[key] = raw }
assertTrue(store.read("scope", "session").isEmpty())
}
dataStore.edit { it[key] = "{\"version\":1,\"records\":[{},${Json.encodeToString(sample())}]}" }
assertEquals(listOf(sample()), store.read("scope", "session"))
}
@Test
fun retentionExpiresWithoutRequiringAnotherWrite() = runTest {
store.upsert(sample())
now += CHAT_ACTIVITY_MAX_AGE_MS + 1
assertTrue(store.read("scope", "session").isEmpty())
}
@Test
fun boundedFieldsPreserveExactIdentifiersAndRejectOversizedOwners() = runTest {
store.upsert(sample().copy(title = "t".repeat(500), children = (0..40).map {
ChatActivityChild("child-$it", goal = "g".repeat(2000), summary = "s".repeat(2000))
}))
val record = store.read("scope", "session").single()
assertEquals(160, record.title.length)
assertEquals(32, record.children.size)
assertEquals(512, record.children.first().goal.length)
assertEquals(512, record.children.first().summary?.length)
store.upsert(sample("bad").copy(scopeKey = "x".repeat(2049)))
assertTrue(store.read("x".repeat(2048), "session").isEmpty())
}
@Test
fun totalAndSessionLimitsEvictOldest() {
val session = boundChatActivities((0..40).map {
sample("$it").copy(createdAt = now - 100, updatedAt = now - it)
}, now)
assertEquals(32, session.size)
assertEquals("0", session.first().id)
val total = boundChatActivities((0..150).map {
sample("$it").copy(sessionId = "session-$it", createdAt = now - 200, updatedAt = now - it)
}, now)
assertEquals(128, total.size)
}
@Test
fun inMemoryStoreUsesSameRecoveryAndOwnershipRules() = runTest {
val fake = InMemoryChatActivityStore { now }
fake.upsert(sample().copy(phase = ChatActivityPhase.RUNNING))
assertEquals(ChatActivityPhase.UNKNOWN, fake.read("scope", "session").single().phase)
fake.removeSession("other", "session")
assertEquals(1, fake.read("scope", "session").size)
fake.removeSession("scope", "session")
assertTrue(fake.read("scope", "session").isEmpty())
}
@Test
fun processGenerationReferenceRoundTrips() = runTest {
val process = sample().copy(
kind = ChatActivityKind.PROCESS, processId = "pid-1", processStartedAt = "2026-09-07T12:00:00Z",
exitCode = 0,
)
store.upsert(process)
assertEquals(listOf(process), store.read("scope", "session"))
}
@Test
fun aggregateCountDoesNotInventChildReferencesAndIsBounded() = runTest {
store.upsert(sample().copy(taskCount = 3))
assertEquals(3, store.read("scope", "session").single().taskCount)
assertTrue(store.read("scope", "session").single().children.isEmpty())
store.upsert(sample().copy(taskCount = Int.MAX_VALUE))
assertEquals(10_000, store.read("scope", "session").single().taskCount)
store.upsert(sample().copy(taskCount = -1))
assertEquals(0, store.read("scope", "session").single().taskCount)
}
@Test
fun shortLogicalClockSkewIsAcceptedButFarFutureRecordsAreRejected() = runTest {
store.upsert(sample().copy(updatedAt = now + 1))
assertEquals(now + 1, store.read("scope", "session").single().updatedAt)
store.upsert(sample("future").copy(updatedAt = now + 300_001))
assertEquals(1, store.read("scope", "session").size)
}
@Test
fun removeRecordKeepsSiblingAndSameIdOtherOwner() = runTest {
store.upsert(sample("a"))
store.upsert(sample("b"))
store.upsert(sample("a").copy(scopeKey = "other"))
store.removeRecord("scope", "session", "a")
assertEquals(listOf("b"), store.read("scope", "session").map { it.id })
assertEquals(listOf("a"), store.read("other", "session").map { it.id })
}
private fun sample(id: String = "activity") = ChatActivityRecord(
id = id, scopeKey = "scope", sessionId = "session", kind = ChatActivityKind.SUBAGENTS,
sourceId = "dispatch", title = "Subagents", phase = ChatActivityPhase.COMPLETE,
createdAt = now - 10, updatedAt = now,
)
}
@@ -2408,6 +2408,27 @@ class GatewayChatClientTest {
assertEquals("focus on Android", (params["text"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `session child listener receives detached updates after parent terminal and rejects foreign frames`() = runBlocking {
val events = LinkedBlockingQueue<GatewaySubagentEvent>()
client.setSubagentEventListener { _, _, event -> events.add(event) }
val recorder = Recorder()
client.sendTurn(null, "delegate", null, recorder.callbacks) { recorder.preflightFailures += it }
val socket = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val payload = buildJsonObject { put("subagent_id", "child"); put("status", "completed") }
socket.send(harness.eventFrame("subagent.start", payload, "live-1"))
assertEquals(GatewaySubagentEvent.Phase.START, events.poll(5, TimeUnit.SECONDS)?.phase)
socket.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "Launched") }, "live-1"))
socket.send(harness.eventFrame("subagent.tool", payload, "foreign"))
socket.send(harness.eventFrame("subagent.tool", payload, null))
socket.send(harness.eventFrame("subagent.progress", payload, "live-1"))
socket.send(harness.eventFrame("subagent.complete", payload, "live-1"))
assertEquals(GatewaySubagentEvent.Phase.PROGRESS, events.poll(5, TimeUnit.SECONDS)?.phase)
assertEquals(GatewaySubagentEvent.Phase.COMPLETE, events.poll(5, TimeUnit.SECONDS)?.phase)
assertTrue(events.isEmpty())
}
@Test
fun `child watch is profile pinned bounded and isolated from main session`() = runBlocking {
harness.sessionProfileOverride = "operator"
@@ -3992,6 +4013,27 @@ class GatewayChatClientTest {
assertTrue(r.preflightFailures.isEmpty())
}
@Test
fun `agent init failure before lazy acknowledgement remains authoritative`() {
harness.suppressAckMethods += "session.create"
val r = Recorder()
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
val ack = harness.awaitPendingAck()
ack.ws.send(harness.eventFrame("error", buildJsonObject {
put("message", "agent init failed: incompatible runtime helper")
}, "live-1"))
harness.releaseAck(ack, buildJsonObject {
put("session_id", "live-1")
put("stored_session_id", "stored-1")
put("profile", "default")
put("info", buildJsonObject { put("lazy", true) })
})
waitUntil { r.preflightFailures.isNotEmpty() }
assertTrue(r.preflightFailures.single().contains("incompatible runtime helper"))
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
assertNull(client.preparingSessionId.value)
}
@Test
fun `lazy fresh session readiness timeout never submits`() {
rebuildClient(sessionReadyTimeoutMs = 50L)
@@ -741,6 +741,29 @@ class GatewayEventMapperTest {
assertNull(event.durationSeconds)
}
@Test
fun `subagent events preserve delegation identity independently of child identity`() {
val phases = listOf("spawn_requested", "start", "thinking", "tool", "progress", "complete")
phases.forEach { phase ->
val event = GatewayEventMapper.parseSubagentEvent(
"subagent.$phase",
obj("""{"delegation_id":"delegation-9","subagent_id":"child-17","child_session_id":"session-17"}"""),
)!!
assertEquals("delegation-9", event.delegationId)
assertEquals("child-17", event.subagentId)
assertEquals("session-17", event.childSessionId)
}
}
@Test
fun `older subagent emitters do not invent a delegation identity`() {
val event = GatewayEventMapper.parseSubagentEvent(
"subagent.complete",
obj("""{"subagent_id":"child-17","task_index":0,"task_count":1}"""),
)!!
assertNull(event.delegationId)
}
// --- Usage translation (tui_gateway key names, not SSE names) ---
@Test
@@ -0,0 +1,141 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.data.ChatActivityChild
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.network.upstream.GatewayProcess
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import com.hermesandroid.relay.ui.components.ToolProgressCard
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xhdpi")
class ChatActivityReceiptScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test
fun completedActivityStaysCompactAndOpensOnlyOnTap() {
var opened = 0
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Surface {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
ToolProgressCard(ToolCall(
name = "delegate_task", args = null, result = "{\"status\":\"dispatched\"}",
success = true, isComplete = true,
))
ChatActivityReceipt(
record(ChatActivityKind.SUBAGENTS, ChatActivityPhase.COMPLETE).copy(
children = listOf(
ChatActivityChild("child-one", phase = ChatActivityPhase.COMPLETE, summary = "Private result"),
ChatActivityChild("child-two", phase = ChatActivityPhase.FAILED),
),
),
onClick = { opened++ },
)
ChatActivityReceipt(record(ChatActivityKind.PROCESS, ChatActivityPhase.UNKNOWN), {})
}
}
}
}
assertEquals(0, opened)
compose.onNodeWithText("Dispatched").assertExists()
compose.onNodeWithText("Subagents · 1 Completed · 1 Failed").assertExists()
compose.onNodeWithText("Private result").assertDoesNotExist()
compose.onNodeWithText("Background command · Final state unavailable").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/chat-activity-receipts.png")
compose.onNodeWithText("View activity").performClick()
assertEquals(1, opened)
}
@Test
fun completionRemovesActiveStripButLeavesTranscriptEntry() {
val running = mutableStateOf(true)
compose.setContent {
HermesRelayTheme {
Column {
GatewayBackgroundProcessStrip(
processes = listOf(GatewayProcess("process", "command", status = if (running.value) "running" else "completed")),
subagentActivities = emptyList(),
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = true,
onClick = {},
)
ChatActivityReceipt(
record(ChatActivityKind.PROCESS, if (running.value) ChatActivityPhase.RUNNING else ChatActivityPhase.COMPLETE),
{},
)
}
}
}
compose.onNodeWithText("Current chat activity").assertExists()
compose.runOnIdle { running.value = false }
compose.onNodeWithText("Current chat activity").assertDoesNotExist()
compose.onNodeWithText("Background command · Completed").assertExists()
}
@Test
fun recordedProcessPreviewOffersNoMutationControls() {
compose.setContent {
HermesRelayTheme {
GatewayBackgroundProcessSheet(
processes = listOf(GatewayProcess("process", "Saved command", status = "completed", exitCode = 0)),
subagentActivities = emptyList(),
subagentChildPreview = null,
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = false,
stoppingProcessIds = emptySet(),
onRefresh = {},
onStop = {},
onDismissProcess = {},
onOpenSubagentChild = {},
onDismiss = {},
readOnlyHistory = true,
historyNotice = "Recorded activity. Output is unavailable.",
)
}
}
compose.onNodeWithText("Chat activity").assertExists()
compose.onNodeWithText("Recorded activity. Output is unavailable.").assertExists()
compose.onNodeWithText("Saved command").assertExists()
compose.onNodeWithText("Stop").assertDoesNotExist()
compose.onNodeWithText("Dismiss").assertDoesNotExist()
}
private fun record(kind: ChatActivityKind, phase: ChatActivityPhase) = ChatActivityRecord(
id = "receipt-${kind.name}",
scopeKey = "scope",
sessionId = "session",
kind = kind,
sourceId = "source",
title = "Activity",
phase = phase,
createdAt = 1L,
updatedAt = 2L,
)
}
@@ -0,0 +1,47 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xhdpi")
class ChatDebugDrawerScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test
fun initializationFailureShowsConfirmedConnectionAndActionableError() {
var closed = false
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
ChatDebugDrawer(
profile = "Server Default", model = "gpt-5.6-sol", sessionId = "session-example",
gateway = true, signedIn = true, signInRequired = false,
socketState = GatewayConnectionState.Ready, preparing = false,
streaming = false, loadingHistory = false, directoryUnavailable = false,
failure = "agent init failed: incompatible runtime helper",
onClose = { closed = true }, onConnections = {},
)
}
}
compose.onNodeWithText("Authenticated for this connection.").assertExists()
compose.onNodeWithText("The request failed. Details are below.").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/chat-debug-drawer.png")
compose.onNodeWithContentDescription("Close session diagnostics").performClick()
assertTrue(closed)
}
}
@@ -0,0 +1,56 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.components.LocalMessageActionHost
import com.hermesandroid.relay.ui.components.MessageBannerHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import com.hermesandroid.relay.ui.screens.MessagePreviewControls
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h1200dp-xhdpi")
class MessagePreviewControlsTest {
@get:Rule val compose = createComposeRule()
@Test
fun previewRetryIsLocalAndClearPreservesRealMessages() {
val host = SnackbarHostState()
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
CompositionLocalProvider(LocalMessageActionHost provides host) {
Surface { Column {
MessageBannerHost(includeStatusBarPadding = false)
MessagePreviewControls()
ThemedMessageHost(host)
} }
}
}
}
compose.onNodeWithContentDescription("Actionable error preview").performClick()
compose.onNodeWithText("Retry").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/message-previews.png")
compose.onNodeWithText("Retry").performClick()
compose.onNodeWithText("Preview retry selected — no request sent.").assertExists()
compose.runOnIdle { UiMessageBus.post("Real app message", ttlMillis = 0L, key = "real-test-message") }
compose.onNodeWithContentDescription("Clear previews").performClick()
compose.onNodeWithText("Real app message").assertExists()
compose.runOnIdle { UiMessageBus.clear("real-test-message") }
}
}
@@ -0,0 +1,44 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.ToolCall
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DetachedDelegationDispatchTest {
@Test
fun completedSuccessfulStructuredDispatchIsRecognized() {
assertTrue(isDetachedDelegationDispatch(call("""{"status":"dispatched"}""")))
assertTrue(isDetachedDelegationDispatch(call("""{"mode":"background"}""")))
}
@Test
fun synchronousDelegationAndUnstructuredTextRemainOrdinaryCompletion() {
listOf(
"""{"status":"completed","results":["done"]}""",
"""{"result":"status=dispatched mode=background"}""",
"dispatched in background",
"""{"status":{"status":"dispatched"}}""",
"""[{"status":"dispatched"}]""",
"{broken",
).forEach { assertFalse(isDetachedDelegationDispatch(call(it))) }
}
@Test
fun failedRunningAndOtherToolCallsAreNeverDispatchSuccess() {
val dispatch = call("""{"status":"dispatched"}""")
assertFalse(isDetachedDelegationDispatch(dispatch.copy(success = false)))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(success = null)))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(isComplete = false)))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(name = "terminal")))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(result = null)))
}
private fun call(result: String) = ToolCall(
name = "delegate_task",
args = null,
result = result,
success = true,
isComplete = true,
)
}
@@ -0,0 +1,29 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.network.upstream.GatewayProcess
import org.junit.Assert.assertEquals
import org.junit.Test
class ProcessDisplayPhaseTest {
@Test
fun unavailableAndCancelledDoNotBecomeSuccessfulWithoutExitCode() {
assertEquals(ProcessDisplayPhase.UNKNOWN, phase("unknown"))
assertEquals(ProcessDisplayPhase.UNKNOWN, phase("unrecognized"))
assertEquals(ProcessDisplayPhase.CANCELLED, phase("cancelled"))
assertEquals(ProcessDisplayPhase.FAILED, phase("failed"))
}
@Test
fun knownLiveAndTerminalStatesRetainTheirMeaning() {
assertEquals(ProcessDisplayPhase.RUNNING, phase("running"))
assertEquals(ProcessDisplayPhase.COMPLETE, phase("complete"))
assertEquals(ProcessDisplayPhase.COMPLETE, phase("completed"))
assertEquals(ProcessDisplayPhase.COMPLETE, phase("exited", 0))
assertEquals(ProcessDisplayPhase.FAILED, phase("exited", 1))
assertEquals(ProcessDisplayPhase.UNKNOWN, phase("unknown", 0))
}
private fun phase(status: String, exitCode: Int? = null) = processDisplayPhase(
GatewayProcess("process", "command", status = status, exitCode = exitCode),
)
}
@@ -1,5 +1,7 @@
package com.hermesandroid.relay.util
import com.hermesandroid.relay.network.upstream.DashboardHttpException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
@@ -11,6 +13,54 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class RelayErrorClassifierTest {
@Test
fun dashboard404UsesTypedStatusAndNamesItsOwner() {
val error = classifyError(DashboardHttpException(404, "request failed"), context = "send_message")
assertEquals("Endpoint not found", error.title)
assertTrue(error.body.contains("Dashboard"))
assertFalse(error.body.contains("relay", ignoreCase = true))
assertFalse(error.retryable)
}
@Test
fun typedDashboardStatusWinsOverMisleadingResponseBody() {
val error = classifyError(DashboardHttpException(500, "HTTP 404 missing"))
assertFalse(error.title == "Endpoint not found")
assertTrue(error.body.contains("HTTP 500"))
assertTrue(error.retryable)
}
@Test
fun explicitLegacy404IsNeutralForVoiceAndChat() {
listOf("HTTP 404", "API error 404: Not found", "Relay responded HTTP 404").forEach { text ->
listOf("voice_config", "send_message").forEach { context ->
val error = classifyError(IOException(text), context)
assertEquals("Endpoint not found", error.title)
assertFalse(error.body.contains("relay", ignoreCase = true))
assertFalse(error.body.contains("older", ignoreCase = true))
}
}
}
@Test
fun incidental404NeverBecomesMissingEndpoint() {
listOf("file 404 not readable", "HTTP 4040", "request id=404", "See https://example.test/404", "Response body: HTTP 404").forEach { text ->
val error = classifyError(IOException(text))
assertEquals("Network error", error.title)
assertEquals(text, error.body)
}
}
@Test
fun typedDashboardAuthDoesNotSuggestRelayRepair() {
listOf(401, 403).forEach { code ->
val error = classifyError(DashboardHttpException(code, "request failed"), "media_fetch")
assertEquals(if (code == 401) "Dashboard sign-in required" else "Not allowed", error.title)
assertEquals(null, error.action)
assertFalse(error.body.contains("re-pair", ignoreCase = true))
}
}
@Test
fun gatewayDrainIsNotMisclassifiedAsProviderOutage() {
val err = classifyError(
@@ -0,0 +1,285 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.data.ChatActivityStore
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.network.upstream.GatewayProcess
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatActivityControllerTest {
private val now = 100_000L
@Test
fun completedChildrenRemainWhenNewParentTurnPrunesActiveList() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("first", SubagentActivityPhase.COMPLETED)))
val original = controller.records.value.single()
controller.captureSubagents(emptyList())
controller.captureSubagents(listOf(child("second", SubagentActivityPhase.THINKING).copy(turnId = "next-turn")))
val record = controller.records.value.single()
assertEquals(original.id, record.id)
assertEquals(original.createdAt, record.createdAt)
assertEquals(listOf("first", "second"), record.children.map { it.id })
assertEquals(ChatActivityPhase.RUNNING, record.phase)
controller.captureSubagents(listOf(child("second", SubagentActivityPhase.COMPLETED)))
assertEquals(ChatActivityPhase.COMPLETE, controller.records.value.single().phase)
}
@Test
fun groupsOnlyByExactDelegationAndSkipsAnonymousChildren() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(
child("a").copy(delegationId = null), child("b").copy(delegationId = null),
child("c").copy(delegationId = "different"),
child("anonymous").copy(subagentId = null, childSessionId = null),
))
assertEquals(3, controller.records.value.size)
assertEquals(3, controller.records.value.map { it.id }.distinct().size)
}
@Test
fun uncertaintyAndExactFailuresDriveAggregateWithoutPersistingEventBodies() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(
child("a", SubagentActivityPhase.FAILED), child("b", SubagentActivityPhase.ENDED_WITH_PARENT),
))
assertEquals(ChatActivityPhase.UNKNOWN, controller.records.value.single().phase)
controller.captureSubagents(listOf(child("b", SubagentActivityPhase.COMPLETED)))
assertEquals(ChatActivityPhase.FAILED, controller.records.value.single().phase)
}
@Test
fun processIdReuseCreatesSeparateHistoryAndDisappearanceBecomesUnknown() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(process("old")))
val original = controller.records.value.single()
controller.captureProcesses(listOf(process("new")))
val records = controller.records.value
assertEquals(2, records.size)
assertEquals(ChatActivityPhase.UNKNOWN, records.first { it.id == original.id }.phase)
assertEquals(ChatActivityPhase.RUNNING, records.first { it.processStartedAt == "new" }.phase)
controller.captureProcesses(listOf(process("new").copy(status = "exited", exitCode = 0)))
assertEquals(ChatActivityPhase.COMPLETE, controller.records.value.first { it.processStartedAt == "new" }.phase)
assertTrue(controller.records.value.none { it.title.contains("private") })
}
@Test
fun identityEncodingSeparatesNullAndLiteralStartValues() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(process(null), process("null")))
assertEquals(2, controller.records.value.size)
assertNotEquals(controller.records.value[0].id, controller.records.value[1].id)
}
@Test
fun staleReadCannotPublishIntoAnotherSession() = runTest {
val gate = CompletableDeferred<List<ChatActivityRecord>>()
val store = RecordingStore { _, session -> if (session == "old") gate.await() else emptyList() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "old")
runCurrent()
controller.selectSession("scope", "new")
controller.captureSubagents(listOf(child("new-child")))
gate.complete(listOf(record("old")))
advanceUntilIdle()
assertEquals(listOf("new"), controller.records.value.map { it.sessionId })
}
@Test
fun delayedRestoreDoesNotOverwriteFreshLiveState() = runTest {
val gate = CompletableDeferred<List<ChatActivityRecord>>()
val store = RecordingStore { _, _ -> gate.await() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
runCurrent()
controller.captureProcesses(listOf(process("same")))
val fresh = controller.records.value.single()
gate.complete(listOf(fresh.copy(phase = ChatActivityPhase.UNKNOWN)))
advanceUntilIdle()
assertEquals(ChatActivityPhase.RUNNING, controller.records.value.single().phase)
}
@Test
fun storeBindingMigratesMemoryAndFencesPreviousLoads() = runTest {
val memory = InMemoryChatActivityStore { now }
val controller = ChatActivityController(this, memory) { now }
controller.selectSession("scope", "session")
advanceUntilIdle()
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.COMPLETED)))
advanceUntilIdle()
val durable = RecordingStore { _, _ -> emptyList() }
controller.bindStore(durable)
advanceUntilIdle()
assertEquals(1, controller.records.value.size)
assertEquals(controller.records.value, durable.written)
}
@Test
fun unchangedSnapshotsDoNotWriteAndChangedMetadataUsesMonotonicRevision() = runTest {
val store = RecordingStore { _, _ -> emptyList() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
advanceUntilIdle()
controller.captureSubagents(listOf(child("a")))
advanceUntilIdle()
controller.captureSubagents(listOf(child("a")))
advanceUntilIdle()
assertEquals(1, store.written.size)
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.COMPLETED)))
advanceUntilIdle()
assertEquals(2, store.written.size)
assertTrue(store.written.last().updatedAt > store.written.first().updatedAt)
}
@Test
fun removeFencesPendingLoadAndRemovesOnlyRequestedOwner() = runTest {
val gate = CompletableDeferred<List<ChatActivityRecord>>()
val store = RecordingStore { _, _ -> gate.await() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
runCurrent()
controller.removeSession("scope", "session")
gate.complete(listOf(record("session")))
advanceUntilIdle()
assertTrue(controller.records.value.isEmpty())
assertEquals(listOf("scope" to "session"), store.removed)
}
@Test
fun lateDelegationMigratesExactFallbackAndRemovesDurableDuplicate() = runTest {
val store = RecordingStore { _, _ -> emptyList() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
advanceUntilIdle()
controller.captureSubagents(listOf(child("a").copy(delegationId = null)))
val fallback = controller.records.value.single()
controller.captureSubagents(listOf(child("a")))
advanceUntilIdle()
val grouped = controller.records.value.single()
assertNotEquals(fallback.id, grouped.id)
assertEquals(fallback.createdAt, grouped.createdAt)
assertEquals(1, grouped.children.size)
assertEquals(listOf(fallback.id), store.removedRecords)
}
@Test
fun childSessionIdentityEnrichesWithoutDuplicatingChild() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a").copy(subagentId = null)))
controller.captureSubagents(listOf(child("a")))
val children = controller.records.value.single().children
assertEquals(1, children.size)
assertEquals("a", children.single().id)
}
@Test
fun declaredUnobservedChildrenPreventFalseCompletion() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.COMPLETED).copy(taskCount = 3)))
assertEquals(3, controller.records.value.single().taskCount)
assertEquals(ChatActivityPhase.UNKNOWN, controller.records.value.single().phase)
}
@Test
fun deletionRejectsLateCapturesAndSameOwnerReselectionUntilSwitchAway() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a")))
controller.removeSession("scope", "session")
controller.captureSubagents(listOf(child("late")))
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(process("late")))
assertTrue(controller.records.value.isEmpty())
controller.selectSession("scope", "other")
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("new")))
assertEquals(1, controller.records.value.size)
}
@Test
fun connectionLossDowngradesOnlyRunningRecordsAndChildren() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a"), child("b", SubagentActivityPhase.COMPLETED)))
controller.captureProcesses(listOf(process("now")))
controller.markUnavailable()
assertTrue(controller.records.value.all { it.phase == ChatActivityPhase.UNKNOWN })
val children = controller.records.value.first { it.kind == ChatActivityKind.SUBAGENTS }.children
assertEquals(ChatActivityPhase.UNKNOWN, children.first { it.id == "a" }.phase)
assertEquals(ChatActivityPhase.COMPLETE, children.first { it.id == "b" }.phase)
}
@Test
fun freshChildAfterConnectionLossDoesNotReviveUnobservedSibling() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a"), child("b")))
controller.markUnavailable()
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.PROGRESS)))
val record = controller.records.value.single()
assertEquals(ChatActivityPhase.RUNNING, record.phase)
assertEquals(ChatActivityPhase.RUNNING, record.children.first { it.id == "a" }.phase)
assertEquals(ChatActivityPhase.UNKNOWN, record.children.first { it.id == "b" }.phase)
}
@Test
fun explicitZeroProcessExitCompletesUnknownStatusButDoesNotOverrideCancellationOrRunning() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(
process("zero").copy(status = "unknown", exitCode = 0),
process("cancelled").copy(status = "cancelled", exitCode = 0),
process("running").copy(status = "running", exitCode = 0),
))
val phases = controller.records.value.associate { it.processStartedAt to it.phase }
assertEquals(ChatActivityPhase.COMPLETE, phases["zero"])
assertEquals(ChatActivityPhase.CANCELLED, phases["cancelled"])
assertEquals(ChatActivityPhase.RUNNING, phases["running"])
}
private fun child(id: String, phase: SubagentActivityPhase = SubagentActivityPhase.THINKING) = SubagentActivity(
laneId = 1, turnId = "turn", taskIndex = 1, taskCount = 2, goal = "Review", subagentId = id,
childSessionId = "session-$id", phase = phase, delegationId = "delegation",
)
private fun process(start: String?) = GatewayProcess(
id = "process", command = "private command", cwd = "/private/path", startedAt = start,
status = "running", outputTail = "private output", sessionScoped = true,
)
private fun record(session: String) = ChatActivityRecord(
id = "old-record", scopeKey = "scope", sessionId = session, kind = ChatActivityKind.SUBAGENTS,
sourceId = "source", title = "Subagents", phase = ChatActivityPhase.COMPLETE,
createdAt = now, updatedAt = now,
)
private class RecordingStore(
val reader: suspend (String, String) -> List<ChatActivityRecord>,
) : ChatActivityStore {
val written = mutableListOf<ChatActivityRecord>()
val removed = mutableListOf<Pair<String, String>>()
val removedRecords = mutableListOf<String>()
override suspend fun read(scopeKey: String, sessionId: String) = reader(scopeKey, sessionId)
override suspend fun upsert(record: ChatActivityRecord) { written += record }
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) { removedRecords += id }
override suspend fun removeSession(scopeKey: String, sessionId: String) { removed += scopeKey to sessionId }
}
}
@@ -0,0 +1,203 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.projectChatActivityReceipts
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayClientHarness
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ChatActivityIntegrationTest {
private lateinit var harness: GatewayClientHarness
private lateinit var gatewayScope: CoroutineScope
private lateinit var client: GatewayChatClient
private lateinit var socket: WebSocket
private lateinit var handler: ChatHandler
private lateinit var viewModel: ChatViewModel
private val owner = AgentDisplay.profileContextKey("connection-a", "research")
@Volatile private var history: List<MessageItem> = emptyList()
@Before
fun setUp() {
harness = GatewayClientHarness()
// This harness has a fixed process list for process-controller tests.
// Keep this delegation-only lane independent of those unrelated rows.
harness.methodNotFound.add("process.list")
harness.resumeLiveSessionIds["child-session"] = "child-live"
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
client = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
harness.server.url("/").toString().trimEnd('/'), OkHttpClient(),
),
okHttpClient = OkHttpClient(),
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
)
handler = ChatHandler().also { it.setSessionId(SESSION) }
viewModel = ChatViewModel().also {
it.initialize(HermesApiClient(harness.server.url("/").toString(), "test-key"), handler)
it.streamingEndpoint = "gateway"
it.setSelectedProfileProvider { Profile(name = "research", model = "model") }
it.setSessionProfileNameProvider { "research" }
it.setProfileMessageLoader { Result.success(history) }
it.setChatActivityStore(InMemoryChatActivityStore())
it.switchProfileContext(owner, SESSION)
it.updateGatewayClient(client)
}
assertTrue(runBlocking { client.prewarmAwait(SESSION) })
socket = harness.awaitServerSocket()
shadowOf(Looper.getMainLooper()).idle()
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
client.shutdown()
gatewayScope.cancel()
harness.shutdown()
}
@Test
fun detachedCompletionKeepsHistoryEntryThroughWakeAndClosesOnProfileSwitch() {
viewModel.sendMessage("Inspect the project")
harness.awaitRpc("prompt.submit")
emit("message.start")
emit("subagent.start", childPayload())
awaitCondition { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.RUNNING }
history = listOf(row("prompt", "user", "Inspect the project"), row("parent", "assistant", "Children launched."))
emit("message.complete", buildJsonObject {
put("text", "Children launched.")
put("status", "complete")
})
awaitCondition { !handler.isStreaming.value }
assertEquals(ChatActivityPhase.RUNNING, viewModel.activityRecords.value.single().phase)
emit("subagent.complete", childPayload(completed = true))
awaitCondition { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.COMPLETE }
val completed = viewModel.activityRecords.value.single()
assertEquals("delegation-a", completed.sourceId)
assertEquals("child-session", completed.children.single().childSessionId)
assertTrue(viewModel.openRetainedActivity(completed))
assertEquals(completed.id, viewModel.retainedActivityPreview.value?.record?.id)
val promptCount = harness.rpcLog.count { it.first == "prompt.submit" }
viewModel.openSubagentChildPreview(completed.previewActivities().single().stableKey)
awaitCondition { viewModel.subagentChildPreview.value?.childWatchAvailable == true }
val childResume = harness.rpcLog.single {
it.first == "session.resume" && it.second["session_id"] == JsonPrimitive("child-session")
}.second
assertEquals(JsonPrimitive(true), childResume["lazy"])
assertEquals(JsonPrimitive("research"), childResume["profile"])
assertEquals("live-resumed", client.currentLiveSessionId(SESSION))
assertEquals(promptCount, harness.rpcLog.count { it.first == "prompt.submit" })
val receipt = row("receipt", "user", "[ASYNC DELEGATION COMPLETE — delegation-a]").copy(
rowId = 73,
displayKind = "async_delegation_complete",
displayMetadata = buildJsonObject {
put("delegation_id", "delegation-a")
put("task_count", 1)
put("completed_count", 1)
put("failed_count", 0)
},
)
history = history + receipt + row("wake", "assistant", "Inspection complete.")
emit("message.start")
emit("message.complete", buildJsonObject {
put("text", "Inspection complete.")
put("status", "complete")
})
awaitCondition { handler.messages.value.any { it.id == "receipt" } }
assertEquals(completed.id, viewModel.activityRecords.value.single().id)
val raw = handler.messages.value
val canonical = raw.single { it.id == "receipt" }
val projected = projectChatActivityReceipts(raw, viewModel.activityRecords.value, owner, SESSION)
val displayed = projected.single { it.activityRecord != null }
assertEquals(canonical.id, displayed.id)
assertEquals(canonical.uiKey, displayed.uiKey)
assertEquals(73L, displayed.rowId)
assertEquals(canonical.content, displayed.content)
assertEquals(completed.id, displayed.activityRecord?.id)
assertEquals(raw, handler.messages.value)
assertTrue(handler.messages.value.none { it.activityRecord != null || it.id.startsWith("activity:") })
assertFalse(viewModel.openRetainedActivity(completed.copy(scopeKey = "foreign-owner")))
assertFalse(viewModel.openRetainedActivity(completed.copy(sessionId = "foreign-session")))
assertNotNull(viewModel.retainedActivityPreview.value)
viewModel.switchProfileContext(AgentDisplay.profileContextKey("connection-a", "other"), SESSION)
shadowOf(Looper.getMainLooper()).idle()
assertTrue(viewModel.activityRecords.value.isEmpty())
assertNull(viewModel.retainedActivityPreview.value)
assertNull(viewModel.subagentChildPreview.value)
assertFalse(viewModel.openRetainedActivity(completed))
}
private fun row(id: String, role: String, content: String) = MessageItem(
id = id, sessionId = SESSION, role = role, content = JsonPrimitive(content),
)
private fun childPayload(completed: Boolean = false) = buildJsonObject {
put("delegation_id", "delegation-a")
put("subagent_id", "child-a")
put("child_session_id", "child-session")
put("task_index", 0)
put("task_count", 1)
put("goal", "Inspect the project")
if (completed) {
put("status", "completed")
put("summary", "Inspected the project")
}
}
private fun emit(type: String, payload: JsonObject? = null) {
assertTrue(socket.send(harness.eventFrame(type, payload, "live-resumed")))
}
private fun awaitCondition(condition: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (System.nanoTime() < deadline) {
shadowOf(Looper.getMainLooper()).idleFor(20, TimeUnit.MILLISECONDS)
if (condition()) return
Thread.sleep(20)
}
assertTrue("Activity=${viewModel.activityRecords.value}; messages=${handler.messages.value}", condition())
}
companion object {
private const val SESSION = "stored-session"
}
}
@@ -2475,6 +2475,15 @@ class ChatViewModelGatewayInboundTurnTest {
)
}
@Test
fun recoveredLegacyContextKeepsExplicitPersistedSubagentProfile() {
assertRecoveredSubagentWatchProfile(
contextKey = "connection-a/profile-default",
persistedProfileKey = "default",
expectedProfile = "default",
)
}
@Test
fun currentServerDefaultCheckpointPersistsExplicitSentinel() {
assertCurrentCheckpointProfileKey(
@@ -2553,6 +2562,37 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals(JsonPrimitive("default"), resume["profile"])
}
@Test
fun detachedChildRemainsPreviewableAfterParentCompletesAndAcceptsLateProgress() {
viewModel.sendMessage("Delegate work")
gatewayHarness.awaitRpc("prompt.submit")
val child = buildJsonObject {
put("subagent_id", "detached-child")
put("child_session_id", "detached-session")
put("goal", "Inspect")
}
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("message.interim", buildJsonObject { put("text", "Delegating now") }, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("subagent.start", child, "live-resumed"))
awaitCondition { viewModel.subagentActivities.value.size == 1 }
val key = viewModel.subagentActivities.value.single().stableKey
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject { put("text", "Launched") }, "live-resumed"))
awaitCondition { !handler.isStreaming.value }
assertFalse(viewModel.subagentActivities.value.single().isTerminal)
serverWs.send(gatewayHarness.eventFrame("subagent.progress", buildJsonObject {
put("subagent_id", "detached-child")
put("text", "Still inspecting")
}, "live-resumed"))
awaitCondition { viewModel.subagentActivities.value.single().events.last().text == "Still inspecting" }
assertEquals(key, viewModel.subagentActivities.value.single().stableKey)
assertFalse(handler.isStreaming.value)
serverWs.send(gatewayHarness.eventFrame("subagent.complete", buildJsonObject {
put("subagent_id", "detached-child")
put("status", "completed")
}, "live-resumed"))
awaitCondition { viewModel.subagentActivities.value.single().phase == SubagentActivityPhase.COMPLETED }
}
@Test
fun explicitApprovalActionAloneEmitsResponseAndCollapsesCard() {
viewModel.sendMessage("Run the guarded command")
@@ -14,6 +14,10 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.hermesandroid.relay.util.HumanError
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@@ -285,6 +289,35 @@ class ChatViewModelMediaStateTest {
assertEquals("/media/by-path", server.takeRequest().requestUrl?.encodedPath)
}
@Test
fun missingDashboardMediaStaysInAttachmentWithoutGlobalError() {
dashboardServer.enqueue(MockResponse().setResponseCode(404).setBody("{\"detail\":\"Path not found\"}"))
viewModel.cellularNetworkOverride = false
viewModel.initializeMedia(
context = RuntimeEnvironment.getApplication(),
relayHttpClient = RelayHttpClient(
okHttpClient = OkHttpClient(), relayUrlProvider = { null },
sessionTokenProvider = { null }, pairedTokenSnapshot = { null },
),
mediaSettingsRepo = MediaSettingsRepository(RuntimeEnvironment.getApplication()),
mediaCacheWriter = cache,
dashboardMediaClientProvider = { DashboardApiClient(baseUrl = dashboardServer.url("/").toString()) },
)
val errors = mutableListOf<HumanError>()
val collector = CoroutineScope(Dispatchers.Unconfined).launch { viewModel.errorEvents.collect { errors.add(it) } }
try {
handler.loadMessageHistory(listOf(MessageItem(
id = "missing-image", role = "assistant", content = JsonPrimitive("MEDIA:/tmp/missing-image.png"),
)))
val failed = awaitMessage { it.attachments.singleOrNull()?.state == AttachmentState.FAILED }
shadowOf(Looper.getMainLooper()).idle()
assertEquals(AttachmentState.FAILED, failed.attachments.single().state)
assertEquals(1, dashboardServer.requestCount)
assertEquals(0, server.requestCount)
assertEquals(emptyList<HumanError>(), errors)
} finally { collector.cancel() }
}
@Test
fun assistantBarePathDoesNotBypassUpstreamSensitiveFileDenial() {
val path = "/home/user/.ssh/id_ed25519"
@@ -19,6 +19,29 @@ import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class GatewayProcessControllerTest {
@Test
fun retainedStatusListenerOnlyReceivesAuthoritativeSnapshots() = runTest {
val source = FakeProcessSource().apply {
snapshot = listOf(process(id = "p1", status = "running"))
}
val snapshots = mutableListOf<List<GatewayProcess>>()
val controller = GatewayProcessController(this)
controller.setSnapshotListener { snapshots += it }
controller.bind(source, "chat")
controller.sessionReady("chat")
runCurrent()
assertEquals(1, snapshots.size)
source.emit(GatewayProcessEvent.Output("p1", "new output"))
runCurrent()
assertEquals(1, snapshots.size)
source.snapshot = emptyList()
controller.refresh()
runCurrent()
assertEquals(2, snapshots.size)
assertTrue(snapshots.last().isEmpty())
controller.close()
}
@Test
fun duplicateProcessSnapshotsPublishOneComposeIdentityWithLatestState() = runTest {
val source = FakeProcessSource().apply {
@@ -10,6 +10,86 @@ class SubagentActivityControllerTest {
private var now = 1_000L
private val controller = SubagentActivityController { now++ }
@Test
fun `open sheet keeps completed details across parent wake until dismissed`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "first")
controller.setPreviewOpen(true)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.COMPLETE, subagentId = "child", status = "completed"), null)
controller.beginTurn("parent", "scope", "wake")
assertEquals(SubagentActivityPhase.COMPLETED, controller.activities.value.single().phase)
controller.setPreviewOpen(false)
assertTrue(controller.activities.value.isEmpty())
}
@Test
fun `detached child survives parent completion and a new turn with stable preview identity`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "parent-turn")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
val key = controller.activities.value.single().stableKey
controller.endTurn("parent-turn")
assertFalse(controller.activities.value.single().isTerminal)
controller.beginTurn("parent", "scope", "next-turn")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "Still working", subagentId = "child"), null)
assertEquals(key, controller.activities.value.single().stableKey)
assertEquals("Still working", controller.activities.value.single().events.last().text)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "completed", subagentId = "child"), null)
assertEquals(SubagentActivityPhase.COMPLETED, controller.activities.value.single().phase)
controller.beginTurn("parent", "scope", "completion-wake")
assertTrue(controller.activities.value.isEmpty())
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
assertTrue(controller.activities.value.isEmpty())
}
@Test
fun `idle session accepts child start but not orphan progress or other profile`() {
controller.selectSession("parent", "scope")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.PROGRESS, subagentId = "unknown"), null)
controller.onSessionEvent("parent", "foreign", event(0, GatewaySubagentEvent.Phase.START, subagentId = "foreign"), null)
assertTrue(controller.activities.value.isEmpty())
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
assertFalse(controller.activities.value.single().isTerminal)
controller.interrupt()
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "late"), null)
assertEquals(SubagentActivityPhase.INTERRUPTED, controller.activities.value.single().phase)
}
@Test
fun `concurrent batches reusing task index remain separate across turns`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "first")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child-a"), null)
controller.beginTurn("parent", "scope", "second")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child-b"), null)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "completed", subagentId = "child-a"), null)
assertEquals(2, controller.activities.value.size)
assertFalse(controller.activities.value.single { it.subagentId == "child-b" }.isTerminal)
}
@Test
fun `timeouts and unknown terminal statuses never report success`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "turn")
listOf("timeout", "error", "unknown", "running").forEachIndexed { index, status ->
controller.onSessionEvent("parent", "scope", event(index, GatewaySubagentEvent.Phase.START, subagentId = "child-$index"), null)
controller.onSessionEvent("parent", "scope", event(index, GatewaySubagentEvent.Phase.COMPLETE, status = status, subagentId = "child-$index"), null)
}
assertTrue(controller.activities.value.all { it.phase == SubagentActivityPhase.FAILED })
}
@Test
fun `child preview keeps its admitted profile after parent checkpoint is cleared`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "turn")
val start = event(0, GatewaySubagentEvent.Phase.START, subagentId = "child")
controller.onSessionEvent("parent", "scope", start, "default")
controller.endTurn("turn")
controller.onSessionEvent("parent", "scope", start.copy(phase = GatewaySubagentEvent.Phase.PROGRESS), null)
assertEquals("default", controller.activities.value.single().profile)
}
@Test
fun `interleaved children retain independent lifecycle previews`() {
controller.selectSession("parent", "connection::default")
+10
View File
@@ -46,6 +46,16 @@ Android workflow from the selected `dev` ref.
## Local use
### Message appearance previews
In a development build, open Developer settings → Message previews to exercise
the production info, success, progress, warning, error, and Retry/Dismiss surfaces.
These samples make no network requests and do not change connection state.
Progress remains visible until replaced or cleared. Clear previews and leaving
the screen remove only sample messages, preserving real app feedback. Use these
controls for on-device theme, text sizing, readability, and action checks; they
do not substitute for transport/error-routing tests.
The Windows `scripts/dev.bat` commands and `scripts/android-prepush.py` acquire
the lane automatically. For an ad hoc Gradle command, use:
+10
View File
@@ -68,6 +68,8 @@ the upstream contract identifiers it depends on.
|---|---|
| `initial_history_bind` | Durable, profile-scoped history is already available when the client resumes and first binds its rendered transcript |
| `ordinary_turn` | Normal message start, deltas, completion, and persisted history |
| `session_initialization_failure` | Exact-session initialization error arrives before a lazy create acknowledgement; Android must fail the pending send without waiting for the readiness deadline |
| `subagent_child_preview` | Child activity continues after the parent terminal, followed by child completion and a separate completion wake; preview ownership remains on the same profile/session |
| `ownership_rejection` | A submit acknowledged before the defense-in-depth ownership check emits the canonical terminal refusal; no user/model row is persisted and clients must not enter history recovery |
| `compaction_status` | Compaction status is client-visible before terminal completion and may repeat as a heartbeat |
| `rapid_tools_interims` | Rapid chunks, reasoning, tool activity, and interim assistant boundaries |
@@ -82,6 +84,14 @@ the upstream contract identifiers it depends on.
| `active_status_unsupported` | An older Gateway returns JSON-RPC method-not-found; the client retains Unknown rather than inventing Idle or Working |
| `cross_client_observation` | A second client observes a Desktop-owned working session through active status and history without resume, activate, submit, or interrupt; the producing client receives the terminal event |
Activity receipts join canonical completion metadata by exact delegation identity
within the connection/profile/session owner. Process notices use the canonical
process ID; ambiguous reused process generations do not attach cached output.
Local metadata recovery never asserts live execution, and receipt projection
does not modify the transport/model/voice transcript. Controller/store tests
cover late identity enrichment, partial groups, stale loads, removal, and bounded
retention; rendered tests cover active-strip disappearance and historical controls.
Fixture evidence is a bounded metadata-only ring. It records sequence,
connection number, RPC method, event type, scope classification, and outcome.
It never records prompts, responses, RPC parameters, credentials, URLs,
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+49 -1
View File
@@ -34,6 +34,7 @@ SESSION_RESUME = "gateway.session_resume_durable"
SESSION_ACTIVE_LIST = "gateway.session_active_list"
SESSION_EXCLUSIVE_SUBMIT = "gateway.session_exclusive_submit"
SUBAGENT_CHILD_WATCH = "gateway.subagent_child_watch"
SESSION_INITIALIZATION = "gateway.session_initialization"
API_BOUNDARY = "api.fallback_boundary"
ALL_CONTRACTS = (
GATEWAY_TERMINAL,
@@ -43,6 +44,7 @@ ALL_CONTRACTS = (
SESSION_ACTIVE_LIST,
SESSION_EXCLUSIVE_SUBMIT,
SUBAGENT_CHILD_WATCH,
SESSION_INITIALIZATION,
API_BOUNDARY,
)
@@ -60,6 +62,7 @@ class CheckResult:
class SourceFile:
def __init__(self, root: Path, relative: str):
self.root = root
self.relative = relative
self.path = root / relative
if not self.path.is_file():
@@ -183,6 +186,21 @@ def _check_gateway_terminal(server: SourceFile) -> CheckResult:
return CheckResult(contract, False, (), str(exc))
def _check_session_initialization(server: SourceFile) -> CheckResult:
try:
build = server.function("_start_agent_build")
if not _call_lines(build, "_emit", "error") or "agent init failed:" not in server.segment(build):
raise ValueError("deferred build no longer emits the initialization failure")
ready_owner = build
if _call_lines(build, "_announce_built_agent"):
ready_owner = server.function("_announce_built_agent")
if not _call_lines(ready_owner, "_emit", "session.info"):
raise ValueError("deferred build no longer emits the ready session.info")
return CheckResult(SESSION_INITIALIZATION, True, (server.evidence(build, "ready and initialization-failure events"),))
except ValueError as exc:
return CheckResult(SESSION_INITIALIZATION, False, (), str(exc))
def _check_settled_info(server: SourceFile) -> CheckResult:
contract = GATEWAY_SETTLED_INFO
try:
@@ -386,6 +404,32 @@ def _check_subagent_child_watch(server: SourceFile, methods: SourceFile) -> Chec
try:
resume = methods.method_handler("session.resume")
resume_segment = methods.segment(resume)
if "_resume_lazy(ctx)" in resume_segment:
lazy = methods.function("_resume_lazy")
child_history = methods.function("child_history")
constructor = next((
node for node in methods.tree.body
if isinstance(node, ast.ClassDef) and node.name == "_Resume"
), None)
if constructor is None:
raise ValueError("missing _Resume context")
lazy_text = methods.segment(lazy)
history_text = methods.segment(child_history)
if not {"lazy", "close_on_disconnect"} <= _string_constants(constructor):
raise ValueError("resume context no longer carries lazy/close_on_disconnect")
if "ctx.child_history(" not in lazy_text or "lazy=True" not in lazy_text:
raise ValueError("lazy resume no longer creates a child-history watcher")
if "get_messages_as_conversation(self.target" not in history_text or "include_ancestors=True" in history_text:
raise ValueError("child history no longer uses the child-only conversation")
mirror = SourceFile(server.root, "tui_gateway/agent_callbacks.py")
mirror_fn = mirror.function("_mirror_subagent_to_child")
if not {"child_session_id", "subagent.text", "reasoning.delta", "message.delta"} <= _string_constants(mirror.tree):
raise ValueError("child mirror event contract missing")
return CheckResult(contract, True, (
methods.evidence(resume, "session.resume dispatches lazy watch"),
methods.evidence(lazy, "lazy child-only history and live status"),
mirror.evidence(mirror_fn, "child_session_id routes child mirror events"),
))
resume_strings = _string_constants(resume)
server_strings = _string_constants(server.tree)
missing_resume = sorted(
@@ -504,7 +548,10 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
raise ValueError("fork marker(s) found in upstream source: " + ", ".join(fork_hits))
checks = {
GATEWAY_TERMINAL: lambda: _check_gateway_terminal(server),
GATEWAY_TERMINAL: lambda: _check_gateway_terminal(
SourceFile(root, "tui_gateway/prompt_turn.py")
if (root / "tui_gateway/prompt_turn.py").is_file() else server
),
GATEWAY_SETTLED_INFO: lambda: _check_settled_info(server),
SESSION_ACTIVATE: lambda: _check_activate(server, methods),
SESSION_RESUME: lambda: _check_resume(methods),
@@ -513,6 +560,7 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
server, methods, prompt_methods, active_sessions,
),
SUBAGENT_CHILD_WATCH: lambda: _check_subagent_child_watch(server, methods),
SESSION_INITIALIZATION: lambda: _check_session_initialization(server),
API_BOUNDARY: lambda: _check_api_boundary(api),
}
return [checks[requirement]() for requirement in requirements]
@@ -32,6 +32,7 @@ def _live_session_payload(sid, session):
def _start_agent_build(sid, session):
_emit("session.info", sid, {"lazy": False})
_emit("error", sid, {"message": "agent init failed: fixture"})
ready.set()
def _run_prompt_submit(sid, session, agent):
@@ -197,6 +198,36 @@ class GatewayScenarioConformanceTest(unittest.TestCase):
self.assertIn("message.complete", results[0].problem)
self.assertTrue(results[1].passed)
def test_decomposed_child_watch_and_terminal_contracts(self):
prompt = self.root / "tui_gateway/prompt_turn.py"
prompt.write_text('def _run_prompt_submit():\n _emit("message.complete", sid, {})\n', encoding="utf-8")
methods = self.root / module.SESSION_METHODS
source = '''
class _Resume:
def __init__(self, params):
self.lazy = params.get("lazy")
self.close = params.get("close_on_disconnect")
def child_history(self):
return self.db.get_messages_as_conversation(self.target, include_row_ids=True)
def _resume_lazy(ctx):
history = ctx.child_history()
return ctx.record(history, lazy=True)
@method("session.resume")
def resume(rid, params):
ctx = _Resume(params)
if ctx.lazy:
return _resume_lazy(ctx)
'''
methods.write_text(source, encoding="utf-8")
mirror = self.root / "tui_gateway/agent_callbacks.py"
mirror.write_text('def _mirror_subagent_to_child():\n return ("child_session_id", "subagent.text", "reasoning.delta", "message.delta")\n', encoding="utf-8")
requirements = (module.GATEWAY_TERMINAL, module.SUBAGENT_CHILD_WATCH)
self.assertTrue(all(result.passed for result in module.audit_sources(self.root, requirements)))
methods.write_text(source.replace('include_row_ids=True', 'include_ancestors=True'), encoding="utf-8")
self.assertFalse(module.audit_sources(self.root, requirements)[1].passed)
methods.write_text(source.replace('"lazy"', '"not_lazy"'), encoding="utf-8")
self.assertFalse(module.audit_sources(self.root, requirements)[1].passed)
def test_settlement_must_clear_running_before_info(self):
path = self.root / module.SERVER
reordered = SERVER_SOURCE.replace(
@@ -123,6 +123,49 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(["user", "assistant"], [row["role"] for row in history["messages"]])
self.assertEqual(2, history["pagination"]["returned"])
async def test_initialization_failure_precedes_lazy_create_ack(self) -> None:
fixture, base_url = await self.start("session_initialization_failure")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "session.create", {"profile": "default"})
failure = await ws.receive_json()
self.assertEqual("error", failure["params"]["type"])
self.assertEqual(fixture.scenario.live_session_id, failure["params"]["session_id"])
ack = await ws.receive_json()
self.assertTrue(ack["result"]["info"]["lazy"])
async def test_child_activity_outlives_parent_before_completion_wake(self) -> None:
fixture, base_url = await self.start("subagent_child_preview")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "prompt.submit", {"text": "fixture"})
first = await self.frames_until(ws, lambda f: f.get("params", {}).get("type") == "message.complete")
later = await self.frames_until(ws, lambda f: f.get("params", {}).get("type") == "message.complete")
first_types = [f.get("params", {}).get("type") for f in first]
later_types = [f.get("params", {}).get("type") for f in later]
self.assertIn("subagent.start", first_types)
self.assertNotIn("subagent.complete", first_types)
self.assertIn("subagent.progress", later_types)
self.assertEqual(2, later_types.count("subagent.complete"))
self.assertLess(later_types.index("subagent.complete"), later_types.index("message.start"))
child_events = [
frame["params"]["payload"] for frame in first + later
if frame.get("params", {}).get("type", "").startswith("subagent.")
]
self.assertTrue(child_events)
self.assertEqual({"delegation-fixture-1"}, {event["delegation_id"] for event in child_events})
async with self.session.get(
f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages",
params={"profile": "default", "limit": 500, "offset": 0, "order": "asc"},
) as response:
history = (await response.json())["messages"]
self.assertEqual(["user", "assistant", "user", "assistant"], [row["role"] for row in history])
receipt = history[2]
self.assertEqual("async_delegation_complete", receipt["display_kind"])
self.assertEqual("delegation-fixture-1", receipt["display_metadata"]["delegation_id"])
self.assertEqual(2, receipt["display_metadata"]["task_count"])
self.assertEqual(1, receipt["display_metadata"]["completed_count"])
self.assertNotIn("child_session_id", receipt["display_metadata"])
self.assertEqual("Delegation complete.", history[3]["content"])
async def test_ownership_rejection_is_terminal_without_persisted_turn(self) -> None:
fixture, base_url = await self.start("ownership_rejection")
ws, _ = await self.connect(base_url)
@@ -30,6 +30,7 @@ class Scenario:
turns: tuple[dict[str, Any], ...]
active_list_supported: bool
active_list_snapshots: tuple[tuple[dict[str, Any], ...], ...]
session_initialization_error: str | None = None
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Scenario":
@@ -39,6 +40,11 @@ class Scenario:
raise ScenarioError(f"missing scenario fields: {', '.join(missing)}")
if not isinstance(raw["turns"], list):
raise ScenarioError("turns must be a list")
initialization_error = raw.get("session_initialization_error")
if initialization_error is not None and (
not isinstance(initialization_error, str) or not initialization_error or len(initialization_error) > 500
):
raise ScenarioError("session_initialization_error must be a short non-empty string")
if not isinstance(raw["name"], str) or not _SAFE_NAME.fullmatch(raw["name"]):
raise ScenarioError("name must be a short metadata-safe scenario identifier")
for turn_index, turn in enumerate(raw["turns"]):
@@ -124,6 +130,7 @@ class Scenario:
turns=tuple(dict(turn) for turn in raw["turns"]),
active_list_supported=active_list_supported,
active_list_snapshots=tuple(validated_snapshots),
session_initialization_error=initialization_error,
)
@@ -0,0 +1,9 @@
{
"name": "session_initialization_failure",
"live_session_id": "fixture-live-init",
"stored_session_id": "fixture-stored-init",
"profile": "default",
"contract_requirements": ["gateway.session_initialization"],
"session_initialization_error": "agent init failed: incompatible runtime helper",
"turns": []
}
@@ -11,16 +11,24 @@
{
"steps": [
{"op": "event", "type": "message.start"},
{"op": "event", "type": "subagent.spawn_requested", "payload": {"goal": "Inspect Android", "task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "depth": 0}},
{"op": "event", "type": "subagent.start", "payload": {"goal": "Inspect Android", "task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "depth": 0}},
{"op": "event", "type": "subagent.start", "payload": {"goal": "Review privacy", "task_index": 1, "task_count": 2, "subagent_id": "child-b", "child_session_id": "child-session-b", "depth": 0}},
{"op": "event", "type": "subagent.thinking", "payload": {"task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "text": "Mapping events"}},
{"op": "event", "type": "subagent.tool", "payload": {"task_index": 1, "task_count": 2, "subagent_id": "child-b", "child_session_id": "child-session-b", "tool_name": "read_file", "tool_preview": "policy.md"}},
{"op": "event", "type": "subagent.progress", "payload": {"task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "text": "One tool complete"}},
{"op": "event", "type": "subagent.complete", "payload": {"task_index": 1, "task_count": 2, "subagent_id": "child-b", "child_session_id": "child-session-b", "status": "interrupted", "summary": "Stopped safely"}},
{"op": "event", "type": "subagent.complete", "payload": {"task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "status": "completed", "summary": "Mapped Android events", "duration_seconds": 2.5}},
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Exercise child previews.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Delegation complete.", "timestamp": 2.0}]},
{"op": "event", "type": "subagent.spawn_requested", "payload": {"goal": "Inspect Android", "task_index": 0, "task_count": 2, "delegation_id": "delegation-fixture-1", "subagent_id": "child-a", "child_session_id": "child-session-a", "depth": 0}},
{"op": "event", "type": "subagent.start", "payload": {"goal": "Inspect Android", "task_index": 0, "task_count": 2, "delegation_id": "delegation-fixture-1", "subagent_id": "child-a", "child_session_id": "child-session-a", "depth": 0}},
{"op": "event", "type": "subagent.start", "payload": {"goal": "Review privacy", "task_index": 1, "task_count": 2, "delegation_id": "delegation-fixture-1", "subagent_id": "child-b", "child_session_id": "child-session-b", "depth": 0}},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Children launched.", "status": "complete"}},
{"op": "event", "type": "subagent.thinking", "payload": {"task_index": 0, "task_count": 2, "delegation_id": "delegation-fixture-1", "subagent_id": "child-a", "child_session_id": "child-session-a", "text": "Mapping events"}},
{"op": "event", "type": "subagent.tool", "payload": {"task_index": 1, "task_count": 2, "delegation_id": "delegation-fixture-1", "subagent_id": "child-b", "child_session_id": "child-session-b", "tool_name": "read_file", "tool_preview": "policy.md"}},
{"op": "event", "type": "subagent.progress", "payload": {"task_index": 0, "task_count": 2, "delegation_id": "delegation-fixture-1", "subagent_id": "child-a", "child_session_id": "child-session-a", "text": "One tool complete"}},
{"op": "event", "type": "subagent.complete", "payload": {"task_index": 1, "task_count": 2, "delegation_id": "delegation-fixture-1", "subagent_id": "child-b", "child_session_id": "child-session-b", "status": "interrupted", "summary": "Stopped safely"}},
{"op": "event", "type": "subagent.complete", "payload": {"task_index": 0, "task_count": 2, "delegation_id": "delegation-fixture-1", "subagent_id": "child-a", "child_session_id": "child-session-a", "status": "completed", "summary": "Mapped Android events", "duration_seconds": 2.5}},
{"op": "persist", "messages": [
{"id": 1, "role": "user", "content": "Exercise child previews.", "timestamp": 1.0},
{"id": 2, "role": "assistant", "content": "Children launched.", "timestamp": 2.0},
{"id": 3, "role": "user", "content": "[ASYNC DELEGATION BATCH COMPLETE — delegation-fixture-1]\nMapped Android events. Review privacy was interrupted.", "timestamp": 3.0, "display_kind": "async_delegation_complete", "display_metadata": {"delegation_id": "delegation-fixture-1", "task_count": 2, "completed_count": 1, "failed_count": 0, "duration_seconds": 2.5, "display_text": "Subagent Tasks Finished with Issues: Inspect Android; Review privacy (2 tasks)"}},
{"id": 4, "role": "assistant", "content": "Delegation complete.", "timestamp": 4.0}
]},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.start"},
{"op": "event", "type": "message.complete", "payload": {"text": "Delegation complete.", "status": "complete"}}
]
}
@@ -123,6 +123,11 @@ class GatewayFixture:
self.evidence.add("rpc", connection=connection, method=method, outcome="received")
if method == "session.create":
result = self._session_snapshot(include_stored=True)
if self.scenario.session_initialization_error:
result["info"]["lazy"] = True
await self._send_event(socket, connection, "error", {
"message": self.scenario.session_initialization_error,
}, self.scenario.live_session_id)
elif method == "session.resume":
requested = params.get("session_id")
if requested != self.scenario.stored_session_id:
+17 -4
View File
@@ -101,10 +101,23 @@ requested result stay independent instead of being buried in a run.
### Subagent lanes
When the agent delegates work to subagents, each one renders as its own
collapsible **lane** beneath the reply — a guide rail with that subagent's
thinking and tool rows, so a complex multi-agent turn stays readable instead of
interleaving into one stream. Lanes auto-collapse as each subagent finishes.
While delegated agents or background commands run, **Current chat activity**
appears above the composer. Tap it to view their progress. The strip disappears
when no work is running; an open detail sheet stays open until you close it.
Completed work leaves a compact entry in the conversation. **View activity**
opens recorded subagent details and available read-only child history;
**View output** opens available process output. Historical views have no Stop
or other process-control actions. A **Dispatched** tool card means the
delegation was launched, not that its children finished. Ordinary tool groups
and Realtime Agent task cards keep their own existing presentation.
The app retains bounded metadata and child references locally for up to 30 days
(32 entries per session, 128 overall), without storing full child transcripts
or process output. A persisted completion entry remains useful even when its
details are unavailable; the sheet explains that limitation. Work recovered
without fresh live evidence is marked unavailable rather than still running.
Older events without stable child identity can still use inline subagent lanes.
### Rich cards & interactive prompts