feat(subagents): live active subagents roster and transcript tail (#1089) (#1098)

This commit is contained in:
M57 authored and GitHub committed 2026-09-13 11:06:34 +03:00
1 parent 69cd34d289
commit 29bc367aec
13 files changed
+1452 -2

No files matched your search

@@ -128,6 +128,7 @@ data class SubagentIndicator(
val logs: List<SubagentLogLine> = emptyList(),
val durationSeconds: Double? = null,
val model: String? = null,
val lastEventTimestamp: Long = 0L,
) {
val isComplete: Boolean get() = type == "subagent.complete" || status == "completed" || status == "done"
val isFailed: Boolean get() = status == "failed" || status == "interrupted"
@@ -137,6 +138,20 @@ data class SubagentIndicator(
val isRunning: Boolean get() = !isComplete && !isFailed && !isCancelled && !isQueued
}
/**
* State for inspecting a live subagent's rolling execution transcript (issue #1089).
*/
data class SubagentTranscriptUiState(
val subagentId: String,
val text: String = "",
val isLoading: Boolean = false,
val isTruncated: Boolean = false,
val bytesRead: Long? = null,
val error: String? = null,
) {
val isEmpty: Boolean get() = text.isEmpty() && !isLoading && error == null
}
enum class MessageRole {
USER,
ASSISTANT,
@@ -291,6 +291,11 @@ fun ChatScreen(
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
var showSubagentInspectionSheet by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(showSubagentInspectionSheet) {
if (showSubagentInspectionSheet) {
viewModel.hydrateSubagents()
}
}
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
@@ -732,9 +737,16 @@ fun ChatScreen(
SubagentInspectionSheet(
indicators = state.subagentIndicators,
todos = state.todos,
inspectingSubagentId = state.inspectingSubagentId,
subagentTranscript = state.subagentTranscript,
onToggleTranscript = { subagentId -> viewModel.toggleSubagentTranscript(subagentId) },
onRetryTranscript = { viewModel.retrySubagentTranscript() },
onSteerSubagent = { indicator, msg -> viewModel.steerSubagent(indicator, msg) },
onStopSubagent = { indicator -> viewModel.stopSubagent(indicator) },
onDismiss = { showSubagentInspectionSheet = false },
onDismiss = {
showSubagentInspectionSheet = false
viewModel.closeSubagentTranscript()
},
)
}
@@ -0,0 +1,305 @@
package com.m57.hermescontrol.ui.chat
import android.util.Log
import com.m57.hermescontrol.data.model.SubagentListItem
import com.m57.hermescontrol.data.ws.SubagentRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Delegate managing subagent roster hydration via `subagent.list` and live transcript
* tail polling via `subagent.tail` (issue #1089).
*/
class ChatSubagentsDelegate(
private val uiState: MutableStateFlow<ChatUiState>,
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
private val runtimeSessionId: () -> String?,
private val subagentRepository: SubagentRepository = SubagentRepository,
private val pollingIntervalMs: Long = 1500L,
) {
companion object {
private const val TAG = "ChatSubagentsDelegate"
const val SUBAGENT_TAIL_MAX_BYTES = 16384
}
private var pollingJob: Job? = null
private var hydrationJob: Job? = null
/**
* Request the authoritative subagent roster from the gateway and merge it into [ChatUiState].
*
* A failed request degrades gracefully without clearing existing push-event indicators.
*/
fun hydrateSubagents(sessionId: String? = runtimeSessionId()) {
val targetSession = sessionId?.trim()
if (targetSession.isNullOrEmpty()) return
val requestTime = System.currentTimeMillis()
hydrationJob?.cancel()
hydrationJob =
scope.launch(ioDispatcher) {
try {
val response = subagentRepository.listSubagents(targetSession) ?: return@launch
uiState.update { current ->
val merged = mergeSubagentList(current.subagentIndicators, response.subagents, requestTime)
current.copy(subagentIndicators = merged)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Failed to hydrate subagents for session $targetSession: ${e.message}")
}
}
}
/**
* Toggle transcript inspection for [subagentId].
*
* If already inspecting [subagentId], closes the transcript view; otherwise opens it.
*/
fun toggleSubagentTranscript(subagentId: String) {
if (uiState.value.inspectingSubagentId == subagentId) {
closeSubagentTranscript()
} else {
inspectSubagentTranscript(subagentId)
}
}
/**
* Start inspecting a live subagent's execution transcript, initiating bounded polling.
*/
fun inspectSubagentTranscript(subagentId: String) {
if (subagentId.isBlank()) return
pollingJob?.cancel()
uiState.update { current ->
current.copy(
inspectingSubagentId = subagentId,
subagentTranscript =
SubagentTranscriptUiState(
subagentId = subagentId,
isLoading = true,
),
)
}
pollingJob =
scope.launch(ioDispatcher) {
val sessionId = runtimeSessionId() ?: return@launch
pollTranscriptLoop(sessionId, subagentId)
}
}
/**
* Retry loading the transcript for the currently inspected subagent.
*/
fun retryTranscript() {
val subagentId = uiState.value.inspectingSubagentId ?: return
inspectSubagentTranscript(subagentId)
}
/**
* Stop transcript polling and dismiss the transcript disclosure.
*/
fun closeSubagentTranscript() {
pollingJob?.cancel()
pollingJob = null
uiState.update { current ->
current.copy(
inspectingSubagentId = null,
subagentTranscript = null,
)
}
}
private suspend fun pollTranscriptLoop(
sessionId: String,
subagentId: String,
) {
while (scope.isActive) {
try {
val tailResponse =
subagentRepository.tailSubagent(
sessionId = sessionId,
subagentId = subagentId,
maxBytes = SUBAGENT_TAIL_MAX_BYTES,
)
if (tailResponse != null) {
val content = tailResponse.content()
val truncated = tailResponse.truncated ?: false
val bytesRead = tailResponse.bytesRead
uiState.update { current ->
if (current.inspectingSubagentId != subagentId) return@update current
val prev = current.subagentTranscript
if (prev == null || prev.text != content || prev.isLoading || prev.error != null) {
current.copy(
subagentTranscript =
SubagentTranscriptUiState(
subagentId = subagentId,
text = content,
isLoading = false,
isTruncated = truncated,
bytesRead = bytesRead,
error = null,
),
)
} else {
current
}
}
} else {
uiState.update { current ->
if (current.inspectingSubagentId != subagentId) return@update current
val prev = current.subagentTranscript
if (prev == null || prev.text.isEmpty()) {
current.copy(
subagentTranscript =
SubagentTranscriptUiState(
subagentId = subagentId,
isLoading = false,
error = "Unable to load transcript",
),
)
} else {
current.copy(subagentTranscript = prev.copy(isLoading = false))
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Error fetching transcript tail for subagent $subagentId: ${e.message}")
uiState.update { current ->
if (current.inspectingSubagentId != subagentId) return@update current
val prev = current.subagentTranscript
if (prev == null || prev.text.isEmpty()) {
current.copy(
subagentTranscript =
SubagentTranscriptUiState(
subagentId = subagentId,
isLoading = false,
error = e.message ?: "Unable to load transcript",
),
)
} else {
current.copy(subagentTranscript = prev.copy(isLoading = false))
}
}
}
// Stop polling once the subagent reaches a terminal state (one final refresh is enough)
val currentIndicator = uiState.value.subagentIndicators.firstOrNull { it.subagentId == subagentId }
if (currentIndicator != null && !currentIndicator.isRunning) {
break
}
delay(pollingIntervalMs)
}
}
/**
* Merges snapshot items from `subagent.list` with current indicators.
*
* Rules:
* - `subagent_id` is the primary identity.
* - Protects against races: if an indicator received a push event with timestamp > [requestTime],
* its push-event status/type is preserved.
* - Preserves local details (logs, taskIndex, taskCount, summary) not provided in the snapshot.
* - Does not discard locally known indicators absent from the snapshot.
*/
internal fun mergeSubagentList(
current: List<SubagentIndicator>,
listItems: List<SubagentListItem>,
requestTime: Long,
): List<SubagentIndicator> {
val merged = current.toMutableList()
for (item in listItems) {
val subagentId = item.subagentId.trim()
if (subagentId.isEmpty() && item.goal.isNullOrBlank()) continue
val idx =
if (subagentId.isNotEmpty()) {
merged.indexOfFirst { it.subagentId == subagentId }
} else {
merged.indexOfFirst { it.subagentId == null && it.goal == item.goal }
}
if (idx >= 0) {
val existing = merged[idx]
val isPushEventNewer = existing.lastEventTimestamp > requestTime
val finalStatus =
if (isPushEventNewer) {
existing.status ?: item.status ?: "running"
} else if (existing.isComplete || existing.isFailed || existing.isCancelled) {
// Terminal state guard: don't revert to running
if (item.status == "completed" || item.status == "failed" ||
item.status == "cancelled" || item.status == "stopped"
) {
item.status
} else {
existing.status
}
} else {
item.status ?: existing.status ?: "running"
}
val finalType =
if (isPushEventNewer) {
existing.type
} else if (finalStatus == "completed" || finalStatus == "done" || existing.isComplete) {
"subagent.complete"
} else if (existing.type.isNotBlank()) {
existing.type
} else {
"subagent.progress"
}
merged[idx] =
existing.copy(
type = finalType,
goal = item.goal ?: existing.goal,
status = finalStatus,
subagentId = if (subagentId.isNotEmpty()) subagentId else existing.subagentId,
durationSeconds =
if (isPushEventNewer) {
existing.durationSeconds ?: item.elapsedSeconds
} else {
item.elapsedSeconds ?: existing.durationSeconds
},
model = item.model ?: existing.model,
lastEventTimestamp = maxOf(existing.lastEventTimestamp, requestTime),
)
} else {
val status = item.status ?: "running"
val isComp = status == "completed" || status == "done"
val type = if (isComp) "subagent.complete" else "subagent.progress"
merged.add(
SubagentIndicator(
type = type,
goal = item.goal,
status = status,
subagentId = subagentId.takeIf { it.isNotEmpty() },
durationSeconds = item.elapsedSeconds,
model = item.model,
lastEventTimestamp = requestTime,
),
)
}
}
return merged
}
}
@@ -164,6 +164,10 @@ data class ChatUiState(
val btwState: BtwUiState? = null,
/** Subagent delegation indicators (issue #538) — transient UI state. */
val subagentIndicators: List<SubagentIndicator> = emptyList(),
/** Currently inspected subagent ID for live transcript tail (issue #1089). */
val inspectingSubagentId: String? = null,
/** Transient live transcript tail state for the inspected subagent (issue #1089). */
val subagentTranscript: SubagentTranscriptUiState? = null,
/** Agent todo / plan items (issue #736). */
val todos: List<TodoItem> = emptyList(),
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
@@ -473,6 +477,14 @@ class ChatViewModel(
trackRequest = { id, method -> trackRequest(id, method) },
)
private val subagentsDelegate =
ChatSubagentsDelegate(
uiState = _uiState,
scope = viewModelScope,
ioDispatcher = ioDispatcher,
runtimeSessionId = { runtimeSessionId ?: _uiState.value.currentSessionId },
)
private val streamingController =
ChatStreamingController(
scope = viewModelScope,
@@ -534,6 +546,7 @@ class ChatViewModel(
status == ConnectionStatus.AUTH_EXPIRED
) {
_uiState.update { it.copy(isLoading = false) }
subagentsDelegate.closeSubagentTranscript()
// The runtime session id is only valid while the socket
// owns it — a dropped connection may mean the gateway
// closed/pruned the session (or restarted, wiping the
@@ -1139,6 +1152,7 @@ class ChatViewModel(
val generation = request?.generation ?: sessionGeneration
resumedGeneration = generation
finishResumeWhenHydrated(generation)
subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId)
// Reconnect replay: resume payload can carry `pending_approval`
// (server `_session_info_payload`); surface it, then ask for
// the full queue in case more are parked.
@@ -2182,6 +2196,22 @@ class ChatViewModel(
}
}
fun hydrateSubagents(sessionId: String? = null) {
subagentsDelegate.hydrateSubagents(sessionId ?: runtimeSessionId ?: _uiState.value.currentSessionId)
}
fun toggleSubagentTranscript(subagentId: String) {
subagentsDelegate.toggleSubagentTranscript(subagentId)
}
fun retrySubagentTranscript() {
subagentsDelegate.retryTranscript()
}
fun closeSubagentTranscript() {
subagentsDelegate.closeSubagentTranscript()
}
fun createNewSession(setLoading: Boolean = true) {
// A fresh create has no persisted row until the first prompt.
sessionHasServerPresence = false
@@ -3451,6 +3481,7 @@ class ChatViewModel(
override fun onCleared() {
super.onCleared()
subagentsDelegate.closeSubagentTranscript()
// PERF-16: Don't disconnect the global HermesWsClient singleton when
// leaving the Chat screen — it's used by background notification reply.
}
@@ -946,6 +946,7 @@ object ChatWsEventReducer {
logs = trimmedLogs,
durationSeconds = durationSeconds ?: (if (idx >= 0) indicators[idx].durationSeconds else null),
model = model ?: (if (idx >= 0) indicators[idx].model else null),
lastEventTimestamp = System.currentTimeMillis(),
)
if (idx >= 0) {
@@ -4,20 +4,25 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
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.PaddingValues
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.heightIn
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.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.AltRoute
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
@@ -32,11 +37,14 @@ import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
@@ -45,6 +53,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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
@@ -63,6 +72,7 @@ import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.chat.SubagentTranscriptUiState
import com.m57.hermescontrol.ui.chat.TodoItem
/**
@@ -73,6 +83,10 @@ import com.m57.hermescontrol.ui.chat.TodoItem
fun SubagentInspectionSheet(
indicators: List<SubagentIndicator> = emptyList(),
todos: List<TodoItem> = emptyList(),
inspectingSubagentId: String? = null,
subagentTranscript: SubagentTranscriptUiState? = null,
onToggleTranscript: ((String) -> Unit)? = null,
onRetryTranscript: (() -> Unit)? = null,
onSteerSubagent: ((SubagentIndicator, String) -> Unit)? = null,
onStopSubagent: ((SubagentIndicator) -> Unit)? = null,
onDismiss: () -> Unit,
@@ -197,7 +211,7 @@ fun SubagentInspectionSheet(
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "SUBAGENTS",
text = stringResource(R.string.subagent_subagents_header).uppercase(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
@@ -211,8 +225,14 @@ fun SubagentInspectionSheet(
"subagent-${indicator.subagentId ?: indicator.goal ?: "${indicator.type}_$index"}"
},
) { _, indicator ->
val isInspectingThis =
indicator.subagentId != null && indicator.subagentId == inspectingSubagentId
InspectionItemCard(
indicator = indicator,
isInspectingTranscript = isInspectingThis,
transcriptState = if (isInspectingThis) subagentTranscript else null,
onToggleTranscript = onToggleTranscript,
onRetryTranscript = onRetryTranscript,
onSteer =
if (onSteerSubagent != null) {
{ message -> onSteerSubagent(indicator, message) }
@@ -448,6 +468,10 @@ private fun TodoInspectionCard(
@Composable
private fun InspectionItemCard(
indicator: SubagentIndicator,
isInspectingTranscript: Boolean = false,
transcriptState: SubagentTranscriptUiState? = null,
onToggleTranscript: ((String) -> Unit)? = null,
onRetryTranscript: (() -> Unit)? = null,
onSteer: ((String) -> Unit)? = null,
onStop: (() -> Unit)? = null,
) {
@@ -619,6 +643,44 @@ private fun InspectionItemCard(
}
}
// Full Rolling Transcript Disclosure (issue #1089)
if (!indicator.subagentId.isNullOrBlank() && onToggleTranscript != null) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { onToggleTranscript(indicator.subagentId) },
modifier =
Modifier
.fillMaxWidth()
.testTag("subagent_transcript_toggle_${indicator.subagentId}"),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
) {
Icon(
imageVector = if (isInspectingTranscript) Icons.Filled.ExpandLess else Icons.Filled.Terminal,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text =
if (isInspectingTranscript) {
stringResource(R.string.subagent_hide_transcript)
} else {
stringResource(R.string.subagent_inspect_transcript)
},
style = MaterialTheme.typography.labelMedium,
)
}
AnimatedVisibility(visible = isInspectingTranscript) {
Column(modifier = Modifier.padding(top = 8.dp)) {
SubagentTranscriptView(
state = transcriptState,
onRetry = onRetryTranscript,
)
}
}
}
// Interactive Controls for active subagent (issue #1030)
if (indicator.isRunning || indicator.isSteered) {
Spacer(modifier = Modifier.height(10.dp))
@@ -721,3 +783,175 @@ private fun InspectionItemCard(
}
}
}
@Composable
private fun SubagentTranscriptView(
state: SubagentTranscriptUiState?,
onRetry: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth().testTag("subagent_transcript_container"),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerLowest,
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
when {
state == null || (state.isLoading && state.text.isEmpty()) -> {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.subagent_transcript_loading),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
state.error != null && state.text.isEmpty() -> {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = state.error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
if (onRetry != null) {
OutlinedButton(
onClick = onRetry,
modifier = Modifier.testTag("subagent_transcript_retry"),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
) {
Text(
text = stringResource(R.string.subagent_transcript_retry),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
state.isEmpty -> {
Text(
text = stringResource(R.string.subagent_transcript_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.testTag("subagent_transcript_empty"),
)
}
else -> {
// Header badges (truncation note & background loading indicator)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.subagent_live_transcript),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.outline,
fontSize = 9.sp,
)
if (state.isTruncated) {
Text(
text = stringResource(R.string.subagent_transcript_truncated),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
fontSize = 8.sp,
)
}
}
if (state.isLoading) {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.height(2.dp),
color = MaterialTheme.colorScheme.primary,
)
}
val scrollState = rememberScrollState()
LaunchedEffect(state.text) {
scrollState.animateScrollTo(scrollState.maxValue)
}
Box(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 60.dp, max = 240.dp)
.verticalScroll(scrollState),
) {
Text(
text = state.text,
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag("subagent_transcript_text"),
)
}
if (state.error != null && onRetry != null) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = state.error,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onRetry,
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Filled.Autorenew,
contentDescription = stringResource(R.string.subagent_transcript_retry),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp),
)
}
}
}
}
}
}
}
}