Move subagents into session side panel
This commit is contained in:
1 parent
cd0229bed6
commit
0a2f0eed5f
8 files changed
+497
-536
No files matched your search
@@ -0,0 +1,409 @@
|
||||
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"
|
||||
}
|
||||
Reference in new issue
Block a user