From fad49b7b66620cdc3fbac82cf9a63792b2e22ef0 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:03:50 +0300 Subject: [PATCH] feat(chat): compact sticky task progress chip (#942) (#943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 · · 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 · '. 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. --- .../hermescontrol/ui/chat/TodoTaskCardTest.kt | 133 ------------- .../hermescontrol/data/ws/HermesWsClient.kt | 4 +- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 16 ++ .../hermescontrol/ui/chat/ChatViewModel.kt | 7 +- .../m57/hermescontrol/ui/chat/TodoTaskCard.kt | 159 --------------- .../m57/hermescontrol/ui/chat/ToolBubble.kt | 11 -- .../components/SubagentInspectionSheet.kt | 53 +++-- .../ui/chat/components/TaskProgressChip.kt | 181 ++++++++++++++++++ .../ui/sessions/SessionsScreen.kt | 4 +- app/src/main/res/values-ko/strings.xml | 8 + app/src/main/res/values-zh/strings.xml | 9 +- app/src/main/res/values/strings.xml | 11 +- .../ui/chat/TaskProgressChipTest.kt | 145 ++++++++++++++ 13 files changed, 417 insertions(+), 324 deletions(-) delete mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/TaskProgressChip.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt deleted file mode 100644 index b01b35dd..00000000 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt +++ /dev/null @@ -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() - return context.getString(R.string.todo_task_count, completed, total) - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index ea368a87..8a8d529f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -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()) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 7e27072a..0be31712 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 7bf57b96..59bf41fa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt deleted file mode 100644 index e9f8b68a..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt +++ /dev/null @@ -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, - 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), - ) - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 65580fef..6096fe39 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt index 5cf2597e..d336cf6f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt @@ -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 " } ?: "" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/TaskProgressChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/TaskProgressChip.kt new file mode 100644 index 00000000..8ff73c04 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/TaskProgressChip.kt @@ -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, + indicators: List, + 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, + indicators: List, +): 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, + indicators: List, +): Boolean = todos.any { !it.isCompleted && !it.isCancelled } || indicators.any { it.isRunning } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index f37abe35..4987dec1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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), diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index a4b71117..3a84c8c7 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -755,4 +755,12 @@ 스킬 호출됨 시스템 알림 도구 호출 한도 도달 + 작업 %1$d/%2$d + + 에이전트 %1$d개 + + + 에이전트 %1$d개 실행 중 + + 작업 세부 정보 열기 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ab4ac1b5..297a9525 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1042,7 +1042,14 @@ 正在保存配置… 打开 Telegram 扫描应用内显示的二维码。 Telegram 用户 ID - 任务 %1$d/%2$d + 任务 %1$d/%2$d + + %1$d 个代理 + + + %1$d 个代理运行中 + + 查看任务详情 耗时:%1$s · 已脱敏 搜索工具集… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f2d3c8d..e33cb24f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1166,7 +1166,16 @@ Saving configuration… Open Telegram and scan the QR code displayed in the app. Telegram user ID - Tasks %1$d/%2$d + Tasks %1$d/%2$d + + %1$d agent + %1$d agents + + + %1$d agent running + %1$d agents running + + Open task details Duration: %1$s · redacted Search toolsets... diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt new file mode 100644 index 00000000..43692f21 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/TaskProgressChipTest.kt @@ -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()) + } +}