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

+4 -1
View File
@@ -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`
+46
View File
@@ -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
@@ -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
@@ -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() },
)
}
@@ -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)
@@ -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<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(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.
@@ -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<PanelSide?>(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()
}
}
@@ -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,