fix(rag): stop the per-run blocks from folding — the container is the one disclosure
The owner refinement (2026-10-06): the per-run thinking and tool-call blocks inside the Reasoning bubble were individually collapsible details elements — a fold inside the fold. A tool frame closed the previous thinking block, so the visible thinking vanished mid-turn; and the pinned-tail follow died on the append of every run after the first (the pin was captured AFTER the new block's height growth contaminated the 32px reading). - app.js: the in-container runs are now plain divs — createThinkingRunEl (div.thinking + static .thinking-summary label + .thinking-text) and createToolRunEl (div.tool-calls-disclosure + static .tool-calls-summary label + the .tool-calls list); they never open or fold (the container is the ONE open/fold control, open through the turn, folded at settle — unchanged); the pin capture moves BEFORE ensureThinkingRun/ensureToolRun append (plus the first-frame edge: no container yet -> always pinned); closeCurrentRun just ends the run; closeAllRuns stays the defensive legacy guard; restoreTimeline/addSegments (shared.js) use the div builders; the legacy no-segments restore path keeps the old details-based blocks byte-identical - styles.css: the in-container runs are distinct by label + rail (brand-ink Thinking label on a brand-soft rail; accent-ink Tool call(s) (N) label on a neutral rail); the interactive treatment (cursor + chevron) is scoped to the legacy <summary>; the inner windows stay disabled (the container's .reasoning-body is the ONE live scroll surface) - unit + E2E: the D5-refinement contract is pinned — one details.reasoning disclosure, per-run blocks as plain divs, zero nested <details> inside the container, the container as the only open/fold control, the .reasoning-body window auto-follows the tail while streaming unit+integration 3738 passed (coverage 99%), ruff + pyright clean, all five affected E2E suites pass in isolation (reasoning_bubble 6, thinking_timeline 6, thinking_display 5, thinking_scroll 8, tool_call_compact 4), verified live: the follow holds through a long thinking stream, the tool-frame transition, and the second thinking run; restore renders the div structure without client-side errors LLM-Generated: true
This commit is contained in:
@@ -14,11 +14,16 @@ scratchpad (phase-21 length: ``compose_thinking`` ≈ 2 700 chars ≈ 4.5s of
|
||||
|
||||
This suite REPLACES the deleted phase-21 ``test_thinking_no_scroll.py``
|
||||
(owner direction 2026-08-27, ``TODO.md`` L7, roadmap A2 — the 2026-08-24
|
||||
no-scroll choice is reversed): ``details.thinking .thinking-text`` is
|
||||
user-scrollable again (``overflow-y: auto``, 320px clip kept), and the
|
||||
phase-17 bottom-pin is GATED in app.js (``THINKING_NEAR_BOTTOM_PX = 32``)
|
||||
— the window follows the live tail only while the user is pinned near
|
||||
its bottom; scrolling up pauses the follow, returning resumes it.
|
||||
no-scroll choice is reversed): the ONE live window is the reasoning
|
||||
container's ``.reasoning-body`` (phase 161 — the per-run blocks nest
|
||||
inside it; the D5 refinement, owner 2026-10-06 — the per-run blocks
|
||||
are plain divs, always visible, and the container is the ONE
|
||||
disclosure) — user-scrollable (``overflow-y: auto``, 320px clip
|
||||
kept), and the bottom-pin is GATED in app.js (the phase-17
|
||||
``THINKING_NEAR_BOTTOM_PX = 32`` band, reused on the container
|
||||
window as ``REASONING_NEAR_BOTTOM_PX``) — the window follows the live
|
||||
tail only while the user is pinned near its bottom; scrolling up
|
||||
pauses the follow, returning resumes it.
|
||||
|
||||
Determinism notes:
|
||||
* Tests 1–4 key off the mock's ``think out loud then hesitate`` trigger
|
||||
@@ -119,7 +124,11 @@ THINKING_FRAGMENT = "Step 2: Check my notes"
|
||||
PARAS_QUESTION = "think in paragraphs — how is my kubernetes cluster set up?"
|
||||
STORAGE_KEY = "bor.chat.v1"
|
||||
|
||||
SELECTOR = ".msg.brain details.thinking .thinking-text"
|
||||
#: The ONE live window: the reasoning container's bounded 320px
|
||||
#: ``.reasoning-body`` (phase 161 — the per-run blocks nest inside
|
||||
#: it; the D5 refinement — the per-run blocks are plain divs, always
|
||||
#: visible; the container is the ONE disclosure).
|
||||
SELECTOR = ".msg.brain details.reasoning .reasoning-body"
|
||||
|
||||
#: The story's "within 1px" tolerance for tail-pin claims.
|
||||
TAIL_TOL = 1
|
||||
@@ -264,11 +273,14 @@ def _rendered_len(page: Page) -> int:
|
||||
def _first_text_node(page: Page) -> dict[str, Any]:
|
||||
"""Rect of the scratchpad's FIRST text node vs the window's box — the
|
||||
"earlier content is visible" probe (the mock's text starts with
|
||||
``Step 1: Read the question carefully``)."""
|
||||
``Step 1: Read the question carefully``; the probe walks the
|
||||
scratchpad's own first ``.thinking-text`` — not the window's raw
|
||||
first node, which is the "Thinking" label)."""
|
||||
return page.evaluate(
|
||||
f"""() => {{ const el = document.querySelector('{SELECTOR}');
|
||||
const box = el.getBoundingClientRect();
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
const t = el.querySelector('.thinking-text');
|
||||
const walker = document.createTreeWalker(t, NodeFilter.SHOW_TEXT);
|
||||
const first = walker.nextNode();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(first);
|
||||
@@ -293,11 +305,11 @@ def test_thinking_window_user_scrollable(
|
||||
submit(page, HESITATE_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
container.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# Premise: the long scratchpad overflows the 320px clip (the story's
|
||||
# "window" is only real once it clips).
|
||||
# Premise: the long scratchpad overflows the 320px clip (the
|
||||
# story's "window" is only real once it clips).
|
||||
page.wait_for_function(
|
||||
f"() => {{ const el = document.querySelector('{SELECTOR}');"
|
||||
" return !!el && el.scrollHeight > el.clientHeight; }",
|
||||
@@ -308,7 +320,7 @@ def test_thinking_window_user_scrollable(
|
||||
# pin frames, the block is still open, and the tail is frozen — any
|
||||
# user scroll persists and is observable here.
|
||||
_wait_text_stable(page)
|
||||
expect(details).to_have_attribute("open", "")
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_have_text("")
|
||||
|
||||
# Precondition: the gated pin left the window at the live tail.
|
||||
@@ -316,7 +328,7 @@ def test_thinking_window_user_scrollable(
|
||||
assert before["height"] > before["client"], "the window must overflow"
|
||||
assert _at_tail(before), "the pin must have left the window at the tail"
|
||||
|
||||
box = details.locator(".thinking-text").bounding_box()
|
||||
box = container.locator(".reasoning-body").bounding_box()
|
||||
assert box
|
||||
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
|
||||
page.mouse.move(cx, cy)
|
||||
@@ -337,7 +349,7 @@ def test_thinking_window_user_scrollable(
|
||||
# it so (test scaffolding), give it REAL focus with a click, and press
|
||||
# Home: the window must jump to its top (proven 1:1 on headless
|
||||
# Chromium 151: click + Home -> 0, End -> maxScroll).
|
||||
details.locator(".thinking-text").evaluate(
|
||||
container.locator(".reasoning-body").evaluate(
|
||||
"el => { el.setAttribute('tabindex', '0'); }"
|
||||
)
|
||||
page.mouse.click(cx, cy)
|
||||
@@ -376,8 +388,9 @@ def test_thinking_window_user_scrollable(
|
||||
)
|
||||
assert not _at_tail(after), "the window must stay scrolled up (no re-pin)"
|
||||
|
||||
# Still inside the pure-thinking window.
|
||||
expect(details).to_have_attribute("open", "")
|
||||
# Still inside the live-thinking window (the container is the ONE
|
||||
# open disclosure — the D5 refinement).
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_have_text("")
|
||||
|
||||
|
||||
@@ -396,8 +409,8 @@ def test_thinking_window_follows_while_pinned(
|
||||
submit(page, HESITATE_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
container.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# The exact deterministic scratchpad the mock will stream, sliced the
|
||||
# same way the mock's _sse_stream does (12-char chunks). The user
|
||||
@@ -424,7 +437,7 @@ def test_thinking_window_follows_while_pinned(
|
||||
# the block still open — sample the tail pin, then the geometry.
|
||||
_wait_text_contains(page, marker_last)
|
||||
_wait_text_stable(page)
|
||||
expect(details).to_have_attribute("open", "")
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_have_text("")
|
||||
sample = _scroll_sample(page)
|
||||
assert _at_tail(sample), f"not at the tail after the last chunk: {sample}"
|
||||
@@ -467,8 +480,8 @@ def test_thinking_window_stops_on_scroll_up(
|
||||
submit(page, HESITATE_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
container.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# Mid-stream: the window has REAL headroom (≥100px of hidden content
|
||||
# — the first overflowing frame, a few px, is not the story's
|
||||
@@ -508,9 +521,10 @@ def test_thinking_window_stops_on_scroll_up(
|
||||
f"{top0} -> {after['top']}"
|
||||
)
|
||||
|
||||
# Still inside the pure-thinking window (the stability claim is only
|
||||
# meaningful while the block is open and no content frame has landed).
|
||||
expect(details).to_have_attribute("open", "")
|
||||
# Still inside the live-thinking window (the stability claim is only
|
||||
# meaningful while the container is open and no content frame has
|
||||
# landed).
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_have_text("")
|
||||
|
||||
|
||||
@@ -528,8 +542,8 @@ def test_thinking_window_resumes_on_return(
|
||||
submit(page, HESITATE_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
container.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# Mid-stream: real window headroom (≥100px) + pinned tail (test-3
|
||||
# premises).
|
||||
@@ -581,8 +595,8 @@ def test_thinking_window_resumes_on_return(
|
||||
assert _at_tail(after), f"the window must be re-pinned on the next chunk: {after}"
|
||||
assert after["top"] > top0, "the window must have returned toward the tail"
|
||||
|
||||
# Still inside the pure-thinking window.
|
||||
expect(details).to_have_attribute("open", "")
|
||||
# Still inside the live-thinking window.
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_have_text("")
|
||||
|
||||
|
||||
@@ -599,10 +613,10 @@ def test_thinking_window_css_contract(
|
||||
login(page, app_url, next="/")
|
||||
send_and_wait(page, THINK_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
expect(details).not_to_have_attribute("open") # auto-collapsed
|
||||
details.locator("summary").click() # open for measurement
|
||||
expect(details).to_have_attribute("open", "")
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
expect(container).not_to_have_attribute("open") # folded at done
|
||||
container.locator("summary.reasoning-summary").click() # open for measurement
|
||||
expect(container).to_have_attribute("open", "")
|
||||
|
||||
style = page.evaluate(
|
||||
f"""() => {{ const el = document.querySelector('{SELECTOR}');
|
||||
@@ -694,23 +708,29 @@ def test_restored_collapsed_thinking_unaffected(
|
||||
login(page, app_url, next="/")
|
||||
send_and_wait(page, THINK_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
expect(details).not_to_have_attribute("open") # auto-collapsed
|
||||
captured = details.locator(".thinking-text").text_content()
|
||||
block = page.locator(".msg.brain").last.locator("div.thinking")
|
||||
expect(block).to_have_count(1)
|
||||
captured = block.locator(".thinking-text").text_content()
|
||||
assert captured
|
||||
|
||||
page.reload()
|
||||
expect(page.locator("#empty-state")).to_be_hidden()
|
||||
|
||||
restored = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
restored = page.locator(".msg.brain").last.locator("div.thinking")
|
||||
expect(restored).to_have_count(1)
|
||||
expect(restored).not_to_have_attribute("open") # restored COLLAPSED
|
||||
# Restored COLLAPSED — the at-rest fold is the container's (the
|
||||
# D5 refinement — the inner block is a plain div, not a <details>).
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
expect(container).not_to_have_attribute("open")
|
||||
expect(
|
||||
page.locator(".msg.brain").last.locator("details.reasoning details")
|
||||
).to_have_count(0)
|
||||
expect(restored.locator(".thinking-text")).to_have_text(captured)
|
||||
|
||||
# Opening the restored block still shows the full scratchpad, and the
|
||||
# answer + persistence are intact.
|
||||
restored.locator("summary").click()
|
||||
expect(restored).to_have_attribute("open", "")
|
||||
# Opening the restored container still shows the full scratchpad,
|
||||
# and the answer + persistence are intact.
|
||||
container.locator("summary.reasoning-summary").click()
|
||||
expect(container).to_have_attribute("open", "")
|
||||
expect(restored.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
raw = json.loads(page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')"))[
|
||||
@@ -739,8 +759,8 @@ def test_thinking_window_follows_across_paragraph_breaks(
|
||||
submit(page, PARAS_QUESTION)
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(PARAS_QUESTION)
|
||||
|
||||
details = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
details.wait_for(state="attached", timeout=10_000)
|
||||
container = page.locator(".msg.brain").last.locator("details.reasoning")
|
||||
container.wait_for(state="attached", timeout=10_000)
|
||||
|
||||
# Two preconditions, in one poll: the window is REAL (content past
|
||||
# the 320px clip — a "window" only exists once it clips) AND the
|
||||
@@ -773,13 +793,13 @@ def test_thinking_window_follows_across_paragraph_breaks(
|
||||
# paragraph break is present, and the pin survived it.
|
||||
sample = page.evaluate(
|
||||
f"""() => {{ const el = document.querySelector('{SELECTOR}');
|
||||
const block = document.querySelector('details.thinking');
|
||||
const block = document.querySelector('details.reasoning');
|
||||
const wrap = block ? block.closest('.msg.brain') : null;
|
||||
const bubble = wrap ? wrap.querySelector('.bubble') : null;
|
||||
return {{ top: el.scrollTop, height: el.scrollHeight,
|
||||
client: el.clientHeight,
|
||||
p: el.querySelectorAll('p').length,
|
||||
open: !!(block && block.open),
|
||||
open: !!(block && block.hasAttribute('open')),
|
||||
bubble: bubble ? bubble.innerText.length : 0 }}; }}"""
|
||||
)
|
||||
assert sample["p"] >= 2, "a real paragraph break must be in the scratchpad"
|
||||
@@ -796,5 +816,5 @@ def test_thinking_window_follows_across_paragraph_breaks(
|
||||
# disabled, so to_be_enabled no longer blocks until the turn ends).
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
expect(details.locator(".thinking-text")).to_contain_text("Step 3")
|
||||
expect(container.locator(".thinking-text").first).to_contain_text("Step 3")
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
|
||||
Reference in New Issue
Block a user