2026-04-18 17:09:59 +00:00
|
|
|
|
"""
|
|
|
|
|
|
Tests for fix #677: auto-scroll override during streaming.
|
|
|
|
|
|
|
|
|
|
|
|
The scroll system has a _scrollPinned flag and scrollIfPinned() to respect
|
|
|
|
|
|
user scroll position. The bug was that scrollToBottom() was called
|
|
|
|
|
|
unconditionally inside renderMessages() and appendThinking(), even during
|
|
|
|
|
|
an active stream — overriding any scroll position the user had set.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import pathlib
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
REPO = pathlib.Path(__file__).parent.parent
|
|
|
|
|
|
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
|
|
|
|
|
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
|
|
|
|
|
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestScrollPinningFix:
|
|
|
|
|
|
|
|
|
|
|
|
def test_render_messages_respects_active_stream(self):
|
|
|
|
|
|
"""renderMessages() must not call scrollToBottom() while streaming (#677).
|
|
|
|
|
|
|
|
|
|
|
|
During an active stream, scrollToBottom() unconditionally re-pins scroll
|
|
|
|
|
|
and overrides the user's position. renderMessages() must use scrollIfPinned()
|
|
|
|
|
|
instead when S.activeStreamId is set.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Find renderMessages function
|
2026-05-05 16:21:25 +00:00
|
|
|
|
rm_start = UI_JS.find("function renderMessages(")
|
2026-04-18 17:09:59 +00:00
|
|
|
|
assert rm_start != -1, "renderMessages() not found in ui.js"
|
|
|
|
|
|
rm_end = UI_JS.find("\nfunction ", rm_start + 1)
|
|
|
|
|
|
rm_body = UI_JS[rm_start:rm_end]
|
2026-05-05 16:21:25 +00:00
|
|
|
|
helper_start = UI_JS.find("function _scrollAfterMessageRender")
|
|
|
|
|
|
assert helper_start != -1, "renderMessages scroll helper not found in ui.js"
|
|
|
|
|
|
helper_end = UI_JS.find("\nfunction ", helper_start + 1)
|
|
|
|
|
|
helper_body = UI_JS[helper_start:helper_end]
|
2026-04-18 17:09:59 +00:00
|
|
|
|
|
|
|
|
|
|
# Must check activeStreamId before deciding which scroll fn to call
|
2026-05-05 16:21:25 +00:00
|
|
|
|
assert "activeStreamId" in helper_body, (
|
2026-04-18 17:09:59 +00:00
|
|
|
|
"renderMessages() must check S.activeStreamId before scrolling — "
|
|
|
|
|
|
"unconditional scrollToBottom() overrides user scroll position (#677)"
|
|
|
|
|
|
)
|
2026-05-05 16:21:25 +00:00
|
|
|
|
# scrollIfPinned must be called through the renderMessages scroll policy (stream path)
|
2026-05-09 00:02:03 +00:00
|
|
|
|
assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in rm_body
|
2026-05-05 16:21:25 +00:00
|
|
|
|
assert "scrollIfPinned()" in helper_body, (
|
2026-04-18 17:09:59 +00:00
|
|
|
|
"renderMessages() must call scrollIfPinned() during streaming (#677)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_append_thinking_uses_scroll_if_pinned(self):
|
|
|
|
|
|
"""appendThinking() must use scrollIfPinned() not scrollToBottom() (#677).
|
|
|
|
|
|
|
|
|
|
|
|
appendThinking() fires continuously during streaming — calling scrollToBottom()
|
|
|
|
|
|
inside it re-pins on every token, preventing the user from scrolling up.
|
|
|
|
|
|
"""
|
|
|
|
|
|
at_start = UI_JS.find("function appendThinking(")
|
|
|
|
|
|
assert at_start != -1, "appendThinking() not found in ui.js"
|
|
|
|
|
|
at_end = UI_JS.find("\nfunction ", at_start + 1)
|
|
|
|
|
|
at_body = UI_JS[at_start:at_end]
|
|
|
|
|
|
|
|
|
|
|
|
assert "scrollIfPinned()" in at_body, (
|
|
|
|
|
|
"appendThinking() must call scrollIfPinned() not scrollToBottom() (#677)"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert "scrollToBottom()" not in at_body, (
|
|
|
|
|
|
"appendThinking() must not call scrollToBottom() — it fires mid-stream (#677)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_scroll_threshold_increased(self):
|
|
|
|
|
|
"""Scroll re-pin threshold must be at least 150px (#677).
|
|
|
|
|
|
|
|
|
|
|
|
80px was too small — a fast mouse scroll wheel can jump 100–120px in one
|
|
|
|
|
|
tick, causing unintended re-pin. 150px gives a proper dead zone.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Find the nearBottom assignment in the scroll listener
|
|
|
|
|
|
near_bottom_pos = UI_JS.find("nearBottom=")
|
|
|
|
|
|
if near_bottom_pos == -1:
|
|
|
|
|
|
near_bottom_pos = UI_JS.find("nearBottom =")
|
|
|
|
|
|
assert near_bottom_pos != -1, "nearBottom scroll threshold assignment not found"
|
|
|
|
|
|
threshold_line = UI_JS[near_bottom_pos:near_bottom_pos + 120]
|
|
|
|
|
|
# Extract the numeric threshold
|
|
|
|
|
|
match = re.search(r"<\s*(\d+)", threshold_line)
|
|
|
|
|
|
assert match, f"Numeric threshold not found near nearBottom assignment: {threshold_line!r}"
|
|
|
|
|
|
threshold = int(match.group(1))
|
|
|
|
|
|
assert threshold >= 150, (
|
|
|
|
|
|
f"Scroll re-pin threshold is {threshold}px — must be >= 150px to avoid "
|
|
|
|
|
|
f"hair-trigger re-pinning on fast scroll wheels (#677)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_scroll_to_bottom_button_exists_in_html(self):
|
|
|
|
|
|
"""index.html must contain a scroll-to-bottom button (#677).
|
|
|
|
|
|
|
|
|
|
|
|
All major streaming chat UIs (Claude, ChatGPT) show a floating ↓ button
|
|
|
|
|
|
when the user has scrolled up, giving a clear escape hatch to return to live output.
|
|
|
|
|
|
"""
|
|
|
|
|
|
assert "scrollToBottomBtn" in INDEX_HTML, (
|
|
|
|
|
|
"index.html must contain a #scrollToBottomBtn element (#677)"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert "scroll-to-bottom-btn" in INDEX_HTML, (
|
|
|
|
|
|
"index.html must use class scroll-to-bottom-btn for the scroll button (#677)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_scroll_to_bottom_button_hidden_by_default(self):
|
|
|
|
|
|
"""Scroll-to-bottom button must be hidden by default (display:none) (#677)."""
|
|
|
|
|
|
btn_pos = INDEX_HTML.find("scrollToBottomBtn")
|
|
|
|
|
|
assert btn_pos != -1
|
|
|
|
|
|
btn_context = INDEX_HTML[btn_pos:btn_pos + 200]
|
|
|
|
|
|
assert "display:none" in btn_context or 'display="none"' in btn_context, (
|
|
|
|
|
|
"scrollToBottomBtn must be hidden by default — only shown when user scrolls up (#677)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_scroll_to_bottom_button_css_exists(self):
|
|
|
|
|
|
"""style.css must have styling for .scroll-to-bottom-btn (#677)."""
|
|
|
|
|
|
assert ".scroll-to-bottom-btn" in STYLE_CSS, (
|
|
|
|
|
|
"style.css must define .scroll-to-bottom-btn styles (#677)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-12 13:47:21 +00:00
|
|
|
|
def test_scroll_to_bottom_button_is_overlayed(self):
|
|
|
|
|
|
"""Scroll-to-bottom button stays visible as an overlay outside transcript layout (#677)."""
|
Release v0.51.242 — Release HJ (stage-q14) (#3500)
## Release v0.51.242 — Release HJ (stage-q14)
UX-approved via Telegram (Nathan — dark/light/mobile screenshots).
### Added
| PR | Author | Feature |
|----|--------|---------|
| #3440 | @t3chn0pr13st | **Graphite appearance skin** — a quiet, neutral-gray "workbench" alternative to the default gold/cream. Selectable from Settings → Appearance and `/theme skin graphite`. Both light + dark palettes on the existing CSS-variable token system; tightened typography, shadows, active-sidebar spacing, code-block framing. |
### Why it's safe
- **Fully scoped + additive**: every new CSS rule (and every `!important`) is under `[data-skin="graphite"]` — Codex verified zero bleed into the default appearance or other skins. `api/config.py` keeps the default skin as `default` and only *adds* `graphite` to the allowed set. No i18n keys dropped (only the `/theme` help string gains `graphite`).
- Opt-in; a user has to select it. Default experience unchanged.
### Test-robustness fix (absorbed)
The new graphite scoped selectors (e.g. `:root[data-skin="graphite"] .session-item.active .session-time{…}`) appear in `style.css` *before* the canonical unscoped rules, which broke 3 naive first-occurrence CSS-contract tests (`test_issue677` scroll-btn-overlay, `test_issue856_pinned_indicator_layout`, `test_workspace_panel_session_list`). Fixed those 3 to anchor on the canonical **unscoped** rule (start-of-line regex) instead of the first `.selector` match — robust against this and future skins. Verified they still pass on clean master CSS (invariant preserved, not weakened).
### Gate
- Full pytest suite: **7517 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint runtime gate: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** (verified all CSS scoped, default skin unchanged, no i18n key loss)
- Vision-verified dark + light + mobile; UX-approved by Nathan via Telegram
Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
2026-06-03 23:46:53 +00:00
|
|
|
|
# Anchor on the canonical unscoped `.scroll-to-bottom-btn{` declaration,
|
|
|
|
|
|
# not a skin-scoped variant (e.g. a `:root[data-skin="graphite"] ...
|
|
|
|
|
|
# .scroll-to-bottom-btn,` grouped selector that may precede it).
|
|
|
|
|
|
import re as _re
|
|
|
|
|
|
m = _re.search(r"(?m)^\s*\.scroll-to-bottom-btn\{", STYLE_CSS)
|
|
|
|
|
|
assert m, "canonical unscoped .scroll-to-bottom-btn{ rule not found"
|
|
|
|
|
|
btn_css = STYLE_CSS[m.start():m.start() + 300]
|
2026-05-12 13:47:21 +00:00
|
|
|
|
assert "position:absolute" in btn_css, (
|
|
|
|
|
|
".scroll-to-bottom-btn must be an overlay so it stays visible without "
|
|
|
|
|
|
"participating in transcript scroll layout (#677)"
|
2026-04-18 17:09:59 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_scroll_listener_hides_button_when_pinned(self):
|
|
|
|
|
|
"""Scroll listener must hide the button when user is near the bottom (#677)."""
|
|
|
|
|
|
scroll_listener_start = UI_JS.find("el.addEventListener('scroll'")
|
|
|
|
|
|
assert scroll_listener_start != -1, "scroll event listener not found"
|
2026-06-23 06:57:00 +00:00
|
|
|
|
listener_open = UI_JS.find("{", scroll_listener_start)
|
|
|
|
|
|
assert listener_open != -1, "scroll listener body not found"
|
|
|
|
|
|
depth = 0
|
|
|
|
|
|
listener_end = -1
|
|
|
|
|
|
for idx in range(listener_open, len(UI_JS)):
|
|
|
|
|
|
ch = UI_JS[idx]
|
|
|
|
|
|
if ch == "{":
|
|
|
|
|
|
depth += 1
|
|
|
|
|
|
elif ch == "}":
|
|
|
|
|
|
depth -= 1
|
|
|
|
|
|
if depth == 0:
|
|
|
|
|
|
listener_end = idx + 1
|
|
|
|
|
|
break
|
|
|
|
|
|
assert listener_end != -1, "scroll listener body did not terminate cleanly"
|
|
|
|
|
|
listener_block = UI_JS[scroll_listener_start:listener_end]
|
2026-06-08 22:58:13 +00:00
|
|
|
|
assert "_syncScrollToBottomCue(showBottomButton" in listener_block, (
|
2026-04-18 17:09:59 +00:00
|
|
|
|
"Scroll listener must show/hide scrollToBottomBtn based on _scrollPinned (#677)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_scroll_to_bottom_button_calls_scroll_to_bottom(self):
|
|
|
|
|
|
"""scrollToBottomBtn onclick must call scrollToBottom() (#677)."""
|
|
|
|
|
|
btn_pos = INDEX_HTML.find("scrollToBottomBtn")
|
|
|
|
|
|
assert btn_pos != -1
|
|
|
|
|
|
btn_context = INDEX_HTML[btn_pos:btn_pos + 200]
|
|
|
|
|
|
assert "scrollToBottom()" in btn_context, (
|
|
|
|
|
|
"scrollToBottomBtn onclick must call scrollToBottom() (#677)"
|
|
|
|
|
|
)
|