Move subagents into session side panel

This commit is contained in:
iris-ai committed 2026-09-17 13:41:24 -04:00
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"
}