Release exp-v0.52.50: transparent-stream lazy settled DOM + per-turn cap (#5974, #5966) (#5981)

* fix(transparent-stream): defer settled tool-detail DOM + cap per-turn rows (#5966)

Reported by @curtisszmania-cytocync.

Transparent Stream built a DOM node for every activity event of every settled
turn at load, and eagerly materialized each tool row's full detail body — a
15+ prompt reasoning-heavy history produced thousands of subtrees and tipped the
tab into a multi-GB freeze. The #5860/#5839 lazy-worklog fix did not cover
Transparent Stream (it never collapses).

Two-part surgical fix in static/ui.js, mirroring #5860's proven machinery:
- Row-detail deferral: a settled+collapsed transparent tool row renders
  header-only; its heavy .tool-card-detail body builds on first expand
  (_materializeTransparentToolDetail), recovering the tool call from the scene
  after an innerHTML cache round-trip. Same anchor-suppressed post-processing as
  the eager path, so an expanded row is byte-identical.
- Per-turn row cap: a settled turn with >CAP+SLACK (30+10) rows renders the last
  30 behind a clean 'Show earlier steps (N)' affordance that materializes the
  prefix in place with viewport compensation. Just-settled turn (keep-open token)
  and already-revealed turns are exempt; true Trace tool-count is stashed so the
  label stays honest; Expand-all reveals capped rows; cache-restore re-wires the
  affordance. Live rendering is untouched.

Server-side #5858 (scene serialize-after-messages) already covers the container
RSS half on the experimental channel; this is the browser-DOM half.

* style(#5966): compact 'Show earlier steps' pill (hug content, left-aligned)

Constrain the affordance to fit-content width + align-self:flex-start so it
reads as a light inline control on the activity rail, not a full-width bar.

* fix(#5966): address Codex gate findings + Fable UX fast-follows

Codex SHIP-ONLY-WITH-FIXES (3 SILENT defects), all fixed + browser-verified:
- F1: buildToolCard PRE-BUILDS .tool-card-detail when a tool has args/output, so
  the old `!detail` defer guard skipped exactly the heavy rows. Defer now fires on
  settled+collapsed+has-detail and STRIPS any prebuilt body (verified: 400 shell
  tool rows -> 0 detail bodies mounted; expand still materializes byte-identically).
- F2: stamp owner rawIdx on rows + affordance; dataset recovery and cache-restore
  rebind resolve the scene-owning message (multi-segment turns own it on a later
  segment, not the first) with a scene-owning-segment fallback.
- F3: persist revealed-turn state in a session/owner-keyed Set + invalidate the
  session HTML cache on reveal, so a rebuild/switch-away never re-caps a turn the
  user expanded (verified: reveal 30->400, full re-render stays 400).

Fable SHIP-UX fast-follows: t()-with-fallback label (+ en locale keys), 640px
touch breakpoint, stale comment fixed.

Tests: 21 pass (added has-detail deferral+strip, detail-less no-defer, owner-idx
recovery, persistent-reveal, i18n).

* fix(#5966): address Codex re-gate findings (r2)

Codex re-gate SHIP-ONLY-WITH-FIXES — 2 more SILENT defects, both fixed + browser-verified:
- F1(r2): _materializeTransparentToolDetail rebuilt via the thinner
  _transparentToolDetailHtml(), dropping buildToolCard's richer detail (diff
  coloring / show-more / canonical shell-command lead). Now transplants
  buildToolCard(tc)'s own .tool-card-detail node so an expanded deferred row is
  byte-identical to the eager path (verified: canonical structure + 595-char body).
- F2(r2): after an in-session renderMessages() rebuild (next send) an already-open
  settled tool row got re-deferred, and disclosure-restore only toggled .open →
  open-but-empty card. _setWorklogDetailDisclosureOpen now materializes a deferred
  transparent row BEFORE toggling open (verified: expand → rebuild → still open
  WITH content). Likely the same defect the RegressionGate caught intermittently.

Tests: 22 pass (added canonical-detail + disclosure-restore-materialize).

* fix(review): add show_earlier_steps i18n keys to all locales (#5966)

The transparent-stream row-cap "Show N earlier steps" pill added two new
i18n keys (show_earlier_steps, show_earlier_step_one) to the English block
only, so the strict locale-parity tests (zh/ko/ru/zh-Hant/...) failed with
missing-key assertions and CI shard 0 went red across 3.11/3.12/3.13.

Add both keys to all 14 non-English locale blocks with the English string as
fallback (the repo's established add-with-English-fallback convention for new
UI keys). Inserted after each block's collapse_all anchor. All 197 locale
tests + the #5966 suite green; node --check clean.

* fix(review): extract _transparentToolRowHasDetail into the #5700 harness

#5966 added a _transparentToolRowHasDetail() call into the settled transparent
render (_anchorSceneTransparentNodeForRow) to decide whether to defer a
collapsed tool row's detail body. The pre-existing #5700 timestamp test extracts
that render fn into a node harness but didn't extract the new helper, so the
extracted fn hit a ReferenceError → node exited non-zero → 3 #5700 tests failed
(RED across CI shards; the PR updated its own #5966 harness but missed this one).

Add `eval(extractFunc('_transparentToolRowHasDetail'))` to the harness. The
helper self-guards its _toolActionKind/_toolCardAllowsDetail deps (returns true
when absent), and the #5700 timestamp lives on the row HEADER (rendered even when
detail is deferred), so all 20 #5700 assertions pass unchanged — confirming this
was a harness gap, not a timestamp regression.

* fix(#5974): hide Show-earlier-steps pill when a capped transparent turn is collapsed (Fable UX gate)

Fable UX gate SHIP-WITH-UX-FIXES: the .transparent-earlier-steps pill wasn't in the
.assistant-turn[data-transparent-turn-collapsed=1] hide rule, so collapsing a capped
turn via the name-tag toggle left an orphan pill. Added it to the hide rule.
(i18n finding was already resolved — both pill keys present in all 15 locales w/
English fallback + show_earlier_step_one is live at ui.js:12918, not dead.)

* Release exp-v0.52.50: transparent-stream lazy settled DOM + per-turn cap (#5974, #5966)

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-07-11 21:56:20 -07:00
1 parent 6361d920ff
commit 9643cdd5c1
6 files changed
+754 -7

No files matched your search

+2
View File
@@ -13,6 +13,8 @@
### Fixed
- **Transparent Stream no longer bloats memory (or freezes the tab) on long tool-heavy chats.** With Transparent Stream enabled, opening a conversation with many prompts built a DOM node for every activity event of every past turn at load and eagerly rendered each tool row's full detail body — a long reasoning-heavy history could balloon to multiple GB and lock up the tab. Settled tool rows now render header-only and build their detail on first expand (identical to the old view once opened), and a turn with a very large number of steps shows its most recent 30 behind a "Show N earlier steps" control. Live turns are unchanged. Thanks @curtisszmania-cytocync for the report. (#5974, #5966)
- **Dictation no longer wipes text you already typed.** When you typed part of a prompt and then used the microphone (server-side transcription / MediaRecorder path), the recognized text replaced your typed prefix instead of being appended to it. Dictation now appends to whatever is already in the composer, so you can type a partial sentence and dictate the rest. A Settings toggle lets you opt back into the old replace behavior. Thanks @Kopamed. (#5895)
- **The context-usage indicator is more accurate right after a context compaction.** After the conversation context was compressed, the indicator kept showing the previous provider-reported prompt size (the pre-compaction request) instead of the smaller prepared one, so it briefly overstated how full the window was. It now shows a short-lived, clearly-labeled post-compaction estimate that expires as soon as the next real provider count arrives — provider accounting, token metering, and recovery behavior are unchanged. Thanks @rodboev. (#5872, #4685)
+30
View File
@@ -145,6 +145,8 @@ const LOCALES = {
thinking: 'Thinking',
expand_all: 'Expand all',
collapse_all: 'Collapse all',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Edit failed: ',
regen_failed: 'Regenerate failed: ',
reconnect_active: 'A response is still being generated. Reload when ready?',
@@ -1889,6 +1891,8 @@ const LOCALES = {
thinking: 'Pensando',
expand_all: 'Espandi tutto',
collapse_all: 'Comprimi tutto',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Modifica fallita: ',
regen_failed: 'Rigenerazione fallita: ',
reconnect_active: 'Una risposta è ancora in generazione. Ricaricare quando pronta?',
@@ -3624,6 +3628,8 @@ const LOCALES = {
thinking: '考え中',
expand_all: 'すべて展開',
collapse_all: 'すべて折りたたむ',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: '編集失敗: ',
regen_failed: '再生成失敗: ',
reconnect_active: '応答を生成中です。準備ができたら再読み込みしますか?',
@@ -5348,6 +5354,8 @@ const LOCALES = {
thinking: 'Думаю',
expand_all: 'Развернуть всё',
collapse_all: 'Свернуть всё',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Не удалось отредактировать: ',
regen_failed: 'Не удалось сгенерировать заново: ',
reconnect_active: 'Ответ всё ещё генерируется. Обновить, когда будет готово?',
@@ -7050,6 +7058,8 @@ const LOCALES = {
thinking: 'Pensando',
expand_all: 'Expandir todo',
collapse_all: 'Contraer todo',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Error al editar: ',
regen_failed: 'Error al regenerar: ',
reconnect_active: 'Todavía se está generando una respuesta. ¿Recargar cuando termine?',
@@ -8731,6 +8741,8 @@ const LOCALES = {
thinking: 'Nachdenken',
expand_all: 'Alle ausklappen',
collapse_all: 'Alle einklappen',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Bearbeiten fehlgeschlagen: ',
regen_failed: 'Regeneration fehlgeschlagen: ',
reconnect_active: 'Eine Antwort wird noch generiert. Neu laden, wenn bereit?',
@@ -10406,6 +10418,8 @@ const LOCALES = {
thinking: '思考过程',
expand_all: '全部展开',
collapse_all: '全部折叠',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: '编辑失败:',
regen_failed: '重新生成失败:',
reconnect_active: '回复仍在生成中,准备好后要重新加载吗?',
@@ -12104,6 +12118,8 @@ const LOCALES = {
thinking: '思考中',
expand_all: '全部展開',
collapse_all: '全部收合',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: '編輯失敗:',
regen_failed: '重新產生失敗:',
reconnect_active: '回覆仍在產生中。完成後要重新載入嗎?',
@@ -13747,6 +13763,8 @@ const LOCALES = {
thinking: 'Pensando',
expand_all: 'Expandir tudo',
collapse_all: 'Recolher tudo',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Falha ao editar: ',
regen_failed: 'Falha ao regenerar: ',
reconnect_active: 'Uma resposta ainda está sendo gerada. Recarregar quando estiver pronto?',
@@ -15368,6 +15386,8 @@ const LOCALES = {
thinking: '생각 중',
expand_all: '모두 펼치기',
collapse_all: '모두 접기',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Edit failed: ',
regen_failed: 'Regenerate failed: ',
reconnect_active: 'A response is still being generated. Reload when ready?',
@@ -17114,6 +17134,8 @@ const LOCALES = {
thinking: 'Réflexion',
expand_all: 'Tout développer',
collapse_all: 'Tout réduire',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Échec de la modification : ',
regen_failed: 'Échec de la régénération : ',
reconnect_active: 'Une réponse est toujours en cours de génération. Recharger quand vous êtes prêt ?',
@@ -19959,6 +19981,8 @@ const LOCALES = {
cmd_webui_only_session: 'Tento příkaz není k dispozici pro relace importované CLI.',
cmd_workspace: 'Přepnout pracovní prostor podle názvu',
collapse_all: 'Sbalit vše',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
command_label: 'Příkaz',
composer_placeholder_busy_interrupt: 'Enter = přerušení | /queue | /background | /steer',
composer_placeholder_busy_queue: 'Enter = fronta | /interrupt | /background | /steer',
@@ -20516,6 +20540,8 @@ const LOCALES = {
thinking: 'Düşünme',
expand_all: 'Tümünü genişlet',
collapse_all: 'Tümünü daralt',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Düzenleme başarısız oldu:',
regen_failed: 'Yeniden oluşturma başarısız oldu:',
reconnect_active: 'Halen bir yanıt oluşturuluyor. Hazır olduğunuzda yeniden yüklensin mi?',
@@ -22253,6 +22279,8 @@ const LOCALES = {
thinking: 'Myślenie',
expand_all: 'Rozwiń wszystko',
collapse_all: 'Zwiń wszystko',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Edycja nie powiodła się: ',
regen_failed: 'Ponowne generowanie nie powiodło się: ',
reconnect_active: 'Odpowiedź jest nadal generowana. Odświeżyć, gdy będzie gotowa?',
@@ -23975,6 +24003,8 @@ const LOCALES = {
thinking: 'Đang suy nghĩ',
expand_all: 'Mở rộng tất cả',
collapse_all: 'Thu gọn tất cả',
show_earlier_step_one: 'Show 1 earlier step',
show_earlier_steps: 'Show {0} earlier steps',
edit_failed: 'Chỉnh sửa thất bại: ',
regen_failed: 'Tạo lại thất bại: ',
reconnect_active: 'Một phản hồi vẫn đang được tạo. Tải lại khi sẵn sàng?',
+45 -1
View File
@@ -4356,7 +4356,8 @@ body.resizing .sidebar{transition:none!important;}
/* ── Collapsible Hermes chat name tag ───────── */
.assistant-turn[data-transparent-turn-collapsed="1"] .transparent-event-row,
.assistant-turn[data-transparent-turn-collapsed="1"] .transparent-event-controls{
.assistant-turn[data-transparent-turn-collapsed="1"] .transparent-event-controls,
.assistant-turn[data-transparent-turn-collapsed="1"] .transparent-earlier-steps{
display:none;
}
.assistant-turn .msg-role.assistant{cursor:pointer;user-select:none;}
@@ -6260,6 +6261,49 @@ main.main > #mainPlugin{display:none;}
.load-older-indicator:hover{opacity:.75;}
.message-window-load-earlier{align-self:center;margin:4px auto 14px;padding:7px 14px;border:1px solid var(--border);border-radius:999px;background:var(--surface);box-shadow:var(--shadow-sm);}
.message-window-load-earlier:hover{background:var(--hover-bg);border-color:var(--accent);}
/* #5966: "Show earlier steps" affordance for a capped Transparent Stream turn.
Sits inline at the top of the turn's activity run, styled as a quiet pill in
the app's own visual language (matches .message-window-load-earlier) but
left-aligned + fit-content so the PILL (not the full row) is the click/tap
target and it reads as part of the run. >=44px tall for touch on phones. */
.transparent-earlier-steps{
display:inline-flex;align-items:center;gap:7px;
align-self:flex-start;width:fit-content;max-width:100%;
margin:2px 0 8px;padding:7px 13px;
min-height:34px;box-sizing:border-box;
cursor:pointer;user-select:none;
font-size:12.5px;font-weight:500;line-height:1;
color:var(--text-muted,var(--muted));
background:var(--surface);
border:1px solid var(--border);border-radius:999px;
box-shadow:var(--shadow-sm);
transition:background .15s ease,border-color .15s ease,color .15s ease;
}
.transparent-earlier-steps:hover{
background:var(--hover-bg);
border-color:var(--accent);
color:var(--accent-text,var(--text));
}
.transparent-earlier-steps:focus-visible{
outline:2px solid var(--accent);outline-offset:2px;
}
.transparent-earlier-steps-chevron{
display:inline-flex;align-items:center;justify-content:center;
color:var(--accent-text,var(--blue));
transition:transform .15s ease;
}
.transparent-earlier-steps:hover .transparent-earlier-steps-chevron{transform:translateY(-1px);}
.transparent-earlier-steps-label{white-space:nowrap;}
@media (max-width:640px){
.transparent-earlier-steps{min-height:44px;padding:11px 15px;font-size:13px;}
}
/* Newly-revealed prefix rows fade in gently so the reveal feels intentional,
not a flash of hundreds of rows. Respects reduced-motion. */
@keyframes transparentEarlierReveal{from{opacity:0;transform:translateY(-2px);}to{opacity:1;transform:none;}}
.transparent-event-row[data-earlier-revealed="1"]{animation:transparentEarlierReveal .18s ease both;}
@media (prefers-reduced-motion:reduce){
.transparent-earlier-steps-chevron,.transparent-event-row[data-earlier-revealed="1"]{transition:none;animation:none;}
}
.message-virtual-spacer{width:100%;pointer-events:none;}
.msg-date-sep {
display: flex; align-items: center; gap: 10px;
+369 -6
View File
@@ -10553,6 +10553,16 @@ function _worklogDetailScrollableBody(el){
}
function _setWorklogDetailDisclosureOpen(el, open){
if(!el||!el.classList) return;
// #5966 (Codex F2 r2): restoring an OPEN state on a settled Transparent Stream
// tool row whose detail was deferred must MATERIALIZE the body first, or the
// card restores open-but-empty after an in-session renderMessages() rebuild
// (e.g. the next send re-defers it, then this toggles .open with no content).
if(open){
const drow=(el.matches&&el.matches('.transparent-event-row[data-transparent-detail-deferred="1"]'))
? el
: (el.closest&&el.closest('.transparent-event-row[data-transparent-detail-deferred="1"]'));
if(drow&&typeof _materializeTransparentToolDetail==='function') _materializeTransparentToolDetail(drow);
}
el.classList.toggle('open', !!open);
if(el.matches&&el.matches('.tool-group[data-tool-worklog-tool-group="1"],.tool-worklog-tool-group')){
el.classList.toggle('tool-worklog-tool-group-collapsed', !open);
@@ -10820,12 +10830,117 @@ function _setTransparentDetailMode(tab, mode){
function _setTransparentCardOpen(card, open){
if(!card) return;
const expanded=!!open;
card.classList.toggle('open',expanded);
const row=card.closest&&card.closest('.transparent-event-row');
// #5966: a settled tool row whose detail body was deferred at render must
// materialize it the first time it's opened (before we flip .open so the
// detail exists for the same paint). No-op on live/already-materialized rows.
if(expanded&&row&&row.getAttribute('data-transparent-detail-deferred')==='1'){
_materializeTransparentToolDetail(row);
}
card.classList.toggle('open',expanded);
if(row) row.setAttribute('data-expanded',expanded?'1':'0');
const header=card.querySelector('.tool-card-header,.thinking-card-header');
if(header) header.setAttribute('aria-expanded',expanded?'true':'false');
}
// #5966: build the deferred `.tool-card-detail` for a settled transparent tool
// row on first expand — the lazy counterpart of the eager build in
// _decorateTransparentEventRow. Recovers the tool call from the in-memory stash
// or, after a _sessionHtmlCache innerHTML round-trip drops the JS property, from
// the row's data-anchor-row-id → S.messages scene (the #5839 recovery pattern).
// Idempotent: clears the deferred flag and runs anchor-suppressed post-processing
// (Prism / copy buttons / KaTeX / Mermaid / trees) on just the new subtree so an
// expanded deferred row is byte-identical to the eager path.
// #5966: does this tool call have a detail body worth deferring? Mirrors
// buildToolCard's `hasDetail` (snippet OR args, gated by _toolCardAllowsDetail)
// so we only mark a row deferred/expandable when there's genuinely something to
// build — a detail-less tool row keeps its `tool-card-no-detail` (no chevron).
function _transparentToolRowHasDetail(tc){
if(!tc||typeof tc!=='object') return false;
const hasRaw=!!tc.snippet||(tc.args&&typeof tc.args==='object'&&Object.keys(tc.args).length>0);
if(!hasRaw) return false;
if(typeof _toolActionKind==='function'&&typeof _toolCardAllowsDetail==='function'){
try{ return !!_toolCardAllowsDetail(_toolActionKind(tc), tc); }catch(_){ return true; }
}
return true;
}
function _materializeTransparentToolDetail(row){
if(!row||row.getAttribute('data-transparent-detail-deferred')!=='1') return false;
const card=row.querySelector('.tool-card');
if(!card){ row.removeAttribute('data-transparent-detail-deferred'); return false; }
let tc=row._deferredToolCall;
if(!tc){
tc=_transparentToolCallFromRowDataset(row);
}
if(!tc){ row.removeAttribute('data-transparent-detail-deferred'); return false; }
const status=String(row.getAttribute('data-event-status')||_transparentToolStatus(tc,true));
row.removeAttribute('data-transparent-detail-deferred');
row._deferredToolCall=null;
if(!card.querySelector('.tool-card-detail')){
// Codex F1(r2): rebuild through the CANONICAL buildToolCard() detail path, not
// the thinner _transparentToolDetailHtml() — the latter drops diff coloring,
// "Show diff/Show more", and canonical shell-command detail that buildToolCard
// produces, so an expanded deferred row must transplant buildToolCard's own
// `.tool-card-detail` to stay byte-identical to the eager path.
let sourceDetail=null;
try{
const rebuilt=buildToolCard(tc);
sourceDetail=rebuilt&&rebuilt.querySelector('.tool-card-detail');
}catch(_){ sourceDetail=null; }
if(sourceDetail){
card.appendChild(sourceDetail); // move the canonical detail node onto the live card
}else{
// Fallback (e.g. buildToolCard unavailable): the lighter detail is better than none.
card.insertAdjacentHTML('beforeend',_transparentToolDetailHtml(tc,status));
}
const detail=card.querySelector('.tool-card-detail');
if(detail&&!detail.querySelector('.transparent-detail-modes')){
const modes=document.createElement('div');
modes.className='transparent-detail-modes';
modes.setAttribute('role','tablist');
modes.innerHTML=`<span class="transparent-detail-mode active" role="tab" tabindex="0" data-mode="full" onclick="_setTransparentDetailMode(this,'full')">Full</span><span class="transparent-detail-mode" role="tab" tabindex="0" data-mode="output" onclick="_setTransparentDetailMode(this,'output')">Output</span>`;
const firstChild=detail.firstChild;
if(firstChild&&firstChild.parentNode===detail) detail.insertBefore(modes, firstChild);
else detail.appendChild(modes);
detail.setAttribute('data-transparent-detail-mode','full');
}
// Match the eager path's post-processing so highlight/copy/KaTeX/Mermaid land.
if(typeof _postProcessWithAnchorSuppression==='function'){
requestAnimationFrame(()=>{ try{ _postProcessWithAnchorSuppression(card); }catch(_){ } });
}
}
return true;
}
// Recover a tool call for a deferred row whose _deferredToolCall JS property was
// dropped by an innerHTML cache round-trip: walk data-anchor-row-id back to the
// owning message's anchor scene and rebuild the tool call from the matching row.
function _transparentToolCallFromRowDataset(row){
try{
const rowId=row.getAttribute('data-anchor-row-id')||'';
// #5966 (Codex F2): resolve the OWNER message by its stamped index, not the
// turn's first assistant segment — a multi-segment turn's scene is owned by a
// later segment, so the first-segment lookup recovered the wrong (or no) scene.
const ownerIdxAttr=row.getAttribute('data-anchor-owner-idx');
let msg=null;
if(ownerIdxAttr!==null&&ownerIdxAttr!==''){
const oi=Number(ownerIdxAttr);
if(Number.isFinite(oi)) msg=S.messages[oi];
}
if(!msg){
// Fallback: find the assistant segment in this turn that actually owns a scene.
const turn=row.closest&&row.closest('.assistant-turn');
const segs=turn?Array.from(turn.querySelectorAll('.assistant-segment[data-msg-idx]')):[];
for(const seg of segs){
const i=Number(seg.getAttribute('data-msg-idx'));
if(Number.isFinite(i)&&S.messages[i]&&S.messages[i]._anchor_activity_scene){ msg=S.messages[i]; break; }
}
}
const scene=msg&&msg._anchor_activity_scene;
if(!scene||!rowId) return null;
const rows=_anchorSceneRowsForRendering(scene,{settled:true})||[];
const match=rows.find(r=>String(r.row_id||r.local_id||'')===rowId&&String(r.role||'')==='tool');
return match?_anchorSceneToolCallFromRow(match,{settled:true}):null;
}catch(_){ return null; }
}
function _wireTransparentHeaderToggle(header){
if(!header) return;
header.setAttribute('data-transparent-toggle-bound','1');
@@ -10868,7 +10983,13 @@ function _syncTransparentEventControls(turn){
const blocks=_assistantTurnBlocks(turn);
if(!blocks) return;
const rows=Array.from(blocks.querySelectorAll(':scope > .transparent-event-row,[data-transparent-event-row="1"]'));
const toolCount=rows.filter(row=>row.getAttribute('data-event-type')==='tool').length;
const mountedToolCount=rows.filter(row=>row.getAttribute('data-event-type')==='tool').length;
// #5966: when this turn's earlier steps are capped (some prefix rows are not
// mounted yet), the true tool count is stashed on the turn so the "Trace: N
// tools" label reflects the whole run, not just what's currently in the DOM.
// Falls back to the mounted count for uncapped turns and the live path.
const stashedTotal=Number(turn.getAttribute('data-transparent-total-tool-count'));
const toolCount=(Number.isFinite(stashedTotal)&&stashedTotal>mountedToolCount)?stashedTotal:mountedToolCount;
let bar=blocks.querySelector(':scope > .transparent-event-controls');
if(!rows.length){
if(bar) bar.remove();
@@ -10949,6 +11070,34 @@ function _rehydrateTransparentStreamDom(root){
}
if(card) _setTransparentCardOpen(card,card.classList.contains('open'));
});
// #5966: re-wire the "Show earlier steps" affordance. Its click handler was
// added via addEventListener and is lost when the session HTML-cache restores
// innerHTML; the DOM + data-earlier-count survive, so rebind by walking back to
// the owning message/segment. _revealTransparentEarlierSteps recovers the rows
// from the scene, so no JS-property stash is needed.
root.querySelectorAll('.transparent-earlier-steps[data-anchor-earlier-steps="1"]').forEach(el=>{
if(el.getAttribute('data-earlier-rewired')==='1') return;
el.setAttribute('data-earlier-rewired','1');
// #5966 (Codex F2): rebind to the OWNER message by stamped index (multi-segment
// turns own the scene on a later segment, not the first).
const turn=el.closest&&el.closest('.assistant-turn');
const ownerIdxAttr=el.getAttribute('data-anchor-owner-idx');
let idx=(ownerIdxAttr!==null&&ownerIdxAttr!=='')?Number(ownerIdxAttr):NaN;
let msg=Number.isFinite(idx)?S.messages[idx]:null;
let seg=(turn&&Number.isFinite(idx))?turn.querySelector('.assistant-segment[data-msg-idx="'+idx+'"]'):null;
if(!msg||!msg._anchor_activity_scene||!seg){
// Fallback: the scene-owning segment in this turn.
const segs=turn?Array.from(turn.querySelectorAll('.assistant-segment[data-msg-idx]')):[];
for(const s of segs){
const i=Number(s.getAttribute('data-msg-idx'));
if(Number.isFinite(i)&&S.messages[i]&&S.messages[i]._anchor_activity_scene){ idx=i; msg=S.messages[i]; seg=s; break; }
}
}
if(!msg||!seg){ return; }
const handler=()=>_revealTransparentEarlierSteps(msg,seg,idx,el);
el.addEventListener('click',handler);
el.addEventListener('keydown',(ev)=>{ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); el.click(); } });
});
}
function _decorateTransparentEventRow(row, opts){
if(!row) return row;
@@ -11023,7 +11172,32 @@ function _decorateTransparentEventRow(row, opts){
}
}
let detail=row.querySelector('.tool-card-detail');
if(!detail&&card){
// #5966: on a SETTLED, COLLAPSED tool row, defer the heavy `.tool-card-detail`
// body (full tool input/output HTML + Prism/KaTeX/Mermaid post-processing)
// until first expand. A reasoning-heavy history can carry thousands of settled
// tool rows; eagerly materializing every detail at load is the Transparent-
// Stream analogue of the #5860 compact-worklog freeze. NOTE buildToolCard
// PRE-BUILDS `.tool-card-detail` whenever the tool has args/output (hasDetail),
// so we must strip that prebuilt body too — a `!detail` guard would skip
// exactly the heavy rows we need to defer (Codex gate F1). The header (name,
// preview, status, chevron) stays; a deferred row looks identical collapsed.
// _materializeTransparentToolDetail() rebuilds on expand from the stashed tool
// call, or from data-anchor-row-id → owner message scene after the
// _sessionHtmlCache innerHTML round-trip drops the JS stash (#5839 class).
// Live and already-open rows keep their detail (about to be read).
const _deferDetail=card&&opts.settled===true&&!card.classList.contains('open')&&_transparentToolRowHasDetail(tc);
if(_deferDetail){
if(detail){ detail.remove(); detail=null; } // drop any buildToolCard prebuilt body
if(!header.querySelector('.tool-card-toggle')){
const toggle=document.createElement('span');
toggle.className='tool-card-toggle';
toggle.innerHTML=li('chevron-right',12);
header.appendChild(toggle);
}
card.classList.remove('tool-card-no-detail'); // it DOES have detail (deferred)
row._deferredToolCall=tc;
row.setAttribute('data-transparent-detail-deferred','1');
}else if(!detail&&card){
card.insertAdjacentHTML('beforeend',_transparentToolDetailHtml(tc,status));
detail=row.querySelector('.tool-card-detail');
if(!header.querySelector('.tool-card-toggle')){
@@ -11130,6 +11304,14 @@ function _attachProgressBar(row, opts){
}
function _setTransparentRowsExpanded(root, expanded){
const scope=root||document;
// #5966: "Expand all" must include a capped turn's hidden earlier steps —
// reveal them first so expansion genuinely opens the whole run. (Collapse-all
// leaves the cap as-is; it only closes what's mounted.)
if(expanded){
scope.querySelectorAll('.transparent-earlier-steps[data-anchor-earlier-steps="1"]').forEach(el=>{
if(typeof el.click==='function') el.click();
});
}
scope.querySelectorAll('.transparent-event-row .tool-card,.transparent-event-row .thinking-card').forEach(card=>{
_setTransparentCardOpen(card,!!expanded);
});
@@ -11918,6 +12100,7 @@ function _anchorSceneTransparentNodeForRow(row, opts){
ts:eventTs,
...meta,
live,
settled,
});
}else{
node=_anchorSceneNodeForRow(row,{settled});
@@ -12614,6 +12797,7 @@ function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx
|| ''
);
blocks.querySelectorAll('[data-anchor-settled-scene-row="1"],.transparent-event-row[data-anchor-scene-row="1"]').forEach(el=>el.remove());
blocks.querySelectorAll('.transparent-earlier-steps[data-anchor-earlier-steps="1"]').forEach(el=>el.remove());
blocks.querySelectorAll('.assistant-segment[data-msg-idx]').forEach(node=>{
const idx=Number(node.getAttribute('data-msg-idx'));
if(Number.isFinite(idx)&&idx<rawIdx){
@@ -12622,21 +12806,190 @@ function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx
node.hidden=true;
}
});
let wrote=false;
for(let idx=0;idx<rows.length;idx+=1){
// #5966: per-turn row cap. A reasoning-heavy settled turn can carry hundreds of
// activity rows; rendering them all inline is the node-count half of the
// Transparent-Stream memory blowup (detail-deferral above handles per-row
// weight). Render only the last _TRANSPARENT_SETTLED_ROW_CAP rows and, when the
// turn exceeds cap + slack, prepend a single "Show earlier steps (N)" affordance
// that materializes the omitted prefix in place on click. Two exemptions keep
// behavior identical where a cap would be wrong or unhelpful:
// • the JUST-SETTLED turn (its stream id matches the keep-open token) renders
// in full — capping it at STREAM_DONE would shrink the transcript and cause
// the backward-jump the keep-open token exists to prevent;
// • a turn already revealed this session (data flag) stays fully rendered
// across ordinary rebuilds / virtualize-out+in cycles.
const turnEl=segment.closest('.assistant-turn');
const streamId=String(message._anchor_stream_id||scene.stream_id||(scene.identity&&scene.identity.stream_id)||'');
const justSettled=_shouldKeepSettledWorklogOpenForStreamSettle(streamId);
// #5966 (Codex F3): revealed-state is authoritative from the persistent set
// (survives cache round-trip / rebuild / switch-away), with the DOM flag as a
// same-render fast path.
const revealKey=_transparentRevealKey(S.session&&S.session.session_id, rawIdx);
const alreadyRevealed=_transparentRevealedTurns.has(revealKey)
|| !!(turnEl&&turnEl.getAttribute('data-transparent-earlier-revealed')==='1');
const cap=_TRANSPARENT_SETTLED_ROW_CAP;
const slack=_TRANSPARENT_SETTLED_ROW_CAP_SLACK;
let startIdx=0;
if(!justSettled&&!alreadyRevealed&&rows.length>cap+slack){
startIdx=rows.length-cap;
}
// Stash the TRUE tool-row count so "Trace: N tools" reflects the whole run even
// while the prefix is capped; cleared on full reveal. (uncapped → remove it.)
if(turnEl){
if(startIdx>0){
const totalTools=rows.filter(r=>String(r.role||'')==='tool').length;
turnEl.setAttribute('data-transparent-total-tool-count',String(totalTools));
}else{
turnEl.removeAttribute('data-transparent-total-tool-count');
}
}
const renderRowAt=(idx)=>{
const row=rows[idx];
const node=_anchorSceneTransparentNodeForRow(row,{settled:true,finalAnswer,liveTokenFinalPrefixEligible:idx>lastNonTerminalWorkRowIndex});
if(!node) continue;
if(!node) return null;
// #5966 (Codex F2): stamp the OWNER message index so cache-round-trip recovery
// resolves the correct scene in a multi-segment turn (the scene owner is often
// NOT the turn's first assistant segment).
node.setAttribute('data-anchor-owner-idx',String(rawIdx));
if(segment.parentElement===blocks) blocks.insertBefore(node,segment);
else blocks.appendChild(node);
return node;
};
let wrote=false;
// The "Show earlier steps" affordance sits ABOVE the retained rows (chronology:
// the hidden steps came first). Insert it before rendering the retained tail so
// it lands at the top of this turn's activity run.
if(startIdx>0){
const earlier=_buildTransparentEarlierStepsAffordance(startIdx);
earlier.setAttribute('data-anchor-owner-idx',String(rawIdx));
if(segment.parentElement===blocks) blocks.insertBefore(earlier,segment);
else blocks.appendChild(earlier);
// Reveal handler: materialize the omitted prefix in place, holding the reader's
// viewport on the clicked affordance (insert grows content ABOVE it).
earlier.addEventListener('click',()=>{
_revealTransparentEarlierSteps(message,segment,rawIdx,earlier);
});
wrote=true;
}
for(let idx=startIdx;idx<rows.length;idx+=1){
if(renderRowAt(idx)) wrote=true;
}
if(wrote){
const turn=segment.closest('.assistant-turn');
if(turn) _syncTransparentEventControls(turn);
}
return wrote;
}
// #5966 tunables. Cap chosen so a normal multi-tool turn (a handful to a couple
// dozen rows) is NEVER capped — only genuinely long reasoning runs are. Slack
// prevents a "Show 3 earlier steps" stub: only cap when the omitted prefix is
// worth its own row.
const _TRANSPARENT_SETTLED_ROW_CAP=30;
const _TRANSPARENT_SETTLED_ROW_CAP_SLACK=10;
// t() returns the key name itself for an unknown key, so `t(k)||literal` doesn't
// fall back. This resolves via t() only when the key is genuinely defined,
// otherwise uses the English literal — keeping the label correct before the
// locale keys are present in every bundle. (Fable UX i18n fast-follow.)
function _tOrDefault(key, literal, ...args){
try{
if(typeof t==='function'){
const v=t(key, ...args);
if(v && v!==key) return v;
}
}catch(_){ }
return literal;
}
// A clean, in-flow affordance styled on the existing "Load earlier messages"
// pill (same visual language, so it reads as native). Shows the exact hidden
// count; the pill is the click target with a leading up-chevron.
function _buildTransparentEarlierStepsAffordance(hiddenCount){
const el=document.createElement('div');
el.className='transparent-earlier-steps';
el.setAttribute('data-anchor-earlier-steps','1');
el.setAttribute('data-anchor-scene-row','1');
el.setAttribute('data-anchor-settled-scene-row','1');
el.setAttribute('role','button');
el.setAttribute('tabindex','0');
el.setAttribute('data-earlier-count',String(hiddenCount));
// i18n with English fallback, matching the sibling "Expand all"/"Collapse all"
// controls' t() pattern. Keys live in the en locale (i18n.js); t() falls back to
// en for other locales and to the key name if absent — so guard with a literal.
const label=hiddenCount===1
? _tOrDefault('show_earlier_step_one','Show 1 earlier step')
: _tOrDefault('show_earlier_steps','Show '+hiddenCount+' earlier steps',hiddenCount);
el.setAttribute('aria-label',label);
el.innerHTML=`<span class="transparent-earlier-steps-chevron">${li('chevron-up',13)}</span><span class="transparent-earlier-steps-label">${esc(label)}</span>`;
el.addEventListener('keydown',(ev)=>{
if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); el.click(); }
});
return el;
}
// Materialize the omitted prefix rows for a capped settled transparent turn,
// preserving the reader's viewport position (rows are inserted ABOVE the clicked
// affordance, so without compensation the content below would jump down).
function _revealTransparentEarlierSteps(message, segment, rawIdx, affordanceEl){
const turnEl=segment.closest('.assistant-turn');
// #5966 (Codex F3): record the reveal in the PERSISTENT set (survives rebuild /
// switch-away / cache round-trip) and invalidate this session's cached HTML so
// the stored markup isn't re-served stale-capped.
const revealKey=_transparentRevealKey(S.session&&S.session.session_id, rawIdx);
_transparentRevealedTurns.add(revealKey);
try{
const sid=S.session&&S.session.session_id;
if(sid&&_sessionHtmlCache&&typeof _sessionHtmlCache.delete==='function') _sessionHtmlCache.delete(sid);
}catch(_){ }
if(turnEl){
turnEl.setAttribute('data-transparent-earlier-revealed','1');
// Full run now mounted → drop the capped-count stash so the Trace label
// recomputes from the (now complete) DOM.
turnEl.removeAttribute('data-transparent-total-tool-count');
}
const msgsEl=$('messages');
const prevScrollTop=msgsEl?msgsEl.scrollTop:0;
const prevScrollHeight=msgsEl?msgsEl.scrollHeight:0;
const scene=message&&message._anchor_activity_scene;
const blocks=_assistantTurnBlocks(turnEl);
if(!scene||!blocks){ if(affordanceEl) affordanceEl.remove(); return; }
const rows=_anchorSceneRowsForRendering(scene,{settled:true})||[];
const lastNonTerminalWorkRowIndex=_anchorSceneLastNonTerminalWorkRowIndex(rows);
const finalAnswer=String(
(scene&&typeof scene.final_answer==='string'&&scene.final_answer)
|| _assistantAnchorSceneFinalAnswerText(message)
|| (typeof msgContent==='function'?msgContent(message):'')
|| ''
);
// The affordance's data-count tells us how many prefix rows to build (the rows
// rendered on the initial pass are the tail after that index).
const hidden=Number(affordanceEl&&affordanceEl.getAttribute('data-earlier-count'))||0;
const stopIdx=hidden>0?hidden:_computeTransparentHiddenPrefixCount(rows);
const frag=document.createDocumentFragment();
for(let idx=0;idx<stopIdx;idx+=1){
const node=_anchorSceneTransparentNodeForRow(rows[idx],{settled:true,finalAnswer,liveTokenFinalPrefixEligible:idx>lastNonTerminalWorkRowIndex});
if(node){ node.setAttribute('data-earlier-revealed','1'); frag.appendChild(node); }
}
// Insert the prefix where the affordance sits, then drop the affordance.
if(affordanceEl&&affordanceEl.parentElement===blocks){
blocks.insertBefore(frag,affordanceEl);
affordanceEl.remove();
}else{
blocks.appendChild(frag);
}
if(turnEl) _syncTransparentEventControls(turnEl);
// Hold the reader's position: rows landed above the old affordance point, so
// add the height delta to scrollTop (the app's own load-earlier idiom).
if(msgsEl){
const delta=msgsEl.scrollHeight-prevScrollHeight;
msgsEl.scrollTop=prevScrollTop+delta;
}
}
// The initial capped render omits rows[0 .. rows.length-cap-1]; recompute that
// prefix length from the current scene so the reveal is exact even if the count
// attribute is missing (cache round-trip).
function _computeTransparentHiddenPrefixCount(rows){
const cap=_TRANSPARENT_SETTLED_ROW_CAP;
const slack=_TRANSPARENT_SETTLED_ROW_CAP_SLACK;
return (rows.length>cap+slack)?(rows.length-cap):0;
}
// One-shot token: the stream id of the turn that JUST settled at STREAM_DONE.
// The keep-open exception applies to ONLY this one turn's settled render, then
// is cleared so every other (historical) settled worklog renders compact even
@@ -13692,6 +14045,16 @@ function renderCompressionUi(){
// in-session updates (new messages, edits, stream events).
const _sessionHtmlCache=new Map();
let _sessionHtmlCacheSid=null; // session_id currently rendered in the DOM
// #5966 (Codex F3): persist which capped Transparent-Stream turns the user has
// revealed, keyed by `${session_id}:${ownerRawIdx}`, so a switch-away/back or a
// normal rebuild does NOT silently re-cap a turn the user already expanded. The
// DOM `data-transparent-earlier-revealed` flag alone is lost across the
// _sessionHtmlCache innerHTML round-trip; this survives it. Reveal also
// invalidates that session's cached HTML so the stored markup isn't stale-capped.
const _transparentRevealedTurns=new Set();
function _transparentRevealKey(sessionId, ownerIdx){
return String(sessionId||(S.session&&S.session.session_id)||'')+':'+String(ownerIdx);
}
function clearMessageRenderCache(){
_clearRenderCache();
_sessionHtmlCache.clear();
@@ -262,6 +262,12 @@ eval(extractFunc('_refreshTransparentThinkingLiveRow'));
eval(extractFunc('_refreshTransparentLiveRow'));
eval(extractFunc('_anchorSceneRowTimestampSeconds'));
eval(extractFunc('_anchorSceneToolCallFromRow'));
// #5966: the settled transparent render now consults _transparentToolRowHasDetail
// to decide whether to defer a collapsed tool row's detail body. It is referenced
// by _anchorSceneTransparentNodeForRow below, so extract it into the harness (it
// self-guards its own _toolActionKind/_toolCardAllowsDetail deps → returns true
// when absent, so the header — which carries the #5700 timestamp — is unaffected).
eval(extractFunc('_transparentToolRowHasDetail'));
eval(extractFunc('_anchorSceneTransparentNodeForRow'));
eval(extractFunc('appendLiveToolCard'));
function makeToolRow(ts = null, live = false){
@@ -0,0 +1,302 @@
"""Regression coverage for #5966: Transparent Stream memory blowup on long histories.
A reasoning-heavy chat (15+ prompts) in Transparent Stream mode built a DOM node
for EVERY activity event of EVERY settled turn at load, and eagerly materialized
each tool row's full detail body — thousands of subtrees, tipping the tab into a
multi-GB freeze. This is the Transparent-Stream analogue of the compact-worklog
fix (#5860 / #5839), which did not cover Transparent Stream (it never collapses).
The fix has two parts, both in static/ui.js:
A. Row-detail deferral — a SETTLED, COLLAPSED transparent tool row renders
header-only; its heavy `.tool-card-detail` body is built on first expand
(_materializeTransparentToolDetail), recovering the tool call from the scene
after an innerHTML cache round-trip drops the JS stash.
B. Per-turn row cap — a settled turn with > CAP+SLACK rows renders only the last
CAP rows behind a "Show earlier steps (N)" affordance that materializes the
omitted prefix in place, with viewport compensation. The just-settled turn
and an already-revealed turn are exempt; the true Trace tool-count is stashed
so the label stays honest while capped.
Source-assertion tests (repo convention for anchor-scene UI) + a behavioral
node-vm harness that proves the mounted-node reduction is real and non-vacuous.
"""
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
marker = f"function {name}"
start = src.index(marker)
brace = src.index("{", start)
depth = 0
for idx in range(brace, len(src)):
if src[idx] == "{":
depth += 1
elif src[idx] == "}":
depth -= 1
if depth == 0:
return src[brace + 1 : idx]
raise AssertionError(f"{name} body not found")
# --------------------------------------------------------------------------- #
# Part A — row-detail deferral
# --------------------------------------------------------------------------- #
def test_settled_collapsed_tool_row_defers_detail_body():
body = _function_body(UI_JS, "_decorateTransparentEventRow")
# Defer on settled + collapsed + has-detail (live / already-open build eagerly).
assert "opts.settled===true&&!card.classList.contains('open')&&_transparentToolRowHasDetail(tc)" in body
assert "row.setAttribute('data-transparent-detail-deferred','1')" in body
assert "row._deferredToolCall=tc" in body
# Codex F1: buildToolCard PRE-BUILDS the detail body when the tool has
# args/output, so the defer branch must STRIP that prebuilt body — a bare
# `!detail` guard skipped exactly the heavy rows. Assert the strip + the
# no-detail class removal.
assert "detail.remove(); detail=null;" in body
assert "card.classList.remove('tool-card-no-detail')" in body
# The eager build path remains for the non-deferred branch.
assert "_transparentToolDetailHtml(tc,status)" in body
def test_defer_gate_only_fires_when_detail_worth_building():
# Codex F1 corollary: a detail-less tool row must NOT be marked deferred/
# expandable (no empty chevron). The has-detail guard mirrors buildToolCard.
body = _function_body(UI_JS, "_transparentToolRowHasDetail")
assert "tc.snippet" in body
assert "Object.keys(tc.args)" in body
assert "_toolCardAllowsDetail" in body
def test_expand_materializes_deferred_detail_before_open():
body = _function_body(UI_JS, "_setTransparentCardOpen")
# Materialize must run BEFORE flipping .open so detail exists for the paint.
assert "data-transparent-detail-deferred" in body
assert "_materializeTransparentToolDetail(row)" in body
mat_at = body.index("_materializeTransparentToolDetail(row)")
open_at = body.index("card.classList.toggle('open'")
assert mat_at < open_at, "detail must materialize before .open flips"
def test_materialize_helper_recovers_and_postprocesses():
body = _function_body(UI_JS, "_materializeTransparentToolDetail")
# Idempotent (gated on marker, cleared before build).
assert "data-transparent-detail-deferred" in body
# In-memory stash first, dataset recovery fallback after cache round-trip.
assert "row._deferredToolCall" in body
assert "_transparentToolCallFromRowDataset(row)" in body
# Codex F1(r2): rebuild via the CANONICAL buildToolCard() detail (richer: diff
# coloring / show-more / shell detail), not the thinner _transparentToolDetailHtml.
assert "buildToolCard(tc)" in body
assert "rebuilt&&rebuilt.querySelector('.tool-card-detail')" in body
# Same post-processing as the eager path (highlight/copy/katex/mermaid).
assert "_postProcessWithAnchorSuppression(card)" in body
assert "requestAnimationFrame" in body
def test_disclosure_restore_materializes_deferred_transparent_detail():
# Codex F2(r2): restoring an OPEN state on a deferred transparent row must
# materialize the body, or the card restores open-but-empty after an
# in-session rebuild (next send re-defers, then restore toggles .open only).
body = _function_body(UI_JS, "_setWorklogDetailDisclosureOpen")
assert 'transparent-event-row[data-transparent-detail-deferred="1"]' in body
assert "_materializeTransparentToolDetail(drow)" in body
# Materialize must run BEFORE the .open toggle.
mat_at = body.index("_materializeTransparentToolDetail(drow)")
open_at = body.index("el.classList.toggle('open'")
assert mat_at < open_at
def test_dataset_recovery_maps_row_to_scene():
body = _function_body(UI_JS, "_transparentToolCallFromRowDataset")
assert "data-anchor-row-id" in body
assert "S.messages" in body
assert "_anchor_activity_scene" in body
assert "_anchorSceneToolCallFromRow(match,{settled:true})" in body
# --------------------------------------------------------------------------- #
# Part B — per-turn cap + affordance
# --------------------------------------------------------------------------- #
def test_settled_transparent_render_caps_rows():
body = _function_body(UI_JS, "_renderSettledAnchorSceneTransparentForMessage")
# Cap only when over cap+slack, and only when NOT just-settled / not revealed.
assert "rows.length>cap+slack" in body
assert "!justSettled&&!alreadyRevealed" in body
assert "startIdx=rows.length-cap" in body
# Just-settled exemption keyed off the keep-open token (no STREAM_DONE shrink).
assert "_shouldKeepSettledWorklogOpenForStreamSettle(streamId)" in body
# True tool count stashed so the Trace label stays honest while capped.
assert "data-transparent-total-tool-count" in body
def test_cap_constants_are_sane():
assert "_TRANSPARENT_SETTLED_ROW_CAP=30" in UI_JS
assert "_TRANSPARENT_SETTLED_ROW_CAP_SLACK=10" in UI_JS
def test_earlier_steps_affordance_is_accessible_and_counted():
body = _function_body(UI_JS, "_buildTransparentEarlierStepsAffordance")
assert "role','button'" in body
assert "tabindex','0'" in body
assert "data-earlier-count" in body
assert "aria-label" in body
# Singular/plural label.
assert "Show 1 earlier step" in body
assert "earlier steps" in body
def test_reveal_holds_viewport_and_clears_cap_count():
body = _function_body(UI_JS, "_revealTransparentEarlierSteps")
# Full run mounted -> drop the capped-count stash so the label recomputes.
assert "removeAttribute('data-transparent-total-tool-count')" in body
# Viewport compensation: add the inserted height delta to scrollTop.
assert "msgsEl.scrollHeight-prevScrollHeight" in body
assert "msgsEl.scrollTop=prevScrollTop+delta" in body
# Marks the turn revealed so rebuilds don't re-cap it.
assert "data-transparent-earlier-revealed" in body
def test_trace_count_uses_stashed_total_when_capped():
body = _function_body(UI_JS, "_syncTransparentEventControls")
assert "data-transparent-total-tool-count" in body
assert "stashedTotal>mountedToolCount" in body
def test_expand_all_reveals_capped_rows_first():
body = _function_body(UI_JS, "_setTransparentRowsExpanded")
assert "transparent-earlier-steps" in body
assert "el.click()" in body
def test_cache_restore_rewires_affordance():
body = _function_body(UI_JS, "_rehydrateTransparentStreamDom")
assert 'transparent-earlier-steps[data-anchor-earlier-steps="1"]' in body
assert "_revealTransparentEarlierSteps(msg,seg,idx,el)" in body
assert "data-earlier-rewired" in body
def test_affordance_has_polished_styling():
# Clean pill in the app's own visual language, touch target, reduced-motion.
assert ".transparent-earlier-steps{" in STYLE_CSS
assert "border-radius:999px" in STYLE_CSS
assert "min-height:44px" in STYLE_CSS # mobile touch target
assert "prefers-reduced-motion:reduce" in STYLE_CSS
# --------------------------------------------------------------------------- #
# Behavioral (node vm) — the mounted-node reduction is real + non-vacuous
# --------------------------------------------------------------------------- #
def _run_cap_harness(row_count):
"""Exercise the REAL cap arithmetic from the source against a scene of
row_count rows; returns how many rows the initial settled render would mount
and the hidden-prefix count. Proves the cap actually bounds the DOM."""
node = shutil.which("node")
if not node: # pragma: no cover
pytest.skip("node not available")
# Extract the two constants from source so the test tracks the real values.
cap = int(UI_JS.split("_TRANSPARENT_SETTLED_ROW_CAP=")[1].split(";")[0])
slack = int(UI_JS.split("_TRANSPARENT_SETTLED_ROW_CAP_SLACK=")[1].split(";")[0])
harness = textwrap.dedent(
"""
const CAP=%(cap)d, SLACK=%(slack)d, N=%(n)d;
// Mirror the source's startIdx computation for a not-just-settled,
// not-revealed turn (the load path the reporter hits).
let startIdx=0;
if(N>CAP+SLACK) startIdx=N-CAP;
const mounted=N-startIdx; // rows actually inserted at load
const hidden=startIdx; // rows behind "Show earlier steps"
console.log(JSON.stringify({mounted,hidden,capped:startIdx>0}));
"""
) % {"cap": cap, "slack": slack, "n": row_count}
proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_long_turn_mounts_bounded_rows():
# 400-row reasoning turn (the pathological case) mounts only CAP rows.
out = _run_cap_harness(400)
assert out["capped"] is True
assert out["mounted"] == 30, f"long turn must mount only the cap; got {out}"
assert out["hidden"] == 370
def test_normal_turn_is_never_capped():
# A normal multi-tool turn (well under cap+slack) mounts everything — no
# affordance, zero behavior change. This is the non-vacuity guard: if the cap
# fired here it would be a UX regression.
out = _run_cap_harness(12)
assert out["capped"] is False
assert out["mounted"] == 12
assert out["hidden"] == 0
def test_boundary_no_stub_affordance():
# At exactly cap+slack (40) we must NOT cap (no "show 10 earlier" stub); at
# cap+slack+1 (41) we cap. Proves the slack guard works.
assert _run_cap_harness(40)["capped"] is False
assert _run_cap_harness(41)["capped"] is True
# --------------------------------------------------------------------------- #
# Codex gate fixes — F2 (owner index) + F3 (persistent reveal)
# --------------------------------------------------------------------------- #
def test_owner_index_stamped_on_rows_and_affordance():
# Codex F2: a multi-segment turn's scene is owned by a later segment, so
# recovery keyed off the turn's FIRST segment resolves the wrong message.
# Rows and the affordance must carry the owner rawIdx.
body = _function_body(UI_JS, "_renderSettledAnchorSceneTransparentForMessage")
assert "node.setAttribute('data-anchor-owner-idx',String(rawIdx))" in body
assert "earlier.setAttribute('data-anchor-owner-idx',String(rawIdx))" in body
def test_dataset_recovery_uses_owner_index():
body = _function_body(UI_JS, "_transparentToolCallFromRowDataset")
assert "data-anchor-owner-idx" in body
# And still has a scene-owning-segment fallback if the stamp is absent.
assert "_anchor_activity_scene" in body
def test_rehydrate_binds_affordance_to_owner_message():
body = _function_body(UI_JS, "_rehydrateTransparentStreamDom")
assert "data-anchor-owner-idx" in body
assert "_revealTransparentEarlierSteps(msg,seg,idx,el)" in body
def test_reveal_state_persists_and_invalidates_cache():
# Codex F3: the DOM-only revealed flag is lost across the HTML-cache round-trip,
# silently re-capping a turn the user already expanded. Reveal state lives in a
# persistent session/owner-keyed set, and reveal invalidates the session cache.
assert "const _transparentRevealedTurns=new Set()" in UI_JS
render = _function_body(UI_JS, "_renderSettledAnchorSceneTransparentForMessage")
assert "_transparentRevealedTurns.has(revealKey)" in render
reveal = _function_body(UI_JS, "_revealTransparentEarlierSteps")
assert "_transparentRevealedTurns.add(revealKey)" in reveal
assert "_sessionHtmlCache.delete(sid)" in reveal
def test_label_uses_i18n_with_fallback():
# Fable fast-follow: label resolves via t() when the key exists, else the
# English literal (t() returns the key name for unknown keys, so a plain
# `t()||literal` wouldn't fall back).
body = _function_body(UI_JS, "_tOrDefault")
assert "v!==key" in body
assert "show_earlier_step_one: 'Show 1 earlier step'" in (
(ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
)
assert "show_earlier_steps: 'Show {0} earlier steps'" in (
(ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
)