From 9643cdd5c1ac7c3fd3228224896c70a9f2bd4ba8 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 11 Jul 2026 21:56:20 -0700 Subject: [PATCH] Release exp-v0.52.50: transparent-stream lazy settled DOM + per-turn cap (#5974, #5966) (#5981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Nathan Esquenazi --- CHANGELOG.md | 2 + static/i18n.js | 30 ++ static/style.css | 46 ++- static/ui.js | 375 +++++++++++++++++- ...test_issue5700_worklog_event_timestamps.py | 6 + ...t_issue5966_transparent_stream_lazy_dom.py | 302 ++++++++++++++ 6 files changed, 754 insertions(+), 7 deletions(-) create mode 100644 tests/test_issue5966_transparent_stream_lazy_dom.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3147dc82..01cff280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/static/i18n.js b/static/i18n.js index ec49e195..5d398924 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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?', diff --git a/static/style.css b/static/style.css index 31e517fb..0149dadc 100644 --- a/static/style.css +++ b/static/style.css @@ -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; diff --git a/static/ui.js b/static/ui.js index 58a6ec3f..2e6b8f09 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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=`FullOutput`; + 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)&&idxcap+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