Merge pull request #1136 from Hy4ri/dev

chore(release): promote dev to main
This commit is contained in:
M57 authored and GitHub committed 2026-09-16 11:13:30 +03:00
commit b574e8bfef
133 files changed
+13733 -1242

No files matched your search

+1 -1
View File
@@ -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
@@ -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()
}
}
@@ -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<ChatMessage>,
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()
}
}
@@ -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 =
@@ -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)
}
}
@@ -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) }
}
}
@@ -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)
}
}
@@ -174,6 +174,15 @@ private fun appEntryProvider(
onBack = { NavigationController.goBack() },
)
}
// ── Kanban Task Detail ─────────────────────────────────────────────
entry<KanbanTaskDetailKey> { key ->
com.m57.hermescontrol.ui.kanban.KanbanTaskScreen(
boardSlug = key.boardSlug,
taskId = key.taskId,
onBack = { NavigationController.goBack() },
)
}
}
@Composable
@@ -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<NavKey>? = 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. */
@@ -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
@@ -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",
@@ -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
@@ -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<String, String>()
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
}
}
@@ -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<GatewayMigrationProfile> = emptyList(),
@SerialName("multiplex_flag_on") val multiplexFlagOn: Boolean = false,
@SerialName("live_served") val liveServed: List<String>? = null,
@SerialName("already_multiplexed") val alreadyMultiplexed: Boolean = false,
val interrupted: Boolean = false,
val blockers: List<String> = emptyList(),
val notices: List<String> = 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<GatewayMigrationService> = 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,
)
@@ -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<KanbanTask>,
)
@Serializable
data class KanbanBoardResponse(
val columns: List<KanbanColumn>,
val assignees: List<String>? = null,
val tenants: List<String>? = null,
)
@Serializable
data class KanbanBoardsResponse(
val boards: List<KanbanBoard>,
val current: String? = null,
)
@@ -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,
)
@@ -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<String, Int> = 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<KanbanTask> = emptyList(),
)
@Serializable
data class KanbanBoardResponse(
val columns: List<KanbanColumn> = emptyList(),
val assignees: List<String>? = null,
val tenants: List<String>? = null,
val latestEventId: Long = 0L,
val now: Long = 0L,
)
@Serializable
data class KanbanBoardsResponse(
val boards: List<KanbanBoard> = 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<KanbanProject> = 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,
)
@@ -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<KanbanProfile> = 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<String, Int> = emptyMap(),
val warnings: List<String> = emptyList(),
val current: String? = null,
)
@Serializable
data class DispatchResult(
val spawned: List<JsonElement> = emptyList(),
)
@@ -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<String, JsonElement> = emptyMap(),
val suggested: Boolean = false,
)
@Serializable
data class TaskDiagnostic(
val kind: String,
val severity: String,
val title: String,
val detail: String,
val actions: List<TaskDiagnosticAction> = emptyList(),
val count: Int = 1,
val lastSeenAt: Long = 0L,
val data: Map<String, JsonElement> = emptyMap(),
)
@Serializable
data class TaskLinks(
val parents: List<String> = emptyList(),
val children: List<String> = 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<TaskDiagnostic> = emptyList(),
) {
val description: String? get() = body
val assignedTo: String? get() = assignee
}
@Serializable
data class KanbanTaskDetailResponse(
val task: KanbanTaskFull,
val comments: List<KanbanComment> = emptyList(),
val events: List<KanbanDetailEvent> = emptyList(),
val attachments: List<KanbanAttachment>? = null,
val links: TaskLinks = TaskLinks(),
val childResults: List<ChildTaskResult> = emptyList(),
val runs: List<KanbanRun> = 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<String> = emptyList(),
val triage: Boolean = false,
val maxRuntimeSeconds: Int? = null,
val skills: List<String>? = 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<String, JsonElement>? = 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<String>,
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<String, JsonElement>? = 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<BulkTaskResult> = 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,
)
@@ -17,6 +17,7 @@ data class MessagingPlatform(
@SerialName("env_vars") val envVars: List<EnvVarField>? = 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
@@ -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<ProjectInfo> = 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<ProjectFolder> = 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,
)
@@ -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,
@@ -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<String, PlatformStatus?>? = null,
@SerialName("gateway_shared_with")
val gatewaySharedWith: List<String>? = null,
val memory: MemoryPressureStatus? = null,
val disk: DiskPressureStatus? = null,
)
@@ -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<String>? = 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<String>? =
when (val value = usageMap[key]) {
is JsonArray -> value.mapNotNull { (it as? JsonPrimitive)?.content }
is List<*> -> value.filterIsInstance<String>()
is JsonElement -> (value.toAny() as? List<*>)?.filterIsInstance<String>()
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"),
)
}
@@ -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 }
}
}
@@ -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<Unit>
@GET("api/gateway/migrate/plan")
suspend fun getGatewayMigrationPlan(): Response<GatewayMigrationPlan>
@POST("api/gateway/migrate")
suspend fun startGatewayMigration(): Response<GatewayMigrationStartResponse>
@GET("api/profiles")
suspend fun getProfiles(): Response<ProfilesResponse>
@@ -677,7 +686,7 @@ interface HermesApiService {
suspend fun configurePlatform(
@Path("platform_id") platformId: String,
@Body config: MessagingPlatformUpdate,
): Response<Unit>
): Response<MessagingPlatformUpdateResponse>
@POST("api/messaging/platforms/{platform_id}/test")
suspend fun testMessagingPlatform(
@@ -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<KanbanBoardsResponse>
@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<KanbanBoardResponse>
@POST("api/plugins/kanban/boards")
suspend fun createBoard(
@Body body: CreateBoardBody,
): Response<CreateBoardResponse>
@PATCH("api/plugins/kanban/boards/{slug}")
suspend fun updateBoard(
@Path("slug") slug: String,
@Body body: RenameBoardBody,
): Response<RenameBoardResponse>
@DELETE("api/plugins/kanban/boards/{slug}")
suspend fun deleteBoard(
@Path("slug") slug: String,
@Query("delete") delete: Boolean = false,
): Response<DeleteBoardResponse>
@POST("api/plugins/kanban/boards/{slug}/switch")
suspend fun switchBoard(
@Path("slug") slug: String,
): Response<Unit>
@GET("api/plugins/kanban/tasks/{id}")
suspend fun getTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<KanbanTaskDetailResponse>
@POST("api/plugins/kanban/tasks")
suspend fun createTask(
@Query("board") board: String? = null,
@Body body: CreateTaskBody,
): Response<CreateTaskResponse>
@PATCH("api/plugins/kanban/tasks/{id}")
suspend fun updateTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: UpdateTaskBody,
): Response<UpdateTaskResponse>
@DELETE("api/plugins/kanban/tasks/{id}")
suspend fun deleteTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<Unit>
@POST("api/plugins/kanban/tasks/bulk")
suspend fun bulkTasks(
@Query("board") board: String? = null,
@Body body: BulkTasksBody,
): Response<BulkTasksResponse>
@POST("api/plugins/kanban/tasks/{id}/comments")
suspend fun addComment(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: Map<String, String>,
): Response<Unit>
@POST("api/plugins/kanban/tasks/{id}/reassign")
suspend fun reassignTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: ReassignTaskBody,
): Response<ReassignTaskResponse>
@POST("api/plugins/kanban/tasks/{id}/reclaim")
suspend fun reclaimTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: ReclaimTaskBody = ReclaimTaskBody(),
): Response<ReclaimTaskResponse>
@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<WorkerLog>
@POST("api/plugins/kanban/tasks/{id}/estimate")
suspend fun estimateTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<TaskEstimate>
@POST("api/plugins/kanban/estimate")
suspend fun estimateNew(
@Body body: Map<String, String>,
): Response<TaskEstimate>
@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<AttachmentUploadResponse>
@Streaming
@GET("api/plugins/kanban/attachments/{id}")
suspend fun downloadAttachment(
@Path("id") attachmentId: Long,
@Query("board") board: String? = null,
): Response<ResponseBody>
@GET("api/plugins/kanban/profiles")
suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse>
@PATCH("api/plugins/kanban/profiles/{name}")
suspend fun updateProfileDescription(
@Path("name") name: String,
@Body body: Map<String, String>,
): Response<Unit>
@POST("api/plugins/kanban/profiles/{name}/describe-auto")
suspend fun autoDescribeProfile(
@Path("name") name: String,
@Body body: Map<String, Boolean> = mapOf("overwrite" to true),
): Response<AutoDescribeResponse>
@GET("api/plugins/kanban/orchestration")
suspend fun getOrchestration(): Response<OrchestrationSettings>
@PUT("api/plugins/kanban/orchestration")
suspend fun updateOrchestration(
@Body body: OrchestrationSettingsUpdate,
): Response<OrchestrationSettings>
@GET("api/plugins/kanban/projects")
suspend fun getProjects(): Response<KanbanProjectsResponse>
@POST("api/plugins/kanban/dispatch")
suspend fun nudgeDispatcher(
@Query("board") board: String? = null,
): Response<DispatchResult>
}
@@ -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<KanbanBoardsResponse>
suspend fun getBoard(
board: String,
includeArchived: Boolean = false,
tenant: String? = null,
): NetworkResult<KanbanBoardResponse>
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
suspend fun updateBoard(
slug: String,
body: RenameBoardBody,
): NetworkResult<RenameBoardResponse>
suspend fun deleteBoard(
slug: String,
delete: Boolean = false,
): NetworkResult<DeleteBoardResponse>
suspend fun getTask(
taskId: String,
board: String?,
): NetworkResult<KanbanTaskDetailResponse>
suspend fun createTask(
board: String?,
body: CreateTaskBody,
): NetworkResult<CreateTaskResponse>
suspend fun updateTask(
taskId: String,
board: String?,
body: UpdateTaskBody,
): NetworkResult<UpdateTaskResponse>
suspend fun deleteTask(
taskId: String,
board: String?,
): NetworkResult<Unit>
suspend fun bulkTasks(
board: String?,
body: BulkTasksBody,
): NetworkResult<BulkTasksResponse>
suspend fun addComment(
taskId: String,
board: String?,
body: String,
): NetworkResult<Unit>
suspend fun reassignTask(
taskId: String,
board: String?,
profile: String?,
reclaimFirst: Boolean = true,
): NetworkResult<ReassignTaskResponse>
suspend fun reclaimTask(
taskId: String,
board: String?,
reason: String? = null,
): NetworkResult<ReclaimTaskResponse>
suspend fun getTaskLog(
taskId: String,
board: String?,
tail: Int = 16384,
): NetworkResult<WorkerLog>
suspend fun estimateTask(
taskId: String,
board: String?,
): NetworkResult<TaskEstimate>
suspend fun estimateNew(
title: String,
body: String?,
): NetworkResult<TaskEstimate>
suspend fun uploadAttachment(
taskId: String,
board: String?,
file: MultipartBody.Part,
): NetworkResult<AttachmentUploadResponse>
suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
): NetworkResult<ResponseBody>
suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse>
suspend fun updateProfileDescription(
name: String,
description: String,
): NetworkResult<Unit>
suspend fun autoDescribeProfile(name: String): NetworkResult<AutoDescribeResponse>
suspend fun getOrchestration(): NetworkResult<OrchestrationSettings>
suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult<OrchestrationSettings>
suspend fun getProjects(): NetworkResult<KanbanProjectsResponse>
suspend fun nudgeDispatcher(board: String?): NetworkResult<DispatchResult>
}
class KanbanRepositoryImpl(
private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi },
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : KanbanRepository {
override suspend fun getBoards(includeArchived: Boolean): NetworkResult<KanbanBoardsResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getBoards(includeArchived) }
}
override suspend fun getBoard(
board: String,
includeArchived: Boolean,
tenant: String?,
): NetworkResult<KanbanBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) }
}
override suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().createBoard(body) }
}
override suspend fun updateBoard(
slug: String,
body: RenameBoardBody,
): NetworkResult<RenameBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().updateBoard(slug, body) }
}
override suspend fun deleteBoard(
slug: String,
delete: Boolean,
): NetworkResult<DeleteBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().deleteBoard(slug, delete) }
}
override suspend fun getTask(
taskId: String,
board: String?,
): NetworkResult<KanbanTaskDetailResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) }
}
override suspend fun createTask(
board: String?,
body: CreateTaskBody,
): NetworkResult<CreateTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().createTask(board = board, body = body) }
}
override suspend fun updateTask(
taskId: String,
board: String?,
body: UpdateTaskBody,
): NetworkResult<UpdateTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) }
}
override suspend fun deleteTask(
taskId: String,
board: String?,
): NetworkResult<Unit> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) }
}
override suspend fun bulkTasks(
board: String?,
body: BulkTasksBody,
): NetworkResult<BulkTasksResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().bulkTasks(board = board, body = body) }
}
override suspend fun addComment(
taskId: String,
board: String?,
body: String,
): NetworkResult<Unit> =
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<ReassignTaskResponse> =
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<ReclaimTaskResponse> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().reclaimTask(
taskId = taskId,
board = board,
body = ReclaimTaskBody(reason = reason),
)
}
}
override suspend fun getTaskLog(
taskId: String,
board: String?,
tail: Int,
): NetworkResult<WorkerLog> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getTaskLog(taskId = taskId, tail = tail, board = board) }
}
override suspend fun estimateTask(
taskId: String,
board: String?,
): NetworkResult<TaskEstimate> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().estimateTask(taskId = taskId, board = board) }
}
override suspend fun estimateNew(
title: String,
body: String?,
): NetworkResult<TaskEstimate> =
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<AttachmentUploadResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
}
override suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
): NetworkResult<ResponseBody> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) }
}
override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getKanbanProfiles() }
}
override suspend fun updateProfileDescription(
name: String,
description: String,
): NetworkResult<Unit> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().updateProfileDescription(
name = name,
body = mapOf("description" to description),
)
}
}
override suspend fun autoDescribeProfile(name: String): NetworkResult<AutoDescribeResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().autoDescribeProfile(name) }
}
override suspend fun getOrchestration(): NetworkResult<OrchestrationSettings> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getOrchestration() }
}
override suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult<OrchestrationSettings> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().updateOrchestration(body) }
}
override suspend fun getProjects(): NetworkResult<KanbanProjectsResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getProjects() }
}
override suspend fun nudgeDispatcher(board: String?): NetworkResult<DispatchResult> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().nudgeDispatcher(board) }
}
}
@@ -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>(KanbanSyncStatus.Disconnected)
val syncStatus: StateFlow<KanbanSyncStatus> = _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
}
}
@@ -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<String, Any?> ?: 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)
@@ -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<Map<*, *>>
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<String, Any?> ?: emptyMap()
parsedEvents.tryEmit(WsEvent.ServerRequest(openId, method, params, replayed = true))
}
if (rpc.id == null) {
@Suppress("UNCHECKED_CAST")
val params = rpc.params?.toAny() as? Map<String, Any?>
@@ -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(
@@ -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<String?> {
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. */
@@ -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<ProjectInfo>?
}
/** [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<String, Any>) -> Any? = { method, params ->
HermesWsClient.request(method, params).await()
},
) : ProjectsSource {
override suspend fun fetchProjects(): List<ProjectInfo>? {
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
}
}
}
@@ -152,6 +152,8 @@ sealed class WsEvent {
val questionId: String? = null,
val multiSelect: Boolean = false,
val questions: List<ClarifyQuestion> = emptyList(),
val serverRequestId: String? = null,
val lockedAnswers: Map<String, String> = 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<String, Any?> = 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 ───────────────────────────────────────────
@@ -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
@@ -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)
}
@@ -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 =
@@ -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,
)
}
@@ -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))
@@ -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<String, Any>, 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<String, Any>(
"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,
@@ -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"),
)
}
}
}
}
@@ -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<ChatUiState>,
@@ -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()
@@ -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<ChatUiState>,
private val wsSend: (method: String, params: Map<String, Any>, 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<String, Any>,
) {
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) }
)
}
}
}
@@ -14,6 +14,8 @@ data class ApprovalInfo(
val patternKeys: List<String>?,
/** 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<String>? = null,
/** False → hide "Always allow" (tirith warning, desktop parity). */
@@ -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<ImageViewerModel?>(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,
@@ -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<SubagentIndicator>,
@@ -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
}
}
@@ -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<Attachment> = 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<ClarifyQuestionUi> = emptyList(),
val serverRequestId: String? = null,
val lockedAnswers: Map<String, String> = 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<String>() ?: 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<String>(),
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<String>()
handleWsEvent(
WsEvent.ApprovalRequest(
command = params["command"] as? String,
description = params["description"] as? String,
patternKeys = (params["pattern_keys"] as? List<*>)?.filterIsInstance<String>(),
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) {
@@ -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<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> = (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)
}
}
@@ -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<String> = 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,
)
@@ -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),
)
}
}
@@ -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,
)
}
}
}
@@ -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)
@@ -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)
@@ -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.
*
@@ -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<Float> =
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
},
) {
@@ -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<ChatMessage>,
): 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,
)
}
}
@@ -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 · <time>". Shared by the prose message and the
* hoisted reasoning block so both can lead with the assistant identity and
* timestamp.
*/
@Composable
internal fun AssistantTurnHeader(
timestamp: Long,
modifier: Modifier = Modifier,
private fun AssistantStatItem(
value: String,
testTag: String,
) {
Row(
modifier = modifier.padding(bottom = 4.dp).testTag("fullbleed_agent_header"),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.fullbleed_role_agent),
style =
MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.SemiBold,
),
// Meta color (not primary): Nord's light mode reuses its
// pastel Frost accent as primary, which fails >= 3:1 on a
// light background. The header is metadata, so the dimmed
// meta token is both more correct and gate-compliant in
// every preset.
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
com.m57.hermescontrol.ui.chat.formatTimestamp(
timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = value,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(testTag),
)
}
@@ -27,6 +27,7 @@ import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
@@ -43,8 +44,8 @@ import com.m57.hermescontrol.ui.common.EmptyState
/**
* The chat message list for FULL-BLEED style (issue #866) — the single chat
* surface since the bubble renderer was removed. User messages keep their
* bubble (the universal anchor), agent turns render full-bleed with a turn
* header, and tool rows / system events render as distinct compact cards.
* bubble (the universal anchor), agent turns render full-bleed, and tool rows
* / system events render as distinct compact cards.
* Spacing contract:
* - intra-turn: entries separated by 6.dp (Column padding on agent turn items)
* - inter-turn: 12.dp bottom padding after each turn's last item
@@ -52,20 +53,24 @@ import com.m57.hermescontrol.ui.common.EmptyState
* COMPOSE GOTCHA (verified): LazyColumn `item {}` content lambdas execute
* LAZILY at item-composition time, not during this DSL-building loop. Loop
* locals that are read inside item lambdas must be captured as immutable
* vals FIRST (eagerly), or every item sees the loop's final value — the
* turn header would never render and milestones would be misindexed.
* vals FIRST (eagerly), or every item sees the loop's final value and
* per-entry state can be rendered against the wrong message.
*/
@Composable
fun FullBleedChatList(
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
streamingState: StreamingState,
isAgentTyping: Boolean,
searchState: ChatSearchState,
typingEffectEnabled: Boolean,
typingEffectDelayMs: Int,
messageStatsEnabled: Boolean = false,
showUserMessageTokens: Boolean = true,
showAssistantMessageTokens: Boolean = true,
showTokensPerSecond: Boolean = true,
maxToolCallsPerTurn: Int? = null,
isLoading: Boolean,
isLoadingOlder: Boolean,
isDark: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
scrollController: ChatScrollController,
lastAnimatedMessageId: String?,
@@ -89,7 +94,7 @@ fun FullBleedChatList(
openingAttachmentPath: String? = null,
onImageClick: (ImageViewerModel) -> Unit = {},
) {
if (messages.isEmpty() && !isLoading) {
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
@@ -102,9 +107,10 @@ fun FullBleedChatList(
} else {
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
val turns =
remember(messages, streamingMessage) {
groupIntoTurnsWithStreaming(messages, streamingMessage)
remember(messages, streamingState.streamingMessage) {
groupIntoTurnsWithStreaming(messages, streamingState.streamingMessage)
}
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, messages)
// messageId → LazyColumn item index. The lazy list has EXTRA items
// vs the message list (reasoning hoists, tool rows), so search-match
// scrolling must resolve the lazy index, not use the message index.
@@ -182,13 +188,14 @@ fun FullBleedChatList(
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
)
}
}
}
is ChatTurn.Agent -> {
var firstProseSeen = false
// Reasoning hoist: the turn's reasoning renders at the
// TOP of the turn — above tool rows — so thinking
// leads, then the tool work, then the answer. The
@@ -201,9 +208,6 @@ fun FullBleedChatList(
val reasoning = turnReasoning.message
item(key = "reasoning-${reasoning.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
// Lead the turn with the assistant identity
// and timestamp, then the thinking block.
AssistantTurnHeader(timestamp = reasoning.timestamp)
ReasoningCard(
reasoningText = reasoning.reasoningText,
isStreaming = reasoning.isStreaming,
@@ -215,50 +219,49 @@ fun FullBleedChatList(
when (entry) {
is AgentEntry.Prose -> {
val proseMessage = entry.message
val showTurnHeader = !firstProseSeen && turnReasoning == null
val hoistedReasoning =
turnReasoning != null &&
proseMessage.id == turnReasoning.message.id
item(key = "prose-${proseMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (proseMessage.isStreaming && typingEffectEnabled) {
StreamingFullBleedWithTypingEffect(
streaming = proseMessage,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
showTurnHeader = showTurnHeader,
showReasoning = !hoistedReasoning,
)
} else {
FullBleedAgentMessage(
message = proseMessage,
showTurnHeader = showTurnHeader,
isDarkTheme = isDark,
// Highlight only bubbles that actually contain a match —
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
)
if (proseMessage.hasVisibleAgentContent()) {
item(key = "prose-${proseMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (proseMessage.isStreaming && typingEffectEnabled) {
StreamingFullBleedWithTypingEffect(
streaming = proseMessage,
typingDelayMs = typingEffectDelayMs,
showReasoning = !hoistedReasoning,
)
} else {
FullBleedAgentMessage(
message = proseMessage,
// Highlight only bubbles that actually contain a match —
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
)
}
}
}
}
firstProseSeen = true
}
is AgentEntry.ToolRow -> {
@@ -298,6 +301,12 @@ fun FullBleedChatList(
}
}
agentStatus?.let { status ->
item(key = "agent_status") {
AgentStatusIndicator(status = status)
}
}
// Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
@@ -356,6 +365,8 @@ private fun renderChatBubble(
savingAttachmentPath: String?,
openingAttachmentPath: String?,
onImageClick: (ImageViewerModel) -> Unit,
messageStatsEnabled: Boolean,
showUserMessageTokens: Boolean,
) {
ChatBubble(
message = message,
@@ -367,5 +378,7 @@ private fun renderChatBubble(
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
)
}
@@ -28,8 +28,6 @@ import kotlinx.coroutines.delay
internal fun StreamingFullBleedWithTypingEffect(
streaming: ChatMessage,
typingDelayMs: Int,
isDark: Boolean,
showTurnHeader: Boolean = true,
showReasoning: Boolean = true,
onAnimationComplete: () -> Unit = {},
) {
@@ -86,9 +84,7 @@ internal fun StreamingFullBleedWithTypingEffect(
FullBleedAgentMessage(
message = streaming.copy(content = displayText),
showTurnHeader = showTurnHeader,
showReasoning = showReasoning,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
@@ -37,6 +37,9 @@ sealed interface AgentEntry {
) : AgentEntry
}
/** Empty assistant placeholders carry no user-visible prose and need no list item. */
internal fun ChatMessage.hasVisibleAgentContent(): Boolean = content.isNotBlank() || !attachments.isNullOrEmpty()
/**
* Stable content prefix the backend uses for its max-iterations runtime nudge
* (`handle_max_iterations` in run_agent.py → chat_completion_helpers.py, text
@@ -145,15 +148,16 @@ fun groupIntoTurnsWithStreaming(
* full-bleed renderer.
*
* The lazy list does NOT have one item per message: each agent turn with a
* reasoning block emits an extra `reasoning-<id>` item BEFORE its prose, and
* tool rows / system events are items too. Scrolling a search match by raw
* message index therefore lands on the WRONG item whenever reasoning or tool
* rows precede the target — the classic "match is above/below the view" bug.
* reasoning block emits an extra `reasoning-<id>` item BEFORE its prose, empty
* assistant placeholders emit no prose item, and tool rows / system events are
* items too. Scrolling a search match by raw message index therefore lands on
* the WRONG item whenever reasoning or tool rows precede the target — the
* classic "match is above/below the view" bug.
*
* Mirrors the item emission order in [FullBleedChatList] exactly:
* user turn → 1 item; agent turn → optional reasoning item, then one item per
* entry. [leadingItems] accounts for fixed items emitted before the turns
* (e.g. the `loading-older` spinner when paging).
* visible entry. [leadingItems] accounts for fixed items emitted before the
* turns (e.g. the `loading-older` spinner when paging).
*
* @return messageId → LazyColumn item index of the message's content item
* (prose items for agent messages; user items for user messages).
@@ -183,8 +187,10 @@ fun messageIdToLazyIndex(
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
map[entry.message.id] = itemIndex
itemIndex++
if (entry.message.hasVisibleAgentContent()) {
map[entry.message.id] = itemIndex
itemIndex++
}
}
is AgentEntry.ToolRow -> {
@@ -0,0 +1,50 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
/**
* Two-way bridge between an external [String] holder (ViewModel state, a parent
* callback, …) and a [TextFieldState]-backed text field.
*
* Why not `OutlinedTextField(value:, onValueChange:)`? The value-based overload
* keeps its selection inside the legacy `BasicTextField` implementation, so when
* a scrolled field gains focus it scrolls the cursor into view while the cursor
* is still at index 0 — the viewport jumps back to the top instead of staying
* where the user tapped. `TextFieldState`-backed fields only follow the cursor
* while the user is actively editing, so the scroll position survives the tap.
*
* Inbound (external → field) updates are applied only when the text actually
* differs, which keeps ViewModel round-trips from clobbering in-progress typing;
* outbound (field → external) updates skip the identity case so the two
* directions can never ping-pong.
*/
@Composable
fun rememberSyncedTextFieldState(
value: String,
onValueChange: (String) -> Unit,
): TextFieldState {
val state = rememberTextFieldState(value)
val currentValue by rememberUpdatedState(value)
val currentOnValueChange by rememberUpdatedState(onValueChange)
LaunchedEffect(state, value) {
if (state.text.toString() != value) {
state.edit { replace(0, length, value) }
}
}
LaunchedEffect(state) {
snapshotFlow { state.text.toString() }
.collect { newText ->
if (newText != currentValue) currentOnValueChange(newText)
}
}
return state
}
@@ -18,6 +18,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Code
@@ -51,6 +53,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
@@ -71,6 +74,7 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
@@ -290,6 +294,8 @@ private fun YAMLEditor(
return
}
val textFieldState = rememberSyncedTextFieldState(yamlText, onYamlTextChange)
Column(
modifier =
Modifier
@@ -297,14 +303,12 @@ private fun YAMLEditor(
.padding(horizontal = 16.dp),
) {
OutlinedTextField(
value = yamlText,
onValueChange = onYamlTextChange,
state = textFieldState,
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
maxLines = Int.MAX_VALUE,
)
Spacer(modifier = Modifier.height(12.dp))
@@ -936,34 +940,41 @@ private fun JsonField(
label: String,
onChange: (JsonElement) -> Unit,
) {
var text by remember(key) { mutableStateOf(value?.let(::jsonText) ?: "") }
val textFieldState = remember(key) { TextFieldState(value?.let(::jsonText) ?: "") }
var hasFocus by remember { mutableStateOf(false) }
var invalid by remember { mutableStateOf(false) }
LaunchedEffect(value) {
if (!hasFocus) text = value?.let(::jsonText) ?: ""
LaunchedEffect(value, hasFocus) {
if (!hasFocus) {
val canonical = value?.let(::jsonText) ?: ""
if (textFieldState.text.toString() != canonical) {
textFieldState.edit {
replace(0, length, canonical)
}
}
}
}
LaunchedEffect(textFieldState) {
snapshotFlow { textFieldState.text.toString() }
.collect { newText ->
val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull()
invalid = newText.isNotBlank() && parsed == null
if (parsed != null && newText.isNotBlank() && parsed != value) {
onChange(parsed)
}
}
}
OutlinedTextField(
value = text,
onValueChange = { newText ->
text = newText
val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull()
invalid = newText.isNotBlank() && parsed == null
if (parsed != null && newText.isNotBlank()) {
onChange(parsed)
}
},
state = textFieldState,
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 96.dp)
.onFocusChanged {
hasFocus = it.isFocused
if (!it.isFocused) {
invalid = false
text = value?.let(::jsonText) ?: ""
}
if (!it.isFocused) invalid = false
},
isError = invalid,
supportingText =
@@ -974,6 +985,6 @@ private fun JsonField(
},
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
label = { Text(label) },
maxLines = Int.MAX_VALUE,
lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 4),
)
}
@@ -72,6 +72,7 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
import com.m57.hermescontrol.util.CronExpressionFormatter
@Composable
@@ -377,6 +378,11 @@ fun CronJobEditorDialog(
state.monitor_script.isNotEmpty() || state.monitor_url.isNotEmpty() ||
state.monitorMode != "off"
val promptTextFieldState =
rememberSyncedTextFieldState(state.prompt) { onFieldChange("prompt", it) }
val skillsTextFieldState =
rememberSyncedTextFieldState(state.skills) { onFieldChange("skills", it) }
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
Dialog(
@@ -506,8 +512,7 @@ fun CronJobEditorDialog(
// Prompt
OutlinedTextField(
value = state.prompt,
onValueChange = { onFieldChange("prompt", it) },
state = promptTextFieldState,
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
modifier =
Modifier
@@ -531,8 +536,7 @@ fun CronJobEditorDialog(
// Skills
OutlinedTextField(
value = state.skills,
onValueChange = { onFieldChange("skills", it) },
state = skillsTextFieldState,
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
modifier =
@@ -17,13 +17,21 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -32,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
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
@@ -46,6 +55,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.KanbanTaskDetailKey
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.ui.common.ErrorState
@@ -54,6 +65,9 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog
import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet
import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard
private const val DEFAULT_COLUMN = "todo"
@@ -67,14 +81,26 @@ fun KanbanScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var selectedAssignee by remember { mutableStateOf<String?>(null) }
var selectedTenant by remember { mutableStateOf<String?>(null) }
var includeArchived by remember { mutableStateOf(false) }
var groupRunning by remember { mutableStateOf(false) }
var showFilterSheet by remember { mutableStateOf(false) }
val filteredTasks =
remember(query, state.tasks) {
remember(query, state.tasks, selectedAssignee, selectedTenant) {
state.tasks.filter { task ->
task.title.contains(query, ignoreCase = true) ||
task.description?.contains(query, ignoreCase = true) == true ||
task.status.contains(query, ignoreCase = true) ||
task.assignedTo?.contains(query, ignoreCase = true) == true
val matchesQuery =
query.isBlank() ||
task.title.contains(query, ignoreCase = true) ||
task.description?.contains(query, ignoreCase = true) == true ||
task.status.contains(query, ignoreCase = true) ||
task.assignedTo?.contains(query, ignoreCase = true) == true ||
task.id.contains(query, ignoreCase = true)
val matchesAssignee =
selectedAssignee == null || task.assignee.equals(selectedAssignee, ignoreCase = true)
val matchesTenant = selectedTenant == null || task.tenant.equals(selectedTenant, ignoreCase = true)
matchesQuery && matchesAssignee && matchesTenant
}
}
@@ -87,6 +113,17 @@ fun KanbanScreen(
var confirmTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
var summaryTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
// Multi-select & Bulk operations state
var isMultiSelectMode by remember { mutableStateOf(false) }
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
var showBulkMoveDialog by remember { mutableStateOf(false) }
// Board management dialogs
var showBoardMenu by remember { mutableStateOf(false) }
var showCreateBoardDialog by remember { mutableStateOf(false) }
var showRenameBoardDialog by remember { mutableStateOf(false) }
var showDeleteBoardDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadBoards()
}
@@ -96,6 +133,60 @@ fun KanbanScreen(
HermesScaffold(
title = { Text(stringResource(R.string.kanban_board_title)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
actions = {
IconButton(onClick = { showFilterSheet = true }) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = "Filter",
)
}
Box {
IconButton(onClick = { showBoardMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Board options",
)
}
DropdownMenu(
expanded = showBoardMenu,
onDismissRequest = { showBoardMenu = false },
) {
DropdownMenuItem(
text = { Text("New Board") },
onClick = {
showBoardMenu = false
showCreateBoardDialog = true
},
)
if (state.selectedBoard != null) {
DropdownMenuItem(
text = { Text("Rename Board") },
onClick = {
showBoardMenu = false
showRenameBoardDialog = true
},
)
if (state.boards.size > 1) {
DropdownMenuItem(
text = { Text("Delete Board") },
onClick = {
showBoardMenu = false
showDeleteBoardDialog = true
},
)
}
}
DropdownMenuItem(
text = { Text(if (isMultiSelectMode) "Exit Multi-Select" else "Select Multiple") },
onClick = {
showBoardMenu = false
isMultiSelectMode = !isMultiSelectMode
if (!isMultiSelectMode) selectedTaskIds = emptySet()
},
)
}
}
},
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadBoards() },
) { paddingValues ->
@@ -142,7 +233,7 @@ fun KanbanScreen(
Tab(
selected = board == state.selectedBoard,
onClick = { viewModel.selectBoard(board) },
text = { Text(board.name) },
text = { Text(board.displayName) },
)
}
}
@@ -167,6 +258,14 @@ fun KanbanScreen(
) {
LiveStatusPill(isLive = state.isLive)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = { showFilterSheet = true }) {
Icon(
imageVector = Icons.Filled.FilterList,
contentDescription = "Filters",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(4.dp))
IconButton(onClick = { showAddTaskDialog = true }) {
Icon(
imageVector = Icons.Filled.Add,
@@ -228,9 +327,41 @@ fun KanbanScreen(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(colTasks, key = { it.id }) { task ->
TaskCard(
KanbanTaskCard(
task = task,
onTaskClick = { taskForActions = it },
isSelected =
isMultiSelectMode && task.id in selectedTaskIds,
onTaskClick = { clickedTask ->
if (isMultiSelectMode) {
selectedTaskIds =
if (clickedTask.id in selectedTaskIds) {
selectedTaskIds - clickedTask.id
} else {
selectedTaskIds + clickedTask.id
}
} else {
state.selectedBoard?.let { board ->
NavigationController.navigateTo(
KanbanTaskDetailKey(
boardSlug = board.id,
taskId = clickedTask.id,
),
)
}
}
},
onActionClick = { clickedTask ->
if (isMultiSelectMode) {
selectedTaskIds =
if (clickedTask.id in selectedTaskIds) {
selectedTaskIds - clickedTask.id
} else {
selectedTaskIds + clickedTask.id
}
} else {
taskForActions = clickedTask
}
},
)
}
}
@@ -243,10 +374,16 @@ fun KanbanScreen(
}
if (showAddTaskDialog) {
AddTaskDialog(
KanbanCreateTaskDialog(
columns = state.columns,
defaultColumn = state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN,
profiles = state.profiles,
existingTasks = state.tasks,
isCreating = state.isCreatingTask,
onEstimate = viewModel::estimateNewTask,
onDismiss = { showAddTaskDialog = false },
onConfirm = { title, desc ->
viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN)
onConfirm = { body, targetStatus ->
viewModel.createTask(body, targetStatus)
showAddTaskDialog = false
},
)
@@ -288,48 +425,281 @@ fun KanbanScreen(
summaryTarget?.let { (task, action) ->
CompleteTaskDialog(
onConfirm = { summary -> viewModel.moveTask(task, action, summary) },
onConfirm = { summary ->
summaryTarget = null
viewModel.moveTask(task, action, summary)
},
onDismiss = { summaryTarget = null },
)
}
}
}
}
}
}
@Composable
fun TaskCard(
task: KanbanTask,
onTaskClick: (KanbanTask) -> Unit,
) {
Card(onClick = { onTaskClick(task) }, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(4.dp))
task.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
task.assignedTo?.let { assignee ->
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = assignee,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (showFilterSheet) {
val assignees =
remember(state.tasks) {
state.tasks
.mapNotNull { it.assignee }
.distinct()
.sorted()
}
val tenants =
remember(state.tasks) {
state.tasks
.mapNotNull { it.tenant }
.distinct()
.sorted()
}
KanbanFilterSheet(
assignees = assignees,
tenants = tenants,
selectedAssignee = selectedAssignee,
selectedTenant = selectedTenant,
includeArchived = includeArchived,
groupRunning = groupRunning,
onSelectAssignee = { selectedAssignee = it },
onSelectTenant = { selectedTenant = it },
onToggleIncludeArchived = { includeArchived = it },
onToggleGroupRunning = { groupRunning = it },
onClearFilters = {
selectedAssignee = null
selectedTenant = null
includeArchived = false
groupRunning = false
},
onDismiss = { showFilterSheet = false },
)
}
if (isMultiSelectMode) {
Card(
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
) {
Text(
text = "${selectedTaskIds.size} selected",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(
onClick = {
selectedTaskIds =
if (selectedTaskIds.size == filteredTasks.size) {
emptySet()
} else {
filteredTasks.map { it.id }.toSet()
}
},
) {
Text(if (selectedTaskIds.size == filteredTasks.size) "Clear" else "All")
}
Button(
onClick = { showBulkMoveDialog = true },
enabled = selectedTaskIds.isNotEmpty(),
) {
Text("Move")
}
Button(
onClick = {
viewModel.bulkArchive(selectedTaskIds.toList())
isMultiSelectMode = false
selectedTaskIds = emptySet()
},
enabled = selectedTaskIds.isNotEmpty(),
) {
Text("Archive")
}
IconButton(
onClick = {
isMultiSelectMode = false
selectedTaskIds = emptySet()
},
) {
Icon(Icons.Default.Close, contentDescription = "Close multi-select")
}
}
}
}
}
if (showBulkMoveDialog) {
AlertDialog(
onDismissRequest = { showBulkMoveDialog = false },
title = { Text("Move ${selectedTaskIds.size} tasks") },
text = {
Column {
state.columns.forEach { col ->
TextButton(
onClick = {
viewModel.bulkMove(selectedTaskIds.toList(), col.name)
showBulkMoveDialog = false
isMultiSelectMode = false
selectedTaskIds = emptySet()
},
modifier = Modifier.fillMaxWidth(),
) {
Text(col.name.uppercase(), modifier = Modifier.fillMaxWidth())
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showBulkMoveDialog = false }) {
Text("Cancel")
}
},
)
}
if (showCreateBoardDialog) {
var boardSlug by remember { mutableStateOf("") }
var boardName by remember { mutableStateOf("") }
var boardDesc by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showCreateBoardDialog = false },
title = { Text("Create New Board") },
text = {
Column {
OutlinedTextField(
value = boardSlug,
onValueChange = {
boardSlug =
it.lowercase().filter { c ->
c.isLetterOrDigit() || c == '-' || c == '_'
}
},
label = { Text("Board ID / Slug") },
placeholder = { Text("e.g. sprint-3") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = boardName,
onValueChange = { boardName = it },
label = { Text("Display Name") },
placeholder = { Text("e.g. Sprint 3") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = boardDesc,
onValueChange = { boardDesc = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = {
if (boardSlug.isNotBlank()) {
viewModel.createBoard(
slug = boardSlug.trim(),
name = boardName.trim().ifBlank { null },
description = boardDesc.trim().ifBlank { null },
)
showCreateBoardDialog = false
}
},
enabled = boardSlug.isNotBlank(),
) {
Text("Create")
}
},
dismissButton = {
TextButton(onClick = { showCreateBoardDialog = false }) {
Text("Cancel")
}
},
)
}
if (showRenameBoardDialog && state.selectedBoard != null) {
var newName by remember { mutableStateOf(state.selectedBoard?.name ?: "") }
AlertDialog(
onDismissRequest = { showRenameBoardDialog = false },
title = { Text("Rename Board") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("Board Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
},
confirmButton = {
Button(
onClick = {
if (newName.isNotBlank()) {
state.selectedBoard?.let { board ->
viewModel.renameBoard(board.id, newName.trim())
}
showRenameBoardDialog = false
}
},
enabled = newName.isNotBlank(),
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showRenameBoardDialog = false }) {
Text("Cancel")
}
},
)
}
if (showDeleteBoardDialog && state.selectedBoard != null) {
AlertDialog(
onDismissRequest = { showDeleteBoardDialog = false },
title = { Text("Delete Board") },
text = {
Text(
"Are you sure you want to delete board '${state.selectedBoard?.displayName}'? This action cannot be undone.",
)
},
confirmButton = {
Button(
onClick = {
state.selectedBoard?.let { board ->
viewModel.deleteBoard(board.id)
}
showDeleteBoardDialog = false
},
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteBoardDialog = false }) {
Text("Cancel")
}
},
)
}
}
}
}
@@ -360,51 +730,6 @@ private fun LiveStatusPill(isLive: Boolean) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTaskDialog(
onDismiss: () -> Unit,
onConfirm: (title: String, desc: String?) -> Unit,
) {
var title by remember { mutableStateOf("") }
var desc by remember { mutableStateOf("") }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.kanban_add_new_task)) },
text = {
Column {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(R.string.kanban_task_title)) },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = desc,
onValueChange = { desc = it },
label = { Text(stringResource(R.string.kanban_task_desc)) },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = { if (title.isNotBlank()) onConfirm(title, desc.ifBlank { null }) },
enabled = title.isNotBlank(),
) {
Text(stringResource(R.string.action_add))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
private fun KanbanTaskAction.labelRes(): Int =
when (this) {
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
@@ -0,0 +1,750 @@
package com.m57.hermescontrol.ui.kanban
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanTaskScreen(
boardSlug: String,
taskId: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: KanbanTaskViewModel = viewModel { KanbanTaskViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files")
LaunchedEffect(boardSlug, taskId) {
viewModel.loadTask(boardSlug, taskId)
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold(
title = {
Column {
Text(
text = state.detail?.task?.title ?: "Task Details",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
Text(
text = "#${taskId.removePrefix("t_").take(8)} • $boardSlug",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
navigationIcon = NavIcon.Back(onBack),
drawerGesturesEnabled = false,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadTask(boardSlug, taskId) },
) { paddingValues ->
when {
state.isLoading && state.detail == null -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null && state.detail == null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadTask(boardSlug, taskId) },
modifier = Modifier.padding(paddingValues),
)
}
state.detail != null -> {
val detail = state.detail!!
val task = detail.task
Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index },
text = { Text(title) },
)
}
}
when (selectedTabIndex) {
0 -> {
TaskOverviewTab(
task = task,
onSaveDescription = { newBody ->
viewModel.updateDescription(boardSlug, taskId, newBody)
},
onReclaim = {
viewModel.reclaim(boardSlug, taskId)
},
)
}
1 -> {
TaskDiscussionTab(
comments = detail.comments,
isRunning = task.status.equals("running", ignoreCase = true),
isSubmitting = state.isSubmittingComment,
onAddComment = { body ->
viewModel.addComment(boardSlug, taskId, body)
},
onNoteAndRequeue = { body ->
viewModel.postNoteAndRequeue(boardSlug, taskId, body)
},
)
}
2 -> {
TaskRunsTab(
runs = detail.runs,
workerLog = state.workerLog,
)
}
3 -> {
TaskActivityTab(
events = detail.events,
)
}
4 -> {
TaskFilesTab(
attachments = detail.attachments ?: emptyList(),
isUploading = state.isUploadingAttachment,
onUpload = { filename, mimeType, bytes ->
viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes)
},
)
}
}
}
}
}
}
}
@Composable
private fun TaskOverviewTab(
task: com.m57.hermescontrol.data.model.KanbanTaskFull,
onSaveDescription: (String) -> Unit,
onReclaim: () -> Unit,
modifier: Modifier = Modifier,
) {
var isEditingDescription by remember { mutableStateOf(false) }
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
val statusColors = LocalHermesStatusColors.current
LazyColumn(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
// Diagnostics alerts
if (task.diagnostics.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
task.diagnostics.forEach { diag ->
Card(
colors =
CardDefaults.cardColors(
containerColor = statusColors.error.copy(alpha = 0.15f),
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = statusColors.error,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = diag.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = statusColors.error,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = diag.detail,
style = MaterialTheme.typography.bodySmall,
)
if (diag.actions.any { it.kind == "reclaim" }) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onReclaim,
shape = RoundedCornerShape(6.dp),
) {
Text("Reclaim task")
}
}
}
}
}
}
}
// Metadata card
Card(
shape = RoundedCornerShape(8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Attributes",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(8.dp))
MetaRow(label = "Status", value = task.status.uppercase())
task.assignee?.let { MetaRow(label = "Assignee", value = it) }
task.priority?.let { MetaRow(label = "Priority", value = it.toString()) }
task.tenant?.let { MetaRow(label = "Tenant", value = it) }
task.modelOverride?.let { MetaRow(label = "Model Override", value = it) }
task.reasoningEffort?.let { MetaRow(label = "Reasoning Effort", value = it) }
task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) }
}
}
// Description Section
Card(
shape = RoundedCornerShape(8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Description",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
if (!isEditingDescription) {
IconButton(onClick = { isEditingDescription = true }) {
Icon(
Icons.Filled.Edit,
contentDescription = "Edit Description",
modifier = Modifier.size(16.dp),
)
}
}
}
if (isEditingDescription) {
OutlinedTextField(
value = descriptionDraft,
onValueChange = { descriptionDraft = it },
modifier = Modifier.fillMaxWidth(),
minLines = 4,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { isEditingDescription = false }) {
Text("Cancel")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
onSaveDescription(descriptionDraft)
isEditingDescription = false
}) {
Text("Save")
}
}
} else {
SelectionContainer {
MarkdownText(
text = task.body?.ifBlank { "No description provided." } ?: "No description provided.",
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
// Result/Summary Section if completed
val summary = task.result ?: task.latestSummary
if (!summary.isNullOrBlank()) {
Card(
shape = RoundedCornerShape(8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Latest Outcome / Summary",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(6.dp))
SelectionContainer {
MarkdownText(
text = summary,
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun TaskDiscussionTab(
comments: List<com.m57.hermescontrol.data.model.KanbanComment>,
isRunning: Boolean,
isSubmitting: Boolean,
onAddComment: (String) -> Unit,
onNoteAndRequeue: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var commentText by remember { mutableStateOf("") }
Column(modifier = modifier.fillMaxSize()) {
LazyColumn(
modifier =
Modifier
.weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (comments.isEmpty()) {
item {
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
Text(
"No comments yet. Post a note to steer the agent.",
style = MaterialTheme.typography.bodySmall,
)
}
}
} else {
items(comments, key = { it.id }) { comment ->
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(10.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = comment.author,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(4.dp))
SelectionContainer {
MarkdownText(
text = comment.body,
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
}
HorizontalDivider()
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
OutlinedTextField(
value = commentText,
onValueChange = { commentText = it },
placeholder = { Text("Write a steering note...") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
if (isRunning) {
OutlinedButton(
onClick = {
onNoteAndRequeue(commentText)
commentText = ""
},
enabled = commentText.isNotBlank() && !isSubmitting,
) {
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Note & Requeue")
}
Spacer(modifier = Modifier.width(8.dp))
}
Button(
onClick = {
onAddComment(commentText)
commentText = ""
},
enabled = commentText.isNotBlank() && !isSubmitting,
) {
if (isSubmitting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Post")
}
}
}
}
}
}
@Composable
private fun TaskRunsTab(
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
workerLog: com.m57.hermescontrol.data.model.WorkerLog?,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Text("Attempt History", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
}
if (runs.isEmpty()) {
item {
Text("No worker attempts recorded yet.", style = MaterialTheme.typography.bodySmall)
}
} else {
items(runs, key = { it.id }) { run ->
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
"Run #${run.id}",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = (run.outcome ?: run.status).uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color =
if (run.outcome == "completed") {
LocalHermesStatusColors.current.success
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
run.profile?.let {
Text("Profile: $it", style = MaterialTheme.typography.bodySmall)
}
val summary = run.error ?: run.summary
if (!summary.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(text = summary, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
// Live Log Output
if (workerLog != null && workerLog.exists && workerLog.content.isNotBlank()) {
item {
Spacer(modifier = Modifier.height(16.dp))
Text("Worker Log Output", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(6.dp))
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = workerLog.content,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(10.dp),
)
}
}
}
}
}
@Composable
private fun TaskActivityTab(
events: List<com.m57.hermescontrol.data.model.KanbanDetailEvent>,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Text("Event Timeline", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
}
if (events.isEmpty()) {
item {
Text("No activity events.", style = MaterialTheme.typography.bodySmall)
}
} else {
items(events, key = { it.id }) { event ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.primary, shape = CircleShape),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = event.kind,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
@Composable
private fun MetaRow(
label: String,
value: String,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(text = value, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
}
}
@Composable
private fun TaskFilesTab(
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
isUploading: Boolean,
onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri: Uri? ->
if (uri != null) {
var filename = "attachment"
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && cursor.moveToFirst()) {
filename = cursor.getString(nameIndex) ?: "attachment"
}
}
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
if (bytes != null) {
onUpload(filename, mimeType, bytes)
}
}
}
LazyColumn(
modifier =
modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(
text = "Attached Files (${attachments.size})",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Button(
onClick = { launcher.launch("*/*") },
enabled = !isUploading,
) {
if (isUploading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(6.dp))
}
Text("Attach File")
}
}
}
if (attachments.isEmpty()) {
item {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "No files attached to this task.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
items(attachments, key = { it.id }) { att ->
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(12.dp)
.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Description,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = att.filename,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
val sizeStr =
att.size?.let { s ->
if (s > 1024 * 1024) "${s / (1024 * 1024)} MB" else "${s / 1024} KB"
} ?: "Unknown size"
Text(
text = "$sizeStr • ${att.uploadedBy ?: "unknown"}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
@@ -0,0 +1,301 @@
package com.m57.hermescontrol.ui.kanban
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.ReassignTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.WorkerLog
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
data class KanbanTaskUiState(
val isLoading: Boolean = false,
val detail: KanbanTaskDetailResponse? = null,
val workerLog: WorkerLog? = null,
val errorMessage: String? = null,
val toastMessage: String? = null,
val isSubmittingComment: Boolean = false,
val isSavingDescription: Boolean = false,
val isReclaiming: Boolean = false,
val isUploadingAttachment: Boolean = false,
)
class KanbanTaskViewModel(
private val repository: KanbanRepository = KanbanRepositoryImpl(),
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanTaskUiState())
val uiState: StateFlow<KanbanTaskUiState> = _uiState.asStateFlow()
fun loadTask(
board: String,
taskId: String,
) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
when (val result = repository.getTask(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
detail = result.data,
)
}
if (result.data.task.status
.equals("running", ignoreCase = true)
) {
loadLog(board, taskId)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load task details: ${result.error.message}",
)
}
}
}
}
}
fun loadLog(
board: String,
taskId: String,
) {
viewModelScope.launch {
when (val result = repository.getTaskLog(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(workerLog = result.data) }
}
is NetworkResult.Failure -> {
// Failures to read log don't blank the task screen
}
}
}
}
fun updateDescription(
board: String,
taskId: String,
newBody: String,
) {
_uiState.update { it.copy(isSavingDescription = true) }
viewModelScope.launch {
val result =
repository.updateTask(
taskId = taskId,
board = board,
body = UpdateTaskBody(body = newBody),
)
when (result) {
is NetworkResult.Success -> {
_uiState.update { state ->
state.copy(
isSavingDescription = false,
detail =
state.detail?.let { d ->
d.copy(task = d.task.copy(body = newBody))
},
toastMessage = "Description saved",
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isSavingDescription = false,
toastMessage = "Failed to save: ${result.error.message}",
)
}
}
}
}
}
fun addComment(
board: String,
taskId: String,
body: String,
) {
if (body.isBlank()) return
_uiState.update { it.copy(isSubmittingComment = true) }
viewModelScope.launch {
when (val result = repository.addComment(taskId = taskId, board = board, body = body)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isSubmittingComment = false,
toastMessage = "Comment added",
)
}
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isSubmittingComment = false,
toastMessage = "Failed to add comment: ${result.error.message}",
)
}
}
}
}
}
fun postNoteAndRequeue(
board: String,
taskId: String,
body: String,
) {
if (body.isBlank()) return
_uiState.update { it.copy(isSubmittingComment = true) }
viewModelScope.launch {
val commentRes = repository.addComment(taskId = taskId, board = board, body = body)
if (commentRes is NetworkResult.Failure) {
_uiState.update {
it.copy(
isSubmittingComment = false,
toastMessage = "Failed to post note: ${commentRes.error.message}",
)
}
return@launch
}
val reclaimRes = repository.reclaimTask(taskId = taskId, board = board, reason = "Requeued with note")
when (reclaimRes) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isSubmittingComment = false,
toastMessage = "Note posted & task requeued",
)
}
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isSubmittingComment = false,
toastMessage = "Note posted, but reclaim failed: ${reclaimRes.error.message}",
)
}
}
}
}
}
fun reclaim(
board: String,
taskId: String,
reason: String? = null,
) {
_uiState.update { it.copy(isReclaiming = true) }
viewModelScope.launch {
when (val result = repository.reclaimTask(taskId = taskId, board = board, reason = reason)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isReclaiming = false,
toastMessage = "Task reclaimed",
)
}
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isReclaiming = false,
toastMessage = "Failed to reclaim: ${result.error.message}",
)
}
}
}
}
}
fun reassign(
board: String,
taskId: String,
profile: String,
) {
viewModelScope.launch {
when (val result = repository.reassignTask(taskId = taskId, board = board, profile = profile)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Task reassigned to $profile") }
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to reassign: ${result.error.message}")
}
}
}
}
}
fun uploadAttachment(
board: String,
taskId: String,
filename: String,
mimeType: String,
bytes: ByteArray,
) {
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
uploadAttachment(board, taskId, part)
}
fun uploadAttachment(
board: String,
taskId: String,
part: MultipartBody.Part,
) {
_uiState.update { it.copy(isUploadingAttachment = true) }
viewModelScope.launch {
when (val result = repository.uploadAttachment(taskId = taskId, board = board, file = part)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isUploadingAttachment = false,
toastMessage = "Attachment uploaded",
)
}
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isUploadingAttachment = false,
toastMessage = "Failed to upload: ${result.error.message}",
)
}
}
}
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
@@ -2,17 +2,25 @@ package com.m57.hermescontrol.ui.kanban
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.local.KanbanPreferencesStore
import com.m57.hermescontrol.data.model.BulkTasksBody
import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,14 +28,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class KanbanUiState(
val isLoading: Boolean = false,
val isCreatingTask: Boolean = false,
val operatingTaskIds: Set<String> = emptySet(),
val boards: List<KanbanBoard> = emptyList(),
val selectedBoard: KanbanBoard? = null,
val columns: List<KanbanColumn> = emptyList(),
val tasks: List<KanbanTask> = emptyList(),
val profiles: List<KanbanProfile> = emptyList(),
val isLive: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
@@ -113,7 +123,12 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
}
class KanbanViewModel(
private val repository: KanbanRepository = KanbanRepositoryImpl(),
private val preferences: KanbanPreferencesStore = InMemoryKanbanPreferencesStore(),
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
private val endpointProvider: () -> String = {
runCatching { AuthManager.endpointForBuild().baseUrl.toString() }.getOrDefault("default")
},
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState())
@@ -122,90 +137,290 @@ class KanbanViewModel(
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var reloadJob: Job? = null
private var currentLoadGen: Int = 0
fun loadBoards() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
val boards = data.boards.orEmpty()
_uiState.update { it.copy(isLoading = false, boards = boards) }
val currentSlug = data.current
val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull()
if (currentBoard != null) {
selectBoard(currentBoard)
val endpoint = endpointProvider()
val savedSlug = preferences.getSelectedBoard(endpoint)
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
loadProfiles()
viewModelScope.launch {
when (val result = repository.getBoards()) {
is NetworkResult.Success -> {
val boards = result.data.boards
_uiState.update { it.copy(isLoading = false, boards = boards) }
val targetBoard =
boards.find { it.id == previouslySelectedId }
?: boards.find { it.id == result.data.current }
?: boards.firstOrNull()
if (targetBoard != null) {
selectBoard(targetBoard)
} else {
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
}
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: $errorMsg",
)
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
}
}
},
)
}
}
}
fun selectBoard(board: KanbanBoard) {
val current = _uiState.value.selectedBoard
if (current?.id == board.id) {
// Already-active board: reload its data and (re)connect the events
// stream WITHOUT the /switch round-trip — refresh and createTask
// re-select the same board and must not POST a redundant switch.
viewModelScope.launch { loadBoardIntoState(board) }
connectEvents(board)
return
}
val endpoint = endpointProvider()
preferences.setSelectedBoard(endpoint, board.id)
val gen = ++currentLoadGen
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
viewModelScope.launch {
val switchResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.switchKanbanBoard(board.id) }
}
if (switchResult is NetworkResult.Failure) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to switch Kanban board: ${switchResult.error.message}",
)
}
return@launch
}
loadBoardIntoState(board)
loadBoardIntoState(board, gen)
connectEvents(board)
}
}
fun loadProfiles() {
viewModelScope.launch {
when (val result = repository.getProfiles()) {
is NetworkResult.Success -> {
_uiState.update { it.copy(profiles = result.data.profiles) }
}
is NetworkResult.Failure -> {
// Do not block UI if profiles fetch fails
}
}
}
}
fun createTask(
body: CreateTaskBody,
targetStatus: String = "todo",
) {
val board = _uiState.value.selectedBoard ?: return
if (_uiState.value.isCreatingTask) return
_uiState.update { it.copy(isCreatingTask = true) }
viewModelScope.launch {
val result =
repository.createTask(
board = board.id,
body = body,
)
when (result) {
is NetworkResult.Success -> {
val createdTask = result.data.task
if (createdTask != null &&
targetStatus.isNotBlank() &&
!createdTask.status.equals(targetStatus, ignoreCase = true)
) {
repository.updateTask(
taskId = createdTask.id,
board = board.id,
body = UpdateTaskBody(status = targetStatus),
)
}
val warning = result.data.warning
val msg =
if (!warning.isNullOrBlank()) {
"Task created: $warning"
} else {
"Task created successfully"
}
_uiState.update { it.copy(isCreatingTask = false, toastMessage = msg) }
reloadBoardSilently()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isCreatingTask = false,
toastMessage = "Failed to create task: ${result.error.message}",
)
}
}
}
}
}
fun createTask(
title: String,
description: String?,
status: String,
status: String = "todo",
) {
createTask(
body =
CreateTaskBody(
title = title,
body = description,
),
targetStatus = status,
)
}
suspend fun estimateNewTask(
title: String,
body: String?,
): TaskEstimate? =
when (val result = repository.estimateNew(title, body)) {
is NetworkResult.Success -> result.data
is NetworkResult.Failure -> null
}
fun createBoard(
slug: String,
name: String? = null,
description: String? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.createKanbanTask(
board = board.id,
task =
com.m57.hermescontrol.data.model.CreateTaskBody(
title = title,
body = description,
),
)
}
}
when (result) {
_uiState.update { it.copy(isLoading = true) }
val body =
CreateBoardBody(
slug = slug,
name = name,
description = description,
)
when (val res = repository.createBoard(body)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Task created successfully") }
selectBoard(board)
val boardName = res.data.board?.name ?: slug
_uiState.update { it.copy(toastMessage = "Board created: $boardName") }
loadBoards()
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to create task: ${result.error.message}") }
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to create board: ${res.error.message}",
)
}
}
}
}
}
fun renameBoard(
slug: String,
newName: String,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.updateBoard(slug, RenameBoardBody(name = newName))) {
is NetworkResult.Success -> {
val boardName = res.data.board?.name ?: newName
_uiState.update { it.copy(toastMessage = "Board renamed to $boardName") }
loadBoards()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to rename board: ${res.error.message}",
)
}
}
}
}
}
fun deleteBoard(slug: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.deleteBoard(slug, delete = true)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Board deleted") }
loadBoards()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to delete board: ${res.error.message}",
)
}
}
}
}
}
fun bulkMove(
taskIds: List<String>,
targetStatus: String,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) {
is NetworkResult.Success -> {
val count = res.data.results.count { it.ok }
_uiState.update { it.copy(toastMessage = "Moved $count tasks to $targetStatus") }
reloadBoardSilently()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Bulk move failed: ${res.error.message}",
)
}
}
}
}
}
fun bulkArchive(taskIds: List<String>) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, archive = true))) {
is NetworkResult.Success -> {
val count = res.data.results.count { it.ok }
_uiState.update { it.copy(toastMessage = "Archived $count tasks") }
reloadBoardSilently()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Bulk archive failed: ${res.error.message}",
)
}
}
}
}
}
fun bulkAssign(
taskIds: List<String>,
assignee: String?,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, assignee = assignee))) {
is NetworkResult.Success -> {
val count = res.data.results.count { it.ok }
_uiState.update { it.copy(toastMessage = "Assigned $count tasks") }
reloadBoardSilently()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Bulk assign failed: ${res.error.message}",
)
}
}
}
}
@@ -216,32 +431,75 @@ class KanbanViewModel(
action: KanbanTaskAction,
summary: String? = null,
) {
val board = _uiState.value.selectedBoard ?: return
if (task.id in _uiState.value.operatingTaskIds) return
val originalStatus = task.status
// Optimistically update, desktop-style
// Optimistically update both tasks and columns, desktop-style
_uiState.update { state ->
val updatedTasks =
state.tasks.map {
if (it.id == task.id) it.copy(status = action.targetStatus) else it
}
val updatedColumns =
state.columns.map { col ->
val filtered = col.tasks.filter { it.id != task.id }
val newTasks =
if (col.name.equals(action.targetStatus, ignoreCase = true)) {
val moved = updatedTasks.find { it.id == task.id }
if (moved != null) filtered + moved else filtered
} else {
filtered
}
col.copy(tasks = newTasks)
}
state.copy(
tasks =
state.tasks.map {
if (it.id == task.id) it.copy(status = action.targetStatus) else it
},
tasks = updatedTasks,
columns = updatedColumns,
operatingTaskIds = state.operatingTaskIds + task.id,
)
}
viewModelScope.launch {
val body =
buildMap<String, String?> {
put("status", action.targetStatus)
if (action.needsSummary && !summary.isNullOrBlank()) {
put("result", summary)
put("summary", summary)
UpdateTaskBody(
status = action.targetStatus,
summary = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
result = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
)
val result = repository.updateTask(task.id, board = board.id, body = body)
when (result) {
is NetworkResult.Success -> {
val returnedTask = result.data.task
_uiState.update { state ->
val finalStatus = returnedTask?.status ?: action.targetStatus
val updatedTasks =
state.tasks.map {
if (it.id == task.id) (returnedTask ?: it.copy(status = finalStatus)) else it
}
val updatedColumns =
state.columns.map { col ->
val filtered = col.tasks.filter { it.id != task.id }
val newTasks =
if (col.name.equals(finalStatus, ignoreCase = true)) {
val moved = updatedTasks.find { it.id == task.id }
if (moved != null) filtered + moved else filtered
} else {
filtered
}
col.copy(tasks = newTasks)
}
state.copy(
tasks = updatedTasks,
columns = updatedColumns,
operatingTaskIds = state.operatingTaskIds - task.id,
)
}
}
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, body) }
is NetworkResult.Failure -> {
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
}
if (result is NetworkResult.Failure) {
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
}
}
}
@@ -252,11 +510,26 @@ class KanbanViewModel(
errorMsg: String,
) {
_uiState.update { state ->
val updatedTasks =
state.tasks.map {
if (it.id == taskId) it.copy(status = originalStatus) else it
}
val updatedColumns =
state.columns.map { col ->
val filtered = col.tasks.filter { it.id != taskId }
val newTasks =
if (col.name.equals(originalStatus, ignoreCase = true)) {
val moved = updatedTasks.find { it.id == taskId }
if (moved != null) filtered + moved else filtered
} else {
filtered
}
col.copy(tasks = newTasks)
}
state.copy(
tasks =
state.tasks.map {
if (it.id == taskId) it.copy(status = originalStatus) else it
},
tasks = updatedTasks,
columns = updatedColumns,
operatingTaskIds = state.operatingTaskIds - taskId,
toastMessage = errorMsg,
)
}
@@ -300,47 +573,60 @@ class KanbanViewModel(
/** Re-fetch the current board without touching the loading spinner. */
private fun reloadBoardSilently() {
val board = _uiState.value.selectedBoard ?: return
val gen = currentLoadGen
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
val result = repository.getBoard(board = board.id)
if (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) {
val body = result.data
_uiState.update {
it.copy(
columns = body.columns,
tasks = body.columns.flatMap { it.tasks },
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
columns = body.columns,
tasks = body.columns.flatMap { col -> col.tasks },
)
}
}
}
}
}
private suspend fun loadBoardIntoState(board: KanbanBoard) {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
private suspend fun loadBoardIntoState(
board: KanbanBoard,
gen: Int = currentLoadGen,
) {
val result = repository.getBoard(board = board.id)
if (gen != currentLoadGen) return
when (result) {
is NetworkResult.Success -> {
val body = result.data
val allTasks = body.columns.flatMap { it.tasks }
_uiState.update {
it.copy(
isLoading = false,
columns = body.columns,
tasks = allTasks,
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
isLoading = false,
columns = body.columns,
tasks = allTasks,
)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
)
}
}
}
}
@@ -0,0 +1,536 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
import kotlinx.coroutines.launch
private const val PARKED_VALUE = "__parked__"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanCreateTaskDialog(
columns: List<KanbanColumn>,
defaultColumn: String,
profiles: List<KanbanProfile>,
existingTasks: List<KanbanTask>,
isCreating: Boolean,
onDismiss: () -> Unit,
onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit,
modifier: Modifier = Modifier,
onEstimate: (suspend (title: String, body: String?) -> TaskEstimate?)? = null,
) {
var title by remember { mutableStateOf("") }
var desc by remember { mutableStateOf("") }
val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it }
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
var selectedAssignee by remember { mutableStateOf<String?>(null) }
var priority by remember { mutableIntStateOf(0) }
var goalMode by remember { mutableStateOf(false) }
// Estimation state
var estimate by remember { mutableStateOf<TaskEstimate?>(null) }
var isEstimating by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
// Advanced options
var showAdvanced by remember { mutableStateOf(false) }
var parentTaskId by remember { mutableStateOf<String?>(null) }
var modelOverride by remember { mutableStateOf("") }
var providerOverride by remember { mutableStateOf("") }
var reasoningEffort by remember { mutableStateOf<String?>(null) }
var workspaceKind by remember { mutableStateOf<String?>(null) }
var skillsText by remember { mutableStateOf("") }
var assigneeExpanded by remember { mutableStateOf(false) }
var columnExpanded by remember { mutableStateOf(false) }
var parentExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { if (!isCreating) onDismiss() },
modifier = modifier,
title = { Text(stringResource(R.string.kanban_add_new_task)) },
text = {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(R.string.kanban_task_title)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
state = descTextFieldState,
label = { Text(stringResource(R.string.kanban_task_desc)) },
modifier = Modifier.fillMaxWidth(),
lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 3, maxHeightInLines = 5),
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(12.dp))
// Target Column selector
if (columns.isNotEmpty()) {
ExposedDropdownMenuBox(
expanded = columnExpanded,
onExpandedChange = { if (!isCreating) columnExpanded = it },
) {
OutlinedTextField(
value = selectedColumn.uppercase(),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_target_column)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnExpanded) },
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
enabled = !isCreating,
)
ExposedDropdownMenu(
expanded = columnExpanded,
onDismissRequest = { columnExpanded = false },
) {
columns.forEach { col ->
DropdownMenuItem(
text = { Text(col.name.uppercase()) },
onClick = {
selectedColumn = col.name
columnExpanded = false
},
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
// Assignee dropdown
ExposedDropdownMenuBox(
expanded = assigneeExpanded,
onExpandedChange = { if (!isCreating) assigneeExpanded = it },
) {
val displayAssignee =
when (selectedAssignee) {
null -> stringResource(R.string.kanban_assignee_unassigned)
PARKED_VALUE -> stringResource(R.string.kanban_assignee_parked)
else -> selectedAssignee ?: ""
}
OutlinedTextField(
value = displayAssignee,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_assignee)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = assigneeExpanded) },
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
enabled = !isCreating,
)
ExposedDropdownMenu(
expanded = assigneeExpanded,
onDismissRequest = { assigneeExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
onClick = {
selectedAssignee = null
assigneeExpanded = false
},
)
profiles.forEach { profile ->
DropdownMenuItem(
text = {
Column {
Text(profile.name)
if (profile.model != null) {
Text(
profile.model,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
onClick = {
selectedAssignee = profile.name
assigneeExpanded = false
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_parked)) },
onClick = {
selectedAssignee = PARKED_VALUE
assigneeExpanded = false
},
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Priority chips
Text(
text = stringResource(R.string.kanban_priority),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
listOf(
0 to stringResource(R.string.kanban_priority_normal),
1 to stringResource(R.string.kanban_priority_med),
2 to stringResource(R.string.kanban_priority_high),
3 to stringResource(R.string.kanban_priority_urgent),
).forEach { (pVal, label) ->
FilterChip(
selected = priority == pVal,
onClick = { priority = pVal },
label = { Text(label) },
enabled = !isCreating,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Autonomous Goal Mode switch
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.clickable(enabled = !isCreating) { goalMode = !goalMode }
.padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.kanban_goal_mode),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.kanban_goal_mode_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = goalMode,
onCheckedChange = { goalMode = it },
enabled = !isCreating,
)
}
Spacer(modifier = Modifier.height(8.dp))
// Advanced Options Toggle
TextButton(
onClick = { showAdvanced = !showAdvanced },
modifier = Modifier.align(Alignment.Start),
) {
Icon(
imageVector = if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.kanban_advanced_options))
}
AnimatedVisibility(visible = showAdvanced) {
Column(modifier = Modifier.fillMaxWidth()) {
// Parent Task
if (existingTasks.isNotEmpty()) {
ExposedDropdownMenuBox(
expanded = parentExpanded,
onExpandedChange = { if (!isCreating) parentExpanded = it },
) {
val displayParent =
existingTasks.find { it.id == parentTaskId }?.let {
"#${it.id.removePrefix("t_").take(6)} • ${it.title}"
} ?: stringResource(R.string.kanban_parent_none)
OutlinedTextField(
value = displayParent,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_parent_task)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = parentExpanded,
)
},
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
enabled = !isCreating,
)
ExposedDropdownMenu(
expanded = parentExpanded,
onDismissRequest = { parentExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_parent_none)) },
onClick = {
parentTaskId = null
parentExpanded = false
},
)
existingTasks.forEach { task ->
DropdownMenuItem(
text = { Text("#${task.id.removePrefix("t_").take(6)} • ${task.title}") },
onClick = {
parentTaskId = task.id
parentExpanded = false
},
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
// Model Override
OutlinedTextField(
value = modelOverride,
onValueChange = { modelOverride = it },
label = { Text(stringResource(R.string.kanban_model_override)) },
placeholder = { Text("e.g. claude-3-5-sonnet") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
// Provider Override
OutlinedTextField(
value = providerOverride,
onValueChange = { providerOverride = it },
label = { Text(stringResource(R.string.kanban_provider_override)) },
placeholder = { Text("e.g. anthropic") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
// Reasoning Effort
Text(
text = stringResource(R.string.kanban_reasoning_effort),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
listOf(
null to "Inherit",
"low" to "Low",
"medium" to "Medium",
"high" to "High",
).forEach { (effort, label) ->
FilterChip(
selected = reasoningEffort == effort,
onClick = { reasoningEffort = effort },
label = { Text(label) },
enabled = !isCreating,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Workspace Kind
Text(
text = stringResource(R.string.kanban_workspace_kind),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
listOf(
null to "Scratch (Default)",
"worktree" to "Worktree",
"dir" to "Dir",
).forEach { (kind, label) ->
FilterChip(
selected = workspaceKind == kind,
onClick = { workspaceKind = kind },
label = { Text(label) },
enabled = !isCreating,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Skills
OutlinedTextField(
value = skillsText,
onValueChange = { skillsText = it },
label = { Text(stringResource(R.string.kanban_skills)) },
placeholder = { Text("e.g. git, coding, tests") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
}
}
if (onEstimate != null) {
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
if (estimate != null && estimate?.ok == true) {
Text(
text = "~${estimate?.estTokens ?: 0} tokens · ${estimate?.complexity ?: "normal"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
TextButton(
onClick = {
if (title.isNotBlank() && !isEstimating) {
coroutineScope.launch {
isEstimating = true
estimate = onEstimate(title, desc.ifBlank { null })
isEstimating = false
}
}
},
enabled = title.isNotBlank() && !isEstimating && !isCreating,
) {
if (isEstimating) {
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp)
Spacer(modifier = Modifier.width(4.dp))
}
Text(if (estimate != null) "Re-estimate" else "Estimate tokens")
}
}
}
}
},
confirmButton = {
Button(
onClick = {
if (title.isNotBlank()) {
val skillsList =
skillsText
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.takeIf { it.isNotEmpty() }
val body =
CreateTaskBody(
title = title.trim(),
body = desc.trim().ifBlank { null },
assignee = if (selectedAssignee == PARKED_VALUE) null else selectedAssignee,
priority = priority,
goalMode = goalMode,
parents = parentTaskId?.let { listOf(it) } ?: emptyList(),
modelOverride = modelOverride.trim().ifBlank { null },
providerOverride = providerOverride.trim().ifBlank { null },
reasoningEffort = reasoningEffort,
workspaceKind = workspaceKind,
skills = skillsList,
triage = selectedColumn.equals("triage", ignoreCase = true),
)
onConfirm(body, selectedColumn)
}
},
enabled = title.isNotBlank() && !isCreating,
) {
if (isCreating) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.action_add))
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isCreating,
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@@ -0,0 +1,189 @@
package com.m57.hermescontrol.ui.kanban.components
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanFilterSheet(
assignees: List<String>,
tenants: List<String>,
selectedAssignee: String?,
selectedTenant: String?,
includeArchived: Boolean,
groupRunning: Boolean,
onSelectAssignee: (String?) -> Unit,
onSelectTenant: (String?) -> Unit,
onToggleIncludeArchived: (Boolean) -> Unit,
onToggleGroupRunning: (Boolean) -> Unit,
onClearFilters: () -> Unit,
onDismiss: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Filters & View Options",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
TextButton(onClick = onClearFilters) {
Text("Clear all")
}
}
Spacer(modifier = Modifier.height(8.dp))
// Show Archived Switch
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text("Show archived tasks", style = MaterialTheme.typography.bodyMedium)
Switch(
checked = includeArchived,
onCheckedChange = onToggleIncludeArchived,
)
}
// Group Running Switch
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text("Group running lane by profile", style = MaterialTheme.typography.bodyMedium)
Switch(
checked = groupRunning,
onCheckedChange = onToggleGroupRunning,
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Assignee Filter
Text(
text = "Assignee",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelectAssignee(null) }
.padding(vertical = 4.dp),
) {
RadioButton(
selected = selectedAssignee == null,
onClick = { onSelectAssignee(null) },
)
Text("All assignees", style = MaterialTheme.typography.bodyMedium)
}
assignees.forEach { assignee ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelectAssignee(assignee) }
.padding(vertical = 4.dp),
) {
RadioButton(
selected = selectedAssignee == assignee,
onClick = { onSelectAssignee(assignee) },
)
Text(assignee, style = MaterialTheme.typography.bodyMedium)
}
}
if (tenants.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Tenant",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelectTenant(null) }
.padding(vertical = 4.dp),
) {
RadioButton(
selected = selectedTenant == null,
onClick = { onSelectTenant(null) },
)
Text("All tenants", style = MaterialTheme.typography.bodyMedium)
}
tenants.forEach { tenant ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelectTenant(tenant) }
.padding(vertical = 4.dp),
) {
RadioButton(
selected = selectedTenant == tenant,
onClick = { onSelectTenant(tenant) },
)
Text(tenant, style = MaterialTheme.typography.bodyMedium)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@@ -0,0 +1,283 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@Composable
fun KanbanTaskCard(
task: KanbanTask,
onTaskClick: (KanbanTask) -> Unit,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
onActionClick: ((KanbanTask) -> Unit)? = null,
) {
val statusColors = LocalHermesStatusColors.current
val statusTone =
when (task.status.lowercase()) {
"ready" -> statusColors.info
"running" -> statusColors.success
"blocked" -> statusColors.error
"review" -> statusColors.warning
"done" -> statusColors.success
else -> MaterialTheme.colorScheme.outline
}
val cardColors =
if (isSelected) {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f),
)
} else {
CardDefaults.cardColors()
}
Card(
onClick = { onTaskClick(task) },
colors = cardColors,
shape = RoundedCornerShape(8.dp),
modifier = modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier
.size(6.dp)
.background(color = statusTone, shape = CircleShape),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = task.status.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = statusTone,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "#${task.id.removePrefix("t_").take(6)}",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onActionClick != null) {
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = { onActionClick(task) },
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Task actions",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = task.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val summary = task.latestSummary ?: task.body
if (!summary.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
task.assignee?.let { assignee ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(3.dp))
Text(
text = assignee,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Priority
val priority = task.priority ?: 0
if (priority > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp),
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = statusColors.warning,
)
Text(
text = priority.toString(),
style = MaterialTheme.typography.labelSmall,
color = statusColors.warning,
)
}
}
// Child progress
task.progress?.let { prog ->
if (prog.total > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp),
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = "${prog.done}/${prog.total}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Comments
if (task.commentCount > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp),
) {
Icon(
imageVector = Icons.Filled.ChatBubbleOutline,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = task.commentCount.toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Links
val links = task.linkCounts?.let { it.parents + it.children } ?: 0
if (links > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp),
) {
Icon(
imageVector = Icons.Filled.Hub,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = links.toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Warnings
val warnings = task.warnings?.count ?: 0
if (warnings > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp),
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = statusColors.error,
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = warnings.toString(),
style = MaterialTheme.typography.labelSmall,
color = statusColors.error,
)
}
}
}
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.UploadFile
@@ -37,6 +38,7 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
import com.m57.hermescontrol.ui.common.toDetailRows
import com.m57.hermescontrol.ui.mcp.McpServersUiState
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
@@ -112,6 +114,9 @@ fun McpDialogs(
}
if (state.showImportDialog) {
val importTextFieldState =
rememberSyncedTextFieldState(state.importJsonInput, viewModel::updateImportJsonInput)
AlertDialog(
onDismissRequest = { viewModel.toggleImportDialog() },
title = {
@@ -157,8 +162,7 @@ fun McpDialogs(
}
Spacer(modifier = Modifier.height(spacing.xs))
OutlinedTextField(
value = state.importJsonInput,
onValueChange = viewModel::updateImportJsonInput,
state = importTextFieldState,
placeholder = {
Text(
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
@@ -170,7 +174,7 @@ fun McpDialogs(
Modifier
.fillMaxWidth()
.height(180.dp),
maxLines = 10,
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10),
)
}
},
@@ -720,7 +720,7 @@ private fun ProviderCard(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Column(modifier = Modifier.weight(1f)) {
Text(
text = provider.name,
style = MaterialTheme.typography.titleMedium,
@@ -733,12 +733,36 @@ private fun ProviderCard(
)
}
if (isCurrent) {
Text(
text = stringResource(R.string.model_status_current),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (isCurrent) {
Text(
text = stringResource(R.string.model_status_current),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
}
Icon(
imageVector =
if (isExpanded) {
Icons.Filled.KeyboardArrowUp
} else {
Icons.Filled.KeyboardArrowDown
},
contentDescription =
stringResource(
if (isExpanded) {
R.string.content_desc_collapse_provider
} else {
R.string.content_desc_expand_provider
},
provider.name,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
@@ -16,6 +16,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog
@@ -25,6 +27,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -32,6 +35,7 @@ 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.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -65,6 +69,7 @@ fun ModelPickerDialog(
onDismiss: () -> Unit,
) {
var pickerQuery by remember { mutableStateOf("") }
var expandedProviderSlug by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
@@ -149,6 +154,17 @@ fun ModelPickerDialog(
}
}
LaunchedEffect(filteredProvidersWithModels) {
if (
expandedProviderSlug != null &&
filteredProvidersWithModels.none { (provider, _) ->
provider.slug == expandedProviderSlug
}
) {
expandedProviderSlug = null
}
}
SearchBar(
query = pickerQuery,
onQueryChange = { pickerQuery = it },
@@ -205,45 +221,83 @@ fun ModelPickerDialog(
// ── All providers / models (lazy item per model) ──
filteredProvidersWithModels.forEach { (provider, models) ->
val isExpanded = expandedProviderSlug == provider.slug
item(key = "header:${provider.slug}") {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
expandedProviderSlug =
if (isExpanded) {
null
} else {
provider.slug
}
}.padding(top = 12.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 12.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = provider.name,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${models.size} models",
text =
pluralStringResource(
R.plurals.model_provider_count,
models.size,
models.size,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector =
if (isExpanded) {
Icons.Filled.KeyboardArrowUp
} else {
Icons.Filled.KeyboardArrowDown
},
contentDescription =
stringResource(
if (isExpanded) {
R.string.content_desc_collapse_provider
} else {
R.string.content_desc_expand_provider
},
provider.name,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
items(
items = models,
key = { model -> "${provider.slug}:$model" },
) { model ->
val isPinned = "${provider.slug}:$model" in pinnedSet
val caps = provider.capabilities?.get(model)
ModelItemCard(
modelName = model,
isPinned = isPinned,
onPinToggle =
if (onPinToggle != null) {
{ onPinToggle(provider.slug, model) }
} else {
null
},
onClick = { onSelect(provider.slug, model) },
canDisableReasoning = caps?.can_disable_reasoning,
supportsReasoning = caps?.reasoning,
)
if (isExpanded) {
items(
items = models,
key = { model -> "${provider.slug}:$model" },
) { model ->
val isPinned = "${provider.slug}:$model" in pinnedSet
val caps = provider.capabilities?.get(model)
ModelItemCard(
modelName = model,
isPinned = isPinned,
onPinToggle =
if (onPinToggle != null) {
{ onPinToggle(provider.slug, model) }
} else {
null
},
onClick = { onSelect(provider.slug, model) },
canDisableReasoning = caps?.can_disable_reasoning,
supportsReasoning = caps?.reasoning,
)
}
}
}
}
@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
@@ -52,6 +54,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
@@ -230,8 +233,15 @@ fun ProfilesScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.sharedGatewayProfiles.isNotEmpty()) {
item(key = "shared_gateway_summary") {
SharedGatewaySummary(state.sharedGatewayProfiles)
}
}
items(state.displayProfiles, key = { it.name }) { profile ->
val isActive = profile.name == state.activeProfileName
val isProfileShared = profile.name in state.sharedGatewayProfiles
Card(
modifier = Modifier.fillMaxWidth(),
colors =
@@ -291,6 +301,29 @@ fun ProfilesScreen(
)
}
}
if (isProfileShared) {
val sharedGatewayContainer =
MaterialTheme.colorScheme.secondaryContainer
val onSharedGatewayContainer =
MaterialTheme.colorScheme.onSecondaryContainer
val sharedGatewayLabel =
stringResource(R.string.profiles_shared_gateway_badge)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier =
Modifier
.clip(RoundedCornerShape(4.dp))
.background(sharedGatewayContainer)
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
text = sharedGatewayLabel,
color = onSharedGatewayContainer,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
}
val descriptionText =
if (!profile.description.isNullOrBlank()) {
@@ -586,7 +619,15 @@ fun ProfilesScreen(
if (soulEditProfileName != null) {
val initialText = state.selectedSoulContent ?: ""
var soulText by remember(initialText) { mutableStateOf(initialText) }
val soulTextFieldState = rememberTextFieldState(initialText)
LaunchedEffect(initialText) {
if (soulTextFieldState.text.toString() != initialText) {
soulTextFieldState.edit {
replace(0, length, initialText)
}
}
}
AlertDialog(
onDismissRequest = {
@@ -615,10 +656,9 @@ fun ProfilesScreen(
}
} else {
OutlinedTextField(
value = soulText,
onValueChange = { soulText = it },
state = soulTextFieldState,
modifier = Modifier.fillMaxWidth(),
maxLines = 10,
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10),
)
}
},
@@ -627,7 +667,7 @@ fun ProfilesScreen(
onClick = {
val profileName = soulEditProfileName
if (profileName != null) {
viewModel.saveSoul(profileName, soulText)
viewModel.saveSoul(profileName, soulTextFieldState.text.toString())
}
soulEditProfileName = null
},
@@ -962,6 +1002,43 @@ fun ProfilesScreen(
}
}
@Composable
private fun SharedGatewaySummary(profiles: List<String>) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.profiles_shared_gateway_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
pluralStringResource(
R.plurals.profiles_shared_gateway_count,
profiles.size,
profiles.size,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = profiles.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
// ---------------------------------------------------------------------
// Profile Builder Wizard
// ---------------------------------------------------------------------
@@ -34,6 +34,7 @@ data class ProfilesUiState(
val isLoading: Boolean = false,
val profiles: List<ProfileInfo> = emptyList(),
val activeProfileName: String? = null,
val sharedGatewayProfiles: List<String> = emptyList(),
val selectedSoulContent: String? = null,
val isLoadingSoul: Boolean = false,
val errorMessage: String? = null,
@@ -67,6 +68,15 @@ data class ProfilesUiState(
}
}
private fun normalizeSharedGatewayProfiles(names: List<String>?): List<String> =
names
.orEmpty()
.asSequence()
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
.toList()
class ProfilesViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel(),
@@ -110,6 +120,7 @@ class ProfilesViewModel(
val profilesDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfiles() } }
val activeDeferred =
async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
val statusDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getStatus() } }
val profilesResult = profilesDeferred.await()
val activeResult = activeDeferred.await()
@@ -120,9 +131,20 @@ class ProfilesViewModel(
isLoading = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeResult.data.active,
sharedGatewayProfiles = emptyList(),
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
)
}
val statusResult = statusDeferred.await()
if (statusResult is NetworkResult.Success) {
_uiState.update {
it.copy(
sharedGatewayProfiles =
normalizeSharedGatewayProfiles(statusResult.data.gatewaySharedWith),
)
}
}
} else {
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success"
@@ -0,0 +1,71 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.SessionInfo
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* How long ago a session was last active, bucketed the way chat lists show it:
* "now", minutes, hours, days for the last week, then the calendar date.
*/
sealed interface SessionAge {
data object Now : SessionAge
data class Minutes(
val value: Long,
) : SessionAge
data class Hours(
val value: Long,
) : SessionAge
data class Days(
val value: Long,
) : SessionAge
data class Date(
val date: LocalDate,
val sameYear: Boolean,
) : SessionAge
}
private const val MINUTE_SECONDS = 60L
private const val HOUR_SECONDS = 60 * MINUTE_SECONDS
private const val DAY_SECONDS = 24 * HOUR_SECONDS
private const val WEEK_SECONDS = 7 * DAY_SECONDS
/** Latest activity in epoch seconds: the backend's `last_active`, else `started_at`. */
fun SessionInfo.activityEpochSeconds(): Double? = last_active?.takeIf { it > 0.0 } ?: started_at?.takeIf { it > 0.0 }
fun sessionAge(
epochSeconds: Double?,
now: Instant,
zone: ZoneId,
): SessionAge? {
if (epochSeconds == null || epochSeconds <= 0.0) return null
val then = Instant.ofEpochSecond(epochSeconds.toLong())
val elapsed = (now.epochSecond - then.epochSecond).coerceAtLeast(0)
return when {
elapsed < MINUTE_SECONDS -> {
SessionAge.Now
}
elapsed < HOUR_SECONDS -> {
SessionAge.Minutes(elapsed / MINUTE_SECONDS)
}
elapsed < DAY_SECONDS -> {
SessionAge.Hours(elapsed / HOUR_SECONDS)
}
elapsed < WEEK_SECONDS -> {
SessionAge.Days(elapsed / DAY_SECONDS)
}
else -> {
val date = then.atZone(zone).toLocalDate()
SessionAge.Date(date, sameYear = date.year == now.atZone(zone).year)
}
}
}
@@ -0,0 +1,85 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.SessionInfo
/** The workspace a history row belongs to: a display label plus the project's own color, if any. */
data class SessionProject(
val label: String,
val color: String? = null,
)
/**
* Names the project a session belongs to, mirroring the desktop sidebar card
* (`liveSessionProjectId` + `sessionProjectLabel`):
*
* 1. the non-archived project whose folder contains the cwd or the repo root
* (deepest folder wins, so nested projects resolve to the innermost one);
* 2. the repo root itself when the cwd sits under it (an auto project);
* 3. the recorded repo root's leaf (a worktree outside its repo still names it);
* 4. the cwd's leaf.
*
* Returns null when the session recorded no workspace; the card shows "Home".
*/
fun resolveSessionProject(
session: SessionInfo,
projects: List<ProjectInfo>,
): SessionProject? {
val cwd = session.cwd?.trim().orEmpty()
val recordedRoot = session.git_repo_root?.trim().orEmpty()
val repoRoot = recordedRoot.ifEmpty { cwd }
if (cwd.isEmpty() && repoRoot.isEmpty()) return null
val owner =
projects
.filterNot { it.isArchived }
.flatMap { project -> project.folders.map { project to it.path } }
.filter { (_, folder) -> isPathUnder(folder, cwd) || isPathUnder(folder, repoRoot) }
.maxByOrNull { (_, folder) -> pathSegments(folder).size }
if (owner != null) {
val (project, folder) = owner
val label = project.name.trim().ifEmpty { pathLeaf(folder) }
return SessionProject(label = label, color = project.color)
}
val autoRoot = repoRoot.takeIf { cwd.isEmpty() || isPathUnder(it, cwd) }
val label =
listOf(autoRoot, recordedRoot, cwd)
.firstNotNullOfOrNull { path -> path?.let(::pathLeaf)?.takeIf(String::isNotEmpty) }
?: return null
return SessionProject(label = label)
}
/** Path segments, ignoring mixed separators, repeated separators and trailing slashes. */
private fun pathSegments(path: String): List<String> = path.trim().split('/', '\\').filter(String::isNotEmpty)
private val WindowsDrive = Regex("^[A-Za-z]:[/\\\\]")
/** Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`) or backslash-rooted paths, as the backend classifies them. */
private fun isWindowsPath(path: String): Boolean {
val trimmed = path.trim()
return WindowsDrive.containsMatchIn(trimmed) || trimmed.startsWith("\\") || trimmed.startsWith("//")
}
/**
* Segments for identity comparison. Windows paths fold case so `C:\Work` and `c:/work` are one
* folder; POSIX stays case-sensitive. Labels keep the recorded spelling.
*/
private fun comparisonSegments(path: String): List<String> {
val segments = pathSegments(path)
return if (isWindowsPath(path)) segments.map { it.lowercase() } else segments
}
private fun isPathUnder(
folder: String,
target: String,
): Boolean {
val folderSegments = comparisonSegments(folder)
val targetSegments = comparisonSegments(target)
if (folderSegments.isEmpty() || folderSegments.size > targetSegments.size) return false
return folderSegments.indices.all { folderSegments[it] == targetSegments[it] }
}
/** Last segment in its recorded spelling; the filesystem root stays as written ("/"). */
private fun pathLeaf(path: String): String = pathSegments(path).lastOrNull() ?: path.trim()
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.CallSplit
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Code
@@ -77,6 +78,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -124,12 +126,14 @@ import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar
import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs
import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
import com.m57.hermescontrol.ui.sessions.components.automationGroups
import kotlinx.coroutines.delay
/**
* Auto-load the next history page when the user scrolls to within this many
* items of the end — a pre-load buffer so paging feels continuous.
*/
private const val AUTO_LOAD_THRESHOLD = 6
private const val AGE_TICK_MS = 60_000L
/**
* Maps a session source string to a Material icon for visual identification.
@@ -466,6 +470,15 @@ fun SessionsScreen(
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadSessions() },
actions = {
IconButton(
onClick = { NavigationController.openNewChat() },
modifier = Modifier.testTag("sessions_action_new_chat"),
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.content_desc_new_chat),
)
}
if (state.hasHiddenSessions) {
IconButton(
onClick = { viewModel.toggleShowHidden() },
@@ -754,8 +767,19 @@ fun SessionsScreen(
viewModel.loadMore()
}
}
// One clock for every row's "15m" age, re-read on each minute boundary.
val nowMillis by produceState(System.currentTimeMillis()) {
while (true) {
delay(AGE_TICK_MS - value % AGE_TICK_MS)
value = System.currentTimeMillis()
}
}
val sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
val session = item.session
val project =
remember(session.cwd, session.git_repo_root, state.projects) {
resolveSessionProject(session, state.projects)
}
BranchRow(item = item) {
SessionCard(
session = session,
@@ -770,6 +794,8 @@ fun SessionsScreen(
isPinned = session.pinned == true,
isHidden = session.hidden == true,
liveStatus = state.liveStatuses[session.id],
project = project,
nowMillis = nowMillis,
highlightBackground = primaryContainer,
highlightForeground = onPrimaryContainer,
onCardClick = {
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.BulkDeleteRequest
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.PruneRequest
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionLiveStatus
@@ -15,7 +16,9 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ChangeEventHub
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesProjectsSource
import com.m57.hermescontrol.data.ws.HermesSessionLiveStatusSource
import com.m57.hermescontrol.data.ws.ProjectsSource
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.ui.common.ToastHost
@@ -99,6 +102,8 @@ data class SessionsUiState(
val showHidden: Boolean = false,
val pinnedExpanded: Boolean = true,
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
// Named projects used to label each row with the workspace it belongs to.
val projects: List<ProjectInfo> = emptyList(),
) {
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
@@ -114,6 +119,7 @@ data class SessionsUiState(
class SessionsViewModel(
private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(),
private val projectsSource: ProjectsSource = HermesProjectsSource(),
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(SessionsUiState())
@@ -123,6 +129,7 @@ class SessionsViewModel(
private var pageJob: Job? = null
private var statsJob: Job? = null
private var trackingJob: Job? = null
private var projectsJob: Job? = null
private var trackingGeneration: Long = 0
private var liveTrackingState = SessionLiveTrackingState()
private var liveStatusRefreshInFlight = false
@@ -277,6 +284,7 @@ class SessionsViewModel(
loadEmptyCount()
if (trackingJob?.isActive == true) {
refreshLiveStatuses()
loadProjects()
}
loadJob =
safeLaunchLoad(
@@ -329,6 +337,19 @@ class SessionsViewModel(
)
}
/**
* Refresh the project list; a failed fetch keeps the last good one so labels don't flicker.
* Like live statuses, it only runs while the screen tracks the gateway (visible + connected).
*/
private fun loadProjects() {
projectsJob?.cancel()
projectsJob =
viewModelScope.launch {
val projects = projectsSource.fetchProjects() ?: return@launch
_uiState.update { it.copy(projects = projects) }
}
}
/** Load the next page and append to the existing session list. */
fun loadMore() {
val state = _uiState.value
@@ -905,6 +926,7 @@ class SessionsViewModel(
_uiState.update { it.copy(liveStatuses = emptyMap()) }
} else {
requestLiveStatusSnapshot(++trackingGeneration)
loadProjects()
}
}
}
@@ -940,6 +962,8 @@ class SessionsViewModel(
trackingGeneration++
trackingJob?.cancel()
trackingJob = null
projectsJob?.cancel()
projectsJob = null
liveStatusRefreshInFlight = false
liveStatusRefreshPending = false
liveTrackingState = SessionLiveStatusReducer.clear()
@@ -0,0 +1,70 @@
package com.m57.hermescontrol.ui.sessions.components
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.sessions.SessionAge
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/** Compact age shown on the card: "now", "15m", "3h", "2d", or a short date ("Sep 3"). */
@Composable
fun sessionAgeLabel(age: SessionAge): String =
when (age) {
SessionAge.Now -> {
stringResource(R.string.session_age_now)
}
is SessionAge.Minutes -> {
stringResource(R.string.session_age_minutes, age.value.toInt())
}
is SessionAge.Hours -> {
stringResource(R.string.session_age_hours, age.value.toInt())
}
is SessionAge.Days -> {
stringResource(R.string.session_age_days, age.value.toInt())
}
is SessionAge.Date -> {
val locale = LocalConfiguration.current.locales[0]
val skeleton = if (age.sameYear) "MMMd" else "yMMMd"
DateTimeFormatter
.ofPattern(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
.format(age.date)
}
}
/**
* Spoken form of [sessionAgeLabel]. Screen readers misread micro units ("15m" as
* fifteen meters), so TalkBack gets the full phrase instead.
*/
@Composable
fun sessionAgeDescription(age: SessionAge): String =
when (age) {
SessionAge.Now -> {
stringResource(R.string.session_age_now_desc)
}
is SessionAge.Minutes -> {
pluralStringResource(R.plurals.session_age_minutes_desc, age.value.toInt(), age.value.toInt())
}
is SessionAge.Hours -> {
pluralStringResource(R.plurals.session_age_hours_desc, age.value.toInt(), age.value.toInt())
}
is SessionAge.Days -> {
pluralStringResource(R.plurals.session_age_days_desc, age.value.toInt(), age.value.toInt())
}
is SessionAge.Date -> {
val locale = LocalConfiguration.current.locales[0]
val date = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale).format(age.date)
stringResource(R.string.session_age_date_desc, date)
}
}
@@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.sessions.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,6 +14,7 @@ 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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.CallSplit
import androidx.compose.material.icons.automirrored.filled.Send
@@ -32,17 +35,24 @@ 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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
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
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo
@@ -50,8 +60,14 @@ import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.data.model.SessionTreeItem
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.theme.parseProjectColor
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.sessions.SessionProject
import com.m57.hermescontrol.ui.sessions.activityEpochSeconds
import com.m57.hermescontrol.ui.sessions.sessionAge
import java.time.Instant
import java.time.ZoneId
@Composable
fun BranchRow(
@@ -133,6 +149,13 @@ fun highlightText(
}
}
private val ProjectDotSize = 8.dp
private val MetaIconSize = 14.dp
// The model gives way to the badges down to this width before they wrap to a second line.
private val FooterLeadMinWidth = 72.dp
private val WhitespaceRun = Regex("\\s+")
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SessionCard(
@@ -148,6 +171,8 @@ fun SessionCard(
isPinned: Boolean,
isHidden: Boolean = false,
liveStatus: SessionLiveStatus? = null,
project: SessionProject? = null,
nowMillis: Long = System.currentTimeMillis(),
highlightBackground: Color,
highlightForeground: Color,
onCardClick: () -> Unit,
@@ -160,7 +185,7 @@ fun SessionCard(
) {
val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current
val srcIcon = sourceIcon(session.source)
val palette = sessionCardPalette()
var menuExpanded by remember { mutableStateOf(false) }
Card(
@@ -172,10 +197,7 @@ fun SessionCard(
onClick = onCardClick,
onLongClick = { if (!isSelecting) menuExpanded = true },
),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
colors = CardDefaults.cardColors(containerColor = palette.card),
border =
if (isSelected) {
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
@@ -201,86 +223,57 @@ fun SessionCard(
Spacer(modifier = Modifier.width(spacing.sm))
}
if (isFork && !isSelecting) {
Icon(
imageVector = Icons.AutoMirrored.Filled.CallSplit,
contentDescription = stringResource(R.string.sessions_fork_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
if (forkDepth > 0) {
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = forkDepth.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(spacing.sm))
}
if (srcIcon != null && !isSelecting) {
Icon(
imageVector = srcIcon,
contentDescription = sourceLabel(session.source),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(spacing.sm))
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
if (query.isNotBlank()) {
highlightText(displayTitle, query, highlightBackground, highlightForeground)
} else {
AnnotatedString(displayTitle)
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (isPinned) {
Spacer(modifier = Modifier.width(spacing.xs))
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = stringResource(R.string.sessions_pinned_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp),
)
}
}
SessionCardHeader(
session = session,
project = project,
isFork = isFork && !isSelecting,
forkDepth = forkDepth,
isPinned = isPinned,
nowMillis = nowMillis,
palette = palette,
)
Spacer(modifier = Modifier.height(spacing.xs))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text =
if (query.isNotBlank()) {
highlightText(displayTitle, query, highlightBackground, highlightForeground)
} else {
AnnotatedString(displayTitle)
},
style = MaterialTheme.typography.titleMedium.copy(textDirection = TextDirection.Content),
fontWeight = FontWeight.SemiBold,
color = palette.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val preview =
session.preview
?.replace(WhitespaceRun, " ")
?.trim()
.orEmpty()
if (preview.isNotEmpty() && preview != displayTitle) {
Text(
text = stringResource(R.string.history_message_count, session.message_count ?: 0),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = preview,
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
color = palette.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("session_preview_${session.id}"),
)
if (session.hidden == true) {
Spacer(modifier = Modifier.width(spacing.sm))
StatusBadge(
text = stringResource(R.string.sessions_hidden_badge),
status = StatusBadgeType.NEUTRAL,
)
}
if (liveStatus != null) {
Spacer(modifier = Modifier.width(spacing.sm))
SessionLiveStatusIndicator(
liveStatus = liveStatus,
sessionId = session.id,
)
}
}
Spacer(modifier = Modifier.height(spacing.sm))
SessionCardFooter(
session = session,
showSource = !isSelecting,
liveStatus = liveStatus,
palette = palette,
)
}
}
@@ -314,3 +307,223 @@ fun SessionCard(
}
}
}
/** Project (with its color dot), fork and pin markers, and the activity age on the right. */
@Composable
private fun SessionCardHeader(
session: SessionInfo,
project: SessionProject?,
isFork: Boolean,
forkDepth: Int,
isPinned: Boolean,
nowMillis: Long,
palette: SessionCardPalette,
) {
val spacing = LocalSpacing.current
Row(verticalAlignment = Alignment.CenterVertically) {
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
val dotColor = remember(project?.color) { parseProjectColor(project?.color) }
if (dotColor != null) {
Box(
modifier =
Modifier
.size(ProjectDotSize)
.clip(CircleShape)
.background(dotColor),
)
Spacer(modifier = Modifier.width(spacing.sm))
}
Text(
text = project?.label ?: stringResource(R.string.sessions_project_home),
style = MaterialTheme.typography.labelMedium.copy(textDirection = TextDirection.Content),
color = palette.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false).testTag("session_project_${session.id}"),
)
if (isFork) {
Spacer(modifier = Modifier.width(spacing.sm))
Icon(
imageVector = Icons.AutoMirrored.Filled.CallSplit,
contentDescription = stringResource(R.string.sessions_fork_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(MetaIconSize),
)
if (forkDepth > 0) {
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = forkDepth.toString(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (isPinned) {
Spacer(modifier = Modifier.width(spacing.sm))
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = stringResource(R.string.sessions_pinned_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(MetaIconSize),
)
}
}
Spacer(modifier = Modifier.width(spacing.md))
val age = sessionAge(session.activityEpochSeconds(), Instant.ofEpochMilli(nowMillis), ZoneId.systemDefault())
if (age != null) {
val description = sessionAgeDescription(age)
Text(
text = sessionAgeLabel(age),
style = MaterialTheme.typography.labelMedium,
color = palette.secondary,
maxLines = 1,
modifier =
Modifier
.testTag("session_age_${session.id}")
.semantics { contentDescription = description },
)
}
}
}
/**
* Whether the footer's badges share the first line. The model (the lead) is the part that gives
* way: it middle-ellipsizes down to [leadMinWidth] (or its own width when shorter) before the
* badges move to a second line. [badgesWidth] includes the gap before the badges; 0 means none.
*/
internal fun footerFitsOneLine(
maxWidth: Int,
leadWidth: Int,
countWidth: Int,
badgesWidth: Int,
leadMinWidth: Int,
): Boolean {
if (badgesWidth <= 0) return true
return maxWidth - countWidth - badgesWidth >= minOf(leadWidth, leadMinWidth)
}
/** Origin, model and message count, then the hidden badge and live status. */
@Composable
private fun SessionCardFooter(
session: SessionInfo,
showSource: Boolean,
liveStatus: SessionLiveStatus?,
palette: SessionCardPalette,
) {
val spacing = LocalSpacing.current
val messageCount = session.message_count ?: 0
val model = session.model?.trim().orEmpty()
val srcIcon = sourceIcon(session.source)
val hasBadges = session.hidden == true || liveStatus != null
Layout(
contents =
listOf(
{
Row(verticalAlignment = Alignment.CenterVertically) {
if (srcIcon != null && showSource) {
Icon(
imageVector = srcIcon,
contentDescription = sourceLabel(session.source),
tint = palette.secondary,
modifier = Modifier.size(MetaIconSize),
)
Spacer(modifier = Modifier.width(spacing.sm))
}
if (model.isNotEmpty()) {
Text(
text = model,
style =
MaterialTheme.typography.labelMedium.copy(
textDirection = TextDirection.Content,
),
color = palette.secondary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
text = " · ",
style = MaterialTheme.typography.labelMedium,
color = palette.secondary,
)
}
}
},
{
Text(
text = pluralStringResource(R.plurals.sessions_card_message_count, messageCount, messageCount),
style = MaterialTheme.typography.labelMedium,
color = palette.secondary,
maxLines = 1,
modifier = Modifier.testTag("session_footer_count_${session.id}"),
)
},
{
if (hasBadges) {
Row(
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
if (session.hidden == true) {
StatusBadge(
text = stringResource(R.string.sessions_hidden_badge),
status = StatusBadgeType.NEUTRAL,
)
}
if (liveStatus != null) {
SessionLiveStatusIndicator(
liveStatus = liveStatus,
sessionId = session.id,
)
}
}
}
},
),
) { (leadMeasurables, countMeasurables, badgeMeasurables), constraints ->
val maxWidth = constraints.maxWidth
val loose = Constraints(maxWidth = maxWidth)
val gap = spacing.sm.roundToPx()
val lineGap = spacing.xs.roundToPx()
val count = countMeasurables.first().measure(loose)
val badges = badgeMeasurables.firstOrNull()?.measure(loose)
val lead = leadMeasurables.firstOrNull()
val leadNatural = lead?.maxIntrinsicWidth(Constraints.Infinity) ?: 0
val badgesSpace = badges?.let { it.width + gap } ?: 0
val oneLine =
footerFitsOneLine(
maxWidth = maxWidth,
leadWidth = leadNatural,
countWidth = count.width,
badgesWidth = badgesSpace,
leadMinWidth = FooterLeadMinWidth.roundToPx(),
)
val leadRoom = (maxWidth - count.width - if (oneLine) badgesSpace else 0).coerceAtLeast(0)
val leadPlaceable = lead?.measure(Constraints(maxWidth = minOf(leadNatural, leadRoom)))
val firstLineHeight =
maxOf(leadPlaceable?.height ?: 0, count.height, if (oneLine) badges?.height ?: 0 else 0)
val height = firstLineHeight + if (!oneLine && badges != null) lineGap + badges.height else 0
val width = if (constraints.hasBoundedWidth) maxWidth else constraints.minWidth
layout(width, height) {
var x = 0
leadPlaceable?.let {
it.placeRelative(x, (firstLineHeight - it.height) / 2)
x += it.width
}
count.placeRelative(x, (firstLineHeight - count.height) / 2)
x += count.width
badges?.let {
if (oneLine) {
it.placeRelative(x + gap, (firstLineHeight - it.height) / 2)
} else {
it.placeRelative(0, firstLineHeight + lineGap)
}
}
}
}
}
@@ -0,0 +1,49 @@
package com.m57.hermescontrol.ui.sessions.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.lerp
import androidx.compose.ui.graphics.luminance
/**
* Colors for the history card. Every secondary line (project, age, preview, footer) is
* body-sized or smaller, so it needs 4.5:1 against the card. Some presets' onSurfaceVariant
* falls short on surfaceContainer (Gruvbox, Catppuccin light); there the secondary color is
* pulled toward onSurface just far enough to pass, keeping the hierarchy everywhere else.
* Gated in SessionCardContrastTest.
*/
internal data class SessionCardPalette(
val card: Color,
val title: Color,
val secondary: Color,
)
internal const val SESSION_CARD_MIN_TEXT_CONTRAST = 4.5f
private const val BLEND_STEPS = 20
internal fun sessionCardPalette(scheme: ColorScheme): SessionCardPalette {
val card = scheme.surfaceContainer
val secondary =
(0..BLEND_STEPS)
.asSequence()
.map { step -> lerp(scheme.onSurfaceVariant, scheme.onSurface, step / BLEND_STEPS.toFloat()) }
.firstOrNull { contrastRatio(it, card) >= SESSION_CARD_MIN_TEXT_CONTRAST }
?: scheme.onSurface
return SessionCardPalette(card = card, title = scheme.onSurface, secondary = secondary)
}
@Composable
@ReadOnlyComposable
internal fun sessionCardPalette(): SessionCardPalette = sessionCardPalette(MaterialTheme.colorScheme)
internal fun contrastRatio(
a: Color,
b: Color,
): Float {
val l1 = a.luminance()
val l2 = b.luminance()
return (maxOf(l1, l2) + 0.05f) / (minOf(l1, l2) + 0.05f)
}
@@ -199,6 +199,14 @@ internal fun SettingsChatPage(
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
chatFontScale = state.chatFontScale,
onChatFontScaleChange = viewModel::onChatFontScaleChange,
messageStatsEnabled = state.messageStatsEnabled,
onMessageStatsEnabledChange = viewModel::onMessageStatsEnabledChange,
showUserMessageTokens = state.showUserMessageTokens,
onUserMessageTokensChange = viewModel::onUserMessageTokensChange,
showAssistantMessageTokens = state.showAssistantMessageTokens,
onAssistantMessageTokensChange = viewModel::onAssistantMessageTokensChange,
showTokensPerSecond = state.showTokensPerSecond,
onTokensPerSecondChange = viewModel::onTokensPerSecondChange,
)
}
}
@@ -38,6 +38,10 @@ data class SettingsUiState(
val typingEffectEnabled: Boolean = false,
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,
val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null,
val renameProfileName: String = "",
@@ -80,6 +84,10 @@ class SettingsViewModel(
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
val chatFontScale = AuthManager.getChatFontScale()
val messageStatsEnabled = AuthManager.isMessageStatsEnabled()
val showUserMessageTokens = AuthManager.isUserMessageTokensEnabled()
val showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled()
val showTokensPerSecond = AuthManager.isTokensPerSecondEnabled()
val profiles = AuthManager.getConnectionProfiles()
val appLanguage = AuthManager.getAppLanguage()
val renameProfileName =
@@ -101,6 +109,10 @@ class SettingsViewModel(
typingEffectEnabled = typingEffectEnabled,
typingEffectDelayMs = typingEffectDelayMs,
chatFontScale = chatFontScale,
messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens,
showAssistantMessageTokens = showAssistantMessageTokens,
showTokensPerSecond = showTokensPerSecond,
profiles = profiles,
selectedProfileId = selectedId,
renameProfileName = renameProfileName,
@@ -327,6 +339,26 @@ class SettingsViewModel(
AuthManager.setChatFontScale(scale)
}
fun onMessageStatsEnabledChange(enabled: Boolean) {
_uiState.update { it.copy(messageStatsEnabled = enabled, isSaved = false) }
AuthManager.setMessageStatsEnabled(enabled)
}
fun onUserMessageTokensChange(enabled: Boolean) {
_uiState.update { it.copy(showUserMessageTokens = enabled, isSaved = false) }
AuthManager.setUserMessageTokensEnabled(enabled)
}
fun onAssistantMessageTokensChange(enabled: Boolean) {
_uiState.update { it.copy(showAssistantMessageTokens = enabled, isSaved = false) }
AuthManager.setAssistantMessageTokensEnabled(enabled)
}
fun onTokensPerSecondChange(enabled: Boolean) {
_uiState.update { it.copy(showTokensPerSecond = enabled, isSaved = false) }
AuthManager.setTokensPerSecondEnabled(enabled)
}
/** Clear all auth credentials — logs out and returns to landing screen. */
fun logout() {
AuthManager.setToken(null)
@@ -355,6 +387,10 @@ class SettingsViewModel(
AuthManager.setThemePreset(state.themePreset)
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
AuthManager.setMessageStatsEnabled(state.messageStatsEnabled)
AuthManager.setUserMessageTokensEnabled(state.showUserMessageTokens)
AuthManager.setAssistantMessageTokensEnabled(state.showAssistantMessageTokens)
AuthManager.setTokensPerSecondEnabled(state.showTokensPerSecond)
ApiClient.rebuild()
viewModelScope.launch(ioDispatcher) {
@@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
@@ -204,6 +205,14 @@ internal fun ChatSection(
onTypingEffectDelayMsChange: (Int) -> Unit,
chatFontScale: Float = 1.0f,
onChatFontScaleChange: (Float) -> Unit = {},
messageStatsEnabled: Boolean = false,
onMessageStatsEnabledChange: (Boolean) -> Unit = {},
showUserMessageTokens: Boolean = true,
onUserMessageTokensChange: (Boolean) -> Unit = {},
showAssistantMessageTokens: Boolean = true,
onAssistantMessageTokensChange: (Boolean) -> Unit = {},
showTokensPerSecond: Boolean = true,
onTokensPerSecondChange: (Boolean) -> Unit = {},
) {
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
val currentIndex =
@@ -369,6 +378,53 @@ internal fun ChatSection(
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(R.string.settings_item_message_stats),
style = MaterialTheme.typography.titleMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.settings_desc_message_stats),
modifier = Modifier.weight(1f),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Switch(
checked = messageStatsEnabled,
onCheckedChange = onMessageStatsEnabledChange,
modifier = Modifier.testTag("settings_message_stats"),
)
}
MessageStatsOption(
label = stringResource(R.string.settings_item_user_message_tokens),
checked = showUserMessageTokens,
enabled = messageStatsEnabled,
onCheckedChange = onUserMessageTokensChange,
testTag = "settings_user_message_tokens",
)
MessageStatsOption(
label = stringResource(R.string.settings_item_assistant_message_tokens),
checked = showAssistantMessageTokens,
enabled = messageStatsEnabled,
onCheckedChange = onAssistantMessageTokensChange,
testTag = "settings_assistant_message_tokens",
)
MessageStatsOption(
label = stringResource(R.string.settings_item_tokens_per_second),
checked = showTokensPerSecond,
enabled = messageStatsEnabled,
onCheckedChange = onTokensPerSecondChange,
testTag = "settings_tps",
)
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -440,3 +496,37 @@ internal fun ChatSection(
}
}
}
@Composable
private fun MessageStatsOption(
label: String,
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
testTag: String,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style =
MaterialTheme.typography.bodyMedium.copy(
color =
if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
),
)
Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
modifier = Modifier.testTag(testTag),
)
}
}
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.skills.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.AlertDialog
@@ -44,13 +45,21 @@ internal fun SkillEditorDialog(
onDismiss: () -> Unit,
onClearSaveSuccess: () -> Unit,
) {
var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) }
val textFieldState = rememberTextFieldState(initialContent.orEmpty())
var showDiscardConfirm by remember { mutableStateOf(false) }
LaunchedEffect(initialContent) {
val newContent = initialContent.orEmpty()
if (textFieldState.text.toString() != newContent) {
textFieldState.edit {
replace(0, length, newContent)
}
}
}
val hasChanges =
remember(initialContent, contentText) {
val original = initialContent.orEmpty()
original != contentText
remember(initialContent, textFieldState.text) {
initialContent.orEmpty() != textFieldState.text.toString()
}
LaunchedEffect(saveSuccess) {
@@ -95,7 +104,7 @@ internal fun SkillEditorDialog(
actions = {
if (!isLoading) {
IconButton(
onClick = { onSave(contentText) },
onClick = { onSave(textFieldState.text.toString()) },
enabled = !isSaving,
) {
if (isSaving) {
@@ -126,8 +135,7 @@ internal fun SkillEditorDialog(
else -> {
OutlinedTextField(
value = contentText,
onValueChange = { contentText = it },
state = textFieldState,
modifier =
Modifier
.fillMaxSize()
@@ -133,6 +133,10 @@ fun SystemScreen(
controller = resolvedViewModel.actionProgress,
title = stringResource(R.string.system_update_progress_title),
)
ActionProgressDialog(
controller = resolvedViewModel.migrationProgress,
title = stringResource(R.string.system_migration_progress_title),
)
// Credential remove confirmation using ConfirmDialog
var credToRemove by remember { mutableStateOf<Pair<String, Int>?>(null) }
@@ -15,6 +15,7 @@ import com.m57.hermescontrol.data.model.CredentialPoolProvider
import com.m57.hermescontrol.data.model.CuratorResponse
import com.m57.hermescontrol.data.model.DebugShareResponse
import com.m57.hermescontrol.data.model.DoctorResponse
import com.m57.hermescontrol.data.model.GatewayMigrationPlan
import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.PortalResponse
import com.m57.hermescontrol.data.model.StatusResponse
@@ -26,6 +27,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ActionProgressController
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
@@ -53,6 +55,8 @@ data class SystemUiState(
val updateInfo: UpdateCheckResponse? = null,
val status: StatusResponse? = null,
val doctorReport: DoctorResponse? = null,
val migrationPlan: GatewayMigrationPlan? = null,
val migrationSupported: Boolean? = null,
val activeAction: String? = null,
val actionLog: ActionStatusResponse? = null,
val backupArchive: String? = null,
@@ -88,6 +92,7 @@ data class SystemUiState(
class SystemViewModel(
application: Application,
private val migrationDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : AndroidViewModel(application),
ToastHost {
companion object {
@@ -114,12 +119,22 @@ class SystemViewModel(
onFinished = { status -> onActionFinished(status) },
)
/** Progress popup for the optional gateway migration action. */
val migrationProgress =
ActionProgressController(
scope = viewModelScope,
onFinished = { status -> onMigrationFinished(status) },
)
private var actionPollingJob: Job? = null
private var downloadJob: Job? = null
// ── Full parallel data load ────────────────────────────────────────
fun loadAll() {
// Migration support is optional; probe it independently so an older or
// slow backend cannot hold up the required System data.
loadMigrationPlan()
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
@@ -138,6 +153,7 @@ class SystemViewModel(
val statsResult = statsDeferred.await()
val statusResult = statusDeferred.await()
val portalResult = portalDeferred.await()
val curatorResult = curatorDeferred.await()
@@ -224,6 +240,66 @@ class SystemViewModel(
}
}
/** Load optional migration capability state without affecting other System data. */
fun loadMigrationPlan() {
viewModelScope.launch {
val result =
withContext(migrationDispatcher) {
safeApiCall { ApiClient.hermesApi.getGatewayMigrationPlan() }
}
_uiState.update {
it.copy(
migrationPlan = (result as? NetworkResult.Success)?.data,
migrationSupported = migrationSupport(result),
)
}
}
}
/**
* Start the backend migration only after the Gateway section's confirmation.
* The POST starts an asynchronous `gateway-migrate` action; completion is
* owned by [migrationProgress], not by the trigger response.
*/
fun startMigration() {
val plan = _uiState.value.migrationPlan ?: return
if (plan.alreadyMultiplexed) {
_uiState.update { it.copy(toastMessage = "Gateway is already multiplexed") }
return
}
if (plan.blockers.isNotEmpty()) {
_uiState.update { it.copy(toastMessage = "Migration is blocked by the preflight plan") }
return
}
migrationProgress.open()
viewModelScope.launch {
val result =
withContext(migrationDispatcher) {
safeApiCall { ApiClient.hermesApi.startGatewayMigration() }
}
when (result) {
is NetworkResult.Success -> {
val response = result.data
val actionName = response.name
if (!response.ok) {
migrationProgress.fail("Migration was rejected by the backend")
} else if (actionName != null) {
migrationProgress.markStarted(actionName)
} else {
migrationProgress.fail(
"Migration was accepted but the backend did not report an action name",
)
}
}
is NetworkResult.Failure -> {
migrationProgress.fail("Failed to start migration: ${result.error.message}")
}
}
}
}
/**
* Fired when [actionProgress] finishes tracking a backend action. For the
* `hermes-update` action we additionally fetch the durable update receipt
@@ -242,6 +318,12 @@ class SystemViewModel(
loadAll()
}
private fun onMigrationFinished(status: ActionStatusResponse?) {
if (status?.exit_code == 0) {
loadAll()
}
}
/**
* Pull `GET /api/hermes/update/receipt` and render a compact summary block
* for the popup. Returns an empty list when the endpoint is unavailable
@@ -862,3 +944,15 @@ class SystemViewModel(
_uiState.update { it.copy(toastMessage = null) }
}
}
private fun migrationSupport(result: NetworkResult<GatewayMigrationPlan>): Boolean? =
when (result) {
is NetworkResult.Success -> {
true
}
is NetworkResult.Failure -> {
val code = (result.error as? NetworkError.Http)?.code
if (code == 404 || code == 405) false else null
}
}
@@ -22,12 +22,19 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.GatewayMigrationPlan
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.ConfirmDialog
import com.m57.hermescontrol.ui.common.InfoRow
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.StatusBadge
@@ -183,5 +190,134 @@ fun LazyListScope.gatewaySection(
}
}
}
state.migrationPlan?.let { plan ->
item {
GatewayMigrationCard(
plan = plan,
onStartMigration = viewModel::startMigration,
)
}
}
}
}
@Composable
private fun GatewayMigrationCard(
plan: GatewayMigrationPlan,
onStartMigration: () -> Unit,
) {
var showConfirmation by remember(plan) { mutableStateOf(false) }
val profileNames =
plan.profiles
.mapNotNull { it.profile?.takeIf(String::isNotBlank) }
.ifEmpty { plan.liveServed.orEmpty() }
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.system_migration_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.system_migration_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (profileNames.isNotEmpty()) {
InfoRow(
label = stringResource(R.string.system_migration_profiles),
value = profileNames.joinToString(" · "),
)
}
InfoRow(
label = stringResource(R.string.system_migration_automatic_eligibility),
value =
stringResource(
if (plan.eligible) {
R.string.system_migration_yes
} else {
R.string.system_migration_no
},
),
)
if (plan.alreadyMultiplexed) {
Spacer(modifier = Modifier.height(8.dp))
StatusBadge(
text = stringResource(R.string.system_migration_already_multiplexed),
status = StatusBadgeType.SUCCESS,
)
}
if (plan.interrupted) {
Spacer(modifier = Modifier.height(8.dp))
StatusBadge(
text = stringResource(R.string.system_migration_interrupted),
status = StatusBadgeType.WARNING,
)
}
if (plan.blockers.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.system_migration_blockers),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.error,
)
plan.blockers.forEach { blocker ->
Text(
text = "• $blocker",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
if (plan.notices.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.system_migration_notices),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
plan.notices.forEach { notice ->
Text(
text = "• $notice",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (!plan.alreadyMultiplexed && plan.blockers.isEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { showConfirmation = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.system_migration_start))
}
}
}
}
if (showConfirmation) {
ConfirmDialog(
title = stringResource(R.string.system_migration_confirm_title),
message = stringResource(R.string.system_migration_confirm_description),
onConfirm = {
showConfirmation = false
onStartMigration()
},
onDismiss = { showConfirmation = false },
confirmText = stringResource(R.string.system_migration_start),
)
}
}
+90 -1
View File
@@ -184,7 +184,18 @@
<string name="settings_sec_chat">المحادثة</string>
<string name="settings_item_typing_effect">تأثير الكتابة</string>
<string name="settings_desc_typing_effect">تحريك ظهور الرسائل الجديدة كلمة بكلمة</string>
<string name="fullbleed_role_agent">المساعد</string>
<string name="settings_item_message_stats">إحصائيات الرسائل</string>
<string name="settings_desc_message_stats">إظهار تفاصيل الرموز وأداء التوليد في الدردشة.</string>
<string name="settings_item_user_message_tokens">رموز رسالة المستخدم</string>
<string name="settings_item_assistant_message_tokens">رموز رسالة المساعد</string>
<string name="settings_item_tokens_per_second">الرموز في الثانية (TPS)</string>
<string name="chat_agent_status_typing">الوكيل يكتب</string>
<string name="chat_agent_status_thinking">يفكّر…</string>
<string name="chat_agent_status_searching">يبحث…</string>
<string name="chat_agent_status_reading">يقرأ الملف…</string>
<string name="chat_agent_status_browsing">يتصفّح…</string>
<string name="chat_agent_status_running_command">يشغّل الأمر…</string>
<string name="chat_agent_status_using_tool">يستخدم أداة…</string>
<string name="settings_item_typing_delay">التأخير: %1$d مللي ثانية</string>
<string name="settings_delay_10ms">10 مللي ثانية</string>
<string name="settings_delay_100ms">100 مللي ثانية</string>
@@ -422,6 +433,20 @@
<string name="system_gateway_start">بدء</string>
<string name="system_gateway_restart">إعادة التشغيل</string>
<string name="system_gateway_stop">إيقاف</string>
<string name="system_migration_title">ترحيل البوابة</string>
<string name="system_migration_description">راجع خطة الترحيل من الخادم قبل نقل بوابات الملفات الشخصية إلى مُضاعِف مشترك واحد.</string>
<string name="system_migration_profiles">الملفات الشخصية</string>
<string name="system_migration_automatic_eligibility">الأهلية التلقائية</string>
<string name="system_migration_yes">نعم</string>
<string name="system_migration_no">لا</string>
<string name="system_migration_already_multiplexed">تعمل بالمضاعفة مسبقًا</string>
<string name="system_migration_interrupted">تمت مقاطعة عملية ترحيل سابقة. راجع الخطة قبل إعادة المحاولة.</string>
<string name="system_migration_blockers">العوائق</string>
<string name="system_migration_notices">التنبيهات</string>
<string name="system_migration_start">بدء الترحيل</string>
<string name="system_migration_confirm_title">بدء ترحيل البوابة؟</string>
<string name="system_migration_confirm_description">سيوقف هذا بوابات الملفات الشخصية المستقلة ويعيد تشغيل البوابة الافتراضية كمضاعِف مشترك. ستعمل العملية في الخلفية.</string>
<string name="system_migration_progress_title">ترحيل البوابة</string>
<string name="memory_active">النشط: %1$s</string>
<string name="memory_builtin">الذاكرة المدمجة</string>
<string name="memory_builtin_sizes">MEMORY.md: %1$s · USER.md: %2$s</string>
@@ -503,13 +528,62 @@
<string name="model_label_available">النماذج المتاحة:</string>
<string name="model_no_models">لم يتم العثور على نماذج أو أن المزود يحتاج إلى مصادقة.</string>
<string name="content_desc_active_model">النموذج النشط</string>
<string name="content_desc_expand_provider">توسيع نماذج %1$s</string>
<string name="content_desc_collapse_provider">طي نماذج %1$s</string>
<string name="model_section_pinned">النماذج المثبتة</string>
<string name="content_desc_unpin_model">إلغاء تثبيت النموذج</string>
<string name="content_desc_pinned_section_toggle">تبديل ظهور قسم المثبتات</string>
<plurals name="model_provider_count">
<item quantity="zero">لا توجد نماذج</item>
<item quantity="one">%1$d نموذج</item>
<item quantity="two">%1$d نموذجان</item>
<item quantity="few">%1$d نماذج</item>
<item quantity="many">%1$d نموذجًا</item>
<item quantity="other">%1$d نموذج</item>
</plurals>
<string name="history_empty_title">لا يوجد سجل</string>
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string>
<string name="history_untitled">بلا عنوان</string>
<string name="history_message_count">%1$d رسالة</string>
<plurals name="sessions_card_message_count">
<item quantity="zero">%1$d رسالة</item>
<item quantity="one">%1$d رسالة</item>
<item quantity="two">%1$d رسالتان</item>
<item quantity="few">%1$d رسائل</item>
<item quantity="many">%1$d رسالة</item>
<item quantity="other">%1$d رسالة</item>
</plurals>
<string name="sessions_project_home">الرئيسية</string>
<string name="session_age_now">الآن</string>
<string name="session_age_minutes">%1$d د</string>
<string name="session_age_hours">%1$d س</string>
<string name="session_age_days">%1$d ي</string>
<string name="session_age_now_desc">نشط الآن</string>
<plurals name="session_age_minutes_desc">
<item quantity="zero">نشط منذ %1$d دقيقة</item>
<item quantity="one">نشط منذ %1$d دقيقة</item>
<item quantity="two">نشط منذ %1$d دقيقتين</item>
<item quantity="few">نشط منذ %1$d دقائق</item>
<item quantity="many">نشط منذ %1$d دقيقة</item>
<item quantity="other">نشط منذ %1$d دقيقة</item>
</plurals>
<plurals name="session_age_hours_desc">
<item quantity="zero">نشط منذ %1$d ساعة</item>
<item quantity="one">نشط منذ %1$d ساعة</item>
<item quantity="two">نشط منذ %1$d ساعتين</item>
<item quantity="few">نشط منذ %1$d ساعات</item>
<item quantity="many">نشط منذ %1$d ساعة</item>
<item quantity="other">نشط منذ %1$d ساعة</item>
</plurals>
<plurals name="session_age_days_desc">
<item quantity="zero">نشط منذ %1$d يوم</item>
<item quantity="one">نشط منذ %1$d يوم</item>
<item quantity="two">نشط منذ %1$d يومين</item>
<item quantity="few">نشط منذ %1$d أيام</item>
<item quantity="many">نشط منذ %1$d يومًا</item>
<item quantity="other">نشط منذ %1$d يوم</item>
</plurals>
<string name="session_age_date_desc">نشط %1$s</string>
<string name="history_load_more">تحميل المزيد من الجلسات…</string>
<string name="sessions_search_placeholder">البحث في الجلسات…</string>
<string name="sessions_search_empty_title">لا توجد نتائج</string>
@@ -628,6 +702,16 @@
<string name="profiles_show_hidden">إظهار الملفات الشخصية المخفية</string>
<string name="profiles_hide_hidden">إخفاء الملفات الشخصية المخفية</string>
<string name="profiles_hidden_badge">مخفي</string>
<string name="profiles_shared_gateway_title">بوابة مشتركة</string>
<string name="profiles_shared_gateway_badge">مشترك</string>
<plurals name="profiles_shared_gateway_count">
<item quantity="zero">لا تُخدم أي ملفات شخصية معًا</item>
<item quantity="one">يُخدم %1$d ملف شخصي معًا</item>
<item quantity="two">يُخدم ملفان شخصيان معًا</item>
<item quantity="few">تُخدم %1$d ملفات شخصية معًا</item>
<item quantity="many">تُخدم %1$d ملفًا شخصيًا معًا</item>
<item quantity="other">تُخدم %1$d ملفات شخصية معًا</item>
</plurals>
<string name="profiles_all_hidden_desc">جميع الملفات الشخصية مخفية حاليًا. انقر أدناه لإظهار الملفات الشخصية المخفية.</string>
<string name="profiles_content_desc_active">الملف الشخصي النشط</string>
<string name="profiles_title_edit_soul">تعديل ملف الـ Soul: %1$s</string>
@@ -924,6 +1008,8 @@
<string name="channels_action_test">اختبار</string>
<string name="channels_action_restart_now">إعادة التشغيل الآن</string>
<string name="channels_restart_banner">تم حفظ التغييرات. أعد تشغيل البوابة (Gateway) لتصبح سارية المفعول.</string>
<string name="channels_ingress_url">رابط الدخول</string>
<string name="channels_copy_ingress_url">نسخ رابط الدخول</string>
<string name="pairing_sec_pending">الموافقات المعلقة</string>
<string name="pairing_sec_approved">المستخدمون المعتمدون</string>
<string name="pairing_action_clear_all">مسح الكل</string>
@@ -1042,6 +1128,9 @@
<string name="channels_expiry_format">ينتهي في: %1$s</string>
<string name="channels_search_placeholder">البحث في القنوات…</string>
<string name="chat_attach_file">إرفاق ملف</string>
<string name="chat_attachment_camera">الكاميرا</string>
<string name="chat_attachment_photos">الصور</string>
<string name="chat_attachment_file">ملف</string>
<string name="chat_fast_mode_label">سريع</string>
<string name="chat_fast_mode_desc">الوضع السريع</string>
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
+66 -1
View File
@@ -187,7 +187,18 @@
<string name="settings_sec_chat">채팅</string>
<string name="settings_item_typing_effect">타이핑 효과</string>
<string name="settings_desc_typing_effect">새 메시지를 단어 단위로 애니메이션</string>
<string name="fullbleed_role_agent">어시스턴트</string>
<string name="settings_item_message_stats">메시지 통계</string>
<string name="settings_desc_message_stats">채팅에서 토큰 및 생성 성능 세부정보 표시</string>
<string name="settings_item_user_message_tokens">사용자 메시지 토큰</string>
<string name="settings_item_assistant_message_tokens">어시스턴트 메시지 토큰</string>
<string name="settings_item_tokens_per_second">초당 토큰 수 (TPS)</string>
<string name="chat_agent_status_typing">에이전트 입력 중</string>
<string name="chat_agent_status_thinking">생각 중…</string>
<string name="chat_agent_status_searching">검색 중…</string>
<string name="chat_agent_status_reading">파일 읽는 중…</string>
<string name="chat_agent_status_browsing">탐색 중…</string>
<string name="chat_agent_status_running_command">명령 실행 중…</string>
<string name="chat_agent_status_using_tool">도구 사용 중…</string>
<string name="settings_item_typing_delay">지연: %1$d ms</string>
<string name="settings_delay_10ms">10 ms</string>
<string name="settings_delay_100ms">100 ms</string>
@@ -364,6 +375,20 @@
<string name="system_action_update_now">지금 업데이트</string>
<string name="system_update_confirm_title">Hermes 업데이트?</string>
<string name="system_update_confirm_desc">Hermes 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string>
<string name="system_migration_title">게이트웨이 마이그레이션</string>
<string name="system_migration_description">프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요.</string>
<string name="system_migration_profiles">프로필</string>
<string name="system_migration_automatic_eligibility">자동 마이그레이션 가능 여부</string>
<string name="system_migration_yes">예</string>
<string name="system_migration_no">아니요</string>
<string name="system_migration_already_multiplexed">이미 멀티플렉싱됨</string>
<string name="system_migration_interrupted">이전 마이그레이션이 중단되었습니다. 다시 시도하기 전에 계획을 확인하세요.</string>
<string name="system_migration_blockers">차단 요인</string>
<string name="system_migration_notices">알림</string>
<string name="system_migration_start">마이그레이션 시작</string>
<string name="system_migration_confirm_title">게이트웨이 마이그레이션을 시작할까요?</string>
<string name="system_migration_confirm_description">독립 프로필 게이트웨이를 중지하고 기본 게이트웨이를 공유 멀티플렉서로 재시작합니다. 작업은 백그라운드에서 실행됩니다.</string>
<string name="system_migration_progress_title">게이트웨이 마이그레이션</string>
<string name="system_hooks_modal_title">새 셸 훅</string>
<string name="system_hooks_field_event">이벤트</string>
<string name="system_hooks_field_command">명령어 (절대 경로)</string>
@@ -393,16 +418,45 @@
<string name="model_label_available">사용 가능한 모델:</string>
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
<string name="content_desc_active_model">활성 모델</string>
<string name="content_desc_expand_provider">%1$s 모델 펼치기</string>
<string name="content_desc_collapse_provider">%1$s 모델 접기</string>
<string name="model_section_pinned">고정된 모델</string>
<string name="model_confirm_continue">계속</string>
<string name="content_desc_unpin_model">모델 고정 해제</string>
<string name="content_desc_pinned_section_toggle">고정 섹션 표시 전환</string>
<plurals name="model_provider_count">
<item quantity="one">%1$d개 모델</item>
<item quantity="other">%1$d개 모델</item>
</plurals>
<!-- Sessions (History) Screen -->
<string name="history_empty_title">히스토리 없음</string>
<string name="history_empty_desc">이전 채팅 세션이 여기에 표시됩니다.</string>
<string name="history_untitled">제목 없음</string>
<string name="history_message_count">%1$d개 메시지</string>
<plurals name="sessions_card_message_count">
<item quantity="one">%1$d개 메시지</item>
<item quantity="other">%1$d개 메시지</item>
</plurals>
<string name="sessions_project_home">홈</string>
<string name="session_age_now">지금</string>
<string name="session_age_minutes">%1$d분</string>
<string name="session_age_hours">%1$d시간</string>
<string name="session_age_days">%1$d일</string>
<string name="session_age_now_desc">방금 활동</string>
<plurals name="session_age_minutes_desc">
<item quantity="one">%1$d분 전 활동</item>
<item quantity="other">%1$d분 전 활동</item>
</plurals>
<plurals name="session_age_hours_desc">
<item quantity="one">%1$d시간 전 활동</item>
<item quantity="other">%1$d시간 전 활동</item>
</plurals>
<plurals name="session_age_days_desc">
<item quantity="one">%1$d일 전 활동</item>
<item quantity="other">%1$d일 전 활동</item>
</plurals>
<string name="session_age_date_desc">%1$s 활동</string>
<string name="history_load_more">더 불러오기…</string>
<!-- Sessions Stats -->
@@ -484,6 +538,12 @@
<string name="profiles_show_hidden">숨겨진 프로필 표시</string>
<string name="profiles_hide_hidden">숨겨진 프로필 숨기기</string>
<string name="profiles_hidden_badge">숨김</string>
<string name="profiles_shared_gateway_title">공유 게이트웨이</string>
<string name="profiles_shared_gateway_badge">공유</string>
<plurals name="profiles_shared_gateway_count">
<item quantity="one">프로필 %1$d개가 함께 제공됨</item>
<item quantity="other">프로필 %1$d개가 함께 제공됨</item>
</plurals>
<string name="profiles_all_hidden_desc">모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요.</string>
<string name="profiles_content_desc_active">활성 프로필</string>
<string name="profiles_title_edit_soul">소울 프로필 편집: %1$s</string>
@@ -743,6 +803,8 @@
<string name="channels_action_test">테스트</string>
<string name="channels_action_restart_now">지금 재시작</string>
<string name="channels_restart_banner">변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다.</string>
<string name="channels_ingress_url">수신 URL</string>
<string name="channels_copy_ingress_url">수신 URL 복사</string>
<!-- Pairing Screen -->
<string name="pairing_sec_pending">승인 대기</string>
@@ -894,6 +956,9 @@
<string name="subagent_status_cancelled">취소됨</string>
<string name="subagent_status_failed">실패</string>
<string name="subagent_duration">%1$.1f초</string>
<string name="chat_attachment_camera">카메라</string>
<string name="chat_attachment_photos">사진</string>
<string name="chat_attachment_file">파일</string>
<string name="chat_fast_mode_label">빠름</string>
<string name="chat_fast_mode_desc">빠른 모드</string>
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
+66 -1
View File
@@ -184,7 +184,18 @@
<string name="settings_sec_chat">对话</string>
<string name="settings_item_typing_effect">打字效果</string>
<string name="settings_desc_typing_effect">逐字显示新消息动画</string>
<string name="fullbleed_role_agent">助手</string>
<string name="settings_item_message_stats">消息统计</string>
<string name="settings_desc_message_stats">在聊天中显示令牌和生成性能详情</string>
<string name="settings_item_user_message_tokens">用户消息令牌</string>
<string name="settings_item_assistant_message_tokens">助手消息令牌</string>
<string name="settings_item_tokens_per_second">每秒令牌数 (TPS)</string>
<string name="chat_agent_status_typing">助手正在输入</string>
<string name="chat_agent_status_thinking">正在思考…</string>
<string name="chat_agent_status_searching">正在搜索…</string>
<string name="chat_agent_status_reading">正在读取文件…</string>
<string name="chat_agent_status_browsing">正在浏览…</string>
<string name="chat_agent_status_running_command">正在运行命令…</string>
<string name="chat_agent_status_using_tool">正在使用工具…</string>
<string name="settings_item_typing_delay">延迟:%1$d 毫秒</string>
<string name="settings_delay_10ms">10 毫秒</string>
<string name="settings_delay_100ms">100 毫秒</string>
@@ -413,6 +424,20 @@
<string name="system_gateway_start">启动</string>
<string name="system_gateway_restart">重启</string>
<string name="system_gateway_stop">停止</string>
<string name="system_migration_title">网关迁移</string>
<string name="system_migration_description">将配置文件网关迁移到共享多路复用器前,先查看服务器返回的迁移计划。</string>
<string name="system_migration_profiles">配置文件</string>
<string name="system_migration_automatic_eligibility">自动迁移资格</string>
<string name="system_migration_yes">是</string>
<string name="system_migration_no">否</string>
<string name="system_migration_already_multiplexed">已使用多路复用</string>
<string name="system_migration_interrupted">上一次迁移被中断。重试前请查看计划。</string>
<string name="system_migration_blockers">阻碍</string>
<string name="system_migration_notices">注意事项</string>
<string name="system_migration_start">开始迁移</string>
<string name="system_migration_confirm_title">开始网关迁移?</string>
<string name="system_migration_confirm_description">这会停止独立的配置文件网关,并将默认网关重启为共享多路复用器。操作将在后台运行。</string>
<string name="system_migration_progress_title">网关迁移</string>
<string name="memory_active">当前:%1$s</string>
<string name="memory_builtin">内置记忆</string>
<string name="memory_builtin_sizes">MEMORY.md:%1$s · USER.md:%2$s</string>
@@ -494,13 +519,42 @@
<string name="model_label_available">可用模型:</string>
<string name="model_no_models">没有可用的模型,或提供商需要认证。</string>
<string name="content_desc_active_model">当前模型</string>
<string name="content_desc_expand_provider">展开 %1$s 模型</string>
<string name="content_desc_collapse_provider">折叠 %1$s 模型</string>
<string name="model_section_pinned">固定模型</string>
<string name="content_desc_unpin_model">取消固定模型</string>
<string name="content_desc_pinned_section_toggle">切换固定区显示</string>
<plurals name="model_provider_count">
<item quantity="one">%1$d 个模型</item>
<item quantity="other">%1$d 个模型</item>
</plurals>
<string name="history_empty_title">暂无历史</string>
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string>
<string name="history_untitled">未命名</string>
<string name="history_message_count">%1$d 条消息</string>
<plurals name="sessions_card_message_count">
<item quantity="one">%1$d 条消息</item>
<item quantity="other">%1$d 条消息</item>
</plurals>
<string name="sessions_project_home">主页</string>
<string name="session_age_now">刚刚</string>
<string name="session_age_minutes">%1$d分钟</string>
<string name="session_age_hours">%1$d小时</string>
<string name="session_age_days">%1$d天</string>
<string name="session_age_now_desc">刚刚活跃</string>
<plurals name="session_age_minutes_desc">
<item quantity="one">%1$d 分钟前活跃</item>
<item quantity="other">%1$d 分钟前活跃</item>
</plurals>
<plurals name="session_age_hours_desc">
<item quantity="one">%1$d 小时前活跃</item>
<item quantity="other">%1$d 小时前活跃</item>
</plurals>
<plurals name="session_age_days_desc">
<item quantity="one">%1$d 天前活跃</item>
<item quantity="other">%1$d 天前活跃</item>
</plurals>
<string name="session_age_date_desc">%1$s 活跃</string>
<string name="history_load_more">加载更多会话…</string>
<string name="sessions_search_placeholder">搜索会话…</string>
<string name="sessions_search_empty_title">无结果</string>
@@ -619,6 +673,12 @@
<string name="profiles_show_hidden">显示隐藏的配置文件</string>
<string name="profiles_hide_hidden">隐藏已隐藏的配置文件</string>
<string name="profiles_hidden_badge">已隐藏</string>
<string name="profiles_shared_gateway_title">共享网关</string>
<string name="profiles_shared_gateway_badge">共享</string>
<plurals name="profiles_shared_gateway_count">
<item quantity="one">%1$d 个配置文件共同服务</item>
<item quantity="other">%1$d 个配置文件共同服务</item>
</plurals>
<string name="profiles_all_hidden_desc">所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。</string>
<string name="profiles_content_desc_active">当前配置</string>
<string name="profiles_title_edit_soul">编辑 Soul 配置:%1$s</string>
@@ -915,6 +975,8 @@
<string name="channels_action_test">测试</string>
<string name="channels_action_restart_now">立即重启</string>
<string name="channels_restart_banner">更改已保存。重启网关后生效。</string>
<string name="channels_ingress_url">入口 URL</string>
<string name="channels_copy_ingress_url">复制入口 URL</string>
<string name="pairing_sec_pending">待批准</string>
<string name="pairing_sec_approved">已批准用户</string>
<string name="pairing_action_clear_all">全部清除</string>
@@ -1033,6 +1095,9 @@
<string name="channels_expiry_format">过期时间:%1$s</string>
<string name="channels_search_placeholder">搜索渠道…</string>
<string name="chat_attach_file">附加文件</string>
<string name="chat_attachment_camera">相机</string>
<string name="chat_attachment_photos">照片</string>
<string name="chat_attachment_file">文件</string>
<string name="chat_fast_mode_label">快速</string>
<string name="chat_fast_mode_desc">快速模式</string>
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
+87 -1
View File
@@ -208,7 +208,18 @@
<string name="settings_sec_chat">Chat</string>
<string name="settings_item_typing_effect">Typing Effect</string>
<string name="settings_desc_typing_effect">Animate new messages word by word</string>
<string name="fullbleed_role_agent">Assistant</string>
<string name="settings_item_message_stats">Message stats</string>
<string name="settings_desc_message_stats">Show token and generation performance details in chat.</string>
<string name="settings_item_user_message_tokens">User message tokens</string>
<string name="settings_item_assistant_message_tokens">Assistant message tokens</string>
<string name="settings_item_tokens_per_second">Tokens per second (TPS)</string>
<string name="chat_agent_status_typing">Agent is typing</string>
<string name="chat_agent_status_thinking">Thinking…</string>
<string name="chat_agent_status_searching">Searching…</string>
<string name="chat_agent_status_reading">Reading file…</string>
<string name="chat_agent_status_browsing">Browsing…</string>
<string name="chat_agent_status_running_command">Running command…</string>
<string name="chat_agent_status_using_tool">Using tool…</string>
<string name="settings_item_typing_delay">Delay: %1$d ms</string>
<string name="settings_delay_10ms">10 ms</string>
<string name="settings_delay_100ms">100 ms</string>
@@ -342,6 +353,25 @@
<string name="kanban_live">Live</string>
<string name="kanban_offline">Offline</string>
<string name="kanban_no_tasks">No tasks</string>
<string name="kanban_priority">Priority</string>
<string name="kanban_priority_normal">0 (Normal)</string>
<string name="kanban_priority_med">1 (Med)</string>
<string name="kanban_priority_high">2 (High)</string>
<string name="kanban_priority_urgent">3 (Urgent)</string>
<string name="kanban_assignee">Assignee</string>
<string name="kanban_assignee_unassigned">Default (Unassigned)</string>
<string name="kanban_assignee_parked">Parked (No worker)</string>
<string name="kanban_goal_mode">Autonomous Goal Mode</string>
<string name="kanban_goal_mode_desc">Judge validates task completion across multi-turn iterations</string>
<string name="kanban_advanced_options">Advanced Options</string>
<string name="kanban_parent_task">Parent Task</string>
<string name="kanban_parent_none">None</string>
<string name="kanban_model_override">Model Override</string>
<string name="kanban_provider_override">Provider Override</string>
<string name="kanban_reasoning_effort">Reasoning Effort</string>
<string name="kanban_workspace_kind">Workspace Kind</string>
<string name="kanban_skills">Skills</string>
<string name="kanban_target_column">Column</string>
<string name="action_add">Add</string>
<!-- Skills Screen -->
<string name="skills_status_all">All Statuses</string>
@@ -468,6 +498,20 @@
<string name="system_gateway_start">Start</string>
<string name="system_gateway_restart">Restart</string>
<string name="system_gateway_stop">Stop</string>
<string name="system_migration_title">Gateway migration</string>
<string name="system_migration_description">Review the backend migration plan before moving profile gateways onto one shared multiplexer.</string>
<string name="system_migration_profiles">Profiles</string>
<string name="system_migration_automatic_eligibility">Automatic eligibility</string>
<string name="system_migration_yes">Yes</string>
<string name="system_migration_no">No</string>
<string name="system_migration_already_multiplexed">Already multiplexed</string>
<string name="system_migration_interrupted">An earlier migration was interrupted. Review the plan before retrying.</string>
<string name="system_migration_blockers">Blockers</string>
<string name="system_migration_notices">Notices</string>
<string name="system_migration_start">Start migration</string>
<string name="system_migration_confirm_title">Start gateway migration?</string>
<string name="system_migration_confirm_description">This stops standalone profile gateways and restarts the default gateway as a shared multiplexer. The operation runs in the background.</string>
<string name="system_migration_progress_title">Gateway migration</string>
<!-- Memory section -->
<string name="memory_active">Active: %1$s</string>
@@ -567,15 +611,45 @@
<string name="model_label_available">Available Models:</string>
<string name="model_no_models">No models reported or provider needs authentication.</string>
<string name="content_desc_active_model">Active Model</string>
<string name="content_desc_expand_provider">Expand %1$s models</string>
<string name="content_desc_collapse_provider">Collapse %1$s models</string>
<string name="model_section_pinned">Pinned Models</string>
<string name="content_desc_unpin_model">Unpin model</string>
<string name="content_desc_pinned_section_toggle">Toggle pinned section visibility</string>
<plurals name="model_provider_count">
<item quantity="one">%1$d model</item>
<item quantity="other">%1$d models</item>
</plurals>
<!-- Sessions (History) Screen -->
<string name="history_empty_title">No history</string>
<string name="history_empty_desc">Previous chat sessions from Hermes will appear here.</string>
<string name="history_untitled">Untitled</string>
<string name="history_message_count">%1$d messages</string>
<plurals name="sessions_card_message_count">
<item quantity="one">%1$d message</item>
<item quantity="other">%1$d messages</item>
</plurals>
<string name="sessions_project_home">Home</string>
<!-- Short activity age on history cards (m = minutes, h = hours, d = days) -->
<string name="session_age_now">now</string>
<string name="session_age_minutes">%1$dm</string>
<string name="session_age_hours">%1$dh</string>
<string name="session_age_days">%1$dd</string>
<string name="session_age_now_desc">Active just now</string>
<plurals name="session_age_minutes_desc">
<item quantity="one">Active %1$d minute ago</item>
<item quantity="other">Active %1$d minutes ago</item>
</plurals>
<plurals name="session_age_hours_desc">
<item quantity="one">Active %1$d hour ago</item>
<item quantity="other">Active %1$d hours ago</item>
</plurals>
<plurals name="session_age_days_desc">
<item quantity="one">Active %1$d day ago</item>
<item quantity="other">Active %1$d days ago</item>
</plurals>
<string name="session_age_date_desc">Active %1$s</string>
<string name="history_load_more">Load more sessions…</string>
<!-- Sessions Stats -->
@@ -714,6 +788,12 @@
<string name="profiles_show_hidden">Show hidden profiles</string>
<string name="profiles_hide_hidden">Hide hidden profiles</string>
<string name="profiles_hidden_badge">Hidden</string>
<string name="profiles_shared_gateway_title">Shared gateway</string>
<string name="profiles_shared_gateway_badge">Shared</string>
<plurals name="profiles_shared_gateway_count">
<item quantity="one">%1$d profile served together</item>
<item quantity="other">%1$d profiles served together</item>
</plurals>
<string name="profiles_all_hidden_desc">All profiles are currently hidden. Tap below to show hidden profiles.</string>
<string name="profiles_content_desc_active">Active Profile</string>
<string name="profiles_title_edit_soul">Edit Soul Profile: %1$s</string>
@@ -1052,6 +1132,8 @@
<string name="channels_action_test">Test</string>
<string name="channels_action_restart_now">Restart now</string>
<string name="channels_restart_banner">Changes saved. Restart the gateway for them to take effect.</string>
<string name="channels_ingress_url">Ingress URL</string>
<string name="channels_copy_ingress_url">Copy ingress URL</string>
<!-- Pairing Screen -->
<string name="pairing_sec_pending">Pending Approvals</string>
@@ -1191,9 +1273,13 @@
<string name="channels_expiry_format">Expires: %1$s</string>
<string name="channels_search_placeholder">Search channels…</string>
<string name="chat_attach_file">Attach file</string>
<string name="chat_attachment_camera">Camera</string>
<string name="chat_attachment_photos">Photos</string>
<string name="chat_attachment_file">File</string>
<string name="chat_fast_mode_label">Fast</string>
<string name="chat_fast_mode_desc">Fast mode</string>
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
<string name="chat_reasoning_menu_desc">Reasoning options</string>
<string name="chat_save_attachment">Save %1$s</string>
<string name="chat_switch_model_title">Switch model (this chat)</string>
<string name="config_clear_value">Clear value</string>
@@ -27,6 +27,7 @@ import com.m57.hermescontrol.data.model.MemoryResponse
import com.m57.hermescontrol.data.model.MemoryStats
import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
import com.m57.hermescontrol.data.model.MessagingPlatformUpdateResponse
import com.m57.hermescontrol.data.model.MoaConfigResponse
import com.m57.hermescontrol.data.model.MoaModelSlot
import com.m57.hermescontrol.data.model.ModelOptionsResponse
@@ -1118,7 +1119,8 @@ class E2eIntegrationTest {
platforms = listOf(platform),
),
)
coEvery { mockApiService.configurePlatform("telegram", any()) } returns Response.success(Unit)
coEvery { mockApiService.configurePlatform("telegram", any()) } returns
Response.success(MessagingPlatformUpdateResponse(ok = true))
val viewModel = ChannelsViewModel()
viewModel.loadPlatforms()
@@ -1205,10 +1207,9 @@ class E2eIntegrationTest {
runTest {
val board = KanbanBoard("board-1", "Backlog", null)
val task = KanbanTask("task-1", "Do laundry", null, "todo", null)
coEvery { mockApiService.getKanbanBoards() } returns
coEvery { mockApiService.getBoards(any()) } returns
Response.success(KanbanBoardsResponse(listOf(board), "board-1"))
coEvery { mockApiService.switchKanbanBoard("board-1") } returns Response.success(Unit)
coEvery { mockApiService.getKanbanBoard() } returns
coEvery { mockApiService.getBoard("board-1", any(), any()) } returns
Response.success(KanbanBoardResponse(listOf(KanbanColumn("todo", listOf(task))), null, null))
val viewModel = KanbanViewModel(eventsClientProvider = { mockk<KanbanEventsClient>(relaxed = true) })
@@ -1230,11 +1231,16 @@ class E2eIntegrationTest {
// Desktop-style transition: todo -> ready is a direct write the
// backend accepts; the VM PATCHes the target status.
coEvery {
mockApiService.updateKanbanTask(
"task-1",
mapOf("status" to "ready"),
mockApiService.updateTask(
eq("task-1"),
eq("board-1"),
any(),
)
} returns
Response.success(
com.m57.hermescontrol.data.model
.UpdateTaskResponse(task = task.copy(status = "ready")),
)
} returns Response.success(Unit)
viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY)
advanceUntilIdle()
assertEquals(
@@ -1243,9 +1249,13 @@ class E2eIntegrationTest {
.status,
)
// Create path: the wrapped {"task": ...} response must not
// break deserialization — the API layer reads it as Unit.
coEvery { mockApiService.createKanbanTask(any(), any()) } returns Response.success(Unit)
// Create path: returns CreateTaskResponse
coEvery { mockApiService.createTask(any(), any()) } returns
Response.success(
com.m57.hermescontrol.data.model.CreateTaskResponse(
task = KanbanTask("task-2", "Fresh task", status = "todo"),
),
)
viewModel.createTask("Fresh task", null, "todo")
advanceUntilIdle()
assertEquals("Task created successfully", viewModel.uiState.value.toastMessage)
@@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavKey
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -21,12 +22,14 @@ class NavigationControllerTest {
// Start fresh — no pinned back stack from a previous test
NavigationController.backStack = null
NavigationController.consumePendingSessionId()
NavigationController.consumePendingNewChatNavigation()
}
@After
fun tearDown() {
NavigationController.backStack = null
NavigationController.consumePendingSessionId()
NavigationController.consumePendingNewChatNavigation()
}
// ── Dedup guard: navigateTo with same key ──────────────────────────────
@@ -119,6 +122,18 @@ class NavigationControllerTest {
assertNull(NavigationController.consumePendingSessionId())
}
@Test
fun `openNewChat navigates to chat and exposes a consumable request`() {
val backStack = NavBackStack<NavKey>(HistoryScreen)
NavigationController.backStack = backStack
NavigationController.openNewChat()
assertEquals(ChatScreen, backStack.lastOrNull())
assertNotNull(NavigationController.consumePendingNewChatNavigation())
assertNull(NavigationController.consumePendingNewChatNavigation())
}
@Test
fun `openChatSession ignores blank ids`() {
val backStack = NavBackStack<NavKey>(HistoryScreen)
@@ -0,0 +1,174 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayMultiplexSerializationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `status decodes shared gateway profile names`() {
val response =
json.decodeFromString<StatusResponse>(
"""
{
"gateway_running": true,
"gateway_shared_with": ["default", "coding"]
}
""".trimIndent(),
)
assertEquals(listOf("default", "coding"), response.gatewaySharedWith)
}
@Test
fun `status accepts null and absent shared gateway field`() {
val nullResponse = json.decodeFromString<StatusResponse>("""{"gateway_shared_with": null}""")
val oldResponse = json.decodeFromString<StatusResponse>("""{"gateway_running": true}""")
assertNull(nullResponse.gatewaySharedWith)
assertNull(oldResponse.gatewaySharedWith)
}
@Test
fun `messaging platform decodes backend ingress url`() {
val platform =
json.decodeFromString<MessagingPlatform>(
"""
{
"id": "telegram",
"name": "Telegram",
"enabled": true,
"configured": true,
"ingress_url": "/some/backend/value"
}
""".trimIndent(),
)
assertEquals("/some/backend/value", platform.ingressUrl)
}
@Test
fun `messaging platform update decodes hot served and old response safely`() {
val hotServed =
json.decodeFromString<MessagingPlatformUpdateResponse>(
"""{"ok": true, "platform": "telegram", "hot_served": true}""",
)
val oldResponse =
json.decodeFromString<MessagingPlatformUpdateResponse>("""{"ok": true, "platform": "telegram"}""")
assertTrue(hotServed.ok)
assertEquals("telegram", hotServed.platform)
assertTrue(hotServed.hotServed)
assertFalse(oldResponse.hotServed)
}
@Test
fun `migration plan decodes operational states and ignores low level fields in ui`() {
val plan =
json.decodeFromString<GatewayMigrationPlan>(
"""
{
"default_home": "/home/hermes",
"profiles": [
{
"profile": "default",
"home": "/home/hermes",
"pid": 1234,
"service": {"kind": "systemd", "system": false},
"services": [
{"kind": "systemd", "system": false},
{"kind": "systemd", "system": true}
],
"uid": 1000,
"runtime_home": "/home/hermes"
},
{"profile": "coding"}
],
"multiplex_flag_on": false,
"live_served": null,
"already_multiplexed": false,
"interrupted": true,
"blockers": ["duplicate credential"],
"notices": ["callback URL changes"],
"eligible": false,
"command": "hermes gateway migrate --multiplex"
}
""".trimIndent(),
)
assertEquals(listOf("default", "coding"), plan.profiles.map { it.profile })
assertEquals(1234, plan.profiles.first().pid)
assertEquals(
2,
plan.profiles
.first()
.services.size,
)
assertEquals(
"systemd",
plan.profiles
.first()
.services[0]
.kind,
)
assertFalse(
plan.profiles
.first()
.services[0]
.system ?: true,
)
assertTrue(
plan.profiles
.first()
.services[1]
.system == true,
)
assertTrue(plan.interrupted)
assertEquals(listOf("duplicate credential"), plan.blockers)
assertEquals(listOf("callback URL changes"), plan.notices)
assertFalse(plan.eligible)
}
@Test
fun `migration profile service fields remain optional`() {
val emptyServices =
json.decodeFromString<GatewayMigrationPlan>(
"""
{
"profiles": [
{"profile": "default", "service": null, "services": []}
]
}
""".trimIndent(),
)
val missingServices =
json.decodeFromString<GatewayMigrationPlan>(
"""
{
"profiles": [
{"profile": "default"}
]
}
""".trimIndent(),
)
assertNull(emptyServices.profiles.first().service)
assertTrue(
emptyServices.profiles
.first()
.services
.isEmpty(),
)
assertTrue(
missingServices.profiles
.first()
.services
.isEmpty(),
)
}
}
@@ -0,0 +1,292 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.encodeToString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class KanbanSerializationTest {
private val json = OkHttpProvider.json
@Test
fun testBoardResponseSerialization() {
val raw =
"""
{
"columns": [
{
"name": "todo",
"tasks": [
{
"id": "t_101",
"title": "Build mobile Kanban",
"body": "Full desktop parity",
"status": "todo",
"assignee": "researcher",
"priority": 1,
"tenant": "mobile",
"created_at": 1718000000,
"latest_summary": "Initial draft ready",
"comment_count": 3,
"link_counts": { "parents": 1, "children": 2 },
"progress": { "done": 1, "total": 2 },
"warnings": { "count": 1, "highest_severity": "warning" },
"started_at": 1718000100,
"worker_pid": 12345,
"last_heartbeat_at": 1718000200
}
]
}
],
"assignees": ["researcher", "reviewer"],
"tenants": ["mobile"],
"latest_event_id": 42,
"now": 1718000500
}
""".trimIndent()
val decoded = json.decodeFromString<KanbanBoardResponse>(raw)
assertEquals(1, decoded.columns.size)
assertEquals("todo", decoded.columns[0].name)
val task = decoded.columns[0].tasks[0]
assertEquals("t_101", task.id)
assertEquals("Build mobile Kanban", task.title)
assertEquals("Full desktop parity", task.body)
assertEquals("Full desktop parity", task.description)
assertEquals("researcher", task.assignee)
assertEquals("researcher", task.assignedTo)
assertEquals(1, task.priority)
assertEquals(3, task.commentCount)
assertEquals(1, task.linkCounts?.parents)
assertEquals(2, task.linkCounts?.children)
assertEquals(1, task.progress?.done)
assertEquals(2, task.progress?.total)
assertEquals(1, task.warnings?.count)
assertEquals("warning", task.warnings?.highestSeverity)
assertEquals(12345, task.workerPid)
assertEquals(42L, decoded.latestEventId)
assertEquals(1718000500L, decoded.now)
}
@Test
fun testTaskDetailResponseSerialization() {
val raw =
"""
{
"task": {
"id": "t_202",
"title": "Fix memory leak",
"body": "Detailed description",
"status": "running",
"assignee": "debugger",
"result": "Resolved leak",
"created_by": "m57",
"model_override": "gpt-4o",
"provider_override": "openai",
"reasoning_effort": "high",
"completed_at": 1718000900,
"last_failure_error": null,
"workspace_kind": "worktree",
"workspace_path": "/tmp/wt-1",
"branch_name": "feat/leak-fix",
"consecutive_failures": 0,
"diagnostics": [
{
"kind": "stale_heartbeat",
"severity": "warning",
"title": "Worker heartbeat missing",
"detail": "Last ping was 3 minutes ago",
"count": 1,
"last_seen_at": 1718000850,
"actions": [
{ "kind": "reclaim", "label": "Reclaim task", "suggested": true }
]
}
]
},
"comments": [
{
"id": 1,
"author": "mobile",
"body": "Steering comment",
"created_at": 1718000800
}
],
"events": [
{
"id": 10,
"kind": "status",
"payload": { "status": "running" },
"created_at": 1718000700
}
],
"attachments": [
{
"id": 5,
"task_id": "t_202",
"filename": "leak.png",
"content_type": "image/png",
"size": 1024,
"uploaded_by": "mobile",
"stored_path": "/files/leak.png",
"created_at": 1718000750
}
],
"links": {
"parents": ["t_100"],
"children": ["t_300"]
},
"child_results": [
{
"id": "t_300",
"title": "Child task",
"status": "done",
"latest_summary": "Finished child",
"result": "OK"
}
],
"runs": [
{
"id": 7,
"profile": "debugger",
"status": "done",
"outcome": "completed",
"summary": "Completed attempt 1",
"worker_pid": 9876,
"started_at": 1718000500,
"ended_at": 1718000900
}
]
}
""".trimIndent()
val decoded = json.decodeFromString<KanbanTaskDetailResponse>(raw)
assertEquals("t_202", decoded.task.id)
assertEquals("gpt-4o", decoded.task.modelOverride)
assertEquals("high", decoded.task.reasoningEffort)
assertEquals(1, decoded.task.diagnostics.size)
assertEquals("stale_heartbeat", decoded.task.diagnostics[0].kind)
assertTrue(
decoded.task.diagnostics[0]
.actions[0]
.suggested,
)
assertEquals(1, decoded.comments.size)
assertEquals("Steering comment", decoded.comments[0].body)
assertEquals(1, decoded.events.size)
assertEquals("status", decoded.events[0].kind)
assertEquals(1, decoded.attachments?.size)
assertEquals("leak.png", decoded.attachments?.get(0)?.filename)
assertEquals(listOf("t_100"), decoded.links.parents)
assertEquals(listOf("t_300"), decoded.links.children)
assertEquals(1, decoded.childResults.size)
assertEquals(1, decoded.runs.size)
assertEquals(7L, decoded.runs[0].id)
assertEquals("completed", decoded.runs[0].outcome)
}
@Test
fun testCreateAndUpdatePayloadSerialization() {
val createBody =
CreateTaskBody(
title = "New Task",
body = "Task description",
assignee = "default",
priority = 2,
parents = listOf("t_001"),
triage = true,
modelOverride = "claude-3-5-sonnet",
reasoningEffort = "none",
)
val createJson = json.encodeToString(createBody)
assertTrue(createJson.contains("\"title\":\"New Task\""))
assertTrue(createJson.contains("\"triage\":true"))
assertTrue(createJson.contains("\"parents\":[\"t_001\"]"))
assertTrue(createJson.contains("\"reasoning_effort\":\"none\""))
val updateBody =
UpdateTaskBody(
status = "blocked",
blockReason = "Waiting for credentials",
clearModelOverride = true,
clearReasoningEffort = true,
)
val updateJson = json.encodeToString(updateBody)
assertTrue(updateJson.contains("\"status\":\"blocked\""))
assertTrue(updateJson.contains("\"block_reason\":\"Waiting for credentials\""))
assertTrue(updateJson.contains("\"clear_model_override\":true"))
assertTrue(updateJson.contains("\"clear_reasoning_effort\":true"))
}
@Test
fun testBulkAndSettingsSerialization() {
val bulk =
BulkTasksBody(
ids = listOf("t_1", "t_2"),
status = "ready",
reclaimFirst = true,
)
val bulkJson = json.encodeToString(bulk)
assertTrue(bulkJson.contains("\"ids\":[\"t_1\",\"t_2\"]"))
assertTrue(bulkJson.contains("\"reclaim_first\":true"))
val orchestrationRaw =
"""
{
"orchestrator_profile": "lead",
"default_assignee": "coder",
"auto_decompose": true,
"auto_promote_children": false,
"resolved_orchestrator_profile": "lead",
"resolved_default_assignee": "coder",
"active_profile": "default"
}
""".trimIndent()
val orchestration = json.decodeFromString<OrchestrationSettings>(orchestrationRaw)
assertEquals("lead", orchestration.orchestratorProfile)
assertEquals("coder", orchestration.defaultAssignee)
assertTrue(orchestration.autoDecompose)
assertFalse(orchestration.autoPromoteChildren)
}
@Test
fun testWorkerLogAndEstimateSerialization() {
val logRaw =
"""
{
"task_id": "t_999",
"path": "/logs/worker.log",
"exists": true,
"size_bytes": 4096,
"content": "Running tests...\nAll passed!",
"truncated": false
}
""".trimIndent()
val log = json.decodeFromString<WorkerLog>(logRaw)
assertEquals("t_999", log.taskId)
assertTrue(log.exists)
assertEquals(4096L, log.sizeBytes)
assertEquals("Running tests...\nAll passed!", log.content)
assertFalse(log.truncated)
val estRaw =
"""
{
"ok": true,
"est_tokens": 15000,
"complexity": "M",
"rationale": "Multi-file changes needed",
"model": "auxiliary"
}
""".trimIndent()
val estimate = json.decodeFromString<TaskEstimate>(estRaw)
assertTrue(estimate.ok)
assertEquals(15000, estimate.estTokens)
assertEquals("M", estimate.complexity)
assertEquals("Multi-file changes needed", estimate.rationale)
}
}
@@ -0,0 +1,67 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.remote.OkHttpProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Wire-shape tests for the session workspace fields and the `projects.list` RPC result. */
class ProjectModelsTest {
private val json = OkHttpProvider.json
@Test
fun `session list row decodes its workspace`() {
val session =
json.decodeFromString<SessionInfo>(
"""{"id":"s1","cwd":"/srv/app/src","git_repo_root":"/srv/app","git_branch":"main"}""",
)
assertEquals("/srv/app/src", session.cwd)
assertEquals("/srv/app", session.git_repo_root)
}
@Test
fun `session list row without a workspace decodes nulls`() {
val session = json.decodeFromString<SessionInfo>("""{"id":"s1"}""")
assertNull(session.cwd)
assertNull(session.git_repo_root)
}
@Test
fun `projects list payload decodes folders, color and archived`() {
val response =
json.decodeFromString<ProjectsListResponse>(
"""
{"projects":[
{"id":"p_1","slug":"app","name":"App","description":null,"icon":"rocket",
"color":"hsl(120 68% 58%)","board_slug":null,"primary_path":"/srv/app",
"archived":false,"created_at":1787590315,
"folders":[{"path":"/srv/app","label":null,"is_primary":true,"added_at":1787590315}]},
{"id":"p_2","slug":"old","name":"Old","archived":1,"folders":[]}
],"active_id":"p_1"}
""".trimIndent(),
)
val app = response.projects[0]
assertEquals("App", app.name)
assertEquals("hsl(120 68% 58%)", app.color)
assertEquals("/srv/app", app.primaryPath)
assertEquals(listOf("/srv/app"), app.folders.map { it.path })
assertTrue(app.folders[0].isPrimary == true)
assertFalse(app.isArchived)
assertTrue(response.projects[1].isArchived)
assertEquals("p_1", response.activeId)
}
@Test
fun `project with only an id and name decodes defaults`() {
val project = json.decodeFromString<ProjectInfo>("""{"id":"p_3","name":"Bare"}""")
assertNull(project.color)
assertTrue(project.folders.isEmpty())
assertFalse(project.isArchived)
}
}
@@ -1,5 +1,10 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
@@ -12,6 +17,120 @@ import org.junit.Test
* mirror the real wire shape.
*/
class UsageSnapshotResponseTest {
private fun completePayload(): Map<String, Any?> =
mapOf(
"model" to "provider/model",
"input" to 13_000L,
"output" to 2_700L,
"reasoning" to 1_100L,
"prompt" to 48_000L,
"completion" to 2_700L,
"total" to 50_700L,
"calls" to 5L,
"compressions" to 2,
"context_used" to 12_345L,
"context_max" to 200_000L,
"context_percent" to 6,
"context_source" to "compressor",
"context_estimated" to true,
"cache_hit_pct" to 42,
"cache_read" to 1_200L,
"cache_write" to 300L,
"avg_latency_s" to 0.75,
"avg_tps" to 42.5,
"active_subagents" to 1,
"dev_credits_spent_micros" to 9_000L,
"cost_usd" to 0.0125,
"cost_status" to "estimated",
"credits_lines" to listOf("line one", "line two"),
)
@Test
fun `complete direct payload maps every usage field`() {
val parsed = parseUsageSnapshot(completePayload())
assertEquals("provider/model", parsed?.model)
assertEquals(13_000L, parsed?.inputTokens)
assertEquals(2_700L, parsed?.outputTokens)
assertEquals(1_100L, parsed?.reasoningTokens)
assertEquals(48_000L, parsed?.promptTokens)
assertEquals(2_700L, parsed?.completionTokens)
assertEquals(50_700L, parsed?.totalTokens)
assertEquals(5L, parsed?.apiCalls)
assertEquals(2, parsed?.compressions)
assertEquals(12_345L, parsed?.contextUsed)
assertEquals(200_000L, parsed?.contextMax)
assertEquals(6, parsed?.contextPercent)
assertEquals("compressor", parsed?.contextSource)
assertEquals(true, parsed?.contextEstimated)
assertEquals(42, parsed?.cacheHitPercent)
assertEquals(1_200L, parsed?.cacheReadTokens)
assertEquals(300L, parsed?.cacheWriteTokens)
assertEquals(0.75, parsed?.avgLatencySeconds ?: 0.0, 0.001)
assertEquals(42.5, parsed?.avgTps ?: 0.0, 0.001)
assertEquals(1, parsed?.activeSubagents)
assertEquals(9_000L, parsed?.devCreditsSpentMicros)
assertEquals(0.0125, parsed?.costUsd ?: 0.0, 0.0001)
assertEquals("estimated", parsed?.costStatus)
assertEquals(listOf("line one", "line two"), parsed?.creditsLines)
}
@Test
fun `nested usage envelope and JsonObject map correctly`() {
val json =
Json.parseToJsonElement(
"""{"usage":{"model":"m","input":100,"output":200,"reasoning":50,"prompt":300,"completion":200,"total":300,"calls":2,"context_used":400,"context_max":1000,"avg_tps":12.5,"context_estimated":false,"credits_lines":["credit"]}}""",
) as JsonObject
val parsed = parseUsageSnapshot(json)
assertEquals("m", parsed?.model)
assertEquals(100L, parsed?.inputTokens)
assertEquals(200L, parsed?.outputTokens)
assertEquals(50L, parsed?.reasoningTokens)
assertEquals(2L, parsed?.apiCalls)
assertEquals(12.5, parsed?.avgTps ?: 0.0, 0.001)
assertEquals(false, parsed?.contextEstimated)
assertEquals(listOf("credit"), parsed?.creditsLines)
}
@Test
fun `numeric strings booleans and large Long values are preserved`() {
val parsed =
parseUsageSnapshot(
mapOf(
"input" to "2147483648",
"output" to JsonPrimitive("9223372036854770000"),
"context_estimated" to "true",
"avg_tps" to "42.25",
),
)
assertEquals(2_147_483_648L, parsed?.inputTokens)
assertEquals(9_223_372_036_854_770_000L, parsed?.outputTokens)
assertEquals(true, parsed?.contextEstimated)
assertEquals(42.25, parsed?.avgTps ?: 0.0, 0.001)
}
@Test
fun `missing and malformed optional fields remain unavailable`() {
val parsed =
parseUsageSnapshot(
buildJsonObject {
put("input", JsonPrimitive("not-a-number"))
put("context_estimated", JsonPrimitive("maybe"))
put("avg_tps", JsonPrimitive("NaN"))
put("credits_lines", JsonPrimitive("not-a-list"))
},
)
assertNull(parsed?.inputTokens)
assertNull(parsed?.outputTokens)
assertNull(parsed?.contextEstimated)
assertNull(parsed?.avgTps)
assertNull(parsed?.creditsLines)
}
@Test
fun `compressions parses from Double and Int`() {
assertEquals(2, parseUsageSnapshot(mapOf("compressions" to 2.0))?.compressions)
@@ -50,4 +169,63 @@ class UsageSnapshotResponseTest {
assertEquals(50000L, parsed?.totalTokens)
assertEquals(45.5, parsed?.avgTps ?: 0.0, 0.001)
}
@Test
fun `deltaFrom derives independent cumulative fields and copies rolling values`() {
val baseline =
UsageSnapshotResponse(
inputTokens = 10_000,
outputTokens = 2_000,
reasoningTokens = 800,
promptTokens = 40_000,
completionTokens = 2_000,
totalTokens = 42_000,
apiCalls = 3,
cacheReadTokens = 100,
cacheWriteTokens = 50,
costUsd = 1.0,
)
val final =
UsageSnapshotResponse(
inputTokens = 13_000,
outputTokens = 2_700,
reasoningTokens = 1_100,
promptTokens = 48_000,
completionTokens = 2_700,
totalTokens = 50_700,
apiCalls = 5,
cacheReadTokens = 180,
cacheWriteTokens = 75,
costUsd = 1.25,
avgTps = 42.5,
avgLatencySeconds = 0.75,
)
val turn = final.deltaFrom(baseline)
assertEquals(3_000L, turn.inputTokens)
assertEquals(700L, turn.outputTokens)
assertEquals(300L, turn.reasoningTokens)
assertEquals(8_000L, turn.promptTokens)
assertEquals(700L, turn.completionTokens)
assertEquals(8_700L, turn.totalTokens)
assertEquals(2L, turn.apiCalls)
assertEquals(80L, turn.cacheReadTokens)
assertEquals(25L, turn.cacheWriteTokens)
assertEquals(0.25, turn.costUsd ?: 0.0, 0.001)
assertEquals(42.5, turn.avgTps ?: 0.0, 0.001)
}
@Test
fun `deltaFrom treats missing reset and equal counters correctly`() {
val baseline = UsageSnapshotResponse(outputTokens = 5_000, inputTokens = 10, totalTokens = 10)
val reset = UsageSnapshotResponse(outputTokens = 100, inputTokens = 20, totalTokens = 20)
val equal = UsageSnapshotResponse(outputTokens = 5_000, inputTokens = 10, totalTokens = 10)
assertNull(reset.deltaFrom(baseline).outputTokens)
assertNull(UsageSnapshotResponse(outputTokens = 6).deltaFrom(baseline).outputTokens)
assertEquals(0L, equal.deltaFrom(baseline).outputTokens)
assertEquals(0L, equal.deltaFrom(baseline).inputTokens)
assertEquals(0L, equal.deltaFrom(baseline).totalTokens)
}
}
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more