diff --git a/AGENTS.md b/AGENTS.md index 435ac11..56bdcce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -638,6 +638,20 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or further means a chunked backwards reader. `RUST_LOG=ai_server=debug` logs each page with what was asked and what came back, which is how to see a phone paging back in real time. +- **Paging back has two failures that look like "there is simply no more + history", and neither says anything on screen.** Both fixed 2026-08-31, + both invisible on a loopback server and reproducible at `--delay 150`. + The pager fires on the *first layout*, before any event has arrived -- + `moreHistory` starts true, so the history spinner is in the list and + `visibleItemsInfo` is not empty -- and `before = 0` asks for the events + before the first one, which is none, which is exactly how this code is + told it has reached the start. `loadOlderPage` refuses `oldestSeq == 0` + now. And `joinPages` only ran `adoptRun` on the path where a *split* call + had been found, so a boundary landing cleanly between two calls -- most of + them -- left one run of tool calls drawn as two groups with the seam + wherever the reader happened to have paged. Reproducing either takes a + boundary placed on purpose: the opening page is 80 events, so arrange the + transcript so that event counts back from the newest. - **A page is 800 events and a screen is a handful of rows, and the two have no fixed ratio.** A run of thirty-five tool calls is one row; a reply is hundreds of text deltas folded into one. So anything that budgets in diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index d03afce..3105593 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -503,6 +503,24 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () * value, which does not change under a running one. */ suspend fun loadOlderPage(limit: Int = HISTORY_PAGE): Boolean { + // Nothing is loaded, so there is no "before" to ask about, and asking anyway is not a + // harmless no-op: `before = 0` fetches the events before the first one, which is none, + // and an empty page is how this function is told it has reached the start of the + // conversation -- so it would latch `moreHistory` false and the session could never be + // paged back at all. + // + // The window it fires in is the first layout. `moreHistory` starts true, which puts the + // history spinner in the list, which makes `visibleItemsInfo` non-empty before a single + // event has arrived -- and with no units loaded the room ahead adds up to zero, so the + // pager fetches. On a loopback server the opening page beat it and nothing was ever + // wrong; at `--delay 150`, which is what a phone over the tunnel actually costs, it won + // the race and the transcript stopped one page from its newest end with no spinner and + // nothing to say why. + // + // Guarded here rather than at the two callers because it is a fact about the question, + // not about who is asking: the post-open fetch reaches it too, on the path where the + // opening page failed and left `oldestSeq` unset. + if (oldestSeq == 0L) return false // The fetch *and* the fold, both off the thread that draws. Only the fetch used to be, // and the fold is the expensive half: `foldEvent` returns a new list per event, so a page // of [HISTORY_PAGE] events is that many copies of a list growing to that length -- around diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt index c358417..ea36397 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt @@ -179,18 +179,24 @@ private fun runIdFor(items: List, id: String, tool: String): Str * boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the * newer on what an end knows (the output, and whether it finished), which is the only way round * that loses nothing. + * + * The third thing is the *run*, and it is the one that used to be missed. Every page ends up here, + * but [adoptRun] only ran on the path where a split call had been found -- so the boundary that + * falls cleanly between two finished calls, which is most of them, went straight to concatenation + * and left the older page's calls under the run name they were folded with. On screen: one run of + * tool calls drawn as two groups, with the seam wherever the reader happened to have paged. The two + * early returns were an optimisation on a list the size of one page, and they were skipping work + * rather than saving it. */ fun joinPages(earlier: List, later: List): List { val (older, newer) = healSplitMessage(earlier, later) val startedEarlier = older.filterIsInstance().mapTo(mutableSetOf()) { it.id } - if (startedEarlier.isEmpty()) return older + newer val endedLater = newer .filterIsInstance() .associateBy { it.id } .filterKeys { it in startedEarlier } - if (endedLater.isEmpty()) return older + newer val healed = older.map { row -> val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] } if (row is TranscriptItem.ToolRun && half != null) {