410 lines
16 KiB
Kotlin
410 lines
16 KiB
Kotlin
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<List<SubagentSummary>>>(LoadState.Loading) }
|
|
var selected by remember(summary.id) { mutableStateOf(setOf<String>()) }
|
|
var deleting by remember(summary.id) { mutableStateOf(setOf<String>()) }
|
|
var deleteError by remember(summary.id) { mutableStateOf<String?>(null) }
|
|
var confirming by remember(summary.id) { mutableStateOf<List<SubagentSummary>?>(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<SubagentSummary>): List<SubagentSummary> =
|
|
rows.sortedWith(
|
|
compareByDescending<SubagentSummary> { 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<SubagentSummary>,
|
|
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"
|
|
}
|