Fetch the transcript instead of replaying it one event at a time

**The five seconds of loading top-down.** Opening a session subscribed to
the event stream from sequence zero, so the backlog arrived as one SSE
frame per event -- 864 of them for an imported conversation, rendered as
they landed. That is not a slow list; it is a conversation being replayed
at network speed, and it looks like loading from the top because it is.

The newest page now comes as one request, and the stream starts from where
that page ended, carrying live events only -- which is what a stream is
good at. Scrolling back fetches the page before it, so history costs
something only when somebody actually reads it. 80 events instead of 864,
and the first frame is already the end of the conversation.

I had called this fixed after anchoring the list at the bottom, on the
strength of an emulator on the same machine as the server. That test could
not have shown the problem: the whole backlog arrived in one frame's worth
of time over loopback. Iris's phone, over a tunnel, took five seconds.

**Send disappearing while running.** It was never conditional -- the row
simply ran out of width. A Row hands out intrinsic widths in order and
clips the overflow, so when Stop appeared the pickers I had added pushed
Send off the screen: the app's central control, gone at exactly the moment
the app is most in use. The settings now share what is left after the
actions have taken what they need.

While a turn is in flight the button says **Queue**, because that is what
sending then does -- the message is injected at the next tool boundary
rather than starting a turn of its own. The backend has always done this;
the button was describing something else.

**And the model picker no longer dismisses the keyboard**, which it did by
taking focus. Changing the model mid-sentence is an aside, not a departure
from what you were typing.

Verified on the 864-event import: at the newest message within a second,
history paging back continuously past the first page, and Stop beside
Queue while running.
This commit is contained in:
iris committed 2026-08-28 23:12:34 -04:00
1 parent ba25a5cacf
commit dcb158ee44
4 files changed
+203 -22

No files matched your search

+36
View File
@@ -64,6 +64,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions", get(list_sessions).post(spawn_session))
.route("/sessions/{id}", delete(delete_session))
.route("/sessions/{id}/events", get(events))
.route("/sessions/{id}/transcript", get(transcript))
.route("/sessions/{id}/message", post(message))
.route("/sessions/{id}/answer", post(answer))
.route("/sessions/{id}/interrupt", post(interrupt))
@@ -720,6 +721,41 @@ struct EventsQuery {
/// cursor from the transcript, then live events as they happen. An SSE
/// auto-reconnect sends the last event id it saw as `Last-Event-ID`, which
/// takes precedence over `after` -- same cursor, native mechanism.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TranscriptQuery {
/// Page backwards from this sequence number; absent means the newest.
#[serde(default)]
before: Option<u64>,
#[serde(default = "default_window")]
limit: usize,
}
fn default_window() -> usize {
80
}
/// A page of a session's transcript, newest first to open with.
///
/// One request rather than one stream frame per event. The SSE stream
/// stays as it is and remains the right shape for *live* events, which
/// arrive one at a time by nature; it is only the backlog that has to
/// stop pretending to be live.
async fn transcript(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
Query(query): Query<TranscriptQuery>,
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
let session = lookup(&manager, &id)?;
let events = crate::session::transcript::read_window(
session.transcript_path(),
query.before,
query.limit,
)
.map_err(bad_request)?;
Ok(axum::Json(events))
}
async fn events(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
+23
View File
@@ -73,6 +73,29 @@ impl Transcript {
/// Replays every event with `seq > after`, oldest first. A missing file is
/// an empty transcript, not an error -- the session just hasn't produced an
/// event yet.
/// A window of the transcript ending just before `before`, newest-biased.
///
/// The screen opens on the end of a conversation, not the start of it, and
/// the end is all it can show at once. Replaying the whole file to get
/// there costs one network frame per event -- on an 863-event import that
/// was several seconds of messages arriving oldest-first, which reads as
/// the app loading top-down because that is exactly what it was doing.
///
/// `before` pages backwards for history somebody actually scrolls to; the
/// file is read whole each time because a transcript is small and a
/// seek-backwards reader would be a lot of machinery for a list that fits
/// in memory anyway.
pub fn read_window(path: &Path, before: Option<u64>, limit: usize) -> Result<Vec<SeqEvent>> {
let mut all = read_after(path, 0)?;
if let Some(before) = before {
all.retain(|entry| entry.seq < before);
}
if all.len() > limit {
all.drain(..all.len() - limit);
}
Ok(all)
}
pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
let file = match File::open(path) {
Ok(file) => file,