Skip to content

fix: restore MSC4357 streaming animation for bot replies#99

Open
TigerInYourDream wants to merge 9 commits intomainfrom
fix/streaming-animation-regression
Open

fix: restore MSC4357 streaming animation for bot replies#99
TigerInYourDream wants to merge 9 commits intomainfrom
fix/streaming-animation-regression

Conversation

@TigerInYourDream
Copy link
Copy Markdown

Summary

  • Adopt Moly-style live snapshot rendering: display latest full text + trailing ● cursor during live updates, then fall through to the existing rich markdown renderer when the MSC4357 live field clears
  • Fix regression from 1a33998 where should_render_streaming_full_snapshot disabled the PR feat: MSC4357 streaming typewriter animation for bot responses #14 typewriter for any bot reply with HTML formatted_body or markdown syntax (= virtually every AI response)
  • Repair two timeline-update paths that previously dropped new live streams: clear_cache=true batches on fresh room subscriptions, and same-event_id text growth in the non-clear_cache scan range
  • Side fix in LinkPreview: reset collapsible buttons when preview count shrinks, fixing stale state leak across recycled PortalList items

Design

See specs/task-restore-streaming-animation.spec.md for the full task contract. Key decisions:

  • StreamingAnimState stays as the MSC4357 lifecycle tracker but no longer paces local character reveal; new/update_target/restore sync displayed_* to the latest full target
  • needs_frame() returns false during live; the existing frame handler only runs a cleanup pass when is_complete() flips true on the finish edit
  • Streaming render path hides bot_card_markdown, bot_card_markdown_plain, bot_status_strip, bot_metadata_footer, and link_preview_view to keep recycled PortalList items from bleeding state into streaming replies

External dependency

If the appservice never emits the MSC4357 finish_stream signal (i.e. the terminal edit still carries the live field), robrix2 waits the existing 5-minute live-stall timeout before restoring rich markdown. This PR does not modify Octos or any appservice. Recommended follow-up: upstream Octos PR to edit_messagefinish_stream on terminal streaming edits.

Test plan

  • cargo check --lib clean
  • cargo test --lib home::streaming_animation 15/15 pass
  • cargo test --lib home::link_preview 3/3 pass
  • Manual: MSC4357-compliant bot reply renders latest text + ● during live; switches to rich markdown on finish
  • Manual: long replies (500+ chars) keep pace with upstream updates without local typewriter tail
  • Manual: link preview cards do not leak between recycled timeline items (send two bot replies in a row, first containing a URL)
  • Manual: Chinese / emoji / code-block replies do not panic (UTF-8 safety)
  • Manual: switching rooms mid-stream preserves visible text on return

Adopt Moly-style live snapshot rendering: display the latest full
text with a trailing cursor during live updates, then fall through
to the existing rich markdown renderer once the `live` field clears.

Regression introduced by 1a33998: `should_render_streaming_full_snapshot`
disabled the PR #14 typewriter for any bot reply with HTML formatted_body
or markdown syntax, i.e. virtually every AI response. The previous
client-side fixed-cadence pacer also produced long tails or catch-up
bursts depending on network timing.

- streaming_animation.rs: drop render_full_target and local reveal
  cadence; new/update/restore sync displayed state to the latest
  target; tick degrades to a no-op
- room_screen.rs: thread streaming plaintext + cursor into the
  bot-card populate path; fix clear_cache and same-event_id
  scan_range paths that previously dropped new live streams
- link_preview.rs: reset collapsible buttons when preview count
  shrinks (stale state leak in recycled PortalList items)
- specs/task-restore-streaming-animation.spec.md: task contract

External dependency: if the appservice never emits the MSC4357
finish_stream marker, robrix2 waits the existing 5-minute live-stall
timeout before restoring rich markdown. This PR does not modify any
appservice.

cargo check clean; streaming_animation 15/15; link_preview 3/3.
@TigerInYourDream
Copy link
Copy Markdown
Author

Added bounded per-update tween (TWEEN_DURATION = 150ms, strictly < any reasonable server edit throttle) so live streaming feels smooth even on coarse-grained server updates.

Three additional commits on this branch:

  • test: add failing tests for bounded streaming tween
  • feat: bounded per-update tween for streaming animation
  • test: align streaming tests with bounded tween contract

Spec updated in this same branch with the tween decisions and acceptance criteria. Plan: docs/superpowers/plans/2026-04-15-streaming-bounded-tween.md.

Verification:

  • cargo check --lib: clean
  • cargo test --lib home::streaming_animation: 20/20 pass
  • cargo test --lib home::link_preview: 3/3 pass
  • cargo test --lib (full): same 4 pre-existing failures as before this work, no new regressions

