package com.example.aiapp import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable 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.fillMaxSize 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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.AlertDialog import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.platform.LocalContext import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** * What a session has running beside the turn you are reading: its background tasks, then its * subagents, in the panel [SidePanels] slides over it from the right. * * [active] is whether the panel is being looked at: the lists are fetched then rather than on * composition, since the panel is composed for every session whether or not anybody opens it. * * [backgroundTasks] is the live count from the session's own event stream, and is what the * background list is refetched against: a card for work that has since finished is a stale * measurement drawn as a current one, which is the one thing a list of what is running now must not * do. * * Both lists are items of one lazy column rather than two stacked scrollers, so expanding the * background section pushes the subagents down without either being able to run off the panel. */ @Composable fun SubagentPanel( settings: ServerSettings, summary: SessionSummary, active: Boolean, backgroundTasks: Int, onClose: () -> Unit, onOpenSubagent: (SubagentSummary) -> Unit, ) { val scope = rememberCoroutineScope() val context = LocalContext.current val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } var rows by remember(summary.id) { mutableStateOf>>(LoadState.Loading) } var selected by remember(summary.id) { mutableStateOf(setOf()) } var deleting by remember(summary.id) { mutableStateOf(setOf()) } var deleteError by remember(summary.id) { mutableStateOf(null) } var confirming by remember(summary.id) { mutableStateOf?>(null) } var refreshToken by remember(summary.id) { mutableIntStateOf(0) } var background by remember(summary.id) { mutableStateOf?>>(LoadState.Loading) } var backgroundExpanded by remember(summary.id) { mutableStateOf(false) } LaunchedEffect(active, refreshToken) { if (!active) return@LaunchedEffect rows = LoadState.Loading rows = try { val fetched = withContext(Dispatchers.IO) { fetchSubagents(settings, summary.id) } selected = selected intersect fetched.mapTo(mutableSetOf()) { it.id } LoadState.Loaded(fetched) } catch (e: ApiException) { LoadState.failed(e) } } // No reset to Loading on a refetch: the spinner belongs to the first fetch, and one flashed // over the list at every start and end would blink precisely when something happened. LaunchedEffect(active, backgroundTasks, refreshToken) { if (!active || backgroundTasks == 0) return@LaunchedEffect background = try { LoadState.Loaded( withContext(Dispatchers.IO) { fetchBackgroundTasks(settings, summary.id) } ) } catch (e: ApiException) { LoadState.failed(e) } } BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() } val ordered = (rows as? LoadState.Loaded)?.value?.let(::subagentOrder) Column(Modifier.fillMaxSize()) { Row( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) { MarkButton("Close panel", onClose) { Chevron(Pointing.Right) } } LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f).padding(horizontal = 16.dp), ) { backgroundTaskSection( count = backgroundTasks, tasks = background, expanded = backgroundExpanded, onToggle = { backgroundExpanded = !backgroundExpanded }, onRetry = { refreshToken++ }, ) item(key = "subagents-heading") { PanelSectionHeading("Subagents") } when (val state = rows) { is LoadState.Loading -> item(key = "subagents-loading") { CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp)) } is LoadState.Error -> item(key = "subagents-error") { Column { Text( state.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) TextButton(onClick = { refreshToken++ }) { Text("Try again") } } } is LoadState.Loaded -> if (ordered.isNullOrEmpty()) { item(key = "subagents-empty") { Text( "No subagents in this session.", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) } } else { uniqueItems(ordered, key = { it.id }) { subagent -> SubagentCard( subagent = subagent, selected = subagent.id in selected, selecting = selected.isNotEmpty(), deleting = subagent.id in deleting, onClick = { onOpenSubagent(subagent) }, onSelect = { selected = if (subagent.id in selected) selected - subagent.id else selected + subagent.id }, ) } } } } if (selected.isNotEmpty()) { val picked = ordered.orEmpty().filter { it.id in selected } SubagentSelectionBar( picked = picked, onDelete = { confirming = picked }, modifier = Modifier.padding(horizontal = 16.dp), ) } deleteError?.let { Text( it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) } } confirming?.let { picked -> AlertDialog( onDismissRequest = { confirming = null }, title = { Text( if (picked.size == 1) "Delete this subagent?" else "Delete ${picked.size} subagents?" ) }, text = { Text( (if (picked.size == 1) "\"${picked.first().title}\"\n\n" else "") + "A subagent's transcript is the only record of what it did: the session " + "that started it kept just the Task call. Nothing else has a copy, so " + "this can't be undone. The session itself is untouched." ) }, confirmButton = { TextButton( onClick = { confirming = null selected = emptySet() val ids = picked.map { it.id } val gone = ids.toSet() deleting += gone deleteError = null scope.launch { try { withContext(Dispatchers.IO) { deleteSubagents(settings, summary.id, ids) gone.forEach { transcriptCache .session(TranscriptAddress(summary.id, it)) .purge() } } val loaded = rows if (loaded is LoadState.Loaded) { rows = LoadState.Loaded(loaded.value.filterNot { it.id in gone }) } } catch (e: ApiException) { deleteError = e.message ?: "Delete failed" } finally { deleting -= gone } } } ) { Text("Delete", color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } }, ) } } private fun subagentOrder(rows: List): List = rows.sortedWith( compareByDescending { it.status == "running" } .thenByDescending { it.lastActivity } ) @OptIn(ExperimentalFoundationApi::class) @Composable private fun SubagentCard( subagent: SubagentSummary, selected: Boolean, selecting: Boolean, deleting: Boolean, onClick: () -> Unit, onSelect: () -> Unit, ) { BusyItem(label = if (deleting) "deleting" else null) { OutlinedCard( colors = if (selected) CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, ) else CardDefaults.outlinedCardColors(), modifier = Modifier.fillMaxWidth() .combinedClickable( enabled = !deleting, onClick = { if (selecting) onSelect() else onClick() }, onLongClick = onSelect, ), ) { Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { Text(subagent.title, style = MaterialTheme.typography.titleSmall) Spacer(Modifier.height(2.dp)) Row(Modifier.fillMaxWidth()) { Text( subagentStatusLabel(subagent.status), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) Text( relativeTime(subagent.lastActivity), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } } @Composable private fun SubagentSelectionBar( picked: List, onDelete: () -> Unit, modifier: Modifier = Modifier, ) { val running = picked.count { it.status == "running" } Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth().heightIn(min = 48.dp), ) { Text( if (running == 0) "${picked.size} selected" else "$running still running", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) TextButton(onClick = onDelete, enabled = running == 0) { Text( "Delete", color = if (running == 0) MaterialTheme.colorScheme.error else LocalContentColor.current, ) } } } private fun subagentStatusLabel(status: String) = when (status) { "running" -> "running" "exited" -> "finished" else -> "unknown" }