refactor(chat): tool display cleanup — split ChatBubble.kt god file, drop dead code (#762)
* refactor(chat): extract tool display subsystem from ChatBubble.kt ChatBubble.kt was a 2578-line god file. Split it into four files in the same package (zero import churn, no behavior change): - ChatBubble.kt: user/assistant/system bubbles + dispatch + shared helpers (formatTimestamp, buildHighlightedString, InlineAttachment) - ParsedToolData.kt: the parsed tool payload model - ToolResultParser.kt: parseToolOutput + per-tool formatters + the Gson-compat JSON shims and tolerant exit_code parser - ToolBubble.kt: ToolBubble + ExpandedToolContent + HeaderRow + SecurityRiskChip + CopyButton formatTimestamp became internal (shared with ToolBubble.kt). Verified: ktlintCheck + testDebugUnitTest green locally. * refactor(chat): drop dead ParsedToolData fields and unused shim Verified via grep: parsed.args / parsed.result / parsed.stderr / parsed.isRunning / parsed.toolName had zero readers across app/src (main + test + androidTest). toolName + isRunning are asserted by ToolBubbleParsingTest, so they stay; args/result/stderr were built by every formatter (21 construction sites) and never read — removing them also kills a JSON.stringify-per-tool-complete wasted map build. keySet() shim had no call sites. formatTodoToolOutput's args param became unused with the field removal — dropped it too. Verified: ktlintCheck + testDebugUnitTest green locally. * refactor(chat): drop unused lastSessionId and ToolBubble isDarkTheme param ChatScrollController.lastSessionId: introduced in #683, never read or written since (namefreq scan + git log -S confirm single occurrence at declaration). Session-change detection moved to lastTailKey/ lastMessageCount. ToolBubble.isDarkTheme: never read in the body (MaterialTheme + statusColors do the theming); single call site updated. Verified: ktlintCheck + compileDebugKotlin green locally. --------- Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
3a96edd4c1
commit
1daa03d686
5 files changed
+1875
-1867
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
/**
|
||||
* Cleanly parsed representation of a tool call, extracted from the
|
||||
* tool.complete JSON payload (which contains both args and result).
|
||||
*/
|
||||
data class ParsedToolData(
|
||||
val toolName: String = "",
|
||||
val isTerminal: Boolean = false,
|
||||
val stdout: String? = null,
|
||||
val exitCode: Int? = null,
|
||||
val error: String? = null,
|
||||
val summaryText: String? = null,
|
||||
val durationSec: Double? = null,
|
||||
val mainOutput: String? = null,
|
||||
val extraFields: Map<String, String> = emptyMap(),
|
||||
val isRunning: Boolean = false,
|
||||
val diffOutput: String? = null,
|
||||
val diffPath: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,528 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.ClipData
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
private fun ExpandedToolContent(
|
||||
parsed: ParsedToolData,
|
||||
contentColor: Color,
|
||||
statusColors: HermesStatusColors,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
// Summary line at top of expanded view
|
||||
if (parsed.summaryText != null) {
|
||||
Text(
|
||||
text = parsed.summaryText,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
|
||||
if (parsed.isTerminal) {
|
||||
// ── Terminal output ──
|
||||
parsed.stdout?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
parsed.error?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_execution_error, it),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = statusColors.error,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
parsed.exitCode?.let { code ->
|
||||
if (code != 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_exit_code, code),
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = statusColors.error,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Duration footer for terminal
|
||||
parsed.durationSec?.let { dur ->
|
||||
Text(
|
||||
text = "Duration: ${"%.1f".format(dur)}s",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.5f),
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// ── Generic tool output / Diff view ──
|
||||
if (parsed.diffOutput != null) {
|
||||
DiffViewCard(
|
||||
diffText = parsed.diffOutput,
|
||||
filePath = parsed.diffPath,
|
||||
)
|
||||
} else {
|
||||
parsed.mainOutput?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
parsed.extraFields.forEach { (key, value) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$key:",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.9f),
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Duration footer
|
||||
parsed.durationSec?.let { dur ->
|
||||
Text(
|
||||
text = "Duration: ${"%.1f".format(dur)}s",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = contentColor.copy(alpha = 0.5f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ToolBubble(
|
||||
message: ChatMessage,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showRawJson by remember { mutableStateOf(false) }
|
||||
val chipColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
val parsed =
|
||||
remember(message.content, message.toolName, message.toolStatus) {
|
||||
parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING)
|
||||
}
|
||||
val config = ToolSchemaRegistry.getDisplayConfig(message.toolName)
|
||||
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var showCopyButton by remember { mutableStateOf(false) }
|
||||
|
||||
// Auto-dismiss copy button after 4 seconds
|
||||
LaunchedEffect(showCopyButton) {
|
||||
if (showCopyButton) {
|
||||
delay(4000)
|
||||
showCopyButton = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 1.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Card(
|
||||
onClick = { expanded = !expanded },
|
||||
colors = CardDefaults.cardColors(containerColor = chipColor),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.animateContentSize()
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
// ── Header row: icon + tool name ──
|
||||
HeaderRow(message, config, contentColor, statusColors)
|
||||
|
||||
// ── Tool progress preview (tool.progress) ──
|
||||
if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) {
|
||||
Text(
|
||||
text = message.progressPreview,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 4.dp, start = 22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Security risk chip (tool.output_risk) ──
|
||||
val riskData = message.toolOutputRiskData
|
||||
if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) {
|
||||
SecurityRiskChip(riskData, contentColor)
|
||||
}
|
||||
|
||||
// ── Summary line (always visible when collapsed) ──
|
||||
if (!expanded && parsed?.summaryText != null) {
|
||||
Text(
|
||||
text = parsed.summaryText,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 4.dp, start = 22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Expanded content ──
|
||||
if (expanded) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
if (showRawJson) {
|
||||
// Raw JSON view — selectable + copy button
|
||||
Box {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = message.content,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
CopyButton(
|
||||
visible = showCopyButton,
|
||||
textToCopy = message.content,
|
||||
onCopy = { showCopyButton = false },
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 8.dp, y = (-8).dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_show_parsed),
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.testTag("chat_tool_show_parsed")
|
||||
.clickable(role = Role.Button) { showRawJson = false },
|
||||
)
|
||||
} else if (parsed != null) {
|
||||
// Clean structured expanded view
|
||||
Box {
|
||||
SelectionContainer {
|
||||
ExpandedToolContent(parsed, contentColor, statusColors)
|
||||
}
|
||||
CopyButton(
|
||||
visible = showCopyButton,
|
||||
textToCopy = message.content,
|
||||
onCopy = { showCopyButton = false },
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 8.dp, y = (-8).dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_show_raw),
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.testTag("chat_tool_show_raw")
|
||||
.clickable(role = Role.Button) { showRawJson = true },
|
||||
)
|
||||
} else {
|
||||
// Unparseable content — show raw JSON, selectable
|
||||
Box {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = message.content,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
CopyButton(
|
||||
visible = showCopyButton,
|
||||
textToCopy = message.content,
|
||||
onCopy = { showCopyButton = false },
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 8.dp, y = (-8).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Timestamp ──
|
||||
Text(
|
||||
text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)),
|
||||
color = contentColor.copy(alpha = 0.5f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.align(Alignment.End).padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderRow(
|
||||
message: ChatMessage,
|
||||
config: ToolDisplayConfig,
|
||||
contentColor: Color,
|
||||
statusColors: HermesStatusColors,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Status icon or spinner
|
||||
if (message.toolStatus == ToolStatus.RUNNING) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
} else {
|
||||
val icon =
|
||||
when (message.toolStatus) {
|
||||
ToolStatus.COMPLETED -> Icons.Filled.CheckCircle
|
||||
ToolStatus.FAILED -> Icons.Filled.Error
|
||||
else -> Icons.Filled.Build
|
||||
}
|
||||
val tint =
|
||||
when (message.toolStatus) {
|
||||
ToolStatus.COMPLETED -> statusColors.success
|
||||
ToolStatus.FAILED -> statusColors.error
|
||||
else -> contentColor.copy(alpha = 0.6f)
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = message.toolName ?: stringResource(R.string.chat_tool_fallback),
|
||||
style =
|
||||
MaterialTheme.typography.labelMedium.copy(
|
||||
color = contentColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Security risk chip for [tool.output_risk] events.
|
||||
*
|
||||
* Shows a compact ⚠ badge when the backend flagged tool output as risky.
|
||||
* Renders in the tool card between the header row and the summary line.
|
||||
*/
|
||||
@Composable
|
||||
private fun SecurityRiskChip(
|
||||
riskData: ToolOutputRiskData,
|
||||
contentColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val (chipColor, label) =
|
||||
when {
|
||||
riskData.risk == "high" -> statusColors.error to "Risky output"
|
||||
riskData.risk == "medium" -> statusColors.warning to "Caution"
|
||||
else -> statusColors.warning to "Redacted"
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.padding(top = 4.dp, start = 22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "⚠",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = chipColor,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = chipColor,
|
||||
)
|
||||
if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) {
|
||||
Text(
|
||||
text = "· redacted",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = chipColor.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyButton(
|
||||
visible: Boolean,
|
||||
textToCopy: String,
|
||||
onCopy: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
||||
shadowElevation = 6.dp,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, textToCopy)))
|
||||
}
|
||||
onCopy()
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.ContentCopy,
|
||||
contentDescription = stringResource(R.string.content_desc_copy),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -54,7 +54,6 @@ class ChatScrollController(
|
||||
var pendingCount by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
private var lastSessionId: String? = null
|
||||
private var lastTailKey: Any? = null
|
||||
private var lastMessageCount: Int = 0
|
||||
|
||||
|
||||
Reference in new issue
Block a user