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
+44 -54
View File
@@ -23,12 +23,15 @@ further tool call). The client renders the ENTIRE per-run timeline
inside ONE ``details.reasoning`` container (D1): open from creation
until the turn settles (D2 — it STAYS OPEN through a mid-round
delta; only ``done`` / stop / the failed-turn funnel fold it),
condensed in its bounded 320px ``.reasoning-body`` live window (D3),
the summary carrying the D6 status literals (``Thinking…`` /
``Calling <name>…`` live, ``N steps`` at rest). The restore path and
the shared page render the SAME container COLLAPSED (D5) with the
at-rest step count (D6). A turn with no thinking and no tools creates
NO container (D1/D4 — the byte-identical DOM).
condensed in its bounded 320px ``.reasoning-body`` live window (D3,
the pinned-tail follow — the window follows the live tail while the
user stays near the bottom). The per-run blocks inside the container
are plain divs — NOT collapsible (the D5 refinement, owner
2026-10-06: the container is the ONE disclosure; a nested fold
inside the fold used to hide the live reasoning mid-turn). The
restore path and the shared page render the SAME container COLLAPSED
(D5) with the at-rest step count (D6). A turn with no thinking and
no tools creates NO container (D1/D4 — the byte-identical DOM).
KB fixture — mirrored from ``test_thinking_timeline.py`` (the
deterministic two-document pair): the marker question cosines
@@ -53,9 +56,9 @@ Test → phase mapping (Playwright Mapping Rule):
``Stop`` control), the container is STILL ``open`` with a run
visible.
3. ``test_container_folds_at_done`` — D2/D5/D6: after ``done`` the
container has NO ``open`` attribute, its summary is visible, EVERY
inner run has no ``open`` attribute (the phase-158 fold-at-rest
contract preserved inside the fold), and the status is ``3 steps``
container has NO ``open`` attribute, its summary is visible, the
inner runs are NOT collapsible (zero ``<details>`` inside the
container — the D5 refinement), and the status is ``3 steps``
(the flow's contiguous-run count — ``thinking (Step 1)``,
``tool (ls)``, ``thinking (Step 2)`` — the D6 live-settle
literal); the bubble carries the mid-round + closing answer.
@@ -281,8 +284,8 @@ CONTAINER_LIVE_JS = """
const live = st === 'Thinking…' || st.indexOf('Calling ') === 0;
if (!live) return false;
return !!cont.querySelector(
'.reasoning-body details.thinking, ' +
'.reasoning-body details.tool-calls-disclosure');
'.reasoning-body div.thinking, ' +
'.reasoning-body div.tool-calls-disclosure');
}
"""
@@ -305,8 +308,8 @@ MIDROUND_STILL_OPEN_JS = """
const cont = wrap.querySelector('details.reasoning');
if (!cont || !cont.hasAttribute('open')) return false;
return !!cont.querySelector(
'.reasoning-body details.thinking, ' +
'.reasoning-body details.tool-calls-disclosure');
'.reasoning-body div.thinking, ' +
'.reasoning-body div.tool-calls-disclosure');
}
"""
@@ -322,7 +325,7 @@ TOOL_ONLY_LIVE_JS = """
const cont = wrap.querySelector('details.reasoning');
if (!cont || !cont.hasAttribute('open')) return false;
return !!cont.querySelector(
'.reasoning-body details.tool-calls-disclosure');
'.reasoning-body div.tool-calls-disclosure');
}
"""
@@ -567,17 +570,13 @@ def test_container_folds_at_done(
_wait_settled(page)
# D2/D5: at rest the container has NO ``open`` attribute (the
# done settle folded it) — and EVERY inner run has no ``open``
# attribute either (the phase-158 fold-at-rest contract
# preserved inside the fold).
# done settle folded it) — and the inner runs are NOT
# collapsible (zero ``<details>`` inside the container — the D5
# refinement, owner 2026-10-06: the per-run blocks are plain
# divs, always visible while the container is open).
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
expect(
page.locator("#messages .msg.brain details.reasoning details.thinking[open]")
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.tool-calls-disclosure[open]"
)
page.locator("#messages .msg.brain details.reasoning details")
).to_have_count(0)
# The collapsed summary is still visible (the bubble stays
@@ -649,19 +648,17 @@ def test_restored_container_collapsed_with_step_count(
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
expect(page.locator("#messages .msg.brain details.reasoning[open]")).to_have_count(0)
# The SAME per-run timeline, nested in the container (all
# collapsed — the D5 restore convention).
# The SAME per-run timeline, nested in the container (plain divs
# — the D5 refinement; the at-rest fold is the container's).
expect(
page.locator("#messages .msg.brain details.reasoning details.thinking")
page.locator("#messages .msg.brain details.reasoning div.thinking")
).to_have_count(2)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.thinking[open]"
)
page.locator("#messages .msg.brain details.reasoning details")
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.tool-calls-disclosure"
"#messages .msg.brain details.reasoning div.tool-calls-disclosure"
)
).to_have_count(1)
texts = page.locator(
@@ -737,30 +734,23 @@ def test_shared_page_container_collapsed(
# D5 (the shared-page half): ONE ``details.reasoning``, NO
# ``open`` attribute (the pure-render convention — the
# shared page is a finished conversation), wrapping the SAME
# per-run timeline, all collapsed.
# shared page is a finished conversation), wrapping the
# SAME per-run timeline as plain divs (the D5 refinement —
# the inner runs are not collapsible).
expect(anon.locator("#messages .msg.brain details.reasoning")).to_have_count(1)
expect(
anon.locator("#messages .msg.brain details.reasoning[open]")
).to_have_count(0)
expect(
anon.locator(
"#messages .msg.brain details.reasoning details.thinking[open]"
)
anon.locator("#messages .msg.brain details.reasoning details")
).to_have_count(0)
expect(
anon.locator(
"#messages .msg.brain details.reasoning "
"details.tool-calls-disclosure[open]"
)
).to_have_count(0)
expect(
anon.locator("#messages .msg.brain details.reasoning details.thinking")
anon.locator("#messages .msg.brain details.reasoning div.thinking")
).to_have_count(2)
expect(
anon.locator(
"#messages .msg.brain details.reasoning "
"details.tool-calls-disclosure"
"div.tool-calls-disclosure"
)
).to_have_count(1)
anon_texts = anon.locator(
@@ -807,9 +797,9 @@ def test_no_reasoning_turn_has_no_container(
_submit(page, PLAIN_ANSWER_QUESTION)
_wait_settled(page)
expect(page.locator("#messages .msg.brain details.reasoning")).to_have_count(0)
expect(page.locator("#messages .msg.brain details.thinking")).to_have_count(0)
expect(page.locator("#messages .msg.brain .thinking")).to_have_count(0)
expect(
page.locator("#messages .msg.brain details.tool-calls-disclosure")
page.locator("#messages .msg.brain .tool-calls-disclosure")
).to_have_count(0)
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("")
@@ -851,13 +841,18 @@ def test_no_reasoning_turn_has_no_container(
).to_have_text("1 steps")
# The phase-117 single-disclosure behavior, preserved and now
# wrapped: ONE disclosure with the 3 ``.tool-call`` lines, zero
# thinking blocks, folded at rest.
# wrapped: ONE disclosure — a plain div (the D5 refinement —
# not collapsible; zero ``<details>`` inside the container) with
# the 3 ``.tool-call`` lines, zero thinking blocks, folded at
# rest (the container's fold).
expect(
page.locator(
"#messages .msg.brain details.reasoning details.tool-calls-disclosure"
"#messages .msg.brain details.reasoning div.tool-calls-disclosure"
)
).to_have_count(1)
expect(
page.locator("#messages .msg.brain details.reasoning details")
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.reasoning .tool-call"
@@ -869,12 +864,7 @@ def test_no_reasoning_turn_has_no_container(
)
).to_have_text("Tool calls (3)")
expect(
page.locator("#messages .msg.brain details.reasoning details.thinking")
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.reasoning details.thinking[open]"
)
page.locator("#messages .msg.brain details.reasoning div.thinking")
).to_have_count(0)
# The phase-37 answer echo (the read document reached the model).
+74 -54
View File
@@ -11,7 +11,7 @@ regression test (scenario 4) — the deterministic mock's ``think out loud``
trigger (mock_llm.py) keeps all five scenarios reproducible.
Test → story mapping (Playwright Mapping Rule):
1. ``test_thinking_block_streams_open_then_collapses``
1. ``test_thinking_block_streams_in_the_open_container``
2. ``test_thinking_toggle_after_done``
3. ``test_thinking_restored_after_reload``
4. ``test_no_thinking_block_without_trigger``
@@ -123,12 +123,15 @@ def send_and_wait(page: Page, question: str) -> None:
# ---------------------------------------------------------------------------
# 1. Streaming: the block attaches OPEN at the first thinking event, then
# auto-collapses when the first answer token lands
# 1. Streaming: the block attaches at the first thinking event (nested
# in the open reasoning container — D1); the container STAYS OPEN
# through the first answer token (D2) and the block is ALWAYS
# visible (the D5 refinement — a plain div, not collapsible); the
# container folds at done.
# ---------------------------------------------------------------------------
def test_thinking_block_streams_open_then_collapses(
def test_thinking_block_streams_in_the_open_container(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
@@ -137,25 +140,35 @@ def test_thinking_block_streams_open_then_collapses(
page.evaluate("() => document.querySelector('#composer').requestSubmit()")
expect(page.locator(".msg.user .bubble").last).to_contain_text(THINK_QUESTION)
# The block attaches at the FIRST thinking event — before any answer
# token — and is created OPEN.
details = page.locator(".msg.brain").last.locator("details.thinking")
details.wait_for(state="attached", timeout=10_000)
# The ~2 700-char thinking stream (≈4.5s, phase 21) keeps the block
# open right after attach — assert while it is still streaming.
expect(details).to_have_attribute("open", "")
expect(details.locator(".thinking-text")).not_to_have_text("")
# The block attaches at the FIRST thinking event — before any
# answer token — nested in the container created OPEN (D1).
block = page.locator(".msg.brain").last.locator("div.thinking")
block.wait_for(state="attached", timeout=10_000)
container = page.locator(".msg.brain").last.locator("details.reasoning")
expect(container).to_have_attribute("open", "")
# The ~2 700-char thinking stream (≈4.5s, phase 21) keeps the
# container open right after attach — assert while it is still
# streaming (the D5 refinement: the container is the ONE open
# disclosure — zero <details> inside it).
expect(container).to_have_attribute("open", "")
expect(
page.locator(".msg.brain").last.locator("details.reasoning details")
).to_have_count(0)
expect(block.locator(".thinking-text")).not_to_have_text("")
# First answer token: the block auto-collapses and stays closed.
# First answer token: the container STAYS OPEN (D2 — the mid-round
# delta does not fold it; only done / stop / the failed-turn
# funnel do).
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).not_to_have_text("", timeout=30_000)
expect(details).not_to_have_attribute("open")
expect(container).to_have_attribute("open", "")
# Settled: full scratchpad, grounded mock answer, ZERO citation
# chips (phase 119, LOCKED A1 — the zero-read turn chips nothing;
# the retired phase-118 A4 union is gone), and the re-enabled send
# button.
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
# Settled: the container FOLDS (done); the block is not
# collapsible (a div — the D5 refinement); the full scratchpad is
# intact, the grounded mock answer is present, ZERO citation chips
# (phase 119, LOCKED A1), and the re-enabled send button.
expect(container).not_to_have_attribute("open")
expect(block.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER)
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
expect(page.locator("#send-btn")).to_be_enabled()
@@ -163,13 +176,12 @@ def test_thinking_block_streams_open_then_collapses(
# ---------------------------------------------------------------------------
# 2. Toggle: after settle the block is closed; the summary re-opens it
# (a real keyboard-focusable control) and closes it again.
# Phase 161 (D2): at rest the run sits INSIDE the folded reasoning
# container — the container's summary is the at-rest focusable
# surface (native <details> nesting semantics: a closed details
# hides its content, so the inner summary is focusable only after
# the container opens).
# 2. Toggle: after settle the container is folded; its summary
# re-opens it (a real keyboard-focusable control) and closes it
# again. The D5 refinement (owner 2026-10-06): the inner run is a
# plain div — NOT collapsible — so the container's summary is the
# ONE toggle (the per-run "summary" click is gone; the block is
# always visible once the container opens).
# ---------------------------------------------------------------------------
@@ -179,12 +191,14 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -
send_and_wait(page, THINK_QUESTION)
last = page.locator(".msg.brain").last
details = last.locator("details.thinking")
expect(details).to_have_count(1)
expect(details).not_to_have_attribute("open") # auto-collapsed at first token
block = last.locator("div.thinking")
expect(block).to_have_count(1)
# The D5 refinement: the inner run is not a <details> at all (zero
# <details> inside the container — the block is a plain div).
expect(last.locator("details.reasoning details")).to_have_count(0)
# Phase 161 (D2): the run nests in the reasoning container, which
# folds at done (the at-rest focusable surface is the container's
# summary — open it to reach the inner run's summary).
# summary — the ONE toggle).
container = last.locator("details.reasoning")
expect(container).to_have_count(1)
expect(container).not_to_have_attribute("open")
@@ -193,21 +207,18 @@ def test_thinking_toggle_after_done(page: Page, app_url: str, seeded_kb: None) -
container.locator("summary.reasoning-summary").click()
expect(container).to_have_attribute("open", "")
# The inner run's summary is a real, keyboard-focusable control.
details.locator("summary").focus()
assert page.evaluate("() => document.activeElement.tagName") == "SUMMARY"
# Open: the full scratchpad is visible.
details.locator("summary").click()
expect(details).to_have_attribute("open", "")
text_el = details.locator(".thinking-text")
# Open: the full scratchpad is visible — ONE toggle (the D5
# refinement — no second, per-run toggle; the block is always
# visible once the container opens).
text_el = block.locator(".thinking-text")
expect(text_el).to_be_visible()
expect(text_el).to_contain_text(THINKING_FRAGMENT)
expect(text_el).to_contain_text("nothing is invented")
# Closed again — user control in both directions.
details.locator("summary").click()
expect(details).not_to_have_attribute("open")
# Closed again — user control in both directions (the container's
# summary hides the always-visible block with it).
container.locator("summary.reasoning-summary").click()
expect(container).not_to_have_attribute("open")
expect(text_el).not_to_be_visible()
@@ -222,11 +233,12 @@ def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: Non
login(page, app_url, next="/")
send_and_wait(page, THINK_QUESTION)
# The live block is collapsed; capture what it shows and what the
# turn persisted (raw text, same as what was rendered).
details = page.locator(".msg.brain").last.locator("details.thinking")
expect(details).not_to_have_attribute("open")
captured = details.locator(".thinking-text").text_content()
# The live block is a plain div (the D5 refinement — always visible
# while the container is live-open); capture what it shows and what
# the turn persisted (raw text, same as what was rendered).
block = page.locator(".msg.brain").last.locator("div.thinking")
expect(block).to_have_count(1)
captured = block.locator(".thinking-text").text_content()
assert captured
# The persisted raw text is the same scratchpad (renderMarkdown turns
# the line breaks into <br>, which textContent drops — compare without
@@ -239,9 +251,15 @@ def test_thinking_restored_after_reload(page: Page, app_url: str, seeded_kb: Non
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 not a <details> at all).
restored_wrap = page.locator(".msg.brain").last
expect(
restored_wrap.locator("details.reasoning")
).not_to_have_attribute("open")
expect(restored_wrap.locator("details.reasoning details")).to_have_count(0)
expect(restored.locator(".thinking-text")).to_have_text(captured)
# Answer bubble is intact after the restore (phase-14 restore path)
@@ -264,7 +282,7 @@ def test_no_thinking_block_without_trigger(page: Page, app_url: str, seeded_kb:
send_and_wait(page, PLAIN_QUESTION)
# No trigger → no thinking events → no block anywhere on the page.
expect(page.locator("details.thinking")).to_have_count(0)
expect(page.locator(".thinking")).to_have_count(0)
# The turn itself is complete and grounded, exactly as before phase
# 17 — with ZERO citation chips (phase 119, LOCKED A1: the zero-read
@@ -293,8 +311,10 @@ def test_thinking_with_deflection(page: Page, app_url: str, seeded_kb: None) ->
expect(chips.first).to_be_visible()
assert chips.count() >= 2
# And the thinking block came along, closed, with its scratchpad.
details = last.locator("details.thinking")
expect(details).to_have_count(1)
expect(details).not_to_have_attribute("open")
expect(details.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
# And the thinking block came along — a plain div (the D5
# refinement — not collapsible; the container is the ONE fold) —
# with its scratchpad.
block = last.locator("div.thinking")
expect(block).to_have_count(1)
expect(last.locator("details.reasoning details")).to_have_count(0)
expect(block.locator(".thinking-text")).to_contain_text(THINKING_FRAGMENT)
+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)
+57 -62
View File
@@ -21,17 +21,18 @@ shape (list → drill → read → the quoted answer) with a DISTINCT
``reasoning_content`` run streamed AHEAD of every tool call and ahead
of the final answer — the ``Step N`` markers make each run
individually assertable. The client renders each CONTIGUOUS run in its
own ``details.thinking`` / ``details.tool-calls-disclosure`` (D1), all
above the answer bubble in arrival order (D2 — Option B,
owner-chosen), open while the run's frames stream and folded at rest
(D5), and persists the run order in the record's ``segments`` array
(D4). Phase 161 (the documented supersession, owner TODO direction
2026-10-05): the per-run blocks are NESTED inside ONE
own per-run block (D1) in arrival order (D2 — Option B,
owner-chosen) and persists the run order in the record's ``segments``
array (D4). Phase 161 (the documented supersession, owner TODO
direction 2026-10-05): the per-run blocks are NESTED inside ONE
``details.reasoning`` container (``.reasoning-body``) that sits
before the bubble — open during the turn, folded at done / on
restore / on the shared page; the per-run open/fold behavior itself
is unchanged. The sequence pin is shape-unchanged, read from the
container's ``.reasoning-body`` children (the container is the one
restore / on the shared page. The D5 refinement (owner 2026-10-06):
the per-run blocks are plain divs — NOT collapsible (the container
is the ONE disclosure; a nested fold inside the fold used to hide
the live reasoning mid-turn), always visible while the turn is live.
The sequence pin is shape-unchanged, read from the container's
``.reasoning-body`` children (the container is the one
new parent — the pre-bubble ``.msg-body`` children are exactly
``["details.reasoning"]``).
@@ -54,15 +55,17 @@ Test → phase mapping (Playwright Mapping Rule):
one lump); each disclosure summary is ``Tool call (1)``; the
answer bubble carries the ``Read <source/path>`` echo.
2. ``test_runs_fold_at_rest`` — D5 (phase 161 D2): after ``done``
the container AND every ``.thinking`` /
``.tool-calls-disclosure`` inside it has NO ``open`` attribute
(the settle folded the runs + the container); the at-rest visible
line is the container's ``Reasoning`` summary (``7 steps``);
opening it reveals the folded run summaries.
the container has NO ``open`` attribute (the settle folded it) and
the inner runs are NOT collapsible (zero ``<details>`` inside the
container — the D5 refinement); the at-rest visible line is the
container's ``Reasoning`` summary (``7 steps``); opening it
reveals the always-visible run blocks.
3. ``test_live_runs_open_while_streaming`` — D5 (the live half):
DURING the turn (the mock's pacing window — the 0.1 s tool frames +
the thinking stream, the answer bubble still EMPTY) the active run's
block is ``open`` (the live feedback the owner keeps).
the thinking stream, the answer bubble still EMPTY) the container
is ``open`` with a run visible in its ``.reasoning-body`` (the
live feedback the owner keeps — the container is the ONE open
disclosure).
4. ``test_restored_timeline_and_record`` — the restore path + D4: a
same-context RELOAD re-renders the same 7-element sequence in ONE
collapsed reasoning container (``7 steps``, phase 161 D5/D6), and
@@ -75,8 +78,9 @@ Test → phase mapping (Playwright Mapping Rule):
5. ``test_shared_page_timeline`` — the shared-page parity (phase 51
save + share flow): a FRESH context on ``/shared/<token>`` renders
the same 7-element sequence in ONE collapsed reasoning container
(``addSegments``), the ``Step N`` markers visible on container +
summary click (expand ONE each), the answer bubble carries the
(``addSegments``), the ``Step N`` markers visible on container
click (the D5 refinement — the inner blocks are always visible
once the container opens), the answer bubble carries the
``Read <source/path>`` echo.
6. ``test_no_thinking_turn_single_disclosure`` — the run-grouping
contract for no-thinking turns (the phase-117 behavior preserved,
@@ -192,13 +196,13 @@ STEP_MARKERS = ("Step 1", "Step 2", "Step 3", "Step 4")
#: sequence is shape-unchanged — the container is the one new parent)
#: — tests 1/4 (chat page) and 5 (shared page) pin it.
TIMELINE_SEQ = [
"details.thinking",
"details.tool-calls-disclosure",
"details.thinking",
"details.tool-calls-disclosure",
"details.thinking",
"details.tool-calls-disclosure",
"details.thinking",
"div.thinking",
"div.tool-calls-disclosure",
"div.thinking",
"div.tool-calls-disclosure",
"div.thinking",
"div.tool-calls-disclosure",
"div.thinking",
]
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
@@ -325,18 +329,23 @@ BODY_SEQUENCE_JS = """
}
"""
#: D5's live-open predicate — the active run's block is ``open`` while
#: the answer bubble is STILL EMPTY (the turn is in flight; the mock's
#: pacing window — the 0.1 s tool frames + the thinking stream — keeps
#: the window comfortably open for a 100 ms poll).
#: D5's live predicate (the D5 refinement — the container is the ONE
#: open disclosure): the container is ``open`` with a per-run block
#: nested in its ``.reasoning-body`` while the answer bubble is STILL
#: EMPTY (the turn is in flight; the mock's pacing window — the 0.1 s
#: tool frames + the thinking stream — keeps the window comfortably
#: open for a 100 ms poll).
LIVE_OPEN_JS = """
() => {
const wrap = document.querySelector('#messages .msg.brain');
if (!wrap) return false;
const bubble = wrap.querySelector('.msg-body .bubble');
if (!bubble || bubble.textContent !== '') return false;
return !!wrap.querySelector(
'details.thinking[open], details.tool-calls-disclosure[open]');
const cont = wrap.querySelector('details.reasoning');
if (!cont || !cont.hasAttribute('open')) return false;
return !!cont.querySelector(
'.reasoning-body div.thinking, ' +
'.reasoning-body div.tool-calls-disclosure');
}
"""
@@ -540,25 +549,23 @@ def test_runs_fold_at_rest(
page.locator("#messages .msg.brain .reasoning-status")
).to_have_text("7 steps")
# D5: at rest NOTHING inside is open — the ``done`` settle
# (closeAllRuns) folded every run (a closed <details> has no
# ``open`` attribute).
# D5 refinement (owner 2026-10-06): the inner runs are NOT
# collapsible — ZERO <details> inside the container (the
# per-run blocks are plain divs, always visible while the
# container is open).
expect(
page.locator("#messages .msg.brain details.thinking[open]")
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.tool-calls-disclosure[open]"
)
page.locator("#messages .msg.brain details.reasoning details")
).to_have_count(0)
# The inner runs stay in the DOM inside the fold — opening the
# container (the native toggle) reveals the 4 "Thinking" + 3
# "Tool call (1)" lines (the timeline stays readable at rest).
# "Tool call (1)" labels (the timeline stays readable at rest;
# the D5 refinement — no second toggle: the blocks are always
# visible once the container opens).
page.locator("#messages .msg.brain .reasoning-summary").click()
expect(container).to_have_attribute("open", "")
expect(
page.locator("#messages .msg.brain details.thinking > summary").first
page.locator("#messages .msg.brain .thinking .thinking-summary").first
).to_be_visible()
expect(
page.locator("#messages .msg.brain .tool-calls-summary").first
@@ -652,12 +659,7 @@ def test_restored_timeline_and_record(
f"the restored in-container sequence is not the timeline: {seq}"
)
expect(
page.locator("#messages .msg.brain details.thinking[open]")
).to_have_count(0)
expect(
page.locator(
"#messages .msg.brain details.tool-calls-disclosure[open]"
)
page.locator("#messages .msg.brain details.reasoning details")
).to_have_count(0)
# The restored answer (the restore is synchronous at boot).
expect(
@@ -778,24 +780,17 @@ def test_shared_page_timeline(
assert seq == TIMELINE_SEQ, (
f"the shared in-container sequence is not the timeline: {seq}"
)
# D5 refinement (owner 2026-10-06): the inner runs are NOT
# collapsible — ZERO <details> inside the container (the
# per-run blocks are plain divs).
expect(
anon.locator("#messages .msg.brain details.thinking[open]")
).to_have_count(0)
expect(
anon.locator(
"#messages .msg.brain details.tool-calls-disclosure[open]"
)
anon.locator("#messages .msg.brain details.reasoning details")
).to_have_count(0)
# The ``Step N`` markers are visible on a summary click (expand
# ONE — the native <summary> is a real focusable toggle; phase
# 161: the container's own summary first — the inner runs'
# summaries sit inside its closed fold).
# The ``Step N`` markers are visible on the container's summary
# click (the D5 refinement — ONE toggle: the inner blocks are
# always visible once the container opens; no second toggle).
anon.locator("#messages .msg.brain .reasoning-summary").click()
anon.locator("#messages .msg.brain details.thinking > summary").first.click()
expect(
anon.locator("#messages .msg.brain details.thinking").first
).to_have_attribute("open")
first_text = anon.locator("#messages .msg.brain .thinking-text").first
expect(first_text).to_be_visible()
expect(first_text).to_contain_text(STEP_MARKERS[0])
+58 -40
View File
@@ -10,12 +10,18 @@ item breaking mid-word (``Rea``/``ding``) and the path wrapping to
three lines indented to the right of that broken label.
Phase 117's fix (frontend-only, D1–D6 in ``00_phase.md``): the
per-call lines ride in ONE native ``<details>`` disclosure — a compact
"Tool calls (N)" summary, open while the turn is live and FOLDED when
the answer begins (the first ``delta``), on ``done``, on stop/abort,
and on restore — and the lines themselves are deboxed inline-flow
text (no card, no flex: the label + ``<code>`` run as one continuous
sentence and the path wraps to the left edge).
per-call lines ride in ONE compact "Tool calls (N)" disclosure, open
while the turn is live and FOLDED when the answer begins (the first
``delta``), on ``done``, on stop/abort, and on restore — and the lines
themselves are deboxed inline-flow text (no card, no flex: the label
+ ``<code>`` run as one continuous sentence and the path wraps to the
left edge). Phase 161 (D2): the disclosure sits INSIDE the ONE
reasoning container (``details.reasoning``), which is the only
collapsible. The D5 refinement (owner 2026-10-06) goes one step
further: the per-run disclosure is now a PLAIN div (no ``<details>``)
— always visible while the container is open; the container is the
ONE disclosure (open while the turn is live, folded at done/stop/
restore).
Run in isolation (DB must be up: ``podman compose up -d db``):
@@ -41,22 +47,26 @@ already folded (phase 117, D3: the fold rides the first delta).
Test → phase mapping (Playwright Mapping Rule):
1. ``test_tool_calls_fold_to_one_line_after_turn`` — pin 1: a
completed 3-call turn renders ONE visible "Tool calls (3)" summary;
the disclosure is CLOSED (no ``open`` attribute); the three
``.tool-call`` lines are present in the DOM but folded (the first
line is not visible); the answer is present.
completed 3-call turn renders ONE visible "Tool calls (3)" summary
(revealed by opening the container — the ONE disclosure, folded at
rest); the in-container runs are plain divs (zero ``<details>``
nested in the container — the D5 refinement); the three
``.tool-call`` lines are present in the DOM but hidden (the
container is folded); the answer is present.
2. ``test_summary_click_expands_the_calls`` — pin 2: tapping the
native ``<summary>`` (a real focusable toggle, AA) opens the
disclosure; the three lines become visible, in order, with the
phase-37/94 pinned text.
container's native ``<summary>`` (the ONE real focusable toggle,
AA) opens the container; the three lines become visible, in order,
with the phase-37/94 pinned text.
3. ``test_expanded_line_is_deboxed_inline_flow`` — pin 3: the
expanded "Reading" line's computed ``display`` is NOT ``flex``
(the label + path are one inline run, not two flex items — the
debox), and the label runs directly into the path in the same run.
4. ``test_restored_turn_renders_folded`` — pin 4: a same-context
RELOAD (the phase-14/50 persisted conversation restores) renders
the turn FOLDED — closed disclosure, "Tool calls (3)" summary,
three lines in the DOM, first line hidden — no auto-expand on load.
the turn FOLDED — folded container, plain div runs (zero nested
``<details>``), "Tool calls (3)" summary (revealed by opening the
container), three lines in the DOM, first line hidden — no
auto-expand on load.
"""
from __future__ import annotations
@@ -232,11 +242,13 @@ def _reset_db(seed: Callable[[Session], None] | None = None) -> None:
# --------------------------------------------------------------------------
def _disclosure_open(page: Page) -> bool:
"""The disclosure's open state: a closed ``<details>`` has NO
def _container_open(page: Page) -> bool:
"""The container's open state: a closed ``<details>`` has NO
``open`` attribute (``get_attribute`` → ``None``); an open one
carries it (any value — the native boolean attribute is empty)."""
return page.get_attribute(DISCLOSURE, "open") is not None
carries it (any value — the native boolean attribute is empty).
Phase 161 (D2): the container is the ONE disclosure — the
per-run disclosure is a plain div (the D5 refinement)."""
return page.get_attribute(REASONING, "open") is not None
def _submit_tools_turn(page: Page, app_url: str) -> None:
@@ -264,14 +276,11 @@ def _submit_tools_turn(page: Page, app_url: str) -> None:
def _expand_disclosure(page: Page) -> None:
"""Tap the summary — the native ``<summary>`` is a real focusable
toggle (AA), so a plain click is the user contract. Phase 161
(D2): at rest the disclosure sits INSIDE the folded reasoning
container, so the user flow is two native toggles — open the
container first (its own summary — a direct child, so the inner
runs' summaries are not matched), then the disclosure's summary."""
"""Tap the container's summary — the native ``<summary>`` is a
real focusable toggle (AA), so a plain click is the user
contract. The D5 refinement: ONE toggle — the per-run disclosure
is a plain div, always visible while the container is open."""
page.locator(f"{REASONING} > summary").click()
page.locator(SUMMARY).click()
# --------------------------------------------------------------------------
@@ -301,6 +310,11 @@ def test_tool_calls_fold_to_one_line_after_turn(
container = page.locator(REASONING)
expect(container).to_have_count(1)
expect(container).not_to_have_attribute("open")
# Folded at rest: the record's lines are hidden (the D5 refinement —
# the at-rest fold is the container's; the per-run runs are plain
# divs that render on reveal).
expect(page.locator(LINES)).to_have_count(3)
expect(page.locator(LINES).first).not_to_be_visible()
page.locator(f"{REASONING} > summary").click()
expect(container).to_have_attribute("open", "")
@@ -309,13 +323,15 @@ def test_tool_calls_fold_to_one_line_after_turn(
expect(summary).to_be_visible()
expect(summary).to_contain_text("Tool calls (3)")
# Folded at rest: a closed <details> carries no `open` attribute.
assert page.get_attribute(DISCLOSURE, "open") is None
# The D5 refinement: the in-container runs are plain divs — the
# per-run disclosure is NOT a <details> (the container is the ONE
# disclosure).
expect(page.locator(f"{REASONING} details")).to_have_count(0)
# The lines are present in the DOM (the permanent record) but
# folded: the FIRST line is not rendered.
# The lines are present in the DOM (the permanent record) and
# render on reveal — always visible inside the open container (D5).
expect(page.locator(LINES)).to_have_count(3)
expect(page.locator(LINES).first).not_to_be_visible()
expect(page.locator(LINES).first).to_be_visible()
# The answer is present (the mock's deterministic single-read quote).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
@@ -339,13 +355,13 @@ def test_summary_click_expands_the_calls(
page.set_default_timeout(30_000)
_reset_db(_seed)
_submit_tools_turn(page, app_url)
assert not _disclosure_open(page) # folded after the turn (pin 1)
assert not _container_open(page) # folded after the turn (pin 1)
_expand_disclosure(page)
# The native boolean attribute is now present (any value — it is
# empty in the DOM; "true" is the attribute-present shorthand).
assert page.get_attribute(DISCLOSURE, "open") is not None
assert _container_open(page)
# All three lines, in order, visible with their pinned text.
lines = page.locator(LINES)
@@ -447,15 +463,17 @@ def test_restored_turn_renders_folded(
expect(container.locator("> summary")).to_be_visible()
expect(container.locator("> summary")).to_contain_text("Reasoning")
# The disclosure is CLOSED (no auto-expand on load) — and the
# in-container record is revealed by opening the container (the
# two-native-toggle user flow).
assert page.get_attribute(DISCLOSURE, "open") is None
# No auto-expand on load (the container is folded) — and the
# in-container record is revealed by opening the ONE toggle.
assert not _container_open(page)
# The D5 refinement: the restored runs are plain divs (no nested
# <details>).
expect(page.locator(f"{REASONING} details")).to_have_count(0)
page.locator(f"{REASONING} > summary").click()
expect(page.locator(SUMMARY)).to_be_visible()
expect(page.locator(SUMMARY)).to_contain_text("Tool calls (3)")
# The three lines are in the DOM (the permanent record) but folded:
# the first line is not rendered — no auto-expand.
# The three lines are in the DOM (the permanent record) and render
# on reveal — always visible inside the open container (D5).
expect(page.locator(LINES)).to_have_count(3)
expect(page.locator(LINES).first).not_to_be_visible()
expect(page.locator(LINES).first).to_be_visible()