Move subagents into session side panel
This commit is contained in:
1 parent
cd0229bed6
commit
0a2f0eed5f
8 files changed
+491
-530
No files matched your search
@@ -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
|
||||
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
|
||||
|
||||
**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE
|
||||
|
||||
+22
-20
@@ -1,8 +1,8 @@
|
||||
# Subagents
|
||||
|
||||
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
|
||||
session's card and readable in the same transcript view the session has.
|
||||
collaboration tools -- each get a transcript of their own, listed in a panel
|
||||
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
|
||||
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
|
||||
below one that just finished.
|
||||
- **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
|
||||
rather than at the bottom of the screen: this selection belongs to one card,
|
||||
and a bar down there would read as acting on the whole list. One card at a
|
||||
time -- picking a row in another card moves the selection rather than adding
|
||||
to it, since one Delete is one request against one parent. Delete is
|
||||
import list works, with the selection bar drawn inside the panel rather than
|
||||
at the bottom of the session: this selection belongs to the subagent list,
|
||||
and a bar under the composer would read as acting on the conversation.
|
||||
Delete is
|
||||
*disabled*, with the reason in words, while anything selected is still
|
||||
running. Deleting confirms first, dims the rows it is acting on
|
||||
(`BusyItem`), and on success takes them out of that card and off the
|
||||
session's count without refetching anything else. The phone's cached copy of
|
||||
(`BusyItem`), and on success takes them out of the panel without refetching
|
||||
anything else. The phone's cached copy of
|
||||
a deleted subagent's transcript is purged with it.
|
||||
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
|
||||
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
|
||||
`Pointing.Up` -- collapsed by default. Expanding fetches
|
||||
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
|
||||
indented inside the session card, the way dev-updater draws a project's
|
||||
components: title, then the status word and a relative time. The
|
||||
expansion state is per session id and survives a refresh of the list.
|
||||
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
|
||||
**read-only** form: the same transcript, paging, cache, selection,
|
||||
- The main session list does not expand or count subagents. Swiping left over
|
||||
an open session pulls an 88%-wide panel in from the right and fetches
|
||||
`/sessions/{id}/subagents`; it draws one `OutlinedCard` per subagent: title,
|
||||
then the status word and a relative time. The transcript remains composed
|
||||
under the panel, so its event stream, draft and scroll position stay live.
|
||||
Horizontal scrollers inside the transcript win the gesture. Collapsing one,
|
||||
or starting over any ordinary part of the session, gives the gesture back to
|
||||
the panel; Android keeps its own edge Back gesture. Swiping right on the
|
||||
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
|
||||
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
|
||||
it. Back returns to the list.
|
||||
The header shows the subagent's title with the session's title beneath it.
|
||||
It is another layer over the still-composed session and its panel; Back
|
||||
returns to the panel.
|
||||
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
|
||||
cache take a transcript address rather than a session id --
|
||||
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
|
||||
|
||||
@@ -236,14 +236,6 @@ data class SessionSummary(
|
||||
val started: Double,
|
||||
/** Latest measured number of live background tasks; zero also covers older servers. */
|
||||
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) =
|
||||
@@ -276,7 +268,6 @@ private fun parseSession(session: JSONObject) =
|
||||
lastActivity = session.getDouble("lastActivity"),
|
||||
started = session.optDouble("started", session.getDouble("lastActivity")),
|
||||
backgroundTasks = session.optInt("backgroundTasks", 0),
|
||||
subagents = session.optInt("subagents", 0),
|
||||
)
|
||||
|
||||
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.wgapplink.localNetworkAllowed
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -37,33 +38,21 @@ import kotlinx.coroutines.withContext
|
||||
* one session, spawning one, and settings.
|
||||
*/
|
||||
private sealed class 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()
|
||||
data object Main : Screen()
|
||||
|
||||
/**
|
||||
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
|
||||
* 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.
|
||||
* One session, with the file explorer or a subagent transcript over it when set.
|
||||
*
|
||||
* The explorer is a layer on this screen rather than a screen of its own, so the session under
|
||||
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
|
||||
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
|
||||
* created on every return, refetching the transcript over the tunnel.
|
||||
* Both are layers on this screen rather than screens of their own, so the session under them
|
||||
* stays composed: its event stream keeps flowing, its scroll position and draft stay put, and
|
||||
* coming back costs nothing. As sibling `Screen`s they would dispose and recreate it on every
|
||||
* 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()
|
||||
|
||||
@@ -100,7 +89,7 @@ fun AppRoot(
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
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
|
||||
// after one succeeds, since success is a screen rather than a message.
|
||||
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
||||
@@ -115,7 +104,7 @@ fun AppRoot(
|
||||
share = shareRequest
|
||||
// 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.
|
||||
if (screen !is Screen.Session) screen = Screen.Main()
|
||||
if (screen !is Screen.Session) screen = Screen.Main
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +131,7 @@ fun AppRoot(
|
||||
existing = null,
|
||||
onSaved = { saved ->
|
||||
settings = saved
|
||||
screen = Screen.Main()
|
||||
screen = Screen.Main
|
||||
},
|
||||
onBack = null,
|
||||
)
|
||||
@@ -155,7 +144,7 @@ fun AppRoot(
|
||||
// shows, so it always refetches.
|
||||
val goToMain = {
|
||||
reloadToken++
|
||||
screen = Screen.Main()
|
||||
screen = Screen.Main
|
||||
}
|
||||
if (screen !is Screen.Main) {
|
||||
BackHandler(onBack = goToMain)
|
||||
@@ -197,16 +186,13 @@ fun AppRoot(
|
||||
// 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.
|
||||
when (val here = screen) {
|
||||
is Screen.Main ->
|
||||
Screen.Main ->
|
||||
Box(Modifier.imePadding()) {
|
||||
MainScreen(
|
||||
settings = current,
|
||||
reloadToken = reloadToken,
|
||||
share = share,
|
||||
onOpen = { screen = Screen.Session(it) },
|
||||
onOpenSubagent = { summary, subagent ->
|
||||
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
|
||||
},
|
||||
onSpawn = { screen = Screen.Spawn },
|
||||
onImported = { imported ->
|
||||
reloadToken++
|
||||
@@ -214,27 +200,6 @@ fun AppRoot(
|
||||
},
|
||||
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 ->
|
||||
// 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 ->
|
||||
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(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
@@ -260,6 +239,24 @@ fun AppRoot(
|
||||
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
|
||||
// platform asks first, and it steps back inside itself before closing.
|
||||
here.files?.let { target ->
|
||||
|
||||
@@ -48,8 +48,6 @@ fun MainScreen(
|
||||
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
|
||||
share: ShareRequest? = null,
|
||||
onOpen: (SessionSummary) -> Unit,
|
||||
/** Opens one session's subagent, from the expander under its card. */
|
||||
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
|
||||
onSpawn: () -> Unit,
|
||||
onImported: (SessionSummary) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
@@ -141,7 +139,6 @@ fun MainScreen(
|
||||
settings = settings,
|
||||
reloadToken = token,
|
||||
onOpen = onOpen,
|
||||
onOpenSubagent = onOpenSubagent,
|
||||
onSpawn = onSpawn,
|
||||
)
|
||||
MainTab.Import ->
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.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.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -37,8 +30,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -56,67 +47,12 @@ fun SessionListScreen(
|
||||
settings: ServerSettings,
|
||||
reloadToken: Int,
|
||||
onOpen: (SessionSummary) -> Unit,
|
||||
/** Opens one session's subagent, from the expander under its card. */
|
||||
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
|
||||
onSpawn: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
||||
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
|
||||
// own card. The two scopes are decided by whether the server answered: it answered and refused,
|
||||
// so this says nothing about the other rows.
|
||||
@@ -133,73 +69,6 @@ fun SessionListScreen(
|
||||
val context = LocalContext.current
|
||||
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() {
|
||||
listState = LoadState.Loading
|
||||
scope.launch {
|
||||
@@ -215,14 +84,6 @@ fun SessionListScreen(
|
||||
withContext(Dispatchers.IO) {
|
||||
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
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
@@ -232,11 +93,6 @@ fun SessionListScreen(
|
||||
|
||||
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()) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
when (val state = listState) {
|
||||
@@ -266,33 +122,6 @@ fun SessionListScreen(
|
||||
deleting = session.id in deleting,
|
||||
onOpen = { onOpen(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))
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@Composable
|
||||
private fun SessionCard(
|
||||
@@ -532,20 +294,6 @@ private fun SessionCard(
|
||||
deleting: Boolean,
|
||||
onOpen: () -> 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) {
|
||||
Card(
|
||||
@@ -610,201 +358,9 @@ private fun SessionCard(
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
started = started,
|
||||
backgroundTasks = 0,
|
||||
subagents = 0,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user