Slide the main screen over a session with a right swipe

Switching conversation was a step back to the list and a step down into
another, which disposed the session being left and refetched its whole
transcript over the tunnel on the way back. A right swipe now pulls
MainScreen itself over the open session -- the screen Back would have
shown, moved over the session instead of replacing it -- and swiping it
back off returns to a live stream, an unsent draft and the scroll
position it had. Tapping the session already open is that same swipe
back; tapping another is a screen of its own; deleting the one
underneath closes the screen, since there is nothing left to return to.

One gesture drives both this and the subagent panel (SidePanels.kt, now
the home of the drag and animation SubagentPanel had): two draggables
over the same content cannot share a horizontal drag, so the position is
a single signed reveal, negative left and positive right, which also
makes it impossible to have both open. The panels exist only inside a
session, so nothing on the main screen swipes anywhere.

Full width and no tonal step for this one, because a screen standing in
for another must be the same colour as it; the subagent panel keeps its
88% and its sliver. The list keeps its rows while it asks again -- the
panel refetches on every open, and blanking it each time handed the
reader an empty screen about something never in doubt -- with a bar over
the top while an answer is outstanding.

Where the panel has got to is read from draw lambdas only: 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.

Verified on the emulator against the sandbox with ui-trace: the panel
opens and closes on the two swipes, tapping another session replaces the
screen, deleting the open one leaves for the list, the subagent panel is
unchanged, and neither swipe does anything on the main screen. ktfmt,
compile, lint and the unit tests are clean.
This commit is contained in:
iris-ai committed 2026-09-19 22:15:14 -04:00
1 parent ef788b0405
commit c8bfc958ad
8 files changed
+418 -126

No files matched your search

@@ -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,