fix(#4024): trifecta UX + correctness pass — rehydrate live-restore, thinking dedup, quiet thinking rows, legible failures, skin-proof reset, compact args, flat output, live-only fade/anim

Functional (Codex+Opus trifecta findings):
- C1: restoreLiveTurnHtmlForSession now calls _rehydrateTransparentStreamDom (controls were dead after active-session live-turn restore).
- O-Bug1: per-turn echoed-thinking dedup in the transparent settled path (mirrors the compact seenReasons), fixes duplicate out-of-order Thinking rows.
- O-Bug2: tool completion preserves the row's open state + Full/Output tab.
- O-Edge: settled/reloaded done===false tools show 'Interrupted' (not a permanent Running shimmer); progress bar clears for Interrupted too.

Visual (trifecta CSS):
- V1: reset .thinking-card to flat/transparent (was the heaviest object in the stream).
- V2: failed=error color + left border, running/interrupted legible (was an invisible muted badge).
- V3: scoped !important reset beats the 6 per-skin .tool-card re-card rules.
- V4: inline key:value args (was a tall stacked form).
- V5/V7: output pre flattened to a quiet left-rail, max-height:none (one expanded zone, no nested scroll).
- V6: drop redundant tool/status pseudo-args from the detail body.
- V8: row fading gated to the live turn only + floor raised for WCAG; settled history stays full-opacity.
- V9: entrance animation scoped to #liveAssistantTurn (was replaying on every renderMessages).
- V10: larger mobile header tap targets + active detail-tab underline.

O-Bug3 (live thinking overwrite when no seq) intentionally deferred — documented follow-up; settled render is already correct and a live heuristic risks a streaming regression.
This commit is contained in:
nesquena-hermes
2026-06-13 01:08:41 +00:00
parent 92f7a24448
commit ed3ac13fc5
6 changed files with 137 additions and 38 deletions

View File

@@ -3175,9 +3175,12 @@ body.resizing .sidebar{transition:none!important;}
background:transparent;
overflow:hidden;
box-shadow:none;
animation:transparent-event-enter .18s ease-out both;
transition:background .18s ease,border-color .18s ease,opacity .22s ease;
}
/* Only animate entrance for rows in the LIVE turn — putting the animation on the
base row class replayed the whole transcript's shimmer on every renderMessages
rebuild. (Trifecta finding V9.) */
#liveAssistantTurn .transparent-event-row{animation:transparent-event-enter .18s ease-out both;}
.transparent-event-row[data-event-type="thinking"]{
background:transparent;
border-color:transparent;
@@ -3193,10 +3196,29 @@ body.resizing .sidebar{transition:none!important;}
border-color:var(--border-subtle);
background:color-mix(in srgb,var(--surface-subtle) 48%,transparent);
}
.transparent-event-row .tool-card{
border:0;
border-radius:0;
background:transparent;
/* Transparent Stream: both tool AND thinking inner cards must reset to a flat,
transparent row. The thinking-card previously kept its base accent-bg/border/
radius/msg-rail chrome (it was the heaviest object in the stream — Frank's
complaint, trifecta V1). The scoped !important also beats the per-skin
`:root[data-skin] .tool-card{...!important}` rules (graphite/sienna/catppuccin/
hepburn/neon/verdigris) that otherwise re-card every row (trifecta V3). */
.transparent-event-row .tool-card,
.transparent-event-row .tool-card:hover,
.transparent-event-row .tool-card-running,
.transparent-event-row .thinking-card,
.transparent-event-row .thinking-card:hover,
:root[data-skin] .transparent-event-row .tool-card,
:root[data-skin] .transparent-event-row .tool-card:hover,
:root[data-skin] .transparent-event-row .tool-card-running,
:root[data-skin] .transparent-event-row .thinking-card,
:root[data-skin] .transparent-event-row .thinking-card:hover{
border:0!important;
border-left:0!important;
border-radius:0!important;
background:transparent!important;
box-shadow:none!important;
margin:0!important;
max-width:none!important;
overflow:visible;
}
.transparent-event-row .tool-card-header,
@@ -3285,6 +3307,19 @@ body.resizing .sidebar{transition:none!important;}
border-radius:6px;
background:transparent;
}
/* Failed / running / interrupted must be legible — a failure in a long trace was
previously invisible (only a muted .64 badge), a regression vs the old card's
accent. (Trifecta finding V2.) */
.transparent-event-status[data-status="failed"]{color:var(--error,#e5484d);opacity:1;font-weight:600;}
.transparent-event-status[data-status="running"]{color:var(--accent-text);opacity:.95;font-weight:600;}
.transparent-event-status[data-status="interrupted"]{color:var(--warning,var(--muted));opacity:.95;font-weight:600;}
.transparent-event-row[data-event-status="Failed"]{
border-left-color:color-mix(in srgb,var(--error,#e5484d) 55%,transparent)!important;
}
.transparent-event-row[data-event-status="Failed"]:hover,
.transparent-event-row[data-event-status="Failed"][data-expanded="1"]{
border-left-color:color-mix(in srgb,var(--error,#e5484d) 70%,transparent)!important;
}
.transparent-event-copy{
border:0;
background:transparent;
@@ -3360,8 +3395,8 @@ body.resizing .sidebar{transition:none!important;}
color:var(--muted);
font-size:10.5px;
}
.transparent-detail-mode{cursor:pointer;opacity:.72;transition:opacity .18s ease,color .18s ease;}
.transparent-detail-mode.active{color:var(--text);opacity:1;font-weight:600;}
.transparent-detail-mode{cursor:pointer;opacity:.72;padding:2px 4px 3px;transition:opacity .18s ease,color .18s ease,box-shadow .18s ease;}
.transparent-detail-mode.active{color:var(--text);opacity:1;font-weight:600;box-shadow:inset 0 -1px 0 var(--accent);}
.transparent-event-row .tool-card-detail,
.transparent-event-row .thinking-card-body{
display:block;
@@ -3391,18 +3426,26 @@ body.resizing .sidebar{transition:none!important;}
.transparent-event-row .tool-card-detail > *,
.transparent-event-row .thinking-card-body > *{min-height:0;}
.transparent-event-row .tool-card-args,
.transparent-event-row .tool-card-result{padding:5px 7px;}
.transparent-event-row .tool-card-result{padding:4px 7px 4px 9px;}
.transparent-event-row .tool-card-detail[data-transparent-detail-mode="output"] .tool-card-args{display:none;}
.transparent-event-row .tool-card-args{margin-bottom:0;}
.transparent-event-row .tool-arg-pair{display:flex;flex-direction:column;gap:1px;padding:3px 0;border-bottom:1px solid color-mix(in srgb,var(--border-subtle) 72%,transparent);}
.transparent-event-row .tool-arg-pair:last-child{border-bottom:0;}
.transparent-event-row .tool-arg-key{color:var(--muted);font-size:10px;font-family:var(--font-mono);display:block;margin-bottom:0;letter-spacing:.01em;}
.transparent-event-row .tool-arg-val{font-family:var(--font-mono);font-size:var(--message-code-font-size);color:var(--text);line-height:1.5;}
/* Compact inline args: key beside value (not stacked), no per-pair rules — the
stacked column layout made offset=735 consume two ruled lines and read like a
form. Restores the old .tool-card-args density. (Trifecta V4/V5.) */
.transparent-event-row .tool-arg-pair{display:flex;flex-direction:row;align-items:baseline;gap:8px;padding:1px 0;border-bottom:0;}
.transparent-event-row .tool-arg-key{flex:0 0 auto;min-width:54px;color:var(--muted);font-size:10px;font-family:var(--font-mono);letter-spacing:.01em;opacity:.85;}
.transparent-event-row .tool-arg-val{flex:1 1 auto;min-width:0;font-family:var(--font-mono);font-size:var(--message-code-font-size);color:var(--text);line-height:1.5;white-space:pre-wrap;word-break:break-word;}
/* Flatten the output box into a quiet left-rail instead of a second bordered
card, so args + output read as ONE expanded zone, not two disjoint cards.
max-height:none kills the nested scrollbar — the detail's own scroll owns it. */
.transparent-event-row .tool-card-result pre{
background:var(--code-bg);
border:1px solid var(--border-subtle);
border-radius:6px;
padding:5px 7px;
background:transparent;
border:0;
border-left:2px solid var(--border-subtle);
border-radius:0;
padding:2px 0 2px 9px;
margin:0;
max-height:none;
color:var(--muted);
line-height:1.5;
}
@@ -3418,6 +3461,11 @@ body.resizing .sidebar{transition:none!important;}
@media(max-width:600px){
.transparent-event-controls,
.transparent-event-row{margin-left:0;width:100%;max-width:none;}
/* Larger header tap targets on mobile (the 19px desktop header is a poor
touch target). (Trifecta finding V10.) */
.transparent-event-row .tool-card-header,
.transparent-event-row .thinking-card-header{min-height:30px;padding:5px 8px;}
.transparent-detail-mode{padding:4px 6px 5px;}
}
@keyframes transparent-event-enter{
from{opacity:0;transform:translateY(2px);}
@@ -3425,11 +3473,12 @@ body.resizing .sidebar{transition:none!important;}
}
/* ── Old-event fading (transparent mode: medium → low) ─────────────── */
.transparent-event-row[data-transparent-fade="1"]{opacity:.82;}
.transparent-event-row[data-transparent-fade="2"]{opacity:.66;}
.transparent-event-row[data-transparent-fade="3"]{opacity:.52;}
.transparent-event-row[data-transparent-fade="4"]{opacity:.4;}
.transparent-event-row[data-transparent-fade="5"]{opacity:.32;}
/* ── Old-event fading (transparent mode, LIVE turn only — gated in JS) ───── */
.transparent-event-row[data-transparent-fade="1"]{opacity:.86;}
.transparent-event-row[data-transparent-fade="2"]{opacity:.76;}
.transparent-event-row[data-transparent-fade="3"]{opacity:.68;}
.transparent-event-row[data-transparent-fade="4"]{opacity:.6;}
.transparent-event-row[data-transparent-fade="5"]{opacity:.54;}
.transparent-event-row[data-transparent-fade]{transition:opacity .22s ease;}
.transparent-event-row[data-transparent-fade]:hover{opacity:1;}

View File

@@ -5346,6 +5346,14 @@ function restoreLiveTurnHtmlForSession(sid){
_mergeRestoredLiveAssistantSegment(restored, existing);
if(existing) existing.replaceWith(restored);
else inner.appendChild(restored);
// Transparent Stream: liveTurnHtml is restored via template.innerHTML, which
// drops the property-bound onclick/onkeydown handlers wired by
// _wireTransparentHeaderToggle / _attachCopyButton / _syncTransparentEventControls /
// _wireTransparentTurnToggle. The settled cache fast-path re-runs the rehydrate;
// this active-session live-turn restore path must too, or row toggles, copy
// buttons, expand/collapse, and the turn chevron silently stop working after a
// session-switch/reconnect restore. (Codex trifecta finding C1.)
if(typeof _rehydrateTransparentStreamDom==='function') _rehydrateTransparentStreamDom(restored);
if(typeof normalizeLiveActivityGroupPlacement==='function') normalizeLiveActivityGroupPlacement(restored);
const liveGroup=restored.querySelector('.tool-call-group[data-live-tool-call-group="1"]');
if(liveGroup&&typeof _startActivityElapsedTimer==='function') _startActivityElapsedTimer(liveGroup);
@@ -6619,9 +6627,9 @@ function _transparentEventPreview(text){
if(!clean) return '';
return clean.length>180?`${clean.slice(0,177)}...`:clean;
}
function _transparentToolStatus(tc){
function _transparentToolStatus(tc, settled){
if(tc&&tc.is_error) return 'Failed';
if(tc&&tc.done===false) return 'Running';
if(tc&&tc.done===false) return settled?'Interrupted':'Running';
return 'Completed';
}
function _copyEventToClipboard(row){
@@ -6763,10 +6771,10 @@ function _wireTransparentHeaderToggle(header){
function _transparentToolDetailHtml(tc, status){
const args=tc&&tc.args&&typeof tc.args==='object'?tc.args:{};
const argEntries=Object.entries(args);
const meta=[
['tool', tc&&tc.name?tc.name:'tool'],
['status', status||_transparentToolStatus(tc)],
];
// The tool name is already shown in the row header and the status is shown as
// a badge, so don't repeat them as pseudo-args in the body. Only surface a
// duration meta when present. (Trifecta finding V6 — reduce redundancy.)
const meta=[];
if(tc&&tc.duration!==undefined&&tc.duration!==null) meta.push(['duration', String(tc.duration)]);
const preview=String((tc&&(tc.snippet||tc.preview||tc.result||tc.output))||'').trim();
const argHtml=[...meta,...argEntries].map(([k,v])=>`<div class="tool-arg-pair"><span class="tool-arg-key">${esc(String(k))}</span><span class="tool-arg-val">${esc(typeof v==='string'?v:JSON.stringify(v,null,2))}</span></div>`).join('');
@@ -6891,7 +6899,7 @@ function _decorateTransparentEventRow(row, opts){
// Update the 3D progress bar to reflect the new status.
const progress=card.querySelector('.transparent-event-progress');
if(progress){
if(status==='Completed'||status==='Failed'){
if(status==='Completed'||status==='Failed'||status==='Interrupted'){
progress.removeAttribute('data-progress-running');
progress.setAttribute('data-progress-percent','100%');
progress.style.setProperty('--transparent-progress-percent','100%');
@@ -7061,9 +7069,19 @@ function _wireTransparentTurnToggle(turn){
// at 0.32 so labels stay readable.
function _applyTransparentRowFading(turn){
if(!turn||!isTransparentStream()) return;
// Recency-fading only makes sense on the LIVE turn (draw the eye to the most
// recent activity). On settled/historical turns it permanently dims the trace
// below readable contrast (floor .32) — the opposite of a transparent record.
// So clear any fade on non-live turns and only fade the live turn.
// (Trifecta finding V8.)
const blocks=_assistantTurnBlocks(turn);
if(!blocks) return;
const rows=Array.from(blocks.querySelectorAll(':scope > .transparent-event-row'));
const isLive=turn.id==='liveAssistantTurn'||turn.getAttribute('data-live-assistant-turn')==='1';
if(!isLive){
rows.forEach(row=>row.removeAttribute('data-transparent-fade'));
return;
}
const total=rows.length;
for(let i=0;i<total;i++){
const row=rows[i];
@@ -9396,6 +9414,11 @@ function renderMessages(options){
}else{
// ── transparent_stream path: individual expandable event rows ──
const transparentInsertCursors=new Map();
// Per-turn dedup of echoed thinking text — mirrors the compact-worklog
// path's `seenReasons` Set (the transparent branch previously had none,
// so the same echoed reasoning rendered twice, once out of chronological
// position). Keyed by the assistant turn element. (Trifecta finding O-Bug1.)
const transparentSeenThinking=new Map();
for(const entry of activityOrder){
const event={
...entry,
@@ -9422,22 +9445,32 @@ function renderMessages(options){
else blocks.appendChild(row);
};
if(event.thinkingText){
const thinkingRow=_decorateTransparentEventRow(_thinkingActivityNode(event.thinkingText,false),{
type:'thinking',
text:event.thinkingText,
preview:event.thinkingText,
segmentSeq,
burstId,
});
if(!anchorIsWorklogSource) insertBeforeAnchor(thinkingRow);
else insertAfterCursor(thinkingRow);
const _thinkKey=typeof _normalizeThinkingEchoCompare==='function'
? _normalizeThinkingEchoCompare(event.thinkingText)
: String(event.thinkingText).trim();
let _seen=transparentSeenThinking.get(anchorTurn);
if(!_seen){_seen=new Set();transparentSeenThinking.set(anchorTurn,_seen);}
if(_thinkKey&&_seen.has(_thinkKey)){
// Echoed reasoning already rendered for this turn — skip the duplicate.
}else{
if(_thinkKey)_seen.add(_thinkKey);
const thinkingRow=_decorateTransparentEventRow(_thinkingActivityNode(event.thinkingText,false),{
type:'thinking',
text:event.thinkingText,
preview:event.thinkingText,
segmentSeq,
burstId,
});
if(!anchorIsWorklogSource) insertBeforeAnchor(thinkingRow);
else insertAfterCursor(thinkingRow);
}
}
for(const toolCall of cards){
event.toolCall=toolCall;
const toolRow=_decorateTransparentEventRow(buildToolCard(event.toolCall),{
type:'tool',
name:event.toolCall&&event.toolCall.name,
status:_transparentToolStatus(event.toolCall),
status:_transparentToolStatus(event.toolCall,true),
toolCall:event.toolCall,
segmentSeq,
burstId,
@@ -10359,6 +10392,23 @@ function appendLiveToolCard(tc){
burstId,
});
replacement.dataset.liveTid=tid;
// Preserve the user's expand state + detail tab across tool completion:
// the running row is rebuilt fresh on toolComplete, which would otherwise
// snap an expanded row shut and reset its Full/Output tab. (Trifecta O-Bug2.)
try{
const _oldCard=existing.querySelector('.tool-card,.thinking-card');
const _newCard=replacement.querySelector('.tool-card,.thinking-card');
if(_oldCard&&_newCard&&_oldCard.classList.contains('open')){
_setTransparentCardOpen(_newCard,true);
const _oldDetail=existing.querySelector('.tool-card-detail');
const _newDetail=replacement.querySelector('.tool-card-detail');
const _mode=_oldDetail&&_oldDetail.getAttribute('data-transparent-detail-mode');
if(_newDetail&&_mode){
const _tab=_newDetail.querySelector(`.transparent-detail-mode[data-mode="${_mode}"]`);
if(_tab) _setTransparentDetailMode(_tab,_mode);
}
}
}catch(_){ /* non-fatal: completion still renders, just collapsed */ }
existing.replaceWith(replacement);
_syncTransparentEventControls(turn);
_moveLiveRunStatusToTurnEnd();