* 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:
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+289
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user