Follow a card that moved, and wait for the answer a move invalidated
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. The list now scrolls to it -- only when it is actually off screen, since moving something the reader can already see is a jump nobody asked for. The two frames before reading the position are load-bearing: the entry has just been applied, so the first frame carries the composition and the second reports the layout it produced, and reading sooner gives where the card was. The other half is why a switched branch showed no commits and no Pull. The build machine drops what it knew about that checkout's remote and asks again, but the answer lands after the response -- and startCheckout read the entry exactly once, so the card kept a pending check for ever. It waits on awaitCheck now, the same poll the card's own Refresh uses. Reproduced against a clone of ai-app before changing anything: immediately after the move newCommits false with checkPending true, three seconds later newCommits true, and nothing asking again in between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af605211e2
commit
07639ed14d
2 files changed
+114
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<ManifestEntry?>(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<String, CardPlace>() }
|
||||
// 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,13 +1607,33 @@ private fun AppListScreen(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
group.forEach { entry ->
|
||||
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] ?: emptyMap(),
|
||||
installedSizes = installedSizes[entry.key] ?: emptyMap(),
|
||||
installedTimes =
|
||||
installedTimes[entry.key] ?: emptyMap(),
|
||||
chosenVariants =
|
||||
chosenVariants[entry.key] ?: emptyMap(),
|
||||
installedSizes =
|
||||
installedSizes[entry.key] ?: emptyMap(),
|
||||
projectState = projectStates[entry.key],
|
||||
componentStates = componentStates[entry.key] ?: emptyMap(),
|
||||
componentStates =
|
||||
componentStates[entry.key] ?: emptyMap(),
|
||||
onUpdate = { updated, component ->
|
||||
startUpdate(updated, component)
|
||||
},
|
||||
@@ -1583,7 +1659,9 @@ private fun AppListScreen(
|
||||
},
|
||||
onRemove = {
|
||||
forgetVariants(context, entry.key)
|
||||
manage(entry, removes = true) { removeApp(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
|
||||
@@ -1597,7 +1675,8 @@ private fun AppListScreen(
|
||||
component,
|
||||
variant?.path,
|
||||
)
|
||||
val forProject = chosenVariants[entry.key] ?: emptyMap()
|
||||
val forProject =
|
||||
chosenVariants[entry.key] ?: emptyMap()
|
||||
chosenVariants =
|
||||
chosenVariants +
|
||||
(entry.key to
|
||||
@@ -1631,6 +1710,7 @@ private fun AppListScreen(
|
||||
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"
|
||||
|
||||
|
||||
Reference in new issue
Block a user