fix(rag): stop the per-run blocks from folding — the container is the one disclosure
Build and Push Containers / build-and-push-app (push) Successful in 1m43s
Build and Push Containers / build-and-push-db (push) Successful in 15s

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:
2026-10-06 14:44:41 -04:00
parent 051f62deb7
commit 690fd98036
15 changed files with 941 additions and 609 deletions
+67 -47
View File
@@ -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)