Hold the transcript still under a scroll, and parse replies before drawing
Both faults needed a real conversation to see, so `app/debug-transcript.sh`
now puts one on the emulator: it copies a Claude Code transcript into /tmp,
gives an ai-server a HOME of its own so the import can only see the copy, and
enrols the app against it. The transcript itself never enters this repository
-- those files hold whatever was said, read and written in a session. Beside
it, `ai-server --delay MS` holds every response back, because a phone's
requests take tens to hundreds of milliseconds over the tunnel and several
faults live entirely in what the app does while one is outstanding.
**Scrolling up threw the reader back to the newest end, once.** `followTail`
is deliberately a remembered answer, rewritten only when a scroll settles, so
for the whole of a fling it still reports the newest end -- where the reader
was when they threw it. A page of history landing during that fling is a
change in the item count, and the correction written for an insertion at the
newest end fired for one at the oldest. Captured on the emulator:
scrolling=true atNewest=false followTail=true
history: START last=20 total=28
history: page of 80 events -> rows now 36
countChanged count=36 followTail=true scrolling=true
>>> scrollToItem(0) SNAP
It could happen only once, which is what made it look arbitrary rather than
mechanical: the snap settles the scroll at the newest end, so the next fling
gets far enough to settle away from it, and from then on `followTail` is
false. So the list is no longer moved while a scroll is running, which is a
rule of its own rather than a refinement of that condition -- and skipping
the correction outright is right rather than merely safe, because the count
can only grow at the newest end while the reader is already there, `record`
holding everything else until they come back.
**A page of history stalled the frame it appeared in.** Parsing is the
expensive half of drawing a reply and costs in proportion to what was
written: against this transcript one message took 51ms and several took
10-25ms, where the synthetic replies this was tuned on took 4.6ms. So each
page's replies are parsed on a background thread as the page arrives --
after the join, since a boundary falling through a reply leaves a message
made of both halves whose text has existed for no time at all, and warming
the page alone warmed the two halves and missed the one thing drawn. A row
with no answer waiting still parses inline: a row measured at nothing before
it is measured at its real height collapses the transcript above it. Misses
are not stored, so a reply still streaming cannot fill the map with copies
of itself on the way to being finished.
Measured over the same twelve flings: 13.5ms average per composed reply
before, 7us after, the remaining parse being one message at session open.
Verified with ui-trace at 1kHz: with a page landing mid-drag the suppression
fires and the row the reader is on moves monotonically down, 266 -> 1063,
with no step backwards; at rest 0 of 65 elements move. 86 server tests pass,
ktfmt/lint/clippy clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ed6e29bc6
commit
fd616361eb
7 files changed
+323
-18
No files matched your search
@@ -26,8 +26,10 @@ mod usage;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::middleware::Next;
|
||||
use clap::Parser;
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
@@ -78,6 +80,19 @@ struct Args {
|
||||
/// its enrollment QR -- the whole lost-phone story.
|
||||
#[arg(long)]
|
||||
rotate_token: bool,
|
||||
|
||||
/// Hold every response back by this many milliseconds.
|
||||
///
|
||||
/// A development aid, and a specific one: over the tunnel a phone's
|
||||
/// requests take tens to hundreds of milliseconds, and several faults
|
||||
/// live entirely in what the app does *while* one is outstanding --
|
||||
/// a page of history landing mid-fling, a screen drawn before its
|
||||
/// first answer arrives. On a loopback server every response is back
|
||||
/// within a millisecond or two, so those windows close before
|
||||
/// anything can be observed and the bug looks like it is not there.
|
||||
/// This reopens them on demand rather than by unplugging something.
|
||||
#[arg(long, default_value_t = 0, value_name = "MS")]
|
||||
delay: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -206,6 +221,22 @@ async fn main() -> Result<()> {
|
||||
auth::require_token,
|
||||
));
|
||||
|
||||
// Outside the auth layer, so an unauthenticated request is refused at
|
||||
// the speed it always was: this is here to slow the app down, not to
|
||||
// widen the window on anything guessing at tokens.
|
||||
let app = match args.delay {
|
||||
0 => app,
|
||||
ms => {
|
||||
tracing::warn!("delaying every response by {ms}ms -- development override");
|
||||
app.layer(axum::middleware::from_fn(
|
||||
move |request, next: Next| async move {
|
||||
tokio::time::sleep(Duration::from_millis(ms)).await;
|
||||
next.run(request).await
|
||||
},
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let addr = SocketAddr::new(bind_ip, args.port);
|
||||
tracing::info!("serving https://{addr}");
|
||||
|
||||
|
||||
Reference in new issue
Block a user