Skip to content

Commit 3d961ef

Browse files
committed
Fix duplicated-write ghost: head-anchor the streaming write preview so the freeze handoff is append-only (tail window shifted committed scrollback rows)
1 parent b34a093 commit 3d961ef

1 file changed

Lines changed: 52 additions & 34 deletions

File tree

src/runtime/view/thread/turn/agent_timeline/tool_body_preview.cpp

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,28 @@ constexpr std::size_t kLiveTailLines = 64;
106106
return std::string{s.substr(start)};
107107
}
108108

109+
// First `keep_lines` lines of `s`, head-anchored. Unlike tail_window,
110+
// this is APPEND-ONLY as `s` grows: line 1..keep_lines never change
111+
// content when more lines arrive at the end, they just stop being the
112+
// last thing rendered. Used for the streaming WRITE preview so the
113+
// rows it commits to native scrollback are byte-identical to the
114+
// settled show_all render's first rows — a tail-anchored preview
115+
// instead SHIFTS its top rows as the file grows, so when those rows
116+
// have already overflowed into scrollback and the card later settles to
117+
// a head-anchored full body, the committed rows no longer match and the
118+
// card is re-emitted below them (the duplicated-write ghost).
119+
[[nodiscard]] std::string head_window(std::string_view s,
120+
std::size_t keep_lines) {
121+
if (s.empty()) return {};
122+
std::size_t nl_seen = 0;
123+
for (std::size_t i = 0; i < s.size(); ++i) {
124+
if (s[i] == '\n') {
125+
if (++nl_seen >= keep_lines) return std::string{s.substr(0, i + 1)};
126+
}
127+
}
128+
return std::string{s};
129+
}
130+
109131
// Body for a terminal line-oriented tool sitting in the LIVE tail.
110132
// Frozen builds keep the full body (full content, painted once); the
111133
// live path elides to a bounded tail window so per-frame split_lines
@@ -300,44 +322,40 @@ maya::ToolBodyPreview::Config tool_body_preview_config(
300322
out.kind = Kind::FileWrite;
301323
out.text_color = text_tertiary;
302324
out.show_footer_stats = true;
303-
// During streaming the body grows every ~120 ms — `show_all`
304-
// would make the card height balloon row-by-row as deltas
305-
// arrive, and any rows already pushed to native scrollback
306-
// disagree with the next live re-render (rails fragment,
307-
// comments from later lines bleed onto earlier rows in the
308-
// viewport). Pin the streaming preview to a head+tail window
309-
// so the card stays a fixed height for the whole stream;
310-
// expand to `show_all` only when the tool is terminal and
311-
// the body is final.
312-
out.show_all = !streaming_now;
325+
// While streaming, the body grows every ~120 ms. The PRIOR
326+
// approach pinned the preview to a TAIL window (last N lines):
327+
// as the file grew, the top rows of that window kept changing
328+
// content. Once they overflowed into native scrollback they
329+
// were frozen there — but on settle the card switches to a
330+
// head-anchored show_all render from line 1, so the committed
331+
// rows no longer matched and the whole card was re-emitted
332+
// below them: the write appeared TWICE in scrollback.
333+
//
334+
// Fix: render the streaming preview HEAD-anchored with
335+
// show_all over a bounded head window. The first N lines never
336+
// change as more arrive (append-only growth), and they are
337+
// byte-identical to the settled show_all render's first N
338+
// lines — so anything that commits to scrollback mid-stream
339+
// stays valid through the freeze handoff. No row is ever
340+
// rewritten; the card only grows downward.
313341
out.is_streaming = streaming_now;
314342
if (streaming_now) {
315-
// Feed maya only the tail window. The widget renders just
316-
// the last few lines while streaming (show_all=false), so
317-
// slicing here makes the per-frame copy + split_lines
318-
// O(window) instead of O(file) — the dominant cost when a
319-
// large write streams in. maya's footer derives N-lines /
320-
// bytes from out.text, so a sliced body would show a wrong
321-
// total; suppress it mid-stream (the status bar carries the
322-
// live byte/tok rate) and it returns with the true total
323-
// the instant the tool goes terminal (full body, show_all).
324-
out.text = tail_window(content, kStreamTailLines);
343+
// Head window, full render: append-only and seam-stable.
344+
// O(window) per frame (the slice bounds split_lines).
345+
// Footer derives line/byte totals from out.text, which is
346+
// a partial head here, so suppress it mid-stream (the
347+
// status bar carries the live byte/tok rate); it returns
348+
// with the true total the instant the tool settles.
349+
out.text = head_window(content, kStreamTailLines);
350+
out.show_all = true;
325351
out.show_footer_stats = false;
326-
} else if (!building_frozen()) {
327-
// Terminal, but sitting in the LIVE tail (run not settled
328-
// yet, re-rendered every frame). The content is FINAL —
329-
// no more deltas arrive — so show it in full immediately
330-
// instead of waiting for the next Tick's
331-
// freeze_settled_subturns to graduate it into the frozen
332-
// prefix. Waiting caused a visible "card stays a stub for
333-
// a beat after the tool finished, then suddenly expands"
334-
// lag. Per-frame split_lines over a stable body for the
335-
// one-or-two frames before the freeze handoff is
336-
// negligible, and the frozen snapshot reuses the same
337-
// show_all body so the freeze instant is seamless (no
338-
// height pop).
339-
out.text = std::move(content);
340352
} else {
353+
// Terminal: full body, head-anchored, in BOTH the live
354+
// tail and the frozen snapshot. Identical bytes / anchor /
355+
// height across the freeze instant, and a pure superset of
356+
// the streaming head window above — so the handoff never
357+
// moves a committed row.
358+
out.show_all = true;
341359
out.text = std::move(content);
342360
}
343361
} else if (tc.is_running()) {

0 commit comments

Comments
 (0)