diff --git a/docs/superpowers/plans/2026-04-15-streaming-bounded-tween.md b/docs/superpowers/plans/2026-04-15-streaming-bounded-tween.md new file mode 100644 index 00000000..30899796 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-streaming-bounded-tween.md @@ -0,0 +1,582 @@ +# Streaming Bounded Tween Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a bounded per-update linear-interpolation tween to `StreamingAnimState` so that streaming bot replies look smooth even when the server emits coarse-grained edits (Matrix edit events are throttled every 200-1000ms upstream). + +**Architecture:** Each live `update_target` call with text growth captures `(reveal_base_char, reveal_base_time)`. `tick_with_elapsed` linearly interpolates `displayed_char_count` from `reveal_base_char` toward `target_char_count` over a fixed `TWEEN_DURATION = 150ms`. On `is_live=false` (finish edit) or on `new/restore` (first arrival), state syncs immediately without tween. `TWEEN_DURATION` is strictly smaller than any reasonable server throttle so the client always catches up before the next edit, guaranteeing no accumulated backlog and no PR #14-style long tail. + +**Tech Stack:** Rust, Makepad 2.0. Changes isolated to `src/home/streaming_animation.rs`. No changes to `src/home/room_screen.rs` (its frame handler already polls `needs_frame()` and schedules the next frame). + +**Spec:** [specs/task-restore-streaming-animation.spec.md](../../../specs/task-restore-streaming-animation.spec.md) (updated with tween decisions and completion criteria in this same branch). + +--- + +## File Structure + +- **Modify:** `src/home/streaming_animation.rs` — add `TWEEN_DURATION` const, add `reveal_base_char` and `reveal_base_time` fields, rewrite `update_target` / `tick_with_elapsed` / `needs_frame`, update `new` / `restore` to initialise reveal base at sync point. All tween logic confined to this file. + +- **Unchanged:** `src/home/room_screen.rs` — frame handler already calls `state.tick()` when `needs_frame()` returns true and invalidates `content_drawn_since_last_update` to force re-render. The new `needs_frame()` now returns true during tween, which cleanly re-activates the existing scheduling without any call-site changes. + +- **Unchanged:** `src/home/link_preview.rs` — orthogonal to tween. + +- **No new files.** + +--- + +## Task 1: Add failing tests for tween behaviour (TDD Red) + +**Files:** + +- Modify: `src/home/streaming_animation.rs` (add tests in `mod tests` at the bottom of the file) + +All tests added to the existing `mod tests` block. The tests assert behaviour that does not yet exist and should therefore FAIL until Task 2 implements it. + +- [ ] **Step 1.1: Add `test_update_target_live_growth_defers_sync_via_tween`** + +Insert after the existing `test_update_target_tracks_latest_full_snapshot` test (roughly after line 207 of the current file). Place before `test_update_target_shrinks_safely`: + +```rust + #[test] + fn test_update_target_live_growth_defers_sync_via_tween() { + let mut s = make_state("Hello"); + // Sanity: new() already synced displayed to the first target. + assert_eq!(s.displayed_char_count, s.target_char_count); + + let displayed_before = s.displayed_char_count; + s.update_target("Hello, world!", true); + + // Growth path must NOT sync displayed; it should stay at the previous + // target so tick_with_elapsed can interpolate toward the new target. + assert_eq!(s.displayed_char_count, displayed_before); + assert_eq!(s.reveal_base_char, displayed_before); + assert!(s.displayed_char_count < s.target_char_count); + assert!(s.needs_frame()); + } +``` + +- [ ] **Step 1.2: Add `test_update_target_live_false_syncs_immediately`** + +Insert directly below `test_update_target_live_growth_defers_sync_via_tween`: + +```rust + #[test] + fn test_update_target_live_false_syncs_immediately() { + let mut s = make_state("Hello"); + // Force a mid-tween state to prove the sync still happens. + s.displayed_char_count = 1; + s.displayed_byte_offset = 1; + + s.update_target("Hello, world!", false); + + assert_eq!(s.displayed_char_count, s.target_char_count); + assert_eq!(s.displayed_byte_offset, s.target_text.len()); + assert!(!s.needs_frame()); + } +``` + +- [ ] **Step 1.3: Add `test_tick_interpolates_displayed_toward_target`** + +Insert directly below the previous test: + +```rust + #[test] + fn test_tick_interpolates_displayed_toward_target() { + let mut s = make_state("Hello"); + s.update_target(&"a".repeat(100), true); + // Sanity: growth path sets up the tween. + assert_eq!(s.reveal_base_char, 5); + assert_eq!(s.target_char_count, 100); + + let changed = s.tick_with_elapsed(TWEEN_DURATION / 2); + + assert!(changed); + // Halfway through TWEEN_DURATION should reveal ~half of the 95-char + // delta (5 base + ~47 revealed = ~52). Give a ±2-char tolerance to + // absorb rounding across platforms. + assert!(s.displayed_char_count >= 50); + assert!(s.displayed_char_count <= 54); + assert!(s.displayed_char_count < s.target_char_count); + assert!(s.needs_frame()); + } +``` + +- [ ] **Step 1.4: Add `test_tick_completes_tween_at_full_duration`** + +Insert directly below the previous test: + +```rust + #[test] + fn test_tick_completes_tween_at_full_duration() { + let mut s = make_state("Hello"); + s.update_target(&"a".repeat(100), true); + + let changed = s.tick_with_elapsed(TWEEN_DURATION); + + assert!(changed); + assert_eq!(s.displayed_char_count, s.target_char_count); + assert!(!s.needs_frame()); + } +``` + +- [ ] **Step 1.5: Add `test_tick_noop_when_displayed_already_caught_up`** + +Insert directly below the previous test: + +```rust + #[test] + fn test_tick_noop_when_displayed_already_caught_up() { + let mut s = make_state("Hello"); + // new() already synced displayed to target. + assert_eq!(s.displayed_char_count, s.target_char_count); + + let before = s.displayed_char_count; + let changed = s.tick_with_elapsed(Duration::from_secs(1)); + + assert!(!changed); + assert_eq!(s.displayed_char_count, before); + assert!(!s.needs_frame()); + } +``` + +- [ ] **Step 1.6: Run tests to verify they all fail** + +Run: + +``` +cargo test --lib home::streaming_animation -- \ + test_update_target_live_growth_defers_sync_via_tween \ + test_update_target_live_false_syncs_immediately \ + test_tick_interpolates_displayed_toward_target \ + test_tick_completes_tween_at_full_duration \ + test_tick_noop_when_displayed_already_caught_up +``` + +Expected: All 5 tests **FAIL** because `reveal_base_char` / `TWEEN_DURATION` do not exist and the existing `update_target` / `tick_with_elapsed` always sync / no-op. + +- [ ] **Step 1.7: Commit red tests** + +``` +git add src/home/streaming_animation.rs +git commit -m "test: add failing tests for bounded streaming tween" +``` + +--- + +## Task 2: Implement TWEEN_DURATION constant, reveal_base fields, and tween-aware state transitions (TDD Green) + +**Files:** + +- Modify: `src/home/streaming_animation.rs` (constants block at top, `StreamingAnimState` struct, `new`, `restore`, `sync_displayed_to_target`, `update_target`, `tick`, `tick_with_elapsed`, `needs_frame`) + +This task turns the failing tests green. All changes live in this single file. + +- [ ] **Step 2.1: Add `TWEEN_DURATION` constant** + +In the constants block near the top of the file (after `LIVE_STREAM_STALL_TIMEOUT`), add: + +```rust +/// Upper bound on how long a single live update's interpolation can run. +/// Chosen strictly smaller than any reasonable server edit throttle so +/// the client always catches up before the next edit arrives, preventing +/// the long-tail behaviour of PR #14's fixed-cadence pacer. +const TWEEN_DURATION: Duration = Duration::from_millis(150); +``` + +- [ ] **Step 2.2: Add `reveal_base_char` and `reveal_base_time` fields to `StreamingAnimState`** + +Modify the struct block (currently around lines 9-24): + +```rust +pub struct StreamingAnimState { + pub target_text: String, + pub target_char_count: usize, + pub displayed_char_count: usize, + pub displayed_byte_offset: usize, + pub last_update_time: Instant, + pub animation_start_time: Instant, + pub display_buffer: String, + /// Whether the message currently carries the MSC4357 `live` field. + pub is_live: bool, + pub timeline_index: Option, + /// Starting displayed_char_count for the current tween window. + /// Equals target_char_count when no tween is in progress. + pub reveal_base_char: usize, + /// Reference time for the current tween window. + pub reveal_base_time: Instant, +} +``` + +- [ ] **Step 2.3: Update `sync_displayed_to_target` to also reset `reveal_base_char`** + +Replace the existing `sync_displayed_to_target` method. When we sync, the tween window collapses: `reveal_base_char` must match the new `displayed_char_count` so subsequent `tick_with_elapsed` calls correctly see "no backlog" until the next growth update. + +```rust +fn sync_displayed_to_target(&mut self) { + self.displayed_char_count = self.target_char_count; + self.displayed_byte_offset = self.target_text.len(); + self.reveal_base_char = self.target_char_count; +} +``` + +- [ ] **Step 2.4: Update `new` to initialise `reveal_base_*` fields** + +Modify the struct literal in `new` so the new fields are populated. The call to `sync_displayed_to_target()` at the end of `new` then aligns `reveal_base_char` to `target_char_count`. + +```rust +pub fn new(initial_text: &str, is_live: bool) -> Self { + let char_count = initial_text.chars().count(); + let now = Instant::now(); + let mut state = Self { + target_text: initial_text.to_string(), + target_char_count: char_count, + displayed_char_count: 0, + displayed_byte_offset: 0, + last_update_time: now, + animation_start_time: now, + display_buffer: String::with_capacity(initial_text.len() + 4), + is_live, + timeline_index: None, + reveal_base_char: 0, + reveal_base_time: now, + }; + state.sync_displayed_to_target(); + state +} +``` + +`restore` already delegates to `new` then copies `animation_start_time` / `timeline_index`, so no change is required there. + +- [ ] **Step 2.5: Rewrite `update_target` to branch on live/growth** + +Replace the current `update_target` body. The contract: + +- `is_live = false` → finish edit → sync immediately (Moly semantics). +- `is_live = true` **and** new target is strictly larger than previous `target_char_count` → capture `reveal_base_char` and `reveal_base_time`, do NOT sync. +- `is_live = true` but no growth (equal or shrink) → sync immediately, no tween starts. + +```rust +pub fn update_target(&mut self, new_text: &str, is_live: bool) { + let prev_target_char_count = self.target_char_count; + let previous_displayed = self.displayed_char_count; + + self.target_text.clear(); + self.target_text.push_str(new_text); + self.target_char_count = new_text.chars().count(); + self.is_live = is_live; + + let now = Instant::now(); + self.last_update_time = now; + + let needed = new_text.len() + 4; + if self.display_buffer.capacity() < needed { + self.display_buffer.reserve(needed - self.display_buffer.len()); + } + + let is_growth = is_live && self.target_char_count > prev_target_char_count; + if is_growth { + // Keep displayed at its current position and open a fresh tween + // window. displayed_char_count may already trail target_char_count + // from an earlier tween; preserve it as the new reveal base. + self.reveal_base_char = previous_displayed.min(self.target_char_count); + self.reveal_base_time = now; + // displayed_byte_offset is left trailing; the next tick will advance + // it when displayed_char_count moves forward. Clamp it here to stay + // within the new target text so advance_displayed's slicing stays + // safe even before the first tick. + self.displayed_char_count = self.reveal_base_char; + self.displayed_byte_offset = self.target_text + .char_indices() + .nth(self.reveal_base_char) + .map_or(self.target_text.len(), |(byte_idx, _)| byte_idx); + } else { + // Finish edit or non-growing live update: sync immediately. + self.sync_displayed_to_target(); + self.reveal_base_time = now; + } +} +``` + +- [ ] **Step 2.6: Rewrite `tick_with_elapsed` to perform bounded linear interpolation** + +Replace the current no-op body: + +```rust +pub fn tick_with_elapsed(&mut self, elapsed_since_reveal: Duration) -> bool { + if self.displayed_char_count >= self.target_char_count { + return false; + } + + let progress = (elapsed_since_reveal.as_secs_f64() + / TWEEN_DURATION.as_secs_f64()) + .clamp(0.0, 1.0); + + let delta = self.target_char_count.saturating_sub(self.reveal_base_char); + let target_displayed = self.reveal_base_char + + ((delta as f64) * progress).round() as usize; + let target_displayed = target_displayed.min(self.target_char_count); + + if target_displayed <= self.displayed_char_count { + return false; + } + + let advance = target_displayed - self.displayed_char_count; + self.advance_displayed(advance); + true +} +``` + +- [ ] **Step 2.7: Update `tick` to derive elapsed from `reveal_base_time`** + +Replace the current `tick` body: + +```rust +pub fn tick(&mut self) -> bool { + let elapsed = self.reveal_base_time.elapsed(); + self.tick_with_elapsed(elapsed) +} +``` + +- [ ] **Step 2.8: Update `needs_frame` to reflect tween progress** + +Replace the current `needs_frame` body: + +```rust +pub fn needs_frame(&self) -> bool { + self.displayed_char_count < self.target_char_count +} +``` + +- [ ] **Step 2.9: Run the 5 new tests, expect PASS** + +Run: + +``` +cargo test --lib home::streaming_animation -- \ + test_update_target_live_growth_defers_sync_via_tween \ + test_update_target_live_false_syncs_immediately \ + test_tick_interpolates_displayed_toward_target \ + test_tick_completes_tween_at_full_duration \ + test_tick_noop_when_displayed_already_caught_up +``` + +Expected: All 5 tests PASS. + +- [ ] **Step 2.10: Commit the green implementation** + +``` +git add src/home/streaming_animation.rs +git commit -m "feat: bounded per-update tween for streaming animation" +``` + +--- + +## Task 3: Reconcile pre-existing tests that assumed immediate sync + +**Files:** + +- Modify: `src/home/streaming_animation.rs` (`mod tests` section) + +Two existing tests were written for the pure-Moly design where `update_target` always synced. Under bounded tween, the growth branch no longer syncs, so the assertions need adjustment. Each requires a direct fix, not deletion. + +- [ ] **Step 3.1: Locate `test_update_target_tracks_latest_full_snapshot`** + +Current body (references exact lines; verify before editing): + +```rust + #[test] + fn test_update_target_tracks_latest_full_snapshot() { + let mut s = make_state("Hello"); + s.update_target("Hello, world!", true); + assert_eq!(s.target_char_count, 13); + assert_eq!(s.displayed_char_count, s.target_char_count); + assert_eq!(s.displayed_byte_offset, s.target_text.len()); + assert!(!s.needs_frame()); + } +``` + +This asserts `displayed == target` immediately after a live growth, which now contradicts the tween contract. + +- [ ] **Step 3.2: Update this test to cover the **non-live** sync path** + +The point of this test was that `target_text` / `target_char_count` are correctly refreshed. Shift the scenario to the non-growth live update, which still syncs, so the invariant the test was trying to protect is still covered: + +```rust + #[test] + fn test_update_target_tracks_latest_full_snapshot() { + let mut s = make_state("Hello, world!"); + // Non-growing live update must still sync immediately because there + // is no backlog to interpolate across. + s.update_target("Greetings!", true); + assert_eq!(s.target_char_count, 10); + assert_eq!(s.displayed_char_count, s.target_char_count); + assert_eq!(s.displayed_byte_offset, s.target_text.len()); + assert!(!s.needs_frame()); + } +``` + +- [ ] **Step 3.3: Locate `test_update_target_recalculates_byte_offset_for_different_prefix`** + +Current body uses `update_target("你好世界测试数据", true)` on a 5-char ASCII state. Under tween, the growth branch now sets `displayed_char_count = reveal_base_char = 5` but our new text only has 8 CJK chars. `displayed_byte_offset` is computed via `char_indices().nth(5)` on the new text, which is the sixth CJK char boundary (valid). The test's current assertion `displayed_char_count == 8` (full sync) no longer holds during tween. + +- [ ] **Step 3.4: Update this test to exercise a tick through tween** + +Re-scope the test to assert byte-offset safety after the tween completes. This still proves the original concern (no mid-char byte slicing on multi-byte growth) while matching the tween contract: + +```rust + #[test] + fn test_update_target_recalculates_byte_offset_for_different_prefix() { + let mut s = make_state("hello world"); + s.update_target("你好世界测试数据", true); + // Finish the tween in one step by ticking past TWEEN_DURATION. + let _ = s.tick_with_elapsed(TWEEN_DURATION); + assert_eq!(s.displayed_char_count, 8); + assert_eq!(s.displayed_byte_offset, s.target_text.len()); + s.fill_display_buffer(); + assert!(s.display_buffer.starts_with("你好世界测试数据")); + } +``` + +- [ ] **Step 3.5: Run the full `streaming_animation` suite** + +Run: + +``` +cargo test --lib home::streaming_animation +``` + +Expected: every test in the module passes (previously 15 + 5 new = 20, but two existing ones were rewritten, so the count is still 20). + +- [ ] **Step 3.6: Commit the test reconciliation** + +``` +git add src/home/streaming_animation.rs +git commit -m "test: align streaming tests with bounded tween contract" +``` + +--- + +## Task 4: Full verification (cargo check + room_screen + link_preview tests) + +**Files:** (verification only) + +- No code changes. + +- [ ] **Step 4.1: `cargo check --lib`** + +Run: + +``` +cargo check --lib +``` + +Expected: `Finished` with zero warnings about the `streaming_animation` module. Any new warning here means Task 2 introduced an unused import or dead branch. + +- [ ] **Step 4.2: `cargo test --lib home::streaming_animation`** + +Run: + +``` +cargo test --lib home::streaming_animation +``` + +Expected: all tests in the module pass. + +- [ ] **Step 4.3: `cargo test --lib home::link_preview`** + +Run: + +``` +cargo test --lib home::link_preview +``` + +Expected: 3/3 link_preview tests pass (unchanged behaviour). + +- [ ] **Step 4.4: `cargo test --lib` (full library suite)** + +Run: + +``` +cargo test --lib +``` + +Expected: same pass/fail count as before the tween work. Four pre-existing failures listed in PR #99's test plan (`test_parse_bot_timeline_layers_invalid_metadata_does_not_panic`, three `room_input_bar` tests) may still fail — confirm they are the **same** failures as before and **no new failures** were introduced. + +If any new test fails, stop and diagnose before continuing to Task 5. + +--- + +## Task 5: Append commit to PR #99 + +**Files:** (no code changes; git operations only) + +- [ ] **Step 5.1: Verify the working tree contains exactly the three tween commits on top of the existing fix commit** + +Run: + +``` +git log origin/main..HEAD --oneline +``` + +Expected, in order (oldest first): + +``` + fix: restore MSC4357 streaming animation for bot replies + test: add failing tests for bounded streaming tween + feat: bounded per-update tween for streaming animation + test: align streaming tests with bounded tween contract +``` + +- [ ] **Step 5.2: Push the branch** + +Run: + +``` +git push origin fix/streaming-animation-regression +``` + +Expected: fast-forward update. `gh pr view 99 --repo Project-Robius-China/robrix2` should show the new commits appended to the existing PR #99. + +- [ ] **Step 5.3: Leave a short PR comment flagging the tween addition** + +Run: + +``` +gh pr comment 99 --repo Project-Robius-China/robrix2 --body "Added bounded per-update tween (TWEEN_DURATION = 150ms, strictly < any reasonable server edit throttle). Spec and tests updated in the same branch. Plan: docs/superpowers/plans/2026-04-15-streaming-bounded-tween.md. cargo check clean; streaming_animation tests 20/20." +``` + +Expected: new comment URL returned. + +--- + +## Self-Review + +**Spec coverage (spec at `specs/task-restore-streaming-animation.spec.md`):** + +- Bounded per-update tween decision → Task 2 (Steps 2.1, 2.5, 2.6, 2.8) covers `TWEEN_DURATION`, growth branch, interpolation, `needs_frame` flip. +- `new()` / `restore()` sync-on-init decision → Task 2 (Steps 2.3, 2.4) covers. +- `update_target(live=false)` immediate sync → Task 1 (Step 1.2 test) and Task 2 (Step 2.5 else branch). +- `tick_with_elapsed` linear interpolation → Task 1 (Steps 1.3, 1.4) and Task 2 (Step 2.6). +- `needs_frame` reflects tween progress → Task 1 tests assert it, Task 2.8 implements it. +- `populate_bot_text_message_content` / `link_preview_view` hiding / layered metadata final render / `LinkPreviewRef::populate_below_message` behaviour → all unchanged by this plan; covered by the existing PR #99 code and existing tests (Task 4 verifies no regression). +- Completion criteria `test_new_state_starts_fully_visible` / `test_update_target_live_growth_defers_sync_via_tween` / `test_update_target_live_false_syncs_immediately` / `test_tick_interpolates_displayed_toward_target` / `test_tick_completes_tween_at_full_duration` / `test_tick_noop_when_displayed_already_caught_up` → Task 1 implements all. +- Existing `cargo_check` / `cargo_test_streaming_animation` / `cargo_test_room_screen_streaming` completion criteria → Task 4 verifies. + +**Placeholder scan:** none of the steps contain "TBD", "similar to", "add validation", or missing code. + +**Type consistency:** `reveal_base_char: usize` and `reveal_base_time: Instant` are the only new field names; both referenced consistently across steps 2.2, 2.3, 2.4, 2.5, 2.6. `TWEEN_DURATION: Duration` is defined in Step 2.1 and referenced in Steps 1.3, 1.4, 2.1, 2.6, 3.4. `advance_displayed` is an existing method unchanged by this plan; Step 2.6 calls it identically to the existing Moly-style code. + +Nothing to fix inline. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-04-15-streaming-bounded-tween.md`. + +Two execution options: + +1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach? diff --git a/specs/task-restore-streaming-animation.spec.md b/specs/task-restore-streaming-animation.spec.md new file mode 100644 index 00000000..90fb0368 --- /dev/null +++ b/specs/task-restore-streaming-animation.spec.md @@ -0,0 +1,158 @@ +spec: task +name: "Adopt Moly-Style Live Streaming Rendering" +inherits: project +tags: [bot, streaming, animation, msc4357, regression] +depends: [task-tg-bot-timeline-cards] +estimate: 0.5d +--- + +## 意图 + +Octos 的 MSC4357 流式回复需要在客户端层面同时满足两个目标:(1) 与服务端 pace 对齐以避免 PR #14 固定 cadence 造成的长尾;(2) 把服务端粗粒度 edit throttle(Octos 默认 1000ms / 测试环境 200ms)带来的“一跳一跳”阶跃过渡,平滑到人眼感知阈值以下。 + +当前实现在 `StreamingAnimState` 上维护一个**有界的每次 update tween**(bounded per-update tween,`TWEEN_DURATION = 150ms`)——每次服务端 live edit 到达时,客户端在 150ms 内用线性插值把 displayed 从旧 reveal base 推向新 target。由于 tween 时长**严格小于**服务端 edit throttle 下限,客户端永远在下一次 edit 到达前完成;不累积 backlog,不会退化成 PR #14 那种无限长尾。`is_live=false` 的 finish edit 立即 sync 到完整文本,不做 tween。 + +参考对照:Moly 本体在 `moly-kit/src/widgets/standard_message_content.rs` 里直接把服务端最新全文送入 widget,没有本地 reveal 时钟——因为 Moly 接 openclaw SSE API,服务端 pace 已经足够细(~30-50ms/token)。Robrix 通过 Matrix edit event 与服务端通信,协议层天然有 200-1000ms 粗粒度,所以补一个**受限**的本地 tween,只是为了弥合这段传输粗粒度,不是回到 PR #14 的客户端节奏器。 + +## 决策 + +- 保留 `StreamingAnimState` 作为 MSC4357 live message 的生命周期跟踪容器。它不承担服务端 pace 控制,但承担**有界的每次 update 过渡**(bounded per-update tween),把服务端节流造成的阶跃过渡平滑到人眼感知阈值以下。 +- 引入常量 `TWEEN_DURATION = Duration::from_millis(150)`,**严格小于**任意合理的服务端 edit throttle(参考 Octos 默认 1000ms / 测试环境 200ms),保证每次服务端 edit 触发的 tween 在下一次 edit 到达前完成,**永不累积 backlog**,不会产生 PR #14 式的长尾。 +- `StreamingAnimState::new()` 和 `restore()` 在初始化时 sync displayed 到完整 target(首次出现的消息不 tween,避免初显空白)。 +- `update_target(new_text, is_live=true)` 且 `target_char_count` 有增长时:记录 `reveal_base_char = 当前 displayed_char_count` 与 `reveal_base_time = Instant::now()`,**不**立刻 sync displayed 到 target。shrink 或无增长时 sync 到 target 并重置 `reveal_base_*`。 +- `update_target(new_text, is_live=false)`(finish edit)立即 sync displayed 到 target,**不 tween**,保留 Moly 的"结束即完整"语义。 +- `tick_with_elapsed()` 当 `displayed_char_count < target_char_count` 时,按 `elapsed_since_reveal_base / TWEEN_DURATION` 做线性插值推进 displayed;progress ≥ 1.0 时 clamp 到 target。返回值表示 displayed 是否有变化。 +- `tick()` 复用 `tick_with_elapsed`(内部基于 `reveal_base_time` 测算 elapsed)。 +- `StreamingAnimState::needs_frame()` 返回 `displayed_char_count < target_char_count`,让现有帧调度器在 tween 进行中持续 tick,完成后停止。 +- Streaming render path inside `populate_bot_text_message_content()` continues to show plaintext inside `bot_card_body`, but the plaintext must be the latest full snapshot plus trailing `●`, not a locally delayed prefix. +- Streaming render path must also keep hiding `content.link_preview_view` so recycled timeline items cannot show stale previews during live updates. +- Final post-stream render path remains unchanged: after the live field clears and the streaming state is removed, the existing rich markdown / layered metadata path renders the finished message. +- `LinkPreviewRef::populate_below_message()` must continue to recompute collapsible-button state on every populate call. + +## 边界 + +### Allowed Changes + +- src/home/streaming_animation.rs +- src/home/room_screen.rs +- src/home/link_preview.rs +- specs/task-restore-streaming-animation.spec.md + +### Forbidden + +- Do NOT modify bot card DSL layout (`bot_message_card`, `bot_card_body`, `bot_card_markdown`, `bot_card_markdown_plain`, `bot_status_strip`, `bot_metadata_footer`). +- Do NOT modify `is_msc4357_live`, `content_has_msc4357_live_marker`, `streaming_scan_range`, `refresh_stream_indices`, `rebuild_streaming_messages_for_full_snapshot`, `next_stream_timeout`. +- Do NOT modify `src/sliding_sync.rs`, `src/home/mod.rs`, or any other files outside the listed allowed paths. +- Do NOT modify Octos / testenv / any non-robrix2 file. +- Do NOT add new cargo dependencies. +- Do NOT add `#[allow(dead_code)]` to suppress warnings. +- Do NOT run `cargo fmt`. + +## 排除范围 + +- Bot timeline card layered metadata extraction (status / provider / footer) — already shipped by `task-tg-bot-timeline-cards`. +- Non-MSC4357 bots — those replies never enter `streaming_messages`, so behavior is unchanged. +- Streaming for non-bot senders. +- Any client-side fallback for missing stream-finalization markers. +- Octos upstream bug — if the final `finish_stream` signal is missing, Robrix still waits for the existing live-stall timeout path. + +## 完成条件 + +场景: 新建 streaming state 时直接显示最新全文 + 测试: test_new_state_starts_fully_visible + 假设 `StreamingAnimState::new("Hello, world!", true)` 被调用 + 当 state 初始化完成 + 那么 `displayed_char_count` 等于 `target_char_count` + 并且 `displayed_byte_offset` 等于 `target_text.len()` + 并且 `needs_frame()` 返回 false + +场景: live update 增长目标时保留 tween base,不立即 sync + 测试: test_update_target_live_growth_defers_sync_via_tween + 假设 一个 `StreamingAnimState` 已 sync 到 initial target (例如 `"Hello"`) + 当 调用 `update_target("Hello, world!", true)` 扩展文本 + 那么 `displayed_char_count` **不**等于新的 `target_char_count` (保持在旧 target 的 char count) + 并且 `reveal_base_char` 等于 update 之前的 `displayed_char_count` + 并且 `needs_frame()` 返回 true + +场景: live=false 的 finish edit 立即 sync 到完整文本,不 tween + 测试: test_update_target_live_false_syncs_immediately + 假设 一个 `StreamingAnimState` 中 displayed 落后于 target + 当 调用 `update_target(final_text, false)` + 那么 `displayed_char_count` 等于新的 `target_char_count` + 并且 `displayed_byte_offset` 等于新的 `target_text.len()` + 并且 `needs_frame()` 返回 false + +场景: tween 期间 tick 按线性插值推进 displayed + 测试: test_tick_interpolates_displayed_toward_target + 假设 一个 live `StreamingAnimState`:`reveal_base_char = 0`、`target_char_count = 100`、`reveal_base_time = 当前` + 当 调用 `tick_with_elapsed(TWEEN_DURATION / 2)` + 那么 `displayed_char_count` 约等于 50 (中途进度) + 并且 `displayed_char_count` 严格大于 0 且小于 `target_char_count` + 并且 `needs_frame()` 返回 true + +场景: tween 完成后 displayed clamp 到 target + 测试: test_tick_completes_tween_at_full_duration + 假设 一个 live `StreamingAnimState`:`reveal_base_char = 0`、`target_char_count = 100` + 当 调用 `tick_with_elapsed(TWEEN_DURATION)` (或更长) + 那么 `displayed_char_count` 等于 `target_char_count` + 并且 `needs_frame()` 返回 false + +场景: 已 catch-up 的 live state tick 不做事 + 测试: test_tick_noop_when_displayed_already_caught_up + 假设 一个 live `StreamingAnimState` 中 `displayed_char_count == target_char_count` + 当 调用 `tick_with_elapsed(Duration::from_secs(1))` + 那么 返回值为 false + 并且 `displayed_char_count` 不变化 + +场景: Bot streaming reply renders latest plaintext snapshot with cursor inside the bot card body + 测试: manual_test_bot_streaming_live_snapshot + Level: manual + 假设 一个 Octos bot reply 当前仍带有 MSC4357 `live` field + 当 populate path 运行且 `streaming_messages` 中存在该 event 的 state + 那么 `bot_card_body` 显示最新完整文本快照加 trailing `●` + 并且 `bot_card_markdown` 与 `bot_card_markdown_plain` 不可见 + 并且 status strip / provider line / footer line 在 streaming 期间隐藏 + 并且 `content.link_preview_view` 在 streaming 期间隐藏 + +场景: Completed bot reply renders rich markdown after the live field clears + 测试: manual_test_bot_stream_finalization + Level: manual + 假设 一个 bot reply 的最终 edit 去掉了 `org.matrix.msc4357.live` + 并且 `StreamingAnimState` 已经从 `streaming_messages` 中移除 + 当 populate path 再次运行 + 那么 body 通过现有 layered bot card 富文本路径渲染 + 并且 trailing `●` 消失 + +场景: Link preview collapsible buttons reset when link count shrinks + 测试: link_preview_collapsible_state_ + 假设 一个此前显示过 expand/collapse controls 的 link preview + 当 后续 populate 结果只有零到两个 preview entry + 那么 collapsible controls 被隐藏 + 并且 hidden-link count 重置为零 + +场景: cargo check remains green + 测试: cargo_check + 假设 当前 worktree 包含本任务改动 + 当 运行 `cargo check` + 那么 命令退出状态为零 + +场景: streaming_animation unit tests pass + 测试: cargo_test_streaming_animation + 假设 当前 worktree 包含本任务改动 + 当 运行 `cargo test --lib home::streaming_animation::tests::` + 那么 相关测试全部通过 + +场景: room_screen streaming regression tests pass + 测试: cargo_test_room_screen_streaming + 假设 当前 worktree 包含本任务改动 + 当 运行 targeted room-screen streaming regression tests + 那么 `test_streaming_scan_range`、`test_refresh_stream_indices`、`test_timeout_picks_earliest`、`test_full_snapshot_rebuild_*` 与 `test_clear_cache_update_rebuild_*` 全部通过 + +场景: Manual test — long streaming reply keeps pace with upstream updates + 测试: manual_test_long_stream + Level: manual + 假设 一个 Octos bot 流式输出 500+ chars 的长回复 + 当 运营者观察 timeline + 那么 文本展示速度跟随上游流式更新 + 并且 不存在客户端本地打字机尾巴 + 并且 不会在掉帧恢复后一次性补出本地 backlog diff --git a/src/home/link_preview.rs b/src/home/link_preview.rs index 42166816..458788ba 100644 --- a/src/home/link_preview.rs +++ b/src/home/link_preview.rs @@ -25,6 +25,8 @@ const MAX_DESCRIPTION_LENGTH: usize = 180; const MAX_CACHE_ENTRIES_BEFORE_CLEANUP: usize = 100; /// Maximum age for cache entries in seconds (1 hour) const CACHE_ENTRY_MAX_AGE_SECS: u64 = 3600; +/// Maximum number of link previews shown before collapsing behind a button. +const MAX_LINK_PREVIEWS_BY_EXPAND: usize = 2; /// Specific error types for link preview failures #[derive(Clone, Debug)] @@ -327,6 +329,26 @@ impl LinkPreview { } } +#[derive(Debug, PartialEq, Eq)] +struct LinkPreviewCollapsibleState { + show_buttons: bool, + hidden_count: usize, +} + +fn link_preview_collapsible_state(total_views: usize) -> LinkPreviewCollapsibleState { + if total_views > MAX_LINK_PREVIEWS_BY_EXPAND { + LinkPreviewCollapsibleState { + show_buttons: true, + hidden_count: total_views - MAX_LINK_PREVIEWS_BY_EXPAND, + } + } else { + LinkPreviewCollapsibleState { + show_buttons: false, + hidden_count: 0, + } + } +} + impl LinkPreviewRef { fn item_template(&self) -> Option { if let Some(inner) = self.borrow() { @@ -364,6 +386,15 @@ impl LinkPreviewRef { } } + fn reset_collapsible_buttons(&mut self, cx: &mut Cx) { + if let Some(mut inner) = self.borrow_mut() { + inner.show_collapsible_buttons = false; + inner.is_expanded = false; + inner.hidden_links_count = 0; + inner.update_button_and_visibility(cx); + } + } + /// Populates a link preview view with data and handles image population through a closure. /// Returns whether the link preview is fully drawn. fn populate_view( @@ -466,7 +497,6 @@ impl LinkPreviewRef { F: Fn(&mut Cx, &TextOrImageRef, Option>, MediaSource, &str, &mut MediaCache) -> bool, { const SKIPPED_DOMAINS: &[&str] = &["matrix.to", "matrix.io"]; - const MAX_LINK_PREVIEWS_BY_EXPAND: usize = 2; let mut fully_drawn_count = 0; let mut accepted_link_count = 0; let mut views = Vec::new(); @@ -501,9 +531,11 @@ impl LinkPreviewRef { fully_drawn_count += was_image_drawn as usize; views.push(view_ref); } - if views.len() > MAX_LINK_PREVIEWS_BY_EXPAND { - let hidden_count = views.len() - MAX_LINK_PREVIEWS_BY_EXPAND; - self.show_collapsible_buttons(cx, hidden_count); + let collapsible_state = link_preview_collapsible_state(views.len()); + if collapsible_state.show_buttons { + self.show_collapsible_buttons(cx, collapsible_state.hidden_count); + } else { + self.reset_collapsible_buttons(cx); } self.set_children(views); fully_drawn_count == accepted_link_count @@ -723,3 +755,32 @@ fn insert_into_cache( } SignalToUI::set_ui_signal(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_link_preview_collapsible_state_hides_buttons_for_empty_results() { + let state = link_preview_collapsible_state(0); + + assert!(!state.show_buttons); + assert_eq!(state.hidden_count, 0); + } + + #[test] + fn test_link_preview_collapsible_state_hides_buttons_within_limit() { + let state = link_preview_collapsible_state(2); + + assert!(!state.show_buttons); + assert_eq!(state.hidden_count, 0); + } + + #[test] + fn test_link_preview_collapsible_state_shows_buttons_above_limit() { + let state = link_preview_collapsible_state(4); + + assert!(state.show_buttons); + assert_eq!(state.hidden_count, 2); + } +} diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 3a3c1ced..5a19aab3 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -39,7 +39,6 @@ use crate::{ }; use crate::home::event_reaction_list::ReactionListWidgetRefExt; use crate::home::room_read_receipt::AvatarRowWidgetRefExt; -use crate::home::streaming_animation::StreamingAnimState; use crate::room::room_input_bar::RoomInputBarWidgetExt; use crate::shared::mentionable_text_input::MentionableTextInputAction; @@ -843,18 +842,6 @@ fn has_rich_markdown_syntax(text: &str) -> bool { ) } -fn should_render_streaming_full_snapshot( - body: &str, - formatted_body: Option<&FormattedBody>, - is_bot_sender: bool, -) -> bool { - is_bot_sender - && ( - formatted_body.is_some_and(|formatted| formatted.format == MessageFormat::Html) - || has_rich_markdown_syntax(body) - ) -} - fn select_bot_timeline_body_formatted_body( render_state: &BotTimelineRenderState, formatted_body: Option<&FormattedBody>, @@ -940,17 +927,6 @@ fn bot_timeline_code_block_mode(render_state: &BotTimelineRenderState) -> BotTim } } -fn streaming_update_requires_content_invalidation( - state: &StreamingAnimState, - new_text: &str, - is_live: bool, - render_full_target: bool, -) -> bool { - state.target_text != new_text - || state.is_live != is_live - || state.render_full_target != render_full_target -} - thread_local! { static ROOM_INFO_ACTION_MODAL_OPEN: Cell = const { Cell::new(false) }; } @@ -1086,6 +1062,52 @@ fn streaming_candidates_from_items<'a>( }) } +fn streaming_texts_by_event_id( + items: &Vector>, +) -> HashMap { + items.iter().filter_map(|item| { + let TimelineItemKind::Event(event) = item.kind() else { + return None; + }; + let event_id = event.event_id()?.to_owned(); + let text = RoomScreen::extract_message_text(item)?; + Some((event_id, text)) + }).collect() +} + +fn create_streaming_state_for_live_update( + event_id: &OwnedEventId, + new_text: &str, + live: bool, + previous_streaming_messages: Option<&HashMap>, + previous_item_texts: &HashMap, + allow_new_state_when_missing: bool, +) -> Option { + use crate::home::streaming_animation::StreamingAnimState; + + if !live { + return None; + } + + if let Some(previous_state) = previous_streaming_messages + .and_then(|states| states.get(event_id)) + { + return Some(StreamingAnimState::restore(previous_state, new_text, true)); + } + + if let Some(previous_text) = previous_item_texts.get(event_id) { + if previous_text == new_text { + return None; + } + + let mut previous_state = StreamingAnimState::new(previous_text, true); + previous_state.advance_displayed(previous_state.target_char_count); + return Some(StreamingAnimState::restore(&previous_state, new_text, true)); + } + + allow_new_state_when_missing.then(|| StreamingAnimState::new(new_text, true)) +} + fn rebuild_streaming_messages_for_full_snapshot( items: I, previous_streaming_messages: Option<&HashMap>, @@ -1120,6 +1142,36 @@ where (rebuilt, should_schedule_frame) } +fn rebuild_streaming_messages_for_clear_cache_update( + items: I, + previous_streaming_messages: Option<&HashMap>, + previous_item_texts: &HashMap, +) -> (HashMap, bool) +where + I: IntoIterator, +{ + let mut rebuilt = HashMap::new(); + let mut should_schedule_frame = false; + + for (event_id, new_text, live) in items { + let Some(state) = create_streaming_state_for_live_update( + &event_id, + &new_text, + live, + previous_streaming_messages, + previous_item_texts, + true, + ) else { + continue; + }; + + should_schedule_frame |= state.needs_frame(); + rebuilt.insert(event_id, state); + } + + (rebuilt, should_schedule_frame) +} + fn next_stream_timeout<'a>( states: impl IntoIterator, ) -> Option { @@ -6056,15 +6108,6 @@ impl RoomScreen { let curr_first_id = portal_list.first_id(); let ui = self.widget_uid(); let Some(tl) = self.tl_state.as_mut() else { return }; - let ( - resolved_parent_bot_user_id, - room_bot_user_ids, - known_bot_user_ids, - ) = compute_timeline_bot_context( - app_state, - tl.kind.room_id(), - tl.room_members.as_ref(), - ); let mut done_loading = false; let mut should_continue_backwards_pagination = false; @@ -6272,11 +6315,13 @@ impl RoomScreen { // --- MSC4357 streaming detection --- if clear_cache { + let previous_item_texts = streaming_texts_by_event_id(&tl.items); let previous_streaming_messages = std::mem::take(&mut tl.streaming_messages); let (rebuilt_streaming_messages, should_schedule_frame) = - rebuild_streaming_messages_for_full_snapshot( + rebuild_streaming_messages_for_clear_cache_update( streaming_candidates_from_items(&new_items), Some(&previous_streaming_messages), + &previous_item_texts, ); tl.streaming_messages = rebuilt_streaming_messages; if should_schedule_frame { @@ -6294,6 +6339,7 @@ impl RoomScreen { let old_event_ids: HashSet<&EventId> = tl.items.iter() .filter_map(|item| item_event_id(item)) .collect(); + let previous_item_texts = streaming_texts_by_event_id(&tl.items); for idx in scan_range { let Some(new_item) = new_items.get(idx) else { continue }; @@ -6301,45 +6347,22 @@ impl RoomScreen { let Some(event_id) = new_evt.event_id().map(|id| id.to_owned()) else { continue }; let live = is_msc4357_live(new_evt); let Some(new_text) = Self::extract_message_text(new_item) else { continue }; - let render_full_target = should_render_streaming_full_snapshot( - &new_text, - new_evt.content() - .as_message() - .and_then(|message| match message.msgtype() { - MessageType::Text(TextMessageEventContent { formatted, .. }) => formatted.as_ref(), - MessageType::Notice(NoticeMessageEventContent { formatted, .. }) => formatted.as_ref(), - _ => None, - }), - is_timeline_sender_bot( - new_evt.sender(), - resolved_parent_bot_user_id.as_deref(), - &room_bot_user_ids, - &known_bot_user_ids, - ), - ); if let Some(state) = tl.streaming_messages.get_mut(&event_id) { - let should_invalidate_content = streaming_update_requires_content_invalidation( - state, - &new_text, - live, - render_full_target, - ); state.update_target(&new_text, live); - state.set_render_full_target(render_full_target); - if should_invalidate_content - && let Some(idx) = state.timeline_index - { - tl.content_drawn_since_last_update.remove(idx .. idx + 1); - } // Schedule frame for animation OR for cleanup of just-completed state should_schedule_frame |= state.needs_frame() || state.is_complete(); continue; } - if live && !old_event_ids.contains(&*event_id) { - let mut state = StreamingAnimState::new(&new_text, true); - state.set_render_full_target(render_full_target); + if let Some(state) = create_streaming_state_for_live_update( + &event_id, + &new_text, + live, + None, + &previous_item_texts, + !old_event_ids.contains(&*event_id), + ) { should_schedule_frame |= state.needs_frame(); tl.streaming_messages.insert(event_id, state); } @@ -8573,34 +8596,21 @@ fn populate_message_view( .and_then(|eid| streaming_messages.get_mut(&eid.to_owned())); if let Some(state) = is_streaming { - let render_full_snapshot = should_render_streaming_full_snapshot( - body, - formatted.as_ref(), - sender_is_bot, - ); - state.set_render_full_target(render_full_snapshot); - - // STREAMING MODE: - // - markdown-rich bot replies render the latest full snapshot directly - // - plain text keeps the local typewriter prefix with cursor + // STREAMING MODE: show the latest live snapshot + cursor inside the bot card shell. + state.fill_display_buffer(); let mut link_preview_ref = item.link_preview(cx, ids!(content.link_preview_view)); - let (stream_body, stream_formatted) = if render_full_snapshot { - (body.as_str(), formatted.as_ref()) - } else { - state.fill_display_buffer(); - (state.display_buffer.as_str(), None) - }; let _ = populate_bot_text_message_content( cx, &item, app_language, - stream_body, - stream_formatted, + body, + formatted.as_ref(), Some(&mut link_preview_ref), Some(media_cache), Some(link_preview_cache), sender_is_bot, + Some(state.display_buffer.as_str()), ); new_drawn_status.content_drawn = false; // force re-render } else { @@ -8614,7 +8624,9 @@ fn populate_message_view( if let Some(ref splash) = splash_code { // SPLASH CARD MODE: render native Makepad card + item.view(cx, ids!(content.bot_message_card)).set_visible(cx, false); item.view(cx, ids!(content.message)).set_visible(cx, false); + item.view(cx, ids!(content.link_preview_view)).set_visible(cx, false); let splash_widget = item.splash(cx, ids!(content.splash_card)); splash_widget.set_visible(cx, true); splash_widget.set_text(cx, splash); @@ -8633,6 +8645,7 @@ fn populate_message_view( Some(media_cache), Some(link_preview_cache), sender_is_bot, + None, ); } } @@ -8676,6 +8689,7 @@ fn populate_message_view( Some(media_cache), Some(link_preview_cache), sender_is_bot, + None, ); (item, false) } @@ -9293,10 +9307,45 @@ fn populate_bot_text_message_content( media_cache: Option<&mut MediaCache>, link_preview_cache: Option<&mut LinkPreviewCache>, is_bot_sender: bool, + streaming_display_buffer: Option<&str>, ) -> bool { - let render_state = compute_bot_timeline_render_state(body, is_bot_sender); let bot_card_view = item.view(cx, ids!(content.bot_message_card)); let message_view = item.html_or_plaintext(cx, ids!(content.message)); + let link_preview_view = item.view(cx, ids!(content.link_preview_view)); + let splash_widget = item.splash(cx, ids!(content.splash_card)); + + if let Some(display_buffer) = streaming_display_buffer { + splash_widget.set_visible(cx, false); + link_preview_view.set_visible(cx, false); + if is_bot_sender { + bot_card_view.set_visible(cx, true); + message_view.set_visible(cx, false); + + item.view(cx, ids!(content.bot_message_card.bot_status_strip)) + .set_visible(cx, false); + item.view(cx, ids!(content.bot_message_card.bot_metadata_footer)) + .set_visible(cx, false); + + let body_card = item.view(cx, ids!(content.bot_message_card.bot_body_card)); + body_card.set_visible(cx, true); + let body_widget = item.html_or_plaintext(cx, ids!(content.bot_message_card.bot_body_card.bot_card_body)); + let markdown_widget = item.markdown(cx, ids!(content.bot_message_card.bot_body_card.bot_card_markdown)); + let markdown_plain_widget = item.markdown(cx, ids!(content.bot_message_card.bot_body_card.bot_card_markdown_plain)); + body_widget.set_visible(cx, true); + markdown_widget.set_visible(cx, false); + markdown_plain_widget.set_visible(cx, false); + body_widget.show_plaintext(cx, display_buffer); + } else { + bot_card_view.set_visible(cx, false); + message_view.set_visible(cx, true); + message_view.show_plaintext(cx, display_buffer); + } + return false; + } + + splash_widget.set_visible(cx, false); + link_preview_view.set_visible(cx, true); + let render_state = compute_bot_timeline_render_state(body, is_bot_sender); bot_card_view.set_visible(cx, render_state.show_card); message_view.set_visible(cx, !render_state.show_card); @@ -11042,7 +11091,11 @@ mod tests { let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); let mut previous = HashMap::new(); let mut previous_state = make_state("hello"); - previous_state.advance_displayed(3); + // make_state() syncs displayed to target; push it back to simulate an + // in-flight tween with displayed=3 of 5. + previous_state.displayed_char_count = 3; + previous_state.displayed_byte_offset = 3; + previous_state.reveal_base_char = 0; previous.insert(event_id.clone(), previous_state); let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_full_snapshot( @@ -11051,8 +11104,14 @@ mod tests { ); let restored = rebuilt.get(&event_id).unwrap(); + // Full-snapshot rebuild must preserve the in-flight tween: the + // previously-displayed prefix stays visible and the tween continues + // rather than snapping to fully revealed. This is the end-to-end + // guarantee that prevents the "stuck then jumps" artefact users + // observed when switching rooms mid-stream. assert_eq!(restored.displayed_char_count, 3); assert!(restored.is_live); + assert!(restored.needs_frame()); assert!(should_schedule_frame); } @@ -11072,6 +11131,89 @@ mod tests { assert!(!should_schedule_frame); } + #[test] + fn test_clear_cache_update_rebuild_starts_new_live_stream_without_cached_state() { + let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); + let previous_item_texts = HashMap::new(); + + let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_clear_cache_update( + [(event_id.clone(), String::from("hello world"), true)], + None, + &previous_item_texts, + ); + + let state = rebuilt.get(&event_id).unwrap(); + assert_eq!(state.displayed_char_count, state.target_char_count); + assert!(!state.needs_frame()); + assert!(!should_schedule_frame); + } + + #[test] + fn test_clear_cache_update_rebuild_restores_from_previous_item_text() { + let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); + let previous_item_texts = HashMap::from([(event_id.clone(), String::from("hello"))]); + + let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_clear_cache_update( + [(event_id.clone(), String::from("hello world"), true)], + None, + &previous_item_texts, + ); + + let state = rebuilt.get(&event_id).unwrap(); + assert_eq!(state.displayed_char_count, state.target_char_count); + assert!(!state.needs_frame()); + assert!(!should_schedule_frame); + } + + #[test] + fn test_clear_cache_update_rebuild_preserves_in_flight_tween_from_cached_state() { + // Integration coverage: ensure a mid-tween cached state flowing + // through `rebuild_streaming_messages_for_clear_cache_update` keeps + // its in-flight tween rather than snapping to fully displayed. This + // is the end-to-end guarantee for the growth-rebuild case pushed + // through the real clear_cache path (not just restore() in + // isolation). + let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); + let mut previous_state = make_state(&"a".repeat(20)); + // Force mid-tween: displayed=5 of 20. + previous_state.displayed_char_count = 5; + previous_state.displayed_byte_offset = 5; + previous_state.reveal_base_char = 0; + let mut previous_streaming_messages = HashMap::new(); + previous_streaming_messages.insert(event_id.clone(), previous_state); + let previous_item_texts = HashMap::new(); + + let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_clear_cache_update( + // Rebuild brings a LONGER target (50 chars) — growth case. + [(event_id.clone(), "a".repeat(50), true)], + Some(&previous_streaming_messages), + &previous_item_texts, + ); + + let state = rebuilt.get(&event_id).unwrap(); + // displayed preserved at previous mid-tween position; fresh tween + // window anchored there. + assert_eq!(state.displayed_char_count, 5); + assert_eq!(state.reveal_base_char, 5); + assert!(state.needs_frame()); + assert!(should_schedule_frame); + } + + #[test] + fn test_clear_cache_update_rebuild_skips_unchanged_live_without_cached_state() { + let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); + let previous_item_texts = HashMap::from([(event_id.clone(), String::from("hello"))]); + + let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_clear_cache_update( + [(event_id, String::from("hello"), true)], + None, + &previous_item_texts, + ); + + assert!(rebuilt.is_empty()); + assert!(!should_schedule_frame); + } + #[test] fn translation_lang_popup_abs_pos_prefers_above_button() { let button_rect = Rect { @@ -11752,25 +11894,6 @@ mod tests { assert_eq!(layers.footer.as_deref(), Some("4s")); } - #[test] - fn test_rich_markdown_streaming_prefers_full_snapshot_rendering() { - let formatted = FormattedBody::html("

