feat(chat): compact sticky task progress chip (#942) (#943)

* 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:
M57 authored and GitHub committed 2026-08-21 00:03:50 +03:00
1 parent 59bcbdfe3c
commit fad49b7b66
13 files changed
+417 -324

No files matched your search

@@ -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
@@ -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),