Author SHA1 Message Date
iris bf3479f5c4 client-core: an unasked page is not an empty one, and two guarded invariants
Review of 73251d6's port of TranscriptSource/joinPages.

`TranscriptSource::page` answered `before == 0` with an empty `Vec`, which
is the same value it answers "this conversation has no more history" with.
That is the state the Kotlin keeps apart: `loadOlderPage` returns false at
`oldestSeq == 0` *without* touching `moreHistory`, and returns false on an
empty page *by latching it*. Collapsing the two moved AGENTS.md's paging
bug one layer down rather than fixing it. `page` returns `OlderPage` now --
`Events(vec![])` is the start of the conversation, `NothingLoaded` is not
an answer about the conversation at all.

`join_pages`' `debug_assert!` on seq ordering across the boundary is not a
true invariant: a peer note carries the seq its turn began at, which can be
older than the page it arrived in, so an ordinary transcript would have
panicked a debug build there. Replaced with the one the function exists to
enforce -- no tool id surviving in both halves.

`fetch_transcript_lines` stores `RawValue`'s exact server bytes, so the
"neither source can produce a newline" comment in `SessionCache::append`
now rests on the server's serializer staying compact rather than on a
local normalization. Checked with a `debug_assert!` in `append` and
`store_page` rather than trusted.

Tests for the failure half, which the port had none of: a 500 mid-page, a
cached line this build cannot read, and the `after` bound in the case that
actually carries one (the existing test asserted only the case with no
bound). `cargo fmt`, `cargo clippy --all-targets`, `cargo test` (112) clean
in client-core; `cargo check -p desktop-app` clean.
2026-09-06 13:00:37 -04:00
iris 312455956d Merge remote-tracking branch 'origin/rustify' into worktree-agent-a6e37a2335f436d08 2026-09-06 12:39:22 -04:00
irisandClaude Fable 5.1 73251d6b8b client-core: port TranscriptSource and joinPages page-boundary healing
Closes docs/RUST.md's "client-core prerequisites for P1" box: the
cache-vs-server stitching TranscriptSource.kt does, and the
joinPages/healSplitMessage/adoptRun page-boundary healing
TranscriptItems.kt does, both ported into client-core with no UI
framework dependency.