The tween is intentionally short (150ms) so it always completes before the next server edit (Octos throttles edits to ≥200ms). This guarantees no PR #14-style long tail while smoothing the visual stutter from coarse server pacing.

Spec adds the bounded-tween contract to the 决策 section
(TWEEN_DURATION = 150ms, growth-defers-sync, finish-syncs-immediately,
linear interpolation in tick_with_elapsed, needs_frame reflects
tween progress) and replaces the obsolete tick-no-op scenario with
five new acceptance scenarios that map 1:1 to the unit tests.

Plan documents the TDD task breakdown that produced the three
preceding commits.
restore() previously called Self::new(new_text, is_live), which invokes
sync_displayed_to_target() and sets displayed == target. That cancelled
any active tween whenever the timeline did a clear_cache or
full_snapshot rebuild mid-stream — the visible artefact was "animation
stuck for a beat, then text appears all at once".

Preserve the previous tween state (displayed_char_count,
displayed_byte_offset, reveal_base_char, reveal_base_time) when
previous was mid-tween (displayed < target) and the new update is
still live. Clamp displayed and reveal_base_char to the new target's
char count and recompute byte offset so slicing in advance_displayed
stays safe.

Added test_restore_preserves_in_flight_tween covering the
clear_cache / full_snapshot rebuild case. cargo test --lib
home::streaming_animation now 21/21.
The 意图 section previously said "不再做本地二次 cadence 动画"
while the 决策 section introduced TWEEN_DURATION = 150ms and bounded
per-update tween — a real internal contradiction that would mislead
future implementers.

Rewrite 意图 to state both goals explicitly (align with server pace
to avoid PR #14 long tail, AND smooth out Matrix edit coarseness with
a bounded local tween) and explain the Moly vs Robrix architectural
difference (openclaw SSE token rate ~30-50ms vs Matrix edit throttle
200-1000ms) that motivates the tween.

agent-spec lint stays at 100%.
Previous restore() preserved reveal_base_time across rebuilds
unconditionally. When the rebuild brought a longer target than the
previous tween was heading toward, the next tick() would pair old
elapsed with a larger delta and jump several chars at once — just
re-introducing the "stuck then jump" artefact via a different path.

Split the preserve branch on target size:
- target unchanged or shrunk: keep old reveal_base_time (tween continues
  seamlessly, visual continuity across rebuild)
- target grew: open a fresh tween window (reveal_base_char = current
  displayed, reveal_base_time = now) so the next tick starts a new
  150ms interpolation from where the user is looking

Added test_restore_growth_rebuild_opens_fresh_tween_window to pin
the growth-rebuild behaviour at the unit level.

Updated the integration regression at room_screen.rs
test_full_snapshot_rebuild_restores_live_cached_streams to assert
the tween-preserve semantics end-to-end — previously it still
verified the obsolete "snap to fully revealed" contract, leaving
the preserve path uncovered at the integration layer.

cargo test --lib home::streaming_animation: 22/22
cargo test --lib test_full_snapshot_rebuild_: 3/3
cargo test --lib test_clear_cache_update_rebuild_: 3/3
Full lib: 253 pass / same 4 pre-existing failures, no new regressions.
restore() previously preserved the old reveal_base_time for any
non-growth rebuild. That meant a same-length rewrite or shrink
during a clear_cache / full_snapshot rebuild would keep animating
a stale prefix against the new text — inconsistent with
update_target()'s non-growth branch which syncs immediately.

Split the preserve branch into three cases:
- Same text (rebuild carried no new content): preserve tween in
  place (visual continuity for clear_cache/full_snapshot replays).
- Different text + target grew: open a fresh tween window from
  current displayed position (existing behaviour).
- Different text + same length or shrunk: sync to target,
  matching update_target()'s non-growth branch and avoiding
  stale-prefix rendering.

Added unit test_restore_non_growth_rewrite_syncs_to_target to
pin the rewrite-to-sync behaviour.

Added integration regression
test_clear_cache_update_rebuild_preserves_in_flight_tween_from_cached_state
in room_screen so the tween-preserve guarantee now rides through
rebuild_streaming_messages_for_clear_cache_update end-to-end, not
just restore() in isolation. Previously only the full_snapshot
path was covered at the integration layer.

cargo test --lib home::streaming_animation: 23/23
cargo test --lib test_full_snapshot_rebuild_: 3/3
cargo test --lib test_clear_cache_update_rebuild_: 4/4
Full lib: 255 pass / same 4 pre-existing failures, no new regressions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant