* 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
@@ -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