fix(android): preserve chat activity and correct feedback ownership
This commit is contained in:
@@ -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.
|
||||
|
||||
+65
@@ -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()
|
||||
}
|
||||
}
|
||||
+183
@@ -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"
|
||||
}
|
||||
}
|
||||
+42
@@ -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
|
||||
|
||||
+37
-28
@@ -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)
|
||||
|
||||
+19
-23
@@ -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()
|
||||
}
|
||||
|
||||
+2
-8
@@ -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),
|
||||
|
||||
+16
-4
@@ -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()
|
||||
}
|
||||
|
||||
+144
-149
@@ -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")
|
||||
|
||||
+50
-49
@@ -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() {
|
||||
|
||||
+58
-17
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
+23
@@ -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
|
||||
|
||||
+141
@@ -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,
|
||||
)
|
||||
}
|
||||
+47
@@ -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") }
|
||||
}
|
||||
}
|
||||
+44
@@ -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"
|
||||
}
|
||||
}
|
||||
+40
@@ -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 {
|
||||
|
||||
+80
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+9
@@ -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": []
|
||||
}
|
||||
+17
-9
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user