Stop a delete re-reading every transcript, and let a busy list scroll

Three faults, all of them mine from the batch work or older than it.

Deleting one Claude Code session called `list` first -- a full read of every
transcript on the machine, about 3.7 seconds against the gigabyte in this
VM -- purely to turn an id into a path. A batch of ten spent most of a
minute re-reading the same files. `context_of` already resolved an id with
a targeted glob; `delete` now does the same and takes 78ms, measured
against the same corpus.

That glob is why ids are now checked. Both places interpolate the id into
`$HOME/.claude/projects/*/"$1".jsonl`, which is an argument rather than
script text, so no shell can be talked into running anything -- but a `/`
or a `..` still walks the glob out of the directory, and `delete` removes
what it lands on. Hex and dashes only, refused rather than escaped, in both
places rather than the dangerous one alone.

Listing was the only expensive call in the app still on the 5 second
default read timeout, against work that takes about four seconds before the
tunnel adds anything -- so it timed out against a server that was answering
perfectly well. A timeout is for a server that has stopped, so it is now
set clear of the work rather than just above it.

And `BusyItem` made a row inert by consuming pointer events, which took the
drag with the tap: a list could not be scrolled while anything in it was
busy. Deciding what a gesture is above the components that already decide
it is the wrong place to stand, so the card disables its own click instead
and the scroll is left alone.
This commit is contained in:
iris committed 2026-08-31 20:11:53 -04:00
1 parent fd2e1d0798
commit b282fa2f52
5 files changed
+116 -30

No files matched your search

@@ -285,8 +285,18 @@ data class Importable(
val inUse: String,
)
/**
* What a machine has that could be continued.
*
* The slowest call this app makes, and it was the only expensive one left on the 5 second default —
* which is how it came to time out against a server that was answering perfectly well. Listing
* means reading every transcript Claude Code has ever written: about four seconds against a
* gigabyte of them before the tunnel adds anything, and that figure grows with every session
* anybody has. A timeout is for a server that has stopped answering, so it is set well clear of how
* long the work takes rather than just above it.
*/
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
requestFromServer(settings, "/setups/$setup/importable") {
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
it.jsonObjects { session ->
Importable(
id = session.getString("id"),
@@ -19,8 +19,6 @@ 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
/**
@@ -36,29 +34,19 @@ import androidx.compose.ui.unit.dp
* 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.
* It does **not** make the row inert; the caller disables its own click handling while it passes a
* label. That was the other way round at first — an overlay consuming pointer events, so no caller
* had to remember — and it swallowed the drag along with the tap, which meant a list could not be
* scrolled while anything in it was busy. Consuming taps but not drags means re-deciding what a
* gesture is above the components that already decide it; disabling the click is the platform's own
* answer and leaves the scroll where it belongs.
*/
@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,
) {
Box(Modifier.matchParentSize(), contentAlignment = Alignment.Center) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
@@ -455,6 +455,11 @@ private fun ImportableList(
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.combinedClickable(
// Off while something is happening to this row --
// see [BusyItem], which draws that but deliberately
// leaves the gestures alone so the list still
// scrolls.
enabled = running[session.id] == null,
onClick = {
if (settling(session.id)) return@combinedClickable
// In selection mode a tap is a selection, so the
@@ -279,7 +279,15 @@ private fun SessionCard(
) {
BusyItem(label = if (deleting) "deleting" else null) {
Card(
Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress)
// 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.
Modifier.fillMaxWidth()
.combinedClickable(
enabled = !deleting,
onClick = onOpen,
onLongClick = onLongPress,
)
) {
Column(Modifier.padding(16.dp)) {
Row(