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
+491 -530

No files matched your search

+10
View File
@@ -976,6 +976,16 @@ SSE stream, reused by addressing rather than by copying — costs a routing step
in the translator and a registry (`session/subagent.rs`) rather than a second in the translator and a registry (`session/subagent.rs`) rather than a second
session type with a driver, a process and a config entry it does not need. session type with a driver, a process and a config entry it does not need.
**The phone presents them beside their open session, not inside the main
session list** (changed 2026-09-17). A left swipe pulls a panel over the live
transcript; the transcript remains composed beneath it so opening the panel or
a child's read-only transcript does not stop its stream, discard its draft or
lose its scroll position. Horizontal transcript content keeps first claim on
the gesture; collapsing it or starting on the ordinary session surface gives
the gesture back to the panel, while Android keeps its own edge Back gesture.
This is also the intended home for background work once that has a list of its
own; only subagents are shown there now.
### HTTP surface ### HTTP surface
**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE **`routes.rs`'s module doc comment is the table.** REST for actions, one SSE
+22 -20
View File
@@ -1,8 +1,8 @@
# Subagents # Subagents
A session's subagents -- helpers started by Claude Code's Task tool or Codex's A session's subagents -- helpers started by Claude Code's Task tool or Codex's
collaboration tools -- each get a transcript of their own, listed under the collaboration tools -- each get a transcript of their own, listed in a panel
session's card and readable in the same transcript view the session has. over the open session and readable in the same transcript view the session has.
Designed 2026-09-05; extended to Codex's multiplexed app-server threads on Designed 2026-09-05; extended to Codex's multiplexed app-server threads on
2026-09-13. The decisions Bryan has not yet reviewed are in `DECISIONS.md`. 2026-09-13. The decisions Bryan has not yet reviewed are in `DECISIONS.md`.
@@ -253,29 +253,31 @@ The per-subagent status is only read when the list route is asked for.
because a subagent that is thinking reports nothing meanwhile and would sink because a subagent that is thinking reports nothing meanwhile and would sink
below one that just finished. below one that just finished.
- **Holding a subcard selects it, and several at a time**, exactly as the - **Holding a subcard selects it, and several at a time**, exactly as the
import list works, with the selection bar drawn inside the session's card import list works, with the selection bar drawn inside the panel rather than
rather than at the bottom of the screen: this selection belongs to one card, at the bottom of the session: this selection belongs to the subagent list,
and a bar down there would read as acting on the whole list. One card at a and a bar under the composer would read as acting on the conversation.
time -- picking a row in another card moves the selection rather than adding Delete is
to it, since one Delete is one request against one parent. Delete is
*disabled*, with the reason in words, while anything selected is still *disabled*, with the reason in words, while anything selected is still
running. Deleting confirms first, dims the rows it is acting on running. Deleting confirms first, dims the rows it is acting on
(`BusyItem`), and on success takes them out of that card and off the (`BusyItem`), and on success takes them out of the panel without refetching
session's count without refetching anything else. The phone's cached copy of anything else. The phone's cached copy of
a deleted subagent's transcript is purged with it. a deleted subagent's transcript is purged with it.
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an - The main session list does not expand or count subagents. Swiping left over
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to an open session pulls an 88%-wide panel in from the right and fetches
`Pointing.Up` -- collapsed by default. Expanding fetches `/sessions/{id}/subagents`; it draws one `OutlinedCard` per subagent: title,
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent, then the status word and a relative time. The transcript remains composed
indented inside the session card, the way dev-updater draws a project's under the panel, so its event stream, draft and scroll position stay live.
components: title, then the status word and a relative time. The Horizontal scrollers inside the transcript win the gesture. Collapsing one,
expansion state is per session id and survives a refresh of the list. or starting over any ordinary part of the session, gives the gesture back to
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in the panel; Android keeps its own edge Back gesture. Swiping right on the
**read-only** form: the same transcript, paging, cache, selection, panel, tapping outside it, or Back closes it.
- Tapping a subcard opens a `SessionScreen` layer in **read-only** form: the
same transcript, paging, cache, selection,
images and status row, with the composer, the process button, the model images and status row, with the composer, the process button, the model
picker, the files button, the settings cog and the usage bar left out. picker, the files button, the settings cog and the usage bar left out.
The header shows the subagent's title with the session's title beneath The header shows the subagent's title with the session's title beneath it.
it. Back returns to the list. It is another layer over the still-composed session and its panel; Back
returns to the panel.
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the - Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
cache take a transcript address rather than a session id -- cache take a transcript address rather than a session id --
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a `sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
@@ -236,14 +236,6 @@ data class SessionSummary(
val started: Double, val started: Double,
/** Latest measured number of live background tasks; zero also covers older servers. */ /** Latest measured number of live background tasks; zero also covers older servers. */
val backgroundTasks: Int, val backgroundTasks: Int,
/**
* How many subagents this session has, however their own status now reads.
*
* A directory listing on the server rather than a status read per subagent, so the list stays
* cheap; the per-subagent state is only fetched when the card is expanded. Zero on a server
* that predates subagents, so this app still opens against one.
*/
val subagents: Int,
) )
private fun parseSession(session: JSONObject) = private fun parseSession(session: JSONObject) =
@@ -276,7 +268,6 @@ private fun parseSession(session: JSONObject) =
lastActivity = session.getDouble("lastActivity"), lastActivity = session.getDouble("lastActivity"),
started = session.optDouble("started", session.getDouble("lastActivity")), started = session.optDouble("started", session.getDouble("lastActivity")),
backgroundTasks = session.optInt("backgroundTasks", 0), backgroundTasks = session.optInt("backgroundTasks", 0),
subagents = session.optInt("subagents", 0),
) )
fun fetchSessions(settings: ServerSettings): List<SessionSummary> = fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
@@ -22,6 +22,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed import com.example.wgapplink.localNetworkAllowed
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -37,33 +38,21 @@ import kotlinx.coroutines.withContext
* one session, spawning one, and settings. * one session, spawning one, and settings.
*/ */
private sealed class Screen { private sealed class Screen {
/** data object Main : Screen()
* The session list, with a subagent's own transcript over it when [subagent] is set.
*
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
* itself the moment its own subagent's view was closed.
*/
data class Main(val subagent: SubagentTarget? = null) : Screen()
/** /**
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and * One session, with the file explorer or a subagent transcript over it when set.
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* is opened from the session list's card rather than from inside the session it belongs to.
*/
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
/**
* One session, with the file explorer over it when [files] is set.
* *
* The explorer is a layer on this screen rather than a screen of its own, so the session under * Both are layers on this screen rather than screens of their own, so the session under them
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put, * stays composed: its event stream keeps flowing, its scroll position and draft stay put, and
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re- * coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every
* created on every return, refetching the transcript over the tunnel. * return, refetching the transcript over the tunnel.
*/ */
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen() data class Session(
val summary: SessionSummary,
val files: FilesTarget? = null,
val subagent: SubagentSummary? = null,
) : Screen()
data object Spawn : Screen() data object Spawn : Screen()
@@ -100,7 +89,7 @@ fun AppRoot(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.Main()) } var screen by remember { mutableStateOf<Screen>(Screen.Main) }
// A notification tap this could not follow, and why. Null both before one is asked for and // A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message. // after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) } var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
@@ -115,7 +104,7 @@ fun AppRoot(
share = shareRequest share = shareRequest
// A session already open takes it. Otherwise the list is where the choice is made, // A session already open takes it. Otherwise the list is where the choice is made,
// whatever screen was showing: Spawn and Settings have nowhere to put a file. // whatever screen was showing: Spawn and Settings have nowhere to put a file.
if (screen !is Screen.Session) screen = Screen.Main() if (screen !is Screen.Session) screen = Screen.Main
} }
} }
@@ -142,7 +131,7 @@ fun AppRoot(
existing = null, existing = null,
onSaved = { saved -> onSaved = { saved ->
settings = saved settings = saved
screen = Screen.Main() screen = Screen.Main
}, },
onBack = null, onBack = null,
) )
@@ -155,7 +144,7 @@ fun AppRoot(
// shows, so it always refetches. // shows, so it always refetches.
val goToMain = { val goToMain = {
reloadToken++ reloadToken++
screen = Screen.Main() screen = Screen.Main
} }
if (screen !is Screen.Main) { if (screen !is Screen.Main) {
BackHandler(onBack = goToMain) BackHandler(onBack = goToMain)
@@ -197,16 +186,13 @@ fun AppRoot(
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is // deliberately does not: resizing a whole screen on every frame of the keyboard animation is
// the cost that made it lag, so it moves only its composer and transcript. // the cost that made it lag, so it moves only its composer and transcript.
when (val here = screen) { when (val here = screen) {
is Screen.Main -> Screen.Main ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding()) {
MainScreen( MainScreen(
settings = current, settings = current,
reloadToken = reloadToken, reloadToken = reloadToken,
share = share, share = share,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onOpenSubagent = { summary, subagent ->
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
},
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onImported = { imported -> onImported = { imported ->
reloadToken++ reloadToken++
@@ -214,27 +200,6 @@ fun AppRoot(
}, },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
) )
// Its own back handler is registered after MainScreen's, so it is the one the
// platform asks first while a subagent is open -- the same rule the files
// explorer's handler follows over its session, below.
here.subagent?.let { target ->
BackHandler { screen = here.copy(subagent = null) }
// Its own opaque background: this screen was always the sole content under
// the theme's own Surface before, so it never had to paint one -- stacked over
// the list here, the space between its own cards let the list underneath show
// through without this. The same fix FilesScreen needed over its session.
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
key(target.summary.id, target.subagent.id) {
SessionScreen(
settings = current,
summary = target.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = target.subagent,
)
}
}
}
} }
is Screen.Session -> is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this // Keyed on the id, because a different session is a different screen rather than this
@@ -250,7 +215,21 @@ fun AppRoot(
val fileLinkHandler = rememberFileLinkHandler { path -> val fileLinkHandler = rememberFileLinkHandler { path ->
screen = here.copy(files = here.summary.filesTarget(path)) screen = here.copy(files = here.summary.filesTarget(path))
} }
CompositionLocalProvider(LocalFileLinkHandler provides fileLinkHandler) { Box(
Modifier.then(
if (here.subagent != null || here.files != null)
Modifier.clearAndSetSemantics {}
else Modifier
)
) {
SubagentPanel(
settings = current,
summary = here.summary,
onOpenSubagent = { screen = here.copy(subagent = it) },
) {
CompositionLocalProvider(
LocalFileLinkHandler provides fileLinkHandler
) {
SessionScreen( SessionScreen(
settings = current, settings = current,
summary = here.summary, summary = here.summary,
@@ -260,6 +239,24 @@ fun AppRoot(
onShareTaken = { share = null }, onShareTaken = { share = null },
) )
} }
}
}
here.subagent?.let { subagent ->
BackHandler { screen = here.copy(subagent = null) }
Box(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
) {
key(subagent.id) {
SessionScreen(
settings = current,
summary = here.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = subagent,
)
}
}
}
// Its own back handler is registered after this screen's, so it is the one the // Its own back handler is registered after this screen's, so it is the one the
// platform asks first, and it steps back inside itself before closing. // platform asks first, and it steps back inside itself before closing.
here.files?.let { target -> here.files?.let { target ->
@@ -48,8 +48,6 @@ fun MainScreen(
/** What another app shared in and no session has taken yet; see [ShareRequest]. */ /** What another app shared in and no session has taken yet; see [ShareRequest]. */
share: ShareRequest? = null, share: ShareRequest? = null,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit, onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
@@ -141,7 +139,6 @@ fun MainScreen(
settings = settings, settings = settings,
reloadToken = token, reloadToken = token,
onOpen = onOpen, onOpen = onOpen,
onOpenSubagent = onOpenSubagent,
onSpawn = onSpawn, onSpawn = onSpawn,
) )
MainTab.Import -> MainTab.Import ->
@@ -1,10 +1,7 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -12,18 +9,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -37,8 +30,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -56,67 +47,12 @@ fun SessionListScreen(
settings: ServerSettings, settings: ServerSettings,
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) } var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) } var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Which session cards are expanded to show their subagents, and what each expansion fetched.
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
// from
// whatever the server last said, and this belongs to the reader's own choice, which survives a
// refresh.
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
var subagentLoads by remember {
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
}
// Which subagents the reader has picked out, and whose card they are in. One card at a time:
// a selection is deleted with one request against its parent session, so a set spanning two
// cards is not something one Delete could act on -- picking a row in another card moves the
// selection there rather than adding to it. Null means selection mode is off, since a
// selection with nothing in it is a mode with no controls and no way out but Back.
var subagentSelection by remember { mutableStateOf<SubagentSelection?>(null) }
// Which subagents have a delete in flight, and what a card's batch failed with. The same two
// states the session rows above keep, for the same reasons: the rows are rebuilt from whatever
// the server last said, and a failure is shown where it happened. The error is keyed by
// session, not by subagent, because the server takes a batch or refuses it whole.
var deletingSubagents by remember { mutableStateOf<Set<String>>(emptySet()) }
var subagentErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Deleting a subagent's transcript cannot be undone, so it is asked rather than done. Held as
// the rows themselves, not a flag, so the dialog can say what it is about.
var confirmingSubagents by remember { mutableStateOf<SubagentDeletion?>(null) }
fun loadSubagents(sessionId: String) {
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
scope.launch {
subagentLoads =
subagentLoads +
(sessionId to
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
)
} catch (e: ApiException) {
LoadState.failed(e)
})
}
}
/**
* Picks a subagent out, or puts it back; see [subagentSelection] for why one card at a time.
*/
fun toggleSubagent(sessionId: String, subagent: SubagentSummary) {
val picked = subagentSelection?.takeIf { it.sessionId == sessionId }?.ids.orEmpty()
val next = if (subagent.id in picked) picked - subagent.id else picked + subagent.id
subagentSelection = if (next.isEmpty()) null else SubagentSelection(sessionId, next)
}
// Failures that belong to one session rather than to the list, keyed by its id and shown on its // Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused, // own card. The two scopes are decided by whether the server answered: it answered and refused,
// so this says nothing about the other rows. // so this says nothing about the other rows.
@@ -133,73 +69,6 @@ fun SessionListScreen(
val context = LocalContext.current val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
/**
* Removes [rows] from [sessionId]'s card, once the reader has confirmed it.
*
* One request for the batch rather than one per row -- see [deleteSubagents]. The selection is
* dropped as the work is handed over, not when it lands: the card goes back to how it started,
* and what says the work is happening is the rows it is happening to.
*/
fun deleteSubagentsIn(sessionId: String, rows: List<SubagentSummary>) {
val ids = rows.map { it.id }
val gone = ids.toSet()
subagentSelection = null
deletingSubagents = deletingSubagents + gone
subagentErrors = subagentErrors - sessionId
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSubagents(settings, sessionId, ids)
// After it succeeded, not before: a refused delete leaves those subagents
// exactly as they were, and this phone's copies of them still worth keeping.
gone.forEach {
transcriptCache.session(TranscriptAddress(sessionId, it)).purge()
}
}
// Only this card, and only what changed -- refetching the list instead put every
// other session back through a spinner to report something never in doubt. The
// count on the row is updated with it, because it is what decides the expander is
// there at all.
val loaded = subagentLoads[sessionId]
if (loaded is LoadState.Loaded) {
subagentLoads =
subagentLoads +
(sessionId to
LoadState.Loaded(loaded.value.filterNot { it.id in gone }))
}
val sessions = listState
if (sessions is LoadState.Loaded) {
listState =
LoadState.Loaded(
sessions.value.map {
if (it.id == sessionId)
it.copy(subagents = (it.subagents - gone.size).coerceAtLeast(0))
else it
}
)
}
// A card with none left has no expander to be open by, so the reader's choice to
// expand it goes too -- its path out, rather than a set holding an id that can
// never be drawn again.
val left =
(listState as? LoadState.Loaded)
?.value
?.firstOrNull { it.id == sessionId }
?.subagents ?: 0
if (left == 0) {
expandedSessions = expandedSessions - sessionId
subagentLoads = subagentLoads - sessionId
}
} catch (e: ApiException) {
// Kept, because they are still there: the server refused, so what it refused about
// is exactly as it was.
subagentErrors = subagentErrors + (sessionId to (e.message ?: "Delete failed"))
} finally {
deletingSubagents = deletingSubagents - gone
}
}
}
fun refresh() { fun refresh() {
listState = LoadState.Loading listState = LoadState.Loading
scope.launch { scope.launch {
@@ -215,14 +84,6 @@ fun SessionListScreen(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet()) transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
} }
// A session gone from this answer cannot still be expanded, and an expanded one
// that is still here asks again -- its subagents may have changed since the
// last
// fetch.
val ids = loaded.value.map { it.id }.toSet()
expandedSessions = expandedSessions intersect ids
subagentLoads = subagentLoads.filterKeys { it in ids }
expandedSessions.forEach(::loadSubagents)
loaded loaded
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
@@ -232,11 +93,6 @@ fun SessionListScreen(
LaunchedEffect(reloadToken) { refresh() } LaunchedEffect(reloadToken) { refresh() }
// Back leaves the subagent selection rather than the tab, which is the level it is one step
// above -- the same nesting the import list's own selection uses. Nested inside MainScreen's
// handler, so it wins only while there is a selection.
BackHandler(enabled = subagentSelection != null) { subagentSelection = null }
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) { Column(Modifier.fillMaxSize().padding(16.dp)) {
when (val state = listState) { when (val state = listState) {
@@ -266,33 +122,6 @@ fun SessionListScreen(
deleting = session.id in deleting, deleting = session.id in deleting,
onOpen = { onOpen(session) }, onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session }, onLongPress = { confirmingDelete = session },
expanded = session.id in expandedSessions,
subagents = subagentLoads[session.id],
subagentError = subagentErrors[session.id],
selectedSubagents =
subagentSelection
?.takeIf { it.sessionId == session.id }
?.ids
.orEmpty(),
deletingSubagents = deletingSubagents,
onSelectSubagent = { toggleSubagent(session.id, it) },
onDeleteSubagents = { rows ->
confirmingSubagents = SubagentDeletion(session.id, rows)
},
onToggleSubagents = {
if (session.id in expandedSessions) {
expandedSessions = expandedSessions - session.id
// A selection made in a card that is now collapsed has no
// rows on screen, so nothing would say what Delete acts on.
if (subagentSelection?.sessionId == session.id) {
subagentSelection = null
}
} else {
expandedSessions = expandedSessions + session.id
loadSubagents(session.id)
}
},
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
@@ -426,45 +255,6 @@ fun SessionListScreen(
}, },
) )
} }
confirmingSubagents?.let { pending ->
AlertDialog(
onDismissRequest = { confirmingSubagents = null },
title = {
Text(
if (pending.rows.size == 1) "Delete this subagent?"
else "Delete ${pending.rows.size} subagents?"
)
},
text = {
Text(
// One name is worth showing and twelve are not, so the count stands in for
// them -- the same shape as the import list's dialog. The sentence after it is
// the same either way, because what deleting costs does not change with how
// many.
(if (pending.rows.size == 1) "\"${pending.rows.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 = {
confirmingSubagents = null
deleteSubagentsIn(pending.sessionId, pending.rows)
}
) {
// Coloured by consequence, wherever it appears -- the same rule the session
// and import deletes above follow.
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { confirmingSubagents = null }) { Text("Cancel") }
},
)
}
} }
/** /**
@@ -488,34 +278,6 @@ fun sessionsInListOrder(sessions: List<SessionSummary>): List<SessionSummary> {
return running.sortedBy { it.started } + stopped.sortedByDescending { it.lastActivity } return running.sortedBy { it.started } + stopped.sortedByDescending { it.lastActivity }
} }
/**
* The subagents picked out inside one session's card, and which card that is.
*
* Both together rather than a set beside a session id, which can disagree -- and the pair is what
* says a Delete has one parent to send itself to. See [SessionListScreen].
*/
private data class SubagentSelection(val sessionId: String, val ids: Set<String>)
/**
* A subagent delete waiting to be confirmed: which card, and the rows so the dialog can name one.
*/
private data class SubagentDeletion(val sessionId: String, val rows: List<SubagentSummary>)
/**
* The order a session's subagents are drawn in: the ones still working first, then most recently
* active.
*
* A display decision, made here rather than on the server, which answers oldest-first and is stable
* -- see UI_RULES. Two keys rather than activity alone because a subagent that is thinking has
* nothing to report meanwhile, and would sink below one that finished a moment ago; "still working"
* is what the reader is looking for at the top.
*/
private fun subagentOrder(rows: List<SubagentSummary>): List<SubagentSummary> =
rows.sortedWith(
compareByDescending<SubagentSummary> { it.status == "running" }
.thenByDescending { it.lastActivity }
)
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SessionCard( private fun SessionCard(
@@ -532,20 +294,6 @@ private fun SessionCard(
deleting: Boolean, deleting: Boolean,
onOpen: () -> Unit, onOpen: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit,
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
expanded: Boolean,
/** What the expander's own fetch answered, or null before it has been asked. */
subagents: LoadState<List<SubagentSummary>>?,
/** What deleting subagents from *this* card failed with, if it has. */
subagentError: String?,
/** Which of this card's subagents are picked out. Empty means selection mode is off here. */
selectedSubagents: Set<String>,
/** Every subagent with a delete in flight, whichever card it is in. */
deletingSubagents: Set<String>,
onToggleSubagents: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
onSelectSubagent: (SubagentSummary) -> Unit,
onDeleteSubagents: (List<SubagentSummary>) -> Unit,
) { ) {
BusyItem(label = if (deleting) "deleting" else null) { BusyItem(label = if (deleting) "deleting" else null) {
Card( Card(
@@ -610,201 +358,9 @@ private fun SessionCard(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
// Nothing at all for a card with no subagents: a disabled expander here would be
// noise on every ordinary session's card. Its own row at the bottom rather than
// beside the title or the machine line, so opening it never displaces text that was
// already on screen -- see UI_RULES on a control not displacing the text beside it.
if (session.subagents > 0) {
Spacer(Modifier.height(8.dp))
// The platform's minimum touch height, not the chevron's own ten or so dp:
// at the chevron's height a tap meant for it landed on the first subcard
// beneath and opened a subagent instead.
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(enabled = !deleting, onClick = onToggleSubagents)
.semantics {
contentDescription =
if (expanded) "Collapse subagents" else "Expand subagents"
},
) {
Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
if (expanded) {
Spacer(Modifier.height(4.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (subagents) {
null,
is LoadState.Loading ->
CircularProgressIndicator(
modifier = Modifier.width(20.dp).height(20.dp),
strokeWidth = 2.dp,
)
is LoadState.Error ->
// Said here rather than left silent: a fetch that failed and an
// expander that simply found nothing must not look the same --
// see UI_RULES on designing the unknown state first.
Text(
subagents.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded -> {
val ordered = subagentOrder(subagents.value)
ordered.forEach { subagent ->
SubagentCard(
subagent,
selected = subagent.id in selectedSubagents,
// Any selection here makes a tap a selection too, so
// the reader is never one mis-tap away from leaving
// the list they were picking rows in.
selecting = selectedSubagents.isNotEmpty(),
deleting = subagent.id in deletingSubagents,
onClick = { onOpenSubagent(subagent) },
onSelect = { onSelectSubagent(subagent) },
)
}
// Beside the rows it acts on rather than at the bottom of the
// screen: this selection belongs to one card, and a bar down
// there would read as acting on whatever the screen last
// showed. See UI_RULES on a control belonging with its thing.
if (selectedSubagents.isNotEmpty()) {
val picked = ordered.filter { it.id in selectedSubagents }
SubagentSelectionBar(
picked = picked,
onDelete = { onDeleteSubagents(picked) },
)
} }
} }
} }
// The server's own words, where the delete was asked for.
subagentError?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
}
}
/**
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
* filled one, so the nesting reads as one step rather than as another session.
*
* Held rather than tapped to pick it out, and tapped to pick it out once anything here is picked --
* the import list's gesture, so selecting is learned once.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SubagentCard(
subagent: SubagentSummary,
selected: Boolean,
/** Whether a selection is already open in this card, which makes a tap a selection. */
selecting: Boolean,
/** Whether this one is being deleted right now -- see [BusyItem]. */
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(
// Off while the delete is in flight: a card that still opens a transcript
// it is deleting is a race the reader can start by tapping. On the card
// rather than in [BusyItem], which leaves gestures alone so the list still
// scrolls.
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 = 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,
)
}
}
}
}
}
/**
* What can be done to the subagents that are selected: delete them, which is the only thing a
* subagent has ever had done to it here.
*
* Delete is **disabled**, not hidden, while anything selected is still running: a subagent's
* transcript is still being written to, and its process is its session's to stop -- so the server
* refuses that batch, and hiding the button would leave a reader holding a selection with no
* controls in it. The reason is in words beside it, because "we won't" and "there is nothing to do"
* are different in kind and no shade of a button says which.
*/
@Composable
private fun SubagentSelectionBar(picked: List<SubagentSummary>, onDelete: () -> Unit) {
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",
// Coloured by consequence while it can act; the button's own disabled colour
// otherwise, so a red word does not promise something that will not happen.
color =
if (running == 0) MaterialTheme.colorScheme.error
else LocalContentColor.current,
)
}
}
}
/**
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped.
*/
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
} }
@Composable @Composable
@@ -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"
}
@@ -81,6 +81,5 @@ class SessionOrderTest {
lastActivity = lastActivity, lastActivity = lastActivity,
started = started, started = started,
backgroundTasks = 0, backgroundTasks = 0,
subagents = 0,
) )
} }