Delete and import sessions in batches, and say which rows are busy
Clearing out imported sessions was one confirmation dialog per row, which is why it was not worth doing. Holding a row on the import screen now selects it and plain taps add more; Delete and Import act on the whole selection from a bar along the bottom. Submitting hands the work over and puts the screen back as it was: the selection clears, the bar goes, and what says the work is happening is the rows it is happening to -- the one in flight marked with its operation, the rest marked "waiting". Both are inert, so a queued row cannot be tapped into starting a second CLI behind the batch already coming for it. Rows leave as each one lands rather than all at the end, because a finished row still sitting there looks exactly like one that was never imported; the rows below it therefore move, so a row that has just moved ignores taps for half a second. That busy appearance is one composable shared with the session list, which had its own dimmed row and its own word for it. It is a word rather than a bare spinner because deleting and importing differ in kind. Deleting a session can now take the machine's own transcript with it, as a switch in the confirmation and only where the driver keeps a record this app's delete cannot otherwise reach. Off by default, since leaving that copy is what makes an ordinary delete recoverable -- and the paragraph is rewritten rather than appended to when it is on, because the sentence promising the conversation is still there to import again is exactly the one the switch makes false. The server removes the machine's copy first, so a machine it cannot reach leaves the session where it was. `app/ui-sandbox.sh` is how all of this was driven: a second server with its own $HOME, invented transcripts and a two-line `claude`. Against the ordinary server, testing delete deletes somebody's conversation and testing import spends a turn on a real account.
This commit is contained in:
1 parent
21d22f89c2
commit
fd2e1d0798
8 files changed
+960
-296
No files matched your search
@@ -694,8 +694,17 @@ fun compactSession(settings: ServerSettings, sessionId: String) {
|
||||
requestFromServer(settings, "/sessions/$sessionId/compact", method = "POST") {}
|
||||
}
|
||||
|
||||
fun deleteSession(settings: ServerSettings, sessionId: String) {
|
||||
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
|
||||
/**
|
||||
* Removes a session, and optionally the machine's own transcript of the same conversation.
|
||||
*
|
||||
* [deleteForeign] is the delete this app cannot otherwise reach: Claude Code keeps its own record
|
||||
* under `~/.claude/projects`, and leaving it is what makes an ordinary delete recoverable. The
|
||||
* server does both halves, and does the unrecoverable one first, so a machine it cannot reach
|
||||
* leaves the session exactly where it was rather than half-deleted.
|
||||
*/
|
||||
fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Boolean = false) {
|
||||
val query = if (deleteForeign) "?deleteForeign=true" else ""
|
||||
requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {}
|
||||
}
|
||||
|
||||
// Models: what this backend has downloaded, what it is downloading, and
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* An item something is happening to: dimmed, drained of colour, inert, with a spinner and the name
|
||||
* of the operation over it.
|
||||
*
|
||||
* One composable rather than a pattern each list repeats, because "this row is busy" has to look
|
||||
* the same in the import list and the session list or the appearance becomes a per-screen dialect
|
||||
* rather than something the reader learns once.
|
||||
*
|
||||
* [label] names the operation and `null` means none is running. One parameter rather than a boolean
|
||||
* beside a string, which can disagree: there is no such thing as busy with nothing happening. It is
|
||||
* a *word* because a spinner alone cannot say which operation this is — deleting and importing are
|
||||
* different in kind, and losing a session to the wrong one is not recoverable by waiting.
|
||||
*
|
||||
* Inert by consuming pointer events above the content rather than by asking every caller to disable
|
||||
* its own click handler: the row is covered, so there is nothing left to remember.
|
||||
*/
|
||||
@Composable
|
||||
fun BusyItem(label: String?, content: @Composable () -> Unit) {
|
||||
Box {
|
||||
Box(Modifier.busy(label != null)) { content() }
|
||||
if (label != null) {
|
||||
Box(
|
||||
Modifier.matchParentSize().pointerInput(Unit) {
|
||||
// Consumed on the initial pass, so nothing underneath sees the gesture at
|
||||
// all -- a press that produced a ripple on a row that cannot be pressed
|
||||
// would say the opposite of everything else here.
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes.forEach {
|
||||
it.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// Full strength, over content that is not: the operation is the one thing on
|
||||
// this row that is still current, and it has to read against a card whose own
|
||||
// text is still visible behind it.
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How an item looks while it is being acted on: darker, and nearly grey.
|
||||
*
|
||||
* Both, rather than either alone. Dimming by itself is what this app already used for a row on its
|
||||
* way out, and it is the same cue as a disabled control, so a busy row read as one more thing that
|
||||
* could not be tapped. Draining the colour is what says the row is *suspended* — the status word,
|
||||
* the accent on a warning and everything else that means something by its colour stop meaning it
|
||||
* for as long as the operation runs, which is exactly true: none of them is being kept up to date.
|
||||
*
|
||||
* Not all the way to grey. A row with no colour left is hard to find again in a list, and the
|
||||
* reader is watching this one.
|
||||
*/
|
||||
private fun Modifier.busy(busy: Boolean): Modifier =
|
||||
if (!busy) this
|
||||
else
|
||||
this.graphicsLayer { alpha = 0.5f }
|
||||
.drawWithContent {
|
||||
drawIntoCanvas { canvas ->
|
||||
canvas.saveLayer(
|
||||
Rect(Offset.Zero, size),
|
||||
Paint().apply {
|
||||
colorFilter =
|
||||
ColorFilter.colorMatrix(
|
||||
ColorMatrix().apply { setToSaturation(0.2f) }
|
||||
)
|
||||
},
|
||||
)
|
||||
drawContent()
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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
|
||||
@@ -13,8 +17,10 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,38 +32,93 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** What a row says about itself while an operation is running on it. See [BusyItem]. */
|
||||
private const val IMPORTING = "importing"
|
||||
private const val DELETING = "deleting"
|
||||
|
||||
/**
|
||||
* What the rows further down a batch say while they wait their turn.
|
||||
*
|
||||
* Its own word rather than the operation's, because it is its own state and the difference is the
|
||||
* kind that matters: nothing has been done to this session yet, so a batch stopped here leaves it
|
||||
* exactly as it was. Marked from the moment the batch is handed over all the same -- a queued row
|
||||
* that still looked ordinary was still tappable, and tapping it would import it a second time
|
||||
* behind the batch already coming for it.
|
||||
*/
|
||||
private const val WAITING = "waiting"
|
||||
|
||||
/**
|
||||
* How long a row that has just moved ignores being touched.
|
||||
*
|
||||
* A batch takes rows out of the list as each one lands, so everything below the one that went
|
||||
* slides up -- and a tap already on its way then arrives at whichever row moved into that place. On
|
||||
* this screen that means importing a session nobody chose, which is not something a second tap can
|
||||
* undo.
|
||||
*
|
||||
* Swallowed silently rather than shown, because anything drawn on every row a batch passes would be
|
||||
* a flicker running down the list. Half a second: long enough to cover a tap already travelling
|
||||
* when the row moved, short enough that it is not in the way of a deliberate one.
|
||||
*/
|
||||
private const val SETTLE_MS = 500L
|
||||
|
||||
/**
|
||||
* Continuing a Claude Code session the machine already has.
|
||||
*
|
||||
* The list is the machine's answer, not this app's: it asks a setup what sessions it holds and
|
||||
* shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this
|
||||
* screen into a file reader.
|
||||
*
|
||||
* Holding a row selects it and puts the screen in selection mode, where the options that act on a
|
||||
* selection appear along the bottom. That exists because these arrive in bulk — a machine
|
||||
* accumulates dozens of abandoned sessions — and one confirmation dialog per row is the reason
|
||||
* clearing them out was not worth doing.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
var chosen by remember { mutableStateOf<Setup?>(null) }
|
||||
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
|
||||
// Which row is being imported. Spawning resumes a CLI, which is not instant, and a tap with
|
||||
// no acknowledgement invites a second tap and a second session.
|
||||
var importing by remember { mutableStateOf<String?>(null) }
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the row
|
||||
// itself, not a flag, so the dialog can say which session it is about.
|
||||
var confirming by remember { mutableStateOf<Importable?>(null) }
|
||||
|
||||
// What is happening to each row right now, as the word the row shows: "importing" or
|
||||
// "deleting". A map keyed by id rather than a flag per row, because the rows are rebuilt from
|
||||
// whatever the server last said and this belongs to the request rather than to the session --
|
||||
// the same arrangement the session list uses for its deletes.
|
||||
var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
// Which rows the reader has picked out. Empty means selection mode is off: there is no
|
||||
// separate flag, because a selection mode with nothing selected is a state with no controls
|
||||
// in it and no way to leave except Back.
|
||||
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
// Failures that belong to one row rather than to the screen, shown on that row. A batch is
|
||||
// exactly where a single banner fails: nine deletes succeeded and one did not, and the
|
||||
// banner cannot say which.
|
||||
var rowErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
// Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows
|
||||
// themselves, not a flag, so the dialog can say what it is about.
|
||||
var confirming by remember { mutableStateOf<List<Importable>?>(null) }
|
||||
// Same default as the spawn screen, and for the same reason: a phone
|
||||
// is the wrong place to answer "allow Bash?" forty times.
|
||||
var permissionMode by remember { mutableStateOf("auto") }
|
||||
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from
|
||||
// it, so a tap reading it needs no recomposition and there is no timer to cancel when a
|
||||
// second removal lands on top of the first.
|
||||
val movedAt = remember { mutableMapOf<String, Long>() }
|
||||
fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS
|
||||
|
||||
fun loadSessions(setup: Setup) {
|
||||
sessions = LoadState.Loading
|
||||
selected = emptySet()
|
||||
rowErrors = emptyMap()
|
||||
scope.launch {
|
||||
sessions =
|
||||
try {
|
||||
@@ -84,110 +145,208 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// No heading: the tab that selected this one already says "Import". The sentence below
|
||||
// stays, because it says what importing *does*, which the tab label cannot.
|
||||
Text(
|
||||
"Sessions Claude Code already has on the machine. Importing continues one where it " +
|
||||
"left off; the transcript here shows its recent history.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
when (val loaded = setups) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
|
||||
is LoadState.Loaded -> {
|
||||
// Only worth choosing when there is a choice.
|
||||
if (loaded.value.size > 1) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
loaded.value.forEach { setup ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
chosen = setup
|
||||
loadSessions(setup)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
setup.name,
|
||||
color =
|
||||
if (setup.id == chosen?.id)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Runs [operation] over [targets] one at a time, marking each row with [label] while its turn
|
||||
* lasts and taking it off the list when it succeeds.
|
||||
*
|
||||
* One runner for both operations and for both the single tap and the batch, so "what a row
|
||||
* looks like while something is happening to it" and "what happens when one of ten fails" are
|
||||
* decided once. Sequentially, because each import starts a CLI on the machine and ten at once
|
||||
* is a load nobody asked for; the reader sees the work walk down the list, which is also the
|
||||
* only honest progress this screen can show.
|
||||
*
|
||||
* The selection is dropped the moment the work is handed over, not when it finishes: the screen
|
||||
* goes back to how it started, and what says the work is happening is the rows it is happening
|
||||
* to. Holding the selection until the end left the bar up over rows that could no longer be
|
||||
* pressed, offering to start again something already running.
|
||||
*
|
||||
* A failure keeps its row and puts the server's words on it. Selecting those rows again is then
|
||||
* the reader's decision rather than a state the screen carried for them — and it is the
|
||||
* decision worth making deliberately, because retrying a delete that the server refused is
|
||||
* usually not what somebody wants to do by pressing the same button twice.
|
||||
*/
|
||||
fun runOn(targets: List<Importable>, label: String, operation: suspend (Importable) -> Unit) {
|
||||
selected = emptySet()
|
||||
running = running + targets.associate { it.id to WAITING }
|
||||
scope.launch {
|
||||
for (target in targets) {
|
||||
running = running + (target.id to label)
|
||||
rowErrors = rowErrors - target.id
|
||||
try {
|
||||
operation(target)
|
||||
val loaded = sessions
|
||||
if (loaded is LoadState.Loaded) {
|
||||
// As each one lands, not all of them at the end. Holding the finished
|
||||
// rows in place to keep the list still was tried and is worse: a row
|
||||
// that has been imported but is still sitting there looks exactly like
|
||||
// one that has not, and tapping it starts a second CLI on the same
|
||||
// transcript. A row that is gone cannot be tapped at all.
|
||||
//
|
||||
// Only this row, and only what changed -- refetching instead put every
|
||||
// other row back through a loading spinner to report a change that was
|
||||
// never in doubt.
|
||||
val now = System.currentTimeMillis()
|
||||
loaded.value
|
||||
.asSequence()
|
||||
.dropWhile { it.id != target.id }
|
||||
.drop(1)
|
||||
.forEach { movedAt[it.id] = now }
|
||||
sessions = LoadState.Loaded(loaded.value.filterNot { it.id == target.id })
|
||||
}
|
||||
}
|
||||
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
|
||||
if (chosen != null && provider == null) {
|
||||
Text(
|
||||
"${chosen?.name} has no Claude CLI, so there is nothing here to continue.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
ChipGroup(
|
||||
label = "Permissions",
|
||||
options = PERMISSION_MODES,
|
||||
selected = permissionMode,
|
||||
onSelect = { permissionMode = it },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
failure?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
ImportableList(
|
||||
state = sessions,
|
||||
importing = importing,
|
||||
onDelete = { confirming = it },
|
||||
onPick = { session ->
|
||||
val setup = chosen ?: return@ImportableList
|
||||
val useProvider = provider ?: return@ImportableList
|
||||
importing = session.id
|
||||
failure = null
|
||||
scope.launch {
|
||||
try {
|
||||
val spawned =
|
||||
withContext(Dispatchers.IO) {
|
||||
spawnSession(
|
||||
settings,
|
||||
setup = setup.id,
|
||||
provider = useProvider.name,
|
||||
// Nothing to say: the
|
||||
// server titles it from
|
||||
// the session it is
|
||||
// continuing.
|
||||
title = "",
|
||||
permissionMode = permissionMode,
|
||||
import = session.id,
|
||||
)
|
||||
}
|
||||
onImported(spawned)
|
||||
} catch (err: Exception) {
|
||||
failure = err.message ?: "Couldn't import that session"
|
||||
} finally {
|
||||
importing = null
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (err: Exception) {
|
||||
rowErrors = rowErrors + (target.id to (err.message ?: "Didn't work"))
|
||||
} finally {
|
||||
running = running - target.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirming?.let { session ->
|
||||
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
|
||||
|
||||
/**
|
||||
* Imports [targets], and goes to the session it made when [thenOpen].
|
||||
*
|
||||
* One function for the tap and for the bar, differing in that one flag: continuing a session
|
||||
* and then looking at it is what a tap on a row means, and a batch has several results and no
|
||||
* reason to pick one of them to become the screen.
|
||||
*/
|
||||
fun importAll(targets: List<Importable>, thenOpen: Boolean) {
|
||||
val setup = chosen ?: return
|
||||
val useProvider = provider ?: return
|
||||
runOn(targets, IMPORTING) { session ->
|
||||
val spawned =
|
||||
withContext(Dispatchers.IO) {
|
||||
spawnSession(
|
||||
settings,
|
||||
setup = setup.id,
|
||||
provider = useProvider.name,
|
||||
// Nothing to say: the server titles it from the session it is continuing.
|
||||
title = "",
|
||||
permissionMode = permissionMode,
|
||||
import = session.id,
|
||||
)
|
||||
}
|
||||
if (thenOpen) onImported(spawned)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, and nothing is nudged by a number that was
|
||||
// right for one font size.
|
||||
var barHeight by remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
// No heading: the tab that selected this one already says "Import". The sentence below
|
||||
// stays, because it says what importing *does*, which the tab label cannot.
|
||||
Text(
|
||||
"Sessions Claude Code already has on the machine. Importing continues one where " +
|
||||
"it left off; the transcript here shows its recent history. Hold one to " +
|
||||
"select it, and several at a time.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
when (val loaded = setups) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
|
||||
is LoadState.Loaded -> {
|
||||
// Only worth choosing when there is a choice.
|
||||
if (loaded.value.size > 1) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
loaded.value.forEach { setup ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
chosen = setup
|
||||
loadSessions(setup)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
setup.name,
|
||||
color =
|
||||
if (setup.id == chosen?.id)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chosen != null && provider == null) {
|
||||
Text(
|
||||
"${chosen?.name} has no Claude CLI, so there is nothing here to " +
|
||||
"continue.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
ChipGroup(
|
||||
label = "Permissions",
|
||||
options = PERMISSION_MODES,
|
||||
selected = permissionMode,
|
||||
onSelect = { permissionMode = it },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ImportableList(
|
||||
state = sessions,
|
||||
running = running,
|
||||
settling = ::settling,
|
||||
selected = selected,
|
||||
errors = rowErrors,
|
||||
bottomInset = barHeight,
|
||||
onToggle = { session ->
|
||||
selected =
|
||||
if (session.id in selected) selected - session.id
|
||||
else selected + session.id
|
||||
},
|
||||
onOpen = { session -> importAll(listOf(session), thenOpen = true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 =
|
||||
(sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
|
||||
SelectionBar(
|
||||
count = picked.size,
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomCenter).onSizeChanged {
|
||||
barHeight = with(density) { it.height.toDp() }
|
||||
},
|
||||
onDelete = { confirming = picked },
|
||||
onImport = { importAll(picked, thenOpen = false) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
confirming?.let { targets ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirming = null },
|
||||
title = { Text("Delete this session?") },
|
||||
title = {
|
||||
Text(
|
||||
if (targets.size == 1) "Delete this session?"
|
||||
else "Delete ${targets.size} sessions?"
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"\"${session.title}\"\n\nClaude Code keeps no copy: its transcript is the " +
|
||||
"session, so this ends any chance of resuming that conversation. " +
|
||||
"Sessions already imported here keep the history they replayed, but " +
|
||||
"cannot be continued."
|
||||
// One name is worth showing and twelve are not, so the count stands in for
|
||||
// them. The sentence after it is the same either way, because what deleting
|
||||
// costs does not change with how many.
|
||||
(if (targets.size == 1) "\"${targets.first().title}\"\n\n" else "") +
|
||||
"Claude Code keeps no copy: its transcript is the session, so this ends " +
|
||||
"any chance of resuming that conversation. Sessions already imported " +
|
||||
"here keep the history they replayed, but cannot be continued."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -195,24 +354,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
onClick = {
|
||||
val setup = chosen ?: return@TextButton
|
||||
confirming = null
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteImportable(settings, setup.id, session.id)
|
||||
}
|
||||
// Only this row, and only what changed -- the same rule as the
|
||||
// session list's delete. Refetching instead put every other row
|
||||
// back through a loading spinner to report a change that was
|
||||
// never in doubt.
|
||||
val loaded = sessions
|
||||
if (loaded is LoadState.Loaded) {
|
||||
sessions =
|
||||
LoadState.Loaded(
|
||||
loaded.value.filterNot { it.id == session.id }
|
||||
)
|
||||
}
|
||||
} catch (err: Exception) {
|
||||
failure = err.message ?: "Couldn't delete that session"
|
||||
runOn(targets, DELETING) { session ->
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteImportable(settings, setup.id, session.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,12 +370,58 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What can be done to the rows that are selected.
|
||||
*
|
||||
* Delete and Import only, for now: they are the two things this screen has ever done to a session,
|
||||
* and an option that appears here has to work on every row in a selection rather than on the one
|
||||
* somebody was thinking of.
|
||||
*/
|
||||
@Composable
|
||||
private fun SelectionBar(
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
onDelete: () -> Unit,
|
||||
onImport: () -> 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)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
TextButton(onClick = onImport) { Text("Import") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ImportableList(
|
||||
state: LoadState<List<Importable>>,
|
||||
importing: String?,
|
||||
onDelete: (Importable) -> Unit,
|
||||
onPick: (Importable) -> Unit,
|
||||
/** Rows an operation is running on, as the word each one shows. */
|
||||
running: Map<String, String>,
|
||||
/** Whether this row has just moved and should ignore being touched -- see [SETTLE_MS]. */
|
||||
settling: (String) -> Boolean,
|
||||
selected: Set<String>,
|
||||
errors: Map<String, String>,
|
||||
/** What the selection bar covers, so the last row can still be reached under it. */
|
||||
bottomInset: Dp,
|
||||
onToggle: (Importable) -> Unit,
|
||||
onOpen: (Importable) -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
@@ -243,87 +433,111 @@ private fun ImportableList(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(state.value) { session ->
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable(
|
||||
// Disabled rather than hidden: a row that vanished would make
|
||||
// its own absence the signal, and the reader could not tell a
|
||||
// session in use from one that is not there. See `detailOf` for
|
||||
// what it says instead.
|
||||
enabled = importing == null && session.inUse != "yes"
|
||||
val selecting = selected.isNotEmpty()
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomInset),
|
||||
) {
|
||||
items(state.value, key = { it.id }) { session ->
|
||||
val picked = session.id in selected
|
||||
BusyItem(label = running[session.id]) {
|
||||
Card(
|
||||
colors =
|
||||
if (picked)
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor =
|
||||
MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
else CardDefaults.cardColors(),
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (settling(session.id)) return@combinedClickable
|
||||
// In selection mode a tap is a selection, so the
|
||||
// reader is never one mis-tap away from starting
|
||||
// a CLI they were only picking rows for.
|
||||
//
|
||||
// Outside it, a tap continues the session --
|
||||
// except on a row that cannot be continued,
|
||||
// where it selects instead. That row's only
|
||||
// remaining action is Delete, and a tap that
|
||||
// did nothing at all would be a worse answer
|
||||
// than one that offers the thing it can do.
|
||||
// Two `--resume` processes on one transcript
|
||||
// each replay the other's writes, which is why
|
||||
// this must not simply try.
|
||||
if (selecting || session.inUse == "yes")
|
||||
onToggle(session)
|
||||
else onOpen(session)
|
||||
},
|
||||
onLongClick = {
|
||||
if (!settling(session.id)) onToggle(session)
|
||||
},
|
||||
),
|
||||
) {
|
||||
onPick(session)
|
||||
}
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(
|
||||
session.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// Beside the title, because "which one was I just in" is the
|
||||
// question this list answers and the order already reflects
|
||||
// it -- the reader should be able to see the ordering they
|
||||
// are being given rather than infer it.
|
||||
Text(
|
||||
relativeTime(session.modified),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Top, not centred: the text beside it is now several lines, and
|
||||
// a control centred against it would sit halfway down the row
|
||||
// rather than beside the line it belongs to.
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
// The path first, and the only thing here that is cut:
|
||||
// it is one long value with no natural break, where the
|
||||
// lines below it are short enough to wrap readably.
|
||||
// Cut at the head, because a path is identified by its
|
||||
// tail and these all share a long prefix. By the row's
|
||||
// real width rather than a character count, which was
|
||||
// one guess for every font size and screen.
|
||||
session.cwd
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { cwd ->
|
||||
Text(
|
||||
cwd,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
color =
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Text(
|
||||
statsOf(session, importing),
|
||||
session.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// Beside the title, because "which one was I just in" is
|
||||
// the question this list answers and the order already
|
||||
// reflects it -- the reader should be able to see the
|
||||
// ordering they are being given rather than infer it.
|
||||
Text(
|
||||
relativeTime(session.modified),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Its own line and its own colour, because it differs
|
||||
// in kind from the stats above rather than in degree:
|
||||
// those describe the session, this says whether taking
|
||||
// it is safe at all.
|
||||
warningOf(session)?.let { warning ->
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// The path first, and the only thing here that is cut: it is
|
||||
// one long value with no natural break, where the lines below
|
||||
// it are short enough to wrap readably. Cut at the head,
|
||||
// because a path is identified by its tail and these all
|
||||
// share a long prefix. By the row's real width rather than a
|
||||
// character count, which was one guess for every font size
|
||||
// and screen.
|
||||
session.cwd
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { cwd ->
|
||||
Text(
|
||||
warning,
|
||||
cwd,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = warningColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Beside the row it acts on, not collected at the bottom of
|
||||
// the screen where its scope would have to be guessed.
|
||||
TextButton(onClick = { onDelete(session) }) {
|
||||
Text(
|
||||
statsOf(session),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Its own line and its own colour, because it differs in kind
|
||||
// from the stats above rather than in degree: those describe
|
||||
// the session, this says whether taking it is safe at all.
|
||||
warningOf(session)?.let { warning ->
|
||||
Text(
|
||||
"Delete",
|
||||
warning,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = warningColor,
|
||||
)
|
||||
}
|
||||
// Reported where it happened, in the server's own words, the
|
||||
// way every other failure in this app is shown.
|
||||
errors[session.id]?.let { message ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
// Coloured by consequence: this takes something
|
||||
// away, and does so wherever it appears.
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
@@ -346,9 +560,8 @@ private fun humanSize(bytes: Long): String? =
|
||||
}
|
||||
|
||||
/** What this session is: the measurements, in the order they are worth knowing. */
|
||||
private fun statsOf(session: Importable, importing: String?): String =
|
||||
private fun statsOf(session: Importable): String =
|
||||
listOfNotNull(
|
||||
if (importing == session.id) "importing…" else null,
|
||||
// Said, because a name and a last message are different claims: one describes the
|
||||
// session, the other is only what happened last in it.
|
||||
if (session.named) "named" else null,
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -29,7 +30,6 @@ 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.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -141,6 +141,9 @@ fun SessionListScreen(
|
||||
}
|
||||
|
||||
confirmingDelete?.let { session ->
|
||||
// Reset per session, so a toggle turned on for one conversation is not still on for the
|
||||
// next one somebody opens this dialog for. Off to begin with: see [deleteSession].
|
||||
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingDelete = null },
|
||||
title = { Text("Delete \"${session.title}\"?") },
|
||||
@@ -163,16 +166,50 @@ fun SessionListScreen(
|
||||
// which nothing here checked; and it names what goes either way, because this
|
||||
// app's transcript holds images, peer messages and commands that the CLI's own
|
||||
// record never had.
|
||||
Text(
|
||||
if (session.keepsOwnTranscript)
|
||||
"Stops the process and deletes this app's copy of the conversation, " +
|
||||
"including any images, peer messages and commands recorded only " +
|
||||
"here. Claude Code keeps its own transcript on the machine, so the " +
|
||||
"conversation itself should still be there to import again."
|
||||
else
|
||||
"Kills the process and deletes the conversation. Nothing else keeps a " +
|
||||
"copy, so this can't be undone."
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
when {
|
||||
!session.keepsOwnTranscript ->
|
||||
"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.
|
||||
alsoDeleteForeign ->
|
||||
"Kills the process and deletes both copies of the conversation: " +
|
||||
"this app's, and Claude Code's own transcript on the " +
|
||||
"machine. Nothing keeps another, so this can't be undone."
|
||||
else ->
|
||||
"Stops the process and deletes this app's copy of the " +
|
||||
"conversation, including any images, peer messages and " +
|
||||
"commands recorded only here. Claude Code keeps its own " +
|
||||
"transcript on the machine, 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) {
|
||||
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.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Delete Claude Code's transcript too",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Switch(
|
||||
checked = alsoDeleteForeign,
|
||||
onCheckedChange = { alsoDeleteForeign = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
@@ -185,7 +222,9 @@ fun SessionListScreen(
|
||||
deleteErrors = deleteErrors - session.id
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { deleteSession(settings, session.id) }
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteSession(settings, session.id, alsoDeleteForeign)
|
||||
}
|
||||
// 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
|
||||
@@ -208,7 +247,9 @@ fun SessionListScreen(
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
// Coloured by consequence: this takes something away, and does so wherever
|
||||
// it appears -- the same rule the import screen's Delete follows.
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
@@ -227,92 +268,68 @@ private fun SessionCard(
|
||||
/**
|
||||
* Whether this session is being deleted right now.
|
||||
*
|
||||
* Faded and inert while it is, which says the row is on its way out without claiming it has
|
||||
* gone: a row removed the moment Delete is pressed is a promise about a request that has not
|
||||
* been answered yet, and putting it back when the server refuses is worse than never having
|
||||
* taken it away.
|
||||
* Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its
|
||||
* way out without claiming it has gone: a row removed the moment Delete is pressed is a promise
|
||||
* about a request that has not been answered yet, and putting it back when the server refuses
|
||||
* is worse than never having taken it away.
|
||||
*/
|
||||
deleting: Boolean,
|
||||
onOpen: () -> Unit,
|
||||
onLongPress: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth()
|
||||
.alpha(if (deleting) 0.45f else 1f)
|
||||
// Not just faded: a card that still opens a session it is deleting is a race the
|
||||
// reader can start by tapping.
|
||||
.combinedClickable(enabled = !deleting, onClick = onOpen, onLongClick = onLongPress)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
session.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// In the status's own place, because that is what it is: what this session is
|
||||
// doing now, which is being deleted.
|
||||
if (deleting) DeletingMark() else StatusText(session.status)
|
||||
}
|
||||
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.
|
||||
listOfNotNull(
|
||||
session.setupName,
|
||||
session.provider,
|
||||
session.model?.let { modelLabel(it) },
|
||||
)
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
relativeTime(session.lastActivity),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// The server's own words, unprefixed, the way every other
|
||||
// failure in this app is shown.
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
BusyItem(label = if (deleting) "deleting" else null) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
session.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusText(session.status)
|
||||
}
|
||||
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.
|
||||
listOfNotNull(
|
||||
session.setupName,
|
||||
session.provider,
|
||||
session.model?.let { modelLabel(it) },
|
||||
)
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
relativeTime(session.lastActivity),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
error?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// The server's own words, unprefixed, the way every other
|
||||
// failure in this app is shown.
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A session on its way out: the same shape as a status, because that is the slot it fills. */
|
||||
@Composable
|
||||
private fun DeletingMark() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(14.dp).height(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
// Not in the error colour, though it is destructive: red here means something went
|
||||
// wrong on its own, and this is going exactly as asked.
|
||||
Text(
|
||||
"deleting",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusText(status: String) {
|
||||
val (label, color) =
|
||||
|
||||
Reference in new issue
Block a user