chore(ui): replace emoji with Material icons across chat UI (#944)

* chore(ui): replace emoji with Material icons across chat UI

Removes emoji glyphs and swaps them for real Material icons / plain text:

- ToolSchemaRegistry: iconEmoji String -> ImageVector icon for all ~35 tools;
  summaryPrefix emoji stripped (terminal keeps '$ '); consumer renders Icon().
- ToolBubble: collapsed summary now shows Icon(config.icon) instead of an emoji
  Text prefix; web-search url drops the link emoji; warning badge uses
  Icons.Filled.Warning instead of a '⚠' Text glyph.
- ChatBubble SelfImprovementReviewCard: 🛠/🧠 String -> Build/Psychology Icon.
- ChatViewModel / ChatWsEventReducer: drop ⚠️/✅ prefixes from user-facing
  status/error messages (severity already conveyed by color/styling).
- SubagentInspectionSheet: ✅/🔄/❌/⭕ status markers -> CheckCircle/Autorenew/
  Cancel/RadioButtonUnchecked (tinted); ElectricBolt section headers ->
  FormatListBulleted + Groups.
- strings.xml (en/zh/ko): drop '✓' from the Copied label.

ktlint clean; compileDebugKotlin SUCCESSFUL. Remaining chat files
(ToolViewBuilder, ChatComposer, MessageCards, ChatReactions, ComposerToolbar,
FullBleedAgentMessage) tracked in follow-up commits on this branch.

* test: align unit tests with emoji-free summary + status messages

composeToolSummaryLines dropped the emoji parameter and no longer prepends
an emoji glyph to the collapsed summary line, so update ToolSummaryLinesTest
call sites + expected strings accordingly.

SlashCommandDispatchRpcTest asserted the old '⚠️ /cmd: ...' error messages;
those prefixes were removed in the UI cleanup, so drop them from the
expected strings.

ChatWsEventReducerTest emoji usages are model-output fixtures, not assertions
on changed code paths — left untouched.
This commit is contained in:
M57 authored and GitHub committed 2026-08-21 00:07:37 +03:00
1 parent fad49b7b66
commit 4549410a45
11 files changed
+148 -108

No files matched your search

@@ -143,7 +143,7 @@ sealed class WsEvent {
/**
* Self-improvement background review summary event.
* Emitted when background review patches a skill or saves a memory.
* Payload: `{ text: "💾 Self-improvement review: ..." }`
* Payload: `{ text: "Self-improvement review: ..." }`
*/
data class ReviewSummary(
val text: String,
@@ -25,10 +25,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -51,6 +53,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration
@@ -65,7 +68,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.theme.DarkOnSurface
@@ -257,7 +259,7 @@ private fun SelfImprovementReviewCard(
val isSkill =
cleanText.contains("skill", ignoreCase = true) ||
cleanText.contains("SKILL.md", ignoreCase = true)
val icon = if (isSkill) "🛠" else "🧠"
val icon: ImageVector = if (isSkill) Icons.Filled.Build else Icons.Filled.Psychology
val title =
if (isSkill) {
"Self-Improvement Review • Skill Patched"
@@ -279,7 +281,12 @@ private fun SelfImprovementReviewCard(
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = icon, fontSize = 16.sp)
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = title,
@@ -1112,7 +1112,7 @@ class ChatViewModel(
val map = result as? Map<*, *>
val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0
if (resolved > 0) {
addSystemMessage("✅ Approval submitted")
addSystemMessage("Approval submitted")
}
}
}
@@ -1308,7 +1308,7 @@ class ChatViewModel(
} catch (e: Exception) {
Log.e(TAG, "Failed to upload attachment ${attachment.name}", e)
_uiState.update {
it.copy(errorMessage = "⚠️ Upload failed: ${attachment.name}")
it.copy(errorMessage = "Upload failed: ${attachment.name}")
}
}
}
@@ -1424,7 +1424,7 @@ class ChatViewModel(
// (before any RPC fires) with a clear message instead of a doomed call.
if (CommandBlocklist.contains(command)) {
addAssistantMessage(
"⚠️ ${command.split(" ", limit = 2)[0]} is not supported on mobile",
"${command.split(" ", limit = 2)[0]} is not supported on mobile",
)
return
}
@@ -1608,11 +1608,11 @@ class ChatViewModel(
val output = (result as? Map<*, *>)?.get("output") as? String
if (!output.isNullOrBlank()) addAssistantMessage(output)
} catch (e2: HermesWsClient.HermesRpcException) {
addAssistantMessage("⚠️ /$name: ${e2.message}")
addAssistantMessage("/$name: ${e2.message}")
}
} else {
// Legit error from command.dispatch (busy, no history, etc.)
addAssistantMessage("⚠️ /$name: ${e.message}")
addAssistantMessage("/$name: ${e.message}")
}
}
}
@@ -3315,7 +3315,7 @@ class ChatViewModel(
private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
val description = event.description ?: event.command ?: "Unknown command"
val content = "⚠️ **Approval Required**\n$description"
val content = "**Approval Required**\n$description"
val msg =
ChatMessage(
role = MessageRole.SYSTEM,
@@ -682,7 +682,7 @@ object ChatWsEventReducer {
state =
state.copy(
isLoading = false,
errorMessage = "⚠️ Backend error: ${event.message ?: "Unknown gateway error"}",
errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}",
),
streamingState = streamingState,
)
@@ -700,8 +700,8 @@ object ChatWsEventReducer {
when (val label = event.data?.get("label")) {
is String -> label
else -> event.data?.get("name") as? String
}?.let { "✅ Background job finished: $it" }
?: "✅ Background job finished"
}?.let { "Background job finished: $it" }
?: "Background job finished"
return ReducerResult(
state = state.copy(backgroundCompleteMessage = message),
streamingState = streamingState,
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
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.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
@@ -26,6 +27,7 @@ import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -79,7 +81,6 @@ import kotlinx.coroutines.launch
internal fun composeToolSummaryLines(
view: ToolView,
toolName: String?,
emoji: String,
): Pair<String, String?>? {
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
@@ -89,9 +90,8 @@ internal fun composeToolSummaryLines(
if (titleIsGeneric) {
firstLine =
buildString {
append(emoji)
if (subtitle != null) {
append(" $subtitle")
append(subtitle)
}
view.countLabel?.let { append(" ($it)") }
}
@@ -99,13 +99,13 @@ internal fun composeToolSummaryLines(
} else {
firstLine =
buildString {
append("$emoji ${view.title}")
append(view.title)
view.countLabel?.let { append(" ($it)") }
}
secondLine = subtitle
}
return firstLine.takeIf { it.trim() != emoji }?.let { it to secondLine }
return firstLine.takeIf { it.isNotBlank() }?.let { it to secondLine }
}
@Composable
@@ -180,21 +180,32 @@ internal fun ToolBubble(
SecurityRiskChip(riskData, contentColor)
}
// ── Collapsed summary: emoji + title, then subtitle ──
// ── Collapsed summary: icon + title, then subtitle ──
if (!expanded && view != null) {
composeToolSummaryLines(view, message.toolName, config.iconEmoji)?.let { (firstLine, secondLine) ->
Text(
text = firstLine,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
composeToolSummaryLines(view, message.toolName)?.let { (firstLine, secondLine) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 4.dp, start = 22.dp),
)
) {
Icon(
imageVector = config.icon,
contentDescription = null,
tint = contentColor.copy(alpha = 0.7f),
modifier = Modifier.size(13.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = firstLine,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (secondLine != null) {
Text(
text = secondLine,
@@ -450,7 +461,7 @@ private fun ExpandedToolContent(
}
if (hit.url.isNotEmpty()) {
Text(
text = "🔗 ${hit.url}",
text = hit.url,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
@@ -567,10 +578,11 @@ internal fun SecurityRiskChip(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "⚠",
style = MaterialTheme.typography.labelSmall,
color = chipColor,
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = chipColor,
modifier = Modifier.size(14.dp),
)
Text(
text = label,
@@ -1,5 +1,36 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.AutoStories
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Display configuration for a Hermes tool — controls the summary line,
* icon, and which arg field is shown when the bubble is collapsed.
@@ -11,10 +42,10 @@ data class ToolDisplayConfig(
val name: String,
/** Key into the `args` dict for the one-line summary (e.g. "command" for terminal). */
val summaryArgKey: String? = null,
/** Prefix for the summary line (e.g. "$ " for terminal, "📄 " for read_file). */
/** Prefix for the summary line (e.g. "$ " for terminal). Emoji-free. */
val summaryPrefix: String = "",
/** Emoji icon for this tool type. */
val iconEmoji: String = "🔧",
/** Material icon for this tool type. */
val icon: ImageVector = Icons.Filled.Build,
)
/** Maps tool names to their display config. Ordered by expected frequency of use. */
@@ -26,240 +57,230 @@ object ToolSchemaRegistry {
name = "terminal",
summaryArgKey = "command",
summaryPrefix = "$ ",
iconEmoji = "💻",
icon = Icons.Filled.Terminal,
),
"read_file" to
ToolDisplayConfig(
name = "read_file",
summaryArgKey = "path",
summaryPrefix = "📄 ",
iconEmoji = "📄",
icon = Icons.Filled.Description,
),
"write_file" to
ToolDisplayConfig(
name = "write_file",
summaryArgKey = "path",
summaryPrefix = "✏️ ",
iconEmoji = "✏️",
icon = Icons.Filled.Edit,
),
"patch" to
ToolDisplayConfig(
name = "patch",
summaryArgKey = "path",
summaryPrefix = "🔧 ",
iconEmoji = "🔧",
icon = Icons.Filled.Build,
),
"search_files" to
ToolDisplayConfig(
name = "search_files",
summaryArgKey = "pattern",
summaryPrefix = "🔍 ",
iconEmoji = "🔍",
icon = Icons.Filled.Search,
),
"web_search" to
ToolDisplayConfig(
name = "web_search",
summaryArgKey = "query",
summaryPrefix = "🌐 ",
iconEmoji = "🌐",
icon = Icons.Filled.Public,
),
"browser_navigate" to
ToolDisplayConfig(
name = "browser_navigate",
summaryArgKey = "url",
summaryPrefix = "🌍 ",
iconEmoji = "🌍",
icon = Icons.Filled.Language,
),
"browser_click" to
ToolDisplayConfig(
name = "browser_click",
summaryArgKey = "ref",
summaryPrefix = "🖱 ",
iconEmoji = "🖱",
icon = Icons.Filled.TouchApp,
),
"browser_snapshot" to
ToolDisplayConfig(
name = "browser_snapshot",
summaryArgKey = null,
iconEmoji = "📋",
icon = Icons.Filled.Photo,
),
"clarify" to
ToolDisplayConfig(
name = "clarify",
summaryArgKey = "question",
summaryPrefix = "💬 ",
iconEmoji = "💬",
icon = Icons.Filled.ChatBubble,
),
"delegate_task" to
ToolDisplayConfig(
name = "delegate_task",
summaryArgKey = "goal",
summaryPrefix = "🔄 ",
iconEmoji = "🔄",
icon = Icons.Filled.AccountTree,
),
"execute_code" to
ToolDisplayConfig(
name = "execute_code",
summaryArgKey = "code",
summaryPrefix = "▶️ ",
iconEmoji = "▶️",
icon = Icons.Filled.PlayArrow,
),
"todo" to
ToolDisplayConfig(
name = "todo",
summaryArgKey = null,
iconEmoji = "📋",
icon = Icons.Filled.Checklist,
),
"fact_store" to
ToolDisplayConfig(
name = "fact_store",
summaryArgKey = null,
iconEmoji = "🧠",
icon = Icons.Filled.Psychology,
),
"session_search" to
ToolDisplayConfig(
name = "session_search",
summaryArgKey = null,
iconEmoji = "🔍",
icon = Icons.Filled.Search,
),
// ── Action-based tools ──
"cronjob" to
ToolDisplayConfig(
name = "cronjob",
summaryArgKey = "action",
iconEmoji = "🔄",
icon = Icons.Filled.Schedule,
),
"memory" to
ToolDisplayConfig(
name = "memory",
summaryArgKey = "action",
iconEmoji = "💾",
icon = Icons.Filled.Memory,
),
"fact_feedback" to
ToolDisplayConfig(
name = "fact_feedback",
summaryArgKey = "action",
iconEmoji = "👍",
icon = Icons.Filled.ThumbUp,
),
"process" to
ToolDisplayConfig(
name = "process",
summaryArgKey = "action",
iconEmoji = "⚙️",
icon = Icons.Filled.Settings,
),
"skill_manage" to
ToolDisplayConfig(
name = "skill_manage",
summaryArgKey = "action",
iconEmoji = "🛠️",
icon = Icons.Filled.Build,
),
// ── Web / Browser tools ──
"web_extract" to
ToolDisplayConfig(
name = "web_extract",
summaryArgKey = "urls",
iconEmoji = "🕸️",
icon = Icons.Filled.Language,
),
"browser_type" to
ToolDisplayConfig(
name = "browser_type",
summaryArgKey = null,
iconEmoji = "⌨️",
icon = Icons.Filled.Keyboard,
),
"browser_cdp" to
ToolDisplayConfig(
name = "browser_cdp",
summaryArgKey = "action",
iconEmoji = "🔧",
icon = Icons.Filled.Build,
),
"browser_dialog" to
ToolDisplayConfig(
name = "browser_dialog",
summaryArgKey = "action",
iconEmoji = "💬",
icon = Icons.Filled.ChatBubble,
),
// ── Media / Vision tools ──
"vision_analyze" to
ToolDisplayConfig(
name = "vision_analyze",
summaryArgKey = "image_url",
iconEmoji = "👁️",
icon = Icons.Filled.Visibility,
),
"text_to_speech" to
ToolDisplayConfig(
name = "text_to_speech",
summaryArgKey = null,
iconEmoji = "🔊",
icon = Icons.AutoMirrored.Filled.VolumeUp,
),
"video_generate" to
ToolDisplayConfig(
name = "video_generate",
summaryArgKey = null,
iconEmoji = "🎬",
icon = Icons.Filled.Movie,
),
"image_generate" to
ToolDisplayConfig(
name = "image_generate",
summaryArgKey = null,
iconEmoji = "🎨",
icon = Icons.Filled.Image,
),
// ── Social / Messaging tools ──
"x_search" to
ToolDisplayConfig(
name = "x_search",
summaryArgKey = "query",
iconEmoji = "𝕏",
icon = Icons.Filled.Forum,
),
"send_message" to
ToolDisplayConfig(
name = "send_message",
summaryArgKey = null,
iconEmoji = "📨",
icon = Icons.AutoMirrored.Filled.Send,
),
// ── Skills tools ──
"skills_list" to
ToolDisplayConfig(
name = "skills_list",
summaryArgKey = null,
iconEmoji = "📚",
icon = Icons.AutoMirrored.Filled.MenuBook,
),
"skill_view" to
ToolDisplayConfig(
name = "skill_view",
summaryArgKey = "name",
iconEmoji = "📖",
icon = Icons.Filled.AutoStories,
),
// ── On-demand tools ──
"tool_search" to
ToolDisplayConfig(
name = "tool_search",
summaryArgKey = "query",
iconEmoji = "🔍",
icon = Icons.Filled.Search,
),
"tool_describe" to
ToolDisplayConfig(
name = "tool_describe",
summaryArgKey = "name",
iconEmoji = "📖",
icon = Icons.Filled.AutoStories,
),
"tool_call" to
ToolDisplayConfig(
name = "tool_call",
summaryArgKey = "name",
iconEmoji = "🔧",
icon = Icons.Filled.Build,
),
// ── Misc ──
"read_terminal" to
ToolDisplayConfig(
name = "read_terminal",
summaryArgKey = "session_id",
iconEmoji = "🖥️",
icon = Icons.Filled.Computer,
),
"computer_use" to
ToolDisplayConfig(
name = "computer_use",
summaryArgKey = "action",
iconEmoji = "🖥️",
icon = Icons.Filled.Computer,
),
)