diff --git a/AGENTS.md b/AGENTS.md index ea8f894..7ce8573 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,10 @@ Module-by-module intent is in PLAN.md's "Backend layout". state and thread id, and reads subscription limits through the same CLI protocol. - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". - `AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's three tabs + `AppRoot.kt` is the navigation `when`; `SidePanels.kt` the one drag that + slides the whole main screen over a session from the left (`MainPanel.kt`) + and its subagents over it from the right (`SubagentPanel.kt`), both keeping + the session composed underneath; `MainScreen.kt` the root's three tabs (sessions, import, machines); `MachineModels.kt` the models on one machine and the downloads putting them there, drawn inside `ProviderScreen.kt` for a provider that serves files off that machine's disk; `Api.kt`/`EventStream.kt` diff --git a/PLAN.md b/PLAN.md index 4d8d574..61a5d1c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1207,6 +1207,52 @@ 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. +### The main screen is the other panel over a session (2026-09-19) + +**A right swipe pulls the main screen over the open session**, the mirror of +the subagent panel, and for the same reason: switching conversation was a step +back to the list and a step down into another, which disposed the session +being left and refetched its transcript over the tunnel on the way back. The +session under the panel stays composed, so swiping it back off returns to a +live stream, an unsent draft and the scroll position it had. + +**It is `MainScreen` itself and the whole width of the screen** (`MainPanel.kt`), +not a list of its own: what the swipe gets is the screen Back would have got, +moved over the session instead of replacing it — tabs, title, refresh and +settings included. A panel narrower than the screen leaves a sliver saying +what it is over, which is right for the subagents and wrong here, and a +full-width panel takes no tonal step either, since a screen standing in for +another must be the same colour as it. + +**One gesture drives both panels** (`SidePanels.kt`, which is now where the +subagent panel's drag and animation live). Two `draggable` modifiers over the +same content cannot share a horizontal drag — the inner one claims it +whichever way the finger went and the outer never sees a thing — so the +position is a single signed reveal, negative for the left panel and positive +for the right, which also makes it impossible to have both open. A side with +no panel cannot be dragged toward at all, and that is what makes the gesture +absent until a session has been opened: the main panel exists only inside the +session screen, and nothing on the main screen swipes anywhere. + +Tapping the session already open is the same act as swiping the panel back off +— reopening it would hand `SessionScreen` a new summary for the conversation +it is already showing. Tapping any other session is a screen of its own, so +the panel and the session under it go with it. Deleting the session the panel +is drawn over is the one thing the list can do that leaves nothing to swipe +back into, so that closes the screen (`onDeleted`, reported by the list to +whoever is showing it elsewhere). + +**The list keeps its rows while it asks again.** The panel refetches every +time it opens, and blanking the list for each of those handed the reader an +empty screen about something never in doubt; a bar over the top says an answer +is outstanding, and only a first load with nothing to keep shows the spinner. + +Where the panel has got to is read from draw lambdas only, never from the +composable body: it changes every frame of a drag, and a body that reads it +recomposes the session beneath once per frame. Composition sees booleans that +change twice per gesture — the same correction the keyboard inset needed in +`SessionScreen`. + ### HTTP surface **`routes.rs`'s module doc comment is the table.** REST for actions, one SSE 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 a0e69b0..402a559 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -235,10 +235,44 @@ fun AppRoot( else Modifier ) ) { - SubagentPanel( - settings = current, - summary = here.summary, - onOpenSubagent = { screen = here.copy(subagent = it) }, + // The two panels this session can be pulled aside for: its subagents + // from the right, and the whole main screen from the left. Both are here + // rather than screens of their own for the same reason the explorer is -- + // the session under them stays composed. The main panel exists only + // inside a session, which is what makes it unswipeable until one has been + // opened. + SidePanels( + left = { active, close -> + MainPanel( + settings = current, + sessionId = here.summary.id, + active = active, + onOpen = { screen = Screen.Session(it) }, + onSpawn = { screen = Screen.Spawn }, + onImported = { imported -> + reloadToken++ + screen = Screen.Session(imported) + }, + onSettings = { screen = Screen.Settings }, + onProvider = { machineId, provider -> + screen = Screen.ProviderSettings(machineId, provider) + }, + onClose = close, + onGone = goToMain, + ) + }, + // The whole width: it stands in for the screen Back would have shown, + // rather than sitting over the session the way the subagents do. + leftFraction = 1f, + right = { active, close -> + SubagentPanel( + settings = current, + summary = here.summary, + active = active, + onClose = close, + onOpenSubagent = { screen = here.copy(subagent = it) }, + ) + }, ) { CompositionLocalProvider( LocalFileLinkHandler provides fileLinkHandler diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainPanel.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainPanel.kt new file mode 100644 index 0000000..8326744 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainPanel.kt @@ -0,0 +1,53 @@ +package com.example.aiapp + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * The app's root screen, in the full-width panel [SidePanels] slides over a session from the left. + * + * Not a list of its own but [MainScreen] itself, and the whole width of the screen: what a right + * swipe gets is the screen Back would have got, moved over the session instead of replacing it. The + * session stays composed underneath, with its stream open and its draft and scroll position where + * they were, so swiping the panel back off returns to it for nothing -- where Back and a tap costs + * the whole transcript over the tunnel again. + * + * Tapping the session already open is that same swipe back rather than a fresh screen: reopening it + * would hand [SessionScreen] a new summary for the conversation it is already showing. + * + * [onGone] is the one thing the list can do that this panel cannot survive -- deleting the very + * session it is drawn over. There is nothing left to swipe back into, so that closes the screen. + */ +@Composable +fun MainPanel( + settings: ServerSettings, + sessionId: String, + active: Boolean, + onOpen: (SessionSummary) -> Unit, + onSpawn: () -> Unit, + onImported: (SessionSummary) -> Unit, + onSettings: () -> Unit, + onProvider: (String, String) -> Unit, + onClose: () -> Unit, + onGone: () -> Unit, +) { + // Asked again each time the panel opens: who is working and who is waiting on an answer is + // exactly what changed while the session underneath was being read. + var reloadToken by remember(sessionId) { mutableIntStateOf(0) } + LaunchedEffect(active) { if (active) reloadToken++ } + + MainScreen( + settings = settings, + reloadToken = reloadToken, + onOpen = { if (it.id == sessionId) onClose() else onOpen(it) }, + onSpawn = onSpawn, + onImported = onImported, + onSettings = onSettings, + onProvider = onProvider, + onDeleted = { if (it == sessionId) onGone() }, + ) +} 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 fb80665..52623d7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt @@ -57,6 +57,8 @@ fun MainScreen( onSettings: () -> Unit, /** One machine's provider, opened from the machines tab. */ onProvider: (String, String) -> Unit, + /** A session the list has just deleted; see [SessionListScreen]. */ + onDeleted: (String) -> Unit = {}, ) { var tab by remember { mutableStateOf(MainTab.Sessions) } var refreshToken by remember { mutableIntStateOf(0) } @@ -146,6 +148,7 @@ fun MainScreen( reloadToken = token, onOpen = onOpen, onSpawn = onSpawn, + onDeleted = onDeleted, ) MainTab.Import -> ImportScreen(settings = settings, reloadToken = token, onImported = onImported) 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 a6a3851..46a56c5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -48,11 +49,17 @@ fun SessionListScreen( reloadToken: Int, onOpen: (SessionSummary) -> Unit, onSpawn: () -> Unit, + /** A session this list has just deleted, for whoever is showing it elsewhere. */ + onDeleted: (String) -> Unit = {}, ) { val scope = rememberCoroutineScope() var listState by remember { mutableStateOf>>(LoadState.Loading) } var confirmingDelete by remember { mutableStateOf(null) } + // Whether an answer is outstanding, which is a different question from whether there is + // anything to draw: see [refresh]. + var reloading by remember { mutableStateOf(false) } + // 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. @@ -70,7 +77,12 @@ fun SessionListScreen( val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } fun refresh() { - listState = LoadState.Loading + // The rows stay while the answer is on its way, with the bar below saying one is: this + // list is asked again every time the panel over a session is opened, and blanking it each + // time hands the reader an empty screen to report on something that was never in doubt. + // A first load has nothing to keep, and says so with the spinner instead. + if (listState !is LoadState.Loaded) listState = LoadState.Loading + reloading = true scope.launch { listState = try { @@ -88,6 +100,7 @@ fun SessionListScreen( } catch (e: ApiException) { LoadState.failed(e) } + reloading = false } } @@ -130,6 +143,12 @@ fun SessionListScreen( } } + // Over the list rather than above it: a bar that appears in the flow moves every row down + // by its own height at the moment the reader is looking at them. + if (reloading) { + LinearProgressIndicator(Modifier.align(Alignment.TopCenter).fillMaxWidth()) + } + FloatingActionButton( onClick = onSpawn, modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp), @@ -234,6 +253,7 @@ fun SessionListScreen( loaded.value.filterNot { it.id == session.id } ) } + onDeleted(session.id) } catch (e: ApiException) { // Kept, because it is still there: the server refused, so the // session it refused about is exactly as it was. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SidePanels.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SidePanels.kt new file mode 100644 index 0000000..7b18163 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SidePanels.kt @@ -0,0 +1,250 @@ +package com.example.aiapp + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.graphicsLayer +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 androidx.compose.ui.unit.dp +import kotlin.math.absoluteValue +import kotlinx.coroutines.launch + +private const val OPEN_THRESHOLD = 0.35f +private val FLING_THRESHOLD = 400.dp + +/** + * How much of the screen a panel takes by default, leaving a sliver of what it is over. + * + * A panel given the whole width instead is standing in for the screen rather than sitting over it, + * and then the sliver would be a strip of a screen the reader has just left behind. + */ +const val PANEL_FRACTION = 0.88f + +/** How dark the scrim over [SidePanels]' content goes with a panel fully open. */ +private const val SCRIM_ALPHA = 0.32f + +/** + * Which side of the content a panel comes in from: where it sits, and which way it slides out. + * + * [sign] is also the direction of the reveal this side owns, so the drag arithmetic is written once + * rather than once per side with the minus signs moved around. + */ +enum class PanelSide(val alignment: Alignment, val sign: Float) { + Left(Alignment.CenterStart, -1f), + Right(Alignment.CenterEnd, 1f), +} + +/** + * Keeps [content] composed while a panel belonging to it moves over from the left or the right. + * + * One gesture drives both sides rather than one handler each, because two `draggable`s over the + * same content cannot share a horizontal drag: the inner one claims it whichever way the finger + * went, and the outer never sees a thing. So the position is a single signed reveal -- negative is + * the left panel showing, positive the right -- which also makes it impossible to have both open. + * + * A side left null has no panel and no gesture toward it -- the reveal cannot travel that way at + * all -- so one composable serves a screen with one panel and a screen with two. + * + * 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 SidePanels( + left: (@Composable (active: Boolean, close: () -> Unit) -> Unit)? = null, + leftFraction: Float = PANEL_FRACTION, + right: (@Composable (active: Boolean, close: () -> Unit) -> Unit)? = null, + rightFraction: Float = PANEL_FRACTION, + content: @Composable () -> Unit, +) { + val scope = rememberCoroutineScope() + // Which panel the gesture settled on, null for neither. The *settled* side rather than the + // current position, so a panel's contents know they are being looked at while the animation + // is still running. + var opened by remember { mutableStateOf(null) } + var dragging by remember { mutableStateOf(false) } + var draggedReveal by remember { mutableFloatStateOf(0f) } + val animatedReveal = remember { Animatable(0f) } + // Read from a draw or layout lambda, never from the composable body: where the panel has got + // to changes every frame of a drag, and a body that reads it recomposes this whole subtree -- + // the session included -- once per frame. The booleans below are what composition is allowed + // to know, and each of them changes twice per gesture. (Same rule as the keyboard inset in + // SessionScreen, and found the same way.) + fun revealNow() = if (dragging) draggedReveal else animatedReveal.value + val leftShown by remember { derivedStateOf { revealNow() < 0f } } + val rightShown by remember { derivedStateOf { revealNow() > 0f } } + val engaged = leftShown || rightShown + val flingThreshold = with(LocalDensity.current) { FLING_THRESHOLD.toPx() } + + suspend fun startDrag() { + animatedReveal.stop() + draggedReveal = animatedReveal.value + dragging = true + } + + // Which panel the reveal belongs to, [bias] breaking the tie at rest -- a drag away from + // nothing is toward whichever panel that direction opens. + fun sideOf(bias: Float): PanelSide? = + when { + draggedReveal < 0f -> PanelSide.Left + draggedReveal > 0f -> PanelSide.Right + bias > 0f -> PanelSide.Left + bias < 0f -> PanelSide.Right + else -> null + } + + suspend fun finishDrag(velocity: Float) { + val side = sideOf(0f) + // How fast the finger is moving toward that side's open position: the left panel opens + // rightwards and the right panel leftwards, so the sign of a velocity only means something + // once it is read against the side. A fling decides on its own; anything slower is decided + // by how far in the panel already is. + val toward = side?.let { -it.sign * velocity } ?: 0f + val opens = + if (toward.absoluteValue > flingThreshold) toward > 0f + else draggedReveal.absoluteValue >= OPEN_THRESHOLD + val target = side.takeIf { opens } + opened = target + animatedReveal.snapTo(draggedReveal) + dragging = false + animatedReveal.animateTo(target?.sign ?: 0f) + } + + fun close() { + opened = null + scope.launch { animatedReveal.animateTo(0f) } + } + + BackHandler(enabled = opened != null) { close() } + + BoxWithConstraints(Modifier.fillMaxSize()) { + val dragState = rememberDraggableState { delta -> + // Against the width of the panel this drag is moving, since the reveal is a fraction + // of it and the two sides need not be the same width. + val width = + sideOf(delta)?.let { + constraints.maxWidth * if (it == PanelSide.Left) leftFraction else rightFraction + } ?: return@rememberDraggableState + draggedReveal = + (draggedReveal - delta / width.coerceAtLeast(1f)).coerceIn( + if (left == null) 0f else -1f, + if (right == null) 0f else 1f, + ) + } + val drag = + Modifier.draggable( + state = dragState, + orientation = Orientation.Horizontal, + onDragStarted = { startDrag() }, + onDragStopped = { velocity -> finishDrag(velocity) }, + ) + + Box( + Modifier.fillMaxSize() + .then(drag) + .then(if (engaged) Modifier.clearAndSetSemantics {} else Modifier) + ) { + content() + } + + if (engaged) { + Box( + Modifier.fillMaxSize() + .graphicsLayer { alpha = revealNow().absoluteValue * SCRIM_ALPHA } + .background(MaterialTheme.colorScheme.scrim) + .semantics { contentDescription = "Dismiss panel" } + .clickable { close() } + ) + } + + // Both panels stay composed while they are off screen, so opening one costs no + // composition -- but an off-screen panel is cleared from the semantics tree, since nothing + // a reader cannot see should be reachable by swiping through the screen. + left?.let { panel -> + SlidingPanel( + side = PanelSide.Left, + width = maxWidth * leftFraction, + raised = leftFraction < 1f, + shown = { (-revealNow()).coerceAtLeast(0f) }, + visible = leftShown, + drag = drag, + ) { + panel(opened == PanelSide.Left, ::close) + } + } + right?.let { panel -> + SlidingPanel( + side = PanelSide.Right, + width = maxWidth * rightFraction, + raised = rightFraction < 1f, + shown = { revealNow().coerceAtLeast(0f) }, + visible = rightShown, + drag = drag, + ) { + panel(opened == PanelSide.Right, ::close) + } + } + } +} + +/** + * One panel at [shown] of the way in, sliding out to its own [side]. + * + * [raised] is for a panel with some of the screen still beside it, which takes a tonal step to say + * it is above what it has not covered. A panel covering the whole width has nothing to be above, + * and a step there is a screen that is simply the wrong colour. + * + * [visible] says the same thing as `shown() > 0f` and is the form composition may read; see + * [SidePanels]. + */ +@Composable +private fun BoxScope.SlidingPanel( + side: PanelSide, + width: Dp, + raised: Boolean, + shown: () -> Float, + visible: Boolean, + drag: Modifier, + contents: @Composable () -> Unit, +) { + Surface( + tonalElevation = if (raised) 3.dp else 0.dp, + shadowElevation = 8.dp, + modifier = + Modifier.align(side.alignment) + .width(width) + .fillMaxHeight() + .graphicsLayer { translationX = side.sign * size.width * (1f - shown()) } + .then(if (visible) Modifier else Modifier.clearAndSetSemantics {}) + .then(drag), + ) { + contents() + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt index 035a2dd..0abdddd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SubagentPanel.kt @@ -1,21 +1,12 @@ package com.example.aiapp import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.Animatable 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 @@ -29,13 +20,11 @@ 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 @@ -43,126 +32,20 @@ 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. + * A session's subagents, listed in the panel [SidePanels] slides over it 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. + * [active] is whether the panel is being looked at: the list is fetched then rather than on + * composition, since the panel is composed for every session whether or not anybody opens it. */ @Composable fun SubagentPanel( - settings: ServerSettings, - summary: SessionSummary, - onOpenSubagent: (SubagentSummary) -> Unit, - content: @Composable () -> Unit, -) { - val scope = rememberCoroutineScope() - 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 = remember(summary.id) { Animatable(0f) } - val reveal = if (dragging) draggedReveal else animatedReveal.value - val flingThreshold = with(LocalDensity.current) { FLING_THRESHOLD.toPx() } - - suspend fun startDrag() { - animatedReveal.stop() - draggedReveal = animatedReveal.value - dragging = true - } - - suspend fun finishDrag(velocity: Float) { - val targetOpen = - when { - velocity < -flingThreshold -> true - velocity > flingThreshold -> false - else -> draggedReveal >= OPEN_THRESHOLD - } - open = targetOpen - animatedReveal.snapTo(draggedReveal) - dragging = false - animatedReveal.animateTo(if (targetOpen) 1f else 0f) - } - - fun setOpen(targetOpen: Boolean) { - open = targetOpen - scope.launch { animatedReveal.animateTo(if (targetOpen) 1f else 0f) } - } - - BackHandler(enabled = open) { setOpen(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 { setOpen(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 = { setOpen(false) }, - onOpenSubagent = onOpenSubagent, - ) - } - } -} - -@Composable -private fun SubagentPanelContents( settings: ServerSettings, summary: SessionSummary, active: Boolean,