Stop drawing one tool call twice where a page of history begins

A page boundary lands wherever it lands, and about half the time that is
between a tool call and its result. The newer page then holds a `ToolEnd`
whose start it never saw, which the fold draws as a row of its own --
correctly, since a call rendering as nothing is indistinguishable from
one that never happened. But when the older page arrived it brought the
real `ToolStart`, and the two lists were concatenated, so the call was
left on screen twice: once as a proper card and once as a nameless
placeholder.

`joinPages` merges the two halves by the call's own id instead, which is
the one thing a page boundary cannot destroy. The older half wins on what
a start knows -- the tool's name, its input -- and the newer on what an
end knows, its output and whether it finished.

The miscount was the visible part; the moving was the point. The extra
row sits exactly at the join, which is where the reader is looking when
the page loads, so everything below it stepped down by a row at the
moment they scrolled into it.

Demonstrated both ways round on a rig of twelve `/tools 8` runs, whose
groups are eight calls each and whose page boundary falls inside the
second one: without this the transcript reads "Called 9 tools" there and
eight everywhere else, with it every group reads eight.

That rig is `/mixed N` in the echo driver, added here: N beats of
paragraphs at three lengths, single tool calls, runs of adjacent ones,
images and peer messages -- every row shape the app draws, in one
session, from a command that costs nothing and produces the same
transcript every time. The paragraphs are deliberately ragged, because a
wall of identical lines looks the same at every offset and makes a scroll
of one line indistinguishable from a scroll of ten, by eye or by
comparing frames.
This commit is contained in:
iris committed 2026-08-29 23:34:11 -04:00
1 parent a49120b0c8
commit 2dc61c5780
3 files changed
+182 -3

No files matched your search

