package com.example.aiapp import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight 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.Surface 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.mutableFloatStateOf 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.draw.alpha import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val OPEN_THRESHOLD = 0.35f private val FLING_THRESHOLD = 400.dp /** * Keeps [content] composed while a panel belonging to it moves over from the right. * * The root drag handler deliberately sits behind descendants. A horizontal scroller consumes its * drag first, so code blocks, attachments and tool inputs keep their existing gesture. Collapsing * that content, or starting over any ordinary part of the session, gives the gesture back to the * panel; Android's own right-edge Back gesture remains untouched. */ @Composable fun SubagentPanel( settings: ServerSettings, summary: SessionSummary, onOpenSubagent: (SubagentSummary) -> Unit, content: @Composable () -> Unit, ) { var open by remember(summary.id) { mutableStateOf(false) } var dragging by remember(summary.id) { mutableStateOf(false) } var draggedReveal by remember(summary.id) { mutableFloatStateOf(0f) } val animatedReveal by animateFloatAsState(if (open) 1f else 0f, label = "subagent panel") val reveal = if (dragging) draggedReveal else animatedReveal val flingThreshold = with(LocalDensity.current) { FLING_THRESHOLD.toPx() } fun startDrag() { draggedReveal = reveal dragging = true } fun finishDrag(velocity: Float) { open = when { velocity < -flingThreshold -> true velocity > flingThreshold -> false else -> draggedReveal >= OPEN_THRESHOLD } dragging = false } BackHandler(enabled = open) { open = false } BoxWithConstraints(Modifier.fillMaxSize()) { val panelWidth = maxWidth * 0.88f val panelWidthPx = constraints.maxWidth * 0.88f val dragState = rememberDraggableState { delta -> draggedReveal = (draggedReveal - delta / panelWidthPx.coerceAtLeast(1f)).coerceIn(0f, 1f) } val drag = Modifier.draggable( state = dragState, orientation = Orientation.Horizontal, onDragStarted = { startDrag() }, onDragStopped = { velocity -> finishDrag(velocity) }, ) Box( Modifier.fillMaxSize() .then(drag) .then(if (reveal > 0f) Modifier.clearAndSetSemantics {} else Modifier) ) { content() } if (reveal > 0f) { Box( Modifier.fillMaxSize() .alpha(reveal * 0.32f) .background(MaterialTheme.colorScheme.scrim) .semantics { contentDescription = "Dismiss subagent panel" } .clickable { open = false } ) } Surface( tonalElevation = 3.dp, shadowElevation = 8.dp, modifier = Modifier.align(Alignment.CenterEnd) .width(panelWidth) .fillMaxHeight() .graphicsLayer { translationX = size.width * (1f - reveal) } .then(drag), ) { SubagentPanelContents( settings = settings, summary = summary, active = open, onClose = { open = false }, onOpenSubagent = onOpenSubagent, ) } } } @Composable private fun SubagentPanelContents( settings: ServerSettings, summary: SessionSummary, active: Boolean, 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) } 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) } } BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() } Column(Modifier.fillMaxSize()) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) { Text( "Subagents", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), ) MarkButton("Close subagents", onClose) { Chevron(Pointing.Right) } } when (val state = rows) { is LoadState.Loading -> CircularProgressIndicator( modifier = Modifier.padding(16.dp).width(24.dp).height(24.dp) ) is LoadState.Error -> Column(Modifier.padding(16.dp)) { Text( state.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) TextButton(onClick = { refreshToken++ }) { Text("Try again") } } is LoadState.Loaded -> { val ordered = subagentOrder(state.value) if (ordered.isEmpty()) { Text( "No subagents in this session.", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(16.dp), ) } else { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f).padding(horizontal = 16.dp), ) { 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.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" }