feat(chat): live task delegation widget with inline card & sticky progress indicator (#736) (#743)

* feat(chat): live task delegation widget with inline card & sticky progress indicator (#736)

* fix(chat): refine subagent isRunning status and fallback Compose list keys (#743)

* feat(ci): add CodeQL analysis workflow & integrate todos into task widget (#736)

- Add CodeQL workflow with manual build mode for java-kotlin
- Render agent plan & todo items inside live delegation widget

* ci(codeql): add advanced CodeQL workflow with continue-on-error for Kotlin 2.4.10 (#736)

* ci(codeql): use build-mode none to prevent Kotlin 2.4.10 extractor error (#736)

* revert(ci): remove codeql workflow (#736)

* refactor(chat): remove inline task card and rely on sticky top bar (#736)

- Keep StickySubagentBar and SubagentInspectionSheet for top widget UI

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-29 18:15:39 +03:00
1 parent 8ba92f2567
commit 2cf41ead06
8 files changed
+754 -31

No files matched your search

@@ -52,6 +52,30 @@ data class ChatMessage(
val progressPreview: String? = null,
)
/**
* Single live transcript log entry for subagent execution.
*/
data class SubagentLogLine(
val timestamp: Long = System.currentTimeMillis(),
val text: String,
val isError: Boolean = false,
val isSummary: Boolean = false,
)
/**
* Representation of a single task/todo item in the agent's plan.
*/
data class TodoItem(
val id: String,
val content: String,
val status: String = "pending", // "pending" | "in_progress" | "completed" | "cancelled"
) {
val isCompleted: Boolean get() = status == "completed" || status == "done"
val isInProgress: Boolean get() = status == "in_progress" || status == "running"
val isCancelled: Boolean get() = status == "cancelled" || status == "failed"
val isPending: Boolean get() = status == "pending" || status == "queued"
}
/**
* Representation of subagent execution details for transient UI indicators.
*
@@ -63,10 +87,18 @@ data class SubagentIndicator(
val taskIndex: Int? = null,
val taskCount: Int? = null,
val text: String? = null, // preview line
val status: String? = null, // complete only
val status: String? = null, // running / queued / completed / failed
val summary: String? = null, // complete only
val subagentId: String? = null,
)
val logs: List<SubagentLogLine> = emptyList(),
val durationSeconds: Double? = null,
val model: String? = null,
) {
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
val isQueued: Boolean get() = status == "queued"
val isRunning: Boolean get() = !isComplete && !isFailed && !isQueued
}
enum class MessageRole {
USER,
@@ -84,6 +84,8 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
import com.m57.hermescontrol.ui.chat.components.StickySubagentBar
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.common.AutoScrollingTitleText
@@ -186,6 +188,7 @@ fun ChatScreen(
streamingState.streamingMessage,
streamingState.isThinking,
state.subagentIndicators,
state.todos,
state.clarifyRequest,
) {
scrollController.onTailChanged(
@@ -211,6 +214,7 @@ fun ChatScreen(
var isListening by rememberSaveable { mutableStateOf(false) }
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
@@ -469,6 +473,15 @@ fun ChatScreen(
}
}
StickySubagentBar(
indicators = state.subagentIndicators,
todos = state.todos,
onClick = {
showSubagentInspectionSheet = true
scrollController.resumeFollowing()
},
)
Box(
modifier =
Modifier
@@ -493,7 +506,6 @@ fun ChatScreen(
lastAnimatedMessageId = lastAnimatedMessageId,
onLastAnimatedMessageIdChange = { lastAnimatedMessageId = it },
viewModel = viewModel,
subagentIndicators = state.subagentIndicators,
clarifyRequest = state.clarifyRequest,
onRespondClarify = viewModel::respondToClarify,
onDismissClarify = viewModel::dismissClarify,
@@ -641,6 +653,14 @@ fun ChatScreen(
)
}
if (showSubagentInspectionSheet) {
SubagentInspectionSheet(
indicators = state.subagentIndicators,
todos = state.todos,
onDismiss = { showSubagentInspectionSheet = false },
)
}
viewingImage?.let { image ->
ImageViewerDialog(
image = image,
@@ -114,6 +114,8 @@ data class ChatUiState(
val reactionTriggerId: Long = 0L,
/** Subagent delegation indicators (issue #538) — transient UI state. */
val subagentIndicators: List<SubagentIndicator> = emptyList(),
/** Agent todo / plan items (issue #736). */
val todos: List<TodoItem> = emptyList(),
) {
/** Convenience — derived from [connectionStatus]. */
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
@@ -4,6 +4,9 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.toJsonElement
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Pure state reducer for WebSocket events.
@@ -42,7 +45,18 @@ object ChatWsEventReducer {
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
return ReducerResult(state = state, streamingState = streamingState)
}
return reduceInternal(state, streamingState, event)
val result = reduceInternal(state, streamingState, event)
val hydratedTodos =
if (result.state.todos.isEmpty() && result.state.messages.isNotEmpty()) {
hydrateTodosFromMessages(result.state.messages)
} else {
result.state.todos
}
return if (hydratedTodos !== result.state.todos) {
result.copy(state = result.state.copy(todos = hydratedTodos))
} else {
result
}
}
private fun reduceInternal(
@@ -134,6 +148,13 @@ object ChatWsEventReducer {
)
val effects = mutableListOf<ReducerEffect>()
val cleanedSubagents =
if (state.subagentIndicators.all { it.isComplete || it.isFailed }) {
emptyList()
} else {
state.subagentIndicators
}
// Build new state: finalize any orphan streaming message, then set the new one
var orphan: ChatMessage? = null
val preState =
@@ -143,10 +164,12 @@ object ChatWsEventReducer {
state.copy(
messages = state.messages + finalized,
isAgentTyping = true,
subagentIndicators = cleanedSubagents,
)
} else {
state.copy(
isAgentTyping = true,
subagentIndicators = cleanedSubagents,
)
}
val newStreamingState =
@@ -361,8 +384,11 @@ object ChatWsEventReducer {
effects.add(ReducerEffect.PersistMessage(toolMessage, sid))
}
val parsedTodos = if (event.name == "todo") extractTodosFromMap(event.data) else null
val nextTodos = parsedTodos ?: newState.todos
return ReducerResult(
state = newState.copy(messages = newState.messages + toolMessage),
state = newState.copy(messages = newState.messages + toolMessage, todos = nextTodos),
effects = effects,
)
}
@@ -399,8 +425,12 @@ object ChatWsEventReducer {
if (sid != null) {
effects.add(ReducerEffect.PersistMessage(updated, sid))
}
val parsedTodos = if (event.name == "todo") extractTodosFromMap(event.data) else null
val nextTodos = parsedTodos ?: state.todos
return ReducerResult(
state = state.copy(messages = messages),
state = state.copy(messages = messages, todos = nextTodos),
streamingState = streamingState,
effects = effects,
)
@@ -600,6 +630,8 @@ object ChatWsEventReducer {
val text = event.payload?.get("text") as? String
val status = event.payload?.get("status") as? String
val summary = event.payload?.get("summary") as? String
val durationSeconds = (event.payload?.get("duration_seconds") as? Number)?.toDouble()
val model = event.payload?.get("model") as? String
val subagentId =
event.payload?.get(
"subagent_id",
@@ -615,18 +647,22 @@ object ChatWsEventReducer {
-1
}
// A completed delegation is no longer "in flight" — drop its indicator
// so it doesn't linger in the chat after the subagent finishes.
if (event.type == "subagent.complete") {
if (idx >= 0) {
indicators.removeAt(idx)
return ReducerResult(
state = state.copy(subagentIndicators = indicators),
streamingState = streamingState,
)
}
return ReducerResult(state = state, streamingState = streamingState)
val existingLogs = if (idx >= 0) indicators[idx].logs else emptyList()
val newLogs = existingLogs.toMutableList()
if (!text.isNullOrBlank() && newLogs.lastOrNull()?.text != text) {
newLogs.add(SubagentLogLine(text = text, isError = status == "failed"))
}
if (event.type == "subagent.complete" && !summary.isNullOrBlank() && newLogs.lastOrNull()?.text != summary) {
newLogs.add(SubagentLogLine(text = summary, isSummary = true))
}
val trimmedLogs = newLogs.takeLast(30)
val finalStatus =
if (event.type == "subagent.complete") {
status ?: "completed"
} else {
status ?: (if (idx >= 0) indicators[idx].status else "running")
}
val indicator =
SubagentIndicator(
@@ -634,10 +670,13 @@ object ChatWsEventReducer {
goal = goal ?: (if (idx >= 0) indicators[idx].goal else null),
taskIndex = taskIndex ?: (if (idx >= 0) indicators[idx].taskIndex else null),
taskCount = taskCount ?: (if (idx >= 0) indicators[idx].taskCount else null),
text = text ?: (if (idx >= 0) indicators[idx].text else null),
status = status ?: (if (idx >= 0) indicators[idx].status else null),
text = summary ?: text ?: (if (idx >= 0) indicators[idx].text else null),
status = finalStatus,
summary = summary ?: (if (idx >= 0) indicators[idx].summary else null),
subagentId = subagentId ?: (if (idx >= 0) indicators[idx].subagentId else null),
logs = trimmedLogs,
durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null),
model = model ?: (if (idx >= 0) indicators[idx].model else null),
)
if (idx >= 0) {
@@ -701,3 +740,62 @@ sealed class ReducerEffect {
val messageId: String,
) : ReducerEffect()
}
@Suppress("UNCHECKED_CAST")
fun extractTodosFromMap(data: Map<String, Any?>?): List<TodoItem>? {
if (data == null) return null
val rawList =
(data["todos"] as? List<*>)
?: ((data["args"] as? Map<String, Any?>)?.get("todos") as? List<*>)
?: ((data["parameters"] as? Map<String, Any?>)?.get("todos") as? List<*>)
?: ((data["result"] as? Map<String, Any?>)?.get("todos") as? List<*>)
?: return null
val items = mutableListOf<TodoItem>()
for (elem in rawList) {
val map = elem as? Map<String, Any?> ?: continue
val id = (map["id"] as? String) ?: continue
val content = (map["content"] as? String) ?: (map["text"] as? String) ?: ""
val status = (map["status"] as? String) ?: "pending"
items.add(TodoItem(id = id, content = content, status = status))
}
return items.takeIf { it.isNotEmpty() }
}
fun extractTodosFromJson(content: String): List<TodoItem>? {
if (content.isBlank()) return null
return try {
val element = OkHttpProvider.json.parseToJsonElement(content)
val obj = element as? JsonObject ?: return null
val todosArray =
(obj["todos"] as? JsonArray)
?: ((obj["args"] as? JsonObject)?.get("todos") as? JsonArray)
?: ((obj["result"] as? JsonObject)?.get("todos") as? JsonArray)
?: ((obj["parameters"] as? JsonObject)?.get("todos") as? JsonArray)
?: return null
todosArray.mapNotNull { item ->
val itemObj = item as? JsonObject ?: return@mapNotNull null
val id = (itemObj["id"] as? JsonPrimitive)?.content ?: return@mapNotNull null
val contentStr =
(itemObj["content"] as? JsonPrimitive)?.content
?: (itemObj["text"] as? JsonPrimitive)?.content ?: ""
val status = (itemObj["status"] as? JsonPrimitive)?.content ?: "pending"
TodoItem(id = id, content = contentStr, status = status)
}.takeIf { it.isNotEmpty() }
} catch (_: Exception) {
null
}
}
fun hydrateTodosFromMessages(messages: List<ChatMessage>): List<TodoItem> {
for (msg in messages.asReversed()) {
if (msg.role == MessageRole.TOOL && (msg.toolName == "todo" || msg.content.contains("\"todos\""))) {
val todos = extractTodosFromJson(msg.content)
if (!todos.isNullOrEmpty()) {
return todos
}
}
}
return emptyList()
}
@@ -22,7 +22,6 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.common.EmptyState
/**
@@ -47,7 +46,6 @@ fun ChatMessageList(
lastAnimatedMessageId: String?,
onLastAnimatedMessageIdChange: (String?) -> Unit,
viewModel: ChatViewModel,
subagentIndicators: List<SubagentIndicator> = emptyList(),
clarifyRequest: ClarifyUi? = null,
onRespondClarify: ((String) -> Unit)? = null,
onDismissClarify: (() -> Unit)? = null,
@@ -145,14 +143,6 @@ fun ChatMessageList(
}
}
// Subagent indicators — SubagentCard replaces SubagentIndicatorRow
items(
items = subagentIndicators,
key = { indicator -> "subagent-${indicator.subagentId ?: indicator.goal ?: indicator.type}" },
) { indicator ->
SubagentCard(indicator = indicator)
}
// Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
@@ -0,0 +1,150 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ElectricBolt
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.chat.TodoItem
/**
* Compact sticky progress indicator displayed below the top app bar
* when background subagents or agent todos are in progress.
*/
@Composable
fun StickySubagentBar(
indicators: List<SubagentIndicator> = emptyList(),
todos: List<TodoItem> = emptyList(),
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val activeSubagents = indicators.count { it.isRunning }
val activeTodos = todos.count { it.isInProgress }
val isVisible = activeSubagents > 0 || activeTodos > 0
AnimatedVisibility(
visible = isVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = modifier,
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.testTag("sticky_subagent_bar"),
color = MaterialTheme.colorScheme.tertiaryContainer,
shadowElevation = 2.dp,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
Icon(
imageVector = Icons.Default.ElectricBolt,
contentDescription = "Active Tasks",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
val labelText =
when {
activeSubagents > 0 && activeTodos > 0 -> {
"$activeSubagents Subagents · $activeTodos Task Active"
}
activeSubagents > 0 -> {
val activeSub = indicators.firstOrNull { it.isRunning }
val goalText = activeSub?.goal?.takeIf { it.isNotBlank() }
val countStr =
if (activeSubagents == 1) {
"1 Subagent Active"
} else {
"$activeSubagents Subagents Active"
}
if (goalText != null) "$countStr · $goalText" else countStr
}
else -> {
val activeTodo = todos.firstOrNull { it.isInProgress }
val todoText = activeTodo?.content?.takeIf { it.isNotBlank() }
if (todoText != null) "Task In Progress: $todoText" else "1 Task In Progress"
}
}
Text(
text = labelText,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onTertiaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Inspect",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontSize = 11.sp,
)
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Inspect",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
}
}
}
}
}
@@ -0,0 +1,289 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ElectricBolt
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
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.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.chat.TodoItem
/**
* Inspection sheet displaying active & completed subagent tasks and agent todos.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SubagentInspectionSheet(
indicators: List<SubagentIndicator> = emptyList(),
todos: List<TodoItem> = emptyList(),
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier.testTag("subagent_inspection_sheet"),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp),
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.ElectricBolt,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Task & Plan Inspection",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
)
}
}
Spacer(modifier = Modifier.height(12.dp))
if (indicators.isEmpty() && todos.isEmpty()) {
Text(
text = "No active tasks or plan items.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (todos.isNotEmpty()) {
item(key = "todos_header") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "AGENT PLAN",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
itemsIndexed(
items = todos,
key = { index, todo -> "todo-${todo.id}_$index" },
) { _, todo ->
TodoInspectionCard(todo = todo)
}
}
if (indicators.isNotEmpty()) {
if (todos.isNotEmpty()) {
item(key = "subagents_header") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
) {
Icon(
imageVector = Icons.Default.ElectricBolt,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "SUBAGENTS",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
itemsIndexed(
items = indicators,
key = { index, indicator ->
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
},
) { _, indicator ->
InspectionItemCard(indicator = indicator)
}
}
}
}
}
}
}
@Composable
private fun TodoInspectionCard(todo: TodoItem) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val statusSymbol =
when {
todo.isCompleted -> "✅"
todo.isInProgress -> "⚡"
todo.isCancelled -> "❌"
else -> "⭕"
}
Text(text = statusSymbol, fontSize = 16.sp)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = todo.content,
style =
MaterialTheme.typography.bodyMedium.copy(
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
),
fontWeight = if (todo.isInProgress) FontWeight.Bold else FontWeight.Normal,
color =
if (todo.isCompleted) {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun InspectionItemCard(indicator: SubagentIndicator) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val statusSymbol =
when {
indicator.isComplete -> "✅"
indicator.isFailed -> "❌"
else -> "⚡"
}
Text(text = statusSymbol, fontSize = 16.sp)
Spacer(modifier = Modifier.width(8.dp))
val taskIndexStr = indicator.taskIndex?.let { "#$it " } ?: ""
Text(
text = "$taskIndexStr${indicator.goal ?: "Subagent Task"}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
}
if (!indicator.summary.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Summary: ${indicator.summary}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (indicator.logs.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = "LIVE TRANSCRIPT",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.outline,
fontSize = 9.sp,
)
indicator.logs.forEach { logLine ->
Text(
text = "› ${logLine.text}",
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
color =
if (logLine.isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}
}