+134 -1
View File
@@ -25,6 +25,12 @@
//! This is exactly the event vocabulary the real drivers produce, so a UI
//! that renders echo sessions correctly renders the real thing.
//!
//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of
//! different lengths, single tool calls, runs of adjacent ones, images
//! and a peer message. Rows of every shape and height the app draws, in
//! one session, which is what a scrolling problem needs in order to be
//! reproduced twice the same way.
//!
//! `/slow` earns its place: a queued message, a Stop button, a spinner
//! where the answer will go are all states that only exist mid-turn, and
//! the obvious way to get one -- ask a real model to sleep -- does not
@@ -33,6 +39,7 @@
//! account. A driver that can be *told* to take its time costs nothing and
//! is the same every run.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -76,6 +83,9 @@ pub struct EchoDriver {
/// Held messages with the id of the `MessageQueued` each one announced,
/// so the announcement can say which waiting bubble it resolves.
queued: Arc<Mutex<Vec<(String, String)>>>,
/// Where `/mixed` writes the images it references, which is the same
/// directory the files route serves them from.
session_dir: PathBuf,
/// Ids of the questions awaiting an answer, in the order they were
/// asked. A list because `/ask` puts up to four on one tool call, the
/// way AskUserQuestion does, and the turn resumes when the last of
@@ -316,6 +326,9 @@ impl EchoDriver {
// rather than trusted: this is a test affordance, and a session
// pinned running for an hour by a typo is a worse outcome than a
// short wait.
let mixed = text
.strip_prefix("/mixed")
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
let linger = text.strip_prefix("/slow").map(|rest| {
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
});
@@ -324,6 +337,7 @@ impl EchoDriver {
.map(|rest| rest.trim().to_string());
let busy = Arc::clone(&self.busy);
let queued = Arc::clone(&self.queued);
let dir = self.session_dir.clone();
busy.store(true, Ordering::SeqCst);
tokio::spawn(async move {
let send = |event: Event| {
@@ -396,6 +410,14 @@ impl EchoDriver {
return;
}
if let Some(beats) = mixed {
for beat in 1..=beats {
write_beat(&sink, &dir, beat).await;
}
finish();
return;
}
if let Some(input) = run_tool {
let id = format!("t-{}", super::random_hex());
send(Event::ToolStart {
@@ -429,12 +451,13 @@ impl EchoDriver {
});
}
pub fn new(sink: EventSink) -> Self {
pub fn new(sink: EventSink, session_dir: PathBuf) -> Self {
let driver = Self {
sink,
pending_questions: Mutex::new(Vec::new()),
busy: Arc::new(AtomicBool::new(false)),
queued: Arc::new(Mutex::new(Vec::new())),
session_dir,
};
driver.emit(Event::Status {
state: SessionStatus::Idle,
@@ -450,6 +473,116 @@ impl EchoDriver {
}
}
/// A 16x10 checkerboard, the smallest thing that is recognisably an image
/// rather than a blank rectangle.
///
/// Embedded rather than generated because the alternative is a PNG encoder
/// in a test rig, and drawn at the transcript's fixed thumbnail height
/// anyway -- what a scroll test needs from an image is that it occupies an
/// image's worth of space, not that it is pretty.
const SAMPLE_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAKCAIAAAAy3EnLAAAAIklEQVR42mPo3PILiOTk9ICIGDYDyRqIVwphk65h1A9EsAGCYdJRj+JH4wAAAABJRU5ErkJggg==";
/// One beat of `/mixed`: a row shape chosen by position, so the same N
/// always produces the same transcript.
///
/// Repeatable on purpose. A scrolling fault is judged by watching the same
/// content behave differently, and a rig that produced a different
/// transcript each run would make every comparison an argument about
/// whether the content changed.
async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
let send = |event: Event| {
let _ = sink.send(event);
};
match beat % 5 {
// A paragraph, of three lengths, because a list of uniform rows
// hides exactly the faults that uneven ones expose.
1 => {
let words = match beat % 3 {
0 => 12,
1 => 60,
_ => 220,
};
// Deliberately ragged: each word's length is a function of its
// position, so no two lines wrap the same way. A paragraph of
// uniform tokens is a wall that looks identical at every
// offset, which makes it impossible to tell a scroll of one
// line from a scroll of ten -- by eye or by comparing frames.
let body: String = (0..words)
.map(|w| {
let len = 3 + (w * 7 + beat * 3) % 14;
format!("{beat}.{w}{} ", "x".repeat(len))
})
.collect();
send(Event::AssistantText {
delta: format!("\n\nParagraph at beat {beat}:\n{body}"),
});
}
// One call on its own -- drawn as a card rather than a group.
2 => {
let id = format!("t-{}", super::random_hex());
send(Event::ToolStart {
id: id.clone(),
tool: "Read".to_string(),
input: serde_json::json!({ "file_path": format!("/tmp/beat-{beat}.txt") }),
});
send(Event::ToolEnd {
id,
output: format!("beat {beat}: forty-two lines of nothing in particular"),
});
}
// A run of three, which the app folds into one collapsed group --
// the row whose identity depends on what is next to it.
3 => {
for i in 1..=3 {
let id = format!("t-{}", super::random_hex());
send(Event::ToolStart {
id: id.clone(),
tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(),
input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }),
});
send(Event::ToolEnd {
id,
output: format!("beat {beat}, call {i} of 3"),
});
}
}
// An image, under the call that produced it, which is where a real
// screenshot lands.
4 => {
let id = format!("t-{}", super::random_hex());
send(Event::ToolStart {
id: id.clone(),
tool: "Screenshot".to_string(),
input: serde_json::json!({ "description": format!("beat {beat}") }),
});
let part = serde_json::json!({
"source": {"media_type": "image/png", "data": SAMPLE_PNG}
});
if let Some(name) = super::claude::translate::save_image(session_dir, &part) {
send(Event::Image {
image: name,
about: Some(id.clone()),
});
}
send(Event::ToolEnd {
id,
output: format!("beat {beat}: captured"),
});
}
// Somebody else's voice, which is its own row shape.
_ => {
send(Event::PeerMessage {
from: format!("beat-{beat}-peer"),
text: format!("Message {beat} from another session, for the row it becomes."),
});
}
}
// Slow enough that the phone renders each beat as it arrives rather
// than composing the whole run in one frame -- which is the condition
// a scrolling fault actually happens under.
tokio::time::sleep(Duration::from_millis(120)).await;
}
/// Ending a turn is also when anything held during it is taken up -- the
/// moment a real CLI would have injected it. One place, because a turn has
/// several ways to end (a reply, an interrupt, a compaction) and every one
+1 -1
View File
@@ -1130,7 +1130,7 @@ fn launch(
}
let driver: Arc<dyn Driver> = match provider.kind {
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone())),
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone(), dir.clone())),
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
&meta,
provider,