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

@@ -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())
}
}