* 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
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user