Fix Codex transcript convergence
This commit is contained in:
1 parent
f9c8f640ce
commit
3c19b5a9bb
9 files changed
+192
-48
No files matched your search
@@ -83,6 +83,8 @@ import androidx.lifecycle.repeatOnLifecycle
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -95,6 +97,15 @@ import kotlinx.coroutines.withContext
|
||||
*/
|
||||
private val LOADING_SPINNER = 48.dp
|
||||
|
||||
/** One callback from the blocking SSE reader, queued in wire order for the UI thread. */
|
||||
private sealed interface TranscriptDelivery {
|
||||
data object Opened : TranscriptDelivery
|
||||
|
||||
data object Reset : TranscriptDelivery
|
||||
|
||||
data class Event(val entry: SeqEvent) : TranscriptDelivery
|
||||
}
|
||||
|
||||
/**
|
||||
* How close, in screenfuls of estimated scroll, the reader may come to the end of loaded history
|
||||
* before the next page is fetched.
|
||||
@@ -360,6 +371,10 @@ fun SessionScreen(
|
||||
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
|
||||
// This is what the jump-to-newest button watches, and the gate on recording.
|
||||
val atNewest by remember { derivedStateOf { !listState.canScrollBackward } }
|
||||
// A decision made only by an actual scroll gesture. `canScrollBackward` also changes while a
|
||||
// streaming row is laid out, and treating that displacement as intent freezes the rest of the
|
||||
// reply off-screen while still advancing the SSE cursor.
|
||||
var followingNewest by remember(address, epoch) { mutableStateOf(savedAnchor == null) }
|
||||
// Transcript events that arrived while somebody was reading further back, in the order they
|
||||
// arrived, waiting for them to return to the newest end. See [record].
|
||||
var held by remember { mutableStateOf(listOf<SeqEvent>()) }
|
||||
@@ -492,7 +507,10 @@ fun SessionScreen(
|
||||
}
|
||||
// In order, always: one late event recorded ahead of the backlog would fold a
|
||||
// streamed delta into whatever row happened to be last by then.
|
||||
if (atNewest && held.isEmpty()) record(entry) else held = held + entry
|
||||
// Read on the UI thread (the stream below marshals every frame here). This direct
|
||||
// check closes the small window before the scroll observer records the gesture.
|
||||
if (listState.isScrollInProgress && !atNewest) followingNewest = false
|
||||
if (followingNewest && held.isEmpty()) record(entry) else held = held + entry
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -797,6 +815,7 @@ fun SessionScreen(
|
||||
}
|
||||
// Whatever happened above: an empty transcript is a state the screen can draw, and a
|
||||
// permanently blank one is not.
|
||||
followingNewest = atNewest
|
||||
restoring = false
|
||||
ready = true
|
||||
// The opening page is sized for time-to-first-frame, not for reading: it fills a viewport
|
||||
@@ -852,25 +871,42 @@ fun SessionScreen(
|
||||
probePassed = true
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
source.follow(
|
||||
after = lastSeq.get(),
|
||||
// Connected, measured rather than inferred: this is what takes a
|
||||
// failure off the screen. Clearing on the first event instead meant
|
||||
// an idle session kept displaying an error it had recovered from.
|
||||
onOpen = { streamError = null },
|
||||
onReset = {
|
||||
// Too far behind to continue from: what is on screen is a stale
|
||||
// prefix of a conversation that has moved on, and the window
|
||||
// arriving next is not adjacent to it. Dropping the rows makes
|
||||
// this the same as opening the screen. The cache needs no
|
||||
// telling: the window's first seq is not the one it expected,
|
||||
// which closes its live run and starts another.
|
||||
dropLoadedTranscript()
|
||||
},
|
||||
) { entry ->
|
||||
apply(entry)
|
||||
// The socket is blocking, but Compose state belongs to this UI coroutine.
|
||||
// Queue every callback -- including reset -- through one channel so none
|
||||
// can race layout or overtake another while crossing threads.
|
||||
val delivery = Channel<TranscriptDelivery>(Channel.UNLIMITED)
|
||||
val follower =
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
source.follow(
|
||||
after = lastSeq.get(),
|
||||
onOpen = { delivery.trySend(TranscriptDelivery.Opened) },
|
||||
onReset = { delivery.trySend(TranscriptDelivery.Reset) },
|
||||
) { entry ->
|
||||
delivery.trySend(TranscriptDelivery.Event(entry))
|
||||
}
|
||||
delivery.close()
|
||||
} catch (error: Throwable) {
|
||||
delivery.close(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (next in delivery) {
|
||||
when (next) {
|
||||
TranscriptDelivery.Opened -> streamError = null
|
||||
TranscriptDelivery.Reset -> {
|
||||
// The fresh window has no position in common with the rows
|
||||
// just dropped, so it becomes the view even if the reader
|
||||
// had been further back in the stale prefix.
|
||||
dropLoadedTranscript()
|
||||
followingNewest = true
|
||||
}
|
||||
is TranscriptDelivery.Event -> apply(next.entry)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
source.close()
|
||||
follower.cancelAndJoin()
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
// Leaving the screen or going below STARTED. Not a failure, and swallowing
|
||||
@@ -920,9 +956,12 @@ fun SessionScreen(
|
||||
// paced out: they are at the bottom, which is the one place the list is allowed to follow new
|
||||
// content.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { atNewest && held.isNotEmpty() }
|
||||
.collect { due ->
|
||||
if (!due) return@collect
|
||||
snapshotFlow { Triple(listState.isScrollInProgress, atNewest, held.isNotEmpty()) }
|
||||
.collect { (scrolling, newest, hasHeld) ->
|
||||
if (scrolling && !newest) followingNewest = false
|
||||
if (!newest) return@collect
|
||||
followingNewest = true
|
||||
if (!hasHeld) return@collect
|
||||
val backlog = held
|
||||
held = listOf()
|
||||
backlog.forEach { record(it) }
|
||||
|
||||
Reference in new issue
Block a user