This commit is contained in:
12 files changed
+491
-171
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
|
||||||
|
|||||||
+41
-12
@@ -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(
|
||||||
|
transcript =
|
||||||
|
TranscriptUiState.resolve(
|
||||||
|
chat =
|
||||||
|
ChatUiState(
|
||||||
messages = messages.value,
|
messages = messages.value,
|
||||||
streamingState = streaming.value,
|
|
||||||
isAgentTyping = tailKey != null,
|
isAgentTyping = tailKey != null,
|
||||||
searchState = ChatSearchState(),
|
|
||||||
typingEffectEnabled = false,
|
typingEffectEnabled = false,
|
||||||
typingEffectDelayMs = 30,
|
typingEffectDelayMs = 30,
|
||||||
isLoading = false,
|
|
||||||
isLoadingOlder = loading.value,
|
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(),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-13
@@ -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,25 +76,18 @@ class FullBleedChatListTest {
|
|||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
val listState = LazyListState()
|
val listState = LazyListState()
|
||||||
FullBleedChatList(
|
FullBleedChatList(
|
||||||
|
transcript =
|
||||||
|
TranscriptUiState.resolve(
|
||||||
|
chat =
|
||||||
|
ChatUiState(
|
||||||
messages = messages,
|
messages = messages,
|
||||||
streamingState = streamingState,
|
|
||||||
isAgentTyping = isAgentTyping,
|
isAgentTyping = isAgentTyping,
|
||||||
searchState = ChatSearchState(),
|
|
||||||
typingEffectEnabled = false,
|
typingEffectEnabled = false,
|
||||||
typingEffectDelayMs = 30,
|
typingEffectDelayMs = 30,
|
||||||
messageStatsEnabled = messageStatsEnabled,
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
showUserMessageTokens = showUserMessageTokens,
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||||
showTokensPerSecond = showTokensPerSecond,
|
showTokensPerSecond = showTokensPerSecond,
|
||||||
isLoading = false,
|
|
||||||
isLoadingOlder = false,
|
|
||||||
listState = listState,
|
|
||||||
scrollController =
|
|
||||||
ChatScrollController(
|
|
||||||
listState = listState,
|
|
||||||
scope = CoroutineScope(Dispatchers.Main.immediate),
|
|
||||||
),
|
|
||||||
viewModel = mockk<ChatViewModel>(relaxed = true),
|
|
||||||
clarifyRequest =
|
clarifyRequest =
|
||||||
if (clarify) {
|
if (clarify) {
|
||||||
com.m57.hermescontrol.ui.chat.ClarifyUi(
|
com.m57.hermescontrol.ui.chat.ClarifyUi(
|
||||||
@@ -104,6 +97,20 @@ class FullBleedChatListTest {
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
timeline = ChatTimelineState(),
|
||||||
|
streaming = streamingState,
|
||||||
|
savingAttachmentPath = null,
|
||||||
|
speakingMessageId = null,
|
||||||
|
),
|
||||||
|
actions = testTranscriptActions(),
|
||||||
|
searchState = ChatSearchState(),
|
||||||
|
listState = listState,
|
||||||
|
scrollController =
|
||||||
|
ChatScrollController(
|
||||||
|
listState = listState,
|
||||||
|
scope = CoroutineScope(Dispatchers.Main.immediate),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -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,51 +734,27 @@ 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,
|
||||||
searchState = searchState,
|
speakingMessageId = speakingMessageId,
|
||||||
typingEffectEnabled = state.typingEffectEnabled && !timelineState.isHistorical,
|
),
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
actions =
|
||||||
messageStatsEnabled = state.messageStatsEnabled,
|
TranscriptActions(
|
||||||
showUserMessageTokens = state.showUserMessageTokens,
|
onLoadOlder = viewModel::loadOlderMessages,
|
||||||
showAssistantMessageTokens = state.showAssistantMessageTokens,
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
showTokensPerSecond = state.showTokensPerSecond,
|
onSaveAttachment = onSaveAttachment,
|
||||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
onImageClick = { viewingImage = it },
|
||||||
isLoading = state.isLoading && !timelineState.isHistorical,
|
onRespondApproval = viewModel::respondToApproval,
|
||||||
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,
|
|
||||||
scrollController = scrollController,
|
|
||||||
viewModel = viewModel,
|
|
||||||
clarifyRequest = state.clarifyRequest.takeUnless { timelineState.isHistorical },
|
|
||||||
onRespondClarify = viewModel::respondToClarify,
|
onRespondClarify = viewModel::respondToClarify,
|
||||||
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
||||||
onDismissClarify = viewModel::dismissClarify,
|
onDismissClarify = viewModel::dismissClarify,
|
||||||
vaultUnlockPrompt = state.vaultUnlockPrompt.takeUnless { timelineState.isHistorical },
|
|
||||||
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
|
onRespondVaultUnlock = viewModel::respondToVaultUnlock,
|
||||||
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
|
onDismissVaultUnlock = viewModel::dismissVaultUnlock,
|
||||||
vaultSaveLoginPrompt = state.vaultSaveLoginPrompt.takeUnless { timelineState.isHistorical },
|
|
||||||
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
|
onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin,
|
||||||
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
|
onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin,
|
||||||
vaultCodePrompt = state.vaultCodePrompt.takeUnless { timelineState.isHistorical },
|
|
||||||
onRespondVaultCode = viewModel::respondToVaultCode,
|
onRespondVaultCode = viewModel::respondToVaultCode,
|
||||||
onDismissVaultCode = viewModel::dismissVaultCode,
|
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 ->
|
onToggleSpeak = { message ->
|
||||||
val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}"
|
val scopeKey = "${dataScope?.inMemoryKey(localKey = activeSessionId ?: "none")}"
|
||||||
speechController.toggle(
|
speechController.toggle(
|
||||||
@@ -814,6 +765,10 @@ fun ChatScreen(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
searchState = searchState,
|
||||||
|
listState = listState,
|
||||||
|
scrollController = scrollController,
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user