1 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 af23d2e682 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>
2026-10-09 12:52:24 +02:00
5 changed files with 219 additions and 13 deletions

No files matched your search

+5 -2
View File
@@ -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"
]
}
+11 -3
View File
@@ -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)
+9 -1
View File
@@ -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": "",
+106 -5
View File
@@ -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.
+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):