* feat(chat): compact sticky task progress chip (#942) Re-add a glanceable progress strip above the chat timeline while work is active. Distinct from the inline TodoTaskCard (permanent timeline record): this chip is the temporary "what is happening right now" surface. - Shows only while todos are incomplete or a subagent is running. - Bounds to the same hydrated state.todos / subagentIndicators the card uses -- NOT the old broken state.todos gate removed in #811. - Label: "Tasks 2/5 · <in-progress task> · N agent" (agent count when relevant). - Tap opens SubagentInspectionSheet + resumes bottom-follow. - Auto-hides when all work completes/cancels; rebuilds on session resume. - i18n strings added (en/zh/ko); ktlint clean; compileDebugKotlin + unit tests green. * refactor(chat): drop inline TodoTaskCard, keep single sticky progress chip Per follow-up to #942: the inline todo card in the chat view is removed. The sticky TaskProgressChip above the timeline is now the only glanceable surface; the tool message itself remains the permanent record. - Remove inline TodoTaskCard early-return in ToolBubble.kt (todo tool output now renders like any other tool bubble). - Delete TodoTaskCard.kt + TodoTaskCardTest.kt (SecurityRiskChip lives in ToolBubble.kt, so risk display is unaffected). - Redesign chip: replace the ElectricBolt icon with a real CircularProgressIndicator (no emoji), keep ChevronRight affordance. - Label handles the no-todos case (subagents only): 'N agent running'. - Drop dead todo_task_count string; add task_progress_agent_running (en/zh/ko). ktlint clean; compileDebugKotlin + chat unit tests green. * chore: clear pre-existing compile warnings - HermesWsClient.kt: drop unnecessary safe call on non-null ResponseBody (resp.body.string()) and the now-dead 'body != null' guard it implied. - SessionsScreen.kt: replace deprecated Icons.Filled.CallSplit with the AutoMirrored variant (match existing Send usage in this file). ktlint clean; compileDebugKotlin SUCCESSFUL with no warnings. * chore(chat): replace emoji status markers with Material icons in inspection sheet The sticky progress chip opens SubagentInspectionSheet on tap; that sheet used emoji Text glyphs for status. Replaced with real Material icons: - Todo rows: completed -> CheckCircle (success), in-progress -> Autorenew (primary), cancelled -> Cancel (warning), pending -> RadioButtonUnchecked (muted). - Subagent rows: complete -> CheckCircle (success), failed -> Cancel (error), running -> Autorenew (primary). - Section headers: ElectricBolt -> FormatListBulleted (agent plan) + Groups (subagents). Icons tinted via LocalHermesStatusColors. No emoji remain in the chip's tap surface. ktlint clean; compileDebugKotlin SUCCESSFUL. * fix(lint): drop orphaned todo_task_count translation in ko Removing the string from the default (en) locale left a stale Korean translation, which tripped Lint's ExtraTranslation error and aborted lintDebug. Removing the orphan so default + translations stay in sync. android-lint now BUILD SUCCESSFUL (0 errors). * fix(chat): address #943 review - progress count, resume hydration, chip tests, plurals - TaskProgressChip: current-task number is now the first unfinished, non-cancelled todo's 1-based position (active index + 1), so a completed task 1 + in-progress task 2 reads as 2/5 instead of 1/5 (review point 2). - Deterministic resume hydration: hydrate todos directly from cached Room messages and from the merged REST transcript inside ChatViewModel load paths, in addition to the WS reducer, so a resumed session rebuilds the chip without waiting for a follow-up event (review point 3). - Extracted computeChipDisplay / shouldShowProgressChip pure functions and added TaskProgressChipTest covering active-task progress, completed/cancelled hide, subagent-only state, and transcript-resume hydration (review point 4). - agent count strings are now Android plurals (task_progress_agents / task_progress_agent_running) in en/zh/ko to avoid '2 agent' (review point 5). Co-Authored-By trailer intentionally omitted per repo convention. * fix(chat): derive chip number and content from the same selected task computeChipDisplay selected the task number from the first unfinished/ non-cancelled todo but the content independently from the first in_progress todo. With a pending task 1 + in_progress task 2 this produced 'Tasks 1/N · <task2 content>'. Now both values come from one selected item (prefer in_progress, fall back to first pending/non-terminal). Adds two focused tests: same-item selection, and pending-only fallback.
This commit is contained in:
13 files changed
+417
-324
No files matched your search
@@ -1,133 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class TodoTaskCardTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun todoTool_displaysExpandedTaskCard() {
|
||||
val message =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
toolName = null,
|
||||
toolStatus = ToolStatus.COMPLETED,
|
||||
toolOutputRiskData = ToolOutputRiskData(risk = "high", findings = emptyList(), redacted = false),
|
||||
content =
|
||||
"""{
|
||||
"name": "todo",
|
||||
"result": {
|
||||
"todos": [
|
||||
{"id": "one", "content": "Finished", "status": "completed"},
|
||||
{"id": "two", "content": "Working", "status": "in_progress"},
|
||||
{"id": "three", "content": "Later", "status": "pending"}
|
||||
],
|
||||
"summary": {"total": 3, "pending": 1, "in_progress": 1, "completed": 1, "cancelled": 0}
|
||||
}
|
||||
}""",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
ToolBubble(message = message)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("todo_task_card").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(taskCount(1, 3)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Finished").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Working").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Later").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Risky output").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyTodoOutput_usesGenericToolBubble() {
|
||||
val message =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
toolName = "todo",
|
||||
toolStatus = ToolStatus.COMPLETED,
|
||||
content = """{"name":"todo","result":{"todos":[]}}""",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
ToolBubble(message = message)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("todo_task_card").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayAliases_renderStructuredStatuses() {
|
||||
val message =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
toolStatus = ToolStatus.COMPLETED,
|
||||
content =
|
||||
"""{"tool_name":"todo_write","result":{"todos":[
|
||||
{"id":"one","content":"Done alias","status":"done"},
|
||||
{"id":"two","content":"Running alias","status":"running"},
|
||||
{"id":"three","content":"Failed alias","status":"failed"},
|
||||
{"id":"four","content":"Queued alias","status":"queued"}
|
||||
]}}""",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
ToolBubble(message = message)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(taskCount(1, 4)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription("Completed").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription("Cancelled").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Running alias").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Queued alias").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedTodoOutput_usesGenericToolBubble() {
|
||||
val message =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
toolName = "todo",
|
||||
toolStatus = ToolStatus.COMPLETED,
|
||||
content = "not json",
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme {
|
||||
ToolBubble(message = message)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("todo_task_card").assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun taskCount(
|
||||
completed: Int,
|
||||
total: Int,
|
||||
): String {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
return context.getString(R.string.todo_task_count, completed, total)
|
||||
}
|
||||
}
|
||||
@@ -457,11 +457,11 @@ object HermesWsClient {
|
||||
val (code, body) =
|
||||
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
|
||||
client.newCall(request).execute().use { resp ->
|
||||
resp.code to resp.body?.string()
|
||||
resp.code to resp.body.string()
|
||||
}
|
||||
}
|
||||
|
||||
if (body != null && code in 200..299) {
|
||||
if (code in 200..299) {
|
||||
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
|
||||
val ticket = ticketMatch?.groupValues?.getOrNull(1)
|
||||
if (!ticket.isNullOrBlank()) {
|
||||
|
||||
@@ -98,7 +98,9 @@ import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||
import com.m57.hermescontrol.ui.common.ActionProgressDialog
|
||||
@@ -515,6 +517,20 @@ fun ChatScreen(
|
||||
onReloginClick = { showReloginDialog = true },
|
||||
)
|
||||
|
||||
// Issue #942: compact glanceable progress strip while work is active.
|
||||
// Bound to the same hydrated todos / subagentIndicators state.
|
||||
// Auto-hides when all todos complete/cancel and no subagent is running.
|
||||
val workActive = shouldShowProgressChip(state.todos, state.subagentIndicators)
|
||||
TaskProgressChip(
|
||||
visible = workActive,
|
||||
todos = state.todos,
|
||||
indicators = state.subagentIndicators,
|
||||
onClick = {
|
||||
showSubagentInspectionSheet = true
|
||||
scrollController.resumeFollowing()
|
||||
},
|
||||
)
|
||||
|
||||
credentialWarning?.let { warning ->
|
||||
CredentialWarningBanner(
|
||||
warning = warning,
|
||||
|
||||
@@ -2002,7 +2002,11 @@ class ChatViewModel(
|
||||
state.messages.isEmpty() &&
|
||||
cachedMessages.isNotEmpty()
|
||||
) {
|
||||
state.copy(messages = cachedMessages, isLoading = false)
|
||||
state.copy(
|
||||
messages = cachedMessages,
|
||||
isLoading = false,
|
||||
todos = hydrateTodosFromMessages(cachedMessages),
|
||||
)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
@@ -2070,6 +2074,7 @@ class ChatViewModel(
|
||||
}
|
||||
state.copy(
|
||||
messages = merged,
|
||||
todos = hydrateTodosFromMessages(merged),
|
||||
isLoading = false,
|
||||
hasOlderMessages = hasOlder,
|
||||
isLoadingOlder = false,
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
|
||||
@Composable
|
||||
internal fun TodoTaskCard(
|
||||
items: List<TodoItem>,
|
||||
isRunning: Boolean,
|
||||
riskData: ToolOutputRiskData?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(true) }
|
||||
val contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val completed = items.count { it.isCompleted }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 1.dp)
|
||||
.testTag("todo_task_card"),
|
||||
) {
|
||||
Card(
|
||||
onClick = { expanded = !expanded },
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().animateContentSize().padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (isRunning) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.todo_task_count, completed, items.size),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (expanded) "Collapse tasks" else "Expand tasks",
|
||||
tint = contentColor.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
|
||||
if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) {
|
||||
SecurityRiskChip(riskData = riskData, contentColor = contentColor)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
items.forEach { item ->
|
||||
TodoTaskRow(item = item, contentColor = contentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TodoTaskRow(
|
||||
item: TodoItem,
|
||||
contentColor: Color,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val finished = item.isCompleted || item.isCancelled
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
when {
|
||||
item.isCompleted ->
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = "Completed",
|
||||
tint = statusColors.success,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
|
||||
item.isInProgress ->
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
|
||||
item.isCancelled ->
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Cancelled",
|
||||
tint = statusColors.warning,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
|
||||
else ->
|
||||
Surface(
|
||||
modifier = Modifier.size(20.dp),
|
||||
shape = RoundedCornerShape(50),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.5.dp, contentColor.copy(alpha = 0.45f)),
|
||||
) {}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = item.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = contentColor,
|
||||
textDecoration = if (finished) TextDecoration.LineThrough else TextDecoration.None,
|
||||
modifier = Modifier.alpha(if (finished) 0.55f else 1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -113,17 +113,6 @@ internal fun ToolBubble(
|
||||
message: ChatMessage,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val todoItems = extractTodosFromJson(message.content)
|
||||
if (todoItems != null) {
|
||||
TodoTaskCard(
|
||||
items = todoItems,
|
||||
isRunning = message.toolStatus == ToolStatus.RUNNING,
|
||||
riskData = message.toolOutputRiskData,
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showRawJson by remember { mutableStateOf(false) }
|
||||
val chipColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
|
||||
+39
-14
@@ -15,8 +15,12 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||
import androidx.compose.material.icons.filled.Autorenew
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ElectricBolt
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -37,6 +41,7 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||
|
||||
@@ -73,7 +78,7 @@ fun SubagentInspectionSheet(
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ElectricBolt,
|
||||
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
@@ -145,7 +150,7 @@ fun SubagentInspectionSheet(
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ElectricBolt,
|
||||
imageVector = Icons.Filled.Groups,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
@@ -186,14 +191,25 @@ private fun TodoInspectionCard(todo: TodoItem) {
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val statusSymbol =
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val (statusIcon, statusTint) =
|
||||
when {
|
||||
todo.isCompleted -> "✅"
|
||||
todo.isInProgress -> "🔄"
|
||||
todo.isCancelled -> "❌"
|
||||
else -> "⭕"
|
||||
todo.isCompleted ->
|
||||
Icons.Filled.CheckCircle to statusColors.success
|
||||
todo.isInProgress ->
|
||||
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
|
||||
todo.isCancelled ->
|
||||
Icons.Filled.Cancel to statusColors.warning
|
||||
else ->
|
||||
Icons.Filled.RadioButtonUnchecked to
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
}
|
||||
Text(text = statusSymbol, fontSize = 16.sp)
|
||||
Icon(
|
||||
imageVector = statusIcon,
|
||||
contentDescription = null,
|
||||
tint = statusTint,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = todo.content,
|
||||
@@ -223,13 +239,22 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val statusSymbol =
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val (statusIcon, statusTint) =
|
||||
when {
|
||||
indicator.isComplete -> "✅"
|
||||
indicator.isFailed -> "❌"
|
||||
else -> "🔄"
|
||||
indicator.isComplete ->
|
||||
Icons.Filled.CheckCircle to statusColors.success
|
||||
indicator.isFailed ->
|
||||
Icons.Filled.Cancel to statusColors.error
|
||||
else ->
|
||||
Icons.Filled.Autorenew to MaterialTheme.colorScheme.primary
|
||||
}
|
||||
Text(text = statusSymbol, fontSize = 16.sp)
|
||||
Icon(
|
||||
imageVector = statusIcon,
|
||||
contentDescription = null,
|
||||
tint = statusTint,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
val taskIndexStr = indicator.taskIndex?.let { "#$it " } ?: ""
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.chat.SubagentIndicator
|
||||
import com.m57.hermescontrol.ui.chat.TodoItem
|
||||
|
||||
/**
|
||||
* Compact, glanceable progress strip shown above the chat timeline while work is
|
||||
* active. This replaces the old inline [TodoTaskCard] (removed per #942 follow-up):
|
||||
* instead of a duplicate card in the message list, the only persistent record is the
|
||||
* tool message itself, and this chip is the temporary "what is happening right now"
|
||||
* surface. Bound to the same hydrated [todos] / [indicators] state, so a resumed
|
||||
* session rebuilds it for free.
|
||||
*
|
||||
* Visibility is driven by [visible]; the caller keeps it true only while at least
|
||||
* one todo is incomplete or a subagent is running, and hides it automatically when
|
||||
* all work completes or is cancelled.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TaskProgressChip(
|
||||
visible: Boolean,
|
||||
todos: List<TodoItem>,
|
||||
indicators: List<SubagentIndicator>,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
val display = computeChipDisplay(todos, indicators)
|
||||
val label =
|
||||
buildString {
|
||||
if (display.hasTodos) {
|
||||
append(stringResource(R.string.task_progress_count, display.currentTaskNumber, display.total))
|
||||
display.currentTaskContent?.let {
|
||||
append(" · ")
|
||||
append(it)
|
||||
}
|
||||
} else if (display.activeAgents > 0) {
|
||||
append(
|
||||
pluralStringResource(
|
||||
R.plurals.task_progress_agent_running,
|
||||
display.activeAgents,
|
||||
display.activeAgents,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (display.hasTodos && display.activeAgents > 0) {
|
||||
append(" · ")
|
||||
append(
|
||||
pluralStringResource(
|
||||
R.plurals.task_progress_agents,
|
||||
display.activeAgents,
|
||||
display.activeAgents,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.testTag("task_progress_chip"),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
tonalElevation = 2.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ChevronRight,
|
||||
contentDescription = stringResource(R.string.task_progress_open_details),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure, testable projection of the progress chip's data. The composable turns
|
||||
* this into a localized label; keeping the math here lets unit tests cover the
|
||||
* progress/resume/visibility rules without a Compose host.
|
||||
*/
|
||||
internal data class ChipDisplay(
|
||||
val hasTodos: Boolean,
|
||||
val currentTaskNumber: Int,
|
||||
val total: Int,
|
||||
val currentTaskContent: String?,
|
||||
val activeAgents: Int,
|
||||
)
|
||||
|
||||
internal fun computeChipDisplay(
|
||||
todos: List<TodoItem>,
|
||||
indicators: List<SubagentIndicator>,
|
||||
): ChipDisplay {
|
||||
// Select the task the chip should surface: prefer the in_progress one, then
|
||||
// fall back to the first unfinished/non-cancelled todo. Both the number and
|
||||
// the content are derived from THIS same selected item so they can never
|
||||
// disagree (e.g. a pending task 1 + in_progress task 2 must read "2/N · task2",
|
||||
// not "1/N · task2").
|
||||
val selectedIndex =
|
||||
todos.indexOfFirst { it.isInProgress }.let { idx ->
|
||||
if (idx >= 0) idx else todos.indexOfFirst { !it.isCompleted && !it.isCancelled }
|
||||
}
|
||||
val currentTaskNumber = if (selectedIndex >= 0) selectedIndex + 1 else todos.count { it.isCompleted }
|
||||
val selected = if (selectedIndex >= 0) todos[selectedIndex] else null
|
||||
val activeAgents = indicators.count { it.isRunning }
|
||||
return ChipDisplay(
|
||||
hasTodos = todos.isNotEmpty(),
|
||||
currentTaskNumber = currentTaskNumber,
|
||||
total = todos.size,
|
||||
currentTaskContent = selected?.content,
|
||||
activeAgents = activeAgents,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the chip should be visible: there is at least one unfinished,
|
||||
* non-cancelled todo, or a subagent is still running. Cancelled/completed-only
|
||||
* state hides it.
|
||||
*/
|
||||
internal fun shouldShowProgressChip(
|
||||
todos: List<TodoItem>,
|
||||
indicators: List<SubagentIndicator>,
|
||||
): Boolean = todos.any { !it.isCompleted && !it.isCancelled } || indicators.any { it.isRunning }
|
||||
@@ -31,8 +31,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.CallSplit
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.CallSplit
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
@@ -1029,7 +1029,7 @@ private fun SessionCard(
|
||||
// number carries the structure so names never get squished.
|
||||
if (isFork && !isSelecting) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CallSplit,
|
||||
imageVector = Icons.AutoMirrored.Filled.CallSplit,
|
||||
contentDescription = stringResource(R.string.sessions_fork_indicator),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
|
||||
@@ -755,4 +755,12 @@
|
||||
<string name="timeline_marker_skill_invocation">스킬 호출됨</string>
|
||||
<string name="timeline_marker_internal_notification">시스템 알림</string>
|
||||
<string name="timeline_marker_max_iterations">도구 호출 한도 도달</string>
|
||||
<string name="task_progress_count">작업 %1$d/%2$d</string>
|
||||
<plurals name="task_progress_agents">
|
||||
<item quantity="other">에이전트 %1$d개</item>
|
||||
</plurals>
|
||||
<plurals name="task_progress_agent_running">
|
||||
<item quantity="other">에이전트 %1$d개 실행 중</item>
|
||||
</plurals>
|
||||
<string name="task_progress_open_details">작업 세부 정보 열기</string>
|
||||
</resources>
|
||||
@@ -1042,7 +1042,14 @@
|
||||
<string name="tg_onboarding_saving">正在保存配置…</string>
|
||||
<string name="tg_onboarding_scan_hint">打开 Telegram 扫描应用内显示的二维码。</string>
|
||||
<string name="tg_onboarding_user_id_placeholder">Telegram 用户 ID</string>
|
||||
<string name="todo_task_count">任务 %1$d/%2$d</string>
|
||||
<string name="task_progress_count">任务 %1$d/%2$d</string>
|
||||
<plurals name="task_progress_agents">
|
||||
<item quantity="other">%1$d 个代理</item>
|
||||
</plurals>
|
||||
<plurals name="task_progress_agent_running">
|
||||
<item quantity="other">%1$d 个代理运行中</item>
|
||||
</plurals>
|
||||
<string name="task_progress_open_details">查看任务详情</string>
|
||||
<string name="tool_duration">耗时:%1$s</string>
|
||||
<string name="tool_redacted">· 已脱敏</string>
|
||||
<string name="toolsets_search_placeholder">搜索工具集…</string>
|
||||
|
||||
@@ -1166,7 +1166,16 @@
|
||||
<string name="tg_onboarding_saving">Saving configuration…</string>
|
||||
<string name="tg_onboarding_scan_hint">Open Telegram and scan the QR code displayed in the app.</string>
|
||||
<string name="tg_onboarding_user_id_placeholder">Telegram user ID</string>
|
||||
<string name="todo_task_count">Tasks %1$d/%2$d</string>
|
||||
<string name="task_progress_count">Tasks %1$d/%2$d</string>
|
||||
<plurals name="task_progress_agents">
|
||||
<item quantity="one">%1$d agent</item>
|
||||
<item quantity="other">%1$d agents</item>
|
||||
</plurals>
|
||||
<plurals name="task_progress_agent_running">
|
||||
<item quantity="one">%1$d agent running</item>
|
||||
<item quantity="other">%1$d agents running</item>
|
||||
</plurals>
|
||||
<string name="task_progress_open_details">Open task details</string>
|
||||
<string name="tool_duration">Duration: %1$s</string>
|
||||
<string name="tool_redacted">· redacted</string>
|
||||
<string name="toolsets_search_placeholder">Search toolsets...</string>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.components.computeChipDisplay
|
||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Focused coverage for the sticky progress chip surface (#942):
|
||||
* - correct current-task progress (active index + 1, not completed count)
|
||||
* - hidden after all todos complete / cancel
|
||||
* - subagent-only state
|
||||
* - resume hydration from a transcript of messages
|
||||
*/
|
||||
class TaskProgressChipTest {
|
||||
private fun todo(
|
||||
content: String,
|
||||
status: String,
|
||||
) = TodoItem(id = content, content = content, status = status)
|
||||
|
||||
@Test
|
||||
fun `current task number is active index plus one, not completed count`() {
|
||||
// task 1 done, task 2 in progress -> "2/5", not "1/5"
|
||||
val todos =
|
||||
listOf(
|
||||
todo("a", "completed"),
|
||||
todo("b", "in_progress"),
|
||||
todo("c", "pending"),
|
||||
todo("d", "pending"),
|
||||
todo("e", "pending"),
|
||||
)
|
||||
val display = computeChipDisplay(todos, emptyList())
|
||||
assertEquals(5, display.total)
|
||||
assertEquals(2, display.currentTaskNumber)
|
||||
assertEquals("b", display.currentTaskContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all completed shows completed count and still has todos`() {
|
||||
val todos = listOf(todo("a", "completed"), todo("b", "completed"))
|
||||
val display = computeChipDisplay(todos, emptyList())
|
||||
assertEquals(2, display.total)
|
||||
assertEquals(2, display.currentTaskNumber)
|
||||
assertNull(display.currentTaskContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `number and content derive from the same selected task`() {
|
||||
// task 1 pending, task 2 in_progress: the in_progress task is surfaced,
|
||||
// so both the number and the content come from task 2 (not 1/N · task2).
|
||||
val todos = listOf(todo("first", "pending"), todo("second", "in_progress"))
|
||||
val display = computeChipDisplay(todos, emptyList())
|
||||
assertEquals(2, display.total)
|
||||
assertEquals(2, display.currentTaskNumber)
|
||||
assertEquals("second", display.currentTaskContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to first pending task when none in progress`() {
|
||||
val todos = listOf(todo("first", "pending"), todo("second", "pending"))
|
||||
val display = computeChipDisplay(todos, emptyList())
|
||||
assertEquals(1, display.currentTaskNumber)
|
||||
assertEquals("first", display.currentTaskContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelled tasks are skipped when finding the active task`() {
|
||||
// task 1 completed, task 2 cancelled, task 3 in progress -> active = 3
|
||||
val todos =
|
||||
listOf(
|
||||
todo("a", "completed"),
|
||||
todo("b", "cancelled"),
|
||||
todo("c", "in_progress"),
|
||||
)
|
||||
val display = computeChipDisplay(todos, emptyList())
|
||||
assertEquals(3, display.currentTaskNumber)
|
||||
assertEquals("c", display.currentTaskContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chip hides when all todos are completed`() {
|
||||
val todos = listOf(todo("a", "completed"), todo("b", "completed"))
|
||||
assertFalse(shouldShowProgressChip(todos, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chip hides when all todos are cancelled`() {
|
||||
val todos = listOf(todo("a", "cancelled"), todo("b", "cancelled"))
|
||||
assertFalse(shouldShowProgressChip(todos, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chip shows while a todo is in progress`() {
|
||||
val todos = listOf(todo("a", "completed"), todo("b", "in_progress"))
|
||||
assertTrue(shouldShowProgressChip(todos, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chip shows with a running subagent even when no todos`() {
|
||||
val indicators = listOf(SubagentIndicator(type = "subagent.start", status = "running"))
|
||||
assertTrue(shouldShowProgressChip(emptyList(), indicators))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subagent only state reports zero todos and running agents`() {
|
||||
val indicators = listOf(SubagentIndicator(type = "subagent.start", status = "running"))
|
||||
val display = computeChipDisplay(emptyList(), indicators)
|
||||
assertEquals(false, display.hasTodos)
|
||||
assertEquals(1, display.activeAgents)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resume hydration recovers todos from a transcript message`() {
|
||||
// A transcript message whose content carries the todos JSON (as the WS
|
||||
// reducer path would have produced) must be picked up directly, so a
|
||||
// resumed session shows the chip without waiting for another event.
|
||||
val json =
|
||||
"""{"todos":[{"id":"1","content":"first","status":"completed"},{"id":"2","content":"second","status":"in_progress"}]}"""
|
||||
val messages =
|
||||
listOf(
|
||||
ChatMessage(
|
||||
id = "m1",
|
||||
role = MessageRole.TOOL,
|
||||
toolName = "todo",
|
||||
content = json,
|
||||
),
|
||||
)
|
||||
val hydrated = hydrateTodosFromMessages(messages)
|
||||
assertEquals(2, hydrated.size)
|
||||
assertEquals("second", hydrated[1].content)
|
||||
assertEquals(true, hydrated[1].isInProgress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resume hydration returns empty when no todo message present`() {
|
||||
val messages =
|
||||
listOf(
|
||||
ChatMessage(id = "m1", role = MessageRole.USER, content = "hi"),
|
||||
)
|
||||
assertTrue(hydrateTodosFromMessages(messages).isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user