diff --git a/AGENTS.md b/AGENTS.md index 1f521ac..35cc2b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -599,6 +599,29 @@ mutable at runtime from the phone. small note in one component's row to say otherwise. Iris found it by switching ai-app onto another branch and seeing nothing flagged. +- **The list follows a card that an action moved, and a checkout waits + for the answer before it stops.** Two halves of the same press, both + Iris's, 2026-09-02. Switching a branch makes the build out of date, + which moves the card out of "Up to date" and somewhere else entirely + on a list of ten: `followCard` scrolls to it, but only when it is + actually off screen, since moving something the reader can already see + is a jump they did not ask for. It waits two frames first -- the entry + has just been applied, so the first frame carries the composition and + the second reports the layout it produced; reading the position before + both have passed gives where the card *was*. Positions come from an + `onGloballyPositioned` per card (`CardPlace`) rather than from the + order, because the cards are different heights and the headings count + too. + And `startCheckout` now waits on `awaitCheck` afterwards. The build + machine drops what it knew about that checkout's remote and asks again, + but the answer lands *after* the response -- so with a single read the + card kept a pending check for ever: no commit count, Pull disabled, on + a branch with commits waiting. That was the whole of "it doesn't show + any updates or the ability to pull even tho there's obviously new + stuff", reproduced against a clone of ai-app: immediately after the + move `newCommits: false, checkPending: true`, three seconds later + `newCommits: true` -- with nothing asking again in between. + - **A moved checkout re-asks its remote, because the last answer was about the branch it left.** `RemoteChecks::recheck` forgets the cached answer and starts a fresh check, called from `move_checkout` where the diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt index d7ed137..c0f82cb 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt @@ -51,15 +51,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf 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.graphics.asImageBitmap +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription @@ -77,6 +81,7 @@ import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanIntentResult import com.journeyapps.barcodescanner.ScanOptions import java.io.File +import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -530,6 +535,10 @@ private fun AppListScreen( // away. One slot rather than one per card: it is a modal, so only one // can be open, and the entry in it says which card asked. var forcePull by remember { mutableStateOf(null) } + val listScroll = rememberScrollState() + // Where each card is in that scrolling column, filled in as they are + // laid out. Only [followCard] reads it. + val cardPlaces = remember { mutableStateMapOf() } // Which component of which project has a service action in flight. // Per project, because that is the granularity of a card, and the row // that is busy is the one that shows it. @@ -727,6 +736,33 @@ private fun AppListScreen( ) } + /** + * Puts a card back on screen after something moved it. + * + * Switching a project's branch makes its build out of date, which moves the card out of "Up to + * date" and into the group above -- somewhere else entirely on a list of ten. The reader + * pressed something on that card and is owed the answer, so the list follows it. Iris asked for + * this on 2026-09-02, having watched a card she had just acted on disappear upwards. + * + * Only when it is actually off screen: a card that moved a little, or not at all, is left where + * it is, because scrolling something the reader can already see is a jump they did not ask for. + * + * The two frames are the load-bearing part. The entry has just been applied, so the card is + * about to be composed in its new place; the first frame carries that composition and the + * second reports the layout it produced. Reading the position before both have passed gives + * where the card *was*. + */ + suspend fun followCard(key: String) { + withFrameNanos {} + withFrameNanos {} + val place = cardPlaces[key] ?: return + val viewTop = listScroll.value + if (place.top >= viewTop && place.top + place.height <= viewTop + listScroll.viewportSize) { + return + } + listScroll.animateScrollTo(place.top.coerceIn(0, listScroll.maxValue)) + } + /** * Take the server's view of *one* card and leave every other card exactly as it was, returning * the entry that landed. @@ -1077,7 +1113,27 @@ private fun AppListScreen( entry, start = { checkoutTarget(entry.key, target) }, progress = { ProjectState.Working("Moving the checkout", it) }, - ) + ) ?: return@launch + // The card has just moved, most likely into the group above: + // its build is now behind the checkout. Take the reader with + // it, before waiting on the remote below, so the list follows + // the press rather than a round trip. + followCard(entry.key) + // And then the answer that the move invalidated. The build + // machine drops what it knew about this checkout's remote and + // asks again -- the last answer was about the branch being + // left -- but that lands *after* the response, so without + // this the card keeps a pending check for ever: no commit + // count, and Pull disabled, on a branch with commits waiting. + // Reading it back can move the card again, hence the second + // follow. + try { + awaitCheck(entry.key) + } catch (e: DownloadServerException) { + setProject(entry.key, failure(e)?.let(ProjectState::Error)) + return@launch + } + followCard(entry.key) } } @@ -1495,7 +1551,7 @@ private fun AppListScreen( onRefresh = ::refreshByPull, modifier = Modifier.fillMaxSize(), ) { - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + Column(Modifier.fillMaxSize().verticalScroll(listScroll)) { val entries = state.manifest.entries if (entries.isEmpty()) { Text( @@ -1551,86 +1607,110 @@ private fun AppListScreen( Spacer(Modifier.height(8.dp)) } group.forEach { entry -> - AppCard( - entry = entry, - installedTimes = installedTimes[entry.key] ?: emptyMap(), - chosenVariants = chosenVariants[entry.key] ?: emptyMap(), - installedSizes = installedSizes[entry.key] ?: emptyMap(), - projectState = projectStates[entry.key], - componentStates = componentStates[entry.key] ?: emptyMap(), - onUpdate = { updated, component -> - startUpdate(updated, component) - }, - onComponentBuild = { built, component -> - startComponentBuild(built, component) - }, - onPull = { startPull(entry) }, - onProjectUpdate = { startProjectUpdate(entry) }, - onRefresh = { refreshOne(entry) }, - onSettings = { gitIpv4 -> - manage(entry) { setAppSettings(entry.key, gitIpv4) } - }, - onCheckout = { target -> startCheckout(entry, target) }, - // Only this component's slot is - // cleared: the download it was - // holding is dropped, and every - // other card is left alone. - onCancelInstall = { component -> - setComponent(entry.key, component, null) - }, - onApprove = { - manage(entry) { approveDeclaration(entry.key) } - }, - onRemove = { - forgetVariants(context, entry.key) - manage(entry, removes = true) { removeApp(entry.key) } - }, - // Written here rather than sent to the server: - // it is this device's preference. Bumping the - // reload token is what redraws the card with - // the new choice and the mtime that goes with - // it. - onSelectVariant = { component, variant -> - chooseVariant( - context, - entry.key, - component, - variant?.path, - ) - val forProject = chosenVariants[entry.key] ?: emptyMap() + Box( + // Where this card sits in the scrolling + // column, so an action that moves it + // between groups can take the reader + // with it. Recorded per card rather + // than worked out from the order, + // because the cards are different + // heights and the headings count too. + Modifier.onGloballyPositioned { placed -> + cardPlaces[entry.key] = + CardPlace( + placed.positionInParent().y.roundToInt(), + placed.size.height, + ) + } + ) { + AppCard( + entry = entry, + installedTimes = + installedTimes[entry.key] ?: emptyMap(), chosenVariants = - chosenVariants + - (entry.key to - when (variant) { - null -> forProject - component - else -> - forProject + - (component to variant.path) - }) - }, - // The build machine's, not this - // device's: a mode decides what - // gets built there, so it is a - // round trip and a refetch rather - // than a preference written here. - onComponentSettings = { component, mode, strip -> - manageComponent(entry, component) { - setComponentSettings( + chosenVariants[entry.key] ?: emptyMap(), + installedSizes = + installedSizes[entry.key] ?: emptyMap(), + projectState = projectStates[entry.key], + componentStates = + componentStates[entry.key] ?: emptyMap(), + onUpdate = { updated, component -> + startUpdate(updated, component) + }, + onComponentBuild = { built, component -> + startComponentBuild(built, component) + }, + onPull = { startPull(entry) }, + onProjectUpdate = { startProjectUpdate(entry) }, + onRefresh = { refreshOne(entry) }, + onSettings = { gitIpv4 -> + manage(entry) { setAppSettings(entry.key, gitIpv4) } + }, + onCheckout = { target -> startCheckout(entry, target) }, + // Only this component's slot is + // cleared: the download it was + // holding is dropped, and every + // other card is left alone. + onCancelInstall = { component -> + setComponent(entry.key, component, null) + }, + onApprove = { + manage(entry) { approveDeclaration(entry.key) } + }, + onRemove = { + forgetVariants(context, entry.key) + manage(entry, removes = true) { + removeApp(entry.key) + } + }, + // Written here rather than sent to the server: + // it is this device's preference. Bumping the + // reload token is what redraws the card with + // the new choice and the mtime that goes with + // it. + onSelectVariant = { component, variant -> + chooseVariant( + context, entry.key, component, - mode, - strip, + variant?.path, ) - } - }, - onEnroll = { component -> - openEnrollmentLink(entry, component) - }, - serviceBusy = serviceBusy[entry.key], - onServiceAction = { component, action, purge -> - runServiceAction(entry, component, action, purge) - }, - ) + val forProject = + chosenVariants[entry.key] ?: emptyMap() + chosenVariants = + chosenVariants + + (entry.key to + when (variant) { + null -> forProject - component + else -> + forProject + + (component to variant.path) + }) + }, + // The build machine's, not this + // device's: a mode decides what + // gets built there, so it is a + // round trip and a refetch rather + // than a preference written here. + onComponentSettings = { component, mode, strip -> + manageComponent(entry, component) { + setComponentSettings( + entry.key, + component, + mode, + strip, + ) + } + }, + onEnroll = { component -> + openEnrollmentLink(entry, component) + }, + serviceBusy = serviceBusy[entry.key], + onServiceAction = { component, action, purge -> + runServiceAction(entry, component, action, purge) + }, + ) + } Spacer(Modifier.height(12.dp)) } } @@ -2638,6 +2718,9 @@ private fun CommitPicker( } } +/** Where one card sits in the scrolling list, in the column's own coordinates. */ +private data class CardPlace(val top: Int, val height: Int) + /** What git reports as the branch when no branch is checked out. */ private const val DETACHED_HEAD = "HEAD"