* 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:
6 files changed
+754
-7
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
)
|
||||
Reference in new issue
Block a user