diff --git a/plugins/markdown/src/md/render_ast.zig b/plugins/markdown/src/md/render_ast.zig index 8a13d0fd7..795dca39a 100644 --- a/plugins/markdown/src/md/render_ast.zig +++ b/plugins/markdown/src/md/render_ast.zig @@ -1949,8 +1949,16 @@ fn renderTopLevel(doc_node: ast.Node, ids: *IdGen, ctx: RenderContext) void { // everything below jumps, and next frame's anchor "corrects" it — the flash that // only happens when scrolling up. Pin the *emitted* height to the table; `record` still // learns the real size from the unconstrained inner box (see `measured` below). - const pin_for_above = known_h > 0 and !width_in_flux and y <= anchor_y; - const pin_h: ?f32 = if (known_h > 0 and !width_in_flux and + // + // Only a virtualized render pins. With no viewport (`drawInline`: a chat bubble, a + // tooltip) every block is laid out every frame, so no block comes back from a spacer and + // there is nothing for a pin to hide. There a pin does harm: an edit re-parses, which + // leaves the edited block with an estimate (biased low) or the deferred height its first + // frame filed, and next frame's pin emits that in place of its real height. The caller + // sees the block shrink by a line and grow back; a chat streaming a reply re-parses on + // every chunk, so its conversation bounced by a line each time. + const pin_for_above = virtualize and known_h > 0 and !width_in_flux and y <= anchor_y; + const pin_h: ?f32 = if (virtualize and known_h > 0 and !width_in_flux and (pin_for_above or (frozen and block_state != .estimated) or pinnable)) known_h else null; diff --git a/tests/integration.zig b/tests/integration.zig index 0cbe048a8..87b276feb 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7281,6 +7281,24 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes for (0..3) |_| _ = try dvui.testing.step(inlineMarkdownFrame); try std.testing.expect(inline_md_height > one_para * 2); inline_md.?.release(0x1d1e); + + // Streamed in a few bytes a frame, as a chat reply arrives, it only ever grows. Each + // re-parse left the edited block with an estimate that a pin then emitted for a frame, so + // a streaming reply shrank by a line and grew back, over and over. + const reply = "Sure. Let me look at how the view lays out a message while it streams, because that is where the motion comes from.\n\nThe **scroll area** sizes its content from last frame's sizes, and a message that grows by a line is a frame late.\n\n- one point\n- and a second that goes on long enough to wrap at this width, and then some more"; + var last: f32 = 0; + var n: usize = 0; + while (n < reply.len) { + n = @min(reply.len, n + 9); + inline_md_text = reply[0..n]; + _ = try dvui.testing.step(inlineMarkdownFrame); + if (inline_md_height + 0.5 < last) { + std.debug.print("inline markdown shrank from {d:.1} to {d:.1} at byte {d}\n", .{ last, inline_md_height, n }); + return error.TestUnexpectedResult; + } + last = inline_md_height; + } + inline_md.?.release(0x1d1e); } // The `plugins` service: what each plugin's loading did. A built-in is loaded; a reload that