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${li('chevron-up',13)}${esc(label)}`;
+ 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;idxlastNonTerminalWorkRowIndex});
+ 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();
diff --git a/tests/test_issue5700_worklog_event_timestamps.py b/tests/test_issue5700_worklog_event_timestamps.py
index 5059d129..18513645 100644
--- a/tests/test_issue5700_worklog_event_timestamps.py
+++ b/tests/test_issue5700_worklog_event_timestamps.py
@@ -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){
diff --git a/tests/test_issue5966_transparent_stream_lazy_dom.py b/tests/test_issue5966_transparent_stream_lazy_dom.py
new file mode 100644
index 00000000..d9aafd8d
--- /dev/null
+++ b/tests/test_issue5966_transparent_stream_lazy_dom.py
@@ -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")
+ )