Merge pull request #1136 from Hy4ri/dev
chore(release): promote dev to main
This commit is contained in:
133 files changed
+13733
-1242
No files matched your search
@@ -110,7 +110,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.version.outputs.tag }}
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
name: Hermes Mobile ${{ steps.version.outputs.tag }}
|
name: ${{ steps.version.outputs.tag }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: |
|
files: |
|
||||||
release/hermes-mobile-${{ steps.version.outputs.tag }}.apk
|
release/hermes-mobile-${{ steps.version.outputs.tag }}.apk
|
||||||
|
|||||||
+346
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
-6
@@ -1,17 +1,25 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
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.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.assertCountEquals
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
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.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
@@ -42,24 +50,44 @@ class FullBleedChatListTest {
|
|||||||
content: String = "content-$id",
|
content: String = "content-$id",
|
||||||
toolStatus: ToolStatus? = null,
|
toolStatus: ToolStatus? = null,
|
||||||
isStreaming: Boolean = false,
|
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(
|
private fun render(
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
streamingMessage: ChatMessage? = null,
|
streamingMessage: ChatMessage? = null,
|
||||||
|
streamingState: StreamingState = StreamingState(streamingMessage = streamingMessage),
|
||||||
|
isAgentTyping: Boolean = streamingMessage?.isStreaming == true,
|
||||||
clarify: Boolean = false,
|
clarify: Boolean = false,
|
||||||
|
messageStatsEnabled: Boolean = false,
|
||||||
|
showUserMessageTokens: Boolean = true,
|
||||||
|
showAssistantMessageTokens: Boolean = true,
|
||||||
|
showTokensPerSecond: Boolean = true,
|
||||||
) {
|
) {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
val listState = LazyListState()
|
val listState = LazyListState()
|
||||||
FullBleedChatList(
|
FullBleedChatList(
|
||||||
messages = messages,
|
messages = messages,
|
||||||
streamingMessage = streamingMessage,
|
streamingState = streamingState,
|
||||||
|
isAgentTyping = isAgentTyping,
|
||||||
searchState = ChatSearchState(),
|
searchState = ChatSearchState(),
|
||||||
typingEffectEnabled = false,
|
typingEffectEnabled = false,
|
||||||
typingEffectDelayMs = 30,
|
typingEffectDelayMs = 30,
|
||||||
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
|
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||||
|
showTokensPerSecond = showTokensPerSecond,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isLoadingOlder = false,
|
isLoadingOlder = false,
|
||||||
isDark = false,
|
|
||||||
listState = listState,
|
listState = listState,
|
||||||
scrollController =
|
scrollController =
|
||||||
ChatScrollController(
|
ChatScrollController(
|
||||||
@@ -115,7 +143,7 @@ class FullBleedChatListTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun turnHeader_renderedOncePerAgentTurn() {
|
fun assistantHeader_isRemovedFromEveryAgentTurn() {
|
||||||
render(
|
render(
|
||||||
listOf(
|
listOf(
|
||||||
msg("a1", MessageRole.ASSISTANT),
|
msg("a1", MessageRole.ASSISTANT),
|
||||||
@@ -125,8 +153,148 @@ class FullBleedChatListTest {
|
|||||||
msg("a3", MessageRole.ASSISTANT),
|
msg("a3", MessageRole.ASSISTANT),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Two agent turns (a1..a2, a3) -> two turn headers (one per turn).
|
composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(0)
|
||||||
composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(2)
|
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
|
@Test
|
||||||
@@ -139,4 +307,55 @@ class FullBleedChatListTest {
|
|||||||
composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed()
|
composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithTag("clarify_bubble").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.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
@@ -62,6 +64,34 @@ class ModelScreenTest {
|
|||||||
composeTestRule.onNodeWithText("Pinned Models").assertDoesNotExist()
|
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
|
@Test
|
||||||
fun modelScreen_rendersPinnedModels_whenPresent() {
|
fun modelScreen_rendersPinnedModels_whenPresent() {
|
||||||
uiStateFlow.value =
|
uiStateFlow.value =
|
||||||
|
|||||||
+151
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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() },
|
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
|
@Composable
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ data class PendingChatNavigation(
|
|||||||
val requestId: Long,
|
val requestId: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class PendingNewChatNavigation(
|
||||||
|
val requestId: Long,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Central navigation controller with deduplication guard.
|
* Central navigation controller with deduplication guard.
|
||||||
*
|
*
|
||||||
@@ -27,9 +31,12 @@ object NavigationController {
|
|||||||
var backStack: NavBackStack<NavKey>? = null
|
var backStack: NavBackStack<NavKey>? = null
|
||||||
var pendingChatNavigation: PendingChatNavigation? by mutableStateOf(null)
|
var pendingChatNavigation: PendingChatNavigation? by mutableStateOf(null)
|
||||||
private set
|
private set
|
||||||
|
var pendingNewChatNavigation: PendingNewChatNavigation? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
val pendingSessionId: String? get() = pendingChatNavigation?.sessionId
|
val pendingSessionId: String? get() = pendingChatNavigation?.sessionId
|
||||||
|
|
||||||
private var nextChatNavigationRequestId = 0L
|
private var nextChatNavigationRequestId = 0L
|
||||||
|
private var nextNewChatNavigationRequestId = 0L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level primary screens (all drawer-accessible screens).
|
* Top-level primary screens (all drawer-accessible screens).
|
||||||
@@ -73,6 +80,14 @@ object NavigationController {
|
|||||||
queueChatNavigation(sessionId, scrollToBottom = true)
|
queueChatNavigation(sessionId, scrollToBottom = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openNewChat() {
|
||||||
|
pendingNewChatNavigation =
|
||||||
|
PendingNewChatNavigation(
|
||||||
|
requestId = ++nextNewChatNavigationRequestId,
|
||||||
|
)
|
||||||
|
navigateTo(ChatScreen)
|
||||||
|
}
|
||||||
|
|
||||||
private fun queueChatNavigation(
|
private fun queueChatNavigation(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
scrollToBottom: Boolean,
|
scrollToBottom: Boolean,
|
||||||
@@ -90,6 +105,9 @@ object NavigationController {
|
|||||||
fun consumePendingChatNavigation(): PendingChatNavigation? =
|
fun consumePendingChatNavigation(): PendingChatNavigation? =
|
||||||
pendingChatNavigation.also { pendingChatNavigation = null }
|
pendingChatNavigation.also { pendingChatNavigation = null }
|
||||||
|
|
||||||
|
fun consumePendingNewChatNavigation(): PendingNewChatNavigation? =
|
||||||
|
pendingNewChatNavigation.also { pendingNewChatNavigation = null }
|
||||||
|
|
||||||
fun consumePendingSessionId(): String? = consumePendingChatNavigation()?.sessionId
|
fun consumePendingSessionId(): String? = consumePendingChatNavigation()?.sessionId
|
||||||
|
|
||||||
/** Clear the stack and navigate to the given screen atomically. */
|
/** 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 object KanbanScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data class KanbanTaskDetailKey(
|
||||||
|
val boardSlug: String,
|
||||||
|
val taskId: String,
|
||||||
|
) : NavKey
|
||||||
|
|
||||||
@Serializable data object ProcessesScreen : NavKey
|
@Serializable data object ProcessesScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ProvidersScreen : NavKey
|
@Serializable data object ProvidersScreen : NavKey
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ data class ServerStoreState(
|
|||||||
val typingEffectEnabled: Boolean = true,
|
val typingEffectEnabled: Boolean = true,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
val chatFontScale: Float = 1.0f,
|
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
|
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||||
val appLanguage: String = "system",
|
val appLanguage: String = "system",
|
||||||
|
|||||||
@@ -713,6 +713,32 @@ object AuthManager {
|
|||||||
serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
|
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) ───────────────────────────────────
|
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
|
||||||
|
|
||||||
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
|
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("env_vars") val envVars: List<EnvVarField>? = null,
|
||||||
@SerialName("updated_at") val updatedAt: String? = null,
|
@SerialName("updated_at") val updatedAt: String? = null,
|
||||||
@SerialName("home_channel") val homeChannel: HomeChannelInfo? = null,
|
@SerialName("home_channel") val homeChannel: HomeChannelInfo? = null,
|
||||||
|
@SerialName("ingress_url") val ingressUrl: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -64,6 +65,13 @@ data class MessagingPlatformTestResult(
|
|||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessagingPlatformUpdateResponse(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
val platform: String? = null,
|
||||||
|
@SerialName("hot_served") val hotServed: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
// ── Telegram onboarding ─────────────────────────────────────────────────
|
// ── Telegram onboarding ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@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 display_name: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
val terminal_backend: 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
|
// Epoch seconds of the latest message (backend-computed, present on all
|
||||||
// list rows). Recency source of truth; started_at is the fallback.
|
// list rows). Recency source of truth; started_at is the fallback.
|
||||||
val last_active: Double? = null,
|
val last_active: Double? = null,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -8,6 +9,8 @@ data class StatusResponse(
|
|||||||
val active_sessions: Int? = null,
|
val active_sessions: Int? = null,
|
||||||
val auth_required: Boolean? = null,
|
val auth_required: Boolean? = null,
|
||||||
val gateway_platforms: Map<String, PlatformStatus?>? = null,
|
val gateway_platforms: Map<String, PlatformStatus?>? = null,
|
||||||
|
@SerialName("gateway_shared_with")
|
||||||
|
val gatewaySharedWith: List<String>? = null,
|
||||||
val memory: MemoryPressureStatus? = null,
|
val memory: MemoryPressureStatus? = null,
|
||||||
val disk: DiskPressureStatus? = null,
|
val disk: DiskPressureStatus? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,68 +1,187 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.ws.toAny
|
import com.m57.hermescontrol.data.ws.toAny
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
import kotlinx.serialization.json.doubleOrNull
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
import kotlinx.serialization.json.intOrNull
|
|
||||||
import kotlinx.serialization.json.longOrNull
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
|
||||||
/**
|
/** Complete cumulative `session.usage` snapshot returned by Hermes Agent. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
data class UsageSnapshotResponse(
|
data class UsageSnapshotResponse(
|
||||||
/** How many times this session has been context-compressed. */
|
val model: String? = null,
|
||||||
val compressions: Int? = null,
|
val inputTokens: Long? = null,
|
||||||
/** Current prompt / context tokens used in the active window. */
|
val outputTokens: Long? = null,
|
||||||
val contextUsed: Long? = null,
|
val reasoningTokens: Long? = null,
|
||||||
/** Max context length in tokens. */
|
val promptTokens: Long? = null,
|
||||||
val contextMax: Long? = null,
|
val completionTokens: Long? = null,
|
||||||
/** Cumulative session total tokens. */
|
|
||||||
val totalTokens: 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 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": { ... } }`
|
* Handles direct snapshots and nested `{ "usage": { ... } }` envelopes. JSON numbers may
|
||||||
* (from push event payload). The WS event parser decodes JSON numbers as [Double]
|
* arrive as [Double] through [JsonRpcModels.toAny], or as [JsonPrimitive] from an RPC result.
|
||||||
* (`JsonRpcModels.toAny`), so every count is read through [Number]. Also supports raw [JsonObject].
|
* Unknown and malformed optional fields are ignored; a non-map root returns null.
|
||||||
* Returns null when the payload is not a map (error/malformed response) — callers keep the last known value.
|
|
||||||
*/
|
*/
|
||||||
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
|
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
|
||||||
val rawMap =
|
fun asMap(value: Any?): Map<*, *>? =
|
||||||
when (result) {
|
when (value) {
|
||||||
is JsonElement -> result.toAny() as? Map<*, *>
|
is Map<*, *> -> value
|
||||||
is Map<*, *> -> result
|
is JsonElement -> value.toAny() as? Map<*, *>
|
||||||
else -> null
|
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? =
|
fun num(key: String): Long? =
|
||||||
when (val value = usageMap[key]) {
|
when (val value = usageMap[key]) {
|
||||||
is Number -> value.toLong()
|
is Byte, is Short, is Int, is Long -> {
|
||||||
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
|
(value as Number).toLong()
|
||||||
is String -> value.toLongOrNull()
|
}
|
||||||
else -> null
|
|
||||||
|
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? =
|
fun intNum(key: String): Int? = num(key)?.takeIf { it in Int.MIN_VALUE..Int.MAX_VALUE }?.toInt()
|
||||||
when (val value = usageMap[key]) {
|
|
||||||
is Number -> value.toInt()
|
|
||||||
is JsonPrimitive -> value.intOrNull
|
|
||||||
is String -> value.toIntOrNull()
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun doubleNum(key: String): Double? =
|
fun doubleNum(key: String): Double? =
|
||||||
when (val value = usageMap[key]) {
|
when (val value = usageMap[key]) {
|
||||||
@@ -70,13 +189,55 @@ fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
|
|||||||
is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble()
|
is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble()
|
||||||
is String -> value.toDoubleOrNull()
|
is String -> value.toDoubleOrNull()
|
||||||
else -> null
|
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(
|
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"),
|
compressions = intNum("compressions"),
|
||||||
contextUsed = num("context_used"),
|
contextUsed = num("context_used"),
|
||||||
contextMax = num("context_max"),
|
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"),
|
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). */
|
/** Force-rebuild the Retrofit client (e.g. after settings change). */
|
||||||
fun rebuild() {
|
fun rebuild() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
@@ -79,7 +83,15 @@ object ApiClient {
|
|||||||
|
|
||||||
// ── Internal ─────────────────────────────────────────────────────────
|
// ── 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 =
|
val logging =
|
||||||
HttpLoggingInterceptor().apply {
|
HttpLoggingInterceptor().apply {
|
||||||
level =
|
level =
|
||||||
@@ -129,15 +141,12 @@ object ApiClient {
|
|||||||
.authenticator(TokenRefreshAuthenticator)
|
.authenticator(TokenRefreshAuthenticator)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val rf =
|
return Retrofit
|
||||||
Retrofit
|
.Builder()
|
||||||
.Builder()
|
.baseUrl(AuthManager.endpointForBuild().baseUrl)
|
||||||
.baseUrl(AuthManager.endpointForBuild().baseUrl)
|
.client(okHttp)
|
||||||
.client(okHttp)
|
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
||||||
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
.build()
|
||||||
.build()
|
.also { retrofit = it }
|
||||||
.also { retrofit = it }
|
|
||||||
|
|
||||||
return rf.create(HermesApiService::class.java)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,8 @@ import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
|
|||||||
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
|
import com.m57.hermescontrol.data.model.EnvVarRevealRequest
|
||||||
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
|
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
|
||||||
import com.m57.hermescontrol.data.model.EnvVarUpdate
|
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.HealthStatus
|
||||||
import com.m57.hermescontrol.data.model.HookResponse
|
import com.m57.hermescontrol.data.model.HookResponse
|
||||||
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
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.MessagingPlatformResponse
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
|
import com.m57.hermescontrol.data.model.MessagingPlatformTestResult
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
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.MoaConfigResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
|
import com.m57.hermescontrol.data.model.ModelAssignmentRequest
|
||||||
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
|
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
|
||||||
@@ -154,7 +157,7 @@ import retrofit2.http.Path
|
|||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Streaming
|
import retrofit2.http.Streaming
|
||||||
|
|
||||||
interface HermesApiService {
|
interface HermesApiService : KanbanApiService {
|
||||||
@GET("api/skills/content")
|
@GET("api/skills/content")
|
||||||
suspend fun getSkillContent(
|
suspend fun getSkillContent(
|
||||||
@Query("name") name: String,
|
@Query("name") name: String,
|
||||||
@@ -390,6 +393,12 @@ interface HermesApiService {
|
|||||||
@POST("api/gateway/restart")
|
@POST("api/gateway/restart")
|
||||||
suspend fun restartGateway(): Response<Unit>
|
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")
|
@GET("api/profiles")
|
||||||
suspend fun getProfiles(): Response<ProfilesResponse>
|
suspend fun getProfiles(): Response<ProfilesResponse>
|
||||||
|
|
||||||
@@ -677,7 +686,7 @@ interface HermesApiService {
|
|||||||
suspend fun configurePlatform(
|
suspend fun configurePlatform(
|
||||||
@Path("platform_id") platformId: String,
|
@Path("platform_id") platformId: String,
|
||||||
@Body config: MessagingPlatformUpdate,
|
@Body config: MessagingPlatformUpdate,
|
||||||
): Response<Unit>
|
): Response<MessagingPlatformUpdateResponse>
|
||||||
|
|
||||||
@POST("api/messaging/platforms/{platform_id}/test")
|
@POST("api/messaging/platforms/{platform_id}/test")
|
||||||
suspend fun testMessagingPlatform(
|
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,
|
response: JsonRpcResponse,
|
||||||
rawJson: String = "",
|
rawJson: String = "",
|
||||||
): WsEvent {
|
): 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
|
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) {
|
return if (response.error != null) {
|
||||||
WsEvent.RpcError(id, response.error)
|
WsEvent.RpcError(id, response.error)
|
||||||
} else {
|
} else {
|
||||||
@@ -220,6 +228,13 @@ object EventParser {
|
|||||||
WsEvent.ClarifyExpire(clarifyId, sessionId)
|
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" -> {
|
"status.update" -> {
|
||||||
val status = payload?.get("status") as? String
|
val status = payload?.get("status") as? String
|
||||||
WsEvent.StatusUpdate(status, payload)
|
WsEvent.StatusUpdate(status, payload)
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import kotlinx.serialization.encodeToString
|
|||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
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 ─────────────────────────────────────────────────────
|
// ── Send helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -980,7 +1031,7 @@ object HermesWsClient {
|
|||||||
val deferred =
|
val deferred =
|
||||||
request(
|
request(
|
||||||
method = WsMethods.SESSION_EVENTS_SINCE,
|
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,
|
timeoutMs = 10_000L,
|
||||||
)
|
)
|
||||||
val result = deferred.await()
|
val result = deferred.await()
|
||||||
@@ -1249,6 +1300,21 @@ object HermesWsClient {
|
|||||||
resolvePending(rpcId, rpc.result, null)
|
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) {
|
if (rpc.id == null) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val params = rpc.params?.toAny() as? Map<String, Any?>
|
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
|
@Serializable
|
||||||
data class JsonRpcResponse(
|
data class JsonRpcResponse(
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.decodeFromString
|
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.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
@@ -31,11 +39,37 @@ enum class KanbanLiveStatus {
|
|||||||
AUTH_FAILED,
|
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``). */
|
/** One row of the backend's ``task_events`` table (``/api/plugins/kanban/events``). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KanbanEvent(
|
data class KanbanEvent(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
@SerialName("task_id") val taskId: String? = null,
|
@SerialName("task_id") val taskId: String? = null,
|
||||||
|
@Serializable(with = StringOrLongSerializer::class)
|
||||||
@SerialName("run_id") val runId: String? = null,
|
@SerialName("run_id") val runId: String? = null,
|
||||||
val kind: String,
|
val kind: String,
|
||||||
/** Partial update (e.g. ``{"status": "done"}``) — never applied directly. */
|
/** 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 questionId: String? = null,
|
||||||
val multiSelect: Boolean = false,
|
val multiSelect: Boolean = false,
|
||||||
val questions: List<ClarifyQuestion> = emptyList(),
|
val questions: List<ClarifyQuestion> = emptyList(),
|
||||||
|
val serverRequestId: String? = null,
|
||||||
|
val lockedAnswers: Map<String, String> = emptyMap(),
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
data class ClarifyExpire(
|
data class ClarifyExpire(
|
||||||
@@ -213,6 +215,25 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : 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 ────────────────────────────────────────────────────
|
// ── RPC responses ────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class RpcResult(
|
data class RpcResult(
|
||||||
@@ -256,18 +277,21 @@ sealed class WsEvent {
|
|||||||
* restricts to once/deny only (desktop `smartDenied` parity).
|
* restricts to once/deny only (desktop `smartDenied` parity).
|
||||||
*/
|
*/
|
||||||
val smartDenied: Boolean? = null,
|
val smartDenied: Boolean? = null,
|
||||||
|
/** JSON-RPC server-request id (`srq-*`) used for the response frame. */
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Sudo / secret requests ─────────────────────────────────────────
|
// ── Sudo / secret requests ─────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend needs the user's sudo password to continue a turn
|
* Backend needs the user's sudo password to continue a turn
|
||||||
* (desktop: `sudo.request` → `sudo.respond {request_id, password}`).
|
* New gateways use a same-ID JSON-RPC result frame; legacy gateways fall
|
||||||
* Mobile previously dropped this and the agent hung forever.
|
* back to `sudo.respond`.
|
||||||
*/
|
*/
|
||||||
data class SudoRequest(
|
data class SudoRequest(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -278,12 +302,13 @@ sealed class WsEvent {
|
|||||||
data class SudoExpire(
|
data class SudoExpire(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend needs a secret value (password / token) to continue a turn
|
* Backend needs a secret value (password / token) to continue a turn
|
||||||
* (desktop: `secret.request` → `secret.respond {request_id, value}`).
|
* New gateways use a same-ID JSON-RPC result frame; legacy gateways fall
|
||||||
* Mobile previously dropped this and the agent hung forever.
|
* back to `secret.respond`.
|
||||||
* `envVar`/`prompt` mirror desktop: title = envVar ?: secretTitle,
|
* `envVar`/`prompt` mirror desktop: title = envVar ?: secretTitle,
|
||||||
* body = prompt ?: secretDesc.
|
* body = prompt ?: secretDesc.
|
||||||
*/
|
*/
|
||||||
@@ -292,6 +317,7 @@ sealed class WsEvent {
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
val envVar: String? = null,
|
val envVar: String? = null,
|
||||||
val prompt: String? = null,
|
val prompt: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -301,6 +327,7 @@ sealed class WsEvent {
|
|||||||
data class SecretExpire(
|
data class SecretExpire(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Credential Vault prompts (issue #1090) ──────────────────────────
|
// ── Credential Vault prompts (issue #1090) ──────────────────────────
|
||||||
@@ -310,11 +337,13 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
val backend: String? = null,
|
val backend: String? = null,
|
||||||
val displayName: String? = null,
|
val displayName: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
data class VaultUnlockExpire(
|
data class VaultUnlockExpire(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
data class VaultSaveLoginRequest(
|
data class VaultSaveLoginRequest(
|
||||||
@@ -322,11 +351,13 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
val origin: String? = null,
|
val origin: String? = null,
|
||||||
val site: String? = null,
|
val site: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
data class VaultSaveLoginExpire(
|
data class VaultSaveLoginExpire(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
data class VaultCodeRequest(
|
data class VaultCodeRequest(
|
||||||
@@ -334,11 +365,13 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
val site: String? = null,
|
val site: String? = null,
|
||||||
val hint: String? = null,
|
val hint: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
data class VaultCodeExpire(
|
data class VaultCodeExpire(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Gateway-level errors ───────────────────────────────────────────
|
// ── Gateway-level errors ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ object WsMethods {
|
|||||||
const val PROFILES_CONFIGURE = "profiles.configure"
|
const val PROFILES_CONFIGURE = "profiles.configure"
|
||||||
const val PROFILES_LIST = "profiles.list"
|
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 ───────────────────────────────────────────────────────
|
// ── Interaction ───────────────────────────────────────────────────────
|
||||||
const val PROMPT_SUBMIT = "prompt.submit"
|
const val PROMPT_SUBMIT = "prompt.submit"
|
||||||
const val PROMPT_BTW = "prompt.btw"
|
const val PROMPT_BTW = "prompt.btw"
|
||||||
@@ -159,6 +164,7 @@ object WsMethods {
|
|||||||
// Binds HERMES_HOME so config/skills/pets resolve to the
|
// Binds HERMES_HOME so config/skills/pets resolve to the
|
||||||
// focused profile.
|
// focused profile.
|
||||||
"verification.status", // line 282
|
"verification.status", // line 282
|
||||||
|
PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped
|
||||||
"pet.info", // line 1253
|
"pet.info", // line 1253
|
||||||
"pet.info.meta", // line 1279
|
"pet.info.meta", // line 1279
|
||||||
"pet.cells", // line 1302
|
"pet.cells", // line 1302
|
||||||
|
|||||||
@@ -71,3 +71,23 @@ fun parseHexColor(
|
|||||||
fallback
|
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)
|
||||||
|
}
|
||||||
+25
-7
@@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.heightIn
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -29,6 +32,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -58,9 +62,18 @@ fun GroupChatSettingsDialog(
|
|||||||
) {
|
) {
|
||||||
var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) }
|
var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) }
|
||||||
var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.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) }
|
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 =
|
val isHighLimit =
|
||||||
maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES ||
|
maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES ||
|
||||||
maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES
|
maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES
|
||||||
@@ -217,8 +230,7 @@ fun GroupChatSettingsDialog(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = systemPrompt,
|
state = systemPromptTextFieldState,
|
||||||
onValueChange = { systemPrompt = it },
|
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.group_chat_settings_system_prompt_hint),
|
text = stringResource(R.string.group_chat_settings_system_prompt_hint),
|
||||||
@@ -231,7 +243,7 @@ fun GroupChatSettingsDialog(
|
|||||||
.heightIn(min = 80.dp, max = 160.dp)
|
.heightIn(min = 80.dp, max = 160.dp)
|
||||||
.testTag("room_system_prompt_input"),
|
.testTag("room_system_prompt_input"),
|
||||||
textStyle = MaterialTheme.typography.bodyMedium,
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 4,
|
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +257,10 @@ fun GroupChatSettingsDialog(
|
|||||||
onSave(
|
onSave(
|
||||||
maxMessages.roundToInt(),
|
maxMessages.roundToInt(),
|
||||||
maxPasses.roundToInt(),
|
maxPasses.roundToInt(),
|
||||||
systemPrompt.trim().ifBlank { null },
|
systemPromptTextFieldState.text
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.ifBlank { null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -260,7 +275,7 @@ fun GroupChatSettingsDialog(
|
|||||||
onClick = {
|
onClick = {
|
||||||
maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat()
|
maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat()
|
||||||
maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat()
|
maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat()
|
||||||
systemPrompt = ""
|
systemPromptTextFieldState.clearText()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.group_chat_settings_reset_default))
|
Text(stringResource(R.string.group_chat_settings_reset_default))
|
||||||
@@ -307,7 +322,10 @@ fun GroupChatSettingsDialog(
|
|||||||
onSave(
|
onSave(
|
||||||
maxMessages.roundToInt(),
|
maxMessages.roundToInt(),
|
||||||
maxPasses.roundToInt(),
|
maxPasses.roundToInt(),
|
||||||
systemPrompt.trim().ifBlank { null },
|
systemPromptTextFieldState.text
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.ifBlank { null },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
colors =
|
colors =
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ class ChannelsViewModel :
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStart = {},
|
onStart = {},
|
||||||
onSuccess = {
|
onSuccess = { response ->
|
||||||
|
val hotServed = response.hotServed
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
platforms =
|
platforms =
|
||||||
@@ -133,16 +134,28 @@ class ChannelsViewModel :
|
|||||||
if (p.id == platformId) {
|
if (p.id == platformId) {
|
||||||
p.copy(
|
p.copy(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
state = if (enabled) "pending_restart" else "disabled",
|
state =
|
||||||
|
when {
|
||||||
|
!enabled -> "disabled"
|
||||||
|
hotServed -> "connected"
|
||||||
|
else -> "pending_restart"
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
restartNeeded = true,
|
restartNeeded = !hotServed,
|
||||||
togglingId = null,
|
togglingId = null,
|
||||||
|
toastMessage =
|
||||||
|
if (hotServed) {
|
||||||
|
"$platformId updated live"
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
loadPlatforms()
|
||||||
},
|
},
|
||||||
onError = { error ->
|
onError = { error ->
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -201,12 +214,18 @@ class ChannelsViewModel :
|
|||||||
it.copy(isLoading = true, errorMessage = null)
|
it.copy(isLoading = true, errorMessage = null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess = {
|
onSuccess = { response ->
|
||||||
val message = "$platformId configured successfully — restart the gateway for changes to take effect"
|
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 {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
restartNeeded = true,
|
restartNeeded = !hotServed,
|
||||||
toastMessage = message,
|
toastMessage = message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.channels.components
|
package com.m57.hermescontrol.ui.channels.components
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
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.Delete
|
||||||
import androidx.compose.material.icons.filled.QrCode
|
import androidx.compose.material.icons.filled.QrCode
|
||||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.channels.ConfigureForm
|
import com.m57.hermescontrol.ui.channels.ConfigureForm
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private enum class StatusRole { SUCCESS, WARNING, ERROR, NEUTRAL }
|
private enum class StatusRole { SUCCESS, WARNING, ERROR, NEUTRAL }
|
||||||
|
|
||||||
@@ -110,6 +117,8 @@ internal fun PlatformCard(
|
|||||||
) {
|
) {
|
||||||
var showConfigureForm by remember { mutableStateOf(false) }
|
var showConfigureForm by remember { mutableStateOf(false) }
|
||||||
var showRemoveConfirm by remember { mutableStateOf(false) }
|
var showRemoveConfirm by remember { mutableStateOf(false) }
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val style = platformStateStyle(platform.state)
|
val style = platformStateStyle(platform.state)
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
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 ──
|
// ── Error message ──
|
||||||
if (!platform.errorMessage.isNullOrBlank()) {
|
if (!platform.errorMessage.isNullOrBlank()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
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
|
* 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 wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
|
||||||
private val trackRequest: (id: String, method: String) -> Unit,
|
private val trackRequest: (id: String, method: String) -> Unit,
|
||||||
private val addSystemMessage: (text: String) -> Unit,
|
private val addSystemMessage: (text: String) -> Unit,
|
||||||
|
private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
|
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 description = event.description ?: event.command ?: "Unknown command"
|
||||||
val content = "**Approval Required**\n$description"
|
val content = "**Approval Required**\n$description"
|
||||||
val msg =
|
val msg =
|
||||||
@@ -36,6 +80,7 @@ class ChatApprovalsDelegate(
|
|||||||
description = event.description,
|
description = event.description,
|
||||||
patternKeys = event.patternKeys,
|
patternKeys = event.patternKeys,
|
||||||
requestId = event.requestId,
|
requestId = event.requestId,
|
||||||
|
serverRequestId = event.serverRequestId,
|
||||||
choices = event.choices,
|
choices = event.choices,
|
||||||
allowPermanent = event.allowPermanent,
|
allowPermanent = event.allowPermanent,
|
||||||
smartDenied = event.smartDenied,
|
smartDenied = event.smartDenied,
|
||||||
@@ -51,7 +96,7 @@ class ChatApprovalsDelegate(
|
|||||||
// so the backend knows this client holds the prompt. Fire-and-forget.
|
// so the backend knows this client holds the prompt. Fire-and-forget.
|
||||||
val requestId = event.requestId
|
val requestId = event.requestId
|
||||||
val sessionId = runtimeSessionId() ?: event.sessionId ?: uiState.value.currentSessionId
|
val sessionId = runtimeSessionId() ?: event.sessionId ?: uiState.value.currentSessionId
|
||||||
if (requestId != null && sessionId != null) {
|
if (event.serverRequestId == null && requestId != null && sessionId != null) {
|
||||||
scope.launch(ioDispatcher) {
|
scope.launch(ioDispatcher) {
|
||||||
runCatching {
|
runCatching {
|
||||||
wsSend(
|
wsSend(
|
||||||
@@ -118,6 +163,7 @@ class ChatApprovalsDelegate(
|
|||||||
// (any non-deny still unblocks, but stay on-spec going forward).
|
// (any non-deny still unblocks, but stay on-spec going forward).
|
||||||
val choice = if (action == "approve") "once" else action
|
val choice = if (action == "approve") "once" else action
|
||||||
val requestId = approvalMsg.approvalInfo?.requestId
|
val requestId = approvalMsg.approvalInfo?.requestId
|
||||||
|
val serverRequestId = approvalMsg.approvalInfo?.serverRequestId
|
||||||
|
|
||||||
// Clear buttons immediately
|
// Clear buttons immediately
|
||||||
uiState.update { s ->
|
uiState.update { s ->
|
||||||
@@ -134,6 +180,16 @@ class ChatApprovalsDelegate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
scope.launch(ioDispatcher) {
|
scope.launch(ioDispatcher) {
|
||||||
|
if (serverRequestId != null && respondToServerRequest != null) {
|
||||||
|
respondToServerRequest.invoke(
|
||||||
|
serverRequestId,
|
||||||
|
buildJsonObject {
|
||||||
|
put("choice", choice)
|
||||||
|
put("all", false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val params =
|
val params =
|
||||||
mutableMapOf<String, Any>(
|
mutableMapOf<String, Any>(
|
||||||
"session_id" to sessionId,
|
"session_id" to sessionId,
|
||||||
@@ -149,6 +205,21 @@ class ChatApprovalsDelegate(
|
|||||||
replayPendingApproval(sessionId)
|
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,
|
patternKeys = patternKeys,
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
requestId = map["request_id"] as? String,
|
requestId = map["request_id"] as? String,
|
||||||
|
serverRequestId = null,
|
||||||
choices = choices,
|
choices = choices,
|
||||||
allowPermanent = allowPermanent,
|
allowPermanent = allowPermanent,
|
||||||
smartDenied = smartDenied,
|
smartDenied = smartDenied,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import java.time.format.DateTimeFormatter
|
|||||||
* renderer (issue #866). Agent prose renders full-bleed; user messages keep
|
* renderer (issue #866). Agent prose renders full-bleed; user messages keep
|
||||||
* this bubble so the conversation stays scannable.
|
* this bubble so the conversation stays scannable.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatBubble(
|
fun ChatBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
@@ -106,6 +107,8 @@ fun ChatBubble(
|
|||||||
openingAttachmentPath: String? = null,
|
openingAttachmentPath: String? = null,
|
||||||
canSaveAttachment: Boolean = true,
|
canSaveAttachment: Boolean = true,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
|
messageStatsEnabled: Boolean = false,
|
||||||
|
showUserMessageTokens: Boolean = true,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
@@ -222,12 +225,12 @@ fun ChatBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!message.isStreaming) {
|
if (!message.isStreaming) {
|
||||||
Row(
|
FlowRow(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.End)
|
.align(Alignment.End)
|
||||||
.padding(top = 4.dp),
|
.padding(top = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -257,43 +260,28 @@ fun ChatBubble(
|
|||||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
)
|
||||||
if (message.tokenCount != null && message.tokenCount > 0) {
|
if (messageStatsEnabled && showUserMessageTokens &&
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
message.tokenCount != null && message.tokenCount > 0
|
||||||
Text(
|
) {
|
||||||
text = "•",
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
color = userBubbleTextColor.copy(alpha = 0.4f),
|
Text(
|
||||||
)
|
text = "•",
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
style = MaterialTheme.typography.labelSmall,
|
||||||
Text(
|
color = userBubbleTextColor.copy(alpha = 0.4f),
|
||||||
text =
|
)
|
||||||
stringResource(
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
R.string.chat_msg_tokens,
|
Text(
|
||||||
TokenEstimator.formatTokenCount(message.tokenCount),
|
text =
|
||||||
),
|
stringResource(
|
||||||
color = userBubbleTextColor.copy(alpha = 0.7f),
|
R.string.chat_msg_tokens,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
TokenEstimator.formatTokenCount(message.tokenCount),
|
||||||
modifier = Modifier.testTag("bubble_token_count"),
|
),
|
||||||
)
|
color = userBubbleTextColor.copy(alpha = 0.7f),
|
||||||
}
|
style = MaterialTheme.typography.labelSmall,
|
||||||
if (message.tps != null && message.tps > 0.0) {
|
modifier = Modifier.testTag("bubble_token_count"),
|
||||||
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"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
class ChatClarifyDelegate(
|
class ChatClarifyDelegate(
|
||||||
private val uiState: MutableStateFlow<ChatUiState>,
|
private val uiState: MutableStateFlow<ChatUiState>,
|
||||||
@@ -15,6 +18,7 @@ class ChatClarifyDelegate(
|
|||||||
private val persistMessage: suspend (ChatMessage, String) -> Unit,
|
private val persistMessage: suspend (ChatMessage, String) -> Unit,
|
||||||
private val wsClient: HermesWsClient = HermesWsClient,
|
private val wsClient: HermesWsClient = HermesWsClient,
|
||||||
private val trackRequest: (String, String) -> Unit = { _, _ -> },
|
private val trackRequest: (String, String) -> Unit = { _, _ -> },
|
||||||
|
private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
fun respondToClarify(option: String) {
|
fun respondToClarify(option: String) {
|
||||||
val clarify = uiState.value.clarifyRequest
|
val clarify = uiState.value.clarifyRequest
|
||||||
@@ -41,6 +45,8 @@ class ChatClarifyDelegate(
|
|||||||
val sessionId = uiState.value.currentSessionId ?: return
|
val sessionId = uiState.value.currentSessionId ?: return
|
||||||
val clarify = uiState.value.clarifyRequest
|
val clarify = uiState.value.clarifyRequest
|
||||||
val clarifyId = clarify?.clarifyId
|
val clarifyId = clarify?.clarifyId
|
||||||
|
val serverRequestId = clarify?.serverRequestId
|
||||||
|
val lockedAnswers = clarify?.lockedAnswers.orEmpty()
|
||||||
val isBatch = !clarify?.questions.isNullOrEmpty()
|
val isBatch = !clarify?.questions.isNullOrEmpty()
|
||||||
val questions = clarify?.resolvedQuestions.orEmpty()
|
val questions = clarify?.resolvedQuestions.orEmpty()
|
||||||
uiState.update { it.copy(clarifyRequest = null) }
|
uiState.update { it.copy(clarifyRequest = null) }
|
||||||
@@ -49,7 +55,9 @@ class ChatClarifyDelegate(
|
|||||||
if (questions.size > 1) {
|
if (questions.size > 1) {
|
||||||
questions
|
questions
|
||||||
.mapIndexed { index, q ->
|
.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)" }}"
|
"${index + 1}. ${ans.ifEmpty { "(Skipped)" }}"
|
||||||
}.joinToString("\n")
|
}.joinToString("\n")
|
||||||
} else {
|
} else {
|
||||||
@@ -75,6 +83,35 @@ class ChatClarifyDelegate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
scope.launch(ioDispatcher) {
|
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) {
|
if (isBatch) {
|
||||||
for (q in questions) {
|
for (q in questions) {
|
||||||
val ans = answers[q.qid]?.trim().orEmpty()
|
val ans = answers[q.qid]?.trim().orEmpty()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
@@ -21,15 +22,29 @@ class ChatCredentialPromptsDelegate(
|
|||||||
private val uiState: MutableStateFlow<ChatUiState>,
|
private val uiState: MutableStateFlow<ChatUiState>,
|
||||||
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
|
private val wsSend: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> Unit,
|
||||||
private val trackRequest: (id: String, method: String) -> 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
|
* New gateways use a same-ID JSON-RPC response; legacy notifications fall
|
||||||
* reply via sudo.respond.
|
* back to sudo.respond.
|
||||||
*/
|
*/
|
||||||
fun handleSudoRequest(event: WsEvent.SudoRequest) {
|
fun handleSudoRequest(event: WsEvent.SudoRequest) {
|
||||||
uiState.update {
|
uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
sudoPrompt = SudoPromptUi(event.requestId, event.sessionId),
|
sudoPrompt = SudoPromptUi(event.requestId, event.sessionId, event.serverRequestId),
|
||||||
isAgentTyping = false,
|
isAgentTyping = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -42,9 +57,10 @@ class ChatCredentialPromptsDelegate(
|
|||||||
fun handleSudoExpire(event: WsEvent.SudoExpire) {
|
fun handleSudoExpire(event: WsEvent.SudoExpire) {
|
||||||
uiState.update { state ->
|
uiState.update { state ->
|
||||||
val current = state.sudoPrompt ?: return@update state
|
val current = state.sudoPrompt ?: return@update state
|
||||||
if (event.requestId != null && current.requestId != null &&
|
if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) {
|
||||||
event.requestId != current.requestId
|
return@update state
|
||||||
) {
|
}
|
||||||
|
if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) {
|
||||||
return@update state
|
return@update state
|
||||||
}
|
}
|
||||||
state.copy(sudoPrompt = null)
|
state.copy(sudoPrompt = null)
|
||||||
@@ -52,8 +68,8 @@ class ChatCredentialPromptsDelegate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The agent needs a secret value (token/password). Surface a secure dialog
|
* New gateways use a same-ID JSON-RPC response; legacy notifications fall
|
||||||
* and reply via secret.respond.
|
* back to secret.respond.
|
||||||
*/
|
*/
|
||||||
fun handleSecretRequest(event: WsEvent.SecretRequest) {
|
fun handleSecretRequest(event: WsEvent.SecretRequest) {
|
||||||
uiState.update {
|
uiState.update {
|
||||||
@@ -64,6 +80,7 @@ class ChatCredentialPromptsDelegate(
|
|||||||
event.sessionId,
|
event.sessionId,
|
||||||
event.envVar,
|
event.envVar,
|
||||||
event.prompt,
|
event.prompt,
|
||||||
|
event.serverRequestId,
|
||||||
),
|
),
|
||||||
isAgentTyping = false,
|
isAgentTyping = false,
|
||||||
)
|
)
|
||||||
@@ -76,9 +93,10 @@ class ChatCredentialPromptsDelegate(
|
|||||||
fun handleSecretExpire(event: WsEvent.SecretExpire) {
|
fun handleSecretExpire(event: WsEvent.SecretExpire) {
|
||||||
uiState.update { state ->
|
uiState.update { state ->
|
||||||
val current = state.secretPrompt ?: return@update state
|
val current = state.secretPrompt ?: return@update state
|
||||||
if (event.requestId != null && current.requestId != null &&
|
if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) {
|
||||||
event.requestId != current.requestId
|
return@update state
|
||||||
) {
|
}
|
||||||
|
if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) {
|
||||||
return@update state
|
return@update state
|
||||||
}
|
}
|
||||||
state.copy(secretPrompt = null)
|
state.copy(secretPrompt = null)
|
||||||
@@ -101,10 +119,12 @@ class ChatCredentialPromptsDelegate(
|
|||||||
"password" to "",
|
"password" to "",
|
||||||
)
|
)
|
||||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
wsSend(
|
sendResponse(
|
||||||
|
prompt.serverRequestId,
|
||||||
|
buildJsonObject { put("value", "") },
|
||||||
WsMethods.SUDO_RESPOND,
|
WsMethods.SUDO_RESPOND,
|
||||||
params,
|
params,
|
||||||
) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,10 +144,12 @@ class ChatCredentialPromptsDelegate(
|
|||||||
"value" to "",
|
"value" to "",
|
||||||
)
|
)
|
||||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
wsSend(
|
sendResponse(
|
||||||
|
prompt.serverRequestId,
|
||||||
|
buildJsonObject { put("value", "") },
|
||||||
WsMethods.SECRET_RESPOND,
|
WsMethods.SECRET_RESPOND,
|
||||||
params,
|
params,
|
||||||
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,10 +171,12 @@ class ChatCredentialPromptsDelegate(
|
|||||||
"password" to password,
|
"password" to password,
|
||||||
)
|
)
|
||||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
wsSend(
|
sendResponse(
|
||||||
|
prompt.serverRequestId,
|
||||||
|
buildJsonObject { put("value", password) },
|
||||||
WsMethods.SUDO_RESPOND,
|
WsMethods.SUDO_RESPOND,
|
||||||
params,
|
params,
|
||||||
) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,10 +197,12 @@ class ChatCredentialPromptsDelegate(
|
|||||||
"value" to value,
|
"value" to value,
|
||||||
)
|
)
|
||||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
wsSend(
|
sendResponse(
|
||||||
|
prompt.serverRequestId,
|
||||||
|
buildJsonObject { put("value", value) },
|
||||||
WsMethods.SECRET_RESPOND,
|
WsMethods.SECRET_RESPOND,
|
||||||
params,
|
params,
|
||||||
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +210,8 @@ class ChatCredentialPromptsDelegate(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The agent needs to unlock a password manager (1Password, Bitwarden).
|
* 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) {
|
fun handleVaultUnlockRequest(event: WsEvent.VaultUnlockRequest) {
|
||||||
uiState.update {
|
uiState.update {
|
||||||
@@ -195,6 +222,7 @@ class ChatCredentialPromptsDelegate(
|
|||||||
sessionId = event.sessionId,
|
sessionId = event.sessionId,
|
||||||
backend = event.backend,
|
backend = event.backend,
|
||||||
displayName = event.displayName,
|
displayName = event.displayName,
|
||||||
|
serverRequestId = event.serverRequestId,
|
||||||
),
|
),
|
||||||
isAgentTyping = false,
|
isAgentTyping = false,
|
||||||
)
|
)
|
||||||
@@ -204,9 +232,10 @@ class ChatCredentialPromptsDelegate(
|
|||||||
fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) {
|
fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) {
|
||||||
uiState.update { state ->
|
uiState.update { state ->
|
||||||
val current = state.vaultUnlockPrompt ?: return@update state
|
val current = state.vaultUnlockPrompt ?: return@update state
|
||||||
if (event.requestId != null && current.requestId != null &&
|
if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) {
|
||||||
event.requestId != current.requestId
|
return@update state
|
||||||
) {
|
}
|
||||||
|
if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) {
|
||||||
return@update state
|
return@update state
|
||||||
}
|
}
|
||||||
state.copy(vaultUnlockPrompt = null)
|
state.copy(vaultUnlockPrompt = null)
|
||||||
@@ -228,10 +257,12 @@ class ChatCredentialPromptsDelegate(
|
|||||||
"password" to "",
|
"password" to "",
|
||||||
)
|
)
|
||||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
wsSend(
|
sendResponse(
|
||||||
|
prompt.serverRequestId,
|
||||||
|
buildJsonObject { put("value", "") },
|
||||||
WsMethods.VAULT_UNLOCK_RESPOND,
|
WsMethods.VAULT_UNLOCK_RESPOND,
|
||||||
params,
|
params,
|
||||||
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,10 +280,12 @@ class ChatCredentialPromptsDelegate(
|
|||||||
"password" to password,
|
"password" to password,
|
||||||
)
|
)
|
||||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
wsSend(
|
sendResponse(
|
||||||
|
prompt.serverRequestId,
|
||||||
|
buildJsonObject { put("value", password) },
|
||||||
WsMethods.VAULT_UNLOCK_RESPOND,
|
WsMethods.VAULT_UNLOCK_RESPOND,
|
||||||
params,
|
params,
|
||||||
) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +301,7 @@ class ChatCredentialPromptsDelegate(
|
|||||||
sessionId = event.sessionId,
|
sessionId = event.sessionId,
|
||||||
origin = event.origin,
|
origin = event.origin,
|
||||||
site = event.site,
|
site = event.site,
|
||||||
|
serverRequestId = event.serverRequestId,
|
||||||
),
|
),
|
||||||
isAgentTyping = false,
|
isAgentTyping = false,
|
||||||
)
|
)
|
||||||
@@ -277,9 +311,10 @@ class ChatCredentialPromptsDelegate(
|
|||||||
fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) {
|
fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) {
|
||||||
uiState.update { state ->
|
uiState.update { state ->
|
||||||
val current = state.vaultSaveLoginPrompt ?: return@update state
|
val current = state.vaultSaveLoginPrompt ?: return@update state
|
||||||
if (event.requestId != null && current.requestId != null &&
|
if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) {
|
||||||
event.requestId != current.requestId
|
return@update state
|
||||||
) {
|
}
|
||||||
|
if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) {
|
||||||
return@update state
|
return@update state
|
||||||
}
|
}
|
||||||
state.copy(vaultSaveLoginPrompt = null)
|
state.copy(vaultSaveLoginPrompt = null)
|
||||||
@@ -301,10 +336,12 @@ class ChatCredentialPromptsDelegate(
|
|||||||
"login" to "",
|
"login" to "",
|
||||||
)
|
)
|
||||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
wsSend(
|
sendResponse(
|
||||||
|
prompt.serverRequestId,
|
||||||
|
buildJsonObject { put("value", "") },
|
||||||
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
|
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
|
||||||
params,
|
params,
|
||||||
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,10 +367,12 @@ class ChatCredentialPromptsDelegate(
|
|||||||
"login" to loginJson,
|
"login" to loginJson,
|
||||||
)
|
)
|
||||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
wsSend(
|
sendResponse(
|
||||||
|
prompt.serverRequestId,
|
||||||
|
buildJsonObject { put("value", loginJson) },
|
||||||
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
|
WsMethods.VAULT_SAVE_LOGIN_RESPOND,
|
||||||
params,
|
params,
|
||||||
) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +388,7 @@ class ChatCredentialPromptsDelegate(
|
|||||||
sessionId = event.sessionId,
|
sessionId = event.sessionId,
|
||||||
site = event.site,
|
site = event.site,
|
||||||
hint = event.hint,
|
hint = event.hint,
|
||||||
|
serverRequestId = event.serverRequestId,
|
||||||
),
|
),
|
||||||
isAgentTyping = false,
|
isAgentTyping = false,
|
||||||
)
|
)
|
||||||
@@ -358,9 +398,10 @@ class ChatCredentialPromptsDelegate(
|
|||||||
fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) {
|
fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) {
|
||||||
uiState.update { state ->
|
uiState.update { state ->
|
||||||
val current = state.vaultCodePrompt ?: return@update state
|
val current = state.vaultCodePrompt ?: return@update state
|
||||||
if (event.requestId != null && current.requestId != null &&
|
if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) {
|
||||||
event.requestId != current.requestId
|
return@update state
|
||||||
) {
|
}
|
||||||
|
if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) {
|
||||||
return@update state
|
return@update state
|
||||||
}
|
}
|
||||||
state.copy(vaultCodePrompt = null)
|
state.copy(vaultCodePrompt = null)
|
||||||
@@ -382,10 +423,12 @@ class ChatCredentialPromptsDelegate(
|
|||||||
"code" to "",
|
"code" to "",
|
||||||
)
|
)
|
||||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
wsSend(
|
sendResponse(
|
||||||
|
prompt.serverRequestId,
|
||||||
|
buildJsonObject { put("value", "") },
|
||||||
WsMethods.VAULT_CODE_RESPOND,
|
WsMethods.VAULT_CODE_RESPOND,
|
||||||
params,
|
params,
|
||||||
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,10 +446,12 @@ class ChatCredentialPromptsDelegate(
|
|||||||
"code" to code,
|
"code" to code,
|
||||||
)
|
)
|
||||||
prompt.requestId?.let { id -> params["request_id"] = id }
|
prompt.requestId?.let { id -> params["request_id"] = id }
|
||||||
wsSend(
|
sendResponse(
|
||||||
|
prompt.serverRequestId,
|
||||||
|
buildJsonObject { put("value", code) },
|
||||||
WsMethods.VAULT_CODE_RESPOND,
|
WsMethods.VAULT_CODE_RESPOND,
|
||||||
params,
|
params,
|
||||||
) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) }
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@ data class ApprovalInfo(
|
|||||||
val patternKeys: List<String>?,
|
val patternKeys: List<String>?,
|
||||||
/** Backend `request_id` — sent back in `approval.respond` to pin the exact pending. */
|
/** Backend `request_id` — sent back in `approval.respond` to pin the exact pending. */
|
||||||
val requestId: String? = null,
|
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. */
|
/** Backend-advertised choices (`once`/`session`/`always`/`deny`). Null = legacy Run/Deny. */
|
||||||
val choices: List<String>? = null,
|
val choices: List<String>? = null,
|
||||||
/** False → hide "Always allow" (tirith warning, desktop parity). */
|
/** 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.BorderStroke
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -359,7 +358,6 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val isDark = isSystemInDarkTheme()
|
|
||||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||||
@@ -668,14 +666,18 @@ fun ChatScreen(
|
|||||||
// surface since the bubble renderer was removed.
|
// surface since the bubble renderer was removed.
|
||||||
FullBleedChatList(
|
FullBleedChatList(
|
||||||
messages = state.messages,
|
messages = state.messages,
|
||||||
streamingMessage = streamingState.streamingMessage,
|
streamingState = streamingState,
|
||||||
|
isAgentTyping = state.isAgentTyping,
|
||||||
searchState = searchState,
|
searchState = searchState,
|
||||||
typingEffectEnabled = state.typingEffectEnabled,
|
typingEffectEnabled = state.typingEffectEnabled,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
|
messageStatsEnabled = state.messageStatsEnabled,
|
||||||
|
showUserMessageTokens = state.showUserMessageTokens,
|
||||||
|
showAssistantMessageTokens = state.showAssistantMessageTokens,
|
||||||
|
showTokensPerSecond = state.showTokensPerSecond,
|
||||||
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading,
|
||||||
isLoadingOlder = state.isLoadingOlder,
|
isLoadingOlder = state.isLoadingOlder,
|
||||||
isDark = isDark,
|
|
||||||
listState = listState,
|
listState = listState,
|
||||||
scrollController = scrollController,
|
scrollController = scrollController,
|
||||||
lastAnimatedMessageId = lastAnimatedMessageId,
|
lastAnimatedMessageId = lastAnimatedMessageId,
|
||||||
|
|||||||
@@ -213,9 +213,16 @@ class ChatSubagentsDelegate(
|
|||||||
* Rules:
|
* Rules:
|
||||||
* - `subagent_id` is the primary identity.
|
* - `subagent_id` is the primary identity.
|
||||||
* - Protects against races: if an indicator received a push event with timestamp > [requestTime],
|
* - 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.
|
* - 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(
|
internal fun mergeSubagentList(
|
||||||
current: List<SubagentIndicator>,
|
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
|
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.ModelCapabilities
|
||||||
import com.m57.hermescontrol.data.model.ModelProvider
|
import com.m57.hermescontrol.data.model.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
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.parseContextBreakdown
|
||||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import kotlinx.serialization.json.intOrNull
|
import kotlinx.serialization.json.intOrNull
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
@@ -112,6 +114,10 @@ data class ChatUiState(
|
|||||||
// Cached settings
|
// Cached settings
|
||||||
val typingEffectEnabled: Boolean = true,
|
val typingEffectEnabled: Boolean = true,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
|
val messageStatsEnabled: Boolean = false,
|
||||||
|
val showUserMessageTokens: Boolean = true,
|
||||||
|
val showAssistantMessageTokens: Boolean = true,
|
||||||
|
val showTokensPerSecond: Boolean = true,
|
||||||
// Commands catalog
|
// Commands catalog
|
||||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||||
// Per-command usage counts for the slash-autocomplete ranking (issue
|
// Per-command usage counts for the slash-autocomplete ranking (issue
|
||||||
@@ -154,6 +160,8 @@ data class ChatUiState(
|
|||||||
val compressionCount: Int? = null,
|
val compressionCount: Int? = null,
|
||||||
/** Rolling output tokens/sec over the last ~10 calls. */
|
/** Rolling output tokens/sec over the last ~10 calls. */
|
||||||
val latestTps: Double? = null,
|
val latestTps: Double? = null,
|
||||||
|
/** Latest cumulative backend usage snapshot for the current session. */
|
||||||
|
val sessionUsage: UsageSnapshotResponse? = null,
|
||||||
// Attachment state
|
// Attachment state
|
||||||
val pendingAttachments: List<Attachment> = emptyList(),
|
val pendingAttachments: List<Attachment> = emptyList(),
|
||||||
/** One-shot composer recovery after an attachment is rejected before send. */
|
/** One-shot composer recovery after an attachment is rejected before send. */
|
||||||
@@ -204,6 +212,8 @@ data class ClarifyUi(
|
|||||||
val questionId: String? = null,
|
val questionId: String? = null,
|
||||||
val multiSelect: Boolean = false,
|
val multiSelect: Boolean = false,
|
||||||
val questions: List<ClarifyQuestionUi> = emptyList(),
|
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
|
* 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(
|
data class SudoPromptUi(
|
||||||
val requestId: String?,
|
val requestId: String?,
|
||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Transient — not persisted. Holds a pending secret (token/password) request. */
|
/** Transient — not persisted. Holds a pending secret (token/password) request. */
|
||||||
@@ -259,6 +270,7 @@ data class SecretPromptUi(
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
val envVar: String? = null,
|
val envVar: String? = null,
|
||||||
val prompt: String? = null,
|
val prompt: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Transient — not persisted. Holds a pending vault unlock request (issue #1090). */
|
/** Transient — not persisted. Holds a pending vault unlock request (issue #1090). */
|
||||||
@@ -267,6 +279,7 @@ data class VaultUnlockPromptUi(
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
val backend: String? = null,
|
val backend: String? = null,
|
||||||
val displayName: String? = null,
|
val displayName: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Transient — not persisted. Holds a pending vault save login request (issue #1090). */
|
/** Transient — not persisted. Holds a pending vault save login request (issue #1090). */
|
||||||
@@ -275,6 +288,7 @@ data class VaultSaveLoginPromptUi(
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
val origin: String? = null,
|
val origin: String? = null,
|
||||||
val site: String? = null,
|
val site: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Transient — not persisted. Holds a pending vault 2FA/MFA code request (issue #1090). */
|
/** Transient — not persisted. Holds a pending vault 2FA/MFA code request (issue #1090). */
|
||||||
@@ -283,6 +297,7 @@ data class VaultCodePromptUi(
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
val site: String? = null,
|
val site: String? = null,
|
||||||
val hint: String? = null,
|
val hint: String? = null,
|
||||||
|
val serverRequestId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -469,6 +484,7 @@ class ChatViewModel(
|
|||||||
uiState = _uiState,
|
uiState = _uiState,
|
||||||
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) },
|
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) },
|
||||||
trackRequest = { id, method -> trackRequest(id, method) },
|
trackRequest = { id, method -> trackRequest(id, method) },
|
||||||
|
respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) },
|
||||||
)
|
)
|
||||||
|
|
||||||
private val approvalsDelegate =
|
private val approvalsDelegate =
|
||||||
@@ -480,6 +496,7 @@ class ChatViewModel(
|
|||||||
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) },
|
wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) },
|
||||||
trackRequest = { id, method -> trackRequest(id, method) },
|
trackRequest = { id, method -> trackRequest(id, method) },
|
||||||
addSystemMessage = { text -> addSystemMessage(text) },
|
addSystemMessage = { text -> addSystemMessage(text) },
|
||||||
|
respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) },
|
||||||
)
|
)
|
||||||
|
|
||||||
private val clarifyDelegate =
|
private val clarifyDelegate =
|
||||||
@@ -490,6 +507,7 @@ class ChatViewModel(
|
|||||||
persistMessage = { msg, sid -> repo.persistMessage(msg, sid) },
|
persistMessage = { msg, sid -> repo.persistMessage(msg, sid) },
|
||||||
wsClient = wsClient,
|
wsClient = wsClient,
|
||||||
trackRequest = { id, method -> trackRequest(id, method) },
|
trackRequest = { id, method -> trackRequest(id, method) },
|
||||||
|
respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) },
|
||||||
)
|
)
|
||||||
|
|
||||||
private val subagentsDelegate =
|
private val subagentsDelegate =
|
||||||
@@ -941,6 +959,14 @@ class ChatViewModel(
|
|||||||
streamingController.resetStreaming()
|
streamingController.resetStreaming()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WsEvent.ServerRequest -> {
|
||||||
|
handleServerRequest(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
is WsEvent.ServerRequestCancelled -> {
|
||||||
|
handleServerRequestCancelled(event)
|
||||||
|
}
|
||||||
|
|
||||||
is WsEvent.ApprovalRequest -> {
|
is WsEvent.ApprovalRequest -> {
|
||||||
approvalsDelegate.handleApprovalRequest(event)
|
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 ────────────────────────────────────────────────
|
// ── Message streaming ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1057,6 +1261,7 @@ class ChatViewModel(
|
|||||||
fullContextTokens = null,
|
fullContextTokens = null,
|
||||||
contextBreakdown = null,
|
contextBreakdown = null,
|
||||||
compressionCount = null,
|
compressionCount = null,
|
||||||
|
sessionUsage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A gone-session recovery just landed — announce it now that
|
// A gone-session recovery just landed — announce it now that
|
||||||
@@ -1321,6 +1526,16 @@ class ChatViewModel(
|
|||||||
else -> error.toString()
|
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
|
// Session resume failures go through the bounded retry (desktop
|
||||||
// parity) instead of a one-shot snackbar — the session may be
|
// 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
|
// 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 (text.isNotBlank()) "\n\n$text" else ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dispatchGeneration != sessionGeneration) return@launch
|
||||||
|
|
||||||
// While a turn is actively streaming and this is a plain text prompt
|
// While a turn is actively streaming and this is a plain text prompt
|
||||||
// (no attachments — session.redirect carries text only), steer the
|
// (no attachments — session.redirect carries text only), steer the
|
||||||
// in-flight turn via session.redirect instead of queueing a fresh
|
// in-flight turn via session.redirect instead of queueing a fresh
|
||||||
@@ -1600,6 +1817,7 @@ class ChatViewModel(
|
|||||||
if (dispatchGeneration == sessionGeneration) {
|
if (dispatchGeneration == sessionGeneration) {
|
||||||
ActiveSessionHolder.set(agentSessionId, storageSessionId)
|
ActiveSessionHolder.set(agentSessionId, storageSessionId)
|
||||||
}
|
}
|
||||||
|
captureTurnUsageBaselineIfNeeded()
|
||||||
if (wasStreaming && attachments.isEmpty()) {
|
if (wasStreaming && attachments.isEmpty()) {
|
||||||
wsClient.sendRedirect(
|
wsClient.sendRedirect(
|
||||||
agentSessionId,
|
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. */
|
/** Snapshot one URI to private cache while enforcing the outbound frame limit. */
|
||||||
private suspend fun prepareAttachment(attachment: Attachment): PrepareAttachmentResult {
|
private suspend fun prepareAttachment(attachment: Attachment): PrepareAttachmentResult {
|
||||||
var encodedFile: File? = null
|
var encodedFile: File? = null
|
||||||
@@ -2323,6 +2555,10 @@ class ChatViewModel(
|
|||||||
state.copy(
|
state.copy(
|
||||||
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
||||||
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
|
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
|
||||||
|
messageStatsEnabled = AuthManager.isMessageStatsEnabled(),
|
||||||
|
showUserMessageTokens = AuthManager.isUserMessageTokensEnabled(),
|
||||||
|
showAssistantMessageTokens = AuthManager.isAssistantMessageTokensEnabled(),
|
||||||
|
showTokensPerSecond = AuthManager.isTokensPerSecondEnabled(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2652,6 +2888,7 @@ class ChatViewModel(
|
|||||||
fullContextTokens = null,
|
fullContextTokens = null,
|
||||||
contextBreakdown = null,
|
contextBreakdown = null,
|
||||||
compressionCount = null,
|
compressionCount = null,
|
||||||
|
sessionUsage = null,
|
||||||
pendingAttachments = emptyList(),
|
pendingAttachments = emptyList(),
|
||||||
composerTextToRestore = null,
|
composerTextToRestore = null,
|
||||||
reactionKind = null,
|
reactionKind = null,
|
||||||
@@ -3279,7 +3516,7 @@ class ChatViewModel(
|
|||||||
)
|
)
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
val snapshot = parseUsageSnapshot(usage)
|
val snapshot = parseUsageSnapshot(usage)
|
||||||
if (snapshot != null && snapshot.compressions != null) {
|
if (snapshot != null) {
|
||||||
_uiState.update { current ->
|
_uiState.update { current ->
|
||||||
if (!isCurrentContextFetch(
|
if (!isCurrentContextFetch(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -3290,7 +3527,7 @@ class ChatViewModel(
|
|||||||
) {
|
) {
|
||||||
current
|
current
|
||||||
} else {
|
} 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
|
* 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
|
* (CLI timeout is 120s). A silent dismiss would leave the agent hanging
|
||||||
* until that timeout, so we send a cancel sentinel
|
* until that timeout, so we send an empty result frame for new gateways or
|
||||||
* ([CLARIFY_DISMISS_RESPONSE]) over `clarify.respond` to unblock it.
|
* the legacy cancel sentinel for old notification-based gateways.
|
||||||
*
|
*
|
||||||
* This is a *reject*, not an instruction to proceed — the agent is told no
|
* 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.
|
* 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 sessionId = _uiState.value.currentSessionId ?: return
|
||||||
val clarify = _uiState.value.clarifyRequest
|
val clarify = _uiState.value.clarifyRequest
|
||||||
val clarifyId = clarify?.clarifyId
|
val clarifyId = clarify?.clarifyId
|
||||||
|
val serverRequestId = clarify?.serverRequestId
|
||||||
// Raw batch questions (non-empty only for true batch payloads).
|
// Raw batch questions (non-empty only for true batch payloads).
|
||||||
// Legacy singles keep questions empty and rely on questionId (nullable).
|
// Legacy singles keep questions empty and rely on questionId (nullable).
|
||||||
val isBatch = !clarify?.questions.isNullOrEmpty()
|
val isBatch = !clarify?.questions.isNullOrEmpty()
|
||||||
@@ -3476,6 +3714,10 @@ class ChatViewModel(
|
|||||||
addSystemMessage("Clarify dismissed — no answer sent", persist = true)
|
addSystemMessage("Clarify dismissed — no answer sent", persist = true)
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
if (serverRequestId != null) {
|
||||||
|
wsClient.respondToServerRequest(serverRequestId, buildJsonObject {})
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
if (isBatch) {
|
if (isBatch) {
|
||||||
// Send dismissal for every question in the batch
|
// Send dismissal for every question in the batch
|
||||||
for (q in displayQuestions) {
|
for (q in displayQuestions) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
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.model.parseUsageSnapshot
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
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 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.
|
* Strips this turn's sealed-orphan prefix from the final message text.
|
||||||
*
|
*
|
||||||
@@ -83,7 +103,7 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.VaultCodeExpire -> event.sessionId
|
is WsEvent.VaultCodeExpire -> event.sessionId
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
|
||||||
return ReducerResult(state = state, streamingState = streamingState)
|
return ReducerResult(state = state, streamingState = streamingState)
|
||||||
}
|
}
|
||||||
val result = reduceInternal(state, streamingState, event)
|
val result = reduceInternal(state, streamingState, event)
|
||||||
@@ -164,6 +184,12 @@ object ChatWsEventReducer {
|
|||||||
// RpcResult is handled by the ViewModel (needs pending request context)
|
// RpcResult is handled by the ViewModel (needs pending request context)
|
||||||
is WsEvent.RpcResult -> ReducerResult(state = state, streamingState = streamingState)
|
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)
|
// ApprovalRequest is handled by the ViewModel (needs active session + WS client)
|
||||||
is WsEvent.ApprovalRequest -> ReducerResult(state = state, streamingState = streamingState)
|
is WsEvent.ApprovalRequest -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
@@ -236,8 +262,8 @@ object ChatWsEventReducer {
|
|||||||
streamingState.streamingMessage.copy(
|
streamingState.streamingMessage.copy(
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
|
tokenCount = null,
|
||||||
tps = state.latestTps?.takeIf { it > 0.0 },
|
tps = null,
|
||||||
)
|
)
|
||||||
orphan = finalized
|
orphan = finalized
|
||||||
state.copy(
|
state.copy(
|
||||||
@@ -258,6 +284,13 @@ object ChatWsEventReducer {
|
|||||||
// slate — no isReasoning flag, no inherited reasoning text.
|
// slate — no isReasoning flag, no inherited reasoning text.
|
||||||
isReasoning = false,
|
isReasoning = false,
|
||||||
reasoningText = "",
|
reasoningText = "",
|
||||||
|
turnUsageBaseline =
|
||||||
|
if (streamingState.turnUsageBaselineCaptured) {
|
||||||
|
streamingState.turnUsageBaseline
|
||||||
|
} else {
|
||||||
|
state.sessionUsage
|
||||||
|
},
|
||||||
|
turnUsageBaselineCaptured = true,
|
||||||
)
|
)
|
||||||
val newState = preState
|
val newState = preState
|
||||||
val sid = newState.currentSessionId
|
val sid = newState.currentSessionId
|
||||||
@@ -369,6 +402,9 @@ object ChatWsEventReducer {
|
|||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
event: WsEvent.MessageComplete,
|
event: WsEvent.MessageComplete,
|
||||||
): ReducerResult {
|
): 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
|
val streaming = streamingState.streamingMessage
|
||||||
// Prefer the authoritative reasoning carried in the message.complete
|
// Prefer the authoritative reasoning carried in the message.complete
|
||||||
// payload (the gateway's assembled full trace), then fall back to
|
// 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 +
|
// exactly once, matching the persisted transcript (sealed orphans +
|
||||||
// stripped answer). Turns whose final text does NOT contain the
|
// stripped answer). Turns whose final text does NOT contain the
|
||||||
// commentary are left untouched (orphans stay, final keeps its text).
|
// 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
|
// Edge: the complete payload was entirely covered by sealed orphans
|
||||||
// (the interim text already rendered as its own bubble(s)) — nothing
|
// (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()) {
|
if (text.isBlank()) {
|
||||||
return ReducerResult(
|
return ReducerResult(
|
||||||
state = state.copy(isAgentTyping = false),
|
state = usageState.copy(isAgentTyping = false),
|
||||||
streamingState = streamingState.copy(streamingMessage = null),
|
streamingState = StreamingState(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val rawUsage = event.rawPayload?.get("usage") as? Map<*, *>
|
val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull()
|
||||||
val tpsFromPayload = (rawUsage?.get("avg_tps") as? Number)?.toDouble()
|
val tokenCount =
|
||||||
val tps = (tpsFromPayload ?: state.latestTps)?.takeIf { it > 0.0 }
|
if (turnUsage?.outputTokens != null) {
|
||||||
val tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }
|
turnUsage.outputTokens.toIntOrNullSafely()
|
||||||
|
} else {
|
||||||
|
TokenEstimator.estimate(text).takeIf { it > 0 }
|
||||||
|
}
|
||||||
val msg =
|
val msg =
|
||||||
streaming?.copy(
|
streaming?.copy(
|
||||||
content = text,
|
content = text,
|
||||||
@@ -434,11 +475,12 @@ object ChatWsEventReducer {
|
|||||||
}
|
}
|
||||||
return ReducerResult(
|
return ReducerResult(
|
||||||
state =
|
state =
|
||||||
state.copy(
|
usageState.copy(
|
||||||
messages = state.messages.upsertById(msg),
|
messages = usageState.messages.upsertById(msg),
|
||||||
isAgentTyping = false,
|
isAgentTyping = false,
|
||||||
clarifyRequest = null,
|
clarifyRequest = null,
|
||||||
),
|
),
|
||||||
|
streamingState = StreamingState(),
|
||||||
effects = effects,
|
effects = effects,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -450,10 +492,11 @@ object ChatWsEventReducer {
|
|||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
val streaming =
|
val streaming =
|
||||||
streamingState.streamingMessage ?: return ReducerResult(
|
streamingState.streamingMessage
|
||||||
state = state,
|
?: return ReducerResult(
|
||||||
streamingState = streamingState,
|
state = state.copy(isAgentTyping = false),
|
||||||
)
|
streamingState = StreamingState(),
|
||||||
|
)
|
||||||
val reasoning =
|
val reasoning =
|
||||||
if (streamingState.reasoningText.isNotBlank()) {
|
if (streamingState.reasoningText.isNotBlank()) {
|
||||||
streamingState.reasoningText
|
streamingState.reasoningText
|
||||||
@@ -486,6 +529,7 @@ object ChatWsEventReducer {
|
|||||||
messages = state.messages.upsertById(msg),
|
messages = state.messages.upsertById(msg),
|
||||||
isAgentTyping = false,
|
isAgentTyping = false,
|
||||||
),
|
),
|
||||||
|
streamingState = StreamingState(),
|
||||||
effects = effects,
|
effects = effects,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -532,8 +576,8 @@ object ChatWsEventReducer {
|
|||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
reasoningText = reasoning,
|
reasoningText = reasoning,
|
||||||
finishTimestamp = System.currentTimeMillis(),
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
|
tokenCount = null,
|
||||||
tps = state.latestTps?.takeIf { it > 0.0 },
|
tps = null,
|
||||||
)
|
)
|
||||||
orphanToPersist = finalized
|
orphanToPersist = finalized
|
||||||
state.copy(
|
state.copy(
|
||||||
@@ -572,7 +616,12 @@ object ChatWsEventReducer {
|
|||||||
val nextTodos = parsedTodos ?: newState.todos
|
val nextTodos = parsedTodos ?: newState.todos
|
||||||
|
|
||||||
return ReducerResult(
|
return ReducerResult(
|
||||||
state = newState.copy(messages = newState.messages + toolMessage, todos = nextTodos),
|
state =
|
||||||
|
newState.copy(
|
||||||
|
messages = newState.messages + toolMessage,
|
||||||
|
isAgentTyping = true,
|
||||||
|
todos = nextTodos,
|
||||||
|
),
|
||||||
streamingState = finalStreamingState,
|
streamingState = finalStreamingState,
|
||||||
effects = effects,
|
effects = effects,
|
||||||
)
|
)
|
||||||
@@ -706,6 +755,8 @@ object ChatWsEventReducer {
|
|||||||
questionId = event.questionId,
|
questionId = event.questionId,
|
||||||
multiSelect = event.multiSelect,
|
multiSelect = event.multiSelect,
|
||||||
questions = questions,
|
questions = questions,
|
||||||
|
serverRequestId = event.serverRequestId,
|
||||||
|
lockedAnswers = event.lockedAnswers,
|
||||||
),
|
),
|
||||||
isAgentTyping = false,
|
isAgentTyping = false,
|
||||||
),
|
),
|
||||||
@@ -802,10 +853,11 @@ object ChatWsEventReducer {
|
|||||||
ReducerResult(
|
ReducerResult(
|
||||||
state =
|
state =
|
||||||
state.copy(
|
state.copy(
|
||||||
|
isAgentTyping = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}",
|
errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}",
|
||||||
),
|
),
|
||||||
streamingState = streamingState,
|
streamingState = StreamingState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── BackgroundComplete ────────────────────────────────────────────
|
// ── BackgroundComplete ────────────────────────────────────────────
|
||||||
@@ -983,20 +1035,7 @@ object ChatWsEventReducer {
|
|||||||
val snapshot =
|
val snapshot =
|
||||||
parseUsageSnapshot(event.data)
|
parseUsageSnapshot(event.data)
|
||||||
?: return ReducerResult(state = state, streamingState = streamingState)
|
?: return ReducerResult(state = state, streamingState = streamingState)
|
||||||
var updatedState = state
|
return ReducerResult(state = applyUsageSnapshot(state, snapshot), streamingState = streamingState)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
|
||||||
|
|
||||||
data class StreamingState(
|
data class StreamingState(
|
||||||
val streamingMessage: ChatMessage? = null,
|
val streamingMessage: ChatMessage? = null,
|
||||||
val isThinking: Boolean = false,
|
val isThinking: Boolean = false,
|
||||||
@@ -14,4 +16,8 @@ data class StreamingState(
|
|||||||
* every fresh StreamingState (message.start / interrupt / session switch).
|
* every fresh StreamingState (message.start / interrupt / session switch).
|
||||||
*/
|
*/
|
||||||
val sealedOrphanIds: List<String> = emptyList(),
|
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
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
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.material.icons.filled.Close
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -51,7 +44,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
@@ -76,8 +67,8 @@ import com.m57.hermescontrol.ui.common.BotAvatar
|
|||||||
import com.m57.hermescontrol.util.BidiUtils
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat input bar with a two-row layout: input+send on top,
|
* The chat input bar: a single rounded card with the input on top and a
|
||||||
* and a toolbar with attach/model chip/reasoning chip/mic below.
|
* controls row (attach, model/reasoning pill, mic, send) inside it below.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInputBar(
|
fun ChatInputBar(
|
||||||
@@ -114,31 +105,27 @@ fun ChatInputBar(
|
|||||||
// dropped. Slash commands were already allowed; regular prompts now are too.
|
// dropped. Slash commands were already allowed; regular prompts now are too.
|
||||||
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
|
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
|
||||||
|
|
||||||
// Attachment menu state
|
// Attachment tray state
|
||||||
var showAttachmentMenu by remember { mutableStateOf(false) }
|
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
val palette = composerPalette()
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = true,
|
visible = true,
|
||||||
enter = slideInVertically(initialOffsetY = { it }),
|
enter = slideInVertically(initialOffsetY = { it }),
|
||||||
exit = slideOutVertically(targetOffsetY = { 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(
|
Surface(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = MaterialTheme.shapes.large,
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = palette.card,
|
||||||
border =
|
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||||
BorderStroke(
|
|
||||||
width = 1.dp,
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
|
|
||||||
),
|
|
||||||
tonalElevation = 2.dp,
|
|
||||||
shadowElevation = 4.dp,
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||||
// TUI-only commands that don't function on mobile (issue #574).
|
// TUI-only commands that don't function on mobile (issue #574).
|
||||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
// 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(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(horizontal = 20.dp, vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
val placeholderText =
|
val placeholderText =
|
||||||
@@ -257,109 +244,43 @@ fun ChatInputBar(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.heightIn(min = 42.dp, max = 120.dp)
|
.heightIn(min = 42.dp, max = 200.dp)
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
|
||||||
.testTag("chat_input"),
|
.testTag("chat_input"),
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
textStyle =
|
textStyle =
|
||||||
MaterialTheme.typography.bodyMedium.copy(
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = palette.text,
|
||||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
),
|
),
|
||||||
singleLine = false,
|
singleLine = false,
|
||||||
maxLines = 4,
|
maxLines = 8,
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(palette.text),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
|
||||||
Surface(
|
Box(
|
||||||
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,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment =
|
||||||
|
if (isInputRtl) {
|
||||||
|
Alignment.CenterEnd
|
||||||
|
} else {
|
||||||
|
Alignment.CenterStart
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Row(
|
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||||
modifier =
|
if (inputFieldValue.text.isEmpty()) {
|
||||||
Modifier
|
Text(
|
||||||
.padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp)
|
text = placeholderText,
|
||||||
.fillMaxWidth(),
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
) {
|
color = palette.placeholder,
|
||||||
Box(
|
maxLines = 1,
|
||||||
modifier = Modifier.weight(1f),
|
overflow = TextOverflow.Ellipsis,
|
||||||
contentAlignment =
|
modifier = Modifier.fillMaxWidth(),
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
innerTextField()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,7 +295,9 @@ fun ChatInputBar(
|
|||||||
currentSessionModel = currentSessionModel,
|
currentSessionModel = currentSessionModel,
|
||||||
reasoningLevel = reasoningLevel,
|
reasoningLevel = reasoningLevel,
|
||||||
isListening = isListening,
|
isListening = isListening,
|
||||||
onAttachTap = { showAttachmentMenu = true },
|
canSend = canSend,
|
||||||
|
onSend = onSend,
|
||||||
|
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||||
onModelTap = onModelTap,
|
onModelTap = onModelTap,
|
||||||
onReasoningSelected = onReasoningTap,
|
onReasoningSelected = onReasoningTap,
|
||||||
onMicTap = onMicTap,
|
onMicTap = onMicTap,
|
||||||
@@ -387,52 +310,13 @@ fun ChatInputBar(
|
|||||||
onToggleFastMode = onToggleFastMode,
|
onToggleFastMode = onToggleFastMode,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
|
AttachmentTray(
|
||||||
// Shown overlaid at the toolbar level
|
visible = showAttachmentTray,
|
||||||
DropdownMenu(
|
onDismissRequest = { showAttachmentTray = false },
|
||||||
expanded = showAttachmentMenu,
|
onCameraTap = onCameraTap,
|
||||||
onDismissRequest = { showAttachmentMenu = false },
|
onImageTap = onImageTap,
|
||||||
) {
|
onFileTap = onFileTap,
|
||||||
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,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,15 @@ fun ChatLifecycleEffects(
|
|||||||
|
|
||||||
// Switch to session from notification/history
|
// Switch to session from notification/history
|
||||||
val pendingNavigation = NavigationController.pendingChatNavigation
|
val pendingNavigation = NavigationController.pendingChatNavigation
|
||||||
LaunchedEffect(sessionId, pendingNavigation, connectionStatus) {
|
val pendingNewChatNavigation = NavigationController.pendingNewChatNavigation
|
||||||
|
LaunchedEffect(sessionId, pendingNavigation, pendingNewChatNavigation, connectionStatus) {
|
||||||
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
|
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
|
||||||
|
val newChatRequest = NavigationController.consumePendingNewChatNavigation()
|
||||||
val request = NavigationController.consumePendingChatNavigation()
|
val request = NavigationController.consumePendingChatNavigation()
|
||||||
|
if (newChatRequest != null) {
|
||||||
|
viewModel.createNewSession()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
val target = request?.sessionId ?: sessionId
|
val target = request?.sessionId ?: sessionId
|
||||||
if (!target.isNullOrBlank()) {
|
if (!target.isNullOrBlank()) {
|
||||||
viewModel.switchSession(target)
|
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
|
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.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.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.filled.Stop
|
||||||
|
import androidx.compose.material.icons.outlined.Mic
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButtonColors
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.draw.scale
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -46,16 +64,24 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.m57.hermescontrol.R
|
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
|
* When [canDisableReasoning] is false the "None" level is disabled with a
|
||||||
* "reasoning always on" hint (issue #946). Absent key (null) means no
|
* "reasoning always on" hint (issue #946). Absent key (null) means no
|
||||||
* restriction is known — full scale offered.
|
* restriction is known — full scale offered.
|
||||||
* When [supportsReasoning] is false the model takes no reasoning parameter
|
* When [supportsReasoning] is false the model takes no reasoning parameter
|
||||||
* and the chip is disabled.
|
* and the level menu is disabled.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposerToolbar(
|
fun ComposerToolbar(
|
||||||
@@ -68,6 +94,8 @@ fun ComposerToolbar(
|
|||||||
onReasoningSelected: (String?) -> Unit,
|
onReasoningSelected: (String?) -> Unit,
|
||||||
onMicTap: () -> Unit,
|
onMicTap: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
canSend: Boolean = false,
|
||||||
|
onSend: () -> Unit = {},
|
||||||
canDisableReasoning: Boolean? = null,
|
canDisableReasoning: Boolean? = null,
|
||||||
supportsReasoning: Boolean? = null,
|
supportsReasoning: Boolean? = null,
|
||||||
fastMode: Boolean = false,
|
fastMode: Boolean = false,
|
||||||
@@ -76,6 +104,7 @@ fun ComposerToolbar(
|
|||||||
onToggleFastMode: () -> Unit = {},
|
onToggleFastMode: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var showReasoningMenu by remember { mutableStateOf(false) }
|
var showReasoningMenu by remember { mutableStateOf(false) }
|
||||||
|
val palette = composerPalette()
|
||||||
val reasoningDisabledForModel = supportsReasoning == false
|
val reasoningDisabledForModel = supportsReasoning == false
|
||||||
val canDisable = canDisableReasoning
|
val canDisable = canDisableReasoning
|
||||||
|
|
||||||
@@ -83,62 +112,88 @@ fun ComposerToolbar(
|
|||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
.padding(horizontal = 12.dp)
|
||||||
|
.padding(top = 6.dp)
|
||||||
.testTag("composer_toolbar"),
|
.testTag("composer_toolbar"),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
// Attach button
|
// Attach button
|
||||||
IconButton(
|
FilledIconButton(
|
||||||
onClick = onAttachTap,
|
onClick = onAttachTap,
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
modifier = Modifier.size(36.dp),
|
colors = flatIconButtonColors(palette),
|
||||||
|
modifier = Modifier.size(ControlSize).testTag("attachment_button"),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.AttachFile,
|
imageVector = Icons.Default.Add,
|
||||||
contentDescription = stringResource(R.string.chat_attach_file),
|
contentDescription = stringResource(R.string.chat_attach_file),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model chip — takes available space, fixed height
|
// Model + reasoning pill — wraps its content inside the free space,
|
||||||
FilterChip(
|
// pushing the mic/action buttons to the far end
|
||||||
selected = currentSessionModel != null,
|
val modelScrollState = rememberScrollState()
|
||||||
onClick = onModelTap,
|
LaunchedEffect(currentSessionModel) {
|
||||||
label = {
|
modelScrollState.scrollTo(0)
|
||||||
Row(
|
}
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
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(
|
||||||
text = currentSessionModel ?: "Model",
|
// Keeps the provider so same-named models from different providers
|
||||||
style = MaterialTheme.typography.bodySmall,
|
// stay distinguishable while allowing the full label to be revealed.
|
||||||
|
text = currentSessionModel?.let(::composerModelLabel) ?: "Model",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = palette.onControl,
|
||||||
maxLines = 1,
|
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 {
|
||||||
Box {
|
|
||||||
FilterChip(
|
|
||||||
selected = reasoningLevel != null || fastMode,
|
|
||||||
onClick = { showReasoningMenu = true },
|
|
||||||
enabled = isConnected,
|
|
||||||
label = {
|
|
||||||
Row(
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clickable(enabled = isConnected) { showReasoningMenu = true }
|
||||||
|
.padding(start = 6.dp, end = 12.dp)
|
||||||
|
.testTag("reasoning_chip"),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
|
val levelColor =
|
||||||
|
palette.onControlVariant.copy(
|
||||||
|
alpha = if (isConnected) 1f else 0.38f,
|
||||||
|
)
|
||||||
if (fastMode) {
|
if (fastMode) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Bolt,
|
imageVector = Icons.Filled.Bolt,
|
||||||
contentDescription = stringResource(R.string.chat_fast_mode_label),
|
contentDescription = stringResource(R.string.chat_fast_mode_label),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = levelColor,
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(14.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -156,175 +211,265 @@ fun ComposerToolbar(
|
|||||||
rLabel
|
rLabel
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = levelColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
// Chevron marks the level half of the pill as a menu trigger
|
||||||
},
|
|
||||||
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 = {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Bolt,
|
imageVector = Icons.Filled.ExpandMore,
|
||||||
contentDescription = null,
|
contentDescription = stringResource(R.string.chat_reasoning_menu_desc),
|
||||||
tint =
|
tint = levelColor,
|
||||||
if (fastMode) {
|
modifier = Modifier.size(16.dp),
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
text = {
|
|
||||||
Column {
|
DropdownMenu(
|
||||||
Text(
|
expanded = showReasoningMenu,
|
||||||
text = stringResource(R.string.chat_fast_mode_label),
|
onDismissRequest = { showReasoningMenu = false },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
modifier = Modifier.widthIn(min = 220.dp),
|
||||||
fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal,
|
) {
|
||||||
)
|
// ── Fast Mode Toggle Item ──
|
||||||
if (!fastSupported) {
|
val fastAvailable = isConnected && fastSupported
|
||||||
Text(
|
DropdownMenuItem(
|
||||||
text = stringResource(R.string.chat_fast_mode_unavailable),
|
leadingIcon = {
|
||||||
style = MaterialTheme.typography.labelSmall,
|
Icon(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
imageVector = Icons.Filled.Bolt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint =
|
||||||
|
if (fastMode) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}
|
text = {
|
||||||
},
|
Column {
|
||||||
trailingIcon = {
|
Text(
|
||||||
if (isFastModeChanging) {
|
text = stringResource(R.string.chat_fast_mode_label),
|
||||||
CircularProgressIndicator(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.size(16.dp),
|
fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
strokeWidth = 2.dp,
|
)
|
||||||
color = MaterialTheme.colorScheme.primary,
|
if (!fastSupported) {
|
||||||
)
|
Text(
|
||||||
} else {
|
text = stringResource(R.string.chat_fast_mode_unavailable),
|
||||||
Switch(
|
style = MaterialTheme.typography.labelSmall,
|
||||||
checked = fastMode,
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
onCheckedChange = null, // MenuItem click owns the trigger
|
)
|
||||||
enabled = fastAvailable && !isFastModeChanging,
|
}
|
||||||
modifier =
|
}
|
||||||
Modifier
|
},
|
||||||
.scale(0.85f)
|
trailingIcon = {
|
||||||
.testTag("fast_mode_switch"),
|
if (isFastModeChanging) {
|
||||||
)
|
CircularProgressIndicator(
|
||||||
}
|
modifier = Modifier.size(16.dp),
|
||||||
},
|
strokeWidth = 2.dp,
|
||||||
onClick = {
|
color = MaterialTheme.colorScheme.primary,
|
||||||
if (fastAvailable && !isFastModeChanging) {
|
)
|
||||||
onToggleFastMode()
|
} else {
|
||||||
}
|
Switch(
|
||||||
},
|
checked = fastMode,
|
||||||
enabled = fastAvailable && !isFastModeChanging,
|
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(
|
||||||
text = "REASONING",
|
text = "REASONING",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
if (canDisable == false) {
|
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(
|
||||||
text = label,
|
text = "reasoning always on",
|
||||||
fontWeight =
|
style = MaterialTheme.typography.labelSmall,
|
||||||
if (reasoningLevel == level) {
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
MaterialTheme.typography.bodyMedium.fontWeight
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
color =
|
|
||||||
when {
|
|
||||||
noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
reasoningLevel == level -> MaterialTheme.colorScheme.primary
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
onClick = {
|
if (reasoningDisabledForModel) {
|
||||||
showReasoningMenu = false
|
Text(
|
||||||
onReasoningSelected(level)
|
text = "no reasoning parameter for this model",
|
||||||
},
|
style = MaterialTheme.typography.labelSmall,
|
||||||
enabled = !noneDisabled && !reasoningDisabledForModel,
|
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
|
// Flat mic / stop button — only while the action button is in send mode
|
||||||
IconButton(
|
AnimatedVisibility(
|
||||||
onClick = onMicTap,
|
visible = canSend,
|
||||||
enabled = isConnected,
|
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 =
|
colors =
|
||||||
if (isListening) {
|
if (!canSend && isListening) {
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
listeningIconButtonColors()
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onError,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
IconButtonDefaults.filledTonalIconButtonColors()
|
IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = palette.action,
|
||||||
|
contentColor = palette.onAction,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(36.dp)
|
.size(ControlSize)
|
||||||
.testTag(if (isListening) "mic_stop_button" else "mic_button"),
|
.testTag(
|
||||||
|
when {
|
||||||
|
canSend -> "send_button"
|
||||||
|
isListening -> "mic_stop_button"
|
||||||
|
else -> "mic_button"
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Crossfade(
|
||||||
imageVector = if (isListening) Icons.Default.Stop else Icons.Default.Mic,
|
targetState =
|
||||||
contentDescription = if (isListening) "Stop listening" else "Mic",
|
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.
|
* 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.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
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.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
@@ -473,6 +475,7 @@ fun ClarifyBubble(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
questions.forEachIndexed { index, q ->
|
questions.forEachIndexed { index, q ->
|
||||||
|
val lockedAnswer = clarifyRequest.lockedAnswers[q.qid]
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(vertical = 12.dp),
|
modifier = Modifier.padding(vertical = 12.dp),
|
||||||
@@ -497,79 +500,105 @@ fun ClarifyBubble(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (q.multiSelect) {
|
if (lockedAnswer != null) {
|
||||||
Spacer(Modifier.height(2.dp))
|
Surface(
|
||||||
Text(
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
text = "Select all that apply",
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
shape = RoundedCornerShape(8.dp),
|
||||||
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),
|
|
||||||
) {
|
) {
|
||||||
q.choices.forEach { choice ->
|
Row(
|
||||||
val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet()
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
val isSelected = selectedSet.contains(choice)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
FilterChip(
|
) {
|
||||||
selected = isSelected,
|
Icon(
|
||||||
onClick = {
|
imageVector = Icons.Default.Check,
|
||||||
if (q.multiSelect) {
|
contentDescription = null,
|
||||||
val updated = if (isSelected) selectedSet - choice else selectedSet + choice
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated)
|
modifier = Modifier.size(18.dp),
|
||||||
} else {
|
)
|
||||||
if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) {
|
Spacer(Modifier.width(6.dp))
|
||||||
// Fast 1-tap respond for lone single-select question when no custom text is entered
|
Text(
|
||||||
onRespondSingle(choice)
|
text = "Answered: $lockedAnswer",
|
||||||
} else {
|
style = MaterialTheme.typography.bodySmall,
|
||||||
val updated = if (isSelected) emptySet() else setOf(choice)
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
if (q.multiSelect) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
val typed = customTextByQid[q.qid].orEmpty()
|
|
||||||
OutlinedTextField(
|
|
||||||
value = typed,
|
|
||||||
onValueChange = { newText ->
|
|
||||||
customTextByQid = customTextByQid + (q.qid to newText)
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(
|
Text(
|
||||||
if (q.choices.isEmpty()) {
|
text = "Select all that apply",
|
||||||
stringResource(R.string.message_your_response)
|
style = MaterialTheme.typography.labelSmall,
|
||||||
} else {
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
"Other (optional)"
|
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))
|
Spacer(Modifier.height(12.dp))
|
||||||
@@ -719,13 +748,19 @@ fun SubagentCard(
|
|||||||
// ── TypingIndicator ───────────────────────────────────────────────────────
|
// ── TypingIndicator ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Three bouncing dots shown while the assistant is typing / thinking.
|
* Three subtle dots shown while the assistant is waiting to produce visible
|
||||||
* Staggered animation: 0ms, 150ms, 300ms delay per dot.
|
* content. Staggered opacity/scale animation keeps the indicator lightweight
|
||||||
|
* without the distracting vertical bounce used by the old chat renderer.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TypingIndicator(modifier: Modifier = Modifier) {
|
fun TypingIndicator(modifier: Modifier = Modifier) {
|
||||||
|
val description = stringResource(R.string.chat_agent_status_typing)
|
||||||
Row(
|
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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
@@ -741,18 +776,18 @@ private fun TypingDot(delayMs: Int) {
|
|||||||
val typingSpec: InfiniteRepeatableSpec<Float> =
|
val typingSpec: InfiniteRepeatableSpec<Float> =
|
||||||
remember(delayMs) {
|
remember(delayMs) {
|
||||||
infiniteRepeatable(
|
infiniteRepeatable(
|
||||||
animation = tween(400, delayMillis = delayMs, easing = LinearEasing),
|
animation = tween(700, delayMillis = delayMs, easing = LinearEasing),
|
||||||
repeatMode = RepeatMode.Reverse,
|
repeatMode = RepeatMode.Reverse,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val offset by infiniteTransition.animateFloat(
|
val offset by infiniteTransition.animateFloat(
|
||||||
initialValue = 0f,
|
initialValue = 0.85f,
|
||||||
targetValue = -6f,
|
targetValue = 1f,
|
||||||
animationSpec = typingSpec,
|
animationSpec = typingSpec,
|
||||||
label = "typing_dot_offset_$delayMs",
|
label = "typing_dot_scale_$delayMs",
|
||||||
)
|
)
|
||||||
val alpha by infiniteTransition.animateFloat(
|
val alpha by infiniteTransition.animateFloat(
|
||||||
initialValue = 0.4f,
|
initialValue = 0.45f,
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
animationSpec = typingSpec,
|
animationSpec = typingSpec,
|
||||||
label = "typing_dot_alpha_$delayMs",
|
label = "typing_dot_alpha_$delayMs",
|
||||||
@@ -763,7 +798,8 @@ private fun TypingDot(delayMs: Int) {
|
|||||||
.size(8.dp)
|
.size(8.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
this.translationY = offset
|
this.scaleX = offset
|
||||||
|
this.scaleY = offset
|
||||||
this.alpha = alpha
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-96
@@ -1,15 +1,16 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||||
|
|
||||||
import android.content.ClipData
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
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.Modifier
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
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
|
* Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders
|
||||||
* directly on the background — no bubble container, no width cap — with a
|
* directly on the background — no bubble container, no width cap — with a
|
||||||
* turn header (role + time) and a trailing copy affordance. User messages
|
* trailing copy affordance. User messages keep their bubbles; this composable
|
||||||
* keep their bubbles; this composable is only used for ASSISTANT messages.
|
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun FullBleedAgentMessage(
|
internal fun FullBleedAgentMessage(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
showTurnHeader: Boolean,
|
|
||||||
isDarkTheme: Boolean,
|
|
||||||
searchQuery: String = "",
|
searchQuery: String = "",
|
||||||
isCurrentMatch: Boolean = false,
|
isCurrentMatch: Boolean = false,
|
||||||
showReasoning: Boolean = true,
|
showReasoning: Boolean = true,
|
||||||
@@ -70,6 +65,9 @@ internal fun FullBleedAgentMessage(
|
|||||||
openingAttachmentPath: String? = null,
|
openingAttachmentPath: String? = null,
|
||||||
canSaveAttachment: Boolean = true,
|
canSaveAttachment: Boolean = true,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
|
messageStatsEnabled: Boolean = false,
|
||||||
|
showAssistantMessageTokens: Boolean = true,
|
||||||
|
showTokensPerSecond: Boolean = true,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val textColor = MaterialTheme.colorScheme.onSurface
|
val textColor = MaterialTheme.colorScheme.onSurface
|
||||||
@@ -92,10 +90,6 @@ internal fun FullBleedAgentMessage(
|
|||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.testTag("fullbleed_agent_message"),
|
.testTag("fullbleed_agent_message"),
|
||||||
) {
|
) {
|
||||||
if (showTurnHeader) {
|
|
||||||
AssistantTurnHeader(message.timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showReasoning && message.reasoningText.isNotBlank()) {
|
if (showReasoning && message.reasoningText.isNotBlank()) {
|
||||||
ReasoningCard(
|
ReasoningCard(
|
||||||
reasoningText = message.reasoningText,
|
reasoningText = message.reasoningText,
|
||||||
@@ -105,10 +99,10 @@ internal fun FullBleedAgentMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Defense-in-depth: never render an empty prose block (blank bubble +
|
// Defense-in-depth: never render an empty prose block (blank bubble +
|
||||||
// lone Copy button). Blank settled rows are tool-call placeholders that
|
// lone Copy button). Blank rows are tool-call placeholders that slipped
|
||||||
// slipped through upstream mapping; streaming keeps rendering so the
|
// through upstream mapping; the parent list renders the live status
|
||||||
// live cursor survives until the first delta lands.
|
// indicator until the first visible delta lands.
|
||||||
if (message.content.isNotBlank() || message.isStreaming) {
|
if (message.content.isNotBlank()) {
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
@@ -140,9 +134,17 @@ internal fun FullBleedAgentMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!message.isStreaming) {
|
if (!message.isStreaming && message.content.isNotBlank()) {
|
||||||
Row(
|
val showTokenStat =
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -160,54 +162,24 @@ internal fun FullBleedAgentMessage(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
if (showTokenStat) {
|
||||||
val finishTime = message.finishTimestamp ?: message.timestamp
|
AssistantStatItem(
|
||||||
Text(
|
value =
|
||||||
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 =
|
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.chat_msg_tokens,
|
R.string.chat_msg_tokens,
|
||||||
TokenEstimator.formatTokenCount(message.tokenCount),
|
TokenEstimator.formatTokenCount(message.tokenCount),
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
testTag = "fullbleed_token_count",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier.testTag("fullbleed_token_count"),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (message.tps != null && message.tps > 0.0) {
|
if (showTpsStat) {
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
AssistantStatItem(
|
||||||
Text(
|
value =
|
||||||
text = "•",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.chat_msg_tps,
|
R.string.chat_msg_tps,
|
||||||
TokenEstimator.formatTps(message.tps),
|
TokenEstimator.formatTps(message.tps),
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
testTag = "fullbleed_tps",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier.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
|
@Composable
|
||||||
internal fun AssistantTurnHeader(
|
private fun AssistantStatItem(
|
||||||
timestamp: Long,
|
value: String,
|
||||||
modifier: Modifier = Modifier,
|
testTag: String,
|
||||||
) {
|
) {
|
||||||
Row(
|
Text(
|
||||||
modifier = modifier.padding(bottom = 4.dp).testTag("fullbleed_agent_header"),
|
text = value,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
) {
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
Text(
|
modifier = Modifier.testTag(testTag),
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,7 @@ import com.m57.hermescontrol.ui.chat.ChatSearchState
|
|||||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
||||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
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
|
* The chat message list for FULL-BLEED style (issue #866) — the single chat
|
||||||
* surface since the bubble renderer was removed. User messages keep their
|
* surface since the bubble renderer was removed. User messages keep their
|
||||||
* bubble (the universal anchor), agent turns render full-bleed with a turn
|
* bubble (the universal anchor), agent turns render full-bleed, and tool rows
|
||||||
* header, and tool rows / system events render as distinct compact cards.
|
* / system events render as distinct compact cards.
|
||||||
* Spacing contract:
|
* Spacing contract:
|
||||||
* - intra-turn: entries separated by 6.dp (Column padding on agent turn items)
|
* - 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
|
* - 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
|
* COMPOSE GOTCHA (verified): LazyColumn `item {}` content lambdas execute
|
||||||
* LAZILY at item-composition time, not during this DSL-building loop. Loop
|
* LAZILY at item-composition time, not during this DSL-building loop. Loop
|
||||||
* locals that are read inside item lambdas must be captured as immutable
|
* 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
|
* vals FIRST (eagerly), or every item sees the loop's final value and
|
||||||
* turn header would never render and milestones would be misindexed.
|
* per-entry state can be rendered against the wrong message.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun FullBleedChatList(
|
fun FullBleedChatList(
|
||||||
messages: List<ChatMessage>,
|
messages: List<ChatMessage>,
|
||||||
streamingMessage: ChatMessage?,
|
streamingState: StreamingState,
|
||||||
|
isAgentTyping: Boolean,
|
||||||
searchState: ChatSearchState,
|
searchState: ChatSearchState,
|
||||||
typingEffectEnabled: Boolean,
|
typingEffectEnabled: Boolean,
|
||||||
typingEffectDelayMs: Int,
|
typingEffectDelayMs: Int,
|
||||||
|
messageStatsEnabled: Boolean = false,
|
||||||
|
showUserMessageTokens: Boolean = true,
|
||||||
|
showAssistantMessageTokens: Boolean = true,
|
||||||
|
showTokensPerSecond: Boolean = true,
|
||||||
maxToolCallsPerTurn: Int? = null,
|
maxToolCallsPerTurn: Int? = null,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
isLoadingOlder: Boolean,
|
isLoadingOlder: Boolean,
|
||||||
isDark: Boolean,
|
|
||||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
scrollController: ChatScrollController,
|
scrollController: ChatScrollController,
|
||||||
lastAnimatedMessageId: String?,
|
lastAnimatedMessageId: String?,
|
||||||
@@ -89,7 +94,7 @@ fun FullBleedChatList(
|
|||||||
openingAttachmentPath: String? = null,
|
openingAttachmentPath: String? = null,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty() && !isLoading) {
|
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -102,9 +107,10 @@ fun FullBleedChatList(
|
|||||||
} else {
|
} else {
|
||||||
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
|
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
|
||||||
val turns =
|
val turns =
|
||||||
remember(messages, streamingMessage) {
|
remember(messages, streamingState.streamingMessage) {
|
||||||
groupIntoTurnsWithStreaming(messages, streamingMessage)
|
groupIntoTurnsWithStreaming(messages, streamingState.streamingMessage)
|
||||||
}
|
}
|
||||||
|
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, messages)
|
||||||
// messageId → LazyColumn item index. The lazy list has EXTRA items
|
// messageId → LazyColumn item index. The lazy list has EXTRA items
|
||||||
// vs the message list (reasoning hoists, tool rows), so search-match
|
// vs the message list (reasoning hoists, tool rows), so search-match
|
||||||
// scrolling must resolve the lazy index, not use the message index.
|
// scrolling must resolve the lazy index, not use the message index.
|
||||||
@@ -182,13 +188,14 @@ fun FullBleedChatList(
|
|||||||
savingAttachmentPath = savingAttachmentPath,
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
openingAttachmentPath = openingAttachmentPath,
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is ChatTurn.Agent -> {
|
is ChatTurn.Agent -> {
|
||||||
var firstProseSeen = false
|
|
||||||
// Reasoning hoist: the turn's reasoning renders at the
|
// Reasoning hoist: the turn's reasoning renders at the
|
||||||
// TOP of the turn — above tool rows — so thinking
|
// TOP of the turn — above tool rows — so thinking
|
||||||
// leads, then the tool work, then the answer. The
|
// leads, then the tool work, then the answer. The
|
||||||
@@ -201,9 +208,6 @@ fun FullBleedChatList(
|
|||||||
val reasoning = turnReasoning.message
|
val reasoning = turnReasoning.message
|
||||||
item(key = "reasoning-${reasoning.id}") {
|
item(key = "reasoning-${reasoning.id}") {
|
||||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
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(
|
ReasoningCard(
|
||||||
reasoningText = reasoning.reasoningText,
|
reasoningText = reasoning.reasoningText,
|
||||||
isStreaming = reasoning.isStreaming,
|
isStreaming = reasoning.isStreaming,
|
||||||
@@ -215,50 +219,49 @@ fun FullBleedChatList(
|
|||||||
when (entry) {
|
when (entry) {
|
||||||
is AgentEntry.Prose -> {
|
is AgentEntry.Prose -> {
|
||||||
val proseMessage = entry.message
|
val proseMessage = entry.message
|
||||||
val showTurnHeader = !firstProseSeen && turnReasoning == null
|
|
||||||
val hoistedReasoning =
|
val hoistedReasoning =
|
||||||
turnReasoning != null &&
|
turnReasoning != null &&
|
||||||
proseMessage.id == turnReasoning.message.id
|
proseMessage.id == turnReasoning.message.id
|
||||||
item(key = "prose-${proseMessage.id}") {
|
if (proseMessage.hasVisibleAgentContent()) {
|
||||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
item(key = "prose-${proseMessage.id}") {
|
||||||
if (proseMessage.isStreaming && typingEffectEnabled) {
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
StreamingFullBleedWithTypingEffect(
|
if (proseMessage.isStreaming && typingEffectEnabled) {
|
||||||
streaming = proseMessage,
|
StreamingFullBleedWithTypingEffect(
|
||||||
typingDelayMs = typingEffectDelayMs,
|
streaming = proseMessage,
|
||||||
isDark = isDark,
|
typingDelayMs = typingEffectDelayMs,
|
||||||
showTurnHeader = showTurnHeader,
|
showReasoning = !hoistedReasoning,
|
||||||
showReasoning = !hoistedReasoning,
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
FullBleedAgentMessage(
|
||||||
FullBleedAgentMessage(
|
message = proseMessage,
|
||||||
message = proseMessage,
|
// Highlight only bubbles that actually contain a match —
|
||||||
showTurnHeader = showTurnHeader,
|
// the rest skip the highlight scan entirely.
|
||||||
isDarkTheme = isDark,
|
searchQuery =
|
||||||
// Highlight only bubbles that actually contain a match —
|
if (searchState.isActive &&
|
||||||
// the rest skip the highlight scan entirely.
|
proseMessage.id in searchState.matchedIds
|
||||||
searchQuery =
|
) {
|
||||||
if (searchState.isActive &&
|
searchState.query
|
||||||
proseMessage.id in searchState.matchedIds
|
} else {
|
||||||
) {
|
""
|
||||||
searchState.query
|
},
|
||||||
} else {
|
isCurrentMatch =
|
||||||
""
|
searchState.currentMatchId != null &&
|
||||||
},
|
searchState.currentMatchId == proseMessage.id,
|
||||||
isCurrentMatch =
|
showReasoning = !hoistedReasoning,
|
||||||
searchState.currentMatchId != null &&
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
searchState.currentMatchId == proseMessage.id,
|
onSaveAttachment = onSaveAttachment,
|
||||||
showReasoning = !hoistedReasoning,
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
onOpenAttachment = viewModel::openAttachment,
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
onSaveAttachment = onSaveAttachment,
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
savingAttachmentPath = savingAttachmentPath,
|
onImageClick = onImageClick,
|
||||||
openingAttachmentPath = openingAttachmentPath,
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
canSaveAttachment = savingAttachmentPath == null,
|
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||||
onImageClick = onImageClick,
|
showTokensPerSecond = showTokensPerSecond,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
firstProseSeen = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is AgentEntry.ToolRow -> {
|
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
|
// Clarify bubble — rendered at the very bottom
|
||||||
if (clarifyRequest != null) {
|
if (clarifyRequest != null) {
|
||||||
item(key = "clarify_bubble") {
|
item(key = "clarify_bubble") {
|
||||||
@@ -356,6 +365,8 @@ private fun renderChatBubble(
|
|||||||
savingAttachmentPath: String?,
|
savingAttachmentPath: String?,
|
||||||
openingAttachmentPath: String?,
|
openingAttachmentPath: String?,
|
||||||
onImageClick: (ImageViewerModel) -> Unit,
|
onImageClick: (ImageViewerModel) -> Unit,
|
||||||
|
messageStatsEnabled: Boolean,
|
||||||
|
showUserMessageTokens: Boolean,
|
||||||
) {
|
) {
|
||||||
ChatBubble(
|
ChatBubble(
|
||||||
message = message,
|
message = message,
|
||||||
@@ -367,5 +378,7 @@ private fun renderChatBubble(
|
|||||||
openingAttachmentPath = openingAttachmentPath,
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
canSaveAttachment = savingAttachmentPath == null,
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -28,8 +28,6 @@ import kotlinx.coroutines.delay
|
|||||||
internal fun StreamingFullBleedWithTypingEffect(
|
internal fun StreamingFullBleedWithTypingEffect(
|
||||||
streaming: ChatMessage,
|
streaming: ChatMessage,
|
||||||
typingDelayMs: Int,
|
typingDelayMs: Int,
|
||||||
isDark: Boolean,
|
|
||||||
showTurnHeader: Boolean = true,
|
|
||||||
showReasoning: Boolean = true,
|
showReasoning: Boolean = true,
|
||||||
onAnimationComplete: () -> Unit = {},
|
onAnimationComplete: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@@ -86,9 +84,7 @@ internal fun StreamingFullBleedWithTypingEffect(
|
|||||||
|
|
||||||
FullBleedAgentMessage(
|
FullBleedAgentMessage(
|
||||||
message = streaming.copy(content = displayText),
|
message = streaming.copy(content = displayText),
|
||||||
showTurnHeader = showTurnHeader,
|
|
||||||
showReasoning = showReasoning,
|
showReasoning = showReasoning,
|
||||||
isDarkTheme = isDark,
|
|
||||||
searchQuery = "",
|
searchQuery = "",
|
||||||
isCurrentMatch = false,
|
isCurrentMatch = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ sealed interface AgentEntry {
|
|||||||
) : 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
|
* Stable content prefix the backend uses for its max-iterations runtime nudge
|
||||||
* (`handle_max_iterations` in run_agent.py → chat_completion_helpers.py, text
|
* (`handle_max_iterations` in run_agent.py → chat_completion_helpers.py, text
|
||||||
@@ -145,15 +148,16 @@ fun groupIntoTurnsWithStreaming(
|
|||||||
* full-bleed renderer.
|
* full-bleed renderer.
|
||||||
*
|
*
|
||||||
* The lazy list does NOT have one item per message: each agent turn with a
|
* 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
|
* reasoning block emits an extra `reasoning-<id>` item BEFORE its prose, empty
|
||||||
* tool rows / system events are items too. Scrolling a search match by raw
|
* assistant placeholders emit no prose item, and tool rows / system events are
|
||||||
* message index therefore lands on the WRONG item whenever reasoning or tool
|
* items too. Scrolling a search match by raw message index therefore lands on
|
||||||
* rows precede the target — the classic "match is above/below the view" bug.
|
* 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:
|
* Mirrors the item emission order in [FullBleedChatList] exactly:
|
||||||
* user turn → 1 item; agent turn → optional reasoning item, then one item per
|
* user turn → 1 item; agent turn → optional reasoning item, then one item per
|
||||||
* entry. [leadingItems] accounts for fixed items emitted before the turns
|
* visible entry. [leadingItems] accounts for fixed items emitted before the
|
||||||
* (e.g. the `loading-older` spinner when paging).
|
* turns (e.g. the `loading-older` spinner when paging).
|
||||||
*
|
*
|
||||||
* @return messageId → LazyColumn item index of the message's content item
|
* @return messageId → LazyColumn item index of the message's content item
|
||||||
* (prose items for agent messages; user items for user messages).
|
* (prose items for agent messages; user items for user messages).
|
||||||
@@ -183,8 +187,10 @@ fun messageIdToLazyIndex(
|
|||||||
turn.entries.forEach { entry ->
|
turn.entries.forEach { entry ->
|
||||||
when (entry) {
|
when (entry) {
|
||||||
is AgentEntry.Prose -> {
|
is AgentEntry.Prose -> {
|
||||||
map[entry.message.id] = itemIndex
|
if (entry.message.hasVisibleAgentContent()) {
|
||||||
itemIndex++
|
map[entry.message.id] = itemIndex
|
||||||
|
itemIndex++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is AgentEntry.ToolRow -> {
|
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.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Clear
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.compose.material.icons.filled.Code
|
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.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
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.SearchBar
|
||||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||||
|
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
@@ -290,6 +294,8 @@ private fun YAMLEditor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val textFieldState = rememberSyncedTextFieldState(yamlText, onYamlTextChange)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -297,14 +303,12 @@ private fun YAMLEditor(
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = yamlText,
|
state = textFieldState,
|
||||||
onValueChange = onYamlTextChange,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
maxLines = Int.MAX_VALUE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -936,34 +940,41 @@ private fun JsonField(
|
|||||||
label: String,
|
label: String,
|
||||||
onChange: (JsonElement) -> Unit,
|
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 hasFocus by remember { mutableStateOf(false) }
|
||||||
var invalid by remember { mutableStateOf(false) }
|
var invalid by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(value) {
|
LaunchedEffect(value, hasFocus) {
|
||||||
if (!hasFocus) text = value?.let(::jsonText) ?: ""
|
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(
|
OutlinedTextField(
|
||||||
value = text,
|
state = textFieldState,
|
||||||
onValueChange = { newText ->
|
|
||||||
text = newText
|
|
||||||
val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull()
|
|
||||||
invalid = newText.isNotBlank() && parsed == null
|
|
||||||
if (parsed != null && newText.isNotBlank()) {
|
|
||||||
onChange(parsed)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 96.dp)
|
.heightIn(min = 96.dp)
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
hasFocus = it.isFocused
|
hasFocus = it.isFocused
|
||||||
if (!it.isFocused) {
|
if (!it.isFocused) invalid = false
|
||||||
invalid = false
|
|
||||||
text = value?.let(::jsonText) ?: ""
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
isError = invalid,
|
isError = invalid,
|
||||||
supportingText =
|
supportingText =
|
||||||
@@ -974,6 +985,6 @@ private fun JsonField(
|
|||||||
},
|
},
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||||
label = { Text(label) },
|
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.ToastEffect
|
||||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
|
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
|
||||||
import com.m57.hermescontrol.util.CronExpressionFormatter
|
import com.m57.hermescontrol.util.CronExpressionFormatter
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -377,6 +378,11 @@ fun CronJobEditorDialog(
|
|||||||
state.monitor_script.isNotEmpty() || state.monitor_url.isNotEmpty() ||
|
state.monitor_script.isNotEmpty() || state.monitor_url.isNotEmpty() ||
|
||||||
state.monitorMode != "off"
|
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)
|
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
@@ -506,8 +512,7 @@ fun CronJobEditorDialog(
|
|||||||
|
|
||||||
// Prompt
|
// Prompt
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.prompt,
|
state = promptTextFieldState,
|
||||||
onValueChange = { onFieldChange("prompt", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
|
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -531,8 +536,7 @@ fun CronJobEditorDialog(
|
|||||||
|
|
||||||
// Skills
|
// Skills
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.skills,
|
state = skillsTextFieldState,
|
||||||
onValueChange = { onFieldChange("skills", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
|
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
|
||||||
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
|
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
@@ -17,13 +17,21 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.material.icons.filled.Person
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -32,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -46,6 +55,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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.R
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
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.SearchBar
|
||||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
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"
|
private const val DEFAULT_COLUMN = "todo"
|
||||||
|
|
||||||
@@ -67,14 +81,26 @@ fun KanbanScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var query by remember { mutableStateOf("") }
|
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 =
|
val filteredTasks =
|
||||||
remember(query, state.tasks) {
|
remember(query, state.tasks, selectedAssignee, selectedTenant) {
|
||||||
state.tasks.filter { task ->
|
state.tasks.filter { task ->
|
||||||
task.title.contains(query, ignoreCase = true) ||
|
val matchesQuery =
|
||||||
task.description?.contains(query, ignoreCase = true) == true ||
|
query.isBlank() ||
|
||||||
task.status.contains(query, ignoreCase = true) ||
|
task.title.contains(query, ignoreCase = true) ||
|
||||||
task.assignedTo?.contains(query, ignoreCase = true) == 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 confirmTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
|
||||||
var summaryTarget 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) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadBoards()
|
viewModel.loadBoards()
|
||||||
}
|
}
|
||||||
@@ -96,6 +133,60 @@ fun KanbanScreen(
|
|||||||
HermesScaffold(
|
HermesScaffold(
|
||||||
title = { Text(stringResource(R.string.kanban_board_title)) },
|
title = { Text(stringResource(R.string.kanban_board_title)) },
|
||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
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,
|
isRefreshing = state.isLoading,
|
||||||
onRefresh = { viewModel.loadBoards() },
|
onRefresh = { viewModel.loadBoards() },
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
@@ -142,7 +233,7 @@ fun KanbanScreen(
|
|||||||
Tab(
|
Tab(
|
||||||
selected = board == state.selectedBoard,
|
selected = board == state.selectedBoard,
|
||||||
onClick = { viewModel.selectBoard(board) },
|
onClick = { viewModel.selectBoard(board) },
|
||||||
text = { Text(board.name) },
|
text = { Text(board.displayName) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +258,14 @@ fun KanbanScreen(
|
|||||||
) {
|
) {
|
||||||
LiveStatusPill(isLive = state.isLive)
|
LiveStatusPill(isLive = state.isLive)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
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 }) {
|
IconButton(onClick = { showAddTaskDialog = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Add,
|
imageVector = Icons.Filled.Add,
|
||||||
@@ -228,9 +327,41 @@ fun KanbanScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(colTasks, key = { it.id }) { task ->
|
items(colTasks, key = { it.id }) { task ->
|
||||||
TaskCard(
|
KanbanTaskCard(
|
||||||
task = task,
|
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) {
|
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 },
|
onDismiss = { showAddTaskDialog = false },
|
||||||
onConfirm = { title, desc ->
|
onConfirm = { body, targetStatus ->
|
||||||
viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN)
|
viewModel.createTask(body, targetStatus)
|
||||||
showAddTaskDialog = false
|
showAddTaskDialog = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -288,48 +425,281 @@ fun KanbanScreen(
|
|||||||
|
|
||||||
summaryTarget?.let { (task, action) ->
|
summaryTarget?.let { (task, action) ->
|
||||||
CompleteTaskDialog(
|
CompleteTaskDialog(
|
||||||
onConfirm = { summary -> viewModel.moveTask(task, action, summary) },
|
onConfirm = { summary ->
|
||||||
|
summaryTarget = null
|
||||||
|
viewModel.moveTask(task, action, summary)
|
||||||
|
},
|
||||||
onDismiss = { summaryTarget = null },
|
onDismiss = { summaryTarget = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
if (showFilterSheet) {
|
||||||
fun TaskCard(
|
val assignees =
|
||||||
task: KanbanTask,
|
remember(state.tasks) {
|
||||||
onTaskClick: (KanbanTask) -> Unit,
|
state.tasks
|
||||||
) {
|
.mapNotNull { it.assignee }
|
||||||
Card(onClick = { onTaskClick(task) }, modifier = Modifier.fillMaxWidth()) {
|
.distinct()
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
.sorted()
|
||||||
Text(text = task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
val tenants =
|
||||||
task.description?.let {
|
remember(state.tasks) {
|
||||||
Text(
|
state.tasks
|
||||||
text = it,
|
.mapNotNull { it.tenant }
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
.distinct()
|
||||||
maxLines = 2,
|
.sorted()
|
||||||
overflow = TextOverflow.Ellipsis,
|
}
|
||||||
)
|
KanbanFilterSheet(
|
||||||
}
|
assignees = assignees,
|
||||||
task.assignedTo?.let { assignee ->
|
tenants = tenants,
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
selectedAssignee = selectedAssignee,
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
selectedTenant = selectedTenant,
|
||||||
Icon(
|
includeArchived = includeArchived,
|
||||||
imageVector = Icons.Filled.Person,
|
groupRunning = groupRunning,
|
||||||
contentDescription = null,
|
onSelectAssignee = { selectedAssignee = it },
|
||||||
modifier = Modifier.size(14.dp),
|
onSelectTenant = { selectedTenant = it },
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
onToggleIncludeArchived = { includeArchived = it },
|
||||||
)
|
onToggleGroupRunning = { groupRunning = it },
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
onClearFilters = {
|
||||||
Text(
|
selectedAssignee = null
|
||||||
text = assignee,
|
selectedTenant = null
|
||||||
style = MaterialTheme.typography.labelSmall,
|
includeArchived = false
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 =
|
private fun KanbanTaskAction.labelRes(): Int =
|
||||||
when (this) {
|
when (this) {
|
||||||
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.KanbanBoard
|
||||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
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.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.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.KanbanEventsClient
|
||||||
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
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.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -20,14 +28,16 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
data class KanbanUiState(
|
data class KanbanUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
|
val isCreatingTask: Boolean = false,
|
||||||
|
val operatingTaskIds: Set<String> = emptySet(),
|
||||||
val boards: List<KanbanBoard> = emptyList(),
|
val boards: List<KanbanBoard> = emptyList(),
|
||||||
val selectedBoard: KanbanBoard? = null,
|
val selectedBoard: KanbanBoard? = null,
|
||||||
val columns: List<KanbanColumn> = emptyList(),
|
val columns: List<KanbanColumn> = emptyList(),
|
||||||
val tasks: List<KanbanTask> = emptyList(),
|
val tasks: List<KanbanTask> = emptyList(),
|
||||||
|
val profiles: List<KanbanProfile> = emptyList(),
|
||||||
val isLive: Boolean = false,
|
val isLive: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: String? = null,
|
val toastMessage: String? = null,
|
||||||
@@ -113,7 +123,12 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
class KanbanViewModel(
|
class KanbanViewModel(
|
||||||
|
private val repository: KanbanRepository = KanbanRepositoryImpl(),
|
||||||
|
private val preferences: KanbanPreferencesStore = InMemoryKanbanPreferencesStore(),
|
||||||
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
||||||
|
private val endpointProvider: () -> String = {
|
||||||
|
runCatching { AuthManager.endpointForBuild().baseUrl.toString() }.getOrDefault("default")
|
||||||
|
},
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(KanbanUiState())
|
private val _uiState = MutableStateFlow(KanbanUiState())
|
||||||
@@ -122,90 +137,290 @@ class KanbanViewModel(
|
|||||||
private var eventsClient: KanbanEventsClient? = null
|
private var eventsClient: KanbanEventsClient? = null
|
||||||
private var eventsBoard: String? = null
|
private var eventsBoard: String? = null
|
||||||
private var reloadJob: Job? = null
|
private var reloadJob: Job? = null
|
||||||
|
private var currentLoadGen: Int = 0
|
||||||
|
|
||||||
fun loadBoards() {
|
fun loadBoards() {
|
||||||
safeLaunchLoad(
|
val endpoint = endpointProvider()
|
||||||
apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } },
|
val savedSlug = preferences.getSelectedBoard(endpoint)
|
||||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
|
||||||
onSuccess = { data ->
|
|
||||||
val boards = data.boards.orEmpty()
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
_uiState.update { it.copy(isLoading = false, boards = boards) }
|
loadProfiles()
|
||||||
val currentSlug = data.current
|
viewModelScope.launch {
|
||||||
val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull()
|
when (val result = repository.getBoards()) {
|
||||||
if (currentBoard != null) {
|
is NetworkResult.Success -> {
|
||||||
selectBoard(currentBoard)
|
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 ->
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Failed to load Kanban boards: $errorMsg",
|
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectBoard(board: KanbanBoard) {
|
fun selectBoard(board: KanbanBoard) {
|
||||||
val current = _uiState.value.selectedBoard
|
val endpoint = endpointProvider()
|
||||||
if (current?.id == board.id) {
|
preferences.setSelectedBoard(endpoint, board.id)
|
||||||
// Already-active board: reload its data and (re)connect the events
|
|
||||||
// stream WITHOUT the /switch round-trip — refresh and createTask
|
val gen = ++currentLoadGen
|
||||||
// re-select the same board and must not POST a redundant switch.
|
|
||||||
viewModelScope.launch { loadBoardIntoState(board) }
|
|
||||||
connectEvents(board)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val switchResult =
|
loadBoardIntoState(board, gen)
|
||||||
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)
|
|
||||||
connectEvents(board)
|
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(
|
fun createTask(
|
||||||
title: String,
|
title: String,
|
||||||
description: 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 {
|
viewModelScope.launch {
|
||||||
val result =
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
withContext(Dispatchers.IO) {
|
val body =
|
||||||
safeApiCall {
|
CreateBoardBody(
|
||||||
ApiClient.hermesApi.createKanbanTask(
|
slug = slug,
|
||||||
board = board.id,
|
name = name,
|
||||||
task =
|
description = description,
|
||||||
com.m57.hermescontrol.data.model.CreateTaskBody(
|
)
|
||||||
title = title,
|
when (val res = repository.createBoard(body)) {
|
||||||
body = description,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when (result) {
|
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Task created successfully") }
|
val boardName = res.data.board?.name ?: slug
|
||||||
selectBoard(board)
|
_uiState.update { it.copy(toastMessage = "Board created: $boardName") }
|
||||||
|
loadBoards()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
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,
|
action: KanbanTaskAction,
|
||||||
summary: String? = null,
|
summary: String? = null,
|
||||||
) {
|
) {
|
||||||
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
if (task.id in _uiState.value.operatingTaskIds) return
|
||||||
val originalStatus = task.status
|
val originalStatus = task.status
|
||||||
// Optimistically update, desktop-style
|
|
||||||
|
// Optimistically update both tasks and columns, desktop-style
|
||||||
_uiState.update { state ->
|
_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(
|
state.copy(
|
||||||
tasks =
|
tasks = updatedTasks,
|
||||||
state.tasks.map {
|
columns = updatedColumns,
|
||||||
if (it.id == task.id) it.copy(status = action.targetStatus) else it
|
operatingTaskIds = state.operatingTaskIds + task.id,
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val body =
|
val body =
|
||||||
buildMap<String, String?> {
|
UpdateTaskBody(
|
||||||
put("status", action.targetStatus)
|
status = action.targetStatus,
|
||||||
if (action.needsSummary && !summary.isNullOrBlank()) {
|
summary = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
|
||||||
put("result", summary)
|
result = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
|
||||||
put("summary", summary)
|
)
|
||||||
|
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) {
|
is NetworkResult.Failure -> {
|
||||||
safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, body) }
|
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,
|
errorMsg: String,
|
||||||
) {
|
) {
|
||||||
_uiState.update { state ->
|
_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(
|
state.copy(
|
||||||
tasks =
|
tasks = updatedTasks,
|
||||||
state.tasks.map {
|
columns = updatedColumns,
|
||||||
if (it.id == taskId) it.copy(status = originalStatus) else it
|
operatingTaskIds = state.operatingTaskIds - taskId,
|
||||||
},
|
|
||||||
toastMessage = errorMsg,
|
toastMessage = errorMsg,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -300,47 +573,60 @@ class KanbanViewModel(
|
|||||||
|
|
||||||
/** Re-fetch the current board without touching the loading spinner. */
|
/** Re-fetch the current board without touching the loading spinner. */
|
||||||
private fun reloadBoardSilently() {
|
private fun reloadBoardSilently() {
|
||||||
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
val gen = currentLoadGen
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result = repository.getBoard(board = board.id)
|
||||||
withContext(Dispatchers.IO) {
|
if (gen != currentLoadGen) return@launch
|
||||||
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
|
|
||||||
}
|
|
||||||
if (result is NetworkResult.Success) {
|
if (result is NetworkResult.Success) {
|
||||||
val body = result.data
|
val body = result.data
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
if (it.selectedBoard?.id != board.id) {
|
||||||
columns = body.columns,
|
it
|
||||||
tasks = body.columns.flatMap { it.tasks },
|
} else {
|
||||||
)
|
it.copy(
|
||||||
|
columns = body.columns,
|
||||||
|
tasks = body.columns.flatMap { col -> col.tasks },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadBoardIntoState(board: KanbanBoard) {
|
private suspend fun loadBoardIntoState(
|
||||||
val result =
|
board: KanbanBoard,
|
||||||
withContext(Dispatchers.IO) {
|
gen: Int = currentLoadGen,
|
||||||
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
|
) {
|
||||||
}
|
val result = repository.getBoard(board = board.id)
|
||||||
|
if (gen != currentLoadGen) return
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val body = result.data
|
val body = result.data
|
||||||
val allTasks = body.columns.flatMap { it.tasks }
|
val allTasks = body.columns.flatMap { it.tasks }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
if (it.selectedBoard?.id != board.id) {
|
||||||
isLoading = false,
|
it
|
||||||
columns = body.columns,
|
} else {
|
||||||
tasks = allTasks,
|
it.copy(
|
||||||
)
|
isLoading = false,
|
||||||
|
columns = body.columns,
|
||||||
|
tasks = allTasks,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
if (it.selectedBoard?.id != board.id) {
|
||||||
isLoading = false,
|
it
|
||||||
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
|
} else {
|
||||||
)
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+536
@@ -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.height
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.UploadFile
|
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.data.model.McpServer
|
||||||
import com.m57.hermescontrol.theme.Spacing
|
import com.m57.hermescontrol.theme.Spacing
|
||||||
import com.m57.hermescontrol.ui.common.DetailDialog
|
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.common.toDetailRows
|
||||||
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
import com.m57.hermescontrol.ui.mcp.McpServersUiState
|
||||||
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
|
||||||
@@ -112,6 +114,9 @@ fun McpDialogs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.showImportDialog) {
|
if (state.showImportDialog) {
|
||||||
|
val importTextFieldState =
|
||||||
|
rememberSyncedTextFieldState(state.importJsonInput, viewModel::updateImportJsonInput)
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { viewModel.toggleImportDialog() },
|
onDismissRequest = { viewModel.toggleImportDialog() },
|
||||||
title = {
|
title = {
|
||||||
@@ -157,8 +162,7 @@ fun McpDialogs(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.importJsonInput,
|
state = importTextFieldState,
|
||||||
onValueChange = viewModel::updateImportJsonInput,
|
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
||||||
@@ -170,7 +174,7 @@ fun McpDialogs(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(180.dp),
|
.height(180.dp),
|
||||||
maxLines = 10,
|
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -720,7 +720,7 @@ private fun ProviderCard(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = provider.name,
|
text = provider.name,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
@@ -733,12 +733,36 @@ private fun ProviderCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCurrent) {
|
Row(
|
||||||
Text(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
text = stringResource(R.string.model_status_current),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
) {
|
||||||
color = MaterialTheme.colorScheme.primary,
|
if (isCurrent) {
|
||||||
fontWeight = FontWeight.Bold,
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.filled.PushPin
|
||||||
import androidx.compose.material.icons.outlined.PushPin
|
import androidx.compose.material.icons.outlined.PushPin
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -25,6 +27,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -32,6 +35,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -65,6 +69,7 @@ fun ModelPickerDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var pickerQuery by remember { mutableStateOf("") }
|
var pickerQuery by remember { mutableStateOf("") }
|
||||||
|
var expandedProviderSlug by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -149,6 +154,17 @@ fun ModelPickerDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(filteredProvidersWithModels) {
|
||||||
|
if (
|
||||||
|
expandedProviderSlug != null &&
|
||||||
|
filteredProvidersWithModels.none { (provider, _) ->
|
||||||
|
provider.slug == expandedProviderSlug
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
expandedProviderSlug = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SearchBar(
|
SearchBar(
|
||||||
query = pickerQuery,
|
query = pickerQuery,
|
||||||
onQueryChange = { pickerQuery = it },
|
onQueryChange = { pickerQuery = it },
|
||||||
@@ -205,45 +221,83 @@ fun ModelPickerDialog(
|
|||||||
|
|
||||||
// ── All providers / models (lazy item per model) ──
|
// ── All providers / models (lazy item per model) ──
|
||||||
filteredProvidersWithModels.forEach { (provider, models) ->
|
filteredProvidersWithModels.forEach { (provider, models) ->
|
||||||
|
val isExpanded = expandedProviderSlug == provider.slug
|
||||||
item(key = "header:${provider.slug}") {
|
item(key = "header:${provider.slug}") {
|
||||||
Row(
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
expandedProviderSlug =
|
||||||
|
if (isExpanded) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
provider.slug
|
||||||
|
}
|
||||||
|
}.padding(top = 12.dp, bottom = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(top = 12.dp, bottom = 6.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = provider.name,
|
text = provider.name,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text(
|
Text(
|
||||||
text = "${models.size} models",
|
text =
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.model_provider_count,
|
||||||
|
models.size,
|
||||||
|
models.size,
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
if (isExpanded) {
|
||||||
items = models,
|
items(
|
||||||
key = { model -> "${provider.slug}:$model" },
|
items = models,
|
||||||
) { model ->
|
key = { model -> "${provider.slug}:$model" },
|
||||||
val isPinned = "${provider.slug}:$model" in pinnedSet
|
) { model ->
|
||||||
val caps = provider.capabilities?.get(model)
|
val isPinned = "${provider.slug}:$model" in pinnedSet
|
||||||
ModelItemCard(
|
val caps = provider.capabilities?.get(model)
|
||||||
modelName = model,
|
ModelItemCard(
|
||||||
isPinned = isPinned,
|
modelName = model,
|
||||||
onPinToggle =
|
isPinned = isPinned,
|
||||||
if (onPinToggle != null) {
|
onPinToggle =
|
||||||
{ onPinToggle(provider.slug, model) }
|
if (onPinToggle != null) {
|
||||||
} else {
|
{ onPinToggle(provider.slug, model) }
|
||||||
null
|
} else {
|
||||||
},
|
null
|
||||||
onClick = { onSelect(provider.slug, model) },
|
},
|
||||||
canDisableReasoning = caps?.can_disable_reasoning,
|
onClick = { onSelect(provider.slug, model) },
|
||||||
supportsReasoning = caps?.reasoning,
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Check
|
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.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -230,8 +233,15 @@ fun ProfilesScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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 ->
|
items(state.displayProfiles, key = { it.name }) { profile ->
|
||||||
val isActive = profile.name == state.activeProfileName
|
val isActive = profile.name == state.activeProfileName
|
||||||
|
val isProfileShared = profile.name in state.sharedGatewayProfiles
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors =
|
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 =
|
val descriptionText =
|
||||||
if (!profile.description.isNullOrBlank()) {
|
if (!profile.description.isNullOrBlank()) {
|
||||||
@@ -586,7 +619,15 @@ fun ProfilesScreen(
|
|||||||
|
|
||||||
if (soulEditProfileName != null) {
|
if (soulEditProfileName != null) {
|
||||||
val initialText = state.selectedSoulContent ?: ""
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
@@ -615,10 +656,9 @@ fun ProfilesScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = soulText,
|
state = soulTextFieldState,
|
||||||
onValueChange = { soulText = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 10,
|
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -627,7 +667,7 @@ fun ProfilesScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
val profileName = soulEditProfileName
|
val profileName = soulEditProfileName
|
||||||
if (profileName != null) {
|
if (profileName != null) {
|
||||||
viewModel.saveSoul(profileName, soulText)
|
viewModel.saveSoul(profileName, soulTextFieldState.text.toString())
|
||||||
}
|
}
|
||||||
soulEditProfileName = null
|
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
|
// Profile Builder Wizard
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -34,6 +34,7 @@ data class ProfilesUiState(
|
|||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val profiles: List<ProfileInfo> = emptyList(),
|
val profiles: List<ProfileInfo> = emptyList(),
|
||||||
val activeProfileName: String? = null,
|
val activeProfileName: String? = null,
|
||||||
|
val sharedGatewayProfiles: List<String> = emptyList(),
|
||||||
val selectedSoulContent: String? = null,
|
val selectedSoulContent: String? = null,
|
||||||
val isLoadingSoul: Boolean = false,
|
val isLoadingSoul: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
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(
|
class ProfilesViewModel(
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
@@ -110,6 +120,7 @@ class ProfilesViewModel(
|
|||||||
val profilesDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfiles() } }
|
val profilesDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfiles() } }
|
||||||
val activeDeferred =
|
val activeDeferred =
|
||||||
async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
|
async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
|
||||||
|
val statusDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getStatus() } }
|
||||||
|
|
||||||
val profilesResult = profilesDeferred.await()
|
val profilesResult = profilesDeferred.await()
|
||||||
val activeResult = activeDeferred.await()
|
val activeResult = activeDeferred.await()
|
||||||
@@ -120,9 +131,20 @@ class ProfilesViewModel(
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
profiles = profilesResult.data.profiles.orEmpty(),
|
profiles = profilesResult.data.profiles.orEmpty(),
|
||||||
activeProfileName = activeResult.data.active,
|
activeProfileName = activeResult.data.active,
|
||||||
|
sharedGatewayProfiles = emptyList(),
|
||||||
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
hiddenProfiles = AuthManager.getHiddenProfiles().toSet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val statusResult = statusDeferred.await()
|
||||||
|
if (statusResult is NetworkResult.Success) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
sharedGatewayProfiles =
|
||||||
|
normalizeSharedGatewayProfiles(statusResult.data.gatewaySharedWith),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
|
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
|
||||||
val activeError = (activeResult 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.CallSplit
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
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.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Code
|
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.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
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.SessionsDialogs
|
||||||
import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
|
import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
|
||||||
import com.m57.hermescontrol.ui.sessions.components.automationGroups
|
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
|
* 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.
|
* items of the end — a pre-load buffer so paging feels continuous.
|
||||||
*/
|
*/
|
||||||
private const val AUTO_LOAD_THRESHOLD = 6
|
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.
|
* Maps a session source string to a Material icon for visual identification.
|
||||||
@@ -466,6 +470,15 @@ fun SessionsScreen(
|
|||||||
isRefreshing = state.isLoading,
|
isRefreshing = state.isLoading,
|
||||||
onRefresh = { viewModel.loadSessions() },
|
onRefresh = { viewModel.loadSessions() },
|
||||||
actions = {
|
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) {
|
if (state.hasHiddenSessions) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.toggleShowHidden() },
|
onClick = { viewModel.toggleShowHidden() },
|
||||||
@@ -754,8 +767,19 @@ fun SessionsScreen(
|
|||||||
viewModel.loadMore()
|
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 sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
|
||||||
val session = item.session
|
val session = item.session
|
||||||
|
val project =
|
||||||
|
remember(session.cwd, session.git_repo_root, state.projects) {
|
||||||
|
resolveSessionProject(session, state.projects)
|
||||||
|
}
|
||||||
BranchRow(item = item) {
|
BranchRow(item = item) {
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
@@ -770,6 +794,8 @@ fun SessionsScreen(
|
|||||||
isPinned = session.pinned == true,
|
isPinned = session.pinned == true,
|
||||||
isHidden = session.hidden == true,
|
isHidden = session.hidden == true,
|
||||||
liveStatus = state.liveStatuses[session.id],
|
liveStatus = state.liveStatuses[session.id],
|
||||||
|
project = project,
|
||||||
|
nowMillis = nowMillis,
|
||||||
highlightBackground = primaryContainer,
|
highlightBackground = primaryContainer,
|
||||||
highlightForeground = onPrimaryContainer,
|
highlightForeground = onPrimaryContainer,
|
||||||
onCardClick = {
|
onCardClick = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
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.PruneRequest
|
||||||
import com.m57.hermescontrol.data.model.SessionInfo
|
import com.m57.hermescontrol.data.model.SessionInfo
|
||||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
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.ChangeEventHub
|
||||||
import com.m57.hermescontrol.data.ws.ChangeEvents
|
import com.m57.hermescontrol.data.ws.ChangeEvents
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
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.HermesSessionLiveStatusSource
|
||||||
|
import com.m57.hermescontrol.data.ws.ProjectsSource
|
||||||
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
|
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
@@ -99,6 +102,8 @@ data class SessionsUiState(
|
|||||||
val showHidden: Boolean = false,
|
val showHidden: Boolean = false,
|
||||||
val pinnedExpanded: Boolean = true,
|
val pinnedExpanded: Boolean = true,
|
||||||
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
|
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()
|
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||||
|
|
||||||
@@ -114,6 +119,7 @@ data class SessionsUiState(
|
|||||||
|
|
||||||
class SessionsViewModel(
|
class SessionsViewModel(
|
||||||
private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(),
|
private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(),
|
||||||
|
private val projectsSource: ProjectsSource = HermesProjectsSource(),
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(SessionsUiState())
|
private val _uiState = MutableStateFlow(SessionsUiState())
|
||||||
@@ -123,6 +129,7 @@ class SessionsViewModel(
|
|||||||
private var pageJob: Job? = null
|
private var pageJob: Job? = null
|
||||||
private var statsJob: Job? = null
|
private var statsJob: Job? = null
|
||||||
private var trackingJob: Job? = null
|
private var trackingJob: Job? = null
|
||||||
|
private var projectsJob: Job? = null
|
||||||
private var trackingGeneration: Long = 0
|
private var trackingGeneration: Long = 0
|
||||||
private var liveTrackingState = SessionLiveTrackingState()
|
private var liveTrackingState = SessionLiveTrackingState()
|
||||||
private var liveStatusRefreshInFlight = false
|
private var liveStatusRefreshInFlight = false
|
||||||
@@ -277,6 +284,7 @@ class SessionsViewModel(
|
|||||||
loadEmptyCount()
|
loadEmptyCount()
|
||||||
if (trackingJob?.isActive == true) {
|
if (trackingJob?.isActive == true) {
|
||||||
refreshLiveStatuses()
|
refreshLiveStatuses()
|
||||||
|
loadProjects()
|
||||||
}
|
}
|
||||||
loadJob =
|
loadJob =
|
||||||
safeLaunchLoad(
|
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. */
|
/** Load the next page and append to the existing session list. */
|
||||||
fun loadMore() {
|
fun loadMore() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
@@ -905,6 +926,7 @@ class SessionsViewModel(
|
|||||||
_uiState.update { it.copy(liveStatuses = emptyMap()) }
|
_uiState.update { it.copy(liveStatuses = emptyMap()) }
|
||||||
} else {
|
} else {
|
||||||
requestLiveStatusSnapshot(++trackingGeneration)
|
requestLiveStatusSnapshot(++trackingGeneration)
|
||||||
|
loadProjects()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -940,6 +962,8 @@ class SessionsViewModel(
|
|||||||
trackingGeneration++
|
trackingGeneration++
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = null
|
trackingJob = null
|
||||||
|
projectsJob?.cancel()
|
||||||
|
projectsJob = null
|
||||||
liveStatusRefreshInFlight = false
|
liveStatusRefreshInFlight = false
|
||||||
liveStatusRefreshPending = false
|
liveStatusRefreshPending = false
|
||||||
liveTrackingState = SessionLiveStatusReducer.clear()
|
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.BorderStroke
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.CallSplit
|
import androidx.compose.material.icons.automirrored.filled.CallSplit
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.style.TextOverflow
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.SessionInfo
|
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.data.model.SessionTreeItem
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.LocalSpacing
|
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.StatusBadge
|
||||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
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
|
@Composable
|
||||||
fun BranchRow(
|
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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionCard(
|
fun SessionCard(
|
||||||
@@ -148,6 +171,8 @@ fun SessionCard(
|
|||||||
isPinned: Boolean,
|
isPinned: Boolean,
|
||||||
isHidden: Boolean = false,
|
isHidden: Boolean = false,
|
||||||
liveStatus: SessionLiveStatus? = null,
|
liveStatus: SessionLiveStatus? = null,
|
||||||
|
project: SessionProject? = null,
|
||||||
|
nowMillis: Long = System.currentTimeMillis(),
|
||||||
highlightBackground: Color,
|
highlightBackground: Color,
|
||||||
highlightForeground: Color,
|
highlightForeground: Color,
|
||||||
onCardClick: () -> Unit,
|
onCardClick: () -> Unit,
|
||||||
@@ -160,7 +185,7 @@ fun SessionCard(
|
|||||||
) {
|
) {
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val srcIcon = sourceIcon(session.source)
|
val palette = sessionCardPalette()
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
@@ -172,10 +197,7 @@ fun SessionCard(
|
|||||||
onClick = onCardClick,
|
onClick = onCardClick,
|
||||||
onLongClick = { if (!isSelecting) menuExpanded = true },
|
onLongClick = { if (!isSelecting) menuExpanded = true },
|
||||||
),
|
),
|
||||||
colors =
|
colors = CardDefaults.cardColors(containerColor = palette.card),
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
border =
|
border =
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||||
@@ -201,86 +223,57 @@ fun SessionCard(
|
|||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
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)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
SessionCardHeader(
|
||||||
Text(
|
session = session,
|
||||||
text =
|
project = project,
|
||||||
if (query.isNotBlank()) {
|
isFork = isFork && !isSelecting,
|
||||||
highlightText(displayTitle, query, highlightBackground, highlightForeground)
|
forkDepth = forkDepth,
|
||||||
} else {
|
isPinned = isPinned,
|
||||||
AnnotatedString(displayTitle)
|
nowMillis = nowMillis,
|
||||||
},
|
palette = palette,
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
|
||||||
Row(
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
||||||
text = stringResource(R.string.history_message_count, session.message_count ?: 0),
|
text = preview,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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,
|
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
|
||||||
chatFontScale = state.chatFontScale,
|
chatFontScale = state.chatFontScale,
|
||||||
onChatFontScaleChange = viewModel::onChatFontScaleChange,
|
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 typingEffectEnabled: Boolean = false,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
val chatFontScale: Float = 1.0f,
|
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 profiles: List<ConnectionProfile> = emptyList(),
|
||||||
val selectedProfileId: String? = null,
|
val selectedProfileId: String? = null,
|
||||||
val renameProfileName: String = "",
|
val renameProfileName: String = "",
|
||||||
@@ -80,6 +84,10 @@ class SettingsViewModel(
|
|||||||
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
||||||
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||||
val chatFontScale = AuthManager.getChatFontScale()
|
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 profiles = AuthManager.getConnectionProfiles()
|
||||||
val appLanguage = AuthManager.getAppLanguage()
|
val appLanguage = AuthManager.getAppLanguage()
|
||||||
val renameProfileName =
|
val renameProfileName =
|
||||||
@@ -101,6 +109,10 @@ class SettingsViewModel(
|
|||||||
typingEffectEnabled = typingEffectEnabled,
|
typingEffectEnabled = typingEffectEnabled,
|
||||||
typingEffectDelayMs = typingEffectDelayMs,
|
typingEffectDelayMs = typingEffectDelayMs,
|
||||||
chatFontScale = chatFontScale,
|
chatFontScale = chatFontScale,
|
||||||
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
|
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||||
|
showTokensPerSecond = showTokensPerSecond,
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
selectedProfileId = selectedId,
|
selectedProfileId = selectedId,
|
||||||
renameProfileName = renameProfileName,
|
renameProfileName = renameProfileName,
|
||||||
@@ -327,6 +339,26 @@ class SettingsViewModel(
|
|||||||
AuthManager.setChatFontScale(scale)
|
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. */
|
/** Clear all auth credentials — logs out and returns to landing screen. */
|
||||||
fun logout() {
|
fun logout() {
|
||||||
AuthManager.setToken(null)
|
AuthManager.setToken(null)
|
||||||
@@ -355,6 +387,10 @@ class SettingsViewModel(
|
|||||||
AuthManager.setThemePreset(state.themePreset)
|
AuthManager.setThemePreset(state.themePreset)
|
||||||
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
|
AuthManager.setTypingEffectEnabled(state.typingEffectEnabled)
|
||||||
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
||||||
|
AuthManager.setMessageStatsEnabled(state.messageStatsEnabled)
|
||||||
|
AuthManager.setUserMessageTokensEnabled(state.showUserMessageTokens)
|
||||||
|
AuthManager.setAssistantMessageTokensEnabled(state.showAssistantMessageTokens)
|
||||||
|
AuthManager.setTokensPerSecondEnabled(state.showTokensPerSecond)
|
||||||
ApiClient.rebuild()
|
ApiClient.rebuild()
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
@@ -204,6 +205,14 @@ internal fun ChatSection(
|
|||||||
onTypingEffectDelayMsChange: (Int) -> Unit,
|
onTypingEffectDelayMsChange: (Int) -> Unit,
|
||||||
chatFontScale: Float = 1.0f,
|
chatFontScale: Float = 1.0f,
|
||||||
onChatFontScaleChange: (Float) -> Unit = {},
|
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 fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
|
||||||
val currentIndex =
|
val currentIndex =
|
||||||
@@ -369,6 +378,53 @@ internal fun ChatSection(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -44,13 +45,21 @@ internal fun SkillEditorDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onClearSaveSuccess: () -> Unit,
|
onClearSaveSuccess: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) }
|
val textFieldState = rememberTextFieldState(initialContent.orEmpty())
|
||||||
var showDiscardConfirm by remember { mutableStateOf(false) }
|
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 =
|
val hasChanges =
|
||||||
remember(initialContent, contentText) {
|
remember(initialContent, textFieldState.text) {
|
||||||
val original = initialContent.orEmpty()
|
initialContent.orEmpty() != textFieldState.text.toString()
|
||||||
original != contentText
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(saveSuccess) {
|
LaunchedEffect(saveSuccess) {
|
||||||
@@ -95,7 +104,7 @@ internal fun SkillEditorDialog(
|
|||||||
actions = {
|
actions = {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onSave(contentText) },
|
onClick = { onSave(textFieldState.text.toString()) },
|
||||||
enabled = !isSaving,
|
enabled = !isSaving,
|
||||||
) {
|
) {
|
||||||
if (isSaving) {
|
if (isSaving) {
|
||||||
@@ -126,8 +135,7 @@ internal fun SkillEditorDialog(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = contentText,
|
state = textFieldState,
|
||||||
onValueChange = { contentText = it },
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ fun SystemScreen(
|
|||||||
controller = resolvedViewModel.actionProgress,
|
controller = resolvedViewModel.actionProgress,
|
||||||
title = stringResource(R.string.system_update_progress_title),
|
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
|
// Credential remove confirmation using ConfirmDialog
|
||||||
var credToRemove by remember { mutableStateOf<Pair<String, Int>?>(null) }
|
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.CuratorResponse
|
||||||
import com.m57.hermescontrol.data.model.DebugShareResponse
|
import com.m57.hermescontrol.data.model.DebugShareResponse
|
||||||
import com.m57.hermescontrol.data.model.DoctorResponse
|
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.HookResponse
|
||||||
import com.m57.hermescontrol.data.model.PortalResponse
|
import com.m57.hermescontrol.data.model.PortalResponse
|
||||||
import com.m57.hermescontrol.data.model.StatusResponse
|
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.data.remote.safeApiCall
|
||||||
import com.m57.hermescontrol.ui.common.ActionProgressController
|
import com.m57.hermescontrol.ui.common.ActionProgressController
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -53,6 +55,8 @@ data class SystemUiState(
|
|||||||
val updateInfo: UpdateCheckResponse? = null,
|
val updateInfo: UpdateCheckResponse? = null,
|
||||||
val status: StatusResponse? = null,
|
val status: StatusResponse? = null,
|
||||||
val doctorReport: DoctorResponse? = null,
|
val doctorReport: DoctorResponse? = null,
|
||||||
|
val migrationPlan: GatewayMigrationPlan? = null,
|
||||||
|
val migrationSupported: Boolean? = null,
|
||||||
val activeAction: String? = null,
|
val activeAction: String? = null,
|
||||||
val actionLog: ActionStatusResponse? = null,
|
val actionLog: ActionStatusResponse? = null,
|
||||||
val backupArchive: String? = null,
|
val backupArchive: String? = null,
|
||||||
@@ -88,6 +92,7 @@ data class SystemUiState(
|
|||||||
|
|
||||||
class SystemViewModel(
|
class SystemViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
|
private val migrationDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) : AndroidViewModel(application),
|
) : AndroidViewModel(application),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -114,12 +119,22 @@ class SystemViewModel(
|
|||||||
onFinished = { status -> onActionFinished(status) },
|
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 actionPollingJob: Job? = null
|
||||||
private var downloadJob: Job? = null
|
private var downloadJob: Job? = null
|
||||||
|
|
||||||
// ── Full parallel data load ────────────────────────────────────────
|
// ── Full parallel data load ────────────────────────────────────────
|
||||||
|
|
||||||
fun loadAll() {
|
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 {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
|
||||||
@@ -138,6 +153,7 @@ class SystemViewModel(
|
|||||||
|
|
||||||
val statsResult = statsDeferred.await()
|
val statsResult = statsDeferred.await()
|
||||||
val statusResult = statusDeferred.await()
|
val statusResult = statusDeferred.await()
|
||||||
|
|
||||||
val portalResult = portalDeferred.await()
|
val portalResult = portalDeferred.await()
|
||||||
val curatorResult = curatorDeferred.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
|
* Fired when [actionProgress] finishes tracking a backend action. For the
|
||||||
* `hermes-update` action we additionally fetch the durable update receipt
|
* `hermes-update` action we additionally fetch the durable update receipt
|
||||||
@@ -242,6 +318,12 @@ class SystemViewModel(
|
|||||||
loadAll()
|
loadAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onMigrationFinished(status: ActionStatusResponse?) {
|
||||||
|
if (status?.exit_code == 0) {
|
||||||
|
loadAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull `GET /api/hermes/update/receipt` and render a compact summary block
|
* Pull `GET /api/hermes/update/receipt` and render a compact summary block
|
||||||
* for the popup. Returns an empty list when the endpoint is unavailable
|
* for the popup. Returns an empty list when the endpoint is unavailable
|
||||||
@@ -862,3 +944,15 @@ class SystemViewModel(
|
|||||||
_uiState.update { it.copy(toastMessage = null) }
|
_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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.GatewayMigrationPlan
|
||||||
import com.m57.hermescontrol.theme.Spacing
|
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.InfoRow
|
||||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,18 @@
|
|||||||
<string name="settings_sec_chat">المحادثة</string>
|
<string name="settings_sec_chat">المحادثة</string>
|
||||||
<string name="settings_item_typing_effect">تأثير الكتابة</string>
|
<string name="settings_item_typing_effect">تأثير الكتابة</string>
|
||||||
<string name="settings_desc_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_item_typing_delay">التأخير: %1$d مللي ثانية</string>
|
||||||
<string name="settings_delay_10ms">10 مللي ثانية</string>
|
<string name="settings_delay_10ms">10 مللي ثانية</string>
|
||||||
<string name="settings_delay_100ms">100 مللي ثانية</string>
|
<string name="settings_delay_100ms">100 مللي ثانية</string>
|
||||||
@@ -422,6 +433,20 @@
|
|||||||
<string name="system_gateway_start">بدء</string>
|
<string name="system_gateway_start">بدء</string>
|
||||||
<string name="system_gateway_restart">إعادة التشغيل</string>
|
<string name="system_gateway_restart">إعادة التشغيل</string>
|
||||||
<string name="system_gateway_stop">إيقاف</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_active">النشط: %1$s</string>
|
||||||
<string name="memory_builtin">الذاكرة المدمجة</string>
|
<string name="memory_builtin">الذاكرة المدمجة</string>
|
||||||
<string name="memory_builtin_sizes">MEMORY.md: %1$s · USER.md: %2$s</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_label_available">النماذج المتاحة:</string>
|
||||||
<string name="model_no_models">لم يتم العثور على نماذج أو أن المزود يحتاج إلى مصادقة.</string>
|
<string name="model_no_models">لم يتم العثور على نماذج أو أن المزود يحتاج إلى مصادقة.</string>
|
||||||
<string name="content_desc_active_model">النموذج النشط</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_section_pinned">النماذج المثبتة</string>
|
||||||
<string name="content_desc_unpin_model">إلغاء تثبيت النموذج</string>
|
<string name="content_desc_unpin_model">إلغاء تثبيت النموذج</string>
|
||||||
<string name="content_desc_pinned_section_toggle">تبديل ظهور قسم المثبتات</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_title">لا يوجد سجل</string>
|
||||||
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string>
|
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string>
|
||||||
<string name="history_untitled">بلا عنوان</string>
|
<string name="history_untitled">بلا عنوان</string>
|
||||||
<string name="history_message_count">%1$d رسالة</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="history_load_more">تحميل المزيد من الجلسات…</string>
|
||||||
<string name="sessions_search_placeholder">البحث في الجلسات…</string>
|
<string name="sessions_search_placeholder">البحث في الجلسات…</string>
|
||||||
<string name="sessions_search_empty_title">لا توجد نتائج</string>
|
<string name="sessions_search_empty_title">لا توجد نتائج</string>
|
||||||
@@ -628,6 +702,16 @@
|
|||||||
<string name="profiles_show_hidden">إظهار الملفات الشخصية المخفية</string>
|
<string name="profiles_show_hidden">إظهار الملفات الشخصية المخفية</string>
|
||||||
<string name="profiles_hide_hidden">إخفاء الملفات الشخصية المخفية</string>
|
<string name="profiles_hide_hidden">إخفاء الملفات الشخصية المخفية</string>
|
||||||
<string name="profiles_hidden_badge">مخفي</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_all_hidden_desc">جميع الملفات الشخصية مخفية حاليًا. انقر أدناه لإظهار الملفات الشخصية المخفية.</string>
|
||||||
<string name="profiles_content_desc_active">الملف الشخصي النشط</string>
|
<string name="profiles_content_desc_active">الملف الشخصي النشط</string>
|
||||||
<string name="profiles_title_edit_soul">تعديل ملف الـ Soul: %1$s</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_test">اختبار</string>
|
||||||
<string name="channels_action_restart_now">إعادة التشغيل الآن</string>
|
<string name="channels_action_restart_now">إعادة التشغيل الآن</string>
|
||||||
<string name="channels_restart_banner">تم حفظ التغييرات. أعد تشغيل البوابة (Gateway) لتصبح سارية المفعول.</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_pending">الموافقات المعلقة</string>
|
||||||
<string name="pairing_sec_approved">المستخدمون المعتمدون</string>
|
<string name="pairing_sec_approved">المستخدمون المعتمدون</string>
|
||||||
<string name="pairing_action_clear_all">مسح الكل</string>
|
<string name="pairing_action_clear_all">مسح الكل</string>
|
||||||
@@ -1042,6 +1128,9 @@
|
|||||||
<string name="channels_expiry_format">ينتهي في: %1$s</string>
|
<string name="channels_expiry_format">ينتهي في: %1$s</string>
|
||||||
<string name="channels_search_placeholder">البحث في القنوات…</string>
|
<string name="channels_search_placeholder">البحث في القنوات…</string>
|
||||||
<string name="chat_attach_file">إرفاق ملف</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_label">سريع</string>
|
||||||
<string name="chat_fast_mode_desc">الوضع السريع</string>
|
<string name="chat_fast_mode_desc">الوضع السريع</string>
|
||||||
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
|
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
|
||||||
|
|||||||
@@ -187,7 +187,18 @@
|
|||||||
<string name="settings_sec_chat">채팅</string>
|
<string name="settings_sec_chat">채팅</string>
|
||||||
<string name="settings_item_typing_effect">타이핑 효과</string>
|
<string name="settings_item_typing_effect">타이핑 효과</string>
|
||||||
<string name="settings_desc_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_item_typing_delay">지연: %1$d ms</string>
|
||||||
<string name="settings_delay_10ms">10 ms</string>
|
<string name="settings_delay_10ms">10 ms</string>
|
||||||
<string name="settings_delay_100ms">100 ms</string>
|
<string name="settings_delay_100ms">100 ms</string>
|
||||||
@@ -364,6 +375,20 @@
|
|||||||
<string name="system_action_update_now">지금 업데이트</string>
|
<string name="system_action_update_now">지금 업데이트</string>
|
||||||
<string name="system_update_confirm_title">Hermes 업데이트?</string>
|
<string name="system_update_confirm_title">Hermes 업데이트?</string>
|
||||||
<string name="system_update_confirm_desc">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_modal_title">새 셸 훅</string>
|
||||||
<string name="system_hooks_field_event">이벤트</string>
|
<string name="system_hooks_field_event">이벤트</string>
|
||||||
<string name="system_hooks_field_command">명령어 (절대 경로)</string>
|
<string name="system_hooks_field_command">명령어 (절대 경로)</string>
|
||||||
@@ -393,16 +418,45 @@
|
|||||||
<string name="model_label_available">사용 가능한 모델:</string>
|
<string name="model_label_available">사용 가능한 모델:</string>
|
||||||
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
|
<string name="model_no_models">보고된 모델이 없거나 인증이 필요합니다.</string>
|
||||||
<string name="content_desc_active_model">활성 모델</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_section_pinned">고정된 모델</string>
|
||||||
<string name="model_confirm_continue">계속</string>
|
<string name="model_confirm_continue">계속</string>
|
||||||
<string name="content_desc_unpin_model">모델 고정 해제</string>
|
<string name="content_desc_unpin_model">모델 고정 해제</string>
|
||||||
<string name="content_desc_pinned_section_toggle">고정 섹션 표시 전환</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 -->
|
<!-- Sessions (History) Screen -->
|
||||||
<string name="history_empty_title">히스토리 없음</string>
|
<string name="history_empty_title">히스토리 없음</string>
|
||||||
<string name="history_empty_desc">이전 채팅 세션이 여기에 표시됩니다.</string>
|
<string name="history_empty_desc">이전 채팅 세션이 여기에 표시됩니다.</string>
|
||||||
<string name="history_untitled">제목 없음</string>
|
<string name="history_untitled">제목 없음</string>
|
||||||
<string name="history_message_count">%1$d개 메시지</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="history_load_more">더 불러오기…</string>
|
||||||
|
|
||||||
<!-- Sessions Stats -->
|
<!-- Sessions Stats -->
|
||||||
@@ -484,6 +538,12 @@
|
|||||||
<string name="profiles_show_hidden">숨겨진 프로필 표시</string>
|
<string name="profiles_show_hidden">숨겨진 프로필 표시</string>
|
||||||
<string name="profiles_hide_hidden">숨겨진 프로필 숨기기</string>
|
<string name="profiles_hide_hidden">숨겨진 프로필 숨기기</string>
|
||||||
<string name="profiles_hidden_badge">숨김</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_all_hidden_desc">모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요.</string>
|
||||||
<string name="profiles_content_desc_active">활성 프로필</string>
|
<string name="profiles_content_desc_active">활성 프로필</string>
|
||||||
<string name="profiles_title_edit_soul">소울 프로필 편집: %1$s</string>
|
<string name="profiles_title_edit_soul">소울 프로필 편집: %1$s</string>
|
||||||
@@ -743,6 +803,8 @@
|
|||||||
<string name="channels_action_test">테스트</string>
|
<string name="channels_action_test">테스트</string>
|
||||||
<string name="channels_action_restart_now">지금 재시작</string>
|
<string name="channels_action_restart_now">지금 재시작</string>
|
||||||
<string name="channels_restart_banner">변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다.</string>
|
<string name="channels_restart_banner">변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다.</string>
|
||||||
|
<string name="channels_ingress_url">수신 URL</string>
|
||||||
|
<string name="channels_copy_ingress_url">수신 URL 복사</string>
|
||||||
|
|
||||||
<!-- Pairing Screen -->
|
<!-- Pairing Screen -->
|
||||||
<string name="pairing_sec_pending">승인 대기</string>
|
<string name="pairing_sec_pending">승인 대기</string>
|
||||||
@@ -894,6 +956,9 @@
|
|||||||
<string name="subagent_status_cancelled">취소됨</string>
|
<string name="subagent_status_cancelled">취소됨</string>
|
||||||
<string name="subagent_status_failed">실패</string>
|
<string name="subagent_status_failed">실패</string>
|
||||||
<string name="subagent_duration">%1$.1f초</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_label">빠름</string>
|
||||||
<string name="chat_fast_mode_desc">빠른 모드</string>
|
<string name="chat_fast_mode_desc">빠른 모드</string>
|
||||||
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
|
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
|
||||||
|
|||||||
@@ -184,7 +184,18 @@
|
|||||||
<string name="settings_sec_chat">对话</string>
|
<string name="settings_sec_chat">对话</string>
|
||||||
<string name="settings_item_typing_effect">打字效果</string>
|
<string name="settings_item_typing_effect">打字效果</string>
|
||||||
<string name="settings_desc_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_item_typing_delay">延迟:%1$d 毫秒</string>
|
||||||
<string name="settings_delay_10ms">10 毫秒</string>
|
<string name="settings_delay_10ms">10 毫秒</string>
|
||||||
<string name="settings_delay_100ms">100 毫秒</string>
|
<string name="settings_delay_100ms">100 毫秒</string>
|
||||||
@@ -413,6 +424,20 @@
|
|||||||
<string name="system_gateway_start">启动</string>
|
<string name="system_gateway_start">启动</string>
|
||||||
<string name="system_gateway_restart">重启</string>
|
<string name="system_gateway_restart">重启</string>
|
||||||
<string name="system_gateway_stop">停止</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_active">当前:%1$s</string>
|
||||||
<string name="memory_builtin">内置记忆</string>
|
<string name="memory_builtin">内置记忆</string>
|
||||||
<string name="memory_builtin_sizes">MEMORY.md:%1$s · USER.md:%2$s</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_label_available">可用模型:</string>
|
||||||
<string name="model_no_models">没有可用的模型,或提供商需要认证。</string>
|
<string name="model_no_models">没有可用的模型,或提供商需要认证。</string>
|
||||||
<string name="content_desc_active_model">当前模型</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_section_pinned">固定模型</string>
|
||||||
<string name="content_desc_unpin_model">取消固定模型</string>
|
<string name="content_desc_unpin_model">取消固定模型</string>
|
||||||
<string name="content_desc_pinned_section_toggle">切换固定区显示</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_title">暂无历史</string>
|
||||||
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string>
|
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string>
|
||||||
<string name="history_untitled">未命名</string>
|
<string name="history_untitled">未命名</string>
|
||||||
<string name="history_message_count">%1$d 条消息</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="history_load_more">加载更多会话…</string>
|
||||||
<string name="sessions_search_placeholder">搜索会话…</string>
|
<string name="sessions_search_placeholder">搜索会话…</string>
|
||||||
<string name="sessions_search_empty_title">无结果</string>
|
<string name="sessions_search_empty_title">无结果</string>
|
||||||
@@ -619,6 +673,12 @@
|
|||||||
<string name="profiles_show_hidden">显示隐藏的配置文件</string>
|
<string name="profiles_show_hidden">显示隐藏的配置文件</string>
|
||||||
<string name="profiles_hide_hidden">隐藏已隐藏的配置文件</string>
|
<string name="profiles_hide_hidden">隐藏已隐藏的配置文件</string>
|
||||||
<string name="profiles_hidden_badge">已隐藏</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_all_hidden_desc">所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。</string>
|
||||||
<string name="profiles_content_desc_active">当前配置</string>
|
<string name="profiles_content_desc_active">当前配置</string>
|
||||||
<string name="profiles_title_edit_soul">编辑 Soul 配置:%1$s</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_test">测试</string>
|
||||||
<string name="channels_action_restart_now">立即重启</string>
|
<string name="channels_action_restart_now">立即重启</string>
|
||||||
<string name="channels_restart_banner">更改已保存。重启网关后生效。</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_pending">待批准</string>
|
||||||
<string name="pairing_sec_approved">已批准用户</string>
|
<string name="pairing_sec_approved">已批准用户</string>
|
||||||
<string name="pairing_action_clear_all">全部清除</string>
|
<string name="pairing_action_clear_all">全部清除</string>
|
||||||
@@ -1033,6 +1095,9 @@
|
|||||||
<string name="channels_expiry_format">过期时间:%1$s</string>
|
<string name="channels_expiry_format">过期时间:%1$s</string>
|
||||||
<string name="channels_search_placeholder">搜索渠道…</string>
|
<string name="channels_search_placeholder">搜索渠道…</string>
|
||||||
<string name="chat_attach_file">附加文件</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_label">快速</string>
|
||||||
<string name="chat_fast_mode_desc">快速模式</string>
|
<string name="chat_fast_mode_desc">快速模式</string>
|
||||||
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
|
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
|
||||||
|
|||||||
@@ -208,7 +208,18 @@
|
|||||||
<string name="settings_sec_chat">Chat</string>
|
<string name="settings_sec_chat">Chat</string>
|
||||||
<string name="settings_item_typing_effect">Typing Effect</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="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_item_typing_delay">Delay: %1$d ms</string>
|
||||||
<string name="settings_delay_10ms">10 ms</string>
|
<string name="settings_delay_10ms">10 ms</string>
|
||||||
<string name="settings_delay_100ms">100 ms</string>
|
<string name="settings_delay_100ms">100 ms</string>
|
||||||
@@ -342,6 +353,25 @@
|
|||||||
<string name="kanban_live">Live</string>
|
<string name="kanban_live">Live</string>
|
||||||
<string name="kanban_offline">Offline</string>
|
<string name="kanban_offline">Offline</string>
|
||||||
<string name="kanban_no_tasks">No tasks</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>
|
<string name="action_add">Add</string>
|
||||||
<!-- Skills Screen -->
|
<!-- Skills Screen -->
|
||||||
<string name="skills_status_all">All Statuses</string>
|
<string name="skills_status_all">All Statuses</string>
|
||||||
@@ -468,6 +498,20 @@
|
|||||||
<string name="system_gateway_start">Start</string>
|
<string name="system_gateway_start">Start</string>
|
||||||
<string name="system_gateway_restart">Restart</string>
|
<string name="system_gateway_restart">Restart</string>
|
||||||
<string name="system_gateway_stop">Stop</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 -->
|
<!-- Memory section -->
|
||||||
<string name="memory_active">Active: %1$s</string>
|
<string name="memory_active">Active: %1$s</string>
|
||||||
@@ -567,15 +611,45 @@
|
|||||||
<string name="model_label_available">Available Models:</string>
|
<string name="model_label_available">Available Models:</string>
|
||||||
<string name="model_no_models">No models reported or provider needs authentication.</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_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="model_section_pinned">Pinned Models</string>
|
||||||
<string name="content_desc_unpin_model">Unpin model</string>
|
<string name="content_desc_unpin_model">Unpin model</string>
|
||||||
<string name="content_desc_pinned_section_toggle">Toggle pinned section visibility</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 -->
|
<!-- Sessions (History) Screen -->
|
||||||
<string name="history_empty_title">No history</string>
|
<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_empty_desc">Previous chat sessions from Hermes will appear here.</string>
|
||||||
<string name="history_untitled">Untitled</string>
|
<string name="history_untitled">Untitled</string>
|
||||||
<string name="history_message_count">%1$d messages</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>
|
<string name="history_load_more">Load more sessions…</string>
|
||||||
|
|
||||||
<!-- Sessions Stats -->
|
<!-- Sessions Stats -->
|
||||||
@@ -714,6 +788,12 @@
|
|||||||
<string name="profiles_show_hidden">Show hidden profiles</string>
|
<string name="profiles_show_hidden">Show hidden profiles</string>
|
||||||
<string name="profiles_hide_hidden">Hide hidden profiles</string>
|
<string name="profiles_hide_hidden">Hide hidden profiles</string>
|
||||||
<string name="profiles_hidden_badge">Hidden</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_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_content_desc_active">Active Profile</string>
|
||||||
<string name="profiles_title_edit_soul">Edit Soul Profile: %1$s</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_test">Test</string>
|
||||||
<string name="channels_action_restart_now">Restart now</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_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 -->
|
<!-- Pairing Screen -->
|
||||||
<string name="pairing_sec_pending">Pending Approvals</string>
|
<string name="pairing_sec_pending">Pending Approvals</string>
|
||||||
@@ -1191,9 +1273,13 @@
|
|||||||
<string name="channels_expiry_format">Expires: %1$s</string>
|
<string name="channels_expiry_format">Expires: %1$s</string>
|
||||||
<string name="channels_search_placeholder">Search channels…</string>
|
<string name="channels_search_placeholder">Search channels…</string>
|
||||||
<string name="chat_attach_file">Attach file</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_label">Fast</string>
|
||||||
<string name="chat_fast_mode_desc">Fast mode</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_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_save_attachment">Save %1$s</string>
|
||||||
<string name="chat_switch_model_title">Switch model (this chat)</string>
|
<string name="chat_switch_model_title">Switch model (this chat)</string>
|
||||||
<string name="config_clear_value">Clear value</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.MemoryStats
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||||
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
|
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.MoaConfigResponse
|
||||||
import com.m57.hermescontrol.data.model.MoaModelSlot
|
import com.m57.hermescontrol.data.model.MoaModelSlot
|
||||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||||
@@ -1118,7 +1119,8 @@ class E2eIntegrationTest {
|
|||||||
platforms = listOf(platform),
|
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()
|
val viewModel = ChannelsViewModel()
|
||||||
viewModel.loadPlatforms()
|
viewModel.loadPlatforms()
|
||||||
@@ -1205,10 +1207,9 @@ class E2eIntegrationTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val board = KanbanBoard("board-1", "Backlog", null)
|
val board = KanbanBoard("board-1", "Backlog", null)
|
||||||
val task = KanbanTask("task-1", "Do laundry", null, "todo", 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"))
|
Response.success(KanbanBoardsResponse(listOf(board), "board-1"))
|
||||||
coEvery { mockApiService.switchKanbanBoard("board-1") } returns Response.success(Unit)
|
coEvery { mockApiService.getBoard("board-1", any(), any()) } returns
|
||||||
coEvery { mockApiService.getKanbanBoard() } returns
|
|
||||||
Response.success(KanbanBoardResponse(listOf(KanbanColumn("todo", listOf(task))), null, null))
|
Response.success(KanbanBoardResponse(listOf(KanbanColumn("todo", listOf(task))), null, null))
|
||||||
|
|
||||||
val viewModel = KanbanViewModel(eventsClientProvider = { mockk<KanbanEventsClient>(relaxed = true) })
|
val viewModel = KanbanViewModel(eventsClientProvider = { mockk<KanbanEventsClient>(relaxed = true) })
|
||||||
@@ -1230,11 +1231,16 @@ class E2eIntegrationTest {
|
|||||||
// Desktop-style transition: todo -> ready is a direct write the
|
// Desktop-style transition: todo -> ready is a direct write the
|
||||||
// backend accepts; the VM PATCHes the target status.
|
// backend accepts; the VM PATCHes the target status.
|
||||||
coEvery {
|
coEvery {
|
||||||
mockApiService.updateKanbanTask(
|
mockApiService.updateTask(
|
||||||
"task-1",
|
eq("task-1"),
|
||||||
mapOf("status" to "ready"),
|
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)
|
viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -1243,9 +1249,13 @@ class E2eIntegrationTest {
|
|||||||
.status,
|
.status,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create path: the wrapped {"task": ...} response must not
|
// Create path: returns CreateTaskResponse
|
||||||
// break deserialization — the API layer reads it as Unit.
|
coEvery { mockApiService.createTask(any(), any()) } returns
|
||||||
coEvery { mockApiService.createKanbanTask(any(), any()) } returns Response.success(Unit)
|
Response.success(
|
||||||
|
com.m57.hermescontrol.data.model.CreateTaskResponse(
|
||||||
|
task = KanbanTask("task-2", "Fresh task", status = "todo"),
|
||||||
|
),
|
||||||
|
)
|
||||||
viewModel.createTask("Fresh task", null, "todo")
|
viewModel.createTask("Fresh task", null, "todo")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals("Task created successfully", viewModel.uiState.value.toastMessage)
|
assertEquals("Task created successfully", viewModel.uiState.value.toastMessage)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavKey
|
|||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
@@ -21,12 +22,14 @@ class NavigationControllerTest {
|
|||||||
// Start fresh — no pinned back stack from a previous test
|
// Start fresh — no pinned back stack from a previous test
|
||||||
NavigationController.backStack = null
|
NavigationController.backStack = null
|
||||||
NavigationController.consumePendingSessionId()
|
NavigationController.consumePendingSessionId()
|
||||||
|
NavigationController.consumePendingNewChatNavigation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
NavigationController.backStack = null
|
NavigationController.backStack = null
|
||||||
NavigationController.consumePendingSessionId()
|
NavigationController.consumePendingSessionId()
|
||||||
|
NavigationController.consumePendingNewChatNavigation()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dedup guard: navigateTo with same key ──────────────────────────────
|
// ── Dedup guard: navigateTo with same key ──────────────────────────────
|
||||||
@@ -119,6 +122,18 @@ class NavigationControllerTest {
|
|||||||
assertNull(NavigationController.consumePendingSessionId())
|
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
|
@Test
|
||||||
fun `openChatSession ignores blank ids`() {
|
fun `openChatSession ignores blank ids`() {
|
||||||
val backStack = NavBackStack<NavKey>(HistoryScreen)
|
val backStack = NavBackStack<NavKey>(HistoryScreen)
|
||||||
|
|||||||
+174
@@ -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
|
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.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -12,6 +17,120 @@ import org.junit.Test
|
|||||||
* mirror the real wire shape.
|
* mirror the real wire shape.
|
||||||
*/
|
*/
|
||||||
class UsageSnapshotResponseTest {
|
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
|
@Test
|
||||||
fun `compressions parses from Double and Int`() {
|
fun `compressions parses from Double and Int`() {
|
||||||
assertEquals(2, parseUsageSnapshot(mapOf("compressions" to 2.0))?.compressions)
|
assertEquals(2, parseUsageSnapshot(mapOf("compressions" to 2.0))?.compressions)
|
||||||
@@ -50,4 +169,63 @@ class UsageSnapshotResponseTest {
|
|||||||
assertEquals(50000L, parsed?.totalTokens)
|
assertEquals(50000L, parsed?.totalTokens)
|
||||||
assertEquals(45.5, parsed?.avgTps ?: 0.0, 0.001)
|
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
Reference in new issue
Block a user