From af23d2e682feb40066cb87ca8b348f27bbb47a97 Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Fri, 9 Oct 2026 12:52:24 +0200 Subject: [PATCH] Keep the chat on a far-back summary card after its anchor loads 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 --- plugin.json | 7 ++- src/config.py | 14 ++++- src/main.py | 10 ++- src/services/rich_injector.py | 111 ++++++++++++++++++++++++++++++++-- tools/verify_bundle.py | 90 ++++++++++++++++++++++++++- 5 files changed, 219 insertions(+), 13 deletions(-) diff --git a/plugin.json b/plugin.json index 8df7d54..4dc543d 100644 --- a/plugin.json +++ b/plugin.json @@ -3,9 +3,12 @@ "name": "Chat Summaries", "description": "Summarize the last N messages (with offset) in any chat, group, channel, or topic using AI.", "author": "@lyuksovannyy", - "version": "1.1.0", + "version": "1.1.1", "icon": "exteraPlugins/1", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", - "requirements": ["requests", "debugpy"] + "requirements": [ + "requests", + "debugpy" + ] } diff --git a/src/config.py b/src/config.py index c411e81..f82731d 100644 --- a/src/config.py +++ b/src/config.py @@ -57,6 +57,14 @@ SUMMARY_HASHTAG = "#aisummaries" # A summary card can only be placed directly after its newest analyzed # message, which with an offset may not be loaded yet. Placement asks the chat -# to load it, then retries this many times, this many ms apart. -PLACEMENT_RETRIES = 25 -PLACEMENT_RETRY_DELAY_MS = 120 +# to load it, then retries this many times, this many ms apart (~10 s), asking +# again every PLACEMENT_RELOAD_EVERY retries in case a request was dropped. +PLACEMENT_RETRIES = 80 +PLACEMENT_RETRY_DELAY_MS = 125 +PLACEMENT_RELOAD_EVERY = 16 + +# After jumping to a fresh card, check at these delays (ms) that it is still on +# screen and jump again if not. Loading a far anchor makes ChatActivity run its +# own postponed scroll, and a cache-then-server reload can re-scroll it, either +# of which can land after the jump and carry the view away from the card. +JUMP_SETTLE_DELAYS_MS = (250, 700, 1500, 3000) diff --git a/src/main.py b/src/main.py index 1195861..bf8d723 100644 --- a/src/main.py +++ b/src/main.py @@ -274,6 +274,9 @@ class ChatSummariesPlugin(BasePlugin): bulletin and leaves the chat exactly as the user left it: no error bubble to dismiss, no jump away from where they were reading. """ + diagnostics.note( + f"summary: dialog={dialog_id} topic={topic_id} count={count} offset={offset}" + ) BulletinHelper.show_info(f"Chat Summaries: reading {count} messages…", fragment) def on_fetched(messages, error): @@ -292,6 +295,7 @@ class ChatSummariesPlugin(BasePlugin): self.fetcher.fetch_history(dialog_id, count, offset, topic_id, on_fetched) def _report_error(self, message: str, fragment: Any) -> None: + diagnostics.note(f"summary failed: {message}") BulletinHelper.show_error(f"Chat Summaries: {message}", fragment) def _stream_into( @@ -310,7 +314,11 @@ class ChatSummariesPlugin(BasePlugin): """ from services.rich_injector import SummaryInjection, virtual_id_for - anchor_msg_id = max(int(getattr(m, "id", 0) or 0) for m in messages) + ids = [int(getattr(m, "id", 0) or 0) for m in messages] + anchor_msg_id = max(ids) + diagnostics.note( + f"summary: fetched {len(messages)} messages, ids {min(ids)}..{anchor_msg_id}" + ) db = self._get_summary_db() state = { "answer": "", diff --git a/src/services/rich_injector.py b/src/services/rich_injector.py index 3f1065f..a64fe48 100644 --- a/src/services/rich_injector.py +++ b/src/services/rich_injector.py @@ -16,7 +16,13 @@ Verified against exteraGram 12.5.x (dex inspection): from typing import Any, Optional import diagnostics -from config import PLACEMENT_RETRIES, PLACEMENT_RETRY_DELAY_MS, SUMMARY_HASHTAG +from config import ( + JUMP_SETTLE_DELAYS_MS, + PLACEMENT_RELOAD_EVERY, + PLACEMENT_RETRIES, + PLACEMENT_RETRY_DELAY_MS, + SUMMARY_HASHTAG, +) from services.rich_markdown import build_rich_message, hashtag_block, markdown_to_plain # Above Telegram's issued id range, so this user exists only locally. @@ -379,10 +385,18 @@ class SummaryInjection: else: index = find_index_for_anchor(messages_list, self.anchor_msg_id) if index < 0: - if retries_left == PLACEMENT_RETRIES: + attempt = PLACEMENT_RETRIES - retries_left + if retries_left > 0 and attempt % PLACEMENT_RELOAD_EVERY == 0: # The analyzed range ends outside the loaded window # (an offset, or history scrolled away). Have the chat # load and show it, then place once it is in the list. + # Asked again periodically: scrollToMessageId drops the + # request outright while waitingForGetDifference, and + # a load can be superseded by another one. + diagnostics.note( + f"injector: anchor {self.anchor_msg_id} not loaded " + f"({_id_span(messages_list)}), requesting it (attempt {attempt})" + ) self._load_anchor() if retries_left > 0: _on_ui(lambda: self._place(retries_left - 1), PLACEMENT_RETRY_DELAY_MS) @@ -398,16 +412,87 @@ class SummaryInjection: messages_list.set(index, self.message_object) self.placed = True self._placing = False + diagnostics.note( + f"injector: card placed after anchor {self.anchor_msg_id} at index {index} " + f"(retries left {retries_left})" + ) adapter = get_chat_adapter(self.chat_activity) if adapter is not None: adapter.notifyDataSetChanged() - # Once, on placement only -- scrolling on every streaming update - # would fight the user for control of the list. - self.scroll_into_view() + # On placement only -- scrolling on every streaming update would + # fight the user for control of the list. The settle checks right + # after only undo scrolls the app itself makes in that window. + if self.scroll_into_view(): + self._schedule_settle(0) except Exception as exc: self._placing = False diagnostics.record_error("injector.place", exc) + def _schedule_settle(self, step: int) -> None: + if step >= len(JUMP_SETTLE_DELAYS_MS): + return + previous = JUMP_SETTLE_DELAYS_MS[step - 1] if step else 0 + _on_ui(lambda: self._settle(step), JUMP_SETTLE_DELAYS_MS[step] - previous) + + def _settle(self, step: int) -> None: + """Re-jumps if something scrolled the fresh card off screen. + + Stops for good as soon as the user touches the list: from then on + where the chat is scrolled is theirs to decide. + """ + try: + list_view = _chat_list_view(self.chat_activity) + if list_view is not None and int(list_view.getScrollState()) == 1: # SCROLL_STATE_DRAGGING + diagnostics.note("injector: user is scrolling, jump settled") + return + span = self._visible_span() + if span is not None: + position, first, last = span + if not first <= position <= last: + diagnostics.note( + f"injector: card row {position} scrolled away (visible {first}..{last}), " + f"jumping back (check {step})" + ) + self._jump_to_card() + elif self.placed and find_existing(get_messages_list(self.chat_activity), self.virtual_id) < 0: + # The list was reloaded under the card. Put it back. + messages_list = get_messages_list(self.chat_activity) + index = find_index_for_anchor(messages_list, self.anchor_msg_id) + if index >= 0: + diagnostics.note(f"injector: list reloaded, re-placing card (check {step})") + messages_list.add(index, self.message_object) + adapter = get_chat_adapter(self.chat_activity) + if adapter is not None: + adapter.notifyDataSetChanged() + self._jump_to_card() + except Exception as exc: + diagnostics.record_error("injector.settle", exc) + self._schedule_settle(step + 1) + + def _visible_span(self) -> Optional[tuple]: + """(card adapter position, first visible, last visible), or None.""" + try: + from hook_utils import get_private_field + + messages_list = get_messages_list(self.chat_activity) + adapter = get_chat_adapter(self.chat_activity) + layout_manager = getattr(self.chat_activity, "chatLayoutManager", None) or get_private_field( + self.chat_activity, "chatLayoutManager" + ) + if messages_list is None or adapter is None or layout_manager is None: + return None + index = find_existing(messages_list, self.virtual_id) + if index < 0: + return None + first = int(layout_manager.findFirstVisibleItemPosition()) + last = int(layout_manager.findLastVisibleItemPosition()) + if first < 0 or last < 0: + return None + return int(adapter.messagesStartRow) + index, min(first, last), max(first, last) + except Exception as exc: + diagnostics.record_error("injector.visible_span", exc) + return None + def _load_anchor(self) -> None: """Asks the chat to load the anchor and scroll to it. @@ -464,6 +549,7 @@ class SummaryInjection: # bottom=False: offset counts from the top edge, as in # ChatActivity.scrollToMessageId's own scrollToPosition call. layout_manager.scrollToPositionWithOffset(position, offset, False) + diagnostics.note(f"injector: jump to row {position} offset {offset}") return True except Exception as exc: diagnostics.record_error("injector.jump_to_card", exc) @@ -583,6 +669,21 @@ def _chat_list_view(chat_activity: Any) -> Optional[Any]: return None +def _id_span(messages_list: Any) -> str: + """'loaded ids A..B, N rows' for diagnostics.""" + try: + ids = [ + int(getattr(getattr(messages_list.get(i), "messageOwner", None), "id", 0) or 0) + for i in range(messages_list.size()) + ] + real = [i for i in ids if i > 0] + if not real: + return f"no messages, {len(ids)} rows" + return f"loaded ids {min(real)}..{max(real)}, {len(ids)} rows" + except Exception: + return "loaded ids unknown" + + def remove_cards(chat_activity: Any, virtual_ids) -> None: """Drops injected cards from an open chat's list. diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py index bdea76f..bc04276 100755 --- a/tools/verify_bundle.py +++ b/tools/verify_bundle.py @@ -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):