Keep the chat on a far-back summary card after its anchor loads
Publish plugin / publish (push) Successful in 3s

With an offset the newest analyzed message is usually outside the loaded
window, so placement asks the chat to load it first. That path could leave the
view away from the card while offset 0 always worked:

- Placement gave up after ~3 s (25 x 120 ms) and asked for the anchor only
  once. scrollToMessageId drops the request while waitingForGetDifference, and
  a load can be superseded. It now waits ~10 s and re-requests every ~2 s.
- Loading a far anchor makes ChatActivity run its own postponed scroll, and a
  cache-then-server reload can re-scroll or replace the list. Either can land
  after the jump. Settle checks at 0.25/0.7/1.5/3 s re-jump if the card row is
  outside the visible range, and re-place the card if a reload dropped it.
  They stop as soon as the user drags the list.

Also logs each run's count/offset, the fetched id range, pre-stream failures,
load requests, placement and jumps to the diagnostics file, since bulletin-only
errors previously left no trace.

Verified on device with count 1000, offset 1000: anchor loaded on the first
retry, card placed after it and the chat jumped there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lyuksovannyyandClaude Opus 5.5 committed 2026-10-09 12:52:24 +02:00
1 parent 16bf8de84c
commit af23d2e682
5 files changed
+219 -13

No files matched your search

+88 -2
View File
@@ -1845,6 +1845,10 @@ def main(argv=None) -> int:
def __init__(self):
self.cells = []
self.stopped = 0
self.scroll_state = 0
def getScrollState(self):
return self.scroll_state
def getMeasuredHeight(self):
return 900
@@ -1859,11 +1863,30 @@ def main(argv=None) -> int:
return self.cells[index]
class _FakeLayoutManager:
"""Records jumps and reports the visible row range they produce.
`hijack` simulates ChatActivity's own postponed scroll landing
right after a jump and carrying the view elsewhere.
"""
def __init__(self):
self.jumps = []
self.visible = (-1, -1)
self.hijack = 0
def scrollToPositionWithOffset(self, position, offset, bottom):
self.jumps.append((position, offset, bottom))
if self.hijack:
self.hijack -= 1
self.visible = (40, 46)
else:
self.visible = (max(0, position - 3), position + 3)
def findFirstVisibleItemPosition(self):
return self.visible[0]
def findLastVisibleItemPosition(self):
return self.visible[1]
class _FakeChatActivity:
def __init__(self, ids):
@@ -2088,6 +2111,64 @@ def main(argv=None) -> int:
f"ids={far_ids}",
)
# Loading a far anchor makes ChatActivity run its own postponed scroll,
# which can land after the jump and carry the view away from the card.
# The settle checks must bring it back.
hijacked = _FakeChatActivity([900, 800])
hijacked.loadable[300] = [310, 300, 290]
hijacked.chatLayoutManager.hijack = 1
hijacked_card = injector.virtual_id_for(7, 300)
injector.SummaryInjection(hijacked, 0, -4242, 0, 300, hijacked_card).insert(
thinking="chunk", streaming=True
)
card_row = 2 + hijacked.ids().index(hijacked_card)
first, last = hijacked.chatLayoutManager.visible
check(
"a jump overridden by the app's own scroll is redone until the card is on screen",
len(hijacked.chatLayoutManager.jumps) == 2 and first <= card_row <= last,
f"jumps={hijacked.chatLayoutManager.jumps} visible={(first, last)} row={card_row}",
)
# Once the user takes hold of the list, the plugin stops steering it.
dragged = _FakeChatActivity([600, 555, 500])
dragged.chatLayoutManager.hijack = 1
dragged.chatListView.scroll_state = 1
injector.SummaryInjection(dragged, 0, -4242, 0, 555, injector.virtual_id_for(8, 555)).insert(
thinking="chunk", streaming=True
)
check(
"no re-jump while the user is dragging the list",
len(dragged.chatLayoutManager.jumps) == 1,
f"jumps={dragged.chatLayoutManager.jumps}",
)
# A cache-then-server reload can replace the list under a fresh card.
reloaded = _FakeChatActivity([600, 555, 500])
reloaded_card = injector.virtual_id_for(9, 555)
reloaded_inj = injector.SummaryInjection(reloaded, 0, -4242, 0, 555, reloaded_card)
real_on_ui_for_reload = injector._on_ui
dropped = []
def _reloading_on_ui(fn, delay_ms=0):
if delay_ms and not dropped:
# First settle check: the chat reloads its window first.
reloaded.messages[:] = [m for m in reloaded.messages if m.messageOwner.id > 0]
dropped.append(True)
fn()
injector._on_ui = _reloading_on_ui
try:
reloaded_inj.insert(thinking="chunk", streaming=True)
finally:
injector._on_ui = real_on_ui_for_reload
reloaded_ids = reloaded.ids()
check(
"a card dropped by a list reload right after placement is put back",
dropped and reloaded_ids.count(reloaded_card) == 1
and reloaded_ids.index(reloaded_card) == reloaded_ids.index(555) - 1,
f"ids={reloaded_ids}",
)
# An anchor that never loads: no card anywhere, rather than a card
# attached to messages it did not analyze.
never = _FakeChatActivity([900, 800])
@@ -2097,8 +2178,13 @@ def main(argv=None) -> int:
never_inj.update(answer_markdown="more", streaming=True)
check(
"an anchor that never loads places no card at a wrong position",
never_card not in never.ids() and not never_inj.placed and never.scrolled_to == [300],
f"ids={never.ids()} scrolled_to={never.scrolled_to}",
never_card not in never.ids() and not never_inj.placed,
f"ids={never.ids()}",
)
check(
"a far anchor that does not arrive is requested again, not just once",
len(never.scrolled_to) >= 2 and set(never.scrolled_to) == {300},
f"scrolled_to={never.scrolled_to}",
)
# ...and the card shows up once the user scrolls the anchor in.
for mid in (310, 300):