Neither Kotlin file had a JVM unit test of its own, so the port used the
Kotlin source and AGENTS.md's "things that have bitten" paging incidents
as the spec instead of a test-for-test transcription. Both regressions
get a dedicated test: TranscriptSource::page refuses before == 0 before
touching the cache or the network (loadOlderPage's incident), and
adopt_run now runs on every page join rather than only the one where a
split call was found (the "one run drawn as two" incident).

fetch_transcript_lines (api.rs, additive) pairs each transcript line with
the exact server bytes via serde_json::value::RawValue rather than
re-serializing a parsed Value, so a cached line and a live SSE frame for
the same event agree byte-for-byte -- the fetch_transcript_page other
callers under iris/ depend on is untouched.

client-core: 85 -> 109 tests. cargo test/clippy --all-targets/fmt clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 12:38:59 -04:00
iris 2e00e71552 docs: Iris's 11:39 phone report on the 02:07 build, four open items 2026-09-06 11:42:21 -04:00
irisandClaude Fable 5.1 f802de94b5 Merge worktree-agent-a754368325fa06839 into rustify: DragGesture, pointer capture, edge-to-edge insets
Generalizes drag arbitration into a default-input DragGesture with
pointer capture and CursorSense::Drop, and opts MainActivity into
edge-to-edge so IME insets are redelivered. See e12c708.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:38:24 -04:00
iris 9717d1c4b0 docs/RUST.md: 2026-09-06 orchestrator plan for the P0 defects and the P1 prerequisites 2026-09-06 11:37:32 -04:00
iris 9458f443ad Merge remote-tracking branch 'origin/rustify' into worktree-agent-a754368325fa06839 2026-09-06 02:10:55 -04:00
irisandClaude Fable 5.1 e12c708246 iris: generalize drag arbitration into a default-input DragGesture, with pointer capture and Drop
Iris asked (2026-09-06) that dragging be part of iris's default input
system rather than duplicated per app: "anything that provides good
performance and can be generalized well is part of iris rather than the
app." DragArbiter and VelocityTracker (both already in iris::sense) are
now bundled into a new DragGesture, which also takes exclusive pointer
capture (UiRenderState::capture_pointer/release_pointer/captured_pointer)
the moment a gesture commits to panning or selecting, and delivers a new
CursorSense::Drop -- not PressEnd -- to the captured widget when the
button lifts, wherever on screen that happens to be.

This directly targets the phone bench's "finger flings do nothing":
per-widget hit testing silently drops a gesture the instant the pointer
moves off every registered region, which a fast pan/fling does routinely
(crossing several virtualised rows, or ending off the loaded content
entirely) -- so PressEnd, and the velocity/fling-start decision hanging
off it, was frequently never delivered at all. Capture targets List's own
stable id (List::key_at resolves the row-under-pointer from its
extents), not a row's, since List retires rows mid-drag as content
scrolls.

transcript-ui::Selection::drag now only decides pan-vs-select from
DragGesture's outcome; row.rs's per-row registration is only ever a
gesture's first frame, with lib.rs registering the List-level
continuation once. New tests: sense_tests.rs's two pointer-capture
regressions, list.rs's replacing_the_last_row_many_times_does_not_leak_primitives
(a P0 stale-primitives diagnostic -- passes, pinning the widget-arena
layer as not the leak). MainActivity.java opts into edge-to-edge
(Window::setDecorFitsSystemWindows(false), API 30+, no new dependency)
so window insets are redelivered on every change including a pure IME
toggle -- the named-but-untried fix for the phone bench's "keyboard:
could not be shown" and the emulator's identical non-confirmation.

cargo fmt/clippy/test clean across the iris workspace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:10:48 -04:00
iris 543f6d92f0 Merge worktree-agent-a9002910a315fe719 into rustify: composing text, tap-vs-swipe focus, composer rebuild, atlas reset 2026-09-06 02:08:12 -04:00
17 changed files with 1767 additions and 97 deletions

No files matched your search

+6 -1
View File
@@ -17,7 +17,12 @@ edition = "2024"
[dependencies] [dependencies]
event-model = { path = "../event-model" } event-model = { path = "../event-model" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["float_roundtrip"] } # "raw_value" is `fetch_transcript_lines`'s reason -- it needs the exact
# bytes the server sent, not this crate's own re-serialization of a parsed
# `Value`, so a cached line and a live SSE frame for the same event agree
# byte-for-byte (see that method's doc). "float_roundtrip" is why they
# agree on a `ts` at all -- see server/Cargo.toml's identical comment.
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
# The blocking HTTP client for the REST calls and the long-lived SSE GETs. # The blocking HTTP client for the REST calls and the long-lived SSE GETs.
# `server/` already depends on ureq for its own outbound HTTPS (the usage # `server/` already depends on ureq for its own outbound HTTPS (the usage
# poll in usage.rs) and it is rustls-backed like the rest of this project's # poll in usage.rs) and it is rustls-backed like the rest of this project's
+67 -1
View File
@@ -10,6 +10,7 @@
use std::io::Read; use std::io::Read;
use event_model::SeqEvent;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
@@ -116,6 +117,14 @@ impl<T: Transport> ApiClient<T> {
Self { transport } Self { transport }
} }
/// The transport underneath, for a caller that needs the raw SSE
/// stream (`event_stream::follow_session_events`) rather than one of
/// this client's typed REST calls -- `transcript_source::TranscriptSource`
/// is the one that does.
pub fn transport(&self) -> &T {
&self.transport
}
fn json_request<R: for<'de> Deserialize<'de>>( fn json_request<R: for<'de> Deserialize<'de>>(
&self, &self,
method: &str, method: &str,
@@ -266,6 +275,61 @@ impl<T: Transport> ApiClient<T> {
limit: u32, limit: u32,
coalesce: bool, coalesce: bool,
) -> Result<Vec<Value>, ApiError> { ) -> Result<Vec<Value>, ApiError> {
self.json_request(
"GET",
&transcript_path(session_id, before, limit, coalesce, None),
None,
)
}
/// A page of transcript history, each line handed back paired with the
/// exact text it came from, and bounded below by `after` -- the shape
/// `crate::transcript_source::TranscriptSource` needs to store what it
/// fetched in the transcript cache without a second round trip to fetch
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
///
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
/// parsed [`Value`], so the stored line is the exact bytes the server
/// sent (key order and float literal included) rather than this
/// crate's own idea of how to write them back out -- the cache and a
/// live SSE frame must agree byte-for-byte on the same event, which is
/// exactly what caught the `serde_json` float-rounding bug this
/// project's `AGENTS.md` records.
pub fn fetch_transcript_lines(
&self,
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
after: Option<u64>,
) -> Result<Vec<(String, SeqEvent)>, ApiError> {
let path = transcript_path(session_id, before, limit, coalesce, after);
let raw: Vec<Box<serde_json::value::RawValue>> = self.json_request("GET", &path, None)?;
raw.into_iter()
.map(|value| {
let line = value.get().to_string();
let event: SeqEvent = serde_json::from_str(&line).map_err(|e| ApiError {
message: format!(
"the server sent a transcript line this build couldn't parse: {e}"
),
status: None,
})?;
Ok((line, event))
})
.collect()
}
}
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
/// parameter is written rather than keeping two copies to drift.
fn transcript_path(
session_id: &str,
before: Option<u64>,
limit: u32,
coalesce: bool,
after: Option<u64>,
) -> String {
let mut path = format!("/sessions/{session_id}/transcript?limit={limit}"); let mut path = format!("/sessions/{session_id}/transcript?limit={limit}");
if let Some(before) = before { if let Some(before) = before {
path.push_str(&format!("&before={before}")); path.push_str(&format!("&before={before}"));
@@ -273,8 +337,10 @@ impl<T: Transport> ApiClient<T> {
if coalesce { if coalesce {
path.push_str("&coalesce=true"); path.push_str("&coalesce=true");
} }
self.json_request("GET", &path, None) if let Some(after) = after {
path.push_str(&format!("&after={after}"));
} }
path
} }
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/` /// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
+1
View File
@@ -11,5 +11,6 @@ pub mod notifications;
pub mod sse; pub mod sse;
pub mod transcript_cache; pub mod transcript_cache;
pub mod transcript_fold; pub mod transcript_fold;
pub mod transcript_source;
pub use event_model::*; pub use event_model::*;
+14 -2
View File
@@ -361,6 +361,10 @@ impl SessionCache {
{ {
return Ok(false); return Ok(false);
} }
debug_assert!(
lines.iter().all(|l| !l.contains('\n')),
"a stored page's lines must each be one line"
);
fs::create_dir_all(&this.dir)?; fs::create_dir_all(&this.dir)?;
let kind = if rows { "rows" } else { "raw" }; let kind = if rows { "rows" } else { "raw" };
let mut content = lines.join("\n"); let mut content = lines.join("\n");
@@ -389,8 +393,16 @@ impl SessionCache {
return Ok(()); return Ok(());
}; };
// Written as it arrived. A newline inside it would split one // Written as it arrived. A newline inside it would split one
// event into two unreadable halves, but neither source can // event into two unreadable halves. No source here can produce
// produce one. // one -- an SSE `data:` field cannot hold a raw newline, and a
// fetched line is one element of a compact JSON array -- but
// that is a fact about the *server's* serializer rather than
// anything this file controls, so it is checked rather than
// trusted.
debug_assert!(
!line.contains('\n'),
"a cached transcript line must be one line: {line}"
);
use std::io::Write; use std::io::Write;
writer.write_all(line.as_bytes())?; writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?; writer.write_all(b"\n")?;
+323
View File
@@ -294,6 +294,199 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
out out
} }
/// Puts a page of older items in front of the ones already loaded, healing
/// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s
/// `joinPages`.
///
/// Two things straddle a boundary: a tool call separated from its result,
/// and a message separated from the rest of itself. Both were one thing
/// before the transcript was cut into pages.
///
/// A boundary lands wherever it lands, and roughly half the time that is
/// between a call and its result. The newer page then holds a `ToolEnd`
/// whose start it never saw, which `fold_event` draws as a row of its own
/// -- correctly, because a call that renders as nothing is indistinguishable
/// from one that never happened. When the older page arrives it brings the
/// real `ToolStart`, and concatenating the two lists left *both*: the same
/// call twice.
///
/// Merged by the call's own id rather than by position, because position is
/// exactly what a page boundary destroys. The older row wins on what a
/// start knows and the newer on what an end knows, which is the only way
/// round that loses nothing.
///
/// The third thing is the *run*, and it is the one the Kotlin original used
/// to miss (AGENTS.md's "things that have bitten"): every page ends up
/// here, but `adopt_run` must run on *every* join, not only the one where a
/// split call was found -- a boundary landing cleanly between two finished
/// calls, which is most of them, would otherwise leave 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.
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let (older, newer) = heal_split_message(earlier, later);
let started_earlier: std::collections::HashSet<&str> = older
.iter()
.filter_map(TranscriptItem::as_tool_run)
.collect();
// Owned rather than borrowed from `newer`: `kept` below needs to consume `newer` by
// value, and a map borrowing it would keep that alive.
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
.iter()
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
.filter(|(id, _)| started_earlier.contains(id.as_str()))
.collect();
let healed: Vec<TranscriptItem> = older
.into_iter()
.map(|row| match row {
TranscriptItem::ToolRun {
seq,
id,
run_id,
tool,
input,
asks: row_asks,
images: row_images,
..
} if ended_later.contains_key(id.as_str()) => {
let &TranscriptItem::ToolRun {
ref output,
done,
asks: ref half_asks,
images: ref half_images,
..
} = &ended_later[id.as_str()]
else {
unreachable!("filtered to ToolRun above");
};
TranscriptItem::ToolRun {
seq,
id,
run_id,
tool,
input,
output: output.clone(),
done,
// Kept from both halves: a question or an image can be
// attached to either, depending on which side of the
// boundary its event fell.
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
images: row_images.into_iter().chain(half_images.clone()).collect(),
}
}
other => other,
})
.collect();
let kept: Vec<TranscriptItem> = newer
.into_iter()
.filter(|item| match item.as_tool_run() {
Some(id) => !ended_later.contains_key(id),
None => true,
})
.collect();
let mut out = adopt_run(&healed, &kept);
out.extend(kept);
// What this function exists to prevent, checked rather than assumed: the same
// call drawn twice, once from the page that saw its start and once from the page
// that saw its end. Not a seq-ordering check -- a peer note is stamped with the
// seq its turn began at, which can be older than the page it arrived in, so the
// two pages' seqs legitimately interleave at the boundary.
debug_assert!(
{
let mut ids: Vec<&str> = out.iter().filter_map(TranscriptItem::as_tool_run).collect();
let before = ids.len();
ids.sort_unstable();
ids.dedup();
ids.len() == before
},
"join_pages left the same tool call in both halves"
);
out
}
/// Rejoins a message the page boundary cut, and hands back the two pages to
/// concatenate. Ported from `TranscriptItems.kt`'s `healSplitMessage`.
///
/// `fold_event` never leaves two assistant messages next to each other
/// inside one page, so two meeting at a join are always the two halves of
/// one reply, and leaving them apart drew a single answer as two with a
/// paragraph break through the middle of a sentence.
///
/// The newer half keeps its identity, for the reason `adopt_run`'s doc
/// gives. It grows by what the older half brings, which is safe here and
/// nowhere else -- the join is at the oldest end of what is loaded, so the
/// growth extends off the top of the screen.
fn heal_split_message(
earlier: &[TranscriptItem],
later: &[TranscriptItem],
) -> (Vec<TranscriptItem>, Vec<TranscriptItem>) {
let (
Some(TranscriptItem::AssistantMsg {
text: head_text, ..
}),
Some(TranscriptItem::AssistantMsg {
seq: tail_seq,
text: tail_text,
settled: tail_settled,
}),
) = (earlier.last(), later.first())
else {
return (earlier.to_vec(), later.to_vec());
};
let merged = TranscriptItem::AssistantMsg {
seq: *tail_seq,
text: format!("{head_text}{tail_text}"),
settled: *tail_settled,
};
let mut newer = vec![merged];
newer.extend(later[1..].iter().cloned());
(earlier[..earlier.len() - 1].to_vec(), newer)
}
/// Hands the older calls at the join the name of the run they are joining.
/// Ported from `TranscriptItems.kt`'s `adoptRun`.
///
/// The two pages were folded separately, so a run split by the boundary
/// came back as two runs with two names. Naming the joined run after the
/// *older* half would be the obvious way round and is wrong: the newer half
/// is the part already on screen, and renaming it is renaming the row the
/// reader is looking at, which is how a list loses its anchor.
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
return earlier.to_vec();
};
// A question is in a run of its own on both sides of the join, the same as it would be
// had the two pages been folded as one. Without this the heal would merge a group
// straight through the row the reader was asked something on.
if tool == ASK_USER_QUESTION {
return earlier.to_vec();
}
let joining = run_id.clone();
let tail_len = earlier
.iter()
.rev()
.take_while(|item| matches!(item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION))
.count();
if tail_len == 0 {
return earlier.to_vec();
}
let split = earlier.len() - tail_len;
let mut out = earlier[..split].to_vec();
out.extend(earlier[split..].iter().cloned().map(|mut item| {
// `take_while` above already restricted this slice to non-question tool calls;
// this just guards the invariant rather than trusting it silently.
debug_assert!(
matches!(&item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION),
"adopt_run must never rename a question's own run"
);
if let TranscriptItem::ToolRun { run_id, .. } = &mut item {
*run_id = joining.clone();
}
item
}));
out
}
/// Folds one transcript event onto `items`, the way `foldEvent` does in /// Folds one transcript event onto `items`, the way `foldEvent` does in
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc /// `TranscriptItems.kt`. Every wire event has a case; see the module doc
/// for the one difference from the Kotlin original (no `Unknown` fallback /// for the one difference from the Kotlin original (no `Unknown` fallback
@@ -955,4 +1148,134 @@ mod tests {
let err = fold_page(&values).unwrap_err(); let err = fold_page(&values).unwrap_err();
assert!(err.contains("couldn't parse")); assert!(err.contains("couldn't parse"));
} }
fn tool_start(seq: u64, id: &str, tool: &str) -> SeqEvent {
event(
seq,
Event::ToolStart {
id: id.to_string(),
tool: tool.to_string(),
input: serde_json::json!({}),
},
)
}
fn tool_end(seq: u64, id: &str, output: &str) -> SeqEvent {
event(
seq,
Event::ToolEnd {
id: id.to_string(),
output: output.to_string(),
},
)
}
/// AGENTS.md's "things that have bitten": `joinPages` used to run
/// `adoptRun` only on the path where a *split* call was found, so a
/// boundary landing cleanly between two already-finished calls -- most
/// of them -- left the older page's calls under the run name they were
/// folded with, drawing one run of tool calls as two groups. Two
/// finished, unrelated calls (no id in common) must still end up under
/// one run name after the join.
#[test]
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
let newer = fold_all(&[tool_start(3, "b", "Bash"), tool_end(4, "b", "new output")]);
let joined = join_pages(&older, &newer);
let run_ids: Vec<_> = joined
.iter()
.map(|item| match item {
TranscriptItem::ToolRun { run_id, .. } => run_id.as_str(),
other => panic!("expected only ToolRun items, got {other:?}"),
})
.collect();
assert_eq!(
run_ids,
vec!["b", "b"],
"the older call must adopt the newer, already-on-screen run's name"
);
}
#[test]
fn a_call_split_across_the_boundary_merges_into_one_row() {
let older = fold_all(&[tool_start(1, "x", "Bash")]);
let newer = fold_all(&[tool_end(2, "x", "the result")]);
let joined = join_pages(&older, &newer);
assert_eq!(
joined,
vec![TranscriptItem::ToolRun {
seq: 1,
id: "x".to_string(),
run_id: "x".to_string(),
tool: "Bash".to_string(),
input: "{}".to_string(),
output: "the result".to_string(),
done: true,
asks: Vec::new(),
images: Vec::new(),
}],
"the older half's tool/input and the newer half's output/done must both survive"
);
}
#[test]
fn a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity() {
let older = vec![TranscriptItem::AssistantMsg {
seq: 1,
text: "Hel".to_string(),
settled: false,
}];
let newer = vec![
TranscriptItem::AssistantMsg {
seq: 2,
text: "lo".to_string(),
settled: true,
},
TranscriptItem::UserMsg {
seq: 3,
text: "next".to_string(),
attachments: Vec::new(),
},
];
let joined = join_pages(&older, &newer);
assert_eq!(
joined,
vec![
TranscriptItem::AssistantMsg {
seq: 2,
text: "Hello".to_string(),
settled: true,
},
TranscriptItem::UserMsg {
seq: 3,
text: "next".to_string(),
attachments: Vec::new(),
},
]
);
}
/// A question is in a run of its own on both sides of a join -- healing
/// must never rename the run of calls the reader was asked something
/// on, the same rule `splitRun` enforces for a live turn boundary.
#[test]
fn adopt_run_never_renames_into_a_question_row() {
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
let newer = vec![TranscriptItem::ToolRun {
seq: 3,
id: "q".to_string(),
run_id: "q".to_string(),
tool: ASK_USER_QUESTION.to_string(),
input: "{}".to_string(),
output: String::new(),
done: false,
asks: Vec::new(),
images: Vec::new(),
}];
let joined = join_pages(&older, &newer);
match &joined[0] {
TranscriptItem::ToolRun { run_id, .. } => assert_eq!(run_id, "a"),
other => panic!("expected a ToolRun, got {other:?}"),
}
}
} }
+588
View File
@@ -0,0 +1,588 @@
//! Where a session screen gets a transcript from: this phone's copy first,
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
//!
//! One seam rather than a cache the screen has to remember to consult.
//! Everything fetched before is asked of this, and everything the server
//! sends is written into the cache on the way past, so a caller never
//! learns which side answered. The one rule worth keeping in mind: the
//! cache is never load-bearing. Every read here has a network path beside
//! it producing the same result.
//!
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
//! ability to close a live stream from another thread. Both are wall-clock
//! and thread-lifetime concerns that belong to whatever runtime the caller
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
//! scope) rather than to this pure logic -- `follow` below is the same
//! decorator shape `iris/desktop-app/src/app.rs` and
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
//! `event_stream::follow_session_events`, just with the cache write built
//! in so a future caller does not have to repeat it a third time.
use event_model::SeqEvent;
use crate::api::{ApiClient, ApiError, Transport};
use crate::event_stream::{self, StreamItem};
use crate::transcript_cache::SessionCache;
/// How many events a session screen opens with, cached or fetched.
///
/// The server's own default page size, named here because the cached
/// opening has to be the same size as the fetched one -- a reader must not
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
/// in the Kotlin original).
pub const OPENING_WINDOW: u32 = 80;
/// A transcript-line parse failure, told apart from [`ApiError`] so a
/// caller can tell "the server is unreachable" from "the server (or this
/// phone's own disk) sent something this build cannot read" -- the two
/// mean different things to a reader (retry, versus a build that is
/// behind).
#[derive(Debug, Clone)]
pub struct ParseError(pub String);
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ParseError {}
/// Either half of what can go wrong asking for a page: the network, or a
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
/// read.
#[derive(Debug, Clone)]
pub enum PageError {
Api(ApiError),
Parse(ParseError),
}
impl From<ApiError> for PageError {
fn from(e: ApiError) -> Self {
Self::Api(e)
}
}
impl From<ParseError> for PageError {
fn from(e: ParseError) -> Self {
Self::Parse(e)
}
}
/// What [`TranscriptSource::page`] found, kept as two states rather than
/// one possibly-empty list.
///
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
/// empty [`Self::Events`] means "this conversation has no more history",
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
/// question could not be asked yet, which it must not. Collapsing the two
/// into an empty `Vec` puts the bug back, because the caller cannot tell
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
/// same silently.
#[derive(Debug, Clone, PartialEq)]
pub enum OlderPage {
/// The events before the cursor, oldest first. Empty means the start
/// of the conversation has been reached.
Events(Vec<SeqEvent>),
/// Nothing is loaded, so there was no cursor to page back from
/// (`before == 0`). Not an answer about the conversation at all.
NothingLoaded,
}
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
}
/// This phone's copy of one session's transcript, plus the server it
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
pub struct TranscriptSource<T: Transport> {
api: ApiClient<T>,
session_id: String,
pub cache: SessionCache,
}
impl<T: Transport> TranscriptSource<T> {
pub fn new(api: ApiClient<T>, session_id: impl Into<String>, cache: SessionCache) -> Self {
Self {
api,
session_id: session_id.into(),
cache,
}
}
/// The cached opening window, or `None` when there is nothing usable
/// to draw.
///
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
/// whole point of the feature: the rows are on screen while the check
/// that they are still the server's rows is in flight, and a failed
/// check replaces them exactly as a reset does.
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
self.cache.tail()?;
let lines = self.cache.newest(limit);
if lines.is_empty() {
return None;
}
match lines.iter().map(|l| parse_line(l)).collect() {
Ok(events) => Some(events),
// A line this build cannot read at all, which the cache's own checks cannot
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
// open.
Err(ParseError(_)) => {
self.cache.purge();
None
}
}
}
/// Whether the server's event at the cached cursor is still the cached
/// one.
///
/// A caller must not resume a live stream from a cached seq unless it
/// is the same conversation: a transcript is append-only in ordinary
/// use, but the file backing it can be replaced or truncated (a
/// sandbox re-seeded with the same ids, a backup restored, a session
/// re-imported), and the server's catch-up on such a file would hand
/// this phone a continuation of a *different* conversation, spliced
/// onto the cached one with no seam. Caught with one request of a few
/// hundred bytes.
///
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
/// server not being askable, which is neither: the cached rows stay
/// on screen and the caller tries again on its own reconnect schedule.
///
/// What this cannot see is a line changed in the middle of the file
/// with the tail intact -- that is what a full reload is for.
pub fn probe(&self) -> Result<bool, ApiError> {
let Some(tail) = self.cache.tail() else {
return Ok(false);
};
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
// event *at* the cursor when the server still has one there.
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(tail.seq + 1),
1,
false,
None,
)?;
let matches = page.len() == 1
&& parse_line(&tail.line)
.map(|cached| cached == page[0].1)
.unwrap_or(false);
if !matches {
self.cache.purge();
}
Ok(matches)
}
/// Today's opening fetch, kept as the start of the live run. Only
/// called when the cache has nothing to open with, or when
/// [`Self::probe`] said what it had was not the server's.
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
let page =
self.api
.fetch_transcript_lines(&self.session_id, None, OPENING_WINDOW, false, None)?;
for (line, event) in &page {
self.cache.append(line, event.seq);
}
self.cache.flush();
Ok(page.into_iter().map(|(_, event)| event).collect())
}
/// The page before `before`: from the cache when it holds it,
/// otherwise from the server bounded by what the cache already has.
///
/// The server bound (`after`) is what keeps the cache worth having. A
/// coalesced page reaches back as far as its row count takes it -- a
/// single reply is hundreds of lines -- so a page fetched after the
/// reader has been away could run straight past the cached run and
/// overlap it, and an overlapping page cannot be stored. Told where
/// this phone's copy starts, the server stops there instead.
///
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
/// the cache or the server anything -- see AGENTS.md's "things that
/// have bitten": there is no event before the first one, so the
/// request is not a harmless no-op, and its empty answer is
/// indistinguishable from having reached the start of history.
/// Guarded here rather than left to every caller, because it is a fact
/// about the question, not about who is asking it.
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
if before == 0 {
return Ok(OlderPage::NothingLoaded);
}
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
let events: Vec<SeqEvent> = lines
.iter()
.map(|l| parse_line(l).map_err(PageError::from))
.collect::<Result<_, _>>()?;
return Ok(OlderPage::Events(events));
}
let after = self.cache.covered_up_to(before).map(|v| v - 1);
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(before),
limit,
coalesce,
after,
)?;
if let Some((_, first_event)) = page.first() {
// `before` rather than the newest line's seq: a coalesced page covers
// everything up to the cursor it was asked with, and nothing in its lines
// says so.
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
self.cache
.store_page(&lines, first_event.seq, before, coalesce);
}
Ok(OlderPage::Events(
page.into_iter().map(|(_, event)| event).collect(),
))
}
/// [`event_stream::follow_session_events`], with every frame written to
/// the cache before `on_item` sees it.
///
/// Before, so that an event held back for a reader who is scrolled
/// away is already on disk -- what the cache holds is what the server
/// sent, not what a screen has got round to drawing. Flushed on each
/// status change, which is a turn's boundary and the granularity a
/// crash may as well lose, and once more when the stream ends.
pub fn follow(
&self,
after: u64,
mut on_item: impl FnMut(StreamItem) -> bool,
) -> Result<(), ApiError> {
let cache = &self.cache;
let result = event_stream::follow_session_events(
self.api.transport(),
&self.session_id,
after,
|item| {
if let StreamItem::Event { raw, event } = &item {
cache.append(raw, event.seq);
if matches!(event.event, event_model::Event::Status { .. }) {
cache.flush();
}
}
on_item(item)
},
);
cache.flush();
result
}
/// Leaves the cache with everything it was given -- called once a
/// caller is done with this source, mirroring the Kotlin `close`'s
/// final flush (that method's stream cancellation itself is the
/// runtime concern the module doc says is not ported here).
pub fn close(&self) {
self.cache.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::{Body, RawResponse};
use std::collections::VecDeque;
use std::io::Read;
use std::sync::Mutex;
/// A transport that answers fixed bodies in call order, and records
/// every path it was asked for -- so a test can assert *how many*
/// requests a method made, which is the point for the `before == 0`
/// guard (AGENTS.md's regression: the guard must stop the request
/// before it happens, not merely tolerate the empty answer).
#[derive(Default)]
struct ScriptedTransport {
responses: Mutex<VecDeque<(u16, String)>>,
calls: Mutex<Vec<String>>,
}
impl ScriptedTransport {
fn respond(&self, status: u16, body: impl Into<String>) {
self.responses
.lock()
.unwrap()
.push_back((status, body.into()));
}
fn call_count(&self) -> usize {
self.calls.lock().unwrap().len()
}
}
impl Transport for ScriptedTransport {
fn request(
&self,
_method: &str,
path: &str,
_body: Option<Body>,
) -> Result<RawResponse, ApiError> {
self.calls.lock().unwrap().push(path.to_string());
let (status, body) = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| panic!("ScriptedTransport got an unscripted request: {path}"));
Ok(RawResponse {
status,
body: body.into_bytes(),
})
}
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError> {
self.calls.lock().unwrap().push(path.to_string());
let (_, body) = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| {
panic!("ScriptedTransport got an unscripted stream request: {path}")
});
Ok(Box::new(std::io::Cursor::new(body.into_bytes())))
}
}
fn source(
transport: ScriptedTransport,
cache_root: &std::path::Path,
) -> TranscriptSource<ScriptedTransport> {
let api = ApiClient::new(transport);
let cache = crate::transcript_cache::TranscriptCache::new(cache_root).session("s1");
TranscriptSource::new(api, "s1", cache)
}
fn status_line(seq: u64) -> String {
format!(r#"{{"seq":{seq},"ts":1.0,"type":"status","state":"idle"}}"#)
}
#[test]
fn a_cold_cache_has_no_opening_and_fetches_from_the_server() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
assert_eq!(source.cached_opening(80), None);
let opening = source.fetch_opening().unwrap();
assert_eq!(opening.len(), 1);
assert_eq!(opening[0].seq, 1);
// The fetch wrote through: reopening the same cache now has something to show.
assert!(source.cache.tail().is_some());
}
#[test]
fn probe_matching_the_cached_tail_leaves_the_cache_alone() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(1)));
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().unwrap());
assert!(source2.cache.tail().is_some());
}
#[test]
fn probe_mismatching_the_cached_tail_purges_the_cache() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
// The server now answers with a different event at the same seq -- the file
// behind this session was replaced.
let transport2 = ScriptedTransport::default();
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
transport2.respond(200, format!("[{different}]"));
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(!source2.probe().unwrap());
assert!(source2.cache.tail().is_none());
}
#[test]
fn probe_finding_no_server_leaves_the_cache_untouched() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(1)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(500, "server on fire");
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
assert!(source2.probe().is_err());
assert!(
source2.cache.tail().is_some(),
"an unreachable server must not be treated as a mismatch"
);
}
/// The regression this module exists to close: `before == 0` must
/// never reach the network or the cache, because an empty answer there
/// is indistinguishable from "there is genuinely no more history" --
/// AGENTS.md's `loadOlderPage` incident.
#[test]
fn paging_before_the_first_event_makes_no_request_at_all() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
let source = source(transport, dir.path());
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
assert_eq!(source.api.transport().call_count(), 0);
}
#[test]
fn a_page_already_covered_by_the_cache_never_reaches_the_server() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{},{}]", status_line(1), status_line(2)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let calls_before = source.api.transport().call_count();
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
panic!("a cursor of 2 is a real question about the conversation");
};
assert_eq!(page.len(), 1);
assert_eq!(page[0].seq, 1);
assert_eq!(
source.api.transport().call_count(),
calls_before,
"a cache hit must not touch the network"
);
}
/// With nothing older cached there is no floor to give the server, so
/// the request carries no `after` at all.
#[test]
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(5)));
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
let transport2 = ScriptedTransport::default();
transport2.respond(200, format!("[{}]", status_line(3)));
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
let source2 = TranscriptSource::new(ApiClient::new(transport2), "s1", cache);
source2.page(5, 10, true).unwrap();
assert_eq!(
source2.api.transport().calls.lock().unwrap()[0],
"/sessions/s1/transcript?limit=10&before=5&coalesce=true"
);
}
/// The half the test above cannot show: when the cache *does* hold an
/// older run, the fetch is floored at its end, or the page would run
/// straight past it and overlap -- which `store_page` then refuses,
/// silently costing the phone the page it just paid for.
#[test]
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
// A stored page covering [3, 6) and two live events above it, so the run this
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
// cache reads the directory as damaged and discards it.
let lines: Vec<String> = (3..6).map(status_line).collect();
assert!(cache.store_page(&lines, 3, 6, true));
cache.append(&status_line(6), 6);
cache.append(&status_line(7), 7);
cache.flush();
let transport = ScriptedTransport::default();
transport.respond(200, format!("[{}]", status_line(9)));
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
source.page(10, 10, true).unwrap();
assert_eq!(
source.api.transport().calls.lock().unwrap()[0],
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
"the fetch must stop one seq below where this phone's copy ends"
);
}
/// A page the server could not answer is an error, never an empty
/// page: the caller would read the second as "this conversation has no
/// more history" and stop paging for good.
#[test]
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(500, "server on fire");
let source = source(transport, dir.path());
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
}
/// A cached line this build cannot read is told apart from the network
/// failing, for the same reason: neither is "no more history".
#[test]
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.store_page(
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
3,
4,
true,
);
cache.append(&status_line(4), 4);
cache.flush();
let transport = ScriptedTransport::default();
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
assert_eq!(
source.api.transport().call_count(),
0,
"a cache hit that cannot be read must not fall through to the server unnoticed"
);
}
#[test]
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
cache.append("not json at all", 1);
cache.flush();
let transport = ScriptedTransport::default();
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
assert_eq!(source.cached_opening(80), None);
assert!(
source.cache.tail().is_none(),
"a damaged line purges the cache"
);
}
#[test]
fn follow_writes_events_to_the_cache_before_the_caller_sees_them() {
let dir = tempfile::tempdir().unwrap();
let transport = ScriptedTransport::default();
transport.respond(200, format!("{}\n\n", sse_frame(&status_line(1))));
let source = source(transport, dir.path());
let mut seen = Vec::new();
source
.follow(0, |item| {
if let StreamItem::Event { event, .. } = item {
seen.push(event.seq);
}
true
})
.unwrap();
assert_eq!(seen, vec![1]);
assert_eq!(source.cache.tail().unwrap().seq, 1);
}
fn sse_frame(data: &str) -> String {
format!("data:{data}")
}
}
+84 -17
View File
@@ -25,16 +25,19 @@ next (a Masonry or iris transcript screen, most likely).
| `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) | | `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) |
| `api.rs` | `Api.kt` | Partial -- see below | | `api.rs` | `Api.kt` | Partial -- see below |
| `event_stream.rs` | `EventStream.kt` | Done | | `event_stream.rs` | `EventStream.kt` | Done |
| `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Partial -- see below | | `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Done -- see below |
| `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below | | `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below |
| *(not started)* | `TranscriptSource.kt` | Not started | | `transcript_source.rs` | `TranscriptSource.kt` | Done -- see below |
| *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below | | *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below |
Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`, Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`,
`HighlighterTest`, `TranscriptCacheTest`) has had every one of those test `HighlighterTest`, `TranscriptCacheTest`) has had every one of those test
cases ported alongside it, plus new tests for the pieces that had none cases ported alongside it, plus new tests for the pieces that had none
(`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`). Test count by (`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`,
crate as of this writing: **85 in `client-core`**, 0 in `event-model` (its `transcript_source.rs` -- the Kotlin `TranscriptSource.kt`/`TranscriptItems.kt`
had no JVM unit tests of their own, so these were written fresh against the
Kotlin source and AGENTS.md's paging incidents as the spec). Test count by
crate as of this writing: **109 in `client-core`**, 0 in `event-model` (its
types carry no logic of their own to test -- `server/`'s own tests exercise types carry no logic of their own to test -- `server/`'s own tests exercise
them via `session::transcript`'s round-trip coverage). them via `session::transcript`'s round-trip coverage).
@@ -88,13 +91,27 @@ the full table to work from when one of these is next.
including tool-call/question/image attachment and peer-message placement. including tool-call/question/image attachment and peer-message placement.
`group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`. `group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`.
**Not ported:** `TranscriptItems.kt`'s `joinPages` (and its `join_pages` (with `heal_split_message` and `adopt_run`, both private) is
`healSplitMessage`/`adoptRun` helpers) -- the page-boundary healing that now ported too, 2026-09-06 -- the page-boundary healing that merges a tool
merges a tool call split across two fetched pages and re-merges a run a call split across two fetched pages, rejoins a message a boundary cut
boundary cut through. This matters the moment paging backward through through, and renames a run of tool calls onto whichever name is already on
history is exercised; it is deliberately left rather than rushed, since screen. Ported with AGENTS.md's "things that have bitten" incidents as the
it is exactly the kind of boundary logic this project's own "things that spec rather than a JVM test file (`TranscriptItems.kt` had none of its
have bitten" section warns reads fine and is wrong at the edges. own): `a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
is the regression test for the bug that shipped -- `adopt_run` must run on
*every* join, not only the one where a split call was found, or a boundary
landing cleanly between two already-finished calls (most of them) leaves
one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`,
`a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`,
and `adopt_run_never_renames_into_a_question_row` cover the other three
edges the Kotlin doc calls out. `join_pages` ends in a `debug_assert!`
that no tool id survives in both halves -- the duplicate row it exists to
prevent, checked rather than assumed. What it deliberately does *not*
assert is seq ordering across the boundary: a peer note carries the seq
its turn began at (`place_peer_note`), which can be older than the page
it arrived in, so the two pages' seqs legitimately interleave there. An
earlier draft asserted it and would have panicked in debug builds on an
ordinary transcript.
**Known gap, and a decision for whoever closes it:** `event_model::Event` **Known gap, and a decision for whoever closes it:** `event_model::Event`
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror. has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
@@ -119,12 +136,61 @@ caller-specific (the code rules' "ask for the least you need"). Its only
caller today is `desktop-app`; a future Android build of this crate would caller today is `desktop-app`; a future Android build of this crate would
be a second one, not a reason to move the type. be a second one, not a reason to move the type.
## What `transcript_source.rs` covers, and what it does not
`TranscriptSource<T: Transport>` is the seam a session screen asks for a
page, ported test-for-test against the Kotlin doc rather than a JVM test
file (there wasn't one): `cached_opening`, `probe`, `fetch_opening`,
`page` and `follow`, each matching its Kotlin namesake's contract --
including `probe`'s three-way outcome (matches / cache purged /
unreachable, told apart so a caller never treats "couldn't ask" as "was
wrong") and `page`'s cache-vs-server split bounded by `covered_up_to`.
Two additions beyond a literal port, both load-bearing:
- **`page(before, ..)` refuses `before == 0` before touching the cache or
the network**, answering `OlderPage::NothingLoaded`. This is AGENTS.md's
`loadOlderPage` incident (`before = 0` is "no event before the first
one," indistinguishable from "reached the start of history" if a caller
ever asks it) moved out of the Kotlin screen and into this layer, so
every future caller gets the guard rather than having to remember it.
**The return type is `OlderPage`, not a `Vec`, and that is the guard.**
The Kotlin's two falses are different answers -- `oldestSeq == 0`
returns without touching `moreHistory`, an empty page latches it false
-- so a port that answered both with an empty list would have moved the
bug rather than fixed it, one layer down and out of sight of the screen
that used to hold the check. `OlderPage::Events(vec![])` means the start
of the conversation; `OlderPage::NothingLoaded` is not an answer about
the conversation at all. Reviewed 2026-09-06.
`paging_before_the_first_event_makes_no_request_at_all` asserts zero
transport calls, not just the variant, since a request that happens to
answer empty is exactly what caused the original bug, and
`a_failing_server_page_is_an_error_rather_than_an_empty_one` plus
`an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one`
are the same rule for the two ways a page can fail.
- **`fetch_transcript_lines`** (new in `api.rs`) hands back each line
paired with the exact server bytes it came from, via
`serde_json::value::RawValue` rather than re-serializing a parsed
`Value` -- the cache and a live SSE frame for the same event have to
agree byte-for-byte, which is exactly what the `serde_json`
float-rounding bug (AGENTS.md) was about. The existing
`fetch_transcript_page` is untouched (other callers under `iris/`
depend on its signature); the two share a `transcript_path` helper so
the query string is written in one place.
**Not ported:** `EventStream.kt`'s reconnect-with-backoff loop, and
`TranscriptSource.close`'s ability to cancel a live stream from another
thread. Both are wall-clock/thread-lifetime policy that belongs to
whichever runtime embeds this crate (iris's own timers, a Tokio task, a
Kotlin coroutine scope), not to this pure logic -- `follow` is the same
"write to the cache, then hand the frame to the caller" decorator
`iris/desktop-app/src/app.rs` and `iris/android-app/src/transcript_client.rs`
already hand-wrote around `event_stream::follow_session_events` before this
existed; the cache write moved into one shared place so a third caller
does not repeat it again by hand.
## What is not started at all ## What is not started at all
- **`TranscriptSource.kt`** -- the layer that decides whether a page comes
from the transcript cache or the server, and stitches the two. Needs
`transcript_cache.rs` and `api.rs`'s transcript-page method, both of
which exist now, so this is unblocked whenever picked up.
- **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs` - **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs`
colours a `.md` file or fence for the highlighter, but does not build the colours a `.md` file or fence for the highlighter, but does not build the
block tree (headings, lists, tables, fences as distinct nodes) that a block tree (headings, lists, tables, fences as distinct nodes) that a
@@ -142,5 +208,6 @@ be a second one, not a reason to move the type.
`./run-tests.sh` from the repo root now runs `event-model`, `client-core` `./run-tests.sh` from the repo root now runs `event-model`, `client-core`
and `server` in that order (each `cargo test`, forwarding arguments the and `server` in that order (each `cargo test`, forwarding arguments the
same way it always has). From `client-core/` directly: `cargo test`, same way it always has). From `client-core/` directly: `cargo test`
`cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing. (109 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of
this writing (2026-09-06).
+42
View File
@@ -183,6 +183,48 @@ agent takes them without colliding with that pass's `bench_client.rs`/
handling is a follow-up, the same shape `List`'s own touch-drag pan handling is a follow-up, the same shape `List`'s own touch-drag pan
needed before I3/I5. needed before I3/I5.
## From the phone, 2026-09-06, 11:39 (build delivered 02:07, commit 543f6d9)
Iris's report on the build with the composing-text, tap-vs-swipe and
atlas-reset fixes, with a screenshot, verbatim. Each is open until an
agent ticks it here with the evidence.
- [ ] **"The app definitely does not start with keyboard spacing
correct. This is how it looks without me doing anything initially."**
The screenshot shows the composer bar (the grey band) sitting about
two thirds of the way down a 704x1568 screen, with black below it to
the bottom, and the transcript ending at "Claude / Results" just above
it -- at launch, no keyboard. So the composer's bottom padding, which
the 2026-09-06 rebuild tied to the IME/nav-bar inset, is being fed a
large value at start on the phone. Suspects, in order: the initial
inset delivery on the phone (GrapheneOS, gesture navigation) versus
the emulator; `ime_bottom` now carrying a `1`/`0` boolean through a
field the composer may still read as pixels or dp; a stale value from
before the first `on_insets_changed`. Reproduce with the phone's
screen size and density on the emulator before guessing.
- [ ] **"Swiping still gets caught by the grey bar but keeps working
after I go past it."** A pan that starts on the composer is held by
the composer until the finger leaves its region, then the list takes
over. The tap-vs-swipe fix in `attr.rs` stops the *focus*, but the
press frames are still being handled by the field rather than passed
to the list from the first slop-crossing frame. The `DragGesture`
merge (RUST.md's plan box) should make this one mechanism: once a
gesture commits to a pan, the list captures it wherever it began.
- [ ] **"Flinging still does not work."** Expected on this build: finger
flings are dropped by per-widget hit testing, which `DragGesture`'s
pointer capture (commit `e12c708`, not yet merged at 02:07) targets.
Stays open until verified on her phone, not the emulator.
- [ ] **"Text still disappears if I leave and come back to the app."**
The `GlyphAtlas::clear`/`Textures::reset` fix was verified on the
emulator under `force-gles` only; the phone runs Vulkan. So either the
reset is not reached on the phone's path (a different surface-
lifecycle sequence -- `surface_destroyed`/`surface_created` ordering,
or the renderer not being rebuilt but its textures lost), or the CPU
glyph cache and the GPU atlas still disagree after it. Needs logging
of the renderer lifecycle on the phone build, readable from `adb
logcat` when Iris next runs it, since no emulator here has a Vulkan
adapter under host GPU.
## Build ## Build
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a - [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
+81
View File
@@ -34,6 +34,87 @@ the emulator, not by Mesa" and "the present mode was not the cause" are
worth as much as the successes, because they are what stops the next worth as much as the successes, because they are what stops the next
session spending an afternoon on them again. session spending an afternoon on them again.
## Where things stand (2026-09-06, orchestrator plan)
Written by the design agent on picking the branch up after a `/clear`, so
the next session can resume from here. P0 is delivered and Iris's phone
report v2 is in (`docs/bench/iris-phone-v2-2026-09-06.md`); **P1 stays
gated on her verdict**, so this pass works the P0 defects and the pure
prerequisites in this order. Each item is ticked here by the agent that
closes it.
- [ ] **Merge the `DragGesture` work** left complete but unmerged in the
worktree branch `worktree-agent-a754368325fa06839` (commit
`e12c708`, 2026-09-06 02:10, two minutes after the last merge to
`rustify`; already contains `rustify` at `543f6d9`). It targets two
of the four bench-v2 defects: finger flings dropped by per-widget
hit testing (pointer capture + `CursorSense::Drop`), and IME insets
never redelivered (`MainActivity.java` edge-to-edge). Before
merging: build, clippy, tests; then on the emulator confirm the
three things `20b1225` (tap-vs-swipe focus) and the phone asked for
still hold together — a swipe over the composer does not summon
the keyboard, a tap does, a finger fling on the list keeps moving
after the finger lifts, and `on_insets_changed` now fires on an
IME toggle. Then remove the worktree.
- [ ] **Fix `docs/REVIEW-2026-09-06.md`** (after the merge, since the
review's finding 1 is in `selection.rs`, which the merge rewrites).
Finding 1 is a real crash — `TranscriptScreen::apply`'s `Rebuild`
arm leaves `Selection` holding `WeakWidget`s to freed rows, and the
next tap anywhere panics. Findings 25 are `debug_assert!`s on
invariants, 8 and 10 are the missing tests. Commit the review file
with the fixes.
- [ ] **Iris's 11:39 phone report on the 02:07 build** (four items,
verbatim in `IRIS_TODO.md`'s "From the phone, 2026-09-06, 11:39"):
composer floating two thirds down the screen at launch with black
below it; a swipe starting on the composer held until the finger
leaves it; no fling (expected, `DragGesture` unmerged); text still
lost on app-switch on the phone despite the emulator-verified
atlas reset. The first and last are the same class as the next
box and go to that agent; the middle two are the merge box's.
- [ ] **Stale primitives and invisible composer text** — the header drawn
twice after a keyboard resize, the `Compacted:` row drawn twice on
Iris's phone, and typed text never appearing (P0 box item 2). All
three sit on the `redraw_updates` targeted-redraw path and may be
one bug; the P0 box says the next step is instrumentation inside
`Span::draw`/`draw_inner` showing where each placement's
primitives actually land on the frame it goes wrong.
`list.rs`'s new `replacing_the_last_row_many_times_does_not_leak_
primitives` test already pins the widget arena as *not* the leak.
- [ ] **Composer touch-drag scroll** for overflowed text — now that
dragging is a default-input `DragGesture`, `Scroll` should get its
touch pan from the same mechanism `List` uses, not a copy.
- [ ] **Streaming re-layout** (IRIS_TODO.md's last section) — after the
above, since they make the stream phase unrepresentative today.
- [x] **client-core prerequisites for P1, in parallel** (pure Rust,
disjoint from `iris/`), closed 2026-09-06: `TranscriptSource`'s
cache-vs-server stitching (new `client-core/src/transcript_source.rs`)
and `joinPages`/`healSplitMessage`/`adoptRun` page-boundary healing
(new functions in `transcript_fold.rs`), per `CLIENT_CORE.md`. Ported
against the Kotlin source and AGENTS.md's paging incidents as the
spec (`TranscriptSource.kt`/`TranscriptItems.kt` had no JVM unit
tests of their own to port test-for-test). `client-core` goes from
85 to 109 tests; `cargo test`/`clippy --all-targets`/`fmt` all clean.
Both AGENTS.md regressions have a dedicated test: `loadOlderPage`'s
`before == 0` guard moved into `TranscriptSource::page` itself
(`paging_before_the_first_event_makes_no_request_at_all` asserts
zero transport calls, not just an empty result), and
`a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
pins `adopt_run` running on *every* join rather than only the
split-call path. One incidental fix needed to port `TranscriptSource`
faithfully: `api.rs` gained `fetch_transcript_lines` (additive, the
existing `fetch_transcript_page` untouched since `iris/` depends on
its signature), which pairs each event with the exact server bytes
it came from via `serde_json::value::RawValue` rather than
re-serializing a parsed `Value` -- needed so the cache and a live SSE
frame agree byte-for-byte, the same class of bug as the
`float_roundtrip` fix. Deliberately not ported: `EventStream.kt`'s
reconnect/backoff and cross-thread stream cancellation, which are
runtime policy for whichever framework embeds this crate, not pure
logic -- see `CLIENT_CORE.md`'s new section for the full account.
- **Then**: redeliver `~/host/bench/iris-bench-arm64.apk` for Iris with
its README saying what changed, and record any choice she should see in
`DECISIONS.md`.
## Where things stand (2026-09-05) ## Where things stand (2026-09-05)
- **Streaming no longer costs a full rebuild** (P0's box, "Streaming no - **Streaming no longer costs a full rebuild** (P0's box, "Streaming no
@@ -31,6 +31,25 @@ public final class MainActivity extends Activity {
setContentView(layout); setContentView(layout);
view.requestFocus(); view.requestFocus();
// RUST.md's P0 box, defect 4 ("keyboard: could not be shown"):
// `logcat` showed the platform's own IME open/resize happening
// while `setOnApplyWindowInsetsListener` fired only once, at
// attach, and never again for a pure keyboard toggle -- a plain
// (non-edge-to-edge) window is only guaranteed that one initial
// dispatch; `adjustResize` handling the IME entirely by resizing
// the window is not itself a trigger for a fresh one. Opting into
// edge-to-edge (a platform call, API 30+, no new dependency) is
// what makes the system redeliver insets on every change,
// including the ones this activity actually cares about --
// `getSystemWindowInset*` below is unaffected by this (it has
// always reported the raw system-bar/IME overlap regardless of
// who consumes it), so the on-screen bars and the padding Rust
// already derives from those four numbers are unchanged; only the
// callback's firing became reliable.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getWindow().setDecorFitsSystemWindows(false);
}
view.setOnApplyWindowInsetsListener((v, insets) -> { view.setOnApplyWindowInsetsListener((v, insets) -> {
int left = insets.getSystemWindowInsetLeft(); int left = insets.getSystemWindowInsetLeft();
int top = insets.getSystemWindowInsetTop(); int top = insets.getSystemWindowInsetTop();
+44
View File
@@ -20,6 +20,21 @@ pub struct UiRenderState {
resized: bool, resized: bool,
draw_started: HashSet<WidgetId>, draw_started: HashSet<WidgetId>,
/// The widget currently holding exclusive pointer input, if any --
/// `iris::sense::SensorUi::run_sensors` reads and clears this every
/// call. Interior mutability (a `Mutex`, not a bare `Cell`, since a
/// `CursorData` reaching this through an async `task_on` handler needs
/// `Send`/`Sync`) because `run_sensors` takes `&self` (widgets are
/// dispatched to, not owned, at that layer) and this render state is
/// the one structure both backends (winit, android-view) already hold
/// across frames, the same way `old_root`/`resized` are -- see
/// `iris::sense`'s pointer-capture doc for why a drag needs this: once
/// a gesture has committed to panning or selecting, every later sample
/// of it must reach the same widget even if the finger has moved off
/// whatever hit region first noticed the press. Never held across an
/// await or another lock -- every access here is a single get/set.
captured: std::sync::Mutex<Option<WidgetId>>,
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the /// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
/// last `take_counters`. LAYOUT.md section 8's pass conditions are /// last `take_counters`. LAYOUT.md section 8's pass conditions are
/// stated in terms of these two: an unchanged frame must cost 0 of /// stated in terms of these two: an unchanged frame must cost 0 of
@@ -45,6 +60,7 @@ impl UiRenderState {
old_root: None, old_root: None,
resized: false, resized: false,
draw_started: Default::default(), draw_started: Default::default(),
captured: Default::default(),
draw_count: 0, draw_count: 0,
region_mut_count: 0, region_mut_count: 0,
mov_count: 0, mov_count: 0,
@@ -357,6 +373,13 @@ impl UiRenderState {
active.textures.clear(); active.textures.clear();
rsc.ui_mut().textures.free(); rsc.ui_mut().textures.free();
if undraw { if undraw {
// A captured widget that goes away mid-gesture (List's
// virtualisation retiring a row, a rebuild) must not leave
// the pointer permanently captured by an id nothing will
// ever draw again -- `captured`'s own path out.
if *self.captured.lock().unwrap() == Some(id) {
*self.captured.lock().unwrap() = None;
}
// Permanent removal: retire this widget's own move slot // Permanent removal: retire this widget's own move slot
// (the self-ownership ref taken when it was allocated) and // (the self-ownership ref taken when it was allocated) and
// the up-link ref it held on its parent's slot -- read from // the up-link ref it held on its parent's slot -- read from
@@ -429,6 +452,27 @@ impl UiRenderState {
self.active.len() self.active.len()
} }
/// Give `id` exclusive pointer input from the next `run_sensors` call
/// on -- see `captured`'s field doc. Overwrites any previous capture
/// (a gesture that starts a new one has already decided the old one
/// is over).
pub fn capture_pointer(&self, id: WidgetId) {
*self.captured.lock().unwrap() = Some(id);
}
/// Release exclusive pointer input, if any is held -- called once
/// `run_sensors` has delivered the terminal `Drop` to the capturing
/// widget, or by that widget itself if it decides the gesture is over
/// some other way.
pub fn release_pointer(&self) {
*self.captured.lock().unwrap() = None;
}
/// The widget currently holding exclusive pointer input, if any.
pub fn captured_pointer(&self) -> Option<WidgetId> {
*self.captured.lock().unwrap()
}
pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> { pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> {
self.active.iter().filter_map(move |(&id, inst)| { self.active.iter().filter_map(move |(&id, inst)| {
let l = widgets.label(id); let l = widgets.label(id);
+206
View File
@@ -22,6 +22,14 @@ pub enum CursorSense {
Hovering, Hovering,
HoverEnd, HoverEnd,
Scroll, Scroll,
/// Delivered exactly once, in place of `PressEnd`, to whichever widget
/// currently holds pointer capture (`UiRenderState::capture_pointer`)
/// when the button lifts -- see `iris::sense`'s pointer-capture doc
/// and `DragGesture`. A widget must register this explicitly (it is
/// never bundled into `click_or_drag`/`unclick`, since most widgets
/// never call `capture_pointer` and have no use for it) to receive it
/// at all; ordinary hit-tested widgets keep seeing `PressEnd`.
Drop,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -31,6 +39,21 @@ impl Event for CursorSenses {
type Data<'a> = CursorData<'a>; type Data<'a> = CursorData<'a>;
type State = SensorState; type State = SensorState;
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> { fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
// `Drop` is never derived from raw cursor/hover state below (the
// free `should_run`'s own arm for it is only ever asked here,
// never independently true or false against the button) -- it is
// set exclusively by `run_sensors`' pointer-capture branch, which
// has already decided this exact frame is the captured widget's
// terminal event. Matching it by identity, ahead of the general
// derivation, matters because a captured widget's registration
// list very likely also carries `PressEnd` (`unclick()`, for the
// ordinary un-captured case) -- the same button-lift condition
// `PressEnd` matches on, so falling through to the loop below
// would let whichever of the two happens to be registered first
// win, silently swallowing the `Drop` a caller relied on.
if data.sense == CursorSense::Drop {
return self.contains(&CursorSense::Drop).then(|| data.clone());
}
if let Some(sense) = should_run(self, &data.cursor, data.hover) { if let Some(sense) = should_run(self, &data.cursor, data.hover) {
let mut data = data.clone(); let mut data = data.clone();
data.sense = sense; data.sense = sense;
@@ -177,6 +200,48 @@ impl SensorUi for UiRenderState {
cursor: CursorState, cursor: CursorState,
window_size: Vec2, window_size: Vec2,
) { ) {
// Exclusive pointer capture (`UiRenderState::capture_pointer`,
// `DragGesture`): once some widget has committed to a drag, every
// other widget sees nothing from this pointer at all -- no hover,
// no click, no press -- until it releases. This is what lets a
// fast pan or a selection keep going once the finger has moved
// off whatever hit region first noticed the press (including
// right off the end of the gesture, at `PressEnd`/`Cancel`): a
// per-widget hit test would otherwise silently stop delivering to
// *anyone* the moment the pointer left every registered region,
// which is exactly what used to leave a fling never started (no
// widget ever saw the release). The captured widget keeps getting
// ordinary `Pressing` frames while the button is down and gets
// exactly one `Drop` -- not `PressEnd` -- the frame it lifts,
// which also releases the capture.
if let Some(id) = self.captured_pointer() {
let Some(shape) = self.resolved_region(&id, rsc) else {
self.release_pointer();
return;
};
let region = shape.to_px(window_size);
let button_down = cursor.buttons.select(&CursorButton::Left).is_on();
let sense = if button_down {
CursorSense::Pressing(CursorButton::Left)
} else {
CursorSense::Drop
};
let data = CursorData {
pos: cursor.pos - region.top_left,
size: region.bot_right - region.top_left,
scroll_delta: cursor.scroll_delta,
hover: ActivationState::On,
cursor: cursor.clone(),
sense,
render: self,
};
rsc.run_event::<CursorSense>(id, data, state);
if !button_down {
self.release_pointer();
}
return;
}
// in order to remove this take, need to store active list in UiRenderState somehow // in order to remove this take, need to store active list in UiRenderState somehow
// this would probably be done through a generic parameter that adds yet another rsc / // this would probably be done through a generic parameter that adds yet another rsc /
// state like thing, but local to render state, and is passed to UiRsc events so you can // state like thing, but local to render state, and is passed to UiRsc events so you can
@@ -266,6 +331,15 @@ pub fn should_run(
CursorSense::Hovering => hover.is_on(), CursorSense::Hovering => hover.is_on(),
CursorSense::HoverEnd => hover.is_end(), CursorSense::HoverEnd => hover.is_end(),
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO, CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
// Never derived here -- `Drop` only ever fires through
// `CursorSenses::should_run`'s own special case, ahead of this
// loop, for the one widget `run_sensors`' capture branch is
// delivering it to this frame. If this arm answered from raw
// button state instead, an ordinary hit-tested widget that
// happened to register `Drop` (with no capture involved at
// all) would see it fire on every plain button-up under the
// cursor.
CursorSense::Drop => false,
} { } {
return Some(*sense); return Some(*sense);
} }
@@ -541,6 +615,138 @@ impl DragArbiter {
} }
} }
/// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the
/// one further state a shared gesture needs: the drag ending, with the
/// released velocity if (and only if) it had committed to panning.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GestureOutcome {
Undecided,
/// Same units and sign as [`DragOutcome::Pan`] -- the caller's own
/// convention (`List::scroll`'s, for a transcript) to apply.
Pan(f32),
SelectStart,
SelectExtend,
/// The drag ended -- `PressEnd` or the capture's own terminal `Drop`.
/// `Some(velocity)` only if the gesture had committed to panning
/// (never a tap, a long-press selection, or one still `Undecided`);
/// same units as `Pan`, so a caller hands it to `List::fling` with
/// whatever sign flip it already applies to `Pan`.
Released(Option<f32>),
}
/// Bundles a [`DragArbiter`] and a [`VelocityTracker`] into the one thing
/// most drag-driven widgets need: arbitrate pan-vs-hold, track the pan's
/// velocity, and take pointer capture (`UiRenderState::capture_pointer`)
/// the moment the gesture commits so the rest of it -- including the
/// terminal release -- keeps reaching the same widget even after the
/// finger has moved off whatever hit region first noticed the press. Iris
/// asked for this to live here rather than in `transcript-ui::Selection`
/// (2026-09-06, recorded in `IRIS.md`): "dragging should be part of the
/// default input system ... anything that provides good performance and
/// can be generalized well is part of iris rather than the app." A caller
/// still decides what a committed pan or a completed selection *means*
/// (transcript-ui's pan-vs-select is one call site; a slider or a plain
/// scroll area is another) -- this only owns the *mechanics* every one of
/// them would otherwise duplicate.
pub struct DragGesture {
arbiter: DragArbiter,
velocity: VelocityTracker,
}
impl Default for DragGesture {
fn default() -> Self {
Self::new()
}
}
impl DragGesture {
pub fn new() -> Self {
Self {
arbiter: DragArbiter::new(),
velocity: VelocityTracker::new(),
}
}
/// Whether this gesture has no press in flight -- a thin passthrough
/// to the underlying `DragArbiter::is_idle`, for a caller (a test, a
/// diagnostic) that wants to observe the recovery behaviour `handle`'s
/// idle-recovery branch documents without reaching into a private
/// field.
pub fn is_idle(&self) -> bool {
self.arbiter.is_idle()
}
/// Feed one frame of a gesture through. `id` is the widget iris should
/// give exclusive pointer input to once this gesture commits to
/// panning or selecting -- a stable widget that outlives the gesture
/// (a `List`'s own id, not one of its virtualised rows, which can be
/// retired mid-drag as content scrolls). `render` is `CursorData`'s
/// own field, already in hand at every call site. `already_selected`
/// only matters for the first frame of a gesture (`PressStart`, or the
/// recovery branch below) -- see `DragArbiter::press_start`'s doc.
pub fn handle(
&mut self,
render: &UiRenderState,
id: WidgetId,
sense: CursorSense,
pos_window: Vec2,
now: Instant,
already_selected: bool,
) -> GestureOutcome {
match sense {
CursorSense::PressStart(_) => {
self.velocity.reset();
self.arbiter.press_start(pos_window, now, already_selected);
self.dispatch(render, id, pos_window, now)
}
CursorSense::Drop | CursorSense::PressEnd(_) => {
let released = if self.arbiter.is_panning() {
Some(self.velocity.velocity())
} else {
None
};
self.arbiter.release();
render.release_pointer();
GestureOutcome::Released(released)
}
// See `DragArbiter::update`'s own doc: a `Pressing` frame can
// arrive with no matching `PressStart` if the touch-down
// landed outside whichever hit region first noticed it.
_ if self.arbiter.is_idle() => {
self.velocity.reset();
self.arbiter.press_start(pos_window, now, already_selected);
self.dispatch(render, id, pos_window, now)
}
_ => self.dispatch(render, id, pos_window, now),
}
}
fn dispatch(
&mut self,
render: &UiRenderState,
id: WidgetId,
pos: Vec2,
now: Instant,
) -> GestureOutcome {
match self.arbiter.update(pos, now) {
DragOutcome::Undecided => GestureOutcome::Undecided,
DragOutcome::Pan(dy) => {
render.capture_pointer(id);
self.velocity.add_sample(dy, now);
GestureOutcome::Pan(dy)
}
DragOutcome::SelectStart => {
render.capture_pointer(id);
GestureOutcome::SelectStart
}
DragOutcome::SelectExtend => {
render.capture_pointer(id);
GestureOutcome::SelectExtend
}
}
}
}
/// How far back a [`VelocityTracker`] looks when estimating a fling's /// How far back a [`VelocityTracker`] looks when estimating a fling's
/// initial speed -- Android's own `VelocityTracker` defaults to a similar /// initial speed -- Android's own `VelocityTracker` defaults to a similar
/// short window so a gesture's last flick dominates over its slower start. /// short window so a gesture's last flick dominates over its slower start.
+124
View File
@@ -122,3 +122,127 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
"the button on top must still receive an actual click" "the button on top must still receive an actual click"
); );
} }
/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report,
/// defect 2): a fast gesture's `PressEnd` can land at a screen position
/// nothing is registered at -- past the edge of whatever widget noticed
/// the press, in a gap, or off the loaded content entirely. Before pointer
/// capture, `run_sensors`' hit test simply delivered nothing that frame,
/// so a widget mid-drag never saw its release and never got a chance to
/// start a fling. `UiRenderState::capture_pointer`/`DragGesture` fix this
/// by giving the drag's widget every frame regardless of where the
/// pointer is, including the terminal `Drop` in place of `PressEnd`.
#[test]
fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// A small draggable widget in the corner -- the release below lands
// far outside it, exactly the "moved off the hit region" case.
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let draggable_weak = draggable.weak();
let dropped = Rc::new(Cell::new(false));
{
let dropped = dropped.clone();
rsc.register_event(
draggable_weak,
CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop,
move |ctx, rsc| match ctx.data.sense {
CursorSense::PressStart(_) | CursorSense::Pressing(_) => {
// Any committed drag takes capture -- a real caller
// would gate this on a `DragArbiter`/`DragGesture`
// decision, but this test only needs to exercise the
// capture-and-release mechanics themselves.
ctx.data.render.capture_pointer(draggable_weak.id());
let _ = rsc;
}
CursorSense::Drop => dropped.set(true),
_ => {}
},
);
}
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&draggable, &mut rsc);
let mut state = ();
let mut press = cursor_at((5.0, 5.0).into());
press.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
assert_eq!(
render.captured_pointer(),
Some(draggable.id()),
"the press should have taken capture"
);
// The release lands nowhere near the widget's own region -- the exact
// shape of a fast fling's `ACTION_UP`.
let mut release = cursor_at((95.0, 95.0).into());
release.buttons.left = ActivationState::End;
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
assert!(
dropped.get(),
"a release outside every widget's hit region must still reach \
the widget holding pointer capture"
);
assert_eq!(
render.captured_pointer(),
None,
"Drop must release the capture"
);
}
/// A widget that never registers `CursorSense::Drop` at all must not be
/// affected by someone else's capture -- capture is per-gesture, not
/// global suppression of the whole input system for widgets that were
/// never party to it. (Practically this matters because a captured
/// widget's registration list still has to include `Drop` for `should_run`
/// to ever match it; this pins that half of the contract.)
#[test]
fn capturing_one_widget_starves_every_other_widget_of_events() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let a_weak = a.weak();
let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let b_weak = b.weak();
let b_hovered = Rc::new(Cell::new(false));
{
let b_hovered = b_hovered.clone();
rsc.register_event(b_weak, CursorSense::Hovering, move |_ctx, _rsc| {
b_hovered.set(true);
});
}
let root = rsc
.ui
.widgets
.add_strong(Stack {
children: vec![a.any(), b.any()],
size: StackSize::default(),
})
.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
render.capture_pointer(a_weak.id());
let mut state = ();
let cursor = cursor_at((50.0, 50.0).into());
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
assert!(
!b_hovered.get(),
"while a's drag holds capture, b must see no hover at all"
);
}
+71
View File
@@ -552,6 +552,20 @@ impl List {
self.extents.get(&key).map(|e| (e.top, e.bottom)) self.extents.get(&key).map(|e| (e.top, e.bottom))
} }
/// The row whose on-screen box (as of the last layout) contains
/// `viewport_pos`, or `None` if it falls outside every row currently
/// drawn (a gap, a header, or off the loaded content entirely). O
/// (visible rows), same as `reanchor_at_tap`. What a caller resolves a
/// pointer-captured gesture's row-under-the-finger against once the
/// gesture is no longer being delivered through any one row's own hit
/// region -- see `iris::sense`'s pointer-capture doc.
pub fn key_at(&self, viewport_pos: f32) -> Option<RowKey> {
self.extents
.iter()
.find(|(_, ext)| viewport_pos >= ext.top && viewport_pos <= ext.bottom)
.map(|(&key, _)| key)
}
fn slot_exists(&self, slot: isize) -> bool { fn slot_exists(&self, slot: isize) -> bool {
match slot { match slot {
BEFORE_SLOT => self.more_before.is_some(), BEFORE_SLOT => self.more_before.is_some(),
@@ -1332,6 +1346,63 @@ mod tests {
} }
} }
/// RUST.md's P0 phone report (Iris's screenshot, 2026-09-06): a
/// replaced row's primitives drawn a second time, overlapping the
/// replacement. Reproduces the exact path `TranscriptScreen::apply`'s
/// `ReplaceLast` case drives up to 400 times during a streamed reply
/// (`bench_client.rs`'s stream phase): the last slot's widget is
/// swapped for a brand-new one, same key, and (since a fresh widget
/// has no cached height) placed via `place`'s `draw_twice` path every
/// time -- the provisional-then-real two-draw sequence LAYOUT.md
/// documents as the one place in this crate that deliberately draws a
/// widget twice. If `draw_inner`'s old-children diffing or
/// `UiRenderState::remove`'s primitive freeing ever failed to retire
/// the evicted widget (or the provisional draw's own primitives), it
/// would show up here as `active_widgets` growing without bound.
/// **Passes as written** -- this pins the widget-arena layer as
/// correct in isolation; see the P0 box for where the duplicate was
/// actually chased to instead (`Span`'s two-phase draw and the
/// `redraw_all`-vs-`redraw_updates` split, still open).
#[test]
fn replacing_the_last_row_many_times_does_not_leak_primitives() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
for key in 0..5u64 {
let (_bg_id, row) = background_styled_row(&mut rsc, 20.0);
list.push_back(ListRow::new(key, row));
}
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let before = render.active_widgets();
for i in 0..400u32 {
// A varying height keeps every replace on the `draw_twice`
// (cache-miss) path rather than settling into the O(1)
// same-size `mov` fast path once the height happens to repeat.
let (_bg_id, new_row) = background_styled_row(&mut rsc, 20.0 + (i % 3) as f32);
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.replace_back(ListRow::new(4, new_row));
render.update(&root, &mut rsc);
}
let after = render.active_widgets();
assert_eq!(
before, after,
"400 replaces of the last row must leave exactly the same \
number of active widgets as before a leaked id (and the \
primitives that live as long as its ActiveData does) would \
show up here as growth"
);
}
/// Enough rows, tall enough, that a fling toward the start has real /// Enough rows, tall enough, that a fling toward the start has real
/// room to travel before `at_start` clamps it -- shared by the fling /// room to travel before `at_start` clamps it -- shared by the fling
/// tests below. /// tests below.
+38 -1
View File
@@ -51,7 +51,7 @@ pub mod selection;
use client_core::transcript_fold::TranscriptRow as FoldedRow; use client_core::transcript_fold::TranscriptRow as FoldedRow;
use iris::prelude::*; use iris::prelude::*;
use selection::Selection; use selection::Selection;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc, time::Instant};
pub struct TranscriptScreen { pub struct TranscriptScreen {
/// The transcript's own `List` -- exposed so a caller can read /// The transcript's own `List` -- exposed so a caller can read
@@ -220,6 +220,43 @@ where
}) })
.add(rsc); .add(rsc);
// The continuation of a row-started drag once it has committed and
// taken pointer capture on `list`'s own id (`row.rs`'s registration is
// only ever the gesture's first frame) -- registered once here, not
// once per row, since `DragGesture`'s single shared instance must see
// each frame of one gesture exactly once. `ctx.data.pos`/`size` are
// already relative to `list`'s own on-screen box (this is what it was
// registered against), which is exactly the viewport-pixel space
// `List::key_at`/`extent` work in, so the row-under-the-pointer is
// resolved from those instead of a per-row hit test.
{
let selection = selection.clone();
list.on(
CursorSense::Pressing(CursorButton::Left) | CursorSense::Drop,
move |ctx, rsc| {
let pos = ctx.data.pos;
let row = list(rsc).key_at(pos.y).and_then(|key| {
let (top, bottom) = list(rsc).extent(key)?;
Some((
key,
Vec2::new(pos.x, pos.y - top),
Vec2::new(ctx.data.size.x, bottom - top),
))
});
selection.borrow_mut().drag(
rsc,
list,
row,
ctx.data.cursor.pos,
ctx.data.sense,
Instant::now(),
ctx.data.render,
);
},
)
.add(rsc);
}
let (composer, composer_bar) = composer::build_composer(rsc); let (composer, composer_bar) = composer::build_composer(rsc);
let tree = (list.width(rest(1)).height(rest(1)), composer_bar) let tree = (list.width(rest(1)).height(rest(1)), composer_bar)
+11 -5
View File
@@ -137,20 +137,26 @@ where
field field
// `| CursorSense::unclick()` on top of the usual click-or-drag set // `| CursorSense::unclick()` on top of the usual click-or-drag set
// -- the arbiter inside `Selection::drag` needs the release too, // -- this row's own registration only ever needs to see a
// to go back to idle for the next press (`DragArbiter::release`). // gesture's *first* frame (`PressStart`, or a `Pressing` that
// missed it -- `DragGesture::handle`'s idle-recovery branch); once
// it commits, `DragGesture` takes pointer capture on `list`'s own
// id and every further frame, including the terminal `Drop`,
// reaches `lib.rs`'s list-level registration instead -- see
// `iris::sense`'s pointer-capture doc for why that has to be a
// stable id rather than this row's, which `List` can retire mid-
// drag as content scrolls.
.on( .on(
CursorSense::click_or_drag() | CursorSense::unclick(), CursorSense::click_or_drag() | CursorSense::unclick(),
move |ctx, rsc| { move |ctx, rsc| {
selection.borrow_mut().drag( selection.borrow_mut().drag(
rsc, rsc,
list, list,
key, Some((key, ctx.data.pos, ctx.data.size)),
ctx.data.pos,
ctx.data.size,
ctx.data.cursor.pos, ctx.data.cursor.pos,
ctx.data.sense, ctx.data.sense,
Instant::now(), Instant::now(),
ctx.data.render,
); );
}, },
) )
+48 -70
View File
@@ -36,17 +36,15 @@ use std::{collections::BTreeMap, time::Instant};
pub struct Selection { pub struct Selection {
rows: BTreeMap<RowKey, WeakWidget<TextEdit>>, rows: BTreeMap<RowKey, WeakWidget<TextEdit>>,
anchor: Option<(RowKey, Vec2)>, anchor: Option<(RowKey, Vec2)>,
/// One arbiter shared by every row's drag handler -- RUST.md's I5 /// One gesture shared by every row's drag handler -- RUST.md's I5
/// gesture conflict (a row's own `click_or_drag()` and a list-level /// gesture conflict (a row's own `click_or_drag()` and a list-level
/// pan wanting the same touch gesture). See `drag` below, and /// pan wanting the same touch gesture). See `drag` below, and
/// `iris::sense::DragArbiter`'s own doc for the decision itself. /// `iris::sense::DragGesture`'s own doc for the arbitration, velocity
arbiter: DragArbiter, /// tracking and pointer-capture mechanics this no longer owns itself
/// Tracks the last ~100ms of this gesture's pan deltas (in the same /// -- Iris's 2026-09-06 ask (`IRIS.md`) that a drag's *mechanics* live
/// signed units `list.scroll` takes), so a release that turns out to /// in iris's default input layer, with only the pan-vs-select
/// have been panning can hand `List::fling` a realistic initial /// *decision* staying here.
/// velocity instead of one frame's noisy last delta -- gesture: DragGesture,
/// IRIS_TODO.md's "swiping has no momentum."
velocity: VelocityTracker,
} }
impl Default for Selection { impl Default for Selection {
@@ -60,8 +58,7 @@ impl Selection {
Self { Self {
rows: BTreeMap::new(), rows: BTreeMap::new(),
anchor: None, anchor: None,
arbiter: DragArbiter::new(), gesture: DragGesture::new(),
velocity: VelocityTracker::new(),
} }
} }
@@ -165,85 +162,66 @@ impl Selection {
/// row, is what makes that consistent as a drag crosses row /// row, is what makes that consistent as a drag crosses row
/// boundaries). /// boundaries).
/// ///
/// `pos_row`/`size` are row-local, as `begin`/`extend` want; /// `row`, if given, is `(key, pos_row, size)` for whichever row the
/// pointer is currently over -- row-local, as `begin`/`extend` want.
/// `None` once the gesture is pointer-captured (`iris::sense`'s
/// pointer-capture doc) and the current position falls outside every
/// row `List` has loaded (a gap, or off the end of the content); a
/// `Pan` outcome never needs it, so this only actually matters mid-
/// selection, where it is rare and the frame is simply dropped.
/// `pos_window` is in window space, since a pan's delta has to stay /// `pos_window` is in window space, since a pan's delta has to stay
/// meaningful even when this frame's event landed on a different row /// meaningful even when this frame's event landed on a different row
/// than the last one. /// than the last one. `render` is `CursorData`'s own field -- what
/// `DragGesture` needs to take pointer capture.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn drag( pub fn drag(
&mut self, &mut self,
ui: &mut impl UiRsc, ui: &mut impl UiRsc,
list: WeakWidget<List>, list: WeakWidget<List>,
key: RowKey, row: Option<(RowKey, Vec2, Vec2)>,
pos_row: Vec2,
size: Vec2,
pos_window: Vec2, pos_window: Vec2,
sense: CursorSense, sense: CursorSense,
now: Instant, now: Instant,
render: &UiRenderState,
) { ) {
let outcome = match sense { if matches!(sense, CursorSense::PressStart(_)) {
CursorSense::PressStart(_) => {
let already_selected = self.has_selection(ui);
self.arbiter.press_start(pos_window, now, already_selected);
self.velocity.reset();
// A fresh touch-down cancels any fling still coasting from // A fresh touch-down cancels any fling still coasting from
// the previous gesture -- `List::fling`'s own doc, and // the previous gesture -- `List::fling`'s own doc, and
// Android's `Scroller::abortAnimation` for the same reason. // Android's `Scroller::abortAnimation` for the same reason.
list(ui).cancel_fling(); list(ui).cancel_fling();
self.arbiter.update(pos_window, now)
} }
CursorSense::PressEnd(_) => {
// A fling only ever follows a pan -- never a selection
// that happened to end with the finger still moving, and
// never a tap/long-press that never left `Undecided`.
if self.arbiter.is_panning() {
let v = self.velocity.velocity();
list(ui).fling(v);
}
self.arbiter.release();
return;
}
// A `Pressing` frame with the arbiter still `Idle` means this
// gesture's `ACTION_DOWN` landed somewhere no row's sensor
// covers (a row's own padding/gap, or a header with no
// selection handler) and this row is only now getting the
// touch as it moves across it -- the touch is definitely still
// down (that's what `Pressing` means), so without this the
// arbiter would sit in `Idle` answering `Undecided` for the
// rest of the gesture (`DragArbiter::update`'s own doc).
// Recovered by starting the press here instead of where it
// was missed -- RUST.md's I5 intermittent-touch-scroll-dropout
// finding, 2026-09-05.
_ if self.arbiter.is_idle() => {
let already_selected = self.has_selection(ui); let already_selected = self.has_selection(ui);
self.arbiter.press_start(pos_window, now, already_selected); let outcome =
self.velocity.reset(); self.gesture
list(ui).cancel_fling(); .handle(render, list.id(), sense, pos_window, now, already_selected);
self.arbiter.update(pos_window, now)
}
_ => self.arbiter.update(pos_window, now),
};
match outcome { match outcome {
DragOutcome::Undecided => {} GestureOutcome::Undecided => {}
DragOutcome::Pan(dy) => { GestureOutcome::Pan(dy) => list(ui).scroll(-dy),
let amt = -dy; GestureOutcome::SelectStart => {
self.velocity.add_sample(amt, now); if let Some((key, pos_row, size)) = row {
list(ui).scroll(amt); // Grep-able on "iris selection" the way the frame
} // report is on "iris frame report" -- selection has no
DragOutcome::SelectStart => { // accessibility label of its own yet, so this is the
// Grep-able on "iris selection" the way the frame report is // smallest way to confirm a real on-device long-
// on "iris frame report" -- selection has no accessibility // press-then-drag actually reached here (RUST.md's I5
// label of its own yet, so this is the smallest way to // box, "Measurements taken" (c)).
// confirm a real on-device long-press-then-drag actually
// reached here (RUST.md's I5 box, "Measurements taken" (c)).
log::info!("iris selection: begin at row {key:?}"); log::info!("iris selection: begin at row {key:?}");
self.begin(ui, key, pos_row, size); self.begin(ui, key, pos_row, size);
} }
DragOutcome::SelectExtend => { }
GestureOutcome::SelectExtend => {
if let Some((key, pos_row, size)) = row {
log::info!("iris selection: extend to row {key:?}"); log::info!("iris selection: extend to row {key:?}");
self.extend(ui, key, pos_row, size); self.extend(ui, key, pos_row, size);
} }
} }
// A fling only ever follows a pan -- never a selection that
// happened to end with the finger still moving, and never a
// tap/long-press that never left `Undecided` -- exactly what
// `DragGesture`'s `Some(v)` already encodes.
GestureOutcome::Released(Some(v)) => list(ui).fling(-v),
GestureOutcome::Released(None) => {}
}
} }
/// The concatenated selected text, in row order, `None` if nothing is /// The concatenated selected text, in row order, `None` if nothing is
@@ -344,8 +322,9 @@ mod tests {
let mut sel = Selection::new(); let mut sel = Selection::new();
sel.register(1, field); sel.register(1, field);
assert!(sel.arbiter.is_idle()); assert!(sel.gesture.is_idle());
let render = UiRenderState::new();
let now = Instant::now(); let now = Instant::now();
let size = Vec2::new(100.0, 20.0); let size = Vec2::new(100.0, 20.0);
// No `PressStart` is ever sent -- only the `Pressing` frames a // No `PressStart` is ever sent -- only the `Pressing` frames a
@@ -353,15 +332,14 @@ mod tests {
sel.drag( sel.drag(
&mut rsc, &mut rsc,
list, list,
1, Some((1, Vec2::ZERO, size)),
Vec2::ZERO,
size,
Vec2::new(540.0, 700.0), Vec2::new(540.0, 700.0),
CursorSense::Pressing(CursorButton::Left), CursorSense::Pressing(CursorButton::Left),
now, now,
&render,
); );
assert!( assert!(
!sel.arbiter.is_idle(), !sel.gesture.is_idle(),
"a Pressing frame with the arbiter still Idle must recover \ "a Pressing frame with the arbiter still Idle must recover \
the press rather than leaving it stuck" the press rather than leaving it stuck"
); );