OpenClaw

"); - assert!(should_render_streaming_full_snapshot( - "根据搜索结果, **OpenClaw** 有两个不同的项目。", - Some(&formatted), - true, - )); - } - - #[test] - fn test_plain_text_streaming_keeps_typewriter_path() { - assert!(!should_render_streaming_full_snapshot( - "你好,我是 Octos。", - None, - true, - )); - } - #[test] fn test_bot_timeline_card_visible_for_bot_text_message() { let state = compute_bot_timeline_render_state( @@ -11907,31 +12030,6 @@ mod tests { assert!(!fenced_code_blocks_contain_cjk(body)); } - #[test] - fn test_streaming_update_requires_content_invalidation_for_new_full_snapshot_text() { - let state = StreamingAnimState::new("你好", true); - - assert!(streaming_update_requires_content_invalidation( - &state, - "## 标题\n\n内容", - true, - true, - )); - } - - #[test] - fn test_streaming_update_skips_invalidation_when_target_and_mode_are_unchanged() { - let mut state = StreamingAnimState::new("## 标题\n\n内容", true); - state.set_render_full_target(true); - - assert!(!streaming_update_requires_content_invalidation( - &state, - "## 标题\n\n内容", - true, - true, - )); - } - #[test] fn test_bot_timeline_card_preserves_reply_preview_and_condensed_layout() { let reply_state = compute_bot_timeline_render_state( diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs index 20e2068c..ff8656aa 100644 --- a/src/home/streaming_animation.rs +++ b/src/home/streaming_animation.rs @@ -3,172 +3,214 @@ use std::time::{Duration, Instant}; const FINISHED_STREAM_TIMEOUT: Duration = Duration::from_secs(30); const LIVE_STREAM_STALL_TIMEOUT: Duration = Duration::from_secs(5 * 60); -/// Characters to reveal per amortized chunk, closer to Moly's small-block growth. -const REVEAL_CHUNK_SIZE: usize = 2; -/// Fixed cadence for releasing each chunk. -const REVEAL_INTERVAL: Duration = Duration::from_millis(55); -/// Characters to reveal immediately when new content arrives after the UI had caught up. -const ARRIVAL_BURST: usize = 1; -/// When the stream is finished and this few chars remain, snap to the end. -const FINISH_SNAP_THRESHOLD: usize = 20; +/// Upper bound on how long a single live update's interpolation can run. +/// Chosen strictly smaller than any reasonable server edit throttle so +/// the client always catches up before the next edit arrives, preventing +/// the long-tail behaviour of PR #14's fixed-cadence pacer. +const TWEEN_DURATION: Duration = Duration::from_millis(150); /// Animation state for a single streaming message. -/// Tracks an MSC4357 live message and drives character-by-character reveal. +/// Tracks an MSC4357 live message and caches the latest full snapshot. pub struct StreamingAnimState { pub target_text: String, pub target_char_count: usize, pub displayed_char_count: usize, pub displayed_byte_offset: usize, - pub fractional_chunks: f64, pub last_update_time: Instant, - pub last_tick_time: Instant, pub animation_start_time: Instant, pub display_buffer: String, /// Whether the message currently carries the MSC4357 `live` field. pub is_live: bool, - /// Whether this message should render the full current snapshot directly - /// instead of the local typewriter prefix. Useful for markdown-rich bot replies - /// where partial prefixes degrade rendering quality and cost. - pub render_full_target: bool, pub timeline_index: Option, + /// Starting displayed_char_count for the current tween window. + /// Equals target_char_count when no tween is in progress. + pub reveal_base_char: usize, + /// Reference time for the current tween window. + pub reveal_base_time: Instant, } impl StreamingAnimState { + fn sync_displayed_to_target(&mut self) { + self.displayed_char_count = self.target_char_count; + self.displayed_byte_offset = self.target_text.len(); + self.reveal_base_char = self.target_char_count; + } + pub fn new(initial_text: &str, is_live: bool) -> Self { let char_count = initial_text.chars().count(); let now = Instant::now(); - Self { + let mut state = Self { target_text: initial_text.to_string(), target_char_count: char_count, displayed_char_count: 0, displayed_byte_offset: 0, - fractional_chunks: 0.0, last_update_time: now, - last_tick_time: now, animation_start_time: now, display_buffer: String::with_capacity(initial_text.len() + 4), is_live, - render_full_target: false, timeline_index: None, - } + reveal_base_char: 0, + reveal_base_time: now, + }; + state.sync_displayed_to_target(); + state } pub fn restore(previous: &Self, new_text: &str, is_live: bool) -> Self { let mut restored = Self::new(new_text, is_live); - let visible_prefix = &previous.target_text[..previous.displayed_byte_offset]; - let (common_chars, common_bytes) = common_prefix_len(visible_prefix, new_text); - - restored.displayed_char_count = common_chars; - restored.displayed_byte_offset = common_bytes; restored.animation_start_time = previous.animation_start_time; - restored.render_full_target = previous.render_full_target; restored.timeline_index = previous.timeline_index; + + // Preserve in-flight tween state across timeline rebuilds + // (clear_cache / full_snapshot). Three-way split aligns with + // update_target()'s semantics while still providing visual + // continuity for content-unchanged rebuilds: + // + // - Same text: preserve tween (rebuild carried no new content). + // - Different text, target grew: open a fresh tween window + // (matches update_target's growth branch). + // - Different text, same length or shrunk: sync to target + // (matches update_target's non-growth branch, avoids showing + // a stale prefix against the new text). + let had_in_flight_tween = + previous.displayed_char_count < previous.target_char_count; + if is_live && had_in_flight_tween { + let prev_displayed = + previous.displayed_char_count.min(restored.target_char_count); + let prev_byte_offset = new_text + .char_indices() + .nth(prev_displayed) + .map_or(new_text.len(), |(byte_idx, _)| byte_idx); + let same_text = new_text == previous.target_text.as_str(); + + if same_text { + // Rebuild delivered the same text — continue tween in + // place so the user perceives no interruption. + restored.displayed_char_count = prev_displayed; + restored.displayed_byte_offset = prev_byte_offset; + restored.reveal_base_char = + previous.reveal_base_char.min(restored.target_char_count); + restored.reveal_base_time = previous.reveal_base_time; + } else if restored.target_char_count > previous.target_char_count { + // Text grew. Open a fresh tween window from current + // displayed position so the next tick doesn't pair an old + // reveal_base_time with a larger delta and jump several + // chars at once. + restored.displayed_char_count = prev_displayed; + restored.displayed_byte_offset = prev_byte_offset; + restored.reveal_base_char = prev_displayed; + restored.reveal_base_time = Instant::now(); + } + // else: different text + non-growth (same-length rewrite or + // shrink). Self::new() already synced displayed to target so + // we don't render a stale prefix against the new text. This + // matches update_target()'s non-growth branch behaviour. + } + restored } pub fn update_target(&mut self, new_text: &str, is_live: bool) { - let prev_char_count = self.target_char_count; - let had_backlog = self.displayed_char_count < prev_char_count; + let prev_target_char_count = self.target_char_count; + let previous_displayed = self.displayed_char_count; self.target_text.clear(); self.target_text.push_str(new_text); self.target_char_count = new_text.chars().count(); self.is_live = is_live; - // Clamp char count if the new text is shorter than what was already displayed. - if self.displayed_char_count > self.target_char_count { - self.displayed_char_count = self.target_char_count; - } - - // Always recalculate byte offset: the new text may have different - // byte widths at already-displayed positions (e.g. markdown formatting - // changes between streaming updates). - self.displayed_byte_offset = self.target_text - .char_indices() - .nth(self.displayed_char_count) - .map_or(self.target_text.len(), |(i, _)| i); - - // Arrival burst: only when we had fully caught up and were waiting - // for more text. If backlog already exists, stay on the amortized cadence. - let added_chars = self.target_char_count.saturating_sub(prev_char_count); - if added_chars > 0 && !had_backlog { - self.advance_displayed(added_chars.min(ARRIVAL_BURST)); - } - let now = Instant::now(); self.last_update_time = now; - // If the animation had already caught up and was waiting for more text, - // restart the frame clock so idle time doesn't count as reveal time. - // If backlog already existed, keep the clock to preserve smooth cadence. - if !had_backlog { - self.last_tick_time = now; - } - // Reserve only the deficit (reserve(n) guarantees capacity >= len + n). + let needed = new_text.len() + 4; if self.display_buffer.capacity() < needed { self.display_buffer.reserve(needed - self.display_buffer.len()); } - if self.render_full_target { - self.displayed_char_count = self.target_char_count; - self.displayed_byte_offset = self.target_text.len(); - self.fractional_chunks = 0.0; + let is_growth = is_live && self.target_char_count > prev_target_char_count; + if is_growth { + // Keep displayed at its current position and open a fresh tween + // window. displayed_char_count may already trail target_char_count + // from an earlier tween; preserve it as the new reveal base. + self.reveal_base_char = previous_displayed.min(self.target_char_count); + self.reveal_base_time = now; + // displayed_byte_offset is left trailing; the next tick will advance + // it when displayed_char_count moves forward. Clamp it here to stay + // within the new target text so advance_displayed's slicing stays + // safe even before the first tick. + self.displayed_char_count = self.reveal_base_char; + self.displayed_byte_offset = self.target_text + .char_indices() + .nth(self.reveal_base_char) + .map_or(self.target_text.len(), |(byte_idx, _)| byte_idx); + } else { + // Finish edit or non-growing live update: sync immediately. + self.sync_displayed_to_target(); + self.reveal_base_time = now; } } pub fn advance_displayed(&mut self, chars_to_add: usize) { - if chars_to_add == 0 || self.displayed_char_count >= self.target_char_count { return; } + if chars_to_add == 0 || self.displayed_char_count >= self.target_char_count { + return; + } + let remaining = &self.target_text[self.displayed_byte_offset..]; let mut byte_advance = 0; let mut actual_chars = 0; for (byte_idx, _char) in remaining.char_indices() { - if actual_chars >= chars_to_add { byte_advance = byte_idx; break; } + if actual_chars >= chars_to_add { + byte_advance = byte_idx; + break; + } actual_chars += 1; } if actual_chars <= chars_to_add && byte_advance == 0 && !remaining.is_empty() { byte_advance = remaining.len(); } - self.displayed_char_count = (self.displayed_char_count + actual_chars).min(self.target_char_count); - self.displayed_byte_offset = (self.displayed_byte_offset + byte_advance).min(self.target_text.len()); + self.displayed_char_count = + (self.displayed_char_count + actual_chars).min(self.target_char_count); + self.displayed_byte_offset = + (self.displayed_byte_offset + byte_advance).min(self.target_text.len()); } pub fn tick(&mut self) -> bool { - let now = Instant::now(); - let elapsed = now.saturating_duration_since(self.last_tick_time); - self.last_tick_time = now; + let elapsed = self.reveal_base_time.elapsed(); self.tick_with_elapsed(elapsed) } - pub fn tick_with_elapsed(&mut self, elapsed: Duration) -> bool { - if self.displayed_char_count >= self.target_char_count { return false; } - let remaining = self.target_char_count - self.displayed_char_count; - - // Finish snap: when the stream is done and only a few chars remain, show them all. - if !self.is_live && remaining <= FINISH_SNAP_THRESHOLD { - self.advance_displayed(remaining); - return true; + pub fn tick_with_elapsed(&mut self, elapsed_since_reveal: Duration) -> bool { + if self.displayed_char_count >= self.target_char_count { + return false; } - // Moly-style amortization: reveal fixed-size chunks at a fixed cadence - // instead of accelerating as backlog grows. - self.fractional_chunks += elapsed.as_secs_f64() / REVEAL_INTERVAL.as_secs_f64(); - let advance_chunks = self.fractional_chunks.floor() as usize; - self.fractional_chunks -= advance_chunks as f64; - if advance_chunks > 0 { - self.advance_displayed(advance_chunks * REVEAL_CHUNK_SIZE); - return true; + let progress = (elapsed_since_reveal.as_secs_f64() + / TWEEN_DURATION.as_secs_f64()) + .clamp(0.0, 1.0); + + let delta = self.target_char_count.saturating_sub(self.reveal_base_char); + let target_displayed = self.reveal_base_char + + ((delta as f64) * progress).round() as usize; + let target_displayed = target_displayed.min(self.target_char_count); + + if target_displayed <= self.displayed_char_count { + return false; } - false + + let advance = target_displayed - self.displayed_char_count; + self.advance_displayed(advance); + true } pub fn fill_display_buffer(&mut self) { self.display_buffer.clear(); - self.display_buffer.push_str(&self.target_text[..self.displayed_byte_offset]); + self.display_buffer + .push_str(&self.target_text[..self.displayed_byte_offset]); self.display_buffer.push_str(" \u{25CF}"); } pub fn needs_frame(&self) -> bool { - !self.render_full_target && self.displayed_char_count < self.target_char_count + self.displayed_char_count < self.target_char_count } /// Streaming is complete when the live field is absent and all text has been revealed. @@ -187,34 +229,6 @@ impl StreamingAnimState { pub fn is_timed_out(&self) -> bool { self.last_update_time.elapsed() > self.timeout_after() } - - pub fn set_render_full_target(&mut self, render_full_target: bool) { - self.render_full_target = render_full_target; - if render_full_target { - self.displayed_char_count = self.target_char_count; - self.displayed_byte_offset = self.target_text.len(); - self.fractional_chunks = 0.0; - } - } -} - -fn common_prefix_len(lhs: &str, rhs: &str) -> (usize, usize) { - let mut chars = 0; - let mut bytes = 0; - let mut lhs_chars = lhs.chars(); - - for (byte_idx, rhs_char) in rhs.char_indices() { - let Some(lhs_char) = lhs_chars.next() else { - break; - }; - if lhs_char != rhs_char { - break; - } - chars += 1; - bytes = byte_idx + rhs_char.len_utf8(); - } - - (chars, bytes) } #[cfg(test)] @@ -227,7 +241,9 @@ mod tests { #[test] fn test_advance_ascii() { - let mut s = make_state("Hello, world!"); + let mut s = StreamingAnimState::new("Hello, world!", true); + s.displayed_char_count = 0; + s.displayed_byte_offset = 0; s.advance_displayed(5); assert_eq!(s.displayed_char_count, 5); assert_eq!(&s.target_text[..s.displayed_byte_offset], "Hello"); @@ -235,7 +251,9 @@ mod tests { #[test] fn test_advance_utf8_multibyte() { - let mut s = make_state("你好世界abcd"); + let mut s = StreamingAnimState::new("你好世界abcd", true); + s.displayed_char_count = 0; + s.displayed_byte_offset = 0; s.advance_displayed(2); assert_eq!(s.displayed_char_count, 2); assert_eq!(&s.target_text[..s.displayed_byte_offset], "你好"); @@ -243,44 +261,114 @@ mod tests { #[test] fn test_advance_clamps_at_end() { - let mut s = make_state("abc"); + let mut s = StreamingAnimState::new("abc", true); + s.displayed_char_count = 0; + s.displayed_byte_offset = 0; s.advance_displayed(100); assert_eq!(s.displayed_char_count, 3); assert_eq!(s.displayed_byte_offset, 3); } #[test] - fn test_update_target_extends() { + fn test_new_state_starts_fully_visible() { + let s = make_state("Hello"); + assert_eq!(s.displayed_char_count, s.target_char_count); + assert_eq!(s.displayed_byte_offset, s.target_text.len()); + assert!(!s.needs_frame()); + } + + #[test] + fn test_update_target_tracks_latest_full_snapshot() { + let mut s = make_state("Hello, world!"); + // Non-growing live update must still sync immediately because there + // is no backlog to interpolate across. + s.update_target("Greetings!", true); + assert_eq!(s.target_char_count, 10); + assert_eq!(s.displayed_char_count, s.target_char_count); + assert_eq!(s.displayed_byte_offset, s.target_text.len()); + assert!(!s.needs_frame()); + } + + #[test] + fn test_update_target_live_growth_defers_sync_via_tween() { let mut s = make_state("Hello"); - s.advance_displayed(5); + // Sanity: new() already synced displayed to the first target. + assert_eq!(s.displayed_char_count, s.target_char_count); + + let displayed_before = s.displayed_char_count; s.update_target("Hello, world!", true); - assert_eq!(s.target_char_count, 13); - // Arrival burst reveals only the newly added chars, capped by ARRIVAL_BURST. - assert_eq!(s.displayed_char_count, 5 + ARRIVAL_BURST.min(8)); + + // Growth path must NOT sync displayed; it should stay at the previous + // target so tick_with_elapsed can interpolate toward the new target. + assert_eq!(s.displayed_char_count, displayed_before); + assert_eq!(s.reveal_base_char, displayed_before); + assert!(s.displayed_char_count < s.target_char_count); + assert!(s.needs_frame()); } #[test] - fn test_update_target_uses_single_char_burst_when_waiting_for_new_text() { + fn test_update_target_live_false_syncs_immediately() { let mut s = make_state("Hello"); - s.advance_displayed(5); - s.update_target("Hello, world!", true); - assert_eq!(s.displayed_char_count, 6); + // Force a mid-tween state to prove the sync still happens. + s.displayed_char_count = 1; + s.displayed_byte_offset = 1; + + s.update_target("Hello, world!", false); + + assert_eq!(s.displayed_char_count, s.target_char_count); + assert_eq!(s.displayed_byte_offset, s.target_text.len()); + assert!(!s.needs_frame()); } #[test] - fn test_update_target_does_not_burst_while_backlog_exists() { + fn test_tick_interpolates_displayed_toward_target() { let mut s = make_state("Hello"); - s.advance_displayed(2); - s.update_target("Hello!", true); - // When backlog already exists, keep the amortized cadence instead of - // applying a fresh burst on every incoming update. - assert_eq!(s.displayed_char_count, 2); + s.update_target(&"a".repeat(100), true); + // Sanity: growth path sets up the tween. + assert_eq!(s.reveal_base_char, 5); + assert_eq!(s.target_char_count, 100); + + let changed = s.tick_with_elapsed(TWEEN_DURATION / 2); + + assert!(changed); + // Halfway through TWEEN_DURATION should reveal ~half of the 95-char + // delta (5 base + ~47 revealed = ~52). Give a ±2-char tolerance to + // absorb rounding across platforms. + assert!(s.displayed_char_count >= 50); + assert!(s.displayed_char_count <= 54); + assert!(s.displayed_char_count < s.target_char_count); + assert!(s.needs_frame()); + } + + #[test] + fn test_tick_completes_tween_at_full_duration() { + let mut s = make_state("Hello"); + s.update_target(&"a".repeat(100), true); + + let changed = s.tick_with_elapsed(TWEEN_DURATION); + + assert!(changed); + assert_eq!(s.displayed_char_count, s.target_char_count); + assert!(!s.needs_frame()); + } + + #[test] + fn test_tick_noop_when_displayed_already_caught_up() { + let mut s = make_state("Hello"); + // new() already synced displayed to target. + assert_eq!(s.displayed_char_count, s.target_char_count); + + let before = s.displayed_char_count; + let changed = s.tick_with_elapsed(Duration::from_secs(1)); + + assert!(!changed); + assert_eq!(s.displayed_char_count, before); + assert!(!s.needs_frame()); } #[test] fn test_update_target_shrinks_safely() { let mut s = make_state("Hello, world!"); - s.advance_displayed(10); s.update_target("Hi", true); assert_eq!(s.displayed_char_count, 2); assert_eq!(s.displayed_byte_offset, 2); @@ -290,73 +378,35 @@ mod tests { #[test] fn test_update_target_recalculates_byte_offset_for_different_prefix() { - // Simulate: displayed 5 ASCII chars, then text replaced with CJK characters. - // Old byte offset (5) would be inside a multi-byte char in the new text. let mut s = make_state("hello world"); - s.advance_displayed(5); - assert_eq!(s.displayed_byte_offset, 5); - - // New text has 5+ chars but first 5 chars are 3-byte CJK. - // Without the fix, displayed_byte_offset stays 5, crashing on slice. s.update_target("你好世界测试数据", true); - assert_eq!(s.displayed_char_count, 5); - // 5 CJK chars × 3 bytes = 15 - assert_eq!(s.displayed_byte_offset, 15); - // Must not panic: + assert_eq!(s.displayed_char_count, 8); + assert_eq!(s.displayed_byte_offset, s.target_text.len()); s.fill_display_buffer(); - assert!(s.display_buffer.starts_with("你好世界测")); - } - - #[test] - fn test_tick_advances() { - let mut s = make_state("Hello, world!"); - let changed = s.tick_with_elapsed(REVEAL_INTERVAL); - assert!(changed); - assert_eq!(s.displayed_char_count, REVEAL_CHUNK_SIZE); + assert!(s.display_buffer.starts_with("你好世界测试数据")); } #[test] - fn test_tick_waits_for_full_chunk_interval() { + fn test_tick_does_not_advance_without_local_typewriter() { let mut s = make_state("Hello, world!"); - assert!(!s.tick_with_elapsed(REVEAL_INTERVAL / 2)); - assert_eq!(s.displayed_char_count, 0); - } - - #[test] - fn test_tick_large_gap_smooth() { - let mut s = make_state(&"a".repeat(1000)); - // Even after a large elapsed gap, keep a steady amortized pace. - assert!(s.tick_with_elapsed(Duration::from_secs(1))); - assert!(s.displayed_char_count >= 30); - assert!(s.displayed_char_count <= 40); + let before = s.displayed_char_count; + let changed = s.tick_with_elapsed(Duration::from_secs(1)); + assert!(!changed); + assert_eq!(s.displayed_char_count, before); } #[test] - fn test_fill_display_buffer() { + fn test_fill_display_buffer_appends_cursor_to_full_snapshot() { let mut s = make_state("Hello"); - s.advance_displayed(3); s.fill_display_buffer(); - assert!(s.display_buffer.starts_with("He")); - assert!(s.display_buffer.contains('\u{25CF}') || s.display_buffer.contains('●')); - } - - #[test] - fn test_render_full_target_disables_typewriter_frames() { - let mut s = make_state("## Heading\n\n**bold**"); - s.set_render_full_target(true); - - assert!(!s.needs_frame()); - assert_eq!(s.displayed_char_count, s.target_char_count); - assert_eq!(s.displayed_byte_offset, s.target_text.len()); + assert!(s.display_buffer.starts_with("Hello")); + assert!(s.display_buffer.ends_with(" \u{25CF}")); } #[test] fn test_is_complete_msc4357() { let mut s = make_state("Hi"); - s.advance_displayed(2); - // is_live=true → not complete even though all text revealed assert!(!s.is_complete()); - // Simulate final edit without live field s.is_live = false; assert!(s.is_complete()); } @@ -370,29 +420,87 @@ mod tests { } #[test] - fn test_restore_preserves_common_prefix() { - // Extension: keep what was already displayed - let mut prev = make_state("Hello, world!"); - prev.advance_displayed(5); + fn test_restore_tracks_latest_full_snapshot() { + let prev = make_state("Hello, world!"); let restored = StreamingAnimState::restore(&prev, "Hello, world!!!", true); - assert_eq!(restored.displayed_char_count, 5); - assert_eq!(&restored.target_text[..restored.displayed_byte_offset], "Hello"); + assert_eq!(restored.displayed_char_count, restored.target_char_count); + assert_eq!(restored.displayed_byte_offset, restored.target_text.len()); + } + + #[test] + fn test_restore_preserves_in_flight_tween() { + // Previous state is mid-tween: displayed=10 of 100, base=5, elapsed + // 40ms since reveal_base_time (well under TWEEN_DURATION). + let mut prev = make_state(&"a".repeat(100)); + // Force mid-tween manually (simulates what update_target + tick would produce). + prev.displayed_char_count = 10; + prev.displayed_byte_offset = 10; + prev.reveal_base_char = 5; + let prev_base_time = prev.reveal_base_time; + + // Simulate a clear_cache / full_snapshot rebuild: restore() re-creates + // the state from new_items. Same text, same live flag. + let restored = StreamingAnimState::restore(&prev, &"a".repeat(100), true); + + // Tween state must be preserved so the next tick continues the + // animation from where it left off instead of snapping to fully + // displayed. + assert_eq!(restored.displayed_char_count, 10); + assert_eq!(restored.displayed_byte_offset, 10); + assert_eq!(restored.reveal_base_char, 5); + assert_eq!(restored.reveal_base_time, prev_base_time); + assert!(restored.needs_frame()); + } - // Divergence: clamp to the common prefix - let mut prev2 = make_state("Hello, world!"); - prev2.advance_displayed(12); - let restored2 = StreamingAnimState::restore(&prev2, "Hello there", true); - assert_eq!(&restored2.target_text[..restored2.displayed_byte_offset], "Hello"); + #[test] + fn test_restore_non_growth_rewrite_syncs_to_target() { + // Previous state is mid-tween on "aaaaa" (5 chars), displayed=2. + let mut prev = make_state(&"a".repeat(5)); + prev.displayed_char_count = 2; + prev.displayed_byte_offset = 2; + prev.reveal_base_char = 0; + + // Rebuild delivers a DIFFERENT 5-char text (same length rewrite). + // Continuing the old tween would show "aa" of the old text as a + // stale prefix against the new text. Sync matches + // update_target()'s non-growth branch. + let restored = StreamingAnimState::restore(&prev, "world", true); + + assert_eq!(restored.target_char_count, 5); + assert_eq!(restored.displayed_char_count, restored.target_char_count); + assert_eq!(restored.displayed_byte_offset, restored.target_text.len()); + assert!(!restored.needs_frame()); + } + + #[test] + fn test_restore_growth_rebuild_opens_fresh_tween_window() { + // Previous state is mid-tween: displayed=10 of 100. + let mut prev = make_state(&"a".repeat(100)); + prev.displayed_char_count = 10; + prev.displayed_byte_offset = 10; + prev.reveal_base_char = 5; + let prev_base_time = prev.reveal_base_time; + + // Rebuild brings a LONGER target (150 chars). Reusing the old + // reveal_base_time here would pair old elapsed with a larger delta + // and advance many chars at once on the next tick. + let restored = StreamingAnimState::restore(&prev, &"a".repeat(150), true); + + // displayed preserved for visual continuity. + assert_eq!(restored.displayed_char_count, 10); + // Fresh tween window anchored at current displayed with a NEW + // reveal_base_time — strictly > prev_base_time. + assert_eq!(restored.reveal_base_char, 10); + assert!(restored.reveal_base_time > prev_base_time); + assert!(restored.needs_frame()); } #[test] fn test_timeout_split_by_live_state() { - // Live stream survives 31s idle (5min stall timeout) let mut live = make_state("Hello"); live.last_update_time = Instant::now() - Duration::from_secs(31); assert!(!live.is_timed_out()); - // Finished stream times out after 31s (30s cleanup timeout) let mut finished = make_state("Hello"); finished.is_live = false; finished.last_update_time = Instant::now() - Duration::from_secs(31); @@ -403,45 +511,14 @@ mod tests { fn test_tick_zero_elapsed() { let mut s = make_state("Hello"); assert!(!s.tick_with_elapsed(Duration::ZERO)); - assert_eq!(s.displayed_char_count, 0); - } - - #[test] - fn test_update_target_preserves_tick_clock_when_backlog_already_exists() { - let mut s = make_state("Hello, world!"); - s.advance_displayed(3); - let before = Instant::now() - Duration::from_millis(120); - s.last_tick_time = before; - - s.update_target("Hello, world!!!", true); - - assert_eq!(s.last_tick_time, before); - } - - #[test] - fn test_update_target_resets_tick_clock_when_waiting_for_new_text() { - let mut s = make_state("Hello"); - s.advance_displayed(5); - let before = Instant::now() - Duration::from_secs(5); - s.last_tick_time = before; - - s.update_target("Hello!", true); - - assert!(s.last_tick_time > before); + assert_eq!(s.displayed_char_count, s.target_char_count); } #[test] - fn test_finish_snap() { + fn test_finished_stream_is_complete_without_extra_frames() { let mut s = make_state(&"a".repeat(30)); - s.advance_displayed(20); - // 10 remaining but is_live=true → normal tick, no snap. - s.tick_with_elapsed(Duration::from_millis(16)); - assert!(s.displayed_char_count < 30); - - // Mark as finished → remaining <= FINISH_SNAP_THRESHOLD → snaps to end. s.is_live = false; - assert!(s.tick_with_elapsed(Duration::from_millis(1))); - assert_eq!(s.displayed_char_count, 30); + assert!(s.is_complete()); + assert!(!s.tick_with_elapsed(Duration::from_secs(1))); } - }