Let the reader put the session list in its own order
Nothing sorts the sessions tab any more. The order is the server's `sessions` list, which is the reader's arrangement: holding a row puts the screen in selection mode -- the same gesture and the same bottom bar as the import tab -- and each card grows a burger handle at its right edge that drags the row to a new place, with a tick of haptic feedback for each one it passes. The two attempts this replaces, sorting by activity and then by when each agent was turned on, were both looking for an order a session could not move itself out of; no rule computed from what a session is doing can be one. `POST /sessions/order` rewrites the config's order, so it is the same on every device and survives a backend restart, and `SessionConfig::started` goes with the sort that needed it. Rearranging is independent of the selection: the handle moves the row it is on, picked out or not. The click moved off the card and onto its contents so that a press landing on the handle cannot also select the row it is about to move. Selection's one action is Delete, which now takes the whole set. Two traps in `Reorder.kt`, both measured on the emulator and written down in `this-machine-android`: a crossing is decided from how far the finger has travelled, because a lazy list animates an item into its new place and its `offset` reports the old one for several frames; and the viewport is pinned with `requestScrollToItem` around each move, because a lazy list keeps its place by the key of the top item and would otherwise follow the row being dragged. Verified on the emulator against the sandbox: the order survives an app restart and a backend read-back, a two-row drag moves exactly two rows, a drag to the bottom edge scrolls the list and lands the row last, pressing the handle without moving changes nothing, and deleting two selected sessions leaves the rest in place.
This commit is contained in:
1 parent
942edd6b31
commit
b7fd18b195
12 files changed
+756
-294
No files matched your search
@@ -141,7 +141,10 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
||||
and what it has running beside the turn -- its background tasks over its
|
||||
subagents (`BackgroundTasks.kt`, `SubagentPanel.kt`) -- from the right, both keeping
|
||||
the session composed underneath; `MainScreen.kt` the root's three tabs
|
||||
(sessions, import, machines); `MachineModels.kt` the models on one machine
|
||||
(sessions, import, machines); `Reorder.kt` the drag that moves a row of a
|
||||
lazy list, used by the session list's handles -- **the order of that list is
|
||||
the reader's own and nothing sorts it** (`POST /sessions/order`);
|
||||
`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`
|
||||
the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||||
|
||||
@@ -1428,22 +1428,34 @@ Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as
|
||||
dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
|
||||
|
||||
1. **Session list** — kind icon, title, machine, model, status, last activity.
|
||||
**A session with a process stays where it is, and the order is when each
|
||||
agent was turned on** (2026-09-15, replacing the awaiting-answer inbox
|
||||
sort): the running sessions come first, oldest start first, so one that is
|
||||
started joins the bottom of that group and nothing it goes on to do —
|
||||
beginning a turn, finishing one, asking a question — can move it. A list
|
||||
that reorders itself is one nobody can keep their place in, and the status
|
||||
word and its colour already say which session wants an answer without the
|
||||
row having to move to say it. Stopped sessions are a group below, most
|
||||
recently active first; "turned on" is what the order above is made of and a
|
||||
session with no process has no place in it.
|
||||
The order is the server's (`SessionConfig::started`, reported as
|
||||
`started`), written each time a process is started for the session, so it
|
||||
is the same on every device and survives a backend restart — which adopts
|
||||
processes rather than starting them, and so could not work the times out
|
||||
for itself. Applied on the phone (`sessionsInListOrder`), because
|
||||
presentation order is a display decision.
|
||||
**Nothing sorts it: the order is the reader's own** (2026-09-20,
|
||||
superseding first the awaiting-answer inbox sort and then ordering by when
|
||||
each agent was turned on). Both of those were attempts to find an order a
|
||||
session could not move itself out of, and the answer is that no rule
|
||||
computed from what a session is doing can be one — the reader is the only
|
||||
thing that knows which conversation matters. A list that rearranges itself
|
||||
is one nobody can keep their place in, and the status word and its colour
|
||||
already say which session wants an answer without the row having to move to
|
||||
say it.
|
||||
The order is the server's `sessions` list, rewritten by
|
||||
`POST /sessions/order`, so it is the same on every device and survives a
|
||||
backend restart, and a spawn appends rather than displacing anything.
|
||||
`SessionConfig::started` and the `started` field went with the sort that
|
||||
needed them.
|
||||
**Rearranging is a drag on a handle, in selection mode** — hold a row to
|
||||
enter it, the same gesture and the same bottom bar as the import tab, and
|
||||
each card grows a burger handle at its right edge. The handle rather than
|
||||
the row, because a row that can be dragged is a row that sometimes eats a
|
||||
fling; and the click is on the card's contents rather than on the card, so
|
||||
a press that lands on the handle cannot also select the row it is about to
|
||||
move. The move is applied on the phone as the finger crosses each row, with
|
||||
a tick of haptic feedback for each, and the whole order is sent when the
|
||||
finger comes up: a server asked per crossing would be answering about an
|
||||
arrangement that had already changed. A refused save puts the server's own
|
||||
order back rather than leaving the screen showing one nothing kept.
|
||||
Rearranging is deliberately independent of what is selected — the handle
|
||||
moves the row it is on — because "which rows am I acting on" and "where
|
||||
does this one go" are two questions.
|
||||
2. **Import** — Claude Code sessions the machine already has, selected in
|
||||
batches (hold to enter, tap to add), with Delete and Import along the
|
||||
bottom. Submitting clears the selection immediately and marks every chosen
|
||||
|
||||
@@ -232,15 +232,6 @@ data class SessionSummary(
|
||||
val usageProvider: String?,
|
||||
val status: String,
|
||||
val lastActivity: Double,
|
||||
/**
|
||||
* Epoch seconds a process was last started for this session -- when this agent was last turned
|
||||
* on. What the list orders the running sessions by; see [sessionsInListOrder].
|
||||
*
|
||||
* The server's own, so the order is the same on every device and across a backend restart. An
|
||||
* older server does not send it, and those sessions fall back to when the session was last
|
||||
* active, which is the best this app can do without inventing a time.
|
||||
*/
|
||||
val started: Double,
|
||||
/** Latest measured number of live background tasks; zero also covers older servers. */
|
||||
val backgroundTasks: Int,
|
||||
)
|
||||
@@ -275,7 +266,6 @@ private fun parseSession(session: JSONObject) =
|
||||
usageProvider = session.optString("usageProvider").ifEmpty { null },
|
||||
status = session.getString("status"),
|
||||
lastActivity = session.getDouble("lastActivity"),
|
||||
started = session.optDouble("started", session.getDouble("lastActivity")),
|
||||
backgroundTasks = session.optInt("backgroundTasks", 0),
|
||||
)
|
||||
|
||||
@@ -1279,6 +1269,23 @@ fun renameSession(settings: ServerSettings, sessionId: String, title: String) {
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the session list in the order given, ids from top to bottom.
|
||||
*
|
||||
* Held by the server rather than by this phone because the arrangement is a fact about the
|
||||
* sessions, not about the device that dragged them: a second phone opening the tab shows the same
|
||||
* list. See the server's `reorder_sessions` for what it does with a list that changed underneath --
|
||||
* nothing here has to be checked first.
|
||||
*/
|
||||
fun reorderSessions(settings: ServerSettings, sessionIds: List<String>) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/sessions/order",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("sessions", JSONArray(sessionIds)).toString(),
|
||||
) {}
|
||||
}
|
||||
|
||||
/** Switches a running session's model; the CLI changes it in place. */
|
||||
fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) {
|
||||
requestFromServer(
|
||||
|
||||
@@ -28,7 +28,7 @@ import androidx.compose.ui.unit.sp
|
||||
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
|
||||
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
|
||||
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
|
||||
* is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
|
||||
* is `app/build-icon-font.sh`'s output -- eighteen glyphs, 2.9 KB, subset out of the 3 MB symbols
|
||||
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
|
||||
* the script did not subset is a glyph that silently isn't there.
|
||||
*
|
||||
@@ -136,6 +136,16 @@ val EDIT_GLYPH = glyph(0xF03EB)
|
||||
*/
|
||||
val SAVE_GLYPH = glyph(0xF0193)
|
||||
|
||||
/**
|
||||
* `md-menu` -- the burger: three stacked rules, drawn as the handle a row is dragged by.
|
||||
*
|
||||
* The mark for "take hold of this and move it" rather than for a menu, which is what it means on a
|
||||
* row that has one: three rules look like the rows of a list, and the only thing here that draws
|
||||
* them is a list being rearranged. Nothing else in this app opens a menu from a burger, so the two
|
||||
* senses cannot be confused.
|
||||
*/
|
||||
val DRAG_GLYPH = glyph(0xF035C)
|
||||
|
||||
/**
|
||||
* The size an icon draws at beside a line of text.
|
||||
*
|
||||
@@ -165,7 +175,7 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
|
||||
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
|
||||
* text, which is what lets the button fill a header row rather than sit in the middle of one.
|
||||
*/
|
||||
private val GLYPH_BUTTON_SIZE = 48.dp
|
||||
val GLYPH_BUTTON_SIZE = 48.dp
|
||||
|
||||
/**
|
||||
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
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.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Dragging a row of a [androidx.compose.foundation.lazy.LazyColumn] into a different place in it.
|
||||
*
|
||||
* Generic rather than the session list's own, because "hold this and move it" is one gesture
|
||||
* wherever it appears and the arithmetic below is the whole of it. The list itself is left alone:
|
||||
* this reports a move and the caller decides what a move means -- it is the caller that holds the
|
||||
* rows and the caller that tells a server about the new order.
|
||||
*
|
||||
* The drag is on a [ReorderHandle] rather than on the row, which is what keeps it out of the way of
|
||||
* the scroll. A whole row that can be dragged sideways-ish is a row that sometimes eats a fling,
|
||||
* and a list is scrolled far more often than it is rearranged.
|
||||
*/
|
||||
class Reorder
|
||||
internal constructor(
|
||||
private val listState: LazyListState,
|
||||
private val scope: CoroutineScope,
|
||||
private val haptics: HapticFeedback,
|
||||
/** What the [EDGE] band is in pixels here; a band in raw pixels is one screen's answer. */
|
||||
private val density: Density,
|
||||
/**
|
||||
* The caller's own lists are what move; these are [State] so that the gesture, which outlives a
|
||||
* recomposition, is never holding the first composition's copy of them.
|
||||
*/
|
||||
private val onMove: State<(from: Int, to: Int) -> Unit>,
|
||||
private val onSettled: State<() -> Unit>,
|
||||
) {
|
||||
/** The key of the row in hand, or null when nothing is being dragged. */
|
||||
var held by mutableStateOf<Any?>(null)
|
||||
private set
|
||||
|
||||
/** Where the list had laid the row out when it was taken hold of, in viewport pixels. */
|
||||
private var grabbedAt = 0
|
||||
|
||||
/** How far the finger has moved since, which is what the row is drawn following. */
|
||||
private var dragged by mutableFloatStateOf(0f)
|
||||
|
||||
/** How far the list has scrolled under it since -- see [follow]. */
|
||||
private var scrolled = 0f
|
||||
|
||||
/** The index the row has been moved to so far, which is what the next move counts from. */
|
||||
private var at = 0
|
||||
|
||||
/** Where it started, so that a handle merely pressed is not reported as a rearrangement. */
|
||||
private var from = 0
|
||||
|
||||
/**
|
||||
* How much of the travel below the moves so far have accounted for.
|
||||
*
|
||||
* The travel is what decides a crossing, rather than where the row is drawn *now*: a lazy list
|
||||
* animates an item into its new place, so for a few frames after a move `offset` still reports
|
||||
* roughly the old one. Deciding from that offset re-decided the same crossing on every frame
|
||||
* until the animation caught up, and a drag of two rows arrived six rows down.
|
||||
*/
|
||||
private var settled = 0f
|
||||
|
||||
private fun info(key: Any): LazyListItemInfo? =
|
||||
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key }
|
||||
|
||||
private fun itemAt(index: Int): LazyListItemInfo? =
|
||||
listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
|
||||
|
||||
/**
|
||||
* How far from where the list laid it out this row should be drawn -- zero for every row but
|
||||
* the one in hand.
|
||||
*
|
||||
* Measured against where the row is laid out *now* rather than accumulated, which is what makes
|
||||
* it self-correcting: a move, or a scroll under the finger, puts the row somewhere new, and the
|
||||
* same subtraction cancels that out so the row stays under the finger instead of jumping by its
|
||||
* own height.
|
||||
*/
|
||||
fun offsetOf(key: Any): Float {
|
||||
if (key != held) return 0f
|
||||
val now = info(key) ?: return 0f
|
||||
return grabbedAt + dragged - now.offset
|
||||
}
|
||||
|
||||
internal fun grab(key: Any) {
|
||||
val from = info(key) ?: return
|
||||
held = key
|
||||
grabbedAt = from.offset
|
||||
at = from.index
|
||||
this.from = from.index
|
||||
dragged = 0f
|
||||
scrolled = 0f
|
||||
settled = 0f
|
||||
// The platform's "you have picked this up", the same feedback a long press gives, because
|
||||
// the gesture it confirms is the same kind of commitment.
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
internal fun drag(by: Float) {
|
||||
if (held == null) return
|
||||
dragged += by
|
||||
cross()
|
||||
}
|
||||
|
||||
/**
|
||||
* Trades places with as many neighbours as the travel so far has earned.
|
||||
*
|
||||
* Half a neighbour's height each way, so the row changes place when it covers most of the one
|
||||
* it is passing -- and a full height of hysteresis before it can come back, since the move has
|
||||
* already paid that half in the other direction. A loop rather than one step: a fast drag, or a
|
||||
* list scrolling under a parked finger, crosses several rows between two events.
|
||||
*/
|
||||
private fun cross() {
|
||||
while (true) {
|
||||
val slack = dragged + scrolled - settled
|
||||
val next = itemAt(if (slack > 0) at + 1 else at - 1) ?: return
|
||||
if (abs(slack) < next.size / 2f) return
|
||||
// Where the list is looking, taken before the move and put back after it. A lazy list
|
||||
// keeps its place by the *key* of the item at the top, so moving that item takes the
|
||||
// viewport with it -- drag the top row down two places and the list scrolls two rows
|
||||
// to follow it, which reads as the row never having moved. The correction is by index,
|
||||
// which is the thing that did not change.
|
||||
val anchor = listState.firstVisibleItemIndex
|
||||
val within = listState.firstVisibleItemScrollOffset
|
||||
onMove.value(at, next.index)
|
||||
// Requested rather than scrolled to: this has to take effect in the *same* measurement
|
||||
// as the move, and a scroll launched beside it lands before the list has taken the new
|
||||
// order and is then undone by it.
|
||||
listState.requestScrollToItem(anchor, within)
|
||||
settled += if (slack > 0) next.size.toFloat() else -next.size.toFloat()
|
||||
at = next.index
|
||||
// Loud on purpose: the row is under a finger that is covering it, so the tick is how
|
||||
// the reader knows a place was taken rather than that they are still between two.
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun release() {
|
||||
// Only where the row actually went somewhere: a handle pressed and let go has rearranged
|
||||
// nothing, and reporting one would have the server rewrite the order it already has.
|
||||
val moved = held != null && at != from
|
||||
held = null
|
||||
dragged = 0f
|
||||
scrolled = 0f
|
||||
settled = 0f
|
||||
if (moved) onSettled.value()
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the list while the row in hand is held against one end of it, so a row can be moved
|
||||
* further than one screenful. A frame loop rather than a response to the drag, because a finger
|
||||
* parked at the bottom edge sends no more events and is exactly the case this exists for.
|
||||
*/
|
||||
internal fun follow() {
|
||||
val key = held ?: return
|
||||
scope.launch {
|
||||
while (held == key) {
|
||||
withFrameNanos {}
|
||||
val moving = info(key) ?: continue
|
||||
val viewport = listState.layoutInfo.viewportEndOffset
|
||||
val edge = with(density) { EDGE.toPx() }
|
||||
val top = grabbedAt + dragged
|
||||
val bottom = top + moving.size
|
||||
val step =
|
||||
when {
|
||||
top < edge -> -(edge - top).coerceAtMost(edge)
|
||||
bottom > viewport - edge -> (bottom - (viewport - edge)).coerceAtMost(edge)
|
||||
else -> 0f
|
||||
}
|
||||
if (step == 0f) continue
|
||||
// Counted as travel of its own: the finger has not moved, but the rows have moved
|
||||
// under it, which is the same thing to everything above. Nothing is added to the
|
||||
// drag, because where the row is *drawn* is measured against the list's own
|
||||
// offsets and those have already moved.
|
||||
scrolled += listState.scrollBy(step * SPEED)
|
||||
cross()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** How close to an end of the list a held row has to be before the list follows it. */
|
||||
val EDGE = 36.dp
|
||||
|
||||
/** A fraction of the overshoot per frame, so the scroll eases in rather than lurching. */
|
||||
const val SPEED = 0.12f
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberReorder(
|
||||
listState: LazyListState,
|
||||
/** Two indices into the lazy list, which is the caller's own order to rearrange. */
|
||||
onMove: (from: Int, to: Int) -> Unit,
|
||||
/** The drag is over: the order on screen is the one to keep. */
|
||||
onSettled: () -> Unit,
|
||||
): Reorder {
|
||||
val move = rememberUpdatedState(onMove)
|
||||
val settled = rememberUpdatedState(onSettled)
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
return remember(listState) { Reorder(listState, scope, haptics, density, move, settled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle a row is dragged by: the burger, at about the size of a heading.
|
||||
*
|
||||
* Bigger than an icon beside a line of text -- this is what a row is taken hold of by, and at
|
||||
* [GLYPH_SIZE] it read as decoration on the end of the row. Not as big as the row either: a mark
|
||||
* scaled to the card's whole inner height came out heavier than anything else on screen, since
|
||||
* these rules thicken with the glyph.
|
||||
*
|
||||
* The touch square around it is [GLYPH_BUTTON_SIZE], the same as every other icon control here, so
|
||||
* the mark and the area that answers to a finger are two different sizes -- which is why the caller
|
||||
* subtracts [HANDLE_MARGIN] from the gap it wants: what has to line up with the text on the other
|
||||
* side is the mark, not the box around it.
|
||||
*
|
||||
* [key] is the row's own key in the list, which is how a gesture that started here finds the row it
|
||||
* belongs to -- an index would be stale the moment the first move landed.
|
||||
*/
|
||||
@Composable
|
||||
fun ReorderHandle(state: Reorder, key: Any, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
modifier
|
||||
.size(GLYPH_BUTTON_SIZE)
|
||||
// Nothing here draws a word, and a handle is the kind of control somebody using a
|
||||
// screen reader has no other way to find.
|
||||
.semantics { contentDescription = "Drag to reorder" }
|
||||
.pointerInput(key) {
|
||||
detectDragGestures(
|
||||
onDragStart = {
|
||||
state.grab(key)
|
||||
state.follow()
|
||||
},
|
||||
onDrag = { _, amount -> state.drag(amount.y) },
|
||||
onDragEnd = { state.release() },
|
||||
onDragCancel = { state.release() },
|
||||
)
|
||||
},
|
||||
) {
|
||||
Glyph(DRAG_GLYPH, colour = MaterialTheme.colorScheme.onSurfaceVariant, size = HANDLE_MARK)
|
||||
}
|
||||
}
|
||||
|
||||
/** How big the mark itself is: a heading's size, which is what the font is asked for in `sp`. */
|
||||
private val HANDLE_MARK = 24.sp
|
||||
|
||||
/**
|
||||
* How much of the touch square lies outside the mark on each side.
|
||||
*
|
||||
* A caller that wants the *mark* a given distance from something takes this off that distance --
|
||||
* see the rule about aligning the mark rather than the box it is centred in.
|
||||
*/
|
||||
val HANDLE_MARGIN = (GLYPH_BUTTON_SIZE - HANDLE_MARK.value.dp) / 2
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -12,12 +14,15 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -30,14 +35,30 @@ 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.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
|
||||
* The sessions tab: every session, in the order the reader has put them in.
|
||||
*
|
||||
* Nothing here sorts. The order is the server's `sessions` list and the reader's own -- see
|
||||
* [reorderSessions] -- which is the one arrangement a row cannot be moved out of by something the
|
||||
* session does. It replaced sorting by activity, and then sorting by when each agent was turned on:
|
||||
* both meant a list that rearranged itself under whoever was reading it, and the status word and
|
||||
* its colour already say which session wants something without the row having to move to say it.
|
||||
*
|
||||
* Holding a row puts the screen in selection mode, the same gesture and the same bottom bar as the
|
||||
* import tab, so the two lists are learned once. Rearranging is deliberately *not* part of a
|
||||
* selection -- the handle moves the row it is on, whether or not that row is picked out -- because
|
||||
* "which rows am I acting on" and "where does this one go" are two questions.
|
||||
*
|
||||
* No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
|
||||
* What stays here is the button that adds a session, because that acts on this list and nothing
|
||||
@@ -54,7 +75,14 @@ fun SessionListScreen(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
|
||||
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
|
||||
|
||||
// Which rows the reader has picked out. Empty means selection mode is off, as on the import
|
||||
// tab: a selection mode with nothing in it has no controls and no way out but Back.
|
||||
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
|
||||
// The sessions a delete has been confirmed for, or none. A list rather than one session,
|
||||
// because a selection is what the bar below acts on.
|
||||
var confirmingDelete by remember { mutableStateOf<List<SessionSummary>>(emptyList()) }
|
||||
|
||||
// Whether an answer is outstanding, which is a different question from whether there is
|
||||
// anything to draw: see [refresh].
|
||||
@@ -67,6 +95,10 @@ fun SessionListScreen(
|
||||
// Cleared on the next successful load below -- an entry outlives its session otherwise.
|
||||
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
|
||||
// Why the order on screen is not the order that was saved, when saving one failed. The list is
|
||||
// what failed, so it is reported over the list rather than on any row.
|
||||
var orderError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
|
||||
// the rows are rebuilt from whatever the server last said and this belongs to the request.
|
||||
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
@@ -89,13 +121,15 @@ fun SessionListScreen(
|
||||
val loaded =
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
|
||||
deleteErrors = emptyMap()
|
||||
val alive = loaded.value.map { it.id }.toSet()
|
||||
// A selection is of sessions, so one deleted somewhere else leaves it. Only
|
||||
// that one: the other rows the reader picked out are still there.
|
||||
selected = selected.intersect(alive)
|
||||
// The path out for a cached transcript whose session was deleted somewhere
|
||||
// else. This list is the only place that ever learns the full set. On the
|
||||
// answer rather than in `finally`: a list that failed to arrive says nothing
|
||||
// about which sessions exist.
|
||||
withContext(Dispatchers.IO) {
|
||||
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
|
||||
}
|
||||
withContext(Dispatchers.IO) { transcriptCache.retainOnly(alive) }
|
||||
loaded
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
@@ -104,10 +138,113 @@ fun SessionListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every session in [targets], one after another.
|
||||
*
|
||||
* One at a time and in the order they are drawn: the server has no batch delete for sessions,
|
||||
* and each one ends a process. Each row says what is happening to it from the moment the work
|
||||
* is handed over, which is also when the selection goes -- a bar still naming sessions being
|
||||
* deleted is a set nobody can act on.
|
||||
*/
|
||||
fun deleteChosen(targets: List<SessionSummary>, alsoDeleteForeign: Boolean) {
|
||||
selected = emptySet()
|
||||
// Marked here rather than after the request returns: a row has to say something is
|
||||
// happening to it from the moment it is asked for.
|
||||
deleting = deleting + targets.map { it.id }
|
||||
deleteErrors = deleteErrors - targets.map { it.id }.toSet()
|
||||
scope.launch {
|
||||
for (session in targets) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
||||
// After it succeeded, not before: a refused delete leaves the session
|
||||
// exactly as it was, and its transcript with it.
|
||||
transcriptCache.session(TranscriptAddress(session.id)).purge()
|
||||
}
|
||||
// Only this row, and only what changed. Refetching the list instead put every
|
||||
// other session back through loading and handed the reader an empty screen, to
|
||||
// report on something never in doubt.
|
||||
val loaded = listState
|
||||
if (loaded is LoadState.Loaded) {
|
||||
listState = LoadState.Loaded(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.
|
||||
deleteErrors = deleteErrors + (session.id to (e.message ?: "Delete failed"))
|
||||
} finally {
|
||||
deleting = deleting - session.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(reloadToken) { refresh() }
|
||||
|
||||
val rows = rememberLazyListState()
|
||||
val reorder =
|
||||
rememberReorder(
|
||||
listState = rows,
|
||||
onMove = { from, to ->
|
||||
// Moved here and now, because the row is under a finger: waiting for the server to
|
||||
// agree would drag the handle away from the card it is on. What the server thinks
|
||||
// is asked for when the finger comes up, and a refusal puts the list back.
|
||||
val loaded = listState
|
||||
if (loaded is LoadState.Loaded) {
|
||||
val moved = loaded.value.toMutableList()
|
||||
moved.add(to, moved.removeAt(from))
|
||||
listState = LoadState.Loaded(moved)
|
||||
}
|
||||
},
|
||||
onSettled = {
|
||||
val loaded = listState
|
||||
if (loaded is LoadState.Loaded) {
|
||||
val order = loaded.value.map { it.id }
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { reorderSessions(settings, order) }
|
||||
orderError = null
|
||||
} catch (e: ApiException) {
|
||||
orderError = e.message ?: "The new order couldn't be saved"
|
||||
// The screen must not go on showing an arrangement nothing kept, so
|
||||
// the server's own order comes back -- which is also the only way to
|
||||
// see what it does think.
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Back leaves selection mode rather than the tab, which is the level it is one step above.
|
||||
// Nested inside MainScreen's own handler, so it wins while there is a selection.
|
||||
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
|
||||
|
||||
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
|
||||
// can still be scrolled to while it is up.
|
||||
var barHeight by remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
// What the bar covers *now*: its measurement is kept while it is away, but nothing is
|
||||
// reserved for a bar that is not up.
|
||||
val covered = if (selected.isEmpty()) 0.dp else barHeight
|
||||
|
||||
// The spawn button floats over the list, so the list ends above it -- measured, for the
|
||||
// reason the bar is. Without this the last row sat under the button, which was survivable
|
||||
// while every part of a row did the same thing and is not now that corner is a handle.
|
||||
var buttonHeight by remember { mutableStateOf(0.dp) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
orderError?.let { message ->
|
||||
// The server's own words, unprefixed, the way every other failure is shown.
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
when (val state = listState) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
// The message as Api.kt wrote it, with nothing added: it is already a whole
|
||||
@@ -126,15 +263,32 @@ fun SessionListScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
val ordered = sessionsInListOrder(state.value)
|
||||
LazyColumn {
|
||||
uniqueItems(ordered, key = { it.id }) { session ->
|
||||
LazyColumn(
|
||||
state = rows,
|
||||
contentPadding =
|
||||
PaddingValues(bottom = covered + buttonHeight + BUTTON_RING * 2),
|
||||
) {
|
||||
uniqueItems(state.value, key = { it.id }) { session ->
|
||||
SessionCard(
|
||||
session = session,
|
||||
error = deleteErrors[session.id],
|
||||
deleting = session.id in deleting,
|
||||
onOpen = { onOpen(session) },
|
||||
onLongPress = { confirmingDelete = session },
|
||||
picked = session.id in selected,
|
||||
// The handle is a selection-mode control, so it is absent rather
|
||||
// than disabled outside one: this is not a capability being
|
||||
// withheld, it is a mode the list is not in.
|
||||
reorder = reorder.takeIf { selected.isNotEmpty() },
|
||||
onClick = {
|
||||
// In selection mode a tap is a selection, so the reader is
|
||||
// never one mis-tap away from opening a session they were only
|
||||
// picking rows for.
|
||||
if (selected.isEmpty()) onOpen(session)
|
||||
else
|
||||
selected =
|
||||
if (session.id in selected) selected - session.id
|
||||
else selected + session.id
|
||||
},
|
||||
onLongPress = { selected = selected + session.id },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
@@ -149,49 +303,83 @@ fun SessionListScreen(
|
||||
LinearProgressIndicator(Modifier.align(Alignment.TopCenter).fillMaxWidth())
|
||||
}
|
||||
|
||||
// Beside nothing in particular, because a selection is not one row: the options that act on
|
||||
// it belong to the screen, and the bottom is where a thumb already is.
|
||||
if (selected.isNotEmpty()) {
|
||||
val picked =
|
||||
(listState as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
|
||||
SessionSelectionBar(
|
||||
count = picked.size,
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter).onSizeChanged {
|
||||
barHeight = with(density) { it.height.toDp() }
|
||||
},
|
||||
onDelete = { confirmingDelete = picked },
|
||||
)
|
||||
}
|
||||
|
||||
// Above the bar when there is one, by what that bar measured: the button stays rather than
|
||||
// coming and going, since an absent control cannot say whether there was nothing to do.
|
||||
FloatingActionButton(
|
||||
onClick = onSpawn,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomEnd)
|
||||
.padding(end = BUTTON_RING, bottom = BUTTON_RING + covered)
|
||||
.onSizeChanged { buttonHeight = with(density) { it.height.toDp() } },
|
||||
) {
|
||||
Text("+", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
confirmingDelete?.let { session ->
|
||||
// Reset per session, so a toggle turned on for one conversation is not still on for the
|
||||
// next. Off to begin with: see [deleteSession].
|
||||
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
|
||||
// Old servers reported only the capability, when Claude Code was its sole owner.
|
||||
val transcriptOwner = session.ownTranscriptName ?: "Claude Code"
|
||||
val targets = confirmingDelete
|
||||
if (targets.isNotEmpty()) {
|
||||
// Reset per selection, so a toggle turned on for one set of conversations is not still
|
||||
// on for the next. Off to begin with: see [deleteSession].
|
||||
var alsoDeleteForeign by remember(targets) { mutableStateOf(false) }
|
||||
// Whichever of these keep a transcript of their own decide what the sentences below say,
|
||||
// and whether the switch is offered at all. Old servers reported only the capability, when
|
||||
// Claude Code was its sole owner.
|
||||
val owned = targets.filter { it.keepsOwnTranscript }
|
||||
val transcriptOwner = owned.firstOrNull()?.ownTranscriptName ?: "Claude Code"
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingDelete = null },
|
||||
title = { Text("Delete \"${session.title}\"?") },
|
||||
onDismissRequest = { confirmingDelete = emptyList() },
|
||||
title = {
|
||||
Text(
|
||||
if (targets.size == 1) "Delete \"${targets.first().title}\"?"
|
||||
else "Delete ${targets.size} sessions?"
|
||||
)
|
||||
},
|
||||
text = {
|
||||
// Two different acts behind one button, so it says which one this is. What
|
||||
// separates them is whether the *driver* keeps its own record of the conversation
|
||||
// separates them is whether the *driver* keeps its own record of the
|
||||
// conversation
|
||||
// -- the coding CLIs do, whether this app spawned the session or imported it;
|
||||
// echo and llama.cpp do not.
|
||||
//
|
||||
// This used to branch on `imported`, above a comment asserting that "a session
|
||||
// started here has no copy anywhere". That was false for every coding-CLI session
|
||||
// this app spawned, and getting it wrong in that direction is the expensive one:
|
||||
// "this can't be undone", said of something that can, spends the credibility the
|
||||
// "this can't be undone", said of something that can, spends the credibility that
|
||||
// sentence needs.
|
||||
//
|
||||
// Neither branch promises a restore. The recoverable one says what is known -- the
|
||||
// driver keeps its own record -- rather than that the file is still there, and it
|
||||
// names what goes either way, because this app's transcript holds images, peer
|
||||
// messages and commands the CLI's own record never had.
|
||||
// Neither branch promises a restore. The recoverable one says what is known,
|
||||
// that the driver keeps its own record, rather than that the file is still there,
|
||||
// and it names what goes either way, because this app's transcript holds images,
|
||||
// peer messages and commands the CLI's own record never had.
|
||||
//
|
||||
// A selection takes the sentence that covers all of it: "some of these" is what
|
||||
// makes the mixed case true without either half of it being read as a promise
|
||||
// about every row.
|
||||
Column {
|
||||
Text(
|
||||
when {
|
||||
!session.keepsOwnTranscript ->
|
||||
owned.isEmpty() ->
|
||||
"Kills the process and deletes the conversation. Nothing else " +
|
||||
"keeps a copy, so this can't be undone."
|
||||
// The sentence below is the one the toggle makes false, which is why it
|
||||
// is written twice rather than appended to: leaving "should still be
|
||||
// there to import again" on screen beside a switch that removes it is
|
||||
// the reassurance being read at the moment it stops being true.
|
||||
// The sentence below is the one the toggle makes false, which is
|
||||
// why it is written twice rather than appended to: "should still be
|
||||
// there to import again", left on screen beside a switch that removes
|
||||
// it, is the reassurance being read as it stops being true.
|
||||
alsoDeleteForeign ->
|
||||
"Kills the process and deletes both copies of the conversation: " +
|
||||
"this app's, and $transcriptOwner's own transcript on the " +
|
||||
@@ -200,18 +388,20 @@ fun SessionListScreen(
|
||||
"Stops the process and deletes this app's copy of the " +
|
||||
"conversation, including any images, peer messages and " +
|
||||
"commands recorded only here. $transcriptOwner keeps its own " +
|
||||
"transcript on the machine, so the conversation itself " +
|
||||
"should still be there to import again."
|
||||
"transcript on the machine" +
|
||||
(if (owned.size < targets.size) " for some of these" else "") +
|
||||
", so the conversation itself should still be there to " +
|
||||
"import again."
|
||||
}
|
||||
)
|
||||
// Only where there is a second copy to decide about. Absent rather than
|
||||
// disabled, because this is not a capability being withheld: for echo and
|
||||
// llama.cpp there is no other transcript, and a switch offering to delete one
|
||||
// would be asking about something that does not exist.
|
||||
if (session.keepsOwnTranscript) {
|
||||
if (owned.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// Its own row rather than beside the paragraph: a switch is taller than a
|
||||
// line of text and re-centres whatever shares a row with it.
|
||||
// Its own row rather than beside the paragraph: a switch is taller
|
||||
// than a line of text and re-centres whatever shares a row with it.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Delete $transcriptOwner's transcript too",
|
||||
@@ -230,39 +420,8 @@ fun SessionListScreen(
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmingDelete = null
|
||||
// Marked here rather than after the request returns: the row has to say
|
||||
// something is happening to it from the moment it is asked for.
|
||||
deleting = deleting + session.id
|
||||
deleteErrors = deleteErrors - session.id
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
||||
// After it succeeded, not before: a refused delete leaves the
|
||||
// session exactly as it was, and its transcript with it.
|
||||
transcriptCache.session(TranscriptAddress(session.id)).purge()
|
||||
}
|
||||
// Only this row, and only what changed. Refetching the list instead
|
||||
// put every other session back through loading and handed the
|
||||
// reader an empty screen, to report on something never in doubt.
|
||||
val loaded = listState
|
||||
if (loaded is LoadState.Loaded) {
|
||||
listState =
|
||||
LoadState.Loaded(
|
||||
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.
|
||||
deleteErrors =
|
||||
deleteErrors + (session.id to (e.message ?: "Delete failed"))
|
||||
} finally {
|
||||
deleting = deleting - session.id
|
||||
}
|
||||
}
|
||||
confirmingDelete = emptyList()
|
||||
deleteChosen(targets, alsoDeleteForeign)
|
||||
}
|
||||
) {
|
||||
// Coloured by consequence: this takes something away, and does so wherever it
|
||||
@@ -271,31 +430,52 @@ fun SessionListScreen(
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
|
||||
TextButton(onClick = { confirmingDelete = emptyList() }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The order sessions are drawn in: the ones with a process, oldest agent first, then the stopped
|
||||
* ones, most recently active first. A display decision, made here rather than on the server, which
|
||||
* answers in config order -- see UI_RULES.
|
||||
*
|
||||
* The running group is ordered by when each agent was *turned on* rather than by what it is doing,
|
||||
* so a session cannot move by beginning a turn, finishing one or asking a question, and a list a
|
||||
* reader has their place in stays still. That replaces sorting by activity and floating the
|
||||
* awaiting-answer ones to the top: the status word and its colour already say which session wants
|
||||
* something, without the row having to move to say it. [SessionSummary.started] is the server's, so
|
||||
* the order is the same on every device and survives a backend restart.
|
||||
*
|
||||
* Stopped sessions are a group of their own because "turned on" is what the order above is made of.
|
||||
* A session nobody could ask about is not one of them -- it has a process, and dropping it out of
|
||||
* the running group the moment a machine goes quiet is the move this exists to prevent.
|
||||
* The ring of space inside a session's card, which is also what its handle leaves around itself.
|
||||
*/
|
||||
fun sessionsInListOrder(sessions: List<SessionSummary>): List<SessionSummary> {
|
||||
val (running, stopped) = sessions.partition { it.status != "exited" }
|
||||
return running.sortedBy { it.started } + stopped.sortedByDescending { it.lastActivity }
|
||||
private val CARD_PADDING = 16.dp
|
||||
|
||||
/** The gap the spawn button keeps from the edges it floats over, and from the list above it. */
|
||||
private val BUTTON_RING = 24.dp
|
||||
|
||||
/**
|
||||
* What can be done to the sessions that are selected.
|
||||
*
|
||||
* Delete only, for now, which is the one thing this screen has ever done to a session from the list
|
||||
* rather than from inside it. The same bar as the import tab's, down to the wording of the count.
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionSelectionBar(
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
"$count selected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDelete) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -312,22 +492,53 @@ private fun SessionCard(
|
||||
* about a request that has not been answered yet.
|
||||
*/
|
||||
deleting: Boolean,
|
||||
onOpen: () -> Unit,
|
||||
/** Whether this row is one of the selection the bottom bar acts on. */
|
||||
picked: Boolean,
|
||||
/** The drag this row can be moved by, or null where the list is not in selection mode. */
|
||||
reorder: Reorder?,
|
||||
onClick: () -> Unit,
|
||||
onLongPress: () -> Unit,
|
||||
) {
|
||||
val held = reorder?.held == session.id
|
||||
BusyItem(label = if (deleting) "deleting" else null) {
|
||||
Card(
|
||||
// Off while the delete is in flight: a card that still opens a session 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.
|
||||
colors =
|
||||
if (picked)
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
else CardDefaults.cardColors(),
|
||||
// Lifted while it is in hand, which is the one cue that says this row is being carried
|
||||
// rather than sitting where it belongs.
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = if (held) 8.dp else 0.dp),
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
// Drawn where the finger has taken it, above the rows it is passing over. Both
|
||||
// in the layer rather than in the layout, so nothing around it moves and the
|
||||
// list does not remeasure per frame of a drag.
|
||||
.zIndex(if (held) 1f else 0f)
|
||||
.graphicsLayer { translationY = reorder?.offsetOf(session.id) ?: 0f },
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(
|
||||
// Everything but the handle, which is what makes the two gestures separate
|
||||
// rather than competing: a press that lands on the handle never reaches this,
|
||||
// so holding it cannot select the row it is about to move. The card had the
|
||||
// click while the handle was the only thing inside it that did not want one,
|
||||
// and a hold on the handle then both selected the row and ate the drag.
|
||||
//
|
||||
// Off while the delete is in flight: a card that still opens a session it is
|
||||
// deleting is a race the reader can start by tapping. Here rather than in
|
||||
// [BusyItem], which leaves gestures alone so the list still scrolls.
|
||||
Modifier.weight(1f)
|
||||
.combinedClickable(
|
||||
enabled = !deleting,
|
||||
onClick = onOpen,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongPress,
|
||||
)
|
||||
.padding(CARD_PADDING)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -350,9 +561,9 @@ private fun SessionCard(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
// Machine, then what runs on it, then what it is set to: the same order and
|
||||
// separator as the session screen's header and the usage dialog, so one
|
||||
// pair of facts is not written three ways.
|
||||
// Machine, then what runs on it, then what it is set to: the same order
|
||||
// and separator as the session screen's header and the usage dialog, so
|
||||
// one pair of facts is not written three ways.
|
||||
listOfNotNull(
|
||||
session.machineName,
|
||||
session.provider,
|
||||
@@ -379,6 +590,23 @@ private fun SessionCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Inside the card, so what it moves is the thing it is drawn on. Nothing is held
|
||||
// open for it outside selection mode: the row is then the row it always was.
|
||||
if (reorder != null) {
|
||||
ReorderHandle(
|
||||
reorder,
|
||||
session.id,
|
||||
// Dimmed with the rest of the row while something is happening to it, since
|
||||
// a row on its way out is not one to rearrange -- see [BusyItem], whose
|
||||
// appearance this matches rather than repeating its dimming rule.
|
||||
// The mark lines up with the text on the other side of the card,
|
||||
// which means taking the square it is centred in off the gap: see
|
||||
// [HANDLE_MARGIN].
|
||||
Modifier.alpha(if (deleting) 0.4f else 1f)
|
||||
.padding(end = CARD_PADDING - HANDLE_MARGIN),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,87 +0,0 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SessionOrderTest {
|
||||
@Test
|
||||
fun `running sessions keep the order their agents were turned on in`() {
|
||||
val first = session("first", status = "running", started = 10.0, lastActivity = 900.0)
|
||||
val second =
|
||||
session("second", status = "awaitingInput", started = 20.0, lastActivity = 20.0)
|
||||
val third = session("third", status = "waiting", started = 30.0, lastActivity = 500.0)
|
||||
|
||||
assertEquals(
|
||||
listOf("first", "second", "third"),
|
||||
sessionsInListOrder(listOf(third, second, first)).map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session started again joins the bottom of the running group`() {
|
||||
val old = session("old", status = "idle", started = 10.0, lastActivity = 10.0)
|
||||
val restarted = session("restarted", status = "idle", started = 99.0, lastActivity = 99.0)
|
||||
|
||||
assertEquals(
|
||||
listOf("old", "restarted"),
|
||||
sessionsInListOrder(listOf(restarted, old)).map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stopped sessions come after the running ones, most recent first`() {
|
||||
val running = session("running", status = "idle", started = 100.0, lastActivity = 100.0)
|
||||
val stale = session("stale", status = "exited", started = 1.0, lastActivity = 5.0)
|
||||
val recent = session("recent", status = "exited", started = 2.0, lastActivity = 50.0)
|
||||
|
||||
assertEquals(
|
||||
listOf("running", "recent", "stale"),
|
||||
sessionsInListOrder(listOf(stale, recent, running)).map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A session whose process nobody could ask about still has one, so it stays where it was rather
|
||||
* than dropping into the stopped group the moment a machine goes quiet.
|
||||
*/
|
||||
@Test
|
||||
fun `a session of unknown state is one of the running ones`() {
|
||||
val unknown = session("unknown", status = "unknown", started = 10.0, lastActivity = 10.0)
|
||||
val stopped = session("stopped", status = "exited", started = 5.0, lastActivity = 999.0)
|
||||
|
||||
assertEquals(
|
||||
listOf("unknown", "stopped"),
|
||||
sessionsInListOrder(listOf(stopped, unknown)).map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
private fun session(id: String, status: String, started: Double, lastActivity: Double) =
|
||||
SessionSummary(
|
||||
id = id,
|
||||
machine = "machine",
|
||||
machineName = "machine",
|
||||
provider = "echo",
|
||||
title = id,
|
||||
model = null,
|
||||
keepsOwnTranscript = false,
|
||||
ownTranscriptName = null,
|
||||
permissionMode = null,
|
||||
effort = null,
|
||||
takesEffort = false,
|
||||
imported = false,
|
||||
notify = true,
|
||||
autoResume = false,
|
||||
autoResumeMessage = DEFAULT_RESUME_MESSAGE,
|
||||
resumeAt = null,
|
||||
cwd = null,
|
||||
contextTokens = null,
|
||||
contextLimit = null,
|
||||
params = emptyMap(),
|
||||
maxImageEdge = null,
|
||||
usageProvider = null,
|
||||
status = status,
|
||||
lastActivity = lastActivity,
|
||||
started = started,
|
||||
backgroundTasks = 0,
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,7 @@ GLYPHS=(
|
||||
U+F0193 # md-content_save
|
||||
U+F0224 # md-file_outline
|
||||
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
|
||||
U+F035C # md-menu -- the burger, as a row's drag handle
|
||||
)
|
||||
|
||||
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
||||
|
||||
+4
-22
@@ -33,6 +33,10 @@ pub struct Config {
|
||||
/// machines already configured; every subsequent write uses `machines`.
|
||||
#[serde(alias = "setups")]
|
||||
pub machines: Vec<MachineConfig>,
|
||||
/// Every session, in the order the reader has put them in -- what `GET
|
||||
/// /sessions` answers in and what every screen draws. A drag on the phone
|
||||
/// rewrites this (`POST /sessions/order`), and a spawn appends, so a new
|
||||
/// session arrives at the bottom rather than displacing anything.
|
||||
pub sessions: Vec<SessionConfig>,
|
||||
/// What a new session's thinking level is when nothing chose one.
|
||||
///
|
||||
@@ -647,25 +651,6 @@ pub struct SessionConfig {
|
||||
#[serde(default, skip_serializing_if = "not_set")]
|
||||
pub throwaway: bool,
|
||||
pub created: f64,
|
||||
/// Epoch seconds a process was last started for this session, and `None`
|
||||
/// for a session written before this was recorded.
|
||||
///
|
||||
/// The session list orders running sessions by it, so one that is turned
|
||||
/// on joins the bottom of that group and stays where it is however busy
|
||||
/// it gets. Persisted rather than held in memory because that order has
|
||||
/// to survive a backend restart -- sessions are adopted, not restarted,
|
||||
/// so nothing in the run that adopts them knows when they began.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started: Option<f64>,
|
||||
}
|
||||
|
||||
impl SessionConfig {
|
||||
/// When this session's agent was turned on. A session written before that
|
||||
/// was recorded answers with its creation, which is when its first
|
||||
/// process started.
|
||||
pub fn started_at(&self) -> f64 {
|
||||
self.started.unwrap_or(self.created)
|
||||
}
|
||||
}
|
||||
|
||||
/// A message owed to a session whose account ran out, and when to try sending
|
||||
@@ -881,7 +866,6 @@ mod tests {
|
||||
resume: None,
|
||||
throwaway: false,
|
||||
created: 1234.5,
|
||||
started: Some(2345.5),
|
||||
}],
|
||||
};
|
||||
config.save(&path).expect("save");
|
||||
@@ -892,8 +876,6 @@ mod tests {
|
||||
// The label and the id are separate, and the session holds the id.
|
||||
assert_eq!(loaded.machine("vm").expect("machine").name, "the vm");
|
||||
assert_eq!(loaded.sessions[0].provider, "claude-cli");
|
||||
// The list's order is made of this, so it has to survive the file.
|
||||
assert_eq!(loaded.sessions[0].started, Some(2345.5));
|
||||
assert_eq!(
|
||||
loaded
|
||||
.machine("vm")
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
//! GET /sessions list (id, provider, title, model, status, last activity)
|
||||
//! GET /sessions/{id} one session, for refetching after a change
|
||||
//! POST /sessions spawn {machine, provider, title?, model?, cwd?, params?}
|
||||
//! POST /sessions/order {sessions} -- the list in the order it is drawn in,
|
||||
//! as the reader dragged it; ids left out keep their
|
||||
//! places at the end
|
||||
//! GET /sessions/{id}/events?after=N SSE: backlog after N, then live
|
||||
//! (a backlog past CATCH_UP_LIMIT arrives as a
|
||||
//! `reset` frame plus the newest window)
|
||||
@@ -188,6 +191,9 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
get(read_file).put(write_file).post(create_file),
|
||||
)
|
||||
.route("/sessions", get(list_sessions).post(spawn_session))
|
||||
// Before the `{id}` route below only in reading order -- a static
|
||||
// segment wins over a parameter whichever order they are added in.
|
||||
.route("/sessions/order", post(reorder_sessions))
|
||||
.route("/sessions/{id}", get(read_session).delete(delete_session))
|
||||
.route("/sessions/{id}/events", get(events))
|
||||
.route("/sessions/{id}/transcript", get(transcript))
|
||||
@@ -1742,6 +1748,25 @@ async fn usage(
|
||||
Ok(axum::Json(snapshots))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct OrderRequest {
|
||||
sessions: Vec<String>,
|
||||
}
|
||||
|
||||
/// The order the list is drawn in, which is the reader's to choose -- see
|
||||
/// [`SessionManager::reorder_sessions`] for what an id this server does not
|
||||
/// know, or one the caller left out, does.
|
||||
async fn reorder_sessions(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
axum::Json(body): axum::Json<OrderRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
manager
|
||||
.reorder_sessions(&body.sessions)
|
||||
.map_err(ApiError::Internal)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TitleRequest {
|
||||
|
||||
+58
-61
@@ -266,10 +266,6 @@ pub struct SessionInfo {
|
||||
pub status: SessionStatus,
|
||||
pub last_activity: f64,
|
||||
pub created: f64,
|
||||
/// Epoch seconds this session's agent was last turned on, which the
|
||||
/// session list orders the running ones by -- see
|
||||
/// [`SessionConfig::started`], whose fallback this resolves.
|
||||
pub started: f64,
|
||||
/// The provider's latest measured number of live background tasks.
|
||||
/// Absent until it reports one; absence is not a measured zero.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -622,7 +618,6 @@ impl LiveSession {
|
||||
status: *self.shared.status.lock().unwrap(),
|
||||
last_activity: *self.shared.last_activity.lock().unwrap(),
|
||||
created: self.meta.created,
|
||||
started: current.started_at(),
|
||||
background_tasks: self
|
||||
.driver()
|
||||
.and_then(|driver| driver.background_tasks())
|
||||
@@ -1179,7 +1174,6 @@ impl SessionManager {
|
||||
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
|
||||
last_activity: meta.created,
|
||||
created: meta.created,
|
||||
started: meta.started_at(),
|
||||
background_tasks: None,
|
||||
subagents: subagent::count(&self.data_dir.join(&meta.id)),
|
||||
},
|
||||
@@ -1371,9 +1365,6 @@ impl SessionManager {
|
||||
// do with it -- see `SessionConfig::throwaway`.
|
||||
throwaway: self.spawn_throwaway,
|
||||
created,
|
||||
// The spawn starts a process, so this session is turned on from
|
||||
// the moment it exists.
|
||||
started: Some(created),
|
||||
};
|
||||
|
||||
let session = launch(
|
||||
@@ -1722,6 +1713,35 @@ impl SessionManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Puts the session list in the order given, which is the order every
|
||||
/// screen then draws: the reader's arrangement, held on the server so it
|
||||
/// is the same on every device and survives a backend restart.
|
||||
///
|
||||
/// The request is the whole list as one phone last saw it, and it is
|
||||
/// applied as a preference rather than as a replacement: an id naming a
|
||||
/// session that is no longer here is ignored, and a session the caller
|
||||
/// did not name keeps its relative place at the end -- which is where a
|
||||
/// spawn puts one anyway. That is what makes a drag safe against a list
|
||||
/// that changed under it, for which the alternative was refusing the drag
|
||||
/// somebody has already made and watching the row spring back.
|
||||
pub fn reorder_sessions(&self, order: &[String]) -> Result<()> {
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
let rank: HashMap<&str, usize> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(at, id)| (id.as_str(), at))
|
||||
.collect();
|
||||
let mut candidate = inner.config.clone();
|
||||
// Stable, so the sessions with no rank of their own stay in the order
|
||||
// they are already in rather than in whatever order a sort leaves.
|
||||
candidate
|
||||
.sessions
|
||||
.sort_by_key(|meta| rank.get(meta.id.as_str()).copied().unwrap_or(usize::MAX));
|
||||
candidate.save(&self.config_path)?;
|
||||
inner.config = candidate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Changes a session's model: persisted, so a respawn keeps it and the
|
||||
/// list shows it, and handed to the driver, which switches in place
|
||||
/// where its dialect can. Through the manager rather than the session,
|
||||
@@ -2071,18 +2091,6 @@ impl SessionManager {
|
||||
inner.live.insert(id.to_string(), session);
|
||||
}
|
||||
}
|
||||
// Turned on now, so it takes its place at the bottom of the running
|
||||
// group -- see `SessionConfig::started`. Best effort: a config that
|
||||
// cannot be written is not a reason to fail a start that has already
|
||||
// happened, and the cost is one row in the wrong place.
|
||||
let mut candidate = inner.config.clone();
|
||||
if let Some(meta) = candidate.sessions.iter_mut().find(|meta| meta.id == id) {
|
||||
meta.started = Some(now());
|
||||
match candidate.save(&self.config_path) {
|
||||
Ok(()) => inner.config = candidate,
|
||||
Err(err) => tracing::warn!("couldn't record that session {id} started: {err:#}"),
|
||||
}
|
||||
}
|
||||
// Nothing is announced from here. A driver that starts a process
|
||||
// reports the session idle itself, in order with everything else it
|
||||
// says about that process. Saying it here too would be a second
|
||||
@@ -4429,13 +4437,14 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The session list keeps the running sessions in the order their agents
|
||||
/// were turned on, so what that order is made of has to outlive both the
|
||||
/// process and the backend: a session started again belongs at the bottom
|
||||
/// of the group, and a restart adopts processes rather than starting them,
|
||||
/// so nothing in the new run could work the time out for itself.
|
||||
/// The order of the list is the reader's own, kept in the config, so it
|
||||
/// has to survive a backend restart: a phone reopening the tab must see
|
||||
/// the arrangement somebody dragged rather than the order things were
|
||||
/// spawned in. The ids left out of the request are the other half -- a
|
||||
/// session spawned on another device between the fetch and the drag is
|
||||
/// not in what the phone sent, and must not be dropped by it.
|
||||
#[tokio::test]
|
||||
async fn starting_a_session_again_records_when_it_was_turned_on() {
|
||||
async fn the_order_sessions_are_dragged_into_outlives_the_backend() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.ron");
|
||||
let data_dir = dir.path().join("sessions");
|
||||
@@ -4446,47 +4455,35 @@ mod tests {
|
||||
data_dir.join("models"),
|
||||
)
|
||||
.expect("manager");
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let spawned = info.started;
|
||||
assert!(
|
||||
(spawned - info.created).abs() < 1.0,
|
||||
"a spawned session was turned on at {spawned} rather than when it was created",
|
||||
let first = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let second = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let third = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let ids = |manager: &SessionManager| -> Vec<String> {
|
||||
manager
|
||||
.sessions()
|
||||
.into_iter()
|
||||
.map(|session| session.id)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
ids(&manager),
|
||||
vec![first.id.clone(), second.id.clone(), third.id.clone()],
|
||||
"a spawn belongs at the bottom of the list",
|
||||
);
|
||||
|
||||
let session = manager.session(&info.id).expect("live session");
|
||||
let mut rx = session.subscribe();
|
||||
let _ = session.sink.send(Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
});
|
||||
collect_until(&mut rx, |event| {
|
||||
matches!(
|
||||
event,
|
||||
Event::Status {
|
||||
state: SessionStatus::Exited
|
||||
}
|
||||
)
|
||||
})
|
||||
.await;
|
||||
// Otherwise the two starts can land in the same instant and the test
|
||||
// cannot tell a recorded time from a kept one.
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
manager.start_session(&info.id).expect("start again");
|
||||
collect_until(&mut rx, is_idle).await;
|
||||
manager
|
||||
.reorder_sessions(&[third.id.clone(), first.id.clone()])
|
||||
.expect("reorder");
|
||||
let expected = vec![third.id.clone(), first.id.clone(), second.id.clone()];
|
||||
assert_eq!(ids(&manager), expected);
|
||||
|
||||
let restarted = manager.sessions()[0].started;
|
||||
assert!(
|
||||
restarted > spawned,
|
||||
"starting the session again left it reporting {restarted}, the {spawned} of the \
|
||||
process that had already ended",
|
||||
);
|
||||
drop(manager);
|
||||
|
||||
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
|
||||
.expect("manager restart");
|
||||
assert_eq!(
|
||||
manager.sessions()[0].started,
|
||||
restarted,
|
||||
"the backend restart lost when this session's agent was turned on",
|
||||
ids(&manager),
|
||||
expected,
|
||||
"the restart lost the reader's order"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user