diff --git a/PLAN.md b/PLAN.md index 43ae3c9..83edfea 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/SUBAGENTS.md b/SUBAGENTS.md index 7286e01..a0ec78a 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 5acd67a..c49878e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -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 = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index 81eaaba..b720c61 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -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.Main()) } + var screen by remember { mutableStateOf(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(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,15 +215,47 @@ fun AppRoot( val fileLinkHandler = rememberFileLinkHandler { path -> screen = here.copy(files = here.summary.filesTarget(path)) } - CompositionLocalProvider(LocalFileLinkHandler provides fileLinkHandler) { - SessionScreen( + Box( + Modifier.then( + if (here.subagent != null || here.files != null) + Modifier.clearAndSetSemantics {} + else Modifier + ) + ) { + SubagentPanel( settings = current, summary = here.summary, - onBack = goToMain, - onFiles = { screen = here.copy(files = it) }, - share = share, - onShareTaken = { share = null }, - ) + onOpenSubagent = { screen = here.copy(subagent = it) }, + ) { + CompositionLocalProvider( + LocalFileLinkHandler provides fileLinkHandler + ) { + SessionScreen( + settings = current, + summary = here.summary, + onBack = goToMain, + onFiles = { screen = here.copy(files = it) }, + share = share, + 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. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt index d5e3dd6..a4634b9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt @@ -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 -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 74fc55f..a6a3851 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -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.Loading) } var confirmingDelete by remember { mutableStateOf(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()) } - var subagentLoads by remember { - mutableStateOf(mapOf>>()) - } - - // 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(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>(emptySet()) } - var subagentErrors by remember { mutableStateOf>(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(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) { - 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): List { 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) - -/** - * 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) - -/** - * 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): List = - rows.sortedWith( - compareByDescending { 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>?, - /** 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, - /** Every subagent with a delete in flight, whichever card it is in. */ - deletingSubagents: Set, - onToggleSubagents: () -> Unit, - onOpenSubagent: (SubagentSummary) -> Unit, - onSelectSubagent: (SubagentSummary) -> Unit, - onDeleteSubagents: (List) -> Unit, ) { BusyItem(label = if (deleting) "deleting" else null) { Card( @@ -610,203 +358,11 @@ 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, 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 fun StatusText(status: String) { val label = sessionStatusWord(status) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt new file mode 100644 index 0000000..e5443f2 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt @@ -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.Loading) } + var selected by remember(summary.id) { mutableStateOf(setOf()) } + var deleting by remember(summary.id) { mutableStateOf(setOf()) } + var deleteError by remember(summary.id) { mutableStateOf(null) } + var confirming by remember(summary.id) { mutableStateOf?>(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): List = + rows.sortedWith( + compareByDescending { 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, + 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" + } diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/SessionOrderTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/SessionOrderTest.kt index ad252a8..d2aa050 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/SessionOrderTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/SessionOrderTest.kt @@ -81,6 +81,5 @@ class SessionOrderTest { lastActivity = lastActivity, started = started, backgroundTasks = 0, - subagents = 0, ) }