diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d17935d..48ce2ab6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,7 +110,7 @@ jobs: uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.version.outputs.tag }} - name: Hermes Mobile ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} generate_release_notes: true files: | release/hermes-mobile-${{ steps.version.outputs.tag }}.apk diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt new file mode 100644 index 00000000..19530c0d --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt @@ -0,0 +1,346 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.test.espresso.Espresso.pressBack +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.ws.CommandCatalog +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Interaction coverage for the chat composer controls: the morphing action + * button (dictate / send), stopping dictation from either mic position, and + * both halves of the combined model/reasoning pill. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class ComposerInteractionTest { + @get:Rule + val composeTestRule = createComposeRule() + + private var micTaps = 0 + private var sends = 0 + private var modelTaps = 0 + private var selectedLevel: String? = null + private var cameraTaps = 0 + private var photosTaps = 0 + private var fileTaps = 0 + + /** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */ + private fun setComposer( + reasoningLevel: String? = "medium", + model: String = "openai/gpt-5.5", + composerWidth: Dp? = null, + modelAfterTap: String? = null, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, + ) { + composeTestRule.setContent { + var input by remember { mutableStateOf(TextFieldValue("")) } + var listening by remember { mutableStateOf(false) } + var currentModel by remember { mutableStateOf(model) } + + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + val composer: @Composable () -> Unit = { + ChatInputBar( + inputFieldValue = input, + onInputChange = { input = it }, + onSend = { + sends++ + input = TextFieldValue("") + }, + onMicTap = { + micTaps++ + listening = !listening + }, + isListening = listening, + isAgentTyping = false, + isConnected = true, + commandCatalog = CommandCatalog(), + currentSessionModel = currentModel, + reasoningLevel = reasoningLevel, + onModelTap = { + modelTaps++ + modelAfterTap?.let { currentModel = it } + }, + onReasoningTap = { selectedLevel = it }, + onCameraTap = { cameraTaps++ }, + onImageTap = { photosTaps++ }, + onFileTap = { fileTaps++ }, + ) + } + if (composerWidth == null) { + composer() + } else { + Box(Modifier.width(composerWidth).testTag("composer_toolbar")) { composer() } + } + } + } + } + + private fun openAttachmentTray() { + composeTestRule.onNodeWithTag("attachment_button").performClick() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.onNodeWithTag("attachment_tray").assertIsDisplayed() + } + + private fun closeAttachmentTray() { + composeTestRule.onNodeWithTag("attachment_button").performClick() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() + } + + @Test + fun emptyInput_actionButtonStartsDictation() { + setComposer() + + composeTestRule.onNodeWithTag("send_button").assertDoesNotExist() + composeTestRule.onNodeWithTag("mic_button").performClick() + + composeTestRule.runOnIdle { + assertEquals("action button must start dictation", 1, micTaps) + assertEquals("action button must not send without input", 0, sends) + } + } + + @Test + fun typedInput_actionButtonSends_andMicStillDictates() { + setComposer() + + composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes") + composeTestRule.onNodeWithTag("send_button").performClick() + composeTestRule.runOnIdle { + assertEquals("send button must send", 1, sends) + assertEquals("send must not trigger dictation", 0, micTaps) + } + + composeTestRule.onNodeWithTag("chat_input").performTextInput("Again") + composeTestRule.onNodeWithTag("mic_button").performClick() + composeTestRule.runOnIdle { + assertEquals("mic next to send must start dictation", 1, micTaps) + assertEquals("mic must not send", 1, sends) + } + } + + @Test + fun emptyInput_stopButtonStopsDictation() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performClick() + composeTestRule.onNodeWithTag("mic_button").assertDoesNotExist() + composeTestRule.onNodeWithTag("mic_stop_button").performClick() + + composeTestRule.onNodeWithTag("mic_stop_button").assertDoesNotExist() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + composeTestRule.runOnIdle { assertEquals("start + stop must both reach onMicTap", 2, micTaps) } + } + + @Test + fun typedInput_stopButtonStopsDictation_withoutSending() { + setComposer() + + composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes") + composeTestRule.onNodeWithTag("mic_button").performClick() + composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_stop_button").performClick() + + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + composeTestRule.runOnIdle { + assertEquals("start + stop must both reach onMicTap", 2, micTaps) + assertEquals("stopping dictation must not send", 0, sends) + } + } + + @Test + fun modelSideOfPill_opensModelPicker_only() { + setComposer() + + composeTestRule.onNodeWithTag("model_chip").performClick() + + composeTestRule.onNodeWithText("REASONING").assertDoesNotExist() + composeTestRule.runOnIdle { + assertEquals("model side must open the picker", 1, modelTaps) + assertEquals("model side must not change reasoning", null, selectedLevel) + } + } + + @Test + fun reasoningSideOfPill_opensMenu_andSelectsLevel() { + setComposer(reasoningLevel = "medium") + + composeTestRule.onNodeWithTag("reasoning_chip").performClick() + composeTestRule.onNodeWithText("REASONING").assertIsDisplayed() + composeTestRule.onNodeWithText("High").performClick() + + composeTestRule.onNodeWithText("REASONING").assertDoesNotExist() + composeTestRule.runOnIdle { + assertEquals("reasoning menu must report the picked level", "high", selectedLevel) + assertEquals("reasoning side must not open the model picker", 0, modelTaps) + } + } + + @Test + fun longModelName_narrowComposer_scrollsWithoutOpeningPicker_orHidingControls() { + val longModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters" + setComposer(model = longModel, composerWidth = 280.dp) + + composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + val modelText = composeTestRule.onNodeWithText(longModel) + val leftBefore = modelText.getUnclippedBoundsInRoot().left + + composeTestRule.onNodeWithTag("model_chip").performTouchInput { swipeLeft() } + val leftAfter = modelText.getUnclippedBoundsInRoot().left + + composeTestRule.runOnIdle { + assertEquals("swiping the model must not open the picker", 0, modelTaps) + } + check(leftAfter < leftBefore) { "model text did not move after horizontal swipe" } + composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + } + + @Test + fun shortModelName_wideComposerKeepsPillCompact() { + setComposer(composerWidth = 420.dp) + + val toolbarBounds = + composeTestRule + .onNodeWithTag("composer_toolbar", useUnmergedTree = true) + .getUnclippedBoundsInRoot() + val toolbarWidth = toolbarBounds.right - toolbarBounds.left + val modelChip = composeTestRule.onNodeWithTag("model_chip").getUnclippedBoundsInRoot() + val reasoningChip = composeTestRule.onNodeWithTag("reasoning_chip").getUnclippedBoundsInRoot() + val actionButton = composeTestRule.onNodeWithTag("mic_button").getUnclippedBoundsInRoot() + val pillWidth = maxOf(modelChip.right, reasoningChip.right) - minOf(modelChip.left, reasoningChip.left) + + check(pillWidth < toolbarWidth * 0.8f) { + "short model pill should remain content-sized, width=$pillWidth toolbar=$toolbarWidth" + } + check(actionButton.right > toolbarBounds.right - 32.dp) { + "trailing action should stay near the toolbar edge, right=${actionButton.right} toolbarRight=${toolbarBounds.right}" + } + } + + @Test + fun changingModel_resetsScrolledLabelToTheStart() { + val firstModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters" + val secondModel = "anthropic/another-extremely-long-model-name-preview-2026-with-extra-characters" + setComposer(model = firstModel, modelAfterTap = secondModel, composerWidth = 280.dp) + + val firstText = composeTestRule.onNodeWithText(firstModel) + val leftBefore = firstText.getUnclippedBoundsInRoot().left + composeTestRule.onNodeWithTag("model_chip").performTouchInput { swipeLeft() } + check(firstText.getUnclippedBoundsInRoot().left < leftBefore) { + "first model text did not move after horizontal swipe" + } + + composeTestRule.onNodeWithTag("model_chip").performClick() + val secondText = composeTestRule.onNodeWithText(secondModel) + val leftAfterModelChange = secondText.getUnclippedBoundsInRoot().left + + check(leftAfterModelChange >= leftBefore - 1.dp) { + "new model label should reset to its initial left position" + } + composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + composeTestRule.runOnIdle { + assertEquals("model selection should still invoke the picker", 1, modelTaps) + } + } + + @Test + fun attachmentButton_togglesHorizontalTray() { + setComposer() + + openAttachmentTray() + composeTestRule.onNodeWithText("Camera").assertIsDisplayed() + composeTestRule.onNodeWithText("Photos").assertIsDisplayed() + composeTestRule.onNodeWithText("File").assertIsDisplayed() + + closeAttachmentTray() + } + + @Test + fun attachmentTray_shrinksWithinConstrainedWidth() { + setComposer(composerWidth = 180.dp) + + openAttachmentTray() + composeTestRule.onNodeWithText("Camera").assertIsDisplayed() + composeTestRule.onNodeWithText("Photos").assertIsDisplayed() + composeTestRule.onNodeWithText("File").assertIsDisplayed() + } + + @Test + fun attachmentTray_rtlKeepsActionsAccessible() { + setComposer(layoutDirection = LayoutDirection.Rtl) + + openAttachmentTray() + composeTestRule.onNodeWithText("Camera").assertIsDisplayed() + composeTestRule.onNodeWithText("Photos").assertIsDisplayed() + composeTestRule.onNodeWithText("File").assertIsDisplayed() + } + + @Test + fun attachmentTray_actionsDispatchAndCloseTray() { + setComposer() + + openAttachmentTray() + composeTestRule.onNodeWithTag("attachment_action_camera").performClick() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.runOnIdle { + assertEquals("Camera must dispatch its callback", 1, cameraTaps) + assertEquals("Camera must not dispatch Photos", 0, photosTaps) + assertEquals("Camera must not dispatch File", 0, fileTaps) + } + composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() + + openAttachmentTray() + composeTestRule.onNodeWithTag("attachment_action_photos").performClick() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.runOnIdle { assertEquals("Photos must dispatch its callback", 1, photosTaps) } + composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() + + openAttachmentTray() + composeTestRule.onNodeWithTag("attachment_action_file").performClick() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.runOnIdle { assertEquals("File must dispatch its callback", 1, fileTaps) } + composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() + } + + @Test + fun attachmentTray_dismissesOnBackPress() { + setComposer() + + openAttachmentTray() + pressBack() + composeTestRule.mainClock.advanceTimeBy(300) + + composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt index d94af3c5..9a4ef7f1 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt @@ -1,17 +1,25 @@ package com.m57.hermescontrol.ui.chat.fullbleed +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.components.ChatScrollController import io.mockk.mockk @@ -42,24 +50,44 @@ class FullBleedChatListTest { content: String = "content-$id", toolStatus: ToolStatus? = null, isStreaming: Boolean = false, - ) = ChatMessage(id = id, role = role, content = content, toolStatus = toolStatus, isStreaming = isStreaming) + tokenCount: Int? = null, + tps: Double? = null, + ) = ChatMessage( + id = id, + role = role, + content = content, + toolStatus = toolStatus, + isStreaming = isStreaming, + tokenCount = tokenCount, + tps = tps, + ) private fun render( messages: List, streamingMessage: ChatMessage? = null, + streamingState: StreamingState = StreamingState(streamingMessage = streamingMessage), + isAgentTyping: Boolean = streamingMessage?.isStreaming == true, clarify: Boolean = false, + messageStatsEnabled: Boolean = false, + showUserMessageTokens: Boolean = true, + showAssistantMessageTokens: Boolean = true, + showTokensPerSecond: Boolean = true, ) { composeTestRule.setContent { val listState = LazyListState() FullBleedChatList( messages = messages, - streamingMessage = streamingMessage, + streamingState = streamingState, + isAgentTyping = isAgentTyping, searchState = ChatSearchState(), typingEffectEnabled = false, typingEffectDelayMs = 30, + messageStatsEnabled = messageStatsEnabled, + showUserMessageTokens = showUserMessageTokens, + showAssistantMessageTokens = showAssistantMessageTokens, + showTokensPerSecond = showTokensPerSecond, isLoading = false, isLoadingOlder = false, - isDark = false, listState = listState, scrollController = ChatScrollController( @@ -115,7 +143,7 @@ class FullBleedChatListTest { } @Test - fun turnHeader_renderedOncePerAgentTurn() { + fun assistantHeader_isRemovedFromEveryAgentTurn() { render( listOf( msg("a1", MessageRole.ASSISTANT), @@ -125,8 +153,148 @@ class FullBleedChatListTest { msg("a3", MessageRole.ASSISTANT), ), ) - // Two agent turns (a1..a2, a3) -> two turn headers (one per turn). - composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(2) + composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(0) + composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist() + } + + @Test + fun messageStats_masterOff_hidesAllMetadata() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 123), + msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5), + ), + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist() + } + + @Test + fun messageStats_userTokensOnly_showsOnlyUserTokens() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 123), + msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5), + ), + messageStatsEnabled = true, + showAssistantMessageTokens = false, + showTokensPerSecond = false, + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist() + } + + @Test + fun messageStats_assistantTokensOnly_showsOnlyAssistantTokens() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 123), + msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5), + ), + messageStatsEnabled = true, + showUserMessageTokens = false, + showTokensPerSecond = false, + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist() + } + + @Test + fun messageStats_tpsOnly_showsOnlyAssistantTps() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 123), + msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5), + ), + messageStatsEnabled = true, + showUserMessageTokens = false, + showAssistantMessageTokens = false, + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_tps").assertIsDisplayed() + } + + @Test + fun messageStats_allEnabled_showsUserTokensAssistantTokensAndTps() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 123), + msg("a1", MessageRole.ASSISTANT, tokenCount = 456, tps = 42.5), + ), + messageStatsEnabled = true, + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_tps").assertIsDisplayed() + composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(0) + composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist() + } + + @Test + fun messageStats_nullAndZeroValues_areHidden() { + render( + messages = + listOf( + msg("u1", MessageRole.USER, tokenCount = 0), + msg("a1", MessageRole.ASSISTANT, tokenCount = 0, tps = 0.0), + ), + messageStatsEnabled = true, + ) + composeTestRule.onNodeWithTag("bubble_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist() + } + + @Test + fun messageStats_streamingAssistant_hidesFinalMetadata() { + render( + messages = listOf(msg("u1", MessageRole.USER)), + streamingMessage = + msg( + "s1", + MessageRole.ASSISTANT, + content = "", + isStreaming = true, + tokenCount = 456, + tps = 42.5, + ), + isAgentTyping = true, + messageStatsEnabled = true, + ) + composeTestRule.onNodeWithTag("fullbleed_token_count").assertDoesNotExist() + composeTestRule.onNodeWithTag("fullbleed_tps").assertDoesNotExist() + composeTestRule.onNodeWithTag("typing_indicator").assertIsDisplayed() + } + + @Test + fun assistantStats_wrapInsideConstrainedWidth() { + composeTestRule.setContent { + Box( + modifier = Modifier.width(220.dp).testTag("stats_host"), + ) { + FullBleedAgentMessage( + message = msg("a1", MessageRole.ASSISTANT, tokenCount = 123456789, tps = 9876.5), + messageStatsEnabled = true, + ) + } + } + composeTestRule.waitForIdle() + val host = composeTestRule.onNodeWithTag("stats_host").getUnclippedBoundsInRoot() + val token = composeTestRule.onNodeWithTag("fullbleed_token_count").getUnclippedBoundsInRoot() + val tps = composeTestRule.onNodeWithTag("fullbleed_tps").getUnclippedBoundsInRoot() + assert(token.left >= host.left) + assert(token.right <= host.right) + assert(tps.left >= host.left) + assert(tps.right <= host.right) } @Test @@ -139,4 +307,55 @@ class FullBleedChatListTest { composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed() composeTestRule.onNodeWithTag("clarify_bubble").assertIsDisplayed() } + + @Test + fun emptyStreamingMessage_showsTypingStatus_withoutEmptyAssistantProse() { + render( + messages = listOf(msg("u1", MessageRole.USER)), + streamingMessage = msg("s1", MessageRole.ASSISTANT, content = "", isStreaming = true), + isAgentTyping = true, + ) + composeTestRule.onNodeWithTag("typing_indicator").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_agent_message").assertDoesNotExist() + } + + @Test + fun reasoningStreaming_showsThinkingStatus_andKeepsReasoningCard() { + val streaming = + msg( + "s1", + MessageRole.ASSISTANT, + content = "", + isStreaming = true, + ).copy(reasoningText = "step") + render( + messages = listOf(msg("u1", MessageRole.USER)), + streamingMessage = streaming, + streamingState = + StreamingState( + streamingMessage = streaming, + isReasoning = true, + reasoningText = "step", + ), + isAgentTyping = true, + ) + composeTestRule.onNodeWithTag("agent_status_thinking").assertIsDisplayed() + composeTestRule.onNodeWithTag("reasoning_card").assertIsDisplayed() + composeTestRule.onNodeWithTag("typing_indicator").assertDoesNotExist() + } + + @Test + fun runningTool_showsToolStatus_insteadOfTypingDots() { + render( + messages = + listOf( + msg("u1", MessageRole.USER), + msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING).copy(toolName = "web_search"), + ), + isAgentTyping = true, + ) + composeTestRule.onNodeWithTag("agent_status_tool").assertIsDisplayed() + composeTestRule.onNodeWithText("Searching…").assertIsDisplayed() + composeTestRule.onNodeWithTag("typing_indicator").assertDoesNotExist() + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt index c2b9a66e..1ddf87c3 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt @@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.model import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.data.model.ModelProvider @@ -62,6 +64,34 @@ class ModelScreenTest { composeTestRule.onNodeWithText("Pinned Models").assertDoesNotExist() } + @Test + fun modelScreen_providerExpansionArrowMatchesState() { + uiStateFlow.value = + ModelUiState( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-4"), + ), + ), + ) + + composeTestRule.setContent { + ModelScreen( + onOpenDrawer = {}, + viewModel = mockViewModel, + ) + } + + composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist() + composeTestRule.onNodeWithContentDescription("Expand OpenAI models").performClick() + composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Collapse OpenAI models").performClick() + composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist() + } + @Test fun modelScreen_rendersPinnedModels_whenPresent() { uiStateFlow.value = diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialogTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialogTest.kt new file mode 100644 index 00000000..cfffcad7 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialogTest.kt @@ -0,0 +1,151 @@ +package com.m57.hermescontrol.ui.model.components + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.theme.HermesControlTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ModelPickerDialogTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun providers_startCollapsed_andOnlyOneProviderExpands() { + composeTestRule.setContent { + HermesControlTheme { + ModelPickerDialog( + providers = + listOf( + ModelProvider(slug = "openai", name = "OpenAI", models = listOf("gpt-4")), + ModelProvider(slug = "anthropic", name = "Anthropic", models = listOf("claude-3")), + ), + title = "Choose model", + onSelect = { _, _ -> }, + onDismiss = {}, + ) + } + } + + composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist() + composeTestRule.onNodeWithText("claude-3").assertDoesNotExist() + composeTestRule.onAllNodesWithText("1 model").assertCountEquals(2) + composeTestRule.onNodeWithContentDescription("Expand OpenAI models").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Expand Anthropic models").assertIsDisplayed() + + composeTestRule.onNodeWithContentDescription("Expand OpenAI models").performClick() + composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Collapse OpenAI models").assertIsDisplayed() + composeTestRule.onNodeWithText("claude-3").assertDoesNotExist() + + composeTestRule.onNodeWithContentDescription("Expand Anthropic models").performClick() + composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist() + composeTestRule.onNodeWithText("claude-3").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Collapse Anthropic models").assertIsDisplayed() + + composeTestRule.onNodeWithContentDescription("Collapse Anthropic models").performClick() + composeTestRule.onNodeWithText("claude-3").assertDoesNotExist() + composeTestRule.onNodeWithContentDescription("Expand Anthropic models").assertIsDisplayed() + } + + @Test + fun search_filtersModelsAndCountsMatches_beforeSelection() { + var selectedProvider = "" + var selectedModel = "" + + composeTestRule.setContent { + HermesControlTheme { + ModelPickerDialog( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-4", "embedding-3"), + ), + ModelProvider(slug = "anthropic", name = "Anthropic", models = listOf("claude-3")), + ), + title = "Choose model", + onSelect = { provider, model -> + selectedProvider = provider + selectedModel = model + }, + onDismiss = {}, + ) + } + } + + composeTestRule.onNodeWithText("2 models").assertIsDisplayed() + composeTestRule.onNode(hasSetTextAction()).performTextInput("gpt-4") + composeTestRule.onNodeWithText("1 model").assertIsDisplayed() + composeTestRule.onNodeWithText("embedding-3").assertDoesNotExist() + composeTestRule.onAllNodesWithText("gpt-4").assertCountEquals(1) + + composeTestRule.onNodeWithContentDescription("Expand OpenAI models").performClick() + composeTestRule.onAllNodesWithText("gpt-4").get(1).performClick() + + assertEquals("openai", selectedProvider) + assertEquals("gpt-4", selectedModel) + } + + @Test + fun pinnedModels_remainVisibleAndPreserveCapabilitiesAndCallbacks() { + var toggledProvider = "" + var toggledModel = "" + var selectedProvider = "" + var selectedModel = "" + + composeTestRule.setContent { + HermesControlTheme { + ModelPickerDialog( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-4"), + capabilities = mapOf("gpt-4" to ModelCapabilities(reasoning = false)), + ), + ), + title = "Choose model", + pinnedModels = listOf(PinnedModel(providerSlug = "openai", modelName = "gpt-4")), + onPinToggle = { provider, model -> + toggledProvider = provider + toggledModel = model + }, + onSelect = { provider, model -> + selectedProvider = provider + selectedModel = model + }, + onDismiss = {}, + ) + } + } + + composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed() + composeTestRule.onNodeWithText("no reasoning").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Unpin model").performClick() + composeTestRule.onNodeWithText("gpt-4").performClick() + + assertEquals("openai", toggledProvider) + assertEquals("gpt-4", toggledModel) + assertEquals("openai", selectedProvider) + assertEquals("gpt-4", selectedModel) + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionCardTest.kt new file mode 100644 index 00000000..cc237c75 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionCardTest.kt @@ -0,0 +1,173 @@ +package com.m57.hermescontrol.ui.sessions + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.SessionInfo +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.ui.sessions.components.SessionCard +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * History card content: project, activity age (with a spoken description), preview and footer. + * The card is clickable, so it merges its children; finders read the unmerged tree. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class SessionCardTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val nowMillis = 1_800_000_000_000L + private var clicks = 0 + + private fun setCard( + session: SessionInfo, + project: SessionProject?, + liveStatus: SessionLiveStatus? = null, + fontScale: Float = 1f, + ) { + composeTestRule.setContent { + val density = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale)) { + HermesControlTheme { + Box(modifier = Modifier.width(360.dp)) { + SessionCard( + session = session, + displayTitle = session.title ?: "Untitled", + branchStem = null, + query = "", + isSelecting = false, + isSelected = false, + isDeleting = false, + isPinned = false, + liveStatus = liveStatus, + project = project, + nowMillis = nowMillis, + highlightBackground = Color.Unspecified, + highlightForeground = Color.Unspecified, + onCardClick = { clicks++ }, + onToggleSelection = {}, + onSelect = {}, + onRename = {}, + onTogglePin = {}, + onToggleHide = {}, + onDelete = {}, + ) + } + } + } + } + } + + // Fixed 360dp width so the fit doesn't depend on the device screen. The one-line case keeps + // plenty of slack (one badge) because font metrics vary between devices and emulators. + private fun longModelSession(hidden: Boolean) = + SessionInfo( + id = "long", + title = "Draft the release notes", + model = "openrouter/anthropic/a-very-long-model-identifier-name", + message_count = 128, + hidden = hidden, + ) + + private fun bounds(tag: String) = + composeTestRule.onNodeWithTag(tag, useUnmergedTree = true).getUnclippedBoundsInRoot() + + @Test + fun longModel_shrinksInsteadOfWrappingTheBadges() { + setCard(session = longModelSession(hidden = false), project = null, liveStatus = SessionLiveStatus.WORKING) + + val count = bounds("session_footer_count_long") + val badge = bounds("session_live_status_long") + val countCenter = (count.top + count.bottom) / 2 + val badgeCenter = (badge.top + badge.bottom) / 2 + assertEquals("badges share the model's line", countCenter.value, badgeCenter.value, 1f) + } + + @Test + fun largeFont_wrapsTheBadgesToASecondLine() { + setCard( + session = longModelSession(hidden = true), + project = null, + liveStatus = SessionLiveStatus.WORKING, + fontScale = 2f, + ) + + assertTrue(bounds("session_live_status_long").top >= bounds("session_footer_count_long").bottom) + } + + @Test + fun rendersProjectAgePreviewAndFooter() { + setCard( + session = + SessionInfo( + id = "s1", + title = "Restyle the palette", + preview = "Can you check\nthe contrast?", + model = "gpt-5.5", + message_count = 24, + last_active = nowMillis / 1000.0 - 2 * 3600, + ), + project = SessionProject(label = "Hermes", color = "hsl(120 68% 58%)"), + ) + + composeTestRule.onNodeWithTag("session_project_s1", useUnmergedTree = true).assertTextEquals("Hermes") + composeTestRule + .onNodeWithTag("session_age_s1", useUnmergedTree = true) + .assertTextEquals("2h") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.ContentDescription, listOf("Active 2 hours ago"))) + composeTestRule.onNodeWithText("Restyle the palette", useUnmergedTree = true).assertIsDisplayed() + composeTestRule + .onNodeWithTag( + "session_preview_s1", + useUnmergedTree = true, + ).assertTextEquals("Can you check the contrast?") + composeTestRule.onNodeWithText("gpt-5.5", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithText("24 messages", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun sessionWithoutWorkspace_showsHome_andNoPreviewLine() { + setCard( + session = SessionInfo(id = "s2", title = "Quick question", message_count = 1), + project = null, + ) + + composeTestRule.onNodeWithTag("session_project_s2", useUnmergedTree = true).assertTextEquals("Home") + composeTestRule.onNodeWithTag("session_preview_s2", useUnmergedTree = true).assertDoesNotExist() + composeTestRule.onNodeWithTag("session_age_s2", useUnmergedTree = true).assertDoesNotExist() + composeTestRule.onNodeWithText("1 message", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun tappingTheCard_opensIt() { + setCard(session = SessionInfo(id = "s3", title = "Open me"), project = null) + + composeTestRule.onNodeWithTag("session_card_s3").performClick() + + composeTestRule.runOnIdle { assertEquals(1, clicks) } + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt new file mode 100644 index 00000000..cd0082c8 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/settings/components/ChatSectionTest.kt @@ -0,0 +1,81 @@ +package com.m57.hermescontrol.ui.settings.components + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ChatSectionTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun messageStatsMasterOff_keepsChildrenVisibleCheckedAndDisabled() { + composeTestRule.setContent { + ChatSection( + typingEffectEnabled = false, + onTypingEffectEnabledChange = {}, + typingEffectDelayMs = 30, + onTypingEffectDelayMsChange = {}, + messageStatsEnabled = false, + showUserMessageTokens = true, + showAssistantMessageTokens = true, + showTokensPerSecond = true, + ) + } + + composeTestRule.onNodeWithTag("settings_message_stats").assertIsOff().assertIsEnabled() + composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsOn().assertIsNotEnabled() + composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsOn().assertIsNotEnabled() + composeTestRule.onNodeWithTag("settings_tps").assertIsOn().assertIsNotEnabled() + } + + @Test + fun messageStatsMasterOn_enablesChildrenAndRoutesCallbacks() { + var masterChanges = 0 + var userChanges = 0 + var assistantChanges = 0 + var tpsChanges = 0 + composeTestRule.setContent { + ChatSection( + typingEffectEnabled = false, + onTypingEffectEnabledChange = {}, + typingEffectDelayMs = 30, + onTypingEffectDelayMsChange = {}, + messageStatsEnabled = true, + onMessageStatsEnabledChange = { masterChanges++ }, + showUserMessageTokens = true, + onUserMessageTokensChange = { userChanges++ }, + showAssistantMessageTokens = true, + onAssistantMessageTokensChange = { assistantChanges++ }, + showTokensPerSecond = true, + onTokensPerSecondChange = { tpsChanges++ }, + ) + } + + composeTestRule.onNodeWithTag("settings_user_message_tokens").assertIsEnabled().performClick() + composeTestRule.onNodeWithTag("settings_assistant_message_tokens").assertIsEnabled().performClick() + composeTestRule.onNodeWithTag("settings_tps").assertIsEnabled().performClick() + assertEquals(0, masterChanges) + assertEquals(1, userChanges) + assertEquals(1, assistantChanges) + assertEquals(1, tpsChanges) + + composeTestRule.onNodeWithTag("settings_message_stats").performClick() + assertEquals(1, masterChanges) + assertEquals(1, userChanges) + assertEquals(1, assistantChanges) + assertEquals(1, tpsChanges) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 0f7d575d..8ec9ed90 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -174,6 +174,15 @@ private fun appEntryProvider( onBack = { NavigationController.goBack() }, ) } + + // ── Kanban Task Detail ───────────────────────────────────────────── + entry { key -> + com.m57.hermescontrol.ui.kanban.KanbanTaskScreen( + boardSlug = key.boardSlug, + taskId = key.taskId, + onBack = { NavigationController.goBack() }, + ) + } } @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 16ddc35f..466067a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -12,6 +12,10 @@ data class PendingChatNavigation( val requestId: Long, ) +data class PendingNewChatNavigation( + val requestId: Long, +) + /** * Central navigation controller with deduplication guard. * @@ -27,9 +31,12 @@ object NavigationController { var backStack: NavBackStack? = null var pendingChatNavigation: PendingChatNavigation? by mutableStateOf(null) private set + var pendingNewChatNavigation: PendingNewChatNavigation? by mutableStateOf(null) + private set val pendingSessionId: String? get() = pendingChatNavigation?.sessionId private var nextChatNavigationRequestId = 0L + private var nextNewChatNavigationRequestId = 0L /** * Top-level primary screens (all drawer-accessible screens). @@ -73,6 +80,14 @@ object NavigationController { queueChatNavigation(sessionId, scrollToBottom = true) } + fun openNewChat() { + pendingNewChatNavigation = + PendingNewChatNavigation( + requestId = ++nextNewChatNavigationRequestId, + ) + navigateTo(ChatScreen) + } + private fun queueChatNavigation( sessionId: String, scrollToBottom: Boolean, @@ -90,6 +105,9 @@ object NavigationController { fun consumePendingChatNavigation(): PendingChatNavigation? = pendingChatNavigation.also { pendingChatNavigation = null } + fun consumePendingNewChatNavigation(): PendingNewChatNavigation? = + pendingNewChatNavigation.also { pendingNewChatNavigation = null } + fun consumePendingSessionId(): String? = consumePendingChatNavigation()?.sessionId /** Clear the stack and navigate to the given screen atomically. */ diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 776be2da..7f863c92 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -65,6 +65,11 @@ import kotlinx.serialization.Serializable @Serializable data object KanbanScreen : NavKey +@Serializable data class KanbanTaskDetailKey( + val boardSlug: String, + val taskId: String, +) : NavKey + @Serializable data object ProcessesScreen : NavKey @Serializable data object ProvidersScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 1112d746..097094b1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -24,6 +24,10 @@ data class ServerStoreState( val typingEffectEnabled: Boolean = true, val typingEffectDelayMs: Int = 30, val chatFontScale: Float = 1.0f, + val messageStatsEnabled: Boolean = false, + val showUserMessageTokens: Boolean = true, + val showAssistantMessageTokens: Boolean = true, + val showTokensPerSecond: Boolean = true, // App display language. "system" = follow device locale; otherwise a BCP-47 // language code such as "en" or "ko". Applied via ContextWrapper in MainActivity. val appLanguage: String = "system", diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 2cf351ff..1e23a503 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -713,6 +713,32 @@ object AuthManager { serverStore.update { it.copy(typingEffectDelayMs = delayMs) } } + // ── Message stats ─────────────────────────────────────────────────── + + fun isMessageStatsEnabled(): Boolean = serverStore.getLatestState().messageStatsEnabled + + fun setMessageStatsEnabled(enabled: Boolean) { + serverStore.update { it.copy(messageStatsEnabled = enabled) } + } + + fun isUserMessageTokensEnabled(): Boolean = serverStore.getLatestState().showUserMessageTokens + + fun setUserMessageTokensEnabled(enabled: Boolean) { + serverStore.update { it.copy(showUserMessageTokens = enabled) } + } + + fun isAssistantMessageTokensEnabled(): Boolean = serverStore.getLatestState().showAssistantMessageTokens + + fun setAssistantMessageTokensEnabled(enabled: Boolean) { + serverStore.update { it.copy(showAssistantMessageTokens = enabled) } + } + + fun isTokensPerSecondEnabled(): Boolean = serverStore.getLatestState().showTokensPerSecond + + fun setTokensPerSecondEnabled(enabled: Boolean) { + serverStore.update { it.copy(showTokensPerSecond = enabled) } + } + // ── Chat Font Scale (issue #1004) ─────────────────────────────────── fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt b/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt new file mode 100644 index 00000000..9f42707e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt @@ -0,0 +1,65 @@ +package com.m57.hermescontrol.data.local + +/** + * Local preferences for Kanban board viewing: + * selected board slug, view mode (list vs board), archive visibility, and group running. + */ +interface KanbanPreferencesStore { + fun getSelectedBoard(endpoint: String): String? + + fun setSelectedBoard( + endpoint: String, + slug: String, + ) + + fun getViewMode(): String + + fun setViewMode(mode: String) + + fun getIncludeArchived(): Boolean + + fun setIncludeArchived(include: Boolean) + + fun getGroupRunning(): Boolean + + fun setGroupRunning(group: Boolean) + + companion object { + const val MODE_LIST = "list" + const val MODE_BOARD = "board" + } +} + +class InMemoryKanbanPreferencesStore : KanbanPreferencesStore { + private val selectedBoards = mutableMapOf() + private var viewMode: String = KanbanPreferencesStore.MODE_LIST + private var includeArchived: Boolean = false + private var groupRunning: Boolean = false + + override fun getSelectedBoard(endpoint: String): String? = selectedBoards[endpoint] + + override fun setSelectedBoard( + endpoint: String, + slug: String, + ) { + selectedBoards[endpoint] = slug + } + + override fun getViewMode(): String = viewMode + + override fun setViewMode(mode: String) { + viewMode = mode + } + + override fun getIncludeArchived(): Boolean = includeArchived + + override fun setIncludeArchived(include: Boolean) { + includeArchived = include + } + + override fun getGroupRunning(): Boolean = groupRunning + + override fun setGroupRunning(group: Boolean) { + groupRunning = group + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/GatewayMigration.kt b/app/src/main/java/com/m57/hermescontrol/data/model/GatewayMigration.kt new file mode 100644 index 00000000..65f327b5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/GatewayMigration.kt @@ -0,0 +1,43 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GatewayMigrationPlan( + @SerialName("default_home") val defaultHome: String? = null, + val profiles: List = emptyList(), + @SerialName("multiplex_flag_on") val multiplexFlagOn: Boolean = false, + @SerialName("live_served") val liveServed: List? = null, + @SerialName("already_multiplexed") val alreadyMultiplexed: Boolean = false, + val interrupted: Boolean = false, + val blockers: List = emptyList(), + val notices: List = emptyList(), + val eligible: Boolean = false, + val command: String? = null, +) + +@Serializable +data class GatewayMigrationProfile( + val profile: String? = null, + val home: String? = null, + val pid: Int? = null, + val service: GatewayMigrationService? = null, + val services: List = emptyList(), + @SerialName("run_as_user") val runAsUser: String? = null, + val uid: Int? = null, + @SerialName("runtime_home") val runtimeHome: String? = null, +) + +@Serializable +data class GatewayMigrationService( + val kind: String? = null, + val system: Boolean? = null, +) + +@Serializable +data class GatewayMigrationStartResponse( + val ok: Boolean = false, + val pid: Int? = null, + val name: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt deleted file mode 100644 index 592c4b50..00000000 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.m57.hermescontrol.data.model -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class KanbanBoard( - @SerialName("slug") val id: String, - val name: String, - val description: String? = null, -) - -@Serializable -data class KanbanTask( - val id: String, - val title: String, - @SerialName("body") val description: String? = null, - val status: String, - @SerialName("assignee") val assignedTo: String? = null, -) - -@Serializable -data class CreateTaskBody( - val title: String, - val body: String? = null, -) - -@Serializable -data class KanbanColumn( - val name: String, - val tasks: List, -) - -@Serializable -data class KanbanBoardResponse( - val columns: List, - val assignees: List? = null, - val tenants: List? = null, -) - -@Serializable -data class KanbanBoardsResponse( - val boards: List, - val current: String? = null, -) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt new file mode 100644 index 00000000..0b6a8974 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt @@ -0,0 +1,61 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class KanbanComment( + val id: Long, + val author: String, + val body: String, + val createdAt: Long, +) + +@Serializable +data class KanbanDetailEvent( + val id: Long, + val kind: String, + val payload: JsonElement? = null, + val createdAt: Long, +) + +@Serializable +data class KanbanRun( + val id: Long, + val profile: String? = null, + val status: String, + val outcome: String? = null, + val summary: String? = null, + val error: String? = null, + val metadata: JsonElement? = null, + val workerPid: Int? = null, + val startedAt: Long? = null, + val endedAt: Long? = null, +) + +@Serializable +data class WorkerLog( + val taskId: String, + val path: String? = null, + val exists: Boolean = false, + val sizeBytes: Long = 0L, + val content: String = "", + val truncated: Boolean = false, +) + +@Serializable +data class KanbanAttachment( + val id: Long, + val taskId: String? = null, + val filename: String, + val contentType: String? = null, + val size: Long? = null, + val uploadedBy: String? = null, + val storedPath: String? = null, + val createdAt: Long? = null, +) + +@Serializable +data class AttachmentUploadResponse( + val attachment: KanbanAttachment? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt new file mode 100644 index 00000000..22276588 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt @@ -0,0 +1,94 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class KanbanBoard( + @SerialName("slug") val id: String, + val name: String? = null, + val description: String? = null, + val isCurrent: Boolean = false, + val total: Int? = null, + val counts: Map = emptyMap(), + val defaultWorkdir: String? = null, + val defaultWorkspaceKind: String? = null, + val projectId: String? = null, + val projectName: String? = null, + val createdAt: Long? = null, + val archived: Boolean = false, +) { + val slug: String get() = id + val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id +} + +@Serializable +data class KanbanColumn( + val name: String, + val tasks: List = emptyList(), +) + +@Serializable +data class KanbanBoardResponse( + val columns: List = emptyList(), + val assignees: List? = null, + val tenants: List? = null, + val latestEventId: Long = 0L, + val now: Long = 0L, +) + +@Serializable +data class KanbanBoardsResponse( + val boards: List = emptyList(), + val current: String? = null, +) + +@Serializable +data class KanbanProject( + val id: String, + val slug: String, + val name: String, + val primaryPath: String? = null, + val icon: String? = null, + val color: String? = null, +) + +@Serializable +data class KanbanProjectsResponse( + val projects: List = emptyList(), +) + +@Serializable +data class CreateBoardBody( + val slug: String, + val name: String? = null, + val description: String? = null, + val projectId: String? = null, + val switch: Boolean = false, +) + +@Serializable +data class CreateBoardResponse( + val board: KanbanBoard? = null, + val current: String? = null, +) + +@Serializable +data class RenameBoardBody( + val name: String? = null, + val description: String? = null, + val defaultWorkdir: String? = null, + val projectId: String? = null, +) + +@Serializable +data class RenameBoardResponse( + val board: KanbanBoard? = null, +) + +@Serializable +data class DeleteBoardResponse( + val result: JsonElement? = null, + val current: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanSettingsModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanSettingsModels.kt new file mode 100644 index 00000000..e53f2914 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanSettingsModels.kt @@ -0,0 +1,70 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class KanbanProfile( + val name: String, + val isDefault: Boolean = false, + val description: String = "", + val descriptionAuto: Boolean = false, + val model: String? = null, + val provider: String? = null, + val skillCount: Int = 0, +) + +@Serializable +data class KanbanProfilesResponse( + val profiles: List = emptyList(), +) + +@Serializable +data class OrchestrationSettings( + val orchestratorProfile: String = "", + val defaultAssignee: String = "", + val autoDecompose: Boolean = true, + val autoPromoteChildren: Boolean = true, + val resolvedOrchestratorProfile: String = "", + val resolvedDefaultAssignee: String = "", + val activeProfile: String = "default", +) + +@Serializable +data class OrchestrationSettingsUpdate( + val orchestratorProfile: String? = null, + val defaultAssignee: String? = null, + val autoDecompose: Boolean? = null, + val autoPromoteChildren: Boolean? = null, +) + +@Serializable +data class AutoDescribeResponse( + val ok: Boolean = false, + val profile: String? = null, + val reason: String? = null, + val description: String? = null, +) + +@Serializable +data class BoardExportResult( + val board: String, + val archive: String, + val size: Long = 0L, +) + +@Serializable +data class BoardImportResult( + val board: String, + val name: String = "", + val renamed: Boolean = false, + val requestedBoard: String = "", + val counts: Map = emptyMap(), + val warnings: List = emptyList(), + val current: String? = null, +) + +@Serializable +data class DispatchResult( + val spawned: List = emptyList(), +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt new file mode 100644 index 00000000..b93d3c27 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt @@ -0,0 +1,255 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class TaskLinkCounts( + val parents: Int = 0, + val children: Int = 0, +) + +@Serializable +data class TaskProgress( + val done: Int = 0, + val total: Int = 0, +) + +@Serializable +data class TaskWarnings( + val count: Int = 0, + val highestSeverity: String? = null, +) + +@Serializable +data class TaskDiagnosticAction( + val kind: String, + val label: String, + val payload: Map = emptyMap(), + val suggested: Boolean = false, +) + +@Serializable +data class TaskDiagnostic( + val kind: String, + val severity: String, + val title: String, + val detail: String, + val actions: List = emptyList(), + val count: Int = 1, + val lastSeenAt: Long = 0L, + val data: Map = emptyMap(), +) + +@Serializable +data class TaskLinks( + val parents: List = emptyList(), + val children: List = emptyList(), +) + +@Serializable +data class ChildTaskResult( + val id: String, + val title: String, + val status: String, + val latestSummary: String? = null, + val result: String? = null, +) + +@Serializable +data class KanbanTask( + val id: String, + val title: String, + val body: String? = null, + val status: String, + val assignee: String? = null, + val priority: Int? = null, + val tenant: String? = null, + val createdAt: Long? = null, + val latestSummary: String? = null, + val commentCount: Int = 0, + val linkCounts: TaskLinkCounts? = null, + val progress: TaskProgress? = null, + val warnings: TaskWarnings? = null, + val startedAt: Long? = null, + val workerPid: Int? = null, + val lastHeartbeatAt: Long? = null, +) { + val description: String? get() = body + val assignedTo: String? get() = assignee + + constructor( + id: String, + title: String, + description: String? = null, + status: String, + assignedTo: String? = null, + ) : this( + id = id, + title = title, + body = description, + status = status, + assignee = assignedTo, + ) +} + +@Serializable +data class KanbanTaskFull( + val id: String, + val title: String, + val body: String? = null, + val status: String, + val assignee: String? = null, + val priority: Int? = null, + val tenant: String? = null, + val createdAt: Long? = null, + val latestSummary: String? = null, + val commentCount: Int = 0, + val linkCounts: TaskLinkCounts? = null, + val progress: TaskProgress? = null, + val warnings: TaskWarnings? = null, + val startedAt: Long? = null, + val workerPid: Int? = null, + val lastHeartbeatAt: Long? = null, + val result: String? = null, + val createdBy: String? = null, + val modelOverride: String? = null, + val providerOverride: String? = null, + val reasoningEffort: String? = null, + val completedAt: Long? = null, + val lastFailureError: String? = null, + val workspaceKind: String? = null, + val workspacePath: String? = null, + val branchName: String? = null, + val consecutiveFailures: Int = 0, + val diagnostics: List = emptyList(), +) { + val description: String? get() = body + val assignedTo: String? get() = assignee +} + +@Serializable +data class KanbanTaskDetailResponse( + val task: KanbanTaskFull, + val comments: List = emptyList(), + val events: List = emptyList(), + val attachments: List? = null, + val links: TaskLinks = TaskLinks(), + val childResults: List = emptyList(), + val runs: List = emptyList(), +) + +@Serializable +data class CreateTaskBody( + val title: String, + val body: String? = null, + val assignee: String? = null, + val tenant: String? = null, + val priority: Int = 0, + val workspaceKind: String? = null, + val workspacePath: String? = null, + val parents: List = emptyList(), + val triage: Boolean = false, + val maxRuntimeSeconds: Int? = null, + val skills: List? = null, + val goalMode: Boolean = false, + val goalMaxTurns: Int? = null, + val modelOverride: String? = null, + val providerOverride: String? = null, + val reasoningEffort: String? = null, + val projectId: String? = null, +) + +@Serializable +data class CreateTaskResponse( + val task: KanbanTask? = null, + val warning: String? = null, +) + +@Serializable +data class UpdateTaskBody( + val status: String? = null, + val assignee: String? = null, + val priority: Int? = null, + val title: String? = null, + val body: String? = null, + val result: String? = null, + val blockReason: String? = null, + val summary: String? = null, + val metadata: Map? = null, + val modelOverride: String? = null, + val providerOverride: String? = null, + val clearModelOverride: Boolean = false, + val reasoningEffort: String? = null, + val clearReasoningEffort: Boolean = false, +) + +@Serializable +data class UpdateTaskResponse( + val task: KanbanTask? = null, +) + +@Serializable +data class BulkTasksBody( + val ids: List, + val status: String? = null, + val assignee: String? = null, + val priority: Int? = null, + val archive: Boolean = false, + val result: String? = null, + val summary: String? = null, + val metadata: Map? = null, + val reclaimFirst: Boolean = false, + val modelOverride: String? = null, + val providerOverride: String? = null, + val clearModelOverride: Boolean = false, + val reasoningEffort: String? = null, + val clearReasoningEffort: Boolean = false, +) + +@Serializable +data class BulkTaskResult( + val id: String, + val ok: Boolean, + val error: String? = null, +) + +@Serializable +data class BulkTasksResponse( + val results: List = emptyList(), +) + +@Serializable +data class ReassignTaskBody( + val profile: String? = null, + val reclaimFirst: Boolean = true, + val reason: String? = null, +) + +@Serializable +data class ReassignTaskResponse( + val ok: Boolean = true, + val taskId: String, + val assignee: String? = null, +) + +@Serializable +data class ReclaimTaskBody( + val reason: String? = null, +) + +@Serializable +data class ReclaimTaskResponse( + val ok: Boolean = true, + val taskId: String, +) + +@Serializable +data class TaskEstimate( + val ok: Boolean, + val reason: String? = null, + val estTokens: Int? = null, + val complexity: String? = null, + val rationale: String? = null, + val model: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt index 465b5451..74e47ac5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt @@ -17,6 +17,7 @@ data class MessagingPlatform( @SerialName("env_vars") val envVars: List? = null, @SerialName("updated_at") val updatedAt: String? = null, @SerialName("home_channel") val homeChannel: HomeChannelInfo? = null, + @SerialName("ingress_url") val ingressUrl: String? = null, ) @Serializable @@ -64,6 +65,13 @@ data class MessagingPlatformTestResult( val message: String, ) +@Serializable +data class MessagingPlatformUpdateResponse( + val ok: Boolean = false, + val platform: String? = null, + @SerialName("hot_served") val hotServed: Boolean = false, +) + // ── Telegram onboarding ───────────────────────────────────────────────── @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ProjectModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ProjectModels.kt new file mode 100644 index 00000000..02749b58 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ProjectModels.kt @@ -0,0 +1,39 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** Result of the `projects.list` JSON-RPC: the profile's named workspaces. */ +@Serializable +data class ProjectsListResponse( + val projects: List = emptyList(), + val activeId: String? = null, +) + +/** + * A user-created project from the backend's per-profile `projects.db`. A + * session belongs to the project whose folder contains its cwd or repo root. + */ +@Serializable +data class ProjectInfo( + val id: String, + val name: String = "", + val slug: String? = null, + // CSS color string chosen in the desktop app, e.g. "hsl(210 68% 58%)". + val color: String? = null, + val icon: String? = null, + val primaryPath: String? = null, + // Older backends store SQLite 0/1 here. + @Serializable(with = LenientNullableBooleanSerializer::class) + val archived: Boolean? = null, + val folders: List = emptyList(), +) { + val isArchived: Boolean get() = archived == true +} + +@Serializable +data class ProjectFolder( + val path: String, + val label: String? = null, + @Serializable(with = LenientNullableBooleanSerializer::class) + val isPrimary: Boolean? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index ab2e0eb0..bbffae54 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -79,6 +79,10 @@ data class SessionInfo( val display_name: String? = null, val model: String? = null, val terminal_backend: String? = null, + // Workspace the session ran in. The project label resolves from these + // (explicit project folder, else the repo root, else the cwd leaf). + val cwd: String? = null, + val git_repo_root: String? = null, // Epoch seconds of the latest message (backend-computed, present on all // list rows). Recency source of truth; started_at is the fallback. val last_active: Double? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt index 39a108fe..08b8d0c5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt @@ -1,4 +1,5 @@ package com.m57.hermescontrol.data.model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -8,6 +9,8 @@ data class StatusResponse( val active_sessions: Int? = null, val auth_required: Boolean? = null, val gateway_platforms: Map? = null, + @SerialName("gateway_shared_with") + val gatewaySharedWith: List? = null, val memory: MemoryPressureStatus? = null, val disk: DiskPressureStatus? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt index c20e1058..3a30930c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt @@ -1,68 +1,187 @@ package com.m57.hermescontrol.data.model import com.m57.hermescontrol.data.ws.toAny +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.longOrNull -/** - * Minimal view of the `session.usage` WS RPC result or `session.usage` push event — - * only the fields the context widget consumes today. The backend sends many more keys - * (`input`/`output`/`total`/`calls`/`active_subagents`/`credits_lines` …); - * parsing is key-by-key, so unmodelled keys are simply ignored. - * - * Verified backend shape (`tui_gateway/server.py` `_get_usage`): `compressions` - * = `context_compressor.compression_count` (0 when never compressed, absent on - * sessions with no live agent), `context_used` = current window tokens, - * `context_max` = max context length. - */ +/** Complete cumulative `session.usage` snapshot returned by Hermes Agent. */ data class UsageSnapshotResponse( - /** How many times this session has been context-compressed. */ - val compressions: Int? = null, - /** Current prompt / context tokens used in the active window. */ - val contextUsed: Long? = null, - /** Max context length in tokens. */ - val contextMax: Long? = null, - /** Cumulative session total tokens. */ + val model: String? = null, + val inputTokens: Long? = null, + val outputTokens: Long? = null, + val reasoningTokens: Long? = null, + val promptTokens: Long? = null, + val completionTokens: Long? = null, val totalTokens: Long? = null, - /** Rolling tokens-per-second generation speed. */ + val apiCalls: Long? = null, + val cacheReadTokens: Long? = null, + val cacheWriteTokens: Long? = null, + val compressions: Int? = null, + val contextUsed: Long? = null, + val contextMax: Long? = null, + val contextPercent: Int? = null, + val contextSource: String? = null, + val contextEstimated: Boolean? = null, + val cacheHitPercent: Int? = null, + val avgLatencySeconds: Double? = null, val avgTps: Double? = null, + val activeSubagents: Int? = null, + val devCreditsSpentMicros: Long? = null, + val costUsd: Double? = null, + val costStatus: String? = null, + val creditsLines: List? = null, ) +/** Merge an incremental snapshot without erasing values omitted by the backend. */ +fun UsageSnapshotResponse.mergeWith(previous: UsageSnapshotResponse?): UsageSnapshotResponse = + if (previous == null) { + this + } else { + copy( + model = model ?: previous.model, + inputTokens = inputTokens ?: previous.inputTokens, + outputTokens = outputTokens ?: previous.outputTokens, + reasoningTokens = reasoningTokens ?: previous.reasoningTokens, + promptTokens = promptTokens ?: previous.promptTokens, + completionTokens = completionTokens ?: previous.completionTokens, + totalTokens = totalTokens ?: previous.totalTokens, + apiCalls = apiCalls ?: previous.apiCalls, + cacheReadTokens = cacheReadTokens ?: previous.cacheReadTokens, + cacheWriteTokens = cacheWriteTokens ?: previous.cacheWriteTokens, + compressions = compressions ?: previous.compressions, + contextUsed = contextUsed ?: previous.contextUsed, + contextMax = contextMax ?: previous.contextMax, + contextPercent = contextPercent ?: previous.contextPercent, + contextSource = contextSource ?: previous.contextSource, + contextEstimated = contextEstimated ?: previous.contextEstimated, + cacheHitPercent = cacheHitPercent ?: previous.cacheHitPercent, + avgLatencySeconds = avgLatencySeconds ?: previous.avgLatencySeconds, + avgTps = avgTps ?: previous.avgTps, + activeSubagents = activeSubagents ?: previous.activeSubagents, + devCreditsSpentMicros = devCreditsSpentMicros ?: previous.devCreditsSpentMicros, + costUsd = costUsd ?: previous.costUsd, + costStatus = costStatus ?: previous.costStatus, + creditsLines = creditsLines ?: previous.creditsLines, + ) + } + /** - * Parse the decoded JSON-RPC result of `session.usage` or `session.usage` push event payload. + * Usage attributable to one completed turn, derived from two cumulative snapshots. + * Rolling/context fields are copied from the final snapshot rather than differenced. + */ +data class TurnUsage( + val model: String? = null, + val inputTokens: Long? = null, + val outputTokens: Long? = null, + val reasoningTokens: Long? = null, + val promptTokens: Long? = null, + val completionTokens: Long? = null, + val totalTokens: Long? = null, + val apiCalls: Long? = null, + val cacheReadTokens: Long? = null, + val cacheWriteTokens: Long? = null, + val devCreditsSpentMicros: Long? = null, + val costUsd: Double? = null, + val compressionCountDelta: Int? = null, + val avgTps: Double? = null, + val avgLatencySeconds: Double? = null, +) + +private fun Long?.deltaFrom( + baseline: Long?, + final: Long?, +): Long? = if (baseline != null && final != null && final >= baseline) final - baseline else null + +private fun Double?.deltaFrom( + baseline: Double?, + final: Double?, +): Double? = if (baseline != null && final != null && final >= baseline) final - baseline else null + +/** Derive per-turn cumulative counters without ever clamping counter resets. */ +fun UsageSnapshotResponse.deltaFrom(baseline: UsageSnapshotResponse?): TurnUsage = + TurnUsage( + model = model, + inputTokens = inputTokens.deltaFrom(baseline?.inputTokens, inputTokens), + outputTokens = outputTokens.deltaFrom(baseline?.outputTokens, outputTokens), + reasoningTokens = reasoningTokens.deltaFrom(baseline?.reasoningTokens, reasoningTokens), + promptTokens = promptTokens.deltaFrom(baseline?.promptTokens, promptTokens), + completionTokens = completionTokens.deltaFrom(baseline?.completionTokens, completionTokens), + totalTokens = totalTokens.deltaFrom(baseline?.totalTokens, totalTokens), + apiCalls = apiCalls.deltaFrom(baseline?.apiCalls, apiCalls), + cacheReadTokens = cacheReadTokens.deltaFrom(baseline?.cacheReadTokens, cacheReadTokens), + cacheWriteTokens = cacheWriteTokens.deltaFrom(baseline?.cacheWriteTokens, cacheWriteTokens), + devCreditsSpentMicros = devCreditsSpentMicros.deltaFrom(baseline?.devCreditsSpentMicros, devCreditsSpentMicros), + costUsd = costUsd.deltaFrom(baseline?.costUsd, costUsd), + compressionCountDelta = + compressions + ?.let { final -> baseline?.compressions?.let { base -> if (final >= base) final - base else null } }, + avgTps = avgTps, + avgLatencySeconds = avgLatencySeconds, + ) + +/** + * Parse the decoded JSON-RPC result of `session.usage`, push events, or message.complete. * - * Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }` - * (from push event payload). The WS event parser decodes JSON numbers as [Double] - * (`JsonRpcModels.toAny`), so every count is read through [Number]. Also supports raw [JsonObject]. - * Returns null when the payload is not a map (error/malformed response) — callers keep the last known value. + * Handles direct snapshots and nested `{ "usage": { ... } }` envelopes. JSON numbers may + * arrive as [Double] through [JsonRpcModels.toAny], or as [JsonPrimitive] from an RPC result. + * Unknown and malformed optional fields are ignored; a non-map root returns null. */ fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? { - val rawMap = - when (result) { - is JsonElement -> result.toAny() as? Map<*, *> - is Map<*, *> -> result + fun asMap(value: Any?): Map<*, *>? = + when (value) { + is Map<*, *> -> value + is JsonElement -> value.toAny() as? Map<*, *> else -> null - } ?: return null - val usageMap = (rawMap["usage"] as? Map<*, *>) ?: rawMap + } + + val rawMap = asMap(result) ?: return null + val usageMap = asMap(rawMap["usage"]) ?: rawMap fun num(key: String): Long? = when (val value = usageMap[key]) { - is Number -> value.toLong() - is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong() - is String -> value.toLongOrNull() - else -> null + is Byte, is Short, is Int, is Long -> { + (value as Number).toLong() + } + + is Float, is Double -> { + (value as Number) + .toDouble() + .takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE } + ?.toLong() + } + + is Number -> { + value + .toDouble() + .takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE } + ?.toLong() + } + + is JsonPrimitive -> { + value.longOrNull + ?: value.doubleOrNull + ?.takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE } + ?.toLong() + } + + is String -> { + value.toLongOrNull() + ?: value + .toDoubleOrNull() + ?.takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE } + ?.toLong() + } + + else -> { + null + } } - fun intNum(key: String): Int? = - when (val value = usageMap[key]) { - is Number -> value.toInt() - is JsonPrimitive -> value.intOrNull - is String -> value.toIntOrNull() - else -> null - } + fun intNum(key: String): Int? = num(key)?.takeIf { it in Int.MIN_VALUE..Int.MAX_VALUE }?.toInt() fun doubleNum(key: String): Double? = when (val value = usageMap[key]) { @@ -70,13 +189,55 @@ fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? { is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble() is String -> value.toDoubleOrNull() else -> null + }?.takeIf { it.isFinite() } + + fun string(key: String): String? = + when (val value = usageMap[key]) { + is String -> value + is JsonPrimitive -> value.content + else -> null + } + + fun bool(key: String): Boolean? = + when (val value = usageMap[key]) { + is Boolean -> value + is JsonPrimitive -> value.booleanOrNull + is String -> value.toBooleanStrictOrNull() + else -> null + } + + fun strings(key: String): List? = + when (val value = usageMap[key]) { + is JsonArray -> value.mapNotNull { (it as? JsonPrimitive)?.content } + is List<*> -> value.filterIsInstance() + is JsonElement -> (value.toAny() as? List<*>)?.filterIsInstance() + else -> null } return UsageSnapshotResponse( + model = string("model"), + inputTokens = num("input"), + outputTokens = num("output"), + reasoningTokens = num("reasoning"), + promptTokens = num("prompt"), + completionTokens = num("completion"), + totalTokens = num("total"), + apiCalls = num("calls"), + cacheReadTokens = num("cache_read"), + cacheWriteTokens = num("cache_write"), compressions = intNum("compressions"), contextUsed = num("context_used"), contextMax = num("context_max"), - totalTokens = num("total"), + contextPercent = intNum("context_percent"), + contextSource = string("context_source"), + contextEstimated = bool("context_estimated"), + cacheHitPercent = intNum("cache_hit_pct"), + avgLatencySeconds = doubleNum("avg_latency_s"), avgTps = doubleNum("avg_tps"), + activeSubagents = intNum("active_subagents"), + devCreditsSpentMicros = num("dev_credits_spent_micros"), + costUsd = doubleNum("cost_usd"), + costStatus = string("cost_status"), + creditsLines = strings("credits_lines"), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index 94d32674..e413181b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -30,6 +30,10 @@ object ApiClient { } } + /** The current [KanbanApiService] instance. Reuses [hermesApi]. */ + val kanbanApi: KanbanApiService + get() = hermesApi + /** Force-rebuild the Retrofit client (e.g. after settings change). */ fun rebuild() { synchronized(this) { @@ -79,7 +83,15 @@ object ApiClient { // ── Internal ───────────────────────────────────────────────────────── - private fun buildService(): HermesApiService { + private fun buildService(): HermesApiService = + buildRetrofit().create(HermesApiService::class.java).also { + service = it + } + + private fun buildRetrofit(): Retrofit { + val cached = retrofit + if (cached != null) return cached + val logging = HttpLoggingInterceptor().apply { level = @@ -129,15 +141,12 @@ object ApiClient { .authenticator(TokenRefreshAuthenticator) .build() - val rf = - Retrofit - .Builder() - .baseUrl(AuthManager.endpointForBuild().baseUrl) - .client(okHttp) - .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) - .build() - .also { retrofit = it } - - return rf.create(HermesApiService::class.java) + return Retrofit + .Builder() + .baseUrl(AuthManager.endpointForBuild().baseUrl) + .client(okHttp) + .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) + .build() + .also { retrofit = it } } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 88abd60a..d8592605 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -33,6 +33,8 @@ import com.m57.hermescontrol.data.model.EnvVarDeleteRequest import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarRevealResponse import com.m57.hermescontrol.data.model.EnvVarUpdate +import com.m57.hermescontrol.data.model.GatewayMigrationPlan +import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse import com.m57.hermescontrol.data.model.HealthStatus import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest @@ -65,6 +67,7 @@ import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.model.MessagingPlatformResponse import com.m57.hermescontrol.data.model.MessagingPlatformTestResult import com.m57.hermescontrol.data.model.MessagingPlatformUpdate +import com.m57.hermescontrol.data.model.MessagingPlatformUpdateResponse import com.m57.hermescontrol.data.model.MoaConfigResponse import com.m57.hermescontrol.data.model.ModelAssignmentRequest import com.m57.hermescontrol.data.model.ModelAssignmentResponse @@ -154,7 +157,7 @@ import retrofit2.http.Path import retrofit2.http.Query import retrofit2.http.Streaming -interface HermesApiService { +interface HermesApiService : KanbanApiService { @GET("api/skills/content") suspend fun getSkillContent( @Query("name") name: String, @@ -390,6 +393,12 @@ interface HermesApiService { @POST("api/gateway/restart") suspend fun restartGateway(): Response + @GET("api/gateway/migrate/plan") + suspend fun getGatewayMigrationPlan(): Response + + @POST("api/gateway/migrate") + suspend fun startGatewayMigration(): Response + @GET("api/profiles") suspend fun getProfiles(): Response @@ -677,7 +686,7 @@ interface HermesApiService { suspend fun configurePlatform( @Path("platform_id") platformId: String, @Body config: MessagingPlatformUpdate, - ): Response + ): Response @POST("api/messaging/platforms/{platform_id}/test") suspend fun testMessagingPlatform( diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt new file mode 100644 index 00000000..fc85ec16 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt @@ -0,0 +1,195 @@ +package com.m57.hermescontrol.data.remote + +import com.m57.hermescontrol.data.model.AttachmentUploadResponse +import com.m57.hermescontrol.data.model.AutoDescribeResponse +import com.m57.hermescontrol.data.model.BulkTasksBody +import com.m57.hermescontrol.data.model.BulkTasksResponse +import com.m57.hermescontrol.data.model.CreateBoardBody +import com.m57.hermescontrol.data.model.CreateBoardResponse +import com.m57.hermescontrol.data.model.CreateTaskBody +import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.DeleteBoardResponse +import com.m57.hermescontrol.data.model.DispatchResult +import com.m57.hermescontrol.data.model.KanbanBoardResponse +import com.m57.hermescontrol.data.model.KanbanBoardsResponse +import com.m57.hermescontrol.data.model.KanbanProfilesResponse +import com.m57.hermescontrol.data.model.KanbanProjectsResponse +import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse +import com.m57.hermescontrol.data.model.OrchestrationSettings +import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate +import com.m57.hermescontrol.data.model.ReassignTaskBody +import com.m57.hermescontrol.data.model.ReassignTaskResponse +import com.m57.hermescontrol.data.model.ReclaimTaskBody +import com.m57.hermescontrol.data.model.ReclaimTaskResponse +import com.m57.hermescontrol.data.model.RenameBoardBody +import com.m57.hermescontrol.data.model.RenameBoardResponse +import com.m57.hermescontrol.data.model.TaskEstimate +import com.m57.hermescontrol.data.model.UpdateTaskBody +import com.m57.hermescontrol.data.model.UpdateTaskResponse +import com.m57.hermescontrol.data.model.WorkerLog +import okhttp3.MultipartBody +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query +import retrofit2.http.Streaming + +interface KanbanApiService { + @GET("api/plugins/kanban/boards") + suspend fun getBoards( + @Query("include_archived") includeArchived: Boolean = false, + ): Response + + @GET("api/plugins/kanban/board") + suspend fun getBoard( + @Query("board") board: String? = null, + @Query("include_archived") includeArchived: Boolean = false, + @Query("tenant") tenant: String? = null, + ): Response + + @POST("api/plugins/kanban/boards") + suspend fun createBoard( + @Body body: CreateBoardBody, + ): Response + + @PATCH("api/plugins/kanban/boards/{slug}") + suspend fun updateBoard( + @Path("slug") slug: String, + @Body body: RenameBoardBody, + ): Response + + @DELETE("api/plugins/kanban/boards/{slug}") + suspend fun deleteBoard( + @Path("slug") slug: String, + @Query("delete") delete: Boolean = false, + ): Response + + @POST("api/plugins/kanban/boards/{slug}/switch") + suspend fun switchBoard( + @Path("slug") slug: String, + ): Response + + @GET("api/plugins/kanban/tasks/{id}") + suspend fun getTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/tasks") + suspend fun createTask( + @Query("board") board: String? = null, + @Body body: CreateTaskBody, + ): Response + + @PATCH("api/plugins/kanban/tasks/{id}") + suspend fun updateTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: UpdateTaskBody, + ): Response + + @DELETE("api/plugins/kanban/tasks/{id}") + suspend fun deleteTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/tasks/bulk") + suspend fun bulkTasks( + @Query("board") board: String? = null, + @Body body: BulkTasksBody, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/comments") + suspend fun addComment( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: Map, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/reassign") + suspend fun reassignTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: ReassignTaskBody, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/reclaim") + suspend fun reclaimTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: ReclaimTaskBody = ReclaimTaskBody(), + ): Response + + @GET("api/plugins/kanban/tasks/{id}/log") + suspend fun getTaskLog( + @Path("id") taskId: String, + @Query("tail") tail: Int = 16384, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/estimate") + suspend fun estimateTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/estimate") + suspend fun estimateNew( + @Body body: Map, + ): Response + + @Multipart + @POST("api/plugins/kanban/tasks/{id}/attachments") + suspend fun uploadAttachment( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Part file: MultipartBody.Part, + ): Response + + @Streaming + @GET("api/plugins/kanban/attachments/{id}") + suspend fun downloadAttachment( + @Path("id") attachmentId: Long, + @Query("board") board: String? = null, + ): Response + + @GET("api/plugins/kanban/profiles") + suspend fun getKanbanProfiles(): Response + + @PATCH("api/plugins/kanban/profiles/{name}") + suspend fun updateProfileDescription( + @Path("name") name: String, + @Body body: Map, + ): Response + + @POST("api/plugins/kanban/profiles/{name}/describe-auto") + suspend fun autoDescribeProfile( + @Path("name") name: String, + @Body body: Map = mapOf("overwrite" to true), + ): Response + + @GET("api/plugins/kanban/orchestration") + suspend fun getOrchestration(): Response + + @PUT("api/plugins/kanban/orchestration") + suspend fun updateOrchestration( + @Body body: OrchestrationSettingsUpdate, + ): Response + + @GET("api/plugins/kanban/projects") + suspend fun getProjects(): Response + + @POST("api/plugins/kanban/dispatch") + suspend fun nudgeDispatcher( + @Query("board") board: String? = null, + ): Response +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt new file mode 100644 index 00000000..efe3c419 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt @@ -0,0 +1,366 @@ +package com.m57.hermescontrol.data.repository + +import com.m57.hermescontrol.data.model.AttachmentUploadResponse +import com.m57.hermescontrol.data.model.AutoDescribeResponse +import com.m57.hermescontrol.data.model.BulkTasksBody +import com.m57.hermescontrol.data.model.BulkTasksResponse +import com.m57.hermescontrol.data.model.CreateBoardBody +import com.m57.hermescontrol.data.model.CreateBoardResponse +import com.m57.hermescontrol.data.model.CreateTaskBody +import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.DeleteBoardResponse +import com.m57.hermescontrol.data.model.DispatchResult +import com.m57.hermescontrol.data.model.KanbanBoardResponse +import com.m57.hermescontrol.data.model.KanbanBoardsResponse +import com.m57.hermescontrol.data.model.KanbanProfilesResponse +import com.m57.hermescontrol.data.model.KanbanProjectsResponse +import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse +import com.m57.hermescontrol.data.model.OrchestrationSettings +import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate +import com.m57.hermescontrol.data.model.ReassignTaskBody +import com.m57.hermescontrol.data.model.ReassignTaskResponse +import com.m57.hermescontrol.data.model.ReclaimTaskBody +import com.m57.hermescontrol.data.model.ReclaimTaskResponse +import com.m57.hermescontrol.data.model.RenameBoardBody +import com.m57.hermescontrol.data.model.RenameBoardResponse +import com.m57.hermescontrol.data.model.TaskEstimate +import com.m57.hermescontrol.data.model.UpdateTaskBody +import com.m57.hermescontrol.data.model.UpdateTaskResponse +import com.m57.hermescontrol.data.model.WorkerLog +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.KanbanApiService +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MultipartBody +import okhttp3.ResponseBody + +interface KanbanRepository { + suspend fun getBoards(includeArchived: Boolean = false): NetworkResult + + suspend fun getBoard( + board: String, + includeArchived: Boolean = false, + tenant: String? = null, + ): NetworkResult + + suspend fun createBoard(body: CreateBoardBody): NetworkResult + + suspend fun updateBoard( + slug: String, + body: RenameBoardBody, + ): NetworkResult + + suspend fun deleteBoard( + slug: String, + delete: Boolean = false, + ): NetworkResult + + suspend fun getTask( + taskId: String, + board: String?, + ): NetworkResult + + suspend fun createTask( + board: String?, + body: CreateTaskBody, + ): NetworkResult + + suspend fun updateTask( + taskId: String, + board: String?, + body: UpdateTaskBody, + ): NetworkResult + + suspend fun deleteTask( + taskId: String, + board: String?, + ): NetworkResult + + suspend fun bulkTasks( + board: String?, + body: BulkTasksBody, + ): NetworkResult + + suspend fun addComment( + taskId: String, + board: String?, + body: String, + ): NetworkResult + + suspend fun reassignTask( + taskId: String, + board: String?, + profile: String?, + reclaimFirst: Boolean = true, + ): NetworkResult + + suspend fun reclaimTask( + taskId: String, + board: String?, + reason: String? = null, + ): NetworkResult + + suspend fun getTaskLog( + taskId: String, + board: String?, + tail: Int = 16384, + ): NetworkResult + + suspend fun estimateTask( + taskId: String, + board: String?, + ): NetworkResult + + suspend fun estimateNew( + title: String, + body: String?, + ): NetworkResult + + suspend fun uploadAttachment( + taskId: String, + board: String?, + file: MultipartBody.Part, + ): NetworkResult + + suspend fun downloadAttachment( + attachmentId: Long, + board: String?, + ): NetworkResult + + suspend fun getProfiles(): NetworkResult + + suspend fun updateProfileDescription( + name: String, + description: String, + ): NetworkResult + + suspend fun autoDescribeProfile(name: String): NetworkResult + + suspend fun getOrchestration(): NetworkResult + + suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult + + suspend fun getProjects(): NetworkResult + + suspend fun nudgeDispatcher(board: String?): NetworkResult +} + +class KanbanRepositoryImpl( + private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi }, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : KanbanRepository { + override suspend fun getBoards(includeArchived: Boolean): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getBoards(includeArchived) } + } + + override suspend fun getBoard( + board: String, + includeArchived: Boolean, + tenant: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) } + } + + override suspend fun createBoard(body: CreateBoardBody): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().createBoard(body) } + } + + override suspend fun updateBoard( + slug: String, + body: RenameBoardBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().updateBoard(slug, body) } + } + + override suspend fun deleteBoard( + slug: String, + delete: Boolean, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().deleteBoard(slug, delete) } + } + + override suspend fun getTask( + taskId: String, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getTask(taskId = taskId, board = board) } + } + + override suspend fun createTask( + board: String?, + body: CreateTaskBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().createTask(board = board, body = body) } + } + + override suspend fun updateTask( + taskId: String, + board: String?, + body: UpdateTaskBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) } + } + + override suspend fun deleteTask( + taskId: String, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) } + } + + override suspend fun bulkTasks( + board: String?, + body: BulkTasksBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().bulkTasks(board = board, body = body) } + } + + override suspend fun addComment( + taskId: String, + board: String?, + body: String, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { + apiProvider().addComment( + taskId = taskId, + board = board, + body = mapOf("body" to body, "author" to "mobile"), + ) + } + } + + override suspend fun reassignTask( + taskId: String, + board: String?, + profile: String?, + reclaimFirst: Boolean, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { + apiProvider().reassignTask( + taskId = taskId, + board = board, + body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst), + ) + } + } + + override suspend fun reclaimTask( + taskId: String, + board: String?, + reason: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { + apiProvider().reclaimTask( + taskId = taskId, + board = board, + body = ReclaimTaskBody(reason = reason), + ) + } + } + + override suspend fun getTaskLog( + taskId: String, + board: String?, + tail: Int, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getTaskLog(taskId = taskId, tail = tail, board = board) } + } + + override suspend fun estimateTask( + taskId: String, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().estimateTask(taskId = taskId, board = board) } + } + + override suspend fun estimateNew( + title: String, + body: String?, + ): NetworkResult = + withContext(ioDispatcher) { + val payload = + buildMap { + put("title", title) + if (!body.isNullOrBlank()) put("body", body) + } + safeApiCall { apiProvider().estimateNew(payload) } + } + + override suspend fun uploadAttachment( + taskId: String, + board: String?, + file: MultipartBody.Part, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) } + } + + override suspend fun downloadAttachment( + attachmentId: Long, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) } + } + + override suspend fun getProfiles(): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getKanbanProfiles() } + } + + override suspend fun updateProfileDescription( + name: String, + description: String, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { + apiProvider().updateProfileDescription( + name = name, + body = mapOf("description" to description), + ) + } + } + + override suspend fun autoDescribeProfile(name: String): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().autoDescribeProfile(name) } + } + + override suspend fun getOrchestration(): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getOrchestration() } + } + + override suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().updateOrchestration(body) } + } + + override suspend fun getProjects(): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getProjects() } + } + + override suspend fun nudgeDispatcher(board: String?): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().nudgeDispatcher(board) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinator.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinator.kt new file mode 100644 index 00000000..6f9c56f5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinator.kt @@ -0,0 +1,125 @@ +package com.m57.hermescontrol.data.repository + +import com.m57.hermescontrol.data.ws.KanbanEventsClient +import com.m57.hermescontrol.data.ws.KanbanLiveStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +sealed interface KanbanSyncStatus { + data object Live : KanbanSyncStatus + + data class Polling( + val lastSyncEpoch: Long = System.currentTimeMillis(), + ) : KanbanSyncStatus + + data object AuthFailed : KanbanSyncStatus + + data object Disconnected : KanbanSyncStatus +} + +class KanbanSyncCoordinator( + private val repository: KanbanRepository, + private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() }, + private val reloadDebounceMs: Long = 250L, + private val pollIntervalMs: Long = 60_000L, + private val nudgeDebounceMs: Long = 400L, +) { + private var eventsClient: KanbanEventsClient? = null + private var activeBoard: String? = null + private var reloadJob: Job? = null + private var pollJob: Job? = null + private var nudgeJob: Job? = null + + private val _syncStatus = MutableStateFlow(KanbanSyncStatus.Disconnected) + val syncStatus: StateFlow = _syncStatus.asStateFlow() + + fun connect( + scope: CoroutineScope, + board: String, + onReload: () -> Unit, + ) { + if (activeBoard == board && _syncStatus.value == KanbanSyncStatus.Live) return + activeBoard = board + + val client = eventsClient ?: eventsClientProvider().also { eventsClient = it } + client.connect( + scope = scope, + board = board, + onEvents = { + reloadJob?.cancel() + reloadJob = + scope.launch { + delay(reloadDebounceMs) + onReload() + } + }, + onStatus = { status -> + when (status) { + KanbanLiveStatus.CONNECTED -> { + _syncStatus.value = KanbanSyncStatus.Live + } + + KanbanLiveStatus.DISCONNECTED -> { + _syncStatus.value = KanbanSyncStatus.Polling() + } + + KanbanLiveStatus.AUTH_FAILED -> { + _syncStatus.value = KanbanSyncStatus.AuthFailed + } + } + }, + ) + } + + fun startVisiblePolling( + scope: CoroutineScope, + board: String, + onPoll: () -> Unit, + ) { + pollJob?.cancel() + pollJob = + scope.launch { + while (true) { + delay(pollIntervalMs) + if (_syncStatus.value !is KanbanSyncStatus.Live) { + onPoll() + _syncStatus.value = KanbanSyncStatus.Polling(System.currentTimeMillis()) + } + } + } + } + + fun stopVisiblePolling() { + pollJob?.cancel() + pollJob = null + } + + fun scheduleNudge( + scope: CoroutineScope, + board: String, + ) { + nudgeJob?.cancel() + nudgeJob = + scope.launch { + delay(nudgeDebounceMs) + repository.nudgeDispatcher(board) + } + } + + fun disconnect() { + activeBoard = null + reloadJob?.cancel() + reloadJob = null + pollJob?.cancel() + pollJob = null + nudgeJob?.cancel() + nudgeJob = null + eventsClient?.disconnect() + _syncStatus.value = KanbanSyncStatus.Disconnected + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 22bdd752..3306955b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -19,9 +19,17 @@ object EventParser { response: JsonRpcResponse, rawJson: String = "", ): WsEvent { - // ── RPC response (has id) ──────────────────────────────────────── + // JSON-RPC is peer-to-peer: an inbound frame with both `id` and `method` + // is a request from the gateway, not a response to one of our calls. val id = response.id - if (id != null) { + if (response.method != null && id != null) { + @Suppress("UNCHECKED_CAST") + val requestParams = response.params?.toAny() as? Map ?: emptyMap() + return WsEvent.ServerRequest(id, response.method, requestParams) + } + + // A response has no method and carries a result or error member. + if (response.method == null && id != null && (response.result != null || response.error != null)) { return if (response.error != null) { WsEvent.RpcError(id, response.error) } else { @@ -220,6 +228,13 @@ object EventParser { WsEvent.ClarifyExpire(clarifyId, sessionId) } + "request.cancel" -> { + val requestId = payload?.get("id") as? String ?: "" + val requestMethod = payload?.get("method") as? String ?: "" + val reason = payload?.get("reason") as? String ?: "" + WsEvent.ServerRequestCancelled(requestId, requestMethod, reason, sessionId) + } + "status.update" -> { val status = payload?.get("status") as? String WsEvent.StatusUpdate(status, payload) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 4a2c7515..65d3ecc8 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -33,6 +33,8 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response @@ -808,6 +810,55 @@ object HermesWsClient { } } + /** + * Answer a gateway-originated JSON-RPC request. This deliberately bypasses + * [send]: server requests already have an id allocated by the gateway and + * responses must not contain a method or receive a new client id. + */ + fun respondToServerRequest( + id: String, + result: JsonElement, + ): Boolean { + if (id.isBlank()) return false + val json = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put("result", result) + }.toString() + synchronized(outboundLock) { + val ws = webSocket + if (ws == null || !connected.get()) return false + return ws.send(json) + } + } + + /** Answer a gateway-originated request with a standard JSON-RPC error. */ + fun respondToServerRequestError( + id: String, + code: Int, + message: String, + ): Boolean { + if (id.isBlank()) return false + val json = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put( + "error", + buildJsonObject { + put("code", code) + put("message", message) + }, + ) + }.toString() + synchronized(outboundLock) { + val ws = webSocket + if (ws == null || !connected.get()) return false + return ws.send(json) + } + } + // ── Send helpers ───────────────────────────────────────────────────── /** @@ -980,7 +1031,7 @@ object HermesWsClient { val deferred = request( method = WsMethods.SESSION_EVENTS_SINCE, - params = mapOf("session_id" to sid, "last_seen" to lastSeen, "since_seq" to lastSeen), + params = mapOf("session_id" to sid, "last_seen" to lastSeen), timeoutMs = 10_000L, ) val result = deferred.await() @@ -1249,6 +1300,21 @@ object HermesWsClient { resolvePending(rpcId, rpc.result, null) } + // Resume/replay responses carry still-open server requests + // separately from the event ring. Re-deliver them through the + // same generic path before exposing the RPC result. + @Suppress("UNCHECKED_CAST") + val openRequests = + (rpc.result?.toAny() as? Map<*, *>)?.get("open_requests") as? List> + openRequests?.forEach { openRequest -> + val openId = openRequest["id"] as? String ?: return@forEach + val method = openRequest["method"] as? String ?: return@forEach + + @Suppress("UNCHECKED_CAST") + val params = openRequest["params"] as? Map ?: emptyMap() + parsedEvents.tryEmit(WsEvent.ServerRequest(openId, method, params, replayed = true)) + } + if (rpc.id == null) { @Suppress("UNCHECKED_CAST") val params = rpc.params?.toAny() as? Map diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt index b8e91c84..796df16c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt @@ -23,7 +23,9 @@ data class JsonRpcRequest( ) /** - * Incoming JSON-RPC 2.0 response **or** server-pushed notification. + * Incoming JSON-RPC 2.0 response, server-to-client request, or notification. + * Frames carrying both [id] and [method] are classified as server requests by + * [EventParser], never as responses to a client RPC. */ @Serializable data class JsonRpcResponse( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt index d09fd10d..2551a208 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt @@ -8,11 +8,19 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket @@ -31,11 +39,37 @@ enum class KanbanLiveStatus { AUTH_FAILED, } +@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) +object StringOrLongSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StringOrLong", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: String?, + ) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeString(value) + } + } + + override fun deserialize(decoder: Decoder): String? { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString() + val element = jsonDecoder.decodeJsonElement() + if (element is JsonPrimitive) { + return element.content + } + return null + } +} + /** One row of the backend's ``task_events`` table (``/api/plugins/kanban/events``). */ @Serializable data class KanbanEvent( val id: Long, @SerialName("task_id") val taskId: String? = null, + @Serializable(with = StringOrLongSerializer::class) @SerialName("run_id") val runId: String? = null, val kind: String, /** Partial update (e.g. ``{"status": "done"}``) — never applied directly. */ diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt new file mode 100644 index 00000000..f1984bee --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt @@ -0,0 +1,39 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.ProjectInfo +import com.m57.hermescontrol.data.model.ProjectsListResponse +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonObject + +/** Supplies the profile's named projects so history rows can show which one a chat belongs to. */ +interface ProjectsSource { + /** The project list, or null when the backend can't answer (offline, older server). */ + suspend fun fetchProjects(): List? +} + +/** [ProjectsSource] backed by the gateway's `projects.list` JSON-RPC. */ +class HermesProjectsSource( + // Only ask a live socket: an offline request would be queued and force a reconnect on + // every refresh. The view model refetches when the connection comes back. + private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED }, + private val rpcRequest: suspend (method: String, params: Map) -> Any? = { method, params -> + HermesWsClient.request(method, params).await() + }, +) : ProjectsSource { + override suspend fun fetchProjects(): List? { + if (!isConnected()) return null + return try { + val element = SessionLiveStatusDecoder.anyToJsonElement(rpcRequest(WsMethods.PROJECTS_LIST, emptyMap())) + if (element is JsonObject && element.containsKey("projects")) { + OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects + } else { + null + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 94dd2b25..237e4558 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -152,6 +152,8 @@ sealed class WsEvent { val questionId: String? = null, val multiSelect: Boolean = false, val questions: List = emptyList(), + val serverRequestId: String? = null, + val lockedAnswers: Map = emptyMap(), ) : WsEvent() data class ClarifyExpire( @@ -213,6 +215,25 @@ sealed class WsEvent { val sessionId: String? = null, ) : WsEvent() + /** + * Generic JSON-RPC server-to-client request. The gateway keeps the request + * open until the client sends a response frame with this exact [id]. + */ + data class ServerRequest( + val id: String, + val method: String, + val params: Map = emptyMap(), + val replayed: Boolean = false, + ) : WsEvent() + + /** The gateway withdrew a still-open server request (timeout/interruption). */ + data class ServerRequestCancelled( + val id: String, + val method: String, + val reason: String, + val sessionId: String? = null, + ) : WsEvent() + // ── RPC responses ──────────────────────────────────────────────────── data class RpcResult( @@ -256,18 +277,21 @@ sealed class WsEvent { * restricts to once/deny only (desktop `smartDenied` parity). */ val smartDenied: Boolean? = null, + /** JSON-RPC server-request id (`srq-*`) used for the response frame. */ + val serverRequestId: String? = null, ) : WsEvent() // ── Sudo / secret requests ───────────────────────────────────────── /** * Backend needs the user's sudo password to continue a turn - * (desktop: `sudo.request` → `sudo.respond {request_id, password}`). - * Mobile previously dropped this and the agent hung forever. + * New gateways use a same-ID JSON-RPC result frame; legacy gateways fall + * back to `sudo.respond`. */ data class SudoRequest( val requestId: String?, val sessionId: String?, + val serverRequestId: String? = null, ) : WsEvent() /** @@ -278,12 +302,13 @@ sealed class WsEvent { data class SudoExpire( val requestId: String?, val sessionId: String?, + val serverRequestId: String? = null, ) : WsEvent() /** * Backend needs a secret value (password / token) to continue a turn - * (desktop: `secret.request` → `secret.respond {request_id, value}`). - * Mobile previously dropped this and the agent hung forever. + * New gateways use a same-ID JSON-RPC result frame; legacy gateways fall + * back to `secret.respond`. * `envVar`/`prompt` mirror desktop: title = envVar ?: secretTitle, * body = prompt ?: secretDesc. */ @@ -292,6 +317,7 @@ sealed class WsEvent { val sessionId: String?, val envVar: String? = null, val prompt: String? = null, + val serverRequestId: String? = null, ) : WsEvent() /** @@ -301,6 +327,7 @@ sealed class WsEvent { data class SecretExpire( val requestId: String?, val sessionId: String?, + val serverRequestId: String? = null, ) : WsEvent() // ── Credential Vault prompts (issue #1090) ────────────────────────── @@ -310,11 +337,13 @@ sealed class WsEvent { val sessionId: String? = null, val backend: String? = null, val displayName: String? = null, + val serverRequestId: String? = null, ) : WsEvent() data class VaultUnlockExpire( val requestId: String?, val sessionId: String? = null, + val serverRequestId: String? = null, ) : WsEvent() data class VaultSaveLoginRequest( @@ -322,11 +351,13 @@ sealed class WsEvent { val sessionId: String? = null, val origin: String? = null, val site: String? = null, + val serverRequestId: String? = null, ) : WsEvent() data class VaultSaveLoginExpire( val requestId: String?, val sessionId: String? = null, + val serverRequestId: String? = null, ) : WsEvent() data class VaultCodeRequest( @@ -334,11 +365,13 @@ sealed class WsEvent { val sessionId: String? = null, val site: String? = null, val hint: String? = null, + val serverRequestId: String? = null, ) : WsEvent() data class VaultCodeExpire( val requestId: String?, val sessionId: String? = null, + val serverRequestId: String? = null, ) : WsEvent() // ── Gateway-level errors ─────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 4c0a68c9..ab8c47bc 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -29,6 +29,11 @@ object WsMethods { const val PROFILES_CONFIGURE = "profiles.configure" const val PROFILES_LIST = "profiles.list" + // ── Projects ────────────────────────────────────────────────────────── + + /** Named workspaces (id, name, color, folders) used to label history rows. */ + const val PROJECTS_LIST = "projects.list" + // ── Interaction ─────────────────────────────────────────────────────── const val PROMPT_SUBMIT = "prompt.submit" const val PROMPT_BTW = "prompt.btw" @@ -159,6 +164,7 @@ object WsMethods { // Binds HERMES_HOME so config/skills/pets resolve to the // focused profile. "verification.status", // line 282 + PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped "pet.info", // line 1253 "pet.info.meta", // line 1279 "pet.cells", // line 1302 diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index c3128283..3cb10951 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -71,3 +71,23 @@ fun parseHexColor( fallback } } + +private val HSL_COLOR = + Regex("""^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%\s*(?:[,/]\s*[\d.]+%?\s*)?\)$""") + +/** + * Parse a project color chosen in the desktop app: CSS `hsl(210 68% 58%)` (space or comma + * separated) or hex. Returns null for anything else so callers can simply omit the accent. + */ +fun parseProjectColor(raw: String?): Color? { + val value = raw?.trim().orEmpty() + if (value.isEmpty()) return null + if (value.startsWith("#")) return parseHexColor(value, Color.Unspecified).takeIf { it != Color.Unspecified } + val match = HSL_COLOR.matchEntire(value.lowercase()) ?: return null + val (hue, saturation, lightness) = match.destructured + val h = hue.toFloatOrNull() ?: return null + val s = saturation.toFloatOrNull()?.div(100f) ?: return null + val l = lightness.toFloatOrNull()?.div(100f) ?: return null + if (h !in 0f..360f || s !in 0f..1f || l !in 0f..1f) return null + return Color.hsl(h % 360f, s, l) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt index 5d1d26a2..d1305be1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt @@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog @@ -29,6 +32,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,9 +62,18 @@ fun GroupChatSettingsDialog( ) { var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) } var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) } - var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) } + val systemPromptTextFieldState = rememberTextFieldState(currentSystemPrompt.orEmpty()) var showHighLimitsConfirmation by remember { mutableStateOf(false) } + LaunchedEffect(currentSystemPrompt) { + val loaded = currentSystemPrompt.orEmpty() + if (systemPromptTextFieldState.text.toString() != loaded) { + systemPromptTextFieldState.edit { + replace(0, length, loaded) + } + } + } + val isHighLimit = maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES || maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES @@ -217,8 +230,7 @@ fun GroupChatSettingsDialog( ) Spacer(modifier = Modifier.height(6.dp)) OutlinedTextField( - value = systemPrompt, - onValueChange = { systemPrompt = it }, + state = systemPromptTextFieldState, placeholder = { Text( text = stringResource(R.string.group_chat_settings_system_prompt_hint), @@ -231,7 +243,7 @@ fun GroupChatSettingsDialog( .heightIn(min = 80.dp, max = 160.dp) .testTag("room_system_prompt_input"), textStyle = MaterialTheme.typography.bodyMedium, - maxLines = 4, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), ) } } @@ -245,7 +257,10 @@ fun GroupChatSettingsDialog( onSave( maxMessages.roundToInt(), maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, + systemPromptTextFieldState.text + .toString() + .trim() + .ifBlank { null }, ) } }, @@ -260,7 +275,7 @@ fun GroupChatSettingsDialog( onClick = { maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat() maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat() - systemPrompt = "" + systemPromptTextFieldState.clearText() }, ) { Text(stringResource(R.string.group_chat_settings_reset_default)) @@ -307,7 +322,10 @@ fun GroupChatSettingsDialog( onSave( maxMessages.roundToInt(), maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, + systemPromptTextFieldState.text + .toString() + .trim() + .ifBlank { null }, ) }, colors = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt index e79c7b2d..3702fbfa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt @@ -125,7 +125,8 @@ class ChannelsViewModel : } }, onStart = {}, - onSuccess = { + onSuccess = { response -> + val hotServed = response.hotServed _uiState.update { state -> state.copy( platforms = @@ -133,16 +134,28 @@ class ChannelsViewModel : if (p.id == platformId) { p.copy( enabled = enabled, - state = if (enabled) "pending_restart" else "disabled", + state = + when { + !enabled -> "disabled" + hotServed -> "connected" + else -> "pending_restart" + }, ) } else { p } }, - restartNeeded = true, + restartNeeded = !hotServed, togglingId = null, + toastMessage = + if (hotServed) { + "$platformId updated live" + } else { + null + }, ) } + loadPlatforms() }, onError = { error -> _uiState.update { @@ -201,12 +214,18 @@ class ChannelsViewModel : it.copy(isLoading = true, errorMessage = null) } }, - onSuccess = { - val message = "$platformId configured successfully — restart the gateway for changes to take effect" + onSuccess = { response -> + val hotServed = response.hotServed + val message = + if (hotServed) { + "$platformId configured and reloaded live" + } else { + "$platformId configured successfully — restart the gateway for changes to take effect" + } _uiState.update { it.copy( isLoading = false, - restartNeeded = true, + restartNeeded = !hotServed, toastMessage = message, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt index 9f2941ec..b03a65ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.channels.components +import android.content.ClipData import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.RadioButtonChecked @@ -34,18 +36,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformUpdate import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.channels.ConfigureForm +import kotlinx.coroutines.launch private enum class StatusRole { SUCCESS, WARNING, ERROR, NEUTRAL } @@ -110,6 +117,8 @@ internal fun PlatformCard( ) { var showConfigureForm by remember { mutableStateOf(false) } var showRemoveConfirm by remember { mutableStateOf(false) } + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() val style = platformStateStyle(platform.state) Card(modifier = Modifier.fillMaxWidth()) { @@ -166,6 +175,40 @@ internal fun PlatformCard( ) } + platform.ingressUrl?.takeIf { it.isNotBlank() }?.let { ingressUrl -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.channels_ingress_url), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = ingressUrl, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, ingressUrl))) + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.channels_copy_ingress_url), + modifier = Modifier.size(18.dp), + ) + } + } + } + // ── Error message ── if (!platform.errorMessage.isNullOrBlank()) { Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt index 0144d71b..32d68d66 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt @@ -7,6 +7,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put /** * Owns approval rendering, deduplication, user response submission, and @@ -22,8 +25,49 @@ class ChatApprovalsDelegate( private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, private val trackRequest: (id: String, method: String) -> Unit, private val addSystemMessage: (text: String) -> Unit, + private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null, ) { fun handleApprovalRequest(event: WsEvent.ApprovalRequest) { + val existingIndex = + uiState.value.messages.indexOfLast { message -> + val approval = message.approvalInfo ?: return@indexOfLast false + (event.serverRequestId != null && approval.serverRequestId == event.serverRequestId) || + (event.requestId != null && approval.requestId == event.requestId) + } + if (existingIndex >= 0) { + // `approval.pending` can arrive before the live server request during + // reconnect. Upgrade that legacy card with the direct server id so + // the user's next action answers the same JSON-RPC request instead of + // creating a duplicate prompt. + if (event.serverRequestId != null) { + uiState.update { state -> + state.copy( + messages = + state.messages.mapIndexed { index, message -> + if (index != existingIndex) { + message + } else { + val approval = message.approvalInfo + message.copy( + approvalInfo = + approval?.copy( + command = event.command ?: approval.command, + description = event.description ?: approval.description, + patternKeys = event.patternKeys ?: approval.patternKeys, + requestId = event.requestId ?: approval.requestId, + serverRequestId = event.serverRequestId, + choices = event.choices ?: approval.choices, + allowPermanent = event.allowPermanent ?: approval.allowPermanent, + smartDenied = event.smartDenied ?: approval.smartDenied, + ), + ) + } + }, + ) + } + } + return + } val description = event.description ?: event.command ?: "Unknown command" val content = "**Approval Required**\n$description" val msg = @@ -36,6 +80,7 @@ class ChatApprovalsDelegate( description = event.description, patternKeys = event.patternKeys, requestId = event.requestId, + serverRequestId = event.serverRequestId, choices = event.choices, allowPermanent = event.allowPermanent, smartDenied = event.smartDenied, @@ -51,7 +96,7 @@ class ChatApprovalsDelegate( // so the backend knows this client holds the prompt. Fire-and-forget. val requestId = event.requestId val sessionId = runtimeSessionId() ?: event.sessionId ?: uiState.value.currentSessionId - if (requestId != null && sessionId != null) { + if (event.serverRequestId == null && requestId != null && sessionId != null) { scope.launch(ioDispatcher) { runCatching { wsSend( @@ -118,6 +163,7 @@ class ChatApprovalsDelegate( // (any non-deny still unblocks, but stay on-spec going forward). val choice = if (action == "approve") "once" else action val requestId = approvalMsg.approvalInfo?.requestId + val serverRequestId = approvalMsg.approvalInfo?.serverRequestId // Clear buttons immediately uiState.update { s -> @@ -134,6 +180,16 @@ class ChatApprovalsDelegate( } scope.launch(ioDispatcher) { + if (serverRequestId != null && respondToServerRequest != null) { + respondToServerRequest.invoke( + serverRequestId, + buildJsonObject { + put("choice", choice) + put("all", false) + }, + ) + return@launch + } val params = mutableMapOf( "session_id" to sessionId, @@ -149,6 +205,21 @@ class ChatApprovalsDelegate( replayPendingApproval(sessionId) } } + + fun cancelServerRequest(requestId: String) { + uiState.update { state -> + state.copy( + messages = + state.messages.map { message -> + if (message.approvalInfo?.serverRequestId == requestId) { + message.copy(approvalInfo = null) + } else { + message + } + }, + ) + } + } } /** @@ -189,6 +260,7 @@ internal fun parseApprovalMap( patternKeys = patternKeys, sessionId = sessionId, requestId = map["request_id"] as? String, + serverRequestId = null, choices = choices, allowPermanent = allowPermanent, smartDenied = smartDenied, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 6668826a..2923f3cb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -95,6 +95,7 @@ import java.time.format.DateTimeFormatter * renderer (issue #866). Agent prose renders full-bleed; user messages keep * this bubble so the conversation stays scannable. */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun ChatBubble( message: ChatMessage, @@ -106,6 +107,8 @@ fun ChatBubble( openingAttachmentPath: String? = null, canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, + messageStatsEnabled: Boolean = false, + showUserMessageTokens: Boolean = true, modifier: Modifier = Modifier, ) { val screenWidth = LocalConfiguration.current.screenWidthDp.dp @@ -222,12 +225,12 @@ fun ChatBubble( } } if (!message.isStreaming) { - Row( + FlowRow( modifier = Modifier .align(Alignment.End) .padding(top = 4.dp), - verticalAlignment = Alignment.CenterVertically, + itemVerticalAlignment = Alignment.CenterVertically, ) { IconButton( onClick = { @@ -257,43 +260,28 @@ fun ChatBubble( color = userBubbleTextColor.copy(alpha = 0.6f), style = MaterialTheme.typography.labelSmall, ) - if (message.tokenCount != null && message.tokenCount > 0) { - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = userBubbleTextColor.copy(alpha = 0.4f), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = - stringResource( - R.string.chat_msg_tokens, - TokenEstimator.formatTokenCount(message.tokenCount), - ), - color = userBubbleTextColor.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.testTag("bubble_token_count"), - ) - } - if (message.tps != null && message.tps > 0.0) { - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = userBubbleTextColor.copy(alpha = 0.4f), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = - stringResource( - R.string.chat_msg_tps, - TokenEstimator.formatTps(message.tps), - ), - color = userBubbleTextColor.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.testTag("bubble_tps"), - ) + if (messageStatsEnabled && showUserMessageTokens && + message.tokenCount != null && message.tokenCount > 0 + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall, + color = userBubbleTextColor.copy(alpha = 0.4f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + stringResource( + R.string.chat_msg_tokens, + TokenEstimator.formatTokenCount(message.tokenCount), + ), + color = userBubbleTextColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.testTag("bubble_token_count"), + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt index e24f6b6b..1c284fcf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt @@ -7,6 +7,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put class ChatClarifyDelegate( private val uiState: MutableStateFlow, @@ -15,6 +18,7 @@ class ChatClarifyDelegate( private val persistMessage: suspend (ChatMessage, String) -> Unit, private val wsClient: HermesWsClient = HermesWsClient, private val trackRequest: (String, String) -> Unit = { _, _ -> }, + private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null, ) { fun respondToClarify(option: String) { val clarify = uiState.value.clarifyRequest @@ -41,6 +45,8 @@ class ChatClarifyDelegate( val sessionId = uiState.value.currentSessionId ?: return val clarify = uiState.value.clarifyRequest val clarifyId = clarify?.clarifyId + val serverRequestId = clarify?.serverRequestId + val lockedAnswers = clarify?.lockedAnswers.orEmpty() val isBatch = !clarify?.questions.isNullOrEmpty() val questions = clarify?.resolvedQuestions.orEmpty() uiState.update { it.copy(clarifyRequest = null) } @@ -49,7 +55,9 @@ class ChatClarifyDelegate( if (questions.size > 1) { questions .mapIndexed { index, q -> - val ans = answers[q.qid]?.trim().orEmpty() + val ans = + answers[q.qid]?.trim()?.takeIf { it.isNotEmpty() } + ?: lockedAnswers[q.qid].orEmpty() "${index + 1}. ${ans.ifEmpty { "(Skipped)" }}" }.joinToString("\n") } else { @@ -75,6 +83,35 @@ class ChatClarifyDelegate( } scope.launch(ioDispatcher) { + if (serverRequestId != null && respondToServerRequest != null) { + val finalAnswers = + buildMap { + putAll(lockedAnswers) + answers.forEach { (qid, answer) -> + answer.trim().takeIf { it.isNotEmpty() }?.let { put(qid, it) } + } + } + val result = + if (isBatch) { + buildJsonObject { + put( + "answers", + buildJsonObject { + for ((qid, answer) in finalAnswers) { + put(qid, answer) + } + }, + ) + } + } else { + buildJsonObject { + put("answer", answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer.orEmpty()) + } + } + respondToServerRequest.invoke(serverRequestId, result) + return@launch + } + if (isBatch) { for (q in questions) { val ans = answers[q.qid]?.trim().orEmpty() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt index fdaa51c0..c6e03cf1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -21,15 +22,29 @@ class ChatCredentialPromptsDelegate( private val uiState: MutableStateFlow, private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, private val trackRequest: (id: String, method: String) -> Unit, + private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null, ) { + private fun sendResponse( + serverRequestId: String?, + result: JsonElement, + legacyMethod: String, + legacyParams: Map, + ) { + if (serverRequestId != null && respondToServerRequest != null) { + respondToServerRequest.invoke(serverRequestId, result) + } else { + wsSend(legacyMethod, legacyParams) { id -> trackRequest(id, legacyMethod) } + } + } + /** - * The agent needs the user's sudo password. Surface a secure dialog and - * reply via sudo.respond. + * New gateways use a same-ID JSON-RPC response; legacy notifications fall + * back to sudo.respond. */ fun handleSudoRequest(event: WsEvent.SudoRequest) { uiState.update { it.copy( - sudoPrompt = SudoPromptUi(event.requestId, event.sessionId), + sudoPrompt = SudoPromptUi(event.requestId, event.sessionId, event.serverRequestId), isAgentTyping = false, ) } @@ -42,9 +57,10 @@ class ChatCredentialPromptsDelegate( fun handleSudoExpire(event: WsEvent.SudoExpire) { uiState.update { state -> val current = state.sudoPrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { + if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) { + return@update state + } + if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) { return@update state } state.copy(sudoPrompt = null) @@ -52,8 +68,8 @@ class ChatCredentialPromptsDelegate( } /** - * The agent needs a secret value (token/password). Surface a secure dialog - * and reply via secret.respond. + * New gateways use a same-ID JSON-RPC response; legacy notifications fall + * back to secret.respond. */ fun handleSecretRequest(event: WsEvent.SecretRequest) { uiState.update { @@ -64,6 +80,7 @@ class ChatCredentialPromptsDelegate( event.sessionId, event.envVar, event.prompt, + event.serverRequestId, ), isAgentTyping = false, ) @@ -76,9 +93,10 @@ class ChatCredentialPromptsDelegate( fun handleSecretExpire(event: WsEvent.SecretExpire) { uiState.update { state -> val current = state.secretPrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { + if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) { + return@update state + } + if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) { return@update state } state.copy(secretPrompt = null) @@ -101,10 +119,12 @@ class ChatCredentialPromptsDelegate( "password" to "", ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", "") }, WsMethods.SUDO_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) } + ) } } @@ -124,10 +144,12 @@ class ChatCredentialPromptsDelegate( "value" to "", ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", "") }, WsMethods.SECRET_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) } + ) } } @@ -149,10 +171,12 @@ class ChatCredentialPromptsDelegate( "password" to password, ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", password) }, WsMethods.SUDO_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) } + ) } } @@ -173,10 +197,12 @@ class ChatCredentialPromptsDelegate( "value" to value, ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", value) }, WsMethods.SECRET_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) } + ) } } @@ -184,7 +210,8 @@ class ChatCredentialPromptsDelegate( /** * The agent needs to unlock a password manager (1Password, Bitwarden). - * Surface an interactive prompt card in chat and reply via vault.unlock.respond. + * New gateways use a same-ID JSON-RPC response; legacy notifications fall + * back to vault.unlock.respond. */ fun handleVaultUnlockRequest(event: WsEvent.VaultUnlockRequest) { uiState.update { @@ -195,6 +222,7 @@ class ChatCredentialPromptsDelegate( sessionId = event.sessionId, backend = event.backend, displayName = event.displayName, + serverRequestId = event.serverRequestId, ), isAgentTyping = false, ) @@ -204,9 +232,10 @@ class ChatCredentialPromptsDelegate( fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) { uiState.update { state -> val current = state.vaultUnlockPrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { + if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) { + return@update state + } + if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) { return@update state } state.copy(vaultUnlockPrompt = null) @@ -228,10 +257,12 @@ class ChatCredentialPromptsDelegate( "password" to "", ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", "") }, WsMethods.VAULT_UNLOCK_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) } + ) } } @@ -249,10 +280,12 @@ class ChatCredentialPromptsDelegate( "password" to password, ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", password) }, WsMethods.VAULT_UNLOCK_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) } + ) } } @@ -268,6 +301,7 @@ class ChatCredentialPromptsDelegate( sessionId = event.sessionId, origin = event.origin, site = event.site, + serverRequestId = event.serverRequestId, ), isAgentTyping = false, ) @@ -277,9 +311,10 @@ class ChatCredentialPromptsDelegate( fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) { uiState.update { state -> val current = state.vaultSaveLoginPrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { + if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) { + return@update state + } + if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) { return@update state } state.copy(vaultSaveLoginPrompt = null) @@ -301,10 +336,12 @@ class ChatCredentialPromptsDelegate( "login" to "", ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", "") }, WsMethods.VAULT_SAVE_LOGIN_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) } + ) } } @@ -330,10 +367,12 @@ class ChatCredentialPromptsDelegate( "login" to loginJson, ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", loginJson) }, WsMethods.VAULT_SAVE_LOGIN_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) } + ) } } @@ -349,6 +388,7 @@ class ChatCredentialPromptsDelegate( sessionId = event.sessionId, site = event.site, hint = event.hint, + serverRequestId = event.serverRequestId, ), isAgentTyping = false, ) @@ -358,9 +398,10 @@ class ChatCredentialPromptsDelegate( fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) { uiState.update { state -> val current = state.vaultCodePrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { + if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) { + return@update state + } + if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) { return@update state } state.copy(vaultCodePrompt = null) @@ -382,10 +423,12 @@ class ChatCredentialPromptsDelegate( "code" to "", ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", "") }, WsMethods.VAULT_CODE_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) } + ) } } @@ -403,10 +446,12 @@ class ChatCredentialPromptsDelegate( "code" to code, ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", code) }, WsMethods.VAULT_CODE_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) } + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 8239f426..783ddba0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -14,6 +14,8 @@ data class ApprovalInfo( val patternKeys: List?, /** Backend `request_id` — sent back in `approval.respond` to pin the exact pending. */ val requestId: String? = null, + /** JSON-RPC server-request id (`srq-*`) used for the response frame. */ + val serverRequestId: String? = null, /** Backend-advertised choices (`once`/`session`/`always`/`deny`). Null = legacy Run/Deny. */ val choices: List? = null, /** False → hide "Always allow" (tirith warning, desktop parity). */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index db8bfc30..671b5f37 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -359,7 +358,6 @@ fun ChatScreen( } var viewingImage by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } - val isDark = isSystemInDarkTheme() val launchExternalActivity: (() -> Unit) -> Unit = { launch -> ExternalActivityLifecycleGuard.launchExternalActivity( acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, @@ -668,14 +666,18 @@ fun ChatScreen( // surface since the bubble renderer was removed. FullBleedChatList( messages = state.messages, - streamingMessage = streamingState.streamingMessage, + streamingState = streamingState, + isAgentTyping = state.isAgentTyping, searchState = searchState, typingEffectEnabled = state.typingEffectEnabled, typingEffectDelayMs = state.typingEffectDelayMs, + messageStatsEnabled = state.messageStatsEnabled, + showUserMessageTokens = state.showUserMessageTokens, + showAssistantMessageTokens = state.showAssistantMessageTokens, + showTokensPerSecond = state.showTokensPerSecond, maxToolCallsPerTurn = state.maxToolCallsPerTurn, isLoading = state.isLoading, isLoadingOlder = state.isLoadingOlder, - isDark = isDark, listState = listState, scrollController = scrollController, lastAnimatedMessageId = lastAnimatedMessageId, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt index e8801097..eaa56386 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt @@ -213,9 +213,16 @@ class ChatSubagentsDelegate( * Rules: * - `subagent_id` is the primary identity. * - Protects against races: if an indicator received a push event with timestamp > [requestTime], - * its push-event status/type is preserved. + * its push-event status/type is preserved and it is not pruned. * - Preserves local details (logs, taskIndex, taskCount, summary) not provided in the snapshot. - * - Does not discard locally known indicators absent from the snapshot. + * - Reconciles stale running indicators (issue #1117): an identified ([SubagentIndicator.subagentId] != null), + * running ([SubagentIndicator.isRunning]) indicator omitted from an authoritative snapshot is removed + * if its [SubagentIndicator.lastEventTimestamp] <= [requestTime]. This clears indicators that completed + * while disconnected where `subagent.complete` was missed. + * - Protects against races for new subagents: a running indicator spawned or updated by a push event + * newer than [requestTime] is preserved even if omitted from an older in-flight snapshot response. + * - Preserves already-terminal indicators (completed, failed, cancelled) and unassigned placeholders + * ([SubagentIndicator.subagentId] == null) even if omitted from the snapshot. */ internal fun mergeSubagentList( current: List, @@ -300,6 +307,15 @@ class ChatSubagentsDelegate( } } + val snapshotIds = listItems.mapNotNull { it.subagentId.trim().takeIf { id -> id.isNotEmpty() } }.toSet() + merged.removeAll { indicator -> + val id = indicator.subagentId?.trim() + !id.isNullOrEmpty() && + indicator.isRunning && + id !in snapshotIds && + indicator.lastEventTimestamp <= requestTime + } + return merged } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 3cf0199e..7a69978b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.UsageSnapshotResponse import com.m57.hermescontrol.data.model.parseContextBreakdown import com.m57.hermescontrol.data.model.parseUsageSnapshot import com.m57.hermescontrol.data.remote.ApiClient @@ -43,6 +44,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonPrimitive @@ -112,6 +114,10 @@ data class ChatUiState( // Cached settings val typingEffectEnabled: Boolean = true, val typingEffectDelayMs: Int = 30, + val messageStatsEnabled: Boolean = false, + val showUserMessageTokens: Boolean = true, + val showAssistantMessageTokens: Boolean = true, + val showTokensPerSecond: Boolean = true, // Commands catalog val commandCatalog: CommandCatalog = CommandCatalog(), // Per-command usage counts for the slash-autocomplete ranking (issue @@ -154,6 +160,8 @@ data class ChatUiState( val compressionCount: Int? = null, /** Rolling output tokens/sec over the last ~10 calls. */ val latestTps: Double? = null, + /** Latest cumulative backend usage snapshot for the current session. */ + val sessionUsage: UsageSnapshotResponse? = null, // Attachment state val pendingAttachments: List = emptyList(), /** One-shot composer recovery after an attachment is rejected before send. */ @@ -204,6 +212,8 @@ data class ClarifyUi( val questionId: String? = null, val multiSelect: Boolean = false, val questions: List = emptyList(), + val serverRequestId: String? = null, + val lockedAnswers: Map = emptyMap(), ) { /** * Normalized list of questions to display. Guarantees at least one question @@ -251,6 +261,7 @@ private const val CLARIFY_DISMISS_RESPONSE = "The user cancelled — no answer p data class SudoPromptUi( val requestId: String?, val sessionId: String?, + val serverRequestId: String? = null, ) /** Transient — not persisted. Holds a pending secret (token/password) request. */ @@ -259,6 +270,7 @@ data class SecretPromptUi( val sessionId: String?, val envVar: String? = null, val prompt: String? = null, + val serverRequestId: String? = null, ) /** Transient — not persisted. Holds a pending vault unlock request (issue #1090). */ @@ -267,6 +279,7 @@ data class VaultUnlockPromptUi( val sessionId: String?, val backend: String? = null, val displayName: String? = null, + val serverRequestId: String? = null, ) /** Transient — not persisted. Holds a pending vault save login request (issue #1090). */ @@ -275,6 +288,7 @@ data class VaultSaveLoginPromptUi( val sessionId: String?, val origin: String? = null, val site: String? = null, + val serverRequestId: String? = null, ) /** Transient — not persisted. Holds a pending vault 2FA/MFA code request (issue #1090). */ @@ -283,6 +297,7 @@ data class VaultCodePromptUi( val sessionId: String?, val site: String? = null, val hint: String? = null, + val serverRequestId: String? = null, ) /** @@ -469,6 +484,7 @@ class ChatViewModel( uiState = _uiState, wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, trackRequest = { id, method -> trackRequest(id, method) }, + respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) }, ) private val approvalsDelegate = @@ -480,6 +496,7 @@ class ChatViewModel( wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, trackRequest = { id, method -> trackRequest(id, method) }, addSystemMessage = { text -> addSystemMessage(text) }, + respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) }, ) private val clarifyDelegate = @@ -490,6 +507,7 @@ class ChatViewModel( persistMessage = { msg, sid -> repo.persistMessage(msg, sid) }, wsClient = wsClient, trackRequest = { id, method -> trackRequest(id, method) }, + respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) }, ) private val subagentsDelegate = @@ -941,6 +959,14 @@ class ChatViewModel( streamingController.resetStreaming() } + is WsEvent.ServerRequest -> { + handleServerRequest(event) + } + + is WsEvent.ServerRequestCancelled -> { + handleServerRequestCancelled(event) + } + is WsEvent.ApprovalRequest -> { approvalsDelegate.handleApprovalRequest(event) } @@ -1015,6 +1041,184 @@ class ChatViewModel( } } + /** Route the generic transport event into the existing prompt delegates. */ + private fun handleServerRequest(request: WsEvent.ServerRequest) { + val params = request.params + val sessionId = params["session_id"] as? String + when (request.method) { + "clarify" -> { + @Suppress("UNCHECKED_CAST") + val rawQuestions = params["questions"] as? List<*> + + @Suppress("UNCHECKED_CAST") + val lockedAnswers = + (params["answers"] as? Map<*, *>) + ?.entries + ?.mapNotNull { (key, value) -> + if (key is String && value is String) key to value else null + }?.toMap() + .orEmpty() + val questions = + rawQuestions.orEmpty().mapIndexedNotNull { index, item -> + val map = item as? Map<*, *> ?: return@mapIndexedNotNull null + val question = map["question"] as? String ?: return@mapIndexedNotNull null + + @Suppress("UNCHECKED_CAST") + val choices = (map["choices"] as? List<*>)?.filterIsInstance() ?: emptyList() + WsEvent.ClarifyQuestion( + qid = map["qid"] as? String ?: "q$index", + question = question, + choices = choices, + multiSelect = map["multi_select"] as? Boolean ?: false, + ) + } + val first = questions.firstOrNull() + handleWsEvent( + WsEvent.ClarifyRequest( + text = first?.question ?: params["question"] as? String, + options = first?.choices ?: (params["choices"] as? List<*>)?.filterIsInstance(), + clarifyId = params["clarify_id"] as? String ?: params["request_id"] as? String, + sessionId = sessionId, + questionId = first?.qid, + multiSelect = first?.multiSelect ?: (params["multi_select"] as? Boolean ?: false), + questions = questions, + serverRequestId = request.id, + lockedAnswers = lockedAnswers, + ), + ) + } + + "approval" -> { + @Suppress("UNCHECKED_CAST") + val choices = (params["choices"] as? List<*>)?.filterIsInstance() + handleWsEvent( + WsEvent.ApprovalRequest( + command = params["command"] as? String, + description = params["description"] as? String, + patternKeys = (params["pattern_keys"] as? List<*>)?.filterIsInstance(), + sessionId = sessionId, + requestId = params["request_id"] as? String, + serverRequestId = request.id, + choices = choices, + allowPermanent = params["allow_permanent"] as? Boolean, + smartDenied = params["smart_denied"] as? Boolean, + ), + ) + } + + "sudo" -> { + handleWsEvent( + WsEvent.SudoRequest( + requestId = params["request_id"] as? String, + sessionId = sessionId, + serverRequestId = request.id, + ), + ) + } + + "secret" -> { + handleWsEvent( + WsEvent.SecretRequest( + requestId = params["request_id"] as? String, + sessionId = sessionId, + envVar = params["env_var"] as? String, + prompt = params["prompt"] as? String, + serverRequestId = request.id, + ), + ) + } + + "vault.code" -> { + handleWsEvent( + WsEvent.VaultCodeRequest( + requestId = params["request_id"] as? String, + sessionId = sessionId, + site = params["site"] as? String, + hint = params["hint"] as? String, + serverRequestId = request.id, + ), + ) + } + + "vault.save_login" -> { + handleWsEvent( + WsEvent.VaultSaveLoginRequest( + requestId = params["request_id"] as? String, + sessionId = sessionId, + origin = params["origin"] as? String, + site = params["site"] as? String, + serverRequestId = request.id, + ), + ) + } + + "vault.unlock_prompt" -> { + handleWsEvent( + WsEvent.VaultUnlockRequest( + requestId = params["request_id"] as? String, + sessionId = sessionId, + backend = params["backend"] as? String, + displayName = params["display_name"] as? String, + serverRequestId = request.id, + ), + ) + } + + else -> { + wsClient.respondToServerRequestError( + request.id, + -32601, + "no handler for server request: ${request.method}", + ) + } + } + } + + private fun handleServerRequestCancelled(event: WsEvent.ServerRequestCancelled) { + when (event.method) { + "clarify" -> { + if (_uiState.value.clarifyRequest?.serverRequestId == event.id) { + _uiState.update { it.copy(clarifyRequest = null) } + streamingController.resetStreaming() + } + } + + "approval" -> { + approvalsDelegate.cancelServerRequest(event.id) + } + + "sudo" -> { + credentialPromptsDelegate.handleSudoExpire( + WsEvent.SudoExpire(null, event.sessionId, serverRequestId = event.id), + ) + } + + "secret" -> { + credentialPromptsDelegate.handleSecretExpire( + WsEvent.SecretExpire(null, event.sessionId, serverRequestId = event.id), + ) + } + + "vault.code" -> { + credentialPromptsDelegate.handleVaultCodeExpire( + WsEvent.VaultCodeExpire(null, event.sessionId, serverRequestId = event.id), + ) + } + + "vault.save_login" -> { + credentialPromptsDelegate.handleVaultSaveLoginExpire( + WsEvent.VaultSaveLoginExpire(null, event.sessionId, serverRequestId = event.id), + ) + } + + "vault.unlock_prompt" -> { + credentialPromptsDelegate.handleVaultUnlockExpire( + WsEvent.VaultUnlockExpire(null, event.sessionId, serverRequestId = event.id), + ) + } + } + } + // ── Message streaming ──────────────────────────────────────────────── /** @@ -1057,6 +1261,7 @@ class ChatViewModel( fullContextTokens = null, contextBreakdown = null, compressionCount = null, + sessionUsage = null, ) } // A gone-session recovery just landed — announce it now that @@ -1321,6 +1526,16 @@ class ChatViewModel( else -> error.toString() } + if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT) { + // A prompt rejection can arrive before message.start, leaving the + // UI with only the optimistic typing state. Clear the live tail so + // a failed generation cannot leave stale dots or reasoning behind. + sealStreamingMessageIfAny() + _uiState.update { it.copy(isAgentTyping = false) } + _streamingState.update { StreamingState() } + streamingController.resetStreaming() + } + // Session resume failures go through the bounded retry (desktop // parity) instead of a one-shot snackbar — the session may be // mid-flush on the gateway or the WS may have just rebound, and a @@ -1592,6 +1807,8 @@ class ChatViewModel( if (text.isNotBlank()) "\n\n$text" else "" } + if (dispatchGeneration != sessionGeneration) return@launch + // While a turn is actively streaming and this is a plain text prompt // (no attachments — session.redirect carries text only), steer the // in-flight turn via session.redirect instead of queueing a fresh @@ -1600,6 +1817,7 @@ class ChatViewModel( if (dispatchGeneration == sessionGeneration) { ActiveSessionHolder.set(agentSessionId, storageSessionId) } + captureTurnUsageBaselineIfNeeded() if (wasStreaming && attachments.isEmpty()) { wsClient.sendRedirect( agentSessionId, @@ -1633,6 +1851,20 @@ class ChatViewModel( } } + private fun captureTurnUsageBaselineIfNeeded() { + if (_streamingState.value.turnUsageBaselineCaptured) return + _streamingState.update { + if (it.turnUsageBaselineCaptured) { + it + } else { + it.copy( + turnUsageBaseline = _uiState.value.sessionUsage, + turnUsageBaselineCaptured = true, + ) + } + } + } + /** Snapshot one URI to private cache while enforcing the outbound frame limit. */ private suspend fun prepareAttachment(attachment: Attachment): PrepareAttachmentResult { var encodedFile: File? = null @@ -2323,6 +2555,10 @@ class ChatViewModel( state.copy( typingEffectEnabled = AuthManager.isTypingEffectEnabled(), typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(), + messageStatsEnabled = AuthManager.isMessageStatsEnabled(), + showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(), + showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled(), + showTokensPerSecond = AuthManager.isTokensPerSecondEnabled(), ) } } @@ -2652,6 +2888,7 @@ class ChatViewModel( fullContextTokens = null, contextBreakdown = null, compressionCount = null, + sessionUsage = null, pendingAttachments = emptyList(), composerTextToRestore = null, reactionKind = null, @@ -3279,7 +3516,7 @@ class ChatViewModel( ) coroutineContext.ensureActive() val snapshot = parseUsageSnapshot(usage) - if (snapshot != null && snapshot.compressions != null) { + if (snapshot != null) { _uiState.update { current -> if (!isCurrentContextFetch( sessionId, @@ -3290,7 +3527,7 @@ class ChatViewModel( ) { current } else { - current.copy(compressionCount = snapshot.compressions) + applyUsageSnapshot(current, snapshot) } } } @@ -3452,8 +3689,8 @@ class ChatViewModel( * * The backend's clarify tool blocks the agent thread waiting for a response * (CLI timeout is 120s). A silent dismiss would leave the agent hanging - * until that timeout, so we send a cancel sentinel - * ([CLARIFY_DISMISS_RESPONSE]) over `clarify.respond` to unblock it. + * until that timeout, so we send an empty result frame for new gateways or + * the legacy cancel sentinel for old notification-based gateways. * * This is a *reject*, not an instruction to proceed — the agent is told no * answer was provided and should re-ask or back off, NOT charge ahead. @@ -3467,6 +3704,7 @@ class ChatViewModel( val sessionId = _uiState.value.currentSessionId ?: return val clarify = _uiState.value.clarifyRequest val clarifyId = clarify?.clarifyId + val serverRequestId = clarify?.serverRequestId // Raw batch questions (non-empty only for true batch payloads). // Legacy singles keep questions empty and rely on questionId (nullable). val isBatch = !clarify?.questions.isNullOrEmpty() @@ -3476,6 +3714,10 @@ class ChatViewModel( addSystemMessage("Clarify dismissed — no answer sent", persist = true) viewModelScope.launch(ioDispatcher) { + if (serverRequestId != null) { + wsClient.respondToServerRequest(serverRequestId, buildJsonObject {}) + return@launch + } if (isBatch) { // Send dismissal for every question in the batch for (q in displayQuestions) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 068e4b61..5b2f047b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.data.model.deltaFrom +import com.m57.hermescontrol.data.model.mergeWith import com.m57.hermescontrol.data.model.parseUsageSnapshot import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.ws.WsEvent @@ -11,6 +13,24 @@ import kotlinx.serialization.json.JsonPrimitive private fun List.upsertById(message: ChatMessage): List = (this + message).dedupeById() +private fun Long.toIntOrNullSafely(): Int? = takeIf { it in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() }?.toInt() + +private fun Double?.validTpsOrNull(): Double? = takeIf { it != null && it.isFinite() && it > 0.0 } + +internal fun applyUsageSnapshot( + state: ChatUiState, + snapshot: com.m57.hermescontrol.data.model.UsageSnapshotResponse, +): ChatUiState { + val validTps = snapshot.avgTps?.takeIf { it.isFinite() && it > 0.0 } + return state.copy( + sessionUsage = snapshot.mergeWith(state.sessionUsage), + compressionCount = snapshot.compressions ?: state.compressionCount, + usedContextTokens = snapshot.contextUsed?.takeIf { it > 0L } ?: state.usedContextTokens, + fullContextTokens = snapshot.contextMax?.takeIf { it > 0L } ?: state.fullContextTokens, + latestTps = validTps ?: state.latestTps, + ) +} + /** * Strips this turn's sealed-orphan prefix from the final message text. * @@ -83,7 +103,7 @@ object ChatWsEventReducer { is WsEvent.VaultCodeExpire -> event.sessionId else -> null } - if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) { + if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) { return ReducerResult(state = state, streamingState = streamingState) } val result = reduceInternal(state, streamingState, event) @@ -164,6 +184,12 @@ object ChatWsEventReducer { // RpcResult is handled by the ViewModel (needs pending request context) is WsEvent.RpcResult -> ReducerResult(state = state, streamingState = streamingState) + // Server requests are dispatched by ChatViewModel to the existing + // prompt delegates; the reducer must remain side-effect free. + is WsEvent.ServerRequest -> ReducerResult(state = state, streamingState = streamingState) + + is WsEvent.ServerRequestCancelled -> ReducerResult(state = state, streamingState = streamingState) + // ApprovalRequest is handled by the ViewModel (needs active session + WS client) is WsEvent.ApprovalRequest -> ReducerResult(state = state, streamingState = streamingState) @@ -236,8 +262,8 @@ object ChatWsEventReducer { streamingState.streamingMessage.copy( isStreaming = false, finishTimestamp = System.currentTimeMillis(), - tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 }, - tps = state.latestTps?.takeIf { it > 0.0 }, + tokenCount = null, + tps = null, ) orphan = finalized state.copy( @@ -258,6 +284,13 @@ object ChatWsEventReducer { // slate — no isReasoning flag, no inherited reasoning text. isReasoning = false, reasoningText = "", + turnUsageBaseline = + if (streamingState.turnUsageBaselineCaptured) { + streamingState.turnUsageBaseline + } else { + state.sessionUsage + }, + turnUsageBaselineCaptured = true, ) val newState = preState val sid = newState.currentSessionId @@ -369,6 +402,9 @@ object ChatWsEventReducer { streamingState: StreamingState, event: WsEvent.MessageComplete, ): ReducerResult { + val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot) + val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state + val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline) val streaming = streamingState.streamingMessage // Prefer the authoritative reasoning carried in the message.complete // payload (the gateway's assembled full trace), then fall back to @@ -386,20 +422,25 @@ object ChatWsEventReducer { // exactly once, matching the persisted transcript (sealed orphans + // stripped answer). Turns whose final text does NOT contain the // commentary are left untouched (orphans stay, final keeps its text). - val text = stripSealedOrphanPrefix(event.text, state.messages, streamingState.sealedOrphanIds) + val text = stripSealedOrphanPrefix(event.text, usageState.messages, streamingState.sealedOrphanIds) // Edge: the complete payload was entirely covered by sealed orphans // (the interim text already rendered as its own bubble(s)) — nothing - // new to show, so don't add an empty final bubble. + // new to show, so don't add an empty final bubble. Usage is applied + // above before this return because the final snapshot is still needed + // as the next turn's cumulative baseline. if (text.isBlank()) { return ReducerResult( - state = state.copy(isAgentTyping = false), - streamingState = streamingState.copy(streamingMessage = null), + state = usageState.copy(isAgentTyping = false), + streamingState = StreamingState(), ) } - val rawUsage = event.rawPayload?.get("usage") as? Map<*, *> - val tpsFromPayload = (rawUsage?.get("avg_tps") as? Number)?.toDouble() - val tps = (tpsFromPayload ?: state.latestTps)?.takeIf { it > 0.0 } - val tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 } + val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull() + val tokenCount = + if (turnUsage?.outputTokens != null) { + turnUsage.outputTokens.toIntOrNullSafely() + } else { + TokenEstimator.estimate(text).takeIf { it > 0 } + } val msg = streaming?.copy( content = text, @@ -434,11 +475,12 @@ object ChatWsEventReducer { } return ReducerResult( state = - state.copy( - messages = state.messages.upsertById(msg), + usageState.copy( + messages = usageState.messages.upsertById(msg), isAgentTyping = false, clarifyRequest = null, ), + streamingState = StreamingState(), effects = effects, ) } @@ -450,10 +492,11 @@ object ChatWsEventReducer { streamingState: StreamingState, ): ReducerResult { val streaming = - streamingState.streamingMessage ?: return ReducerResult( - state = state, - streamingState = streamingState, - ) + streamingState.streamingMessage + ?: return ReducerResult( + state = state.copy(isAgentTyping = false), + streamingState = StreamingState(), + ) val reasoning = if (streamingState.reasoningText.isNotBlank()) { streamingState.reasoningText @@ -486,6 +529,7 @@ object ChatWsEventReducer { messages = state.messages.upsertById(msg), isAgentTyping = false, ), + streamingState = StreamingState(), effects = effects, ) } @@ -532,8 +576,8 @@ object ChatWsEventReducer { isStreaming = false, reasoningText = reasoning, finishTimestamp = System.currentTimeMillis(), - tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 }, - tps = state.latestTps?.takeIf { it > 0.0 }, + tokenCount = null, + tps = null, ) orphanToPersist = finalized state.copy( @@ -572,7 +616,12 @@ object ChatWsEventReducer { val nextTodos = parsedTodos ?: newState.todos return ReducerResult( - state = newState.copy(messages = newState.messages + toolMessage, todos = nextTodos), + state = + newState.copy( + messages = newState.messages + toolMessage, + isAgentTyping = true, + todos = nextTodos, + ), streamingState = finalStreamingState, effects = effects, ) @@ -706,6 +755,8 @@ object ChatWsEventReducer { questionId = event.questionId, multiSelect = event.multiSelect, questions = questions, + serverRequestId = event.serverRequestId, + lockedAnswers = event.lockedAnswers, ), isAgentTyping = false, ), @@ -802,10 +853,11 @@ object ChatWsEventReducer { ReducerResult( state = state.copy( + isAgentTyping = false, isLoading = false, errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}", ), - streamingState = streamingState, + streamingState = StreamingState(), ) // ── BackgroundComplete ──────────────────────────────────────────── @@ -983,20 +1035,7 @@ object ChatWsEventReducer { val snapshot = parseUsageSnapshot(event.data) ?: return ReducerResult(state = state, streamingState = streamingState) - var updatedState = state - if (snapshot.compressions != null) { - updatedState = updatedState.copy(compressionCount = snapshot.compressions) - } - if (snapshot.contextUsed != null && snapshot.contextUsed > 0L) { - updatedState = updatedState.copy(usedContextTokens = snapshot.contextUsed) - } - if (snapshot.contextMax != null && snapshot.contextMax > 0L) { - updatedState = updatedState.copy(fullContextTokens = snapshot.contextMax) - } - if (snapshot.avgTps != null && snapshot.avgTps > 0.0) { - updatedState = updatedState.copy(latestTps = snapshot.avgTps) - } - return ReducerResult(state = updatedState, streamingState = streamingState) + return ReducerResult(state = applyUsageSnapshot(state, snapshot), streamingState = streamingState) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt index db5e9a48..03ef553d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.data.model.UsageSnapshotResponse + data class StreamingState( val streamingMessage: ChatMessage? = null, val isThinking: Boolean = false, @@ -14,4 +16,8 @@ data class StreamingState( * every fresh StreamingState (message.start / interrupt / session switch). */ val sealedOrphanIds: List = emptyList(), + /** Cumulative usage before the current turn; retained across tool-loop message segments. */ + val turnUsageBaseline: UsageSnapshotResponse? = null, + /** Distinguishes a captured-but-unavailable baseline from a not-yet-captured one. */ + val turnUsageBaselineCaptured: Boolean = false, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AttachmentTray.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AttachmentTray.kt new file mode 100644 index 00000000..60eb3d0d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AttachmentTray.kt @@ -0,0 +1,229 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.m57.hermescontrol.R + +/** + * Compact attachment tray that extends the composer without looking like a + * separate system menu. It is hosted in a focusable popup so outside taps and + * the system back action dismiss it naturally. + */ +@Composable +internal fun AttachmentTray( + visible: Boolean, + onDismissRequest: () -> Unit, + onCameraTap: () -> Unit, + onImageTap: () -> Unit, + onFileTap: () -> Unit, +) { + val transitionState = remember { MutableTransitionState(visible) } + LaunchedEffect(visible) { + transitionState.targetState = visible + } + + if (!visible && !transitionState.currentState) { + return + } + + val marginPx = with(LocalDensity.current) { 8.dp.roundToPx() } + val trayPositionProvider = + remember(marginPx) { + AttachmentTrayPositionProvider(marginPx = marginPx) + } + + Popup( + popupPositionProvider = trayPositionProvider, + onDismissRequest = onDismissRequest, + properties = + PopupProperties( + focusable = visible, + dismissOnBackPress = visible, + dismissOnClickOutside = visible, + ), + ) { + AnimatedVisibility( + visibleState = transitionState, + enter = + fadeIn(animationSpec = tween(durationMillis = 160)) + + slideInVertically( + animationSpec = tween(durationMillis = 160), + initialOffsetY = { it / 4 }, + ) + + scaleIn( + animationSpec = tween(durationMillis = 160), + initialScale = 0.96f, + ), + exit = + fadeOut(animationSpec = tween(durationMillis = 120)) + + slideOutVertically( + animationSpec = tween(durationMillis = 120), + targetOffsetY = { it / 4 }, + ) + + scaleOut( + animationSpec = tween(durationMillis = 120), + targetScale = 0.96f, + ), + ) { + val palette = composerPalette() + Surface( + modifier = + Modifier + .widthIn(max = 280.dp) + .testTag("attachment_tray"), + shape = MaterialTheme.shapes.large, + color = palette.card, + border = BorderStroke(width = 1.dp, color = palette.cardBorder), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + AttachmentTrayAction( + icon = Icons.Filled.CameraAlt, + label = stringResource(R.string.chat_attachment_camera), + onClick = { + onDismissRequest() + onCameraTap() + }, + modifier = Modifier.weight(1f).testTag("attachment_action_camera"), + ) + AttachmentTrayAction( + icon = Icons.Filled.PhotoLibrary, + label = stringResource(R.string.chat_attachment_photos), + onClick = { + onDismissRequest() + onImageTap() + }, + modifier = Modifier.weight(1f).testTag("attachment_action_photos"), + ) + AttachmentTrayAction( + icon = Icons.AutoMirrored.Filled.InsertDriveFile, + label = stringResource(R.string.chat_attachment_file), + onClick = { + onDismissRequest() + onFileTap() + }, + modifier = Modifier.weight(1f).testTag("attachment_action_file"), + ) + } + } + } + } +} + +@Composable +private fun AttachmentTrayAction( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .heightIn(min = 72.dp) + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { + contentDescription = label + role = Role.Button + }.padding(horizontal = 4.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + softWrap = true, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } +} + +internal class AttachmentTrayPositionProvider( + private val marginPx: Int, +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val minX = marginPx + val maxX = + (windowSize.width - popupContentSize.width - marginPx) + .coerceAtLeast(minX) + val x = + if (layoutDirection == LayoutDirection.Ltr) { + anchorBounds.left + marginPx + } else { + anchorBounds.right - popupContentSize.width - marginPx + } + val yAbove = anchorBounds.top - popupContentSize.height - marginPx + val yBelow = anchorBounds.bottom + marginPx + val maxY = (windowSize.height - popupContentSize.height).coerceAtLeast(0) + val y = if (yAbove >= 0) yAbove else yBelow + return IntOffset( + x = x.coerceIn(minX, maxX), + y = y.coerceIn(0, maxY), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 0f6c139a..c3d1a9b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -1,16 +1,12 @@ package com.m57.hermescontrol.ui.chat.components -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -32,13 +28,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -51,7 +44,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection @@ -64,7 +56,6 @@ import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment @@ -76,8 +67,8 @@ import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.util.BidiUtils /** - * The chat input bar with a two-row layout: input+send on top, - * and a toolbar with attach/model chip/reasoning chip/mic below. + * The chat input bar: a single rounded card with the input on top and a + * controls row (attach, model/reasoning pill, mic, send) inside it below. */ @Composable fun ChatInputBar( @@ -114,31 +105,27 @@ fun ChatInputBar( // dropped. Slash commands were already allowed; regular prompts now are too. val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected) - // Attachment menu state - var showAttachmentMenu by remember { mutableStateOf(false) } - var isFocused by remember { mutableStateOf(false) } + // Attachment tray state + var showAttachmentTray by remember { mutableStateOf(false) } + val palette = composerPalette() AnimatedVisibility( visible = true, enter = slideInVertically(initialOffsetY = { it }), exit = slideOutVertically(targetOffsetY = { it }), ) { + // One floating card holds the whole composer: suggestions, attachments, + // the input and the controls row. Flat fill, hairline edge, no shadow. Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - border = - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ), - tonalElevation = 2.dp, - shadowElevation = 4.dp, + .padding(horizontal = 12.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.large, + color = palette.card, + border = BorderStroke(width = 1.dp, color = palette.cardBorder), ) { - Column { + Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) { // Commands hidden from the suggestion menu — desktop/CLI-only and // TUI-only commands that don't function on mobile (issue #574). // Single source of truth: CommandBlocklist.UNSUPPORTED, which is @@ -216,12 +203,12 @@ fun ChatInputBar( } } - // ── TOP ROW: Input field with embedded send button ── + // ── TOP ROW: Borderless input field ── Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), + .padding(horizontal = 20.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { val placeholderText = @@ -257,109 +244,43 @@ fun ChatInputBar( modifier = Modifier .weight(1f) - .heightIn(min = 42.dp, max = 120.dp) + .heightIn(min = 42.dp, max = 200.dp) .padding(vertical = 4.dp) - .onFocusChanged { isFocused = it.isFocused } .testTag("chat_input"), enabled = isConnected, textStyle = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, + MaterialTheme.typography.bodyLarge.copy( + color = palette.text, textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, ), singleLine = false, - maxLines = 4, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + maxLines = 8, + cursorBrush = SolidColor(palette.text), decorationBox = { innerTextField -> CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { - Surface( - shape = RoundedCornerShape(18.dp), - border = - BorderStroke( - width = if (isFocused) 2.dp else 1.dp, - color = - if (isFocused) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - }, - ), - color = MaterialTheme.colorScheme.surface, + Box( modifier = Modifier.fillMaxWidth(), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, ) { - Row( - modifier = - Modifier - .padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.weight(1f), - contentAlignment = - if (isInputRtl) { - Alignment.CenterEnd - } else { - Alignment.CenterStart - }, - ) { - CompositionLocalProvider( - LocalLayoutDirection provides inputLayoutDirection, - ) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = placeholderText, - style = MaterialTheme.typography.bodyMedium, - textAlign = - if (isInputRtl) { - TextAlign.Right - } else { - TextAlign.Left - }, - color = - MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = 0.6f, - ), - modifier = Modifier.fillMaxWidth(), - ) - } - innerTextField() - } - } - - // Send button INSIDE the field. Shown whenever a send is - // possible — text typed OR an attachment pending (issue - // #956): the old text-only gate hid the button entirely - // for attachment-only sends. - AnimatedContent( - targetState = canSend, - transitionSpec = { - (scaleIn(initialScale = 0.8f) + fadeIn()) - .togetherWith(scaleOut(targetScale = 0.8f) + fadeOut()) - }, - label = "send_toggle", - ) { showSend -> - if (showSend) { - IconButton( - onClick = onSend, - enabled = canSend, - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = - Modifier - .size(36.dp) - .testTag("send_button"), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = - stringResource( - R.string.chat_send_desc, - ), - ) - } - } + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = placeholderText, + style = MaterialTheme.typography.bodyLarge, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + color = palette.placeholder, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) } + innerTextField() } } } @@ -374,7 +295,9 @@ fun ChatInputBar( currentSessionModel = currentSessionModel, reasoningLevel = reasoningLevel, isListening = isListening, - onAttachTap = { showAttachmentMenu = true }, + canSend = canSend, + onSend = onSend, + onAttachTap = { showAttachmentTray = !showAttachmentTray }, onModelTap = onModelTap, onReasoningSelected = onReasoningTap, onMicTap = onMicTap, @@ -387,52 +310,13 @@ fun ChatInputBar( onToggleFastMode = onToggleFastMode, ) - // Attachment dropdown (anchored to the attach button in ComposerToolbar) - // Shown overlaid at the toolbar level - DropdownMenu( - expanded = showAttachmentMenu, - onDismissRequest = { showAttachmentMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Camera") }, - onClick = { - showAttachmentMenu = false - onCameraTap() - }, - leadingIcon = { - Text( - text = "📷", - fontSize = 18.sp, - ) - }, - ) - DropdownMenuItem( - text = { Text("Image") }, - onClick = { - showAttachmentMenu = false - onImageTap() - }, - leadingIcon = { - Text( - text = "🖼️", - fontSize = 18.sp, - ) - }, - ) - DropdownMenuItem( - text = { Text("File") }, - onClick = { - showAttachmentMenu = false - onFileTap() - }, - leadingIcon = { - Text( - text = "📄", - fontSize = 18.sp, - ) - }, - ) - } + AttachmentTray( + visible = showAttachmentTray, + onDismissRequest = { showAttachmentTray = false }, + onCameraTap = onCameraTap, + onImageTap = onImageTap, + onFileTap = onFileTap, + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index 7af3e313..a45f6b12 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -45,9 +45,15 @@ fun ChatLifecycleEffects( // Switch to session from notification/history val pendingNavigation = NavigationController.pendingChatNavigation - LaunchedEffect(sessionId, pendingNavigation, connectionStatus) { + val pendingNewChatNavigation = NavigationController.pendingNewChatNavigation + LaunchedEffect(sessionId, pendingNavigation, pendingNewChatNavigation, connectionStatus) { if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect + val newChatRequest = NavigationController.consumePendingNewChatNavigation() val request = NavigationController.consumePendingChatNavigation() + if (newChatRequest != null) { + viewModel.createNewSession() + return@LaunchedEffect + } val target = request?.sessionId ?: sessionId if (!target.isNullOrBlank()) { viewModel.switchSession(target) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt new file mode 100644 index 00000000..531ba3f5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt @@ -0,0 +1,52 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver + +/** + * Resolved colors for the chat composer card and its controls. + * + * Control fills are derived from [ColorScheme.onSurface] instead of the + * surfaceContainer tiers: several presets ship nearly identical tiers + * (Nord's High and Highest are the same color), which made the flat controls + * vanish into the card. A fixed onSurface tint keeps them visible in every + * preset and under dynamic color. Text and icon contrast is gated per preset + * in ComposerPaletteTest. + */ +internal data class ComposerPalette( + val card: Color, + val cardBorder: Color, + val text: Color, + val placeholder: Color, + val control: Color, + val onControl: Color, + val onControlVariant: Color, + val action: Color, + val onAction: Color, +) + +private const val CONTROL_TINT_ALPHA = 0.12f + +internal fun composerPalette(scheme: ColorScheme): ComposerPalette { + val card = scheme.surfaceContainer + val tint = scheme.onSurface.copy(alpha = CONTROL_TINT_ALPHA) + return ComposerPalette( + card = card, + cardBorder = tint.compositeOver(scheme.background), + text = scheme.onSurface, + placeholder = scheme.onSurfaceVariant, + control = tint.compositeOver(card), + onControl = scheme.onSurface, + onControlVariant = scheme.onSurfaceVariant, + action = scheme.onSurface, + onAction = scheme.surface, + ) +} + +@Composable +@ReadOnlyComposable +internal fun composerPalette(): ComposerPalette = composerPalette(MaterialTheme.colorScheme) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 5a0828ae..aab2a2f4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -1,40 +1,58 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.Mic import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.scale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -46,16 +64,24 @@ import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R /** - * Bottom toolbar row for the chat composer. + * Bottom controls row for the chat composer, rendered inside the composer card. * - * Layout: [📎 attach] [model chip] [←spacer→] [🧠 reasoning] [🎙 mic] + * Layout: [+ attach] [model · reasoning pill ──free space──] [mic] [action] * - * The reasoning chip opens a dropdown menu to pick a level (instead of cycling). + * All controls are flat and borderless; hierarchy comes from fill brightness. + * The pill shows the model and the reasoning level side by side — tapping the + * model half opens the model picker, tapping the level half opens the level menu. + * + * The trailing action button morphs: while a send is possible it sends, + * otherwise it carries the mic action. The flat mic button only appears next + * to it while it is in send mode, so dictation stays reachable at all times. + * + * The reasoning menu picks a level (instead of cycling). * When [canDisableReasoning] is false the "None" level is disabled with a * "reasoning always on" hint (issue #946). Absent key (null) means no * restriction is known — full scale offered. * When [supportsReasoning] is false the model takes no reasoning parameter - * and the chip is disabled. + * and the level menu is disabled. */ @Composable fun ComposerToolbar( @@ -68,6 +94,8 @@ fun ComposerToolbar( onReasoningSelected: (String?) -> Unit, onMicTap: () -> Unit, modifier: Modifier = Modifier, + canSend: Boolean = false, + onSend: () -> Unit = {}, canDisableReasoning: Boolean? = null, supportsReasoning: Boolean? = null, fastMode: Boolean = false, @@ -76,6 +104,7 @@ fun ComposerToolbar( onToggleFastMode: () -> Unit = {}, ) { var showReasoningMenu by remember { mutableStateOf(false) } + val palette = composerPalette() val reasoningDisabledForModel = supportsReasoning == false val canDisable = canDisableReasoning @@ -83,62 +112,88 @@ fun ComposerToolbar( modifier = modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(horizontal = 12.dp) + .padding(top = 6.dp) .testTag("composer_toolbar"), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { // Attach button - IconButton( + FilledIconButton( onClick = onAttachTap, enabled = isConnected, - modifier = Modifier.size(36.dp), + colors = flatIconButtonColors(palette), + modifier = Modifier.size(ControlSize).testTag("attachment_button"), ) { Icon( - imageVector = Icons.Default.AttachFile, + imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.chat_attach_file), - tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Model chip — takes available space, fixed height - FilterChip( - selected = currentSessionModel != null, - onClick = onModelTap, - label = { - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), + // Model + reasoning pill — wraps its content inside the free space, + // pushing the mic/action buttons to the far end + val modelScrollState = rememberScrollState() + LaunchedEffect(currentSessionModel) { + modelScrollState.scrollTo(0) + } + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + Row( + modifier = + Modifier + .width(IntrinsicSize.Max) + .height(ControlSize) + .clip(CircleShape) + .background(palette.control), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .clipToBounds() + .horizontalScroll(modelScrollState) + .clickable(onClick = onModelTap) + .testTag("model_chip"), ) { Text( - text = currentSessionModel ?: "Model", - style = MaterialTheme.typography.bodySmall, + // Keeps the provider so same-named models from different providers + // stay distinguishable while allowing the full label to be revealed. + text = currentSessionModel?.let(::composerModelLabel) ?: "Model", + style = MaterialTheme.typography.bodyMedium, + color = palette.onControl, maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + modifier = + Modifier + .fillMaxHeight() + .wrapContentHeight() + .padding(start = 16.dp, end = 6.dp), ) } - }, - modifier = - Modifier - .weight(1f) - .height(28.dp) - .testTag("model_chip"), - ) - // Reasoning & Generation controls with dropdown menu (Option 3) - Box { - FilterChip( - selected = reasoningLevel != null || fastMode, - onClick = { showReasoningMenu = true }, - enabled = isConnected, - label = { + Box { Row( + modifier = + Modifier + .fillMaxHeight() + .clickable(enabled = isConnected) { showReasoningMenu = true } + .padding(start = 6.dp, end = 12.dp) + .testTag("reasoning_chip"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { + val levelColor = + palette.onControlVariant.copy( + alpha = if (isConnected) 1f else 0.38f, + ) if (fastMode) { Icon( imageVector = Icons.Filled.Bolt, contentDescription = stringResource(R.string.chat_fast_mode_label), - tint = MaterialTheme.colorScheme.primary, + tint = levelColor, modifier = Modifier.size(14.dp), ) } @@ -156,175 +211,265 @@ fun ComposerToolbar( rLabel } }, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMedium, + color = levelColor, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - } - }, - modifier = - Modifier - .height(28.dp) - .testTag("reasoning_chip"), - ) - - DropdownMenu( - expanded = showReasoningMenu, - onDismissRequest = { showReasoningMenu = false }, - modifier = Modifier.widthIn(min = 220.dp), - ) { - // ── Fast Mode Toggle Item ── - val fastAvailable = isConnected && fastSupported - DropdownMenuItem( - leadingIcon = { + // Chevron marks the level half of the pill as a menu trigger Icon( - imageVector = Icons.Filled.Bolt, - contentDescription = null, - tint = - if (fastMode) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(20.dp), + imageVector = Icons.Filled.ExpandMore, + contentDescription = stringResource(R.string.chat_reasoning_menu_desc), + tint = levelColor, + modifier = Modifier.size(16.dp), ) - }, - text = { - Column { - Text( - text = stringResource(R.string.chat_fast_mode_label), - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal, - ) - if (!fastSupported) { - Text( - text = stringResource(R.string.chat_fast_mode_unavailable), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + } + + DropdownMenu( + expanded = showReasoningMenu, + onDismissRequest = { showReasoningMenu = false }, + modifier = Modifier.widthIn(min = 220.dp), + ) { + // ── Fast Mode Toggle Item ── + val fastAvailable = isConnected && fastSupported + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Filled.Bolt, + contentDescription = null, + tint = + if (fastMode) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp), ) - } - } - }, - trailingIcon = { - if (isFastModeChanging) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Switch( - checked = fastMode, - onCheckedChange = null, // MenuItem click owns the trigger - enabled = fastAvailable && !isFastModeChanging, - modifier = - Modifier - .scale(0.85f) - .testTag("fast_mode_switch"), - ) - } - }, - onClick = { - if (fastAvailable && !isFastModeChanging) { - onToggleFastMode() - } - }, - enabled = fastAvailable && !isFastModeChanging, - ) + }, + text = { + Column { + Text( + text = stringResource(R.string.chat_fast_mode_label), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal, + ) + if (!fastSupported) { + Text( + text = stringResource(R.string.chat_fast_mode_unavailable), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + }, + trailingIcon = { + if (isFastModeChanging) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Switch( + checked = fastMode, + onCheckedChange = null, // MenuItem click owns the trigger + enabled = fastAvailable && !isFastModeChanging, + modifier = + Modifier + .scale(0.85f) + .testTag("fast_mode_switch"), + ) + } + }, + onClick = { + if (fastAvailable && !isFastModeChanging) { + onToggleFastMode() + } + }, + enabled = fastAvailable && !isFastModeChanging, + ) - HorizontalDivider() + HorizontalDivider() - Text( - text = "REASONING", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - ) - if (canDisable == false) { - Text( - text = "reasoning always on", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), - ) - } - if (reasoningDisabledForModel) { - Text( - text = "no reasoning parameter for this model", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), - ) - } - val allLevels = - listOf( - "none" to "None", - "minimal" to "Minimal", - "low" to "Low", - "medium" to "Med", - "high" to "High", - "xhigh" to "XHigh", - "max" to "Max", - "ultra" to "Ultra", - ) - allLevels.forEach { (level, label) -> - val isNone = level == "none" - val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel) - DropdownMenuItem( - text = { + Text( + text = "REASONING", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + if (canDisable == false) { Text( - text = label, - fontWeight = - if (reasoningLevel == level) { - MaterialTheme.typography.bodyMedium.fontWeight - } else { - null - }, - color = - when { - noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - reasoningLevel == level -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurface - }, + text = "reasoning always on", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), ) - }, - onClick = { - showReasoningMenu = false - onReasoningSelected(level) - }, - enabled = !noneDisabled && !reasoningDisabledForModel, - ) + } + if (reasoningDisabledForModel) { + Text( + text = "no reasoning parameter for this model", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + ) + } + val allLevels = + listOf( + "none" to "None", + "minimal" to "Minimal", + "low" to "Low", + "medium" to "Med", + "high" to "High", + "xhigh" to "XHigh", + "max" to "Max", + "ultra" to "Ultra", + ) + allLevels.forEach { (level, label) -> + val isNone = level == "none" + val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel) + DropdownMenuItem( + text = { + Text( + text = label, + fontWeight = + if (reasoningLevel == level) { + MaterialTheme.typography.bodyMedium.fontWeight + } else { + null + }, + color = + when { + noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + reasoningLevel == level -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, + ) + }, + onClick = { + showReasoningMenu = false + onReasoningSelected(level) + }, + enabled = !noneDisabled && !reasoningDisabledForModel, + ) + } + } } } } - // Mic / Stop button - IconButton( - onClick = onMicTap, - enabled = isConnected, + // Flat mic / stop button — only while the action button is in send mode + AnimatedVisibility( + visible = canSend, + enter = fadeIn() + scaleIn(initialScale = 0.8f), + exit = fadeOut() + scaleOut(targetScale = 0.8f), + ) { + FilledIconButton( + onClick = onMicTap, + enabled = isConnected, + colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette), + modifier = + Modifier + .size(ControlSize) + .testTag(if (isListening) "mic_stop_button" else "mic_button"), + ) { + Icon( + imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, + contentDescription = if (isListening) "Stop listening" else "Mic", + ) + } + } + + // Action button — send when a send is possible, mic / stop otherwise + FilledIconButton( + onClick = if (canSend) onSend else onMicTap, + enabled = canSend || isConnected, colors = - if (isListening) { - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ) + if (!canSend && isListening) { + listeningIconButtonColors() } else { - IconButtonDefaults.filledTonalIconButtonColors() + IconButtonDefaults.filledIconButtonColors( + containerColor = palette.action, + contentColor = palette.onAction, + ) }, modifier = Modifier - .size(36.dp) - .testTag(if (isListening) "mic_stop_button" else "mic_button"), + .size(ControlSize) + .testTag( + when { + canSend -> "send_button" + isListening -> "mic_stop_button" + else -> "mic_button" + }, + ), ) { - Icon( - imageVector = if (isListening) Icons.Default.Stop else Icons.Default.Mic, - contentDescription = if (isListening) "Stop listening" else "Mic", - ) + Crossfade( + targetState = + when { + canSend -> ActionGlyph.SEND + isListening -> ActionGlyph.STOP + else -> ActionGlyph.VOICE + }, + label = "composer_action_glyph", + ) { glyph -> + when (glyph) { + ActionGlyph.SEND -> { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.chat_send_desc), + ) + } + + ActionGlyph.STOP -> { + Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening") + } + + ActionGlyph.VOICE -> { + Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic") + } + } + } } } } +private val ControlSize = 36.dp + +private enum class ActionGlyph { SEND, STOP, VOICE } + +@Composable +private fun flatIconButtonColors(palette: ComposerPalette): IconButtonColors = + IconButtonDefaults.filledIconButtonColors( + containerColor = palette.control, + contentColor = palette.onControl, + ) + +@Composable +private fun listeningIconButtonColors(): IconButtonColors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ) + +/** + * Model label for the composer pill: the session's "provider/model" id with + * only the "custom:" marker removed from the provider ("custom:acme/glm-5.3" + * → "acme/glm-5.3"). The provider itself is kept so the same model served by + * different providers never renders identically. + */ +internal fun composerModelLabel(sessionModel: String): String { + val slash = sessionModel.indexOf('/') + if (slash <= 0) return sessionModel + val provider = sessionModel.substring(0, slash) + if (!provider.startsWith(CUSTOM_PROVIDER_PREFIX) || provider.length == CUSTOM_PROVIDER_PREFIX.length) { + return sessionModel + } + return provider.removePrefix(CUSTOM_PROVIDER_PREFIX) + sessionModel.substring(slash) +} + +private const val CUSTOM_PROVIDER_PREFIX = "custom:" + /** * Build a human-readable label from a reasoning effort level. * diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index 8ec0a493..c4bf9e2e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -68,6 +68,8 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -473,6 +475,7 @@ fun ClarifyBubble( horizontalAlignment = Alignment.CenterHorizontally, ) { questions.forEachIndexed { index, q -> + val lockedAnswer = clarifyRequest.lockedAnswers[q.qid] if (index > 0) { HorizontalDivider( modifier = Modifier.padding(vertical = 12.dp), @@ -497,79 +500,105 @@ fun ClarifyBubble( ) } - if (q.multiSelect) { - Spacer(Modifier.height(2.dp)) - Text( - text = "Select all that apply", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - ) - } - - if (q.choices.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + if (lockedAnswer != null) { + Surface( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(8.dp), ) { - q.choices.forEach { choice -> - val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet() - val isSelected = selectedSet.contains(choice) - FilterChip( - selected = isSelected, - onClick = { - if (q.multiSelect) { - val updated = if (isSelected) selectedSet - choice else selectedSet + choice - selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) - } else { - if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) { - // Fast 1-tap respond for lone single-select question when no custom text is entered - onRespondSingle(choice) - } else { - val updated = if (isSelected) emptySet() else setOf(choice) - selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) - } - } - }, - label = { Text(choice) }, - leadingIcon = - if (isSelected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - } - } else { - null - }, + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "Answered: $lockedAnswer", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, ) } } - } - - Spacer(Modifier.height(8.dp)) - val typed = customTextByQid[q.qid].orEmpty() - OutlinedTextField( - value = typed, - onValueChange = { newText -> - customTextByQid = customTextByQid + (q.qid to newText) - }, - label = { + } else { + if (q.multiSelect) { + Spacer(Modifier.height(2.dp)) Text( - if (q.choices.isEmpty()) { - stringResource(R.string.message_your_response) - } else { - "Other (optional)" - }, + text = "Select all that apply", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), ) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) + } + + if (q.choices.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + q.choices.forEach { choice -> + val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet() + val isSelected = selectedSet.contains(choice) + FilterChip( + selected = isSelected, + onClick = { + if (q.multiSelect) { + val updated = if (isSelected) selectedSet - choice else selectedSet + choice + selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) + } else { + if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) { + // Fast 1-tap respond for lone single-select question when no custom text is entered + onRespondSingle(choice) + } else { + val updated = if (isSelected) emptySet() else setOf(choice) + selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) + } + } + }, + label = { Text(choice) }, + leadingIcon = + if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } else { + null + }, + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + val typed = customTextByQid[q.qid].orEmpty() + OutlinedTextField( + value = typed, + onValueChange = { newText -> + customTextByQid = customTextByQid + (q.qid to newText) + }, + label = { + Text( + if (q.choices.isEmpty()) { + stringResource(R.string.message_your_response) + } else { + "Other (optional)" + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } } Spacer(Modifier.height(12.dp)) @@ -719,13 +748,19 @@ fun SubagentCard( // ── TypingIndicator ─────────────────────────────────────────────────────── /** - * Three bouncing dots shown while the assistant is typing / thinking. - * Staggered animation: 0ms, 150ms, 300ms delay per dot. + * Three subtle dots shown while the assistant is waiting to produce visible + * content. Staggered opacity/scale animation keeps the indicator lightweight + * without the distracting vertical bounce used by the old chat renderer. */ @Composable fun TypingIndicator(modifier: Modifier = Modifier) { + val description = stringResource(R.string.chat_agent_status_typing) Row( - modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("typing_indicator"), + modifier = + modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .semantics { contentDescription = description } + .testTag("typing_indicator"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -741,18 +776,18 @@ private fun TypingDot(delayMs: Int) { val typingSpec: InfiniteRepeatableSpec = remember(delayMs) { infiniteRepeatable( - animation = tween(400, delayMillis = delayMs, easing = LinearEasing), + animation = tween(700, delayMillis = delayMs, easing = LinearEasing), repeatMode = RepeatMode.Reverse, ) } val offset by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = -6f, + initialValue = 0.85f, + targetValue = 1f, animationSpec = typingSpec, - label = "typing_dot_offset_$delayMs", + label = "typing_dot_scale_$delayMs", ) val alpha by infiniteTransition.animateFloat( - initialValue = 0.4f, + initialValue = 0.45f, targetValue = 1f, animationSpec = typingSpec, label = "typing_dot_alpha_$delayMs", @@ -763,7 +798,8 @@ private fun TypingDot(delayMs: Int) { .size(8.dp) .clip(CircleShape) .graphicsLayer { - this.translationY = offset + this.scaleX = offset + this.scaleY = offset this.alpha = alpha }, ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt new file mode 100644 index 00000000..1f6e1cc2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt @@ -0,0 +1,172 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.StreamingState +import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry +import com.m57.hermescontrol.ui.chat.ToolStatus +import com.m57.hermescontrol.ui.chat.components.TypingIndicator +import com.m57.hermescontrol.ui.chat.isUserTurnBoundary + +/** Live status for an agent turn before visible assistant prose is available. */ +internal sealed interface AgentStatus { + data object Typing : AgentStatus + + data object Thinking : AgentStatus + + data class Tool( + val name: String?, + ) : AgentStatus +} + +/** + * Derives the one status that should occupy the agent turn's live tail. + * + * Tool execution wins over generic states, and visible streaming prose wins + * over waiting/reasoning states. This keeps one indicator on screen at a time + * without introducing a second generation state store. + */ +internal fun deriveAgentStatus( + isAgentTyping: Boolean, + streamingState: StreamingState, + messages: List, +): AgentStatus? { + if (!isAgentTyping) return null + + val currentTurnStart = messages.indexOfLast { it.isUserTurnBoundary() } + val currentTurnMessages = + if (currentTurnStart >= 0) { + messages.drop(currentTurnStart + 1) + } else { + emptyList() + } + val activeTool = + currentTurnMessages + .asReversed() + .firstOrNull { + it.role == MessageRole.TOOL && + it.toolStatus == ToolStatus.RUNNING + } + val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true + + return when { + activeTool != null -> AgentStatus.Tool(activeTool.toolName) + hasVisibleStreamingContent -> null + streamingState.isThinking || streamingState.isReasoning -> AgentStatus.Thinking + isAgentTyping -> AgentStatus.Typing + else -> null + } +} + +/** Maps existing canonical tool names to compact, user-facing activity text. */ +internal fun toolStatusLabelRes(toolName: String?): Int = + when (toolName?.lowercase()) { + "web_search", + "search_files", + "session_search", + "tool_search", + "x_search", + -> R.string.chat_agent_status_searching + + "read_file", + -> R.string.chat_agent_status_reading + + "web_extract", + "browser_navigate", + "browser_click", + "browser_type", + "browser_scroll", + "browser_back", + "browser_press", + "browser_snapshot", + "browser_get_images", + "browser_console", + "browser_cdp", + "browser_dialog", + "browser_vision", + -> R.string.chat_agent_status_browsing + + "terminal", + "execute_code", + "process", + "process_manage", + -> R.string.chat_agent_status_running_command + + else -> R.string.chat_agent_status_using_tool + } + +@Composable +internal fun AgentStatusIndicator( + status: AgentStatus, + modifier: Modifier = Modifier, +) { + when (status) { + AgentStatus.Typing -> { + TypingIndicator(modifier = modifier) + } + + AgentStatus.Thinking -> { + StatusText( + text = stringResource(R.string.chat_agent_status_thinking), + icon = null, + modifier = modifier.testTag("agent_status_thinking"), + ) + } + + is AgentStatus.Tool -> { + val config = ToolSchemaRegistry.getDisplayConfig(status.name) + val labelRes = toolStatusLabelRes(status.name) + StatusText( + text = stringResource(labelRes), + icon = config.icon, + modifier = modifier.testTag("agent_status_tool"), + ) + } + } +} + +@Composable +private fun StatusText( + text: String, + icon: ImageVector?, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index f34a0fa5..604d7fae 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -1,15 +1,16 @@ package com.m57.hermescontrol.ui.chat.fullbleed import android.content.ClipData -import android.text.format.DateFormat +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check @@ -29,10 +30,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment @@ -50,17 +49,13 @@ import kotlinx.coroutines.launch * * Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders * directly on the background — no bubble container, no width cap — with a - * turn header (role + time) and a trailing copy affordance. User messages - * keep their bubbles; this composable is only used for ASSISTANT messages. - * - * [showTurnHeader] renders the role/time header — true only for the first - * prose entry of an agent turn so multi-message turns don't repeat it. + * trailing copy affordance. User messages keep their bubbles; this composable + * is only used for ASSISTANT messages. */ +@OptIn(ExperimentalLayoutApi::class) @Composable internal fun FullBleedAgentMessage( message: ChatMessage, - showTurnHeader: Boolean, - isDarkTheme: Boolean, searchQuery: String = "", isCurrentMatch: Boolean = false, showReasoning: Boolean = true, @@ -70,6 +65,9 @@ internal fun FullBleedAgentMessage( openingAttachmentPath: String? = null, canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, + messageStatsEnabled: Boolean = false, + showAssistantMessageTokens: Boolean = true, + showTokensPerSecond: Boolean = true, modifier: Modifier = Modifier, ) { val textColor = MaterialTheme.colorScheme.onSurface @@ -92,10 +90,6 @@ internal fun FullBleedAgentMessage( .padding(horizontal = 16.dp) .testTag("fullbleed_agent_message"), ) { - if (showTurnHeader) { - AssistantTurnHeader(message.timestamp) - } - if (showReasoning && message.reasoningText.isNotBlank()) { ReasoningCard( reasoningText = message.reasoningText, @@ -105,10 +99,10 @@ internal fun FullBleedAgentMessage( } // Defense-in-depth: never render an empty prose block (blank bubble + - // lone Copy button). Blank settled rows are tool-call placeholders that - // slipped through upstream mapping; streaming keeps rendering so the - // live cursor survives until the first delta lands. - if (message.content.isNotBlank() || message.isStreaming) { + // lone Copy button). Blank rows are tool-call placeholders that slipped + // through upstream mapping; the parent list renders the live status + // indicator until the first visible delta lands. + if (message.content.isNotBlank()) { SelectionContainer { MarkdownText( text = message.content, @@ -140,9 +134,17 @@ internal fun FullBleedAgentMessage( } } - if (!message.isStreaming) { - Row( - verticalAlignment = Alignment.CenterVertically, + if (!message.isStreaming && message.content.isNotBlank()) { + val showTokenStat = + messageStatsEnabled && showAssistantMessageTokens && + message.tokenCount != null && message.tokenCount > 0 + val showTpsStat = + messageStatsEnabled && showTokensPerSecond && + message.tps != null && message.tps > 0.0 + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + itemVerticalAlignment = Alignment.CenterVertically, ) { IconButton( onClick = { @@ -160,54 +162,24 @@ internal fun FullBleedAgentMessage( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Spacer(modifier = Modifier.width(4.dp)) - val finishTime = message.finishTimestamp ?: message.timestamp - Text( - text = - com.m57.hermescontrol.ui.chat.formatTimestamp( - finishTime, - DateFormat.is24HourFormat(LocalContext.current), - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.testTag("fullbleed_finish_time"), - ) - if (message.tokenCount != null && message.tokenCount > 0) { - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = + if (showTokenStat) { + AssistantStatItem( + value = stringResource( R.string.chat_msg_tokens, TokenEstimator.formatTokenCount(message.tokenCount), ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.testTag("fullbleed_token_count"), + testTag = "fullbleed_token_count", ) } - if (message.tps != null && message.tps > 0.0) { - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = + if (showTpsStat) { + AssistantStatItem( + value = stringResource( R.string.chat_msg_tps, TokenEstimator.formatTps(message.tps), ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.testTag("fullbleed_tps"), + testTag = "fullbleed_tps", ) } } @@ -215,42 +187,15 @@ internal fun FullBleedAgentMessage( } } -/** - * Agent turn header — "Agent ·