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

@@ -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<Context>()
return context.getString(R.string.todo_task_count, completed, total)
}
}