refactor(chat): isolate transcript and browser return state (#1320, #1321) (#1357)

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 14:30:05 +03:00
1 parent cc010b451a
commit 76726dd086
12 files changed
+521 -201

No files matched your search

@@ -11,6 +11,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState
import com.m57.hermescontrol.ui.common.ActionProgressController import com.m57.hermescontrol.ui.common.ActionProgressController
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@@ -61,6 +62,10 @@ class ChatScreenTest {
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.transcriptState } returns
MutableStateFlow(
TranscriptUiState.resolve(uiState, ChatTimelineState(), StreamingState(), null, null),
).asStateFlow()
every { mockViewModel.connectionOperationState } returns every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow() MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
// ActionProgressDialog collects the controller's StateFlow — a relaxed // ActionProgressDialog collects the controller's StateFlow — a relaxed
@@ -110,6 +115,10 @@ class ChatScreenTest {
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow() every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow() every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.transcriptState } returns
MutableStateFlow(
TranscriptUiState.resolve(uiState, ChatTimelineState(), StreamingState(), null, null),
).asStateFlow()
every { mockViewModel.connectionOperationState } returns every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow() MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
every { mockViewModel.actionProgress } returns every { mockViewModel.actionProgress } returns
@@ -12,7 +12,8 @@ import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.StreamingState
@@ -21,7 +22,6 @@ import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -116,29 +116,58 @@ class FullBleedChatAnchorTest {
val tailKeys = val tailKeys =
listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card") listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card")
val listState = LazyListState(index, offset) val listState = LazyListState(index, offset)
val viewModel = mockk<ChatViewModel>(relaxed = true)
lateinit var scope: CoroutineScope lateinit var scope: CoroutineScope
compose.setContent { compose.setContent {
scope = rememberCoroutineScope() scope = rememberCoroutineScope()
val controller = rememberChatScrollController(listState, scope) val controller = rememberChatScrollController(listState, scope)
Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) { Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) {
FullBleedChatList( FullBleedChatList(
messages = messages.value, transcript =
streamingState = streaming.value, TranscriptUiState.resolve(
isAgentTyping = tailKey != null, chat =
ChatUiState(
messages = messages.value,
isAgentTyping = tailKey != null,
typingEffectEnabled = false,
typingEffectDelayMs = 30,
isLoadingOlder = loading.value,
currentSessionId = session.value,
clarifyRequest =
if (tailKey !=
null
) {
ClarifyUi("Choose an option", listOf("Yes"))
} else {
null
},
vaultUnlockPrompt =
if (tailKey != null) {
VaultUnlockPromptUi(
null,
null,
)
} else {
null
},
vaultSaveLoginPrompt =
if (tailKey !=
null
) {
VaultSaveLoginPromptUi(null, null)
} else {
null
},
vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null,
),
timeline = ChatTimelineState(),
streaming = streaming.value,
savingAttachmentPath = null,
speakingMessageId = null,
),
actions = testTranscriptActions(),
searchState = ChatSearchState(), searchState = ChatSearchState(),
typingEffectEnabled = false,
typingEffectDelayMs = 30,
isLoading = false,
isLoadingOlder = loading.value,
listState = listState, listState = listState,
scrollController = controller, scrollController = controller,
viewModel = viewModel,
pagingSessionId = session.value,
clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null,
vaultUnlockPrompt = if (tailKey != null) VaultUnlockPromptUi(null, null) else null,
vaultSaveLoginPrompt = if (tailKey != null) VaultSaveLoginPromptUi(null, null) else null,
vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null,
) )
} }
} }
@@ -17,12 +17,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ChatScrollController
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.junit.Rule import org.junit.Rule
@@ -76,34 +76,41 @@ class FullBleedChatListTest {
composeTestRule.setContent { composeTestRule.setContent {
val listState = LazyListState() val listState = LazyListState()
FullBleedChatList( FullBleedChatList(
messages = messages, transcript =
streamingState = streamingState, TranscriptUiState.resolve(
isAgentTyping = isAgentTyping, chat =
ChatUiState(
messages = messages,
isAgentTyping = isAgentTyping,
typingEffectEnabled = false,
typingEffectDelayMs = 30,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
clarifyRequest =
if (clarify) {
com.m57.hermescontrol.ui.chat.ClarifyUi(
text = "pick",
options = listOf("a"),
)
} else {
null
},
),
timeline = ChatTimelineState(),
streaming = streamingState,
savingAttachmentPath = null,
speakingMessageId = null,
),
actions = testTranscriptActions(),
searchState = ChatSearchState(), searchState = ChatSearchState(),
typingEffectEnabled = false,
typingEffectDelayMs = 30,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
isLoading = false,
isLoadingOlder = false,
listState = listState, listState = listState,
scrollController = scrollController =
ChatScrollController( ChatScrollController(
listState = listState, listState = listState,
scope = CoroutineScope(Dispatchers.Main.immediate), scope = CoroutineScope(Dispatchers.Main.immediate),
), ),
viewModel = mockk<ChatViewModel>(relaxed = true),
clarifyRequest =
if (clarify) {
com.m57.hermescontrol.ui.chat.ClarifyUi(
text = "pick",
options = listOf("a"),
)
} else {
null
},
) )
} }
} }
@@ -0,0 +1,21 @@
package com.m57.hermescontrol.ui.chat.fullbleed
/** No-op actions for transcript layout tests; callbacks are irrelevant to their assertions. */
internal fun testTranscriptActions() =
TranscriptActions(
onLoadOlder = {},
onOpenAttachment = {},
onSaveAttachment = {},
onImageClick = {},
onRespondApproval = {},
onRespondClarify = {},
onRespondClarifyBatch = {},
onDismissClarify = {},
onRespondVaultUnlock = {},
onDismissVaultUnlock = {},
onRespondVaultSaveLogin = { _, _ -> },
onDismissVaultSaveLogin = {},
onRespondVaultCode = {},
onDismissVaultCode = {},
onToggleSpeak = {},
)
@@ -0,0 +1,44 @@
package com.m57.hermescontrol.ui.chat
/** Tracks a launched browser across pause/resume and screen disposal (issue #1321). */
internal class BrowserReturnTracker {
data class Returned(
val operationId: String?,
)
private var inFlight = false
private var departed = false
private var operationId: String? = null
fun start(operationId: String? = null) {
this.operationId = operationId
inFlight = true
departed = false
}
fun onPause() {
if (inFlight) departed = true
}
/** Only an actual departure followed by resume counts as returning from the browser. */
fun onResume(): Returned? {
if (!inFlight || !departed) return null
val returned = Returned(operationId)
cancel()
return returned
}
/** Drop the tracking after a failed launch; the lifecycle guard handles the failed lease. */
fun cancel() {
inFlight = false
departed = false
operationId = null
}
/** Non-configuration disposal must release an outstanding departed activity lease. */
fun abandon(): Boolean {
val outstanding = inFlight && departed
cancel()
return outstanding
}
}
@@ -28,6 +28,17 @@ class ChatConnectorsViewModel(
) )
val uiState: StateFlow<ChatConnectorsUiState> = delegateInstance.uiState val uiState: StateFlow<ChatConnectorsUiState> = delegateInstance.uiState
private val browserReturnTracker = BrowserReturnTracker()
fun browserLaunched() = browserReturnTracker.start()
fun browserLaunchFailed() = browserReturnTracker.cancel()
fun browserPaused() = browserReturnTracker.onPause()
fun browserReturned(): Boolean = browserReturnTracker.onResume() != null
fun abandonBrowser(): Boolean = browserReturnTracker.abandon()
init { init {
var lastContext: Triple<String?, String?, String?>? = null var lastContext: Triple<String?, String?, String?>? = null
@@ -123,6 +123,7 @@ import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptActions
import com.m57.hermescontrol.ui.common.ActionProgressDialog import com.m57.hermescontrol.ui.common.ActionProgressDialog
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.CredentialWarningBanner
@@ -168,6 +169,7 @@ fun ChatScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val timelineState by viewModel.timelineState.collectAsStateWithLifecycle() val timelineState by viewModel.timelineState.collectAsStateWithLifecycle()
val transcriptState by viewModel.transcriptState.collectAsStateWithLifecycle()
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle() val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val connectorsViewModel: ChatConnectorsViewModel = viewModel() val connectorsViewModel: ChatConnectorsViewModel = viewModel()
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle() val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
@@ -181,42 +183,26 @@ fun ChatScreen(
val displayedMessages = timelineState.historyMessages ?: state.messages val displayedMessages = timelineState.historyMessages ?: state.messages
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current val context = LocalContext.current
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
var browserAuthDeparted by rememberSaveable { mutableStateOf(false) }
var connectionBrowserOperationId by remember { mutableStateOf<String?>(null) }
var connectionBrowserDeparted by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = val observer =
LifecycleEventObserver { _, event -> LifecycleEventObserver { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> { Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> {
if (browserAuthInFlight) { connectorsViewModel.browserPaused()
browserAuthDeparted = true viewModel.connectionBrowserPaused()
}
if (connectionBrowserOperationId != null) {
connectionBrowserDeparted = true
}
connectorsViewModel.onPause() connectorsViewModel.onPause()
} }
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
viewModel.refreshSettings() viewModel.refreshSettings()
connectorsViewModel.onResume() connectorsViewModel.onResume()
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted val legacyBrowserReturned = connectorsViewModel.browserReturned()
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted } val operationId = viewModel.connectionBrowserReturned()
if (legacyBrowserReturned || operationId != null) { if (legacyBrowserReturned || operationId != null) {
ExternalActivityLifecycleGuard.externalActivityReturned() ExternalActivityLifecycleGuard.externalActivityReturned()
} }
if (legacyBrowserReturned) { operationId?.let(viewModel::wakeConnectionOperation)
browserAuthInFlight = false
browserAuthDeparted = false
}
if (operationId != null) {
connectionBrowserOperationId = null
connectionBrowserDeparted = false
viewModel.wakeConnectionOperation(operationId)
}
} }
else -> {} else -> {}
@@ -231,18 +217,11 @@ fun ChatScreen(
.firstOrNull() .firstOrNull()
val isChangingConfigs = activity?.isChangingConfigurations == true val isChangingConfigs = activity?.isChangingConfigurations == true
if (!isChangingConfigs) { if (!isChangingConfigs) {
val externalActivityOutstanding = val legacyOutstanding = connectorsViewModel.abandonBrowser()
(browserAuthInFlight && browserAuthDeparted) || val connectionOutstanding = viewModel.abandonConnectionBrowser()
(connectionBrowserOperationId != null && connectionBrowserDeparted) if (legacyOutstanding || connectionOutstanding) {
if (externalActivityOutstanding) {
ExternalActivityLifecycleGuard.externalActivityReturned() ExternalActivityLifecycleGuard.externalActivityReturned()
} }
if (browserAuthInFlight && browserAuthDeparted) {
browserAuthInFlight = false
browserAuthDeparted = false
}
connectionBrowserOperationId = null
connectionBrowserDeparted = false
connectorsViewModel.onPause() connectorsViewModel.onPause()
connectorsViewModel.hide() connectorsViewModel.hide()
} }
@@ -413,21 +392,17 @@ fun ChatScreen(
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
launchExternalActivity { launchExternalActivity {
browserAuthDeparted = false connectorsViewModel.browserLaunched()
browserAuthInFlight = true
context.startActivity(intent) context.startActivity(intent)
} }
} catch (_: ActivityNotFoundException) { } catch (_: ActivityNotFoundException) {
browserAuthInFlight = false connectorsViewModel.browserLaunchFailed()
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError) connectorsViewModel.launchError(browserLaunchError)
} catch (_: SecurityException) { } catch (_: SecurityException) {
browserAuthInFlight = false connectorsViewModel.browserLaunchFailed()
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError) connectorsViewModel.launchError(browserLaunchError)
} catch (_: Exception) { } catch (_: Exception) {
browserAuthInFlight = false connectorsViewModel.browserLaunchFailed()
browserAuthDeparted = false
connectorsViewModel.launchError(browserLaunchError) connectorsViewModel.launchError(browserLaunchError)
} }
} }
@@ -759,61 +734,41 @@ fun ChatScreen(
// Full-bleed chat renderer (issue #866) — the single chat // Full-bleed chat renderer (issue #866) — the single chat
// surface since the bubble renderer was removed. // surface since the bubble renderer was removed.
FullBleedChatList( FullBleedChatList(
messages = displayedMessages, transcript =
streamingState = if (timelineState.isHistorical) StreamingState() else streamingState, transcriptState.copy(
isAgentTyping = state.isAgentTyping && !timelineState.isHistorical, savingAttachmentPath = pendingSavePath ?: transcriptState.savingAttachmentPath,
speakingMessageId = speakingMessageId,
),
actions =
TranscriptActions(
onLoadOlder = viewModel::loadOlderMessages,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
onImageClick = { viewingImage = it },
onRespondApproval = viewModel::respondToApproval,
onRespondClarify = viewModel::respondToClarify,
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
onDismissClarify = viewModel::dismissClarify,
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
onRespondVaultCode = viewModel::respondToVaultCode,
onDismissVaultCode = viewModel::dismissVaultCode,
onToggleSpeak = { message ->
val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}"
speechController.toggle(
SpeechRequest(
scopeKey = scopeKey,
messageId = message.id,
text = SpeechText.stripMarkdownForSpeech(message.content),
),
)
},
),
searchState = searchState, searchState = searchState,
typingEffectEnabled = state.typingEffectEnabled && !timelineState.isHistorical,
typingEffectDelayMs = state.typingEffectDelayMs,
messageStatsEnabled = state.messageStatsEnabled,
showUserMessageTokens = state.showUserMessageTokens,
showAssistantMessageTokens = state.showAssistantMessageTokens,
showTokensPerSecond = state.showTokensPerSecond,
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
isLoading = state.isLoading && !timelineState.isHistorical,
isLoadingOlder = state.isLoadingOlder && !timelineState.isHistorical,
hasOlderMessages = state.hasOlderMessages && !timelineState.isHistorical,
pagingSessionId =
state.currentSessionId?.let { currentSessionId ->
if (timelineState.isHistorical) {
currentSessionId + ":history:" + timelineState.historyAnchorRowId
} else {
currentSessionId
}
},
listState = listState, listState = listState,
scrollController = scrollController, scrollController = scrollController,
viewModel = viewModel,
clarifyRequest = state.clarifyRequest.takeUnless { timelineState.isHistorical },
onRespondClarify = viewModel::respondToClarify,
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
onDismissClarify = viewModel::dismissClarify,
vaultUnlockPrompt = state.vaultUnlockPrompt.takeUnless { timelineState.isHistorical },
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt.takeUnless { timelineState.isHistorical },
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
vaultCodePrompt = state.vaultCodePrompt.takeUnless { timelineState.isHistorical },
onRespondVaultCode = viewModel::respondToVaultCode,
onDismissVaultCode = viewModel::dismissVaultCode,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
openingAttachmentPath = state.openingAttachmentPath,
isCompressing = state.isCompressing && !timelineState.isHistorical,
compressionStatus = state.compressionStatus.takeUnless { timelineState.isHistorical },
onImageClick = { viewingImage = it },
speakingMessageId = speakingMessageId,
onToggleSpeak = { message ->
val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}"
speechController.toggle(
SpeechRequest(
scopeKey = scopeKey,
messageId = message.id,
text = SpeechText.stripMarkdownForSpeech(message.content),
),
)
},
replyErrorContent = replyErrorContent =
state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure -> state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
{ {
@@ -1084,8 +1039,7 @@ fun ChatScreen(
if (ConnectorUrlValidator.isValidHttpsUrl(url)) { if (ConnectorUrlValidator.isValidHttpsUrl(url)) {
try { try {
launchExternalActivity { launchExternalActivity {
connectionBrowserOperationId = operationId viewModel.connectionBrowserLaunched(operationId)
connectionBrowserDeparted = false
val intent = val intent =
Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -1093,8 +1047,7 @@ fun ChatScreen(
context.startActivity(intent) context.startActivity(intent)
} }
} catch (_: Exception) { } catch (_: Exception) {
connectionBrowserOperationId = null viewModel.connectionBrowserLaunchFailed()
connectionBrowserDeparted = false
scrollScope.launch { snackbarHostState.showSnackbar(browserLaunchError) } scrollScope.launch { snackbarHostState.showSnackbar(browserLaunchError) }
} }
} }
@@ -40,6 +40,7 @@ import com.m57.hermescontrol.data.ws.toAny
import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.notification.captureTurnBoundary import com.m57.hermescontrol.notification.captureTurnBoundary
import com.m57.hermescontrol.notification.correlationScopeId import com.m57.hermescontrol.notification.correlationScopeId
import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
import com.m57.hermescontrol.ui.common.ActionProgressController import com.m57.hermescontrol.ui.common.ActionProgressController
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -452,6 +453,17 @@ class ChatViewModel(
// ── Internal state ─────────────────────────────────────────────────── // ── Internal state ───────────────────────────────────────────────────
private val _uiState = MutableStateFlow(ChatUiState()) private val _uiState = MutableStateFlow(ChatUiState())
val connectionOperationState: StateFlow<ConnectionOperationUiState> = connectionOperationDelegate.state val connectionOperationState: StateFlow<ConnectionOperationUiState> = connectionOperationDelegate.state
private val connectionBrowserReturnTracker = BrowserReturnTracker()
fun connectionBrowserLaunched(operationId: String) = connectionBrowserReturnTracker.start(operationId)
fun connectionBrowserLaunchFailed() = connectionBrowserReturnTracker.cancel()
fun connectionBrowserPaused() = connectionBrowserReturnTracker.onPause()
fun connectionBrowserReturned(): String? = connectionBrowserReturnTracker.onResume()?.operationId
fun abandonConnectionBrowser(): Boolean = connectionBrowserReturnTracker.abandon()
private val _streamingState = MutableStateFlow(StreamingState()) private val _streamingState = MutableStateFlow(StreamingState())
@@ -738,6 +750,15 @@ class ChatViewModel(
_uiState.value, _uiState.value,
) )
val transcriptState: StateFlow<TranscriptUiState> =
combine(uiState, timelineState, streamingState) { chat, timeline, streaming ->
TranscriptUiState.resolve(chat, timeline, streaming, savingAttachmentPath = null, speakingMessageId = null)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
TranscriptUiState.resolve(_uiState.value, _timelineState.value, _streamingState.value, null, null),
)
init { init {
// Parse tool payloads off the main thread before the transcript // Parse tool payloads off the main thread before the transcript
// composes them (issue #1327); ToolBubble then reads a cache hit. // composes them (issue #1327); ToolBubble then reads a cache hit.
@@ -34,15 +34,9 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalChatFontScale import com.m57.hermescontrol.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolCallDivider import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.UserBubble import com.m57.hermescontrol.ui.chat.UserBubble
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch import com.m57.hermescontrol.ui.chat.components.ChatHistoryPrefetch
import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ChatScrollController
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
@@ -78,48 +72,36 @@ private object FullBleedContentType {
*/ */
@Composable @Composable
fun FullBleedChatList( fun FullBleedChatList(
messages: List<ChatMessage>, transcript: TranscriptUiState,
streamingState: StreamingState, actions: TranscriptActions,
isAgentTyping: Boolean,
searchState: ChatSearchState, searchState: ChatSearchState,
typingEffectEnabled: Boolean,
typingEffectDelayMs: Int,
messageStatsEnabled: Boolean = false,
showUserMessageTokens: Boolean = true,
showAssistantMessageTokens: Boolean = true,
showTokensPerSecond: Boolean = true,
maxToolCallsPerTurn: Int? = null,
isLoading: Boolean,
isLoadingOlder: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState, listState: androidx.compose.foundation.lazy.LazyListState,
scrollController: ChatScrollController, scrollController: ChatScrollController,
viewModel: ChatViewModel,
clarifyRequest: ClarifyUi? = null,
onRespondClarify: ((String) -> Unit)? = null,
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
onDismissClarify: (() -> Unit)? = null,
vaultUnlockPrompt: VaultUnlockPromptUi? = null,
onRespondVaultUnlock: ((String) -> Unit)? = null,
onDismissVaultUnlock: (() -> Unit)? = null,
vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null,
onRespondVaultSaveLogin: ((String, String) -> Unit)? = null,
onDismissVaultSaveLogin: (() -> Unit)? = null,
vaultCodePrompt: VaultCodePromptUi? = null,
onRespondVaultCode: ((String) -> Unit)? = null,
onDismissVaultCode: (() -> Unit)? = null,
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
savingAttachmentPath: String? = null,
openingAttachmentPath: String? = null,
onImageClick: (ImageViewerModel) -> Unit = {},
hasOlderMessages: Boolean = false,
pagingSessionId: String? = null,
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
replyErrorContent: (@Composable () -> Unit)? = null, replyErrorContent: (@Composable () -> Unit)? = null,
speakingMessageId: String? = null,
onToggleSpeak: ((ChatMessage) -> Unit)? = null,
isCompressing: Boolean = false,
compressionStatus: String? = null,
) { ) {
val messages = transcript.messages
val streamingState = transcript.streamingState
val isAgentTyping = transcript.isAgentTyping
val typingEffectEnabled = transcript.typingEffectEnabled
val typingEffectDelayMs = transcript.typingEffectDelayMs
val messageStatsEnabled = transcript.messageStatsEnabled
val showUserMessageTokens = transcript.showUserMessageTokens
val showAssistantMessageTokens = transcript.showAssistantMessageTokens
val showTokensPerSecond = transcript.showTokensPerSecond
val maxToolCallsPerTurn = transcript.maxToolCallsPerTurn
val isLoading = transcript.isLoading
val isLoadingOlder = transcript.isLoadingOlder
val hasOlderMessages = transcript.hasOlderMessages
val pagingSessionId = transcript.pagingSessionId
val clarifyRequest = transcript.clarifyRequest
val vaultUnlockPrompt = transcript.vaultUnlockPrompt
val vaultSaveLoginPrompt = transcript.vaultSaveLoginPrompt
val vaultCodePrompt = transcript.vaultCodePrompt
val savingAttachmentPath = transcript.savingAttachmentPath
val openingAttachmentPath = transcript.openingAttachmentPath
val isCompressing = transcript.isCompressing
val compressionStatus = transcript.compressionStatus
val speakingMessageId = transcript.speakingMessageId
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) { if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -189,9 +171,9 @@ fun FullBleedChatList(
put("clarify_bubble") { put("clarify_bubble") {
ClarifyBubble( ClarifyBubble(
clarifyRequest = clarifyRequest, clarifyRequest = clarifyRequest,
onRespondSingle = { option -> onRespondClarify?.invoke(option) }, onRespondSingle = actions.onRespondClarify,
onRespondBatch = { answers -> onRespondClarifyBatch?.invoke(answers) }, onRespondBatch = actions.onRespondClarifyBatch,
onDismiss = { onDismissClarify?.invoke() }, onDismiss = actions.onDismissClarify,
) )
} }
} }
@@ -200,8 +182,8 @@ fun FullBleedChatList(
put("vault_unlock_card") { put("vault_unlock_card") {
VaultUnlockCard( VaultUnlockCard(
prompt = vaultUnlockPrompt, prompt = vaultUnlockPrompt,
onConfirm = { password -> onRespondVaultUnlock?.invoke(password) }, onConfirm = actions.onRespondVaultUnlock,
onDismiss = { onDismissVaultUnlock?.invoke() }, onDismiss = actions.onDismissVaultUnlock,
) )
} }
} }
@@ -210,10 +192,8 @@ fun FullBleedChatList(
put("vault_save_login_card") { put("vault_save_login_card") {
VaultSaveLoginCard( VaultSaveLoginCard(
prompt = vaultSaveLoginPrompt, prompt = vaultSaveLoginPrompt,
onConfirm = { identifier, password -> onConfirm = actions.onRespondVaultSaveLogin,
onRespondVaultSaveLogin?.invoke(identifier, password) onDismiss = actions.onDismissVaultSaveLogin,
},
onDismiss = { onDismissVaultSaveLogin?.invoke() },
) )
} }
} }
@@ -222,8 +202,8 @@ fun FullBleedChatList(
put("vault_code_card") { put("vault_code_card") {
VaultCodeCard( VaultCodeCard(
prompt = vaultCodePrompt, prompt = vaultCodePrompt,
onConfirm = { code -> onRespondVaultCode?.invoke(code) }, onConfirm = actions.onRespondVaultCode,
onDismiss = { onDismissVaultCode?.invoke() }, onDismiss = actions.onDismissVaultCode,
) )
} }
} }
@@ -249,7 +229,7 @@ fun FullBleedChatList(
} }
val prefetch = remember(pagingSessionId) { ChatHistoryPrefetch() } val prefetch = remember(pagingSessionId) { ChatHistoryPrefetch() }
val canLoad = rememberUpdatedState(hasOlderMessages && !isLoadingOlder && !isLoading) val canLoad = rememberUpdatedState(hasOlderMessages && !isLoadingOlder && !isLoading)
val loadOlder = rememberUpdatedState(onLoadOlder) val loadOlder = rememberUpdatedState(actions.onLoadOlder)
val prefetchConnection = val prefetchConnection =
remember(listState, prefetch, scrollController) { remember(listState, prefetch, scrollController) {
object : NestedScrollConnection { object : NestedScrollConnection {
@@ -338,11 +318,11 @@ fun FullBleedChatList(
isCurrentMatch = isCurrentMatch =
searchState.currentMatchId != null && searchState.currentMatchId != null &&
searchState.currentMatchId == userMessage.id, searchState.currentMatchId == userMessage.id,
onOpenAttachment = viewModel::openAttachment, onOpenAttachment = actions.onOpenAttachment,
onSaveAttachment = onSaveAttachment, onSaveAttachment = actions.onSaveAttachment,
savingAttachmentPath = savingAttachmentPath, savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath, openingAttachmentPath = openingAttachmentPath,
onImageClick = onImageClick, onImageClick = actions.onImageClick,
messageStatsEnabled = messageStatsEnabled, messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens, showUserMessageTokens = showUserMessageTokens,
) )
@@ -410,11 +390,7 @@ fun FullBleedChatList(
speakingMessageId != null && speakingMessageId != null &&
speakingMessageId == proseMessage.id speakingMessageId == proseMessage.id
val toggleSpeakAction = val toggleSpeakAction =
if (onToggleSpeak != null) { { actions.onToggleSpeak(proseMessage) }
{ onToggleSpeak(proseMessage) }
} else {
null
}
FullBleedAgentMessage( FullBleedAgentMessage(
message = proseMessage, message = proseMessage,
// Highlight only bubbles that actually contain a match — // Highlight only bubbles that actually contain a match —
@@ -431,12 +407,12 @@ fun FullBleedChatList(
searchState.currentMatchId != null && searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id, searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning, showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment, onOpenAttachment = actions.onOpenAttachment,
onSaveAttachment = onSaveAttachment, onSaveAttachment = actions.onSaveAttachment,
savingAttachmentPath = savingAttachmentPath, savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath, openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null, canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick, onImageClick = actions.onImageClick,
isSpeaking = isSpeakingThis, isSpeaking = isSpeakingThis,
onToggleSpeak = toggleSpeakAction, onToggleSpeak = toggleSpeakAction,
messageStatsEnabled = messageStatsEnabled, messageStatsEnabled = messageStatsEnabled,
@@ -480,7 +456,7 @@ fun FullBleedChatList(
} else { } else {
FullBleedSystemEvent( FullBleedSystemEvent(
message = sysMessage, message = sysMessage,
onRespondApproval = viewModel::respondToApproval, onRespondApproval = actions.onRespondApproval,
) )
} }
} }
@@ -0,0 +1,99 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
/** The transcript's resolved, read-only state; the list never receives the whole chat ViewModel. */
data class TranscriptUiState(
val messages: List<ChatMessage>,
val streamingState: StreamingState,
val isAgentTyping: Boolean,
val typingEffectEnabled: Boolean,
val typingEffectDelayMs: Int,
val messageStatsEnabled: Boolean,
val showUserMessageTokens: Boolean,
val showAssistantMessageTokens: Boolean,
val showTokensPerSecond: Boolean,
val maxToolCallsPerTurn: Int?,
val isLoading: Boolean,
val isLoadingOlder: Boolean,
val hasOlderMessages: Boolean,
val pagingSessionId: String?,
val clarifyRequest: ClarifyUi?,
val vaultUnlockPrompt: VaultUnlockPromptUi?,
val vaultSaveLoginPrompt: VaultSaveLoginPromptUi?,
val vaultCodePrompt: VaultCodePromptUi?,
val savingAttachmentPath: String?,
val openingAttachmentPath: String?,
val isCompressing: Boolean,
val compressionStatus: String?,
val speakingMessageId: String?,
) {
companion object {
/** Resolve historical versus live mode at the state boundary, not in the screen call. */
fun resolve(
chat: ChatUiState,
timeline: ChatTimelineState,
streaming: StreamingState,
savingAttachmentPath: String?,
speakingMessageId: String?,
): TranscriptUiState {
val historical = timeline.isHistorical
return TranscriptUiState(
messages = timeline.historyMessages ?: chat.messages,
streamingState = if (historical) StreamingState() else streaming,
isAgentTyping = !historical && chat.isAgentTyping,
typingEffectEnabled = !historical && chat.typingEffectEnabled,
typingEffectDelayMs = chat.typingEffectDelayMs,
messageStatsEnabled = chat.messageStatsEnabled,
showUserMessageTokens = chat.showUserMessageTokens,
showAssistantMessageTokens = chat.showAssistantMessageTokens,
showTokensPerSecond = chat.showTokensPerSecond,
maxToolCallsPerTurn = chat.maxToolCallsPerTurn,
isLoading = !historical && chat.isLoading,
isLoadingOlder = !historical && chat.isLoadingOlder,
hasOlderMessages = !historical && chat.hasOlderMessages,
pagingSessionId =
chat.currentSessionId?.let { id ->
if (historical) "$id:history:${timeline.historyAnchorRowId}" else id
},
clarifyRequest = chat.clarifyRequest.takeUnless { historical },
vaultUnlockPrompt = chat.vaultUnlockPrompt.takeUnless { historical },
vaultSaveLoginPrompt = chat.vaultSaveLoginPrompt.takeUnless { historical },
vaultCodePrompt = chat.vaultCodePrompt.takeUnless { historical },
savingAttachmentPath = savingAttachmentPath ?: chat.savingAttachmentPath,
openingAttachmentPath = chat.openingAttachmentPath,
isCompressing = !historical && chat.isCompressing,
compressionStatus = chat.compressionStatus.takeUnless { historical },
speakingMessageId = speakingMessageId,
)
}
}
}
/** All effects the transcript can request. The UI never reaches through to [ChatUiState]'s owner. */
class TranscriptActions(
val onLoadOlder: () -> Unit,
val onOpenAttachment: (Attachment) -> Unit,
val onSaveAttachment: (Attachment) -> Unit,
val onImageClick: (ImageViewerModel) -> Unit,
val onRespondApproval: (String) -> Unit,
val onRespondClarify: (String) -> Unit,
val onRespondClarifyBatch: (Map<String, String>) -> Unit,
val onDismissClarify: () -> Unit,
val onRespondVaultUnlock: (String) -> Unit,
val onDismissVaultUnlock: () -> Unit,
val onRespondVaultSaveLogin: (String, String) -> Unit,
val onDismissVaultSaveLogin: () -> Unit,
val onRespondVaultCode: (String) -> Unit,
val onDismissVaultCode: () -> Unit,
val onToggleSpeak: (ChatMessage) -> Unit,
)
@@ -0,0 +1,62 @@
package com.m57.hermescontrol.ui.chat
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 BrowserReturnTrackerTest {
@Test
fun `browser return requires a departure and consumes the lease once`() {
val tracker = BrowserReturnTracker()
tracker.start("operation-1")
assertNull(tracker.onResume())
tracker.onPause()
assertEquals("operation-1", tracker.onResume()?.operationId)
assertNull(tracker.onResume())
assertFalse(tracker.abandon())
}
@Test
fun `legacy browser without operation id still reports return`() {
val tracker = BrowserReturnTracker()
tracker.start()
tracker.onPause()
assertTrue(tracker.onResume() != null)
assertNull(tracker.onResume())
}
@Test
fun `failed launch clears tracking and does not release a departed lease`() {
val tracker = BrowserReturnTracker()
tracker.start("operation-1")
tracker.onPause()
tracker.cancel()
assertNull(tracker.onResume())
assertFalse(tracker.abandon())
}
@Test
fun `non configuration disposal releases only a departed lease and clears the id`() {
val tracker = BrowserReturnTracker()
tracker.start("operation-1")
assertFalse(tracker.abandon())
tracker.start("operation-2")
tracker.onPause()
assertTrue(tracker.abandon())
assertFalse(tracker.abandon())
assertNull(tracker.onResume())
}
@Test
fun `new launch resets departed state from the previous launch`() {
val tracker = BrowserReturnTracker()
tracker.start("first")
tracker.onPause()
tracker.start("second")
assertNull(tracker.onResume())
tracker.onPause()
assertEquals("second", tracker.onResume()?.operationId)
}
}
@@ -0,0 +1,88 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.StreamingState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class TranscriptUiStateTest {
private val live = ChatMessage(id = "live", role = MessageRole.USER, content = "hello")
private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older")
private val clarify = ClarifyUi(text = "choose")
@Test
fun `live state preserves prompts streaming and paging actions`() {
val stream = StreamingState()
val result =
TranscriptUiState.resolve(
chat =
ChatUiState(
messages = listOf(live),
currentSessionId = "session",
isAgentTyping = true,
isLoading = true,
isLoadingOlder = true,
hasOlderMessages = true,
clarifyRequest = clarify,
isCompressing = true,
savingAttachmentPath = "server-path",
),
timeline = ChatTimelineState(),
streaming = stream,
savingAttachmentPath = "local-path",
speakingMessageId = "live",
)
assertEquals(listOf(live), result.messages)
assertSame(stream, result.streamingState)
assertEquals("session", result.pagingSessionId)
assertTrue(result.isAgentTyping)
assertTrue(result.isLoading)
assertTrue(result.isLoadingOlder)
assertTrue(result.hasOlderMessages)
assertSame(clarify, result.clarifyRequest)
assertTrue(result.isCompressing)
assertEquals("local-path", result.savingAttachmentPath)
assertEquals("live", result.speakingMessageId)
}
@Test
fun `historical state isolates live tail prompts loading and compression`() {
val result =
TranscriptUiState.resolve(
chat =
ChatUiState(
messages = listOf(live),
currentSessionId = "session",
isAgentTyping = true,
isLoading = true,
isLoadingOlder = true,
hasOlderMessages = true,
clarifyRequest = clarify,
isCompressing = true,
compressionStatus = "compressing",
),
timeline = ChatTimelineState(historyMessages = listOf(historical), historyAnchorRowId = 42),
streaming = StreamingState(streamingMessage = live),
savingAttachmentPath = null,
speakingMessageId = null,
)
assertEquals(listOf(historical), result.messages)
assertNull(result.streamingState.streamingMessage)
assertEquals("session:history:42", result.pagingSessionId)
assertFalse(result.isAgentTyping)
assertFalse(result.isLoading)
assertFalse(result.isLoadingOlder)
assertFalse(result.hasOlderMessages)
assertNull(result.clarifyRequest)
assertFalse(result.isCompressing)
assertNull(result.compressionStatus)
}
}