"""Regression tests for issue #3820 chat activity display mode."""

import json
import shutil
import subprocess
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
NODE = shutil.which("node")

_EXTRACT_FUNC_JS = """
function extractFunc(name){
  const start = src.indexOf('function ' + name);
  if(start === -1) throw new Error(name + ' not found');
  const params = src.indexOf('(', start);
  let depth = 0, close = -1;
  for(let i=params; i<src.length; i++){
    if(src[i] === '(') depth++;
    else if(src[i] === ')'){
      depth--;
      if(depth === 0){ close = i; break; }
    }
  }
  const brace = src.indexOf('{', close);
  depth = 0;
  for(let i=brace; i<src.length; i++){
    if(src[i] === '{') depth++;
    else if(src[i] === '}'){
      depth--;
      if(depth === 0) return src.slice(start, i + 1);
    }
  }
  throw new Error(name + ' body did not close');
}
""".strip()


def _transparentEventCountLabelBlock(ui_js):
    """Return the body of `_transparentEventCountLabel` as a string slice."""
    start = ui_js.index("function _transparentEventCountLabel")
    end = ui_js.index("\nfunction ", start + 1)
    return ui_js[start:end]


def _run_node_script(script):
    assert NODE, "node is required for chat activity display mode behavior tests"
    result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False)
    assert result.returncode == 0, result.stderr
    return json.loads(result.stdout)


def test_chat_activity_display_mode_defaults_to_compact_worklog(monkeypatch, tmp_path):
    import api.config as config

    settings_path = tmp_path / "settings.json"
    monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)

    loaded = config.load_settings()

    assert loaded["chat_activity_display_mode"] == "compact_worklog"


def test_chat_activity_display_mode_persists_transparent_stream_hide_all_activity_and_rejects_invalid(monkeypatch, tmp_path):
    import api.config as config

    settings_path = tmp_path / "settings.json"
    monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)

    saved = config.save_settings({"chat_activity_display_mode": "transparent_stream"})
    assert saved["chat_activity_display_mode"] == "transparent_stream"
    assert json.loads(settings_path.read_text(encoding="utf-8"))["chat_activity_display_mode"] == "transparent_stream"

    saved = config.save_settings({"chat_activity_display_mode": "hide_all_activity"})
    assert saved["chat_activity_display_mode"] == "hide_all_activity"
    assert json.loads(settings_path.read_text(encoding="utf-8"))["chat_activity_display_mode"] == "hide_all_activity"

    saved = config.save_settings({"chat_activity_display_mode": "invalid_mode"})
    assert saved["chat_activity_display_mode"] == "hide_all_activity"
    assert json.loads(settings_path.read_text(encoding="utf-8"))["chat_activity_display_mode"] == "hide_all_activity"


def test_transparent_stream_event_timestamps_default_true_and_persist_boolean(monkeypatch, tmp_path):
    import api.config as config

    settings_path = tmp_path / "settings.json"
    monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)

    loaded = config.load_settings()
    assert loaded["transparent_stream_event_timestamps"] is True

    saved = config.save_settings({"transparent_stream_event_timestamps": False})
    assert saved["transparent_stream_event_timestamps"] is False
    assert json.loads(settings_path.read_text(encoding="utf-8"))["transparent_stream_event_timestamps"] is False


def test_chat_activity_display_mode_supports_three_values():
    assert "function chatActivityMode()" in UI_JS
    assert "function isTransparentStream()" in UI_JS
    assert "function isFinalAnswerOnlyMode()" in UI_JS
    assert "function isCompactWorklogMode()" in UI_JS
    assert "chatActivityMode()==='transparent_stream'" in UI_JS
    assert "chatActivityMode()==='hide_all_activity'" in UI_JS
    assert "window._chatActivityDisplayMode" in BOOT_JS
    assert "window._chatActivityDisplayMode" in PANELS_JS
    assert "window._simplifiedToolCalling=true" in BOOT_JS
    assert "window._simplifiedToolCalling=true" in PANELS_JS


def test_chat_activity_display_mode_picker_uses_three_desktop_columns():
    assert INDEX_HTML.count('class="chat-activity-mode-btn') == 3
    assert "#mainSettings .chat-activity-mode-toggle{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));" in STYLE_CSS
    assert "#mainSettings .chat-activity-mode-toggle{grid-template-columns:1fr;}" in STYLE_CSS


def test_chat_activity_display_mode_resolver_and_live_early_out():
    script = f"""
const fs = require('fs');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8');
{_EXTRACT_FUNC_JS}
global.window = {{
  _chatActivityDisplayMode: 'hide_all_activity',
  _transparentStream: false,
}};
global.isSimplifiedToolCalling = () => true;
eval(extractFunc('chatActivityMode'));
eval(extractFunc('isTransparentStream'));
eval(extractFunc('isFinalAnswerOnlyMode'));
eval(extractFunc('isCompactWorklogMode'));
const resolverResults = [
  chatActivityMode(),
  isTransparentStream(),
  isFinalAnswerOnlyMode(),
  isCompactWorklogMode(),
];
window._chatActivityDisplayMode = 'transparent_stream';
window._transparentStream = false;
resolverResults.push(
  chatActivityMode(),
  isTransparentStream(),
  isFinalAnswerOnlyMode(),
  isCompactWorklogMode(),
);
window._chatActivityDisplayMode = 'bogus';
window._transparentStream = true;
resolverResults.push(
  chatActivityMode(),
  isTransparentStream(),
  isFinalAnswerOnlyMode(),
  isCompactWorklogMode(),
);
process.stdout.write(JSON.stringify({{resolverResults}}));
"""
    result = _run_node_script(script)

    assert result["resolverResults"] == [
        "hide_all_activity",
        False,
        True,
        False,
        "transparent_stream",
        True,
        False,
        False,
        "transparent_stream",
        True,
        False,
        False,
    ]


def test_chat_activity_display_mode_explicit_modes_are_preserved_by_render_helpers():
    script = f"""
const fs = require('fs');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8');
{_EXTRACT_FUNC_JS}
let captured = [];
function _projectLiveAnchorActivitySceneForStream(streamId, mode){{
  return {{version:'activity_scene_v1', mode, activity_rows:[]}};
}}
function renderLiveAnchorActivityScene(streamId, scene, opts){{
  captured.push({{streamId, sceneMode: scene.mode, optMode: opts.mode, sessionId: opts.sessionId}});
  return false;
}}
let activeMode = 'compact_worklog';
global.chatActivityMode = () => activeMode;
eval(extractFunc('_renderLiveAnchorActivitySceneForStream'));
eval(extractFunc('_renderLiveAnchorActivitySceneSnapshotForStream'));
const helperResult = _renderLiveAnchorActivitySceneForStream('stream-1', 'sid-1', {{mode:'hide_all_activity'}});
const snapshotResult = _renderLiveAnchorActivitySceneSnapshotForStream(
  'stream-2',
  {{version:'activity_scene_v1', activity_rows:[]}},
  'sid-2',
  {{mode:'transparent_stream'}},
);
activeMode = 'hide_all_activity';
const compactOverrideResult = _renderLiveAnchorActivitySceneForStream(
  'stream-3',
  'sid-3',
  {{mode:'compact_worklog'}},
);
process.stdout.write(JSON.stringify({{helperResult, snapshotResult, compactOverrideResult, captured}}));
"""
    result = _run_node_script(script)

    assert result["helperResult"] is False
    assert result["snapshotResult"] is False
    assert result["compactOverrideResult"] is False
    assert result["captured"] == [
        {"streamId": "stream-1", "sceneMode": "hide_all_activity", "optMode": "hide_all_activity", "sessionId": "sid-1"},
        {"streamId": "stream-2", "optMode": "transparent_stream", "sessionId": "sid-2"},
        {"streamId": "stream-3", "sceneMode": "hide_all_activity", "optMode": "compact_worklog", "sessionId": "sid-3"},
    ]


def test_chat_activity_display_mode_live_renderer_early_outs_in_hide_all_mode():
    script = f"""
const fs = require('fs');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8');
{_EXTRACT_FUNC_JS}
global.window = {{
  _chatActivityDisplayMode: 'hide_all_activity',
  _transparentStream: false,
}};
global.S = {{ session: {{ session_id: 'sid-1' }}, activeStreamId: 'stream-1' }};
global.isSimplifiedToolCalling = () => true;
global.$ = () => {{ throw new Error('unexpected DOM access'); }};
eval(extractFunc('chatActivityMode'));
eval(extractFunc('isTransparentStream'));
eval(extractFunc('isFinalAnswerOnlyMode'));
eval(extractFunc('isCompactWorklogMode'));
eval(extractFunc('renderLiveAnchorActivityScene'));
const result = renderLiveAnchorActivityScene(
  'stream-1',
  {{version:'activity_scene_v1', activity_rows:[{{role:'tool'}}]}},
  {{sessionId:'sid-1', mode:'hide_all_activity'}},
);
process.stdout.write(JSON.stringify({{result, mode: chatActivityMode(), finalOnly: isFinalAnswerOnlyMode(), compact: isCompactWorklogMode()}}));
"""
    result = _run_node_script(script)

    assert result == {"result": False, "mode": "hide_all_activity", "finalOnly": True, "compact": False}


def test_chat_activity_display_mode_anchor_scene_preserves_hide_all_mode():
    script = f"""
const fs = require('fs');
const vm = require('vm');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "assistant_turn_anchors.js"))}, 'utf8');
const sandbox = {{window:{{}}}};
vm.createContext(sandbox);
vm.runInContext(src, sandbox, {{filename:'assistant_turn_anchors.js'}});
const api = sandbox.window.HermesAssistantTurnAnchors;
const empty = api.projectAssistantTurnAnchorActivityScene(null, {{mode:'hide_all_activity'}});
const registry = api.createAssistantTurnAnchorRegistry({{session_id:'sid-1', turn_id:'turn-1'}});
api.applyAssistantTurnAnchorSourceEvents(registry, [
  {{event:'tool', payload:{{tool_call_id:'tool-1', name:'terminal'}}, event_id:'run-1:1', seq:1}},
  {{source_type:'settled_message', payload:{{role:'assistant', id:'message-final', content:'final answer'}}}},
], {{run_id:'run-1', stream_id:'stream-1'}});
const scene = api.projectAssistantTurnAnchorActivityScene(registry, {{mode:'hide_all_activity'}});
const snapshot = api.createAssistantTurnAnchorRendererSnapshot({{mode:'hide_all_activity', rows:[]}});
process.stdout.write(JSON.stringify({{emptyMode:empty.mode, sceneMode:scene.mode, snapshotMode:snapshot.mode}}));
"""
    result = _run_node_script(script)

    assert result == {
        "emptyMode": "hide_all_activity",
        "sceneMode": "hide_all_activity",
        "snapshotMode": "hide_all_activity",
    }


def test_chat_activity_display_mode_legacy_live_fallbacks_do_not_render_activity():
    script = f"""
const fs = require('fs');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8');
{_EXTRACT_FUNC_JS}
global.S = {{ session: {{ session_id: 'sid-1' }}, activeStreamId: 'stream-1' }};
global.isFinalAnswerOnlyMode = () => true;
global.$ = () => {{ throw new Error('unexpected DOM access'); }};
eval(extractFunc('appendLiveToolCard'));
eval(extractFunc('ensureLiveWorklogShell'));
const appendResult = appendLiveToolCard({{tid:'tool-1'}}, {{sessionId:'sid-1', streamId:'stream-1'}});
const shellResult = ensureLiveWorklogShell();
process.stdout.write(JSON.stringify({{appendResult: appendResult === undefined, shellResult}}));
"""
    result = _run_node_script(script)

    assert result == {"appendResult": True, "shellResult": None}


def test_chat_activity_display_mode_legacy_thinking_fallback_does_not_render_activity():
    script = f"""
const fs = require('fs');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8');
{_EXTRACT_FUNC_JS}
global.isFinalAnswerOnlyMode = () => true;
global.$ = () => {{ throw new Error('unexpected DOM access'); }};
eval(extractFunc('appendThinking'));
eval(extractFunc('updateThinking'));
const appendResult = appendThinking('reasoning text', {{pending:true}});
const updateResult = updateThinking('reasoning text', {{pending:true}});
process.stdout.write(JSON.stringify({{appendResult: appendResult === undefined, updateResult: updateResult === undefined}}));
"""
    result = _run_node_script(script)

    assert result == {"appendResult": True, "updateResult": True}


def test_chat_activity_display_mode_settled_hide_all_scene_persists_without_worklog():
    start = MESSAGES_JS.index("function _attachProjectedAnchorSceneToLastAssistant(")
    end = MESSAGES_JS.index("function _upsertAnchorProcessProse", start)
    block = MESSAGES_JS[start:end]
    persist_index = block.index("_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex);")
    return_index = block.rfind("return hasWorklogRows;")

    assert "const hasWorklogRows=_anchorSceneHasWorklogWorthyRows(scene);" in block
    assert "const hasOwnedOutcomes=_anchorSceneHasOwnedOutcomes(scene);" in block
    assert (
        "const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity'||hasOwnedOutcomes;"
        in block
    )
    assert persist_index < return_index
    assert "return true;" not in block


def test_chat_activity_display_mode_switch_to_final_only_clears_existing_live_activity():
    script = f"""
const fs = require('fs');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "panels.js"))}, 'utf8');
{_EXTRACT_FUNC_JS}
let cleanupCalls = 0;
const select = {{value:''}};
const buttons = [
  {{mode:'compact_worklog'}},
  {{mode:'transparent_stream'}},
  {{mode:'hide_all_activity'}},
].map(({{mode}}) => ({{
  getAttribute: (name) => name === 'data-chat-activity-mode' ? mode : null,
  classList: {{toggle(){{}}}},
  setAttribute(){{}},
}}));
global.window = {{_hideLiveActivityForFinalAnswerOnly(){{ cleanupCalls += 1; }}}};
global.document = {{querySelectorAll: () => buttons}};
global.$ = (id) => id === 'settingsChatActivityDisplayMode' ? select : null;
eval(extractFunc('_syncChatActivityDisplayModeControl'));
_syncChatActivityDisplayModeControl('transparent_stream');
const afterTransparent = {{mode: window._chatActivityDisplayMode, transparent: window._transparentStream, cleanupCalls}};
_syncChatActivityDisplayModeControl('hide_all_activity');
const afterHide = {{mode: window._chatActivityDisplayMode, transparent: window._transparentStream, cleanupCalls, selectValue: select.value}};
process.stdout.write(JSON.stringify({{afterTransparent, afterHide}}));
"""
    result = _run_node_script(script)

    assert result == {
        "afterTransparent": {"mode": "transparent_stream", "transparent": True, "cleanupCalls": 0},
        "afterHide": {"mode": "hide_all_activity", "transparent": False, "cleanupCalls": 1, "selectValue": "hide_all_activity"},
    }


def test_chat_activity_display_mode_live_cleanup_removes_existing_activity_rows():
    start = UI_JS.index("function _hideLiveActivityForFinalAnswerOnly(){")
    end = UI_JS.index("function _removeEmptyLiveWorklogShells", start)
    block = UI_JS[start:end]

    assert "clearLiveToolCards();" in block
    assert "removeThinking" in block
    assert ".transparent-event-row" in block
    assert "#liveRunStatus" in block
    assert "window._hideLiveActivityForFinalAnswerOnly=_hideLiveActivityForFinalAnswerOnly" in UI_JS


def test_transparent_stream_live_branch_uses_direct_rows():
    decorator_start = UI_JS.index("function _decorateTransparentEventRow(row, opts){")
    decorator_end = UI_JS.index("function _setTransparentRowsExpanded", decorator_start)
    decorator_block = UI_JS[decorator_start:decorator_end]
    assert "data-transparent-event-row" in decorator_block
    assert "transparent-event-status" in decorator_block
    # Title carries the simple tool name (short form for MCP); status text
    # is only shown for non-default states.
    assert "_toolShortName(name)" in decorator_block
    assert "if(status==='Completed'){" in decorator_block
    assert "function _syncTransparentEventControls" in UI_JS
    assert "function _transparentEventCountLabel" in UI_JS
    assert "data-transparent-tool-count" in UI_JS
    assert "data-tool-count" in UI_JS
    # Transparent mode labels the event controls as a trace, not a Worklog
    # Activity summary.
    assert "return toolCount?`Trace: ${toolCount} ${toolCount===1?'tool':'tools'}`:'Trace';" in UI_JS
    assert "return `Activity: ${toolCount} ${toolCount===1?'tool':'tools'}`" not in UI_JS
    assert "Activity: ${parts.join(' · ')}" not in UI_JS
    # The summary must not list thinking count (DESIGN.md:75 forbids a second
    # trailing count badge, and thinking rows render as their own rows).
    assert "thinkingCount" not in _transparentEventCountLabelBlock(UI_JS)
    assert "function _setTransparentRowsExpanded" in UI_JS
    assert "function _setTransparentCardOpen" in UI_JS
    assert "function _wireTransparentHeaderToggle" in UI_JS
    assert "function _transparentToolDetailHtml" in UI_JS
    assert "function _setTransparentDetailMode" in UI_JS
    assert "transparent-detail-modes" in UI_JS
    # Spec R4: key on own line, value on next line, no inline pair.
    assert 'class="tool-arg-pair"' in UI_JS
    assert 'class="tool-arg-key"' in UI_JS
    assert 'class="tool-arg-val"' in UI_JS
    # Spec R5: controls are span[role=button], not <button> (text-link style)
    assert 'setAttribute(\'role\',\'button\')' in UI_JS
    # Spec R4: detail-mode tabs are span[role=tab], not <button>
    assert 'role="tab"' in UI_JS
    assert "transparent-detail-modes" in UI_JS

    append_thinking_start = UI_JS.index("function appendThinking(text='', options){")
    append_thinking_end = UI_JS.index("function updateThinking", append_thinking_start)
    append_thinking_block = UI_JS[append_thinking_start:append_thinking_end]
    assert "if(isTransparentStream())" in append_thinking_block
    assert "turn=_createAssistantTurn()" in append_thinking_block
    assert "row.id='thinkingRow'" in append_thinking_block
    assert "_decorateTransparentEventRow(row,{" in append_thinking_block
    assert "type:'thinking'" in append_thinking_block
    assert "data-live-thinking-key" in append_thinking_block
    assert "_syncTransparentEventControls(turn)" in append_thinking_block

    append_tool_start = UI_JS.index("function appendLiveToolCard(tc){")
    append_tool_end = UI_JS.index("function _findLatestLiveAssistantByBurst", append_tool_start)
    append_tool_block = UI_JS[append_tool_start:append_tool_end]
    assert "if(isTransparentStream())" in append_tool_block
    assert "_decorateTransparentEventRow(buildToolCard(tc)" in append_tool_block
    assert "_syncTransparentEventControls(turn)" in append_tool_block
    assert "ensureLiveWorklogContainer(inner" in append_tool_block  # compact_worklog fallback remains intact


def test_settings_ui_exposes_chat_activity_display_mode_selector():
    assert 'id="settingsChatActivityDisplayMode"' in INDEX_HTML
    assert 'id="settingsTransparentEventTimestamps"' in INDEX_HTML
    assert 'data-chat-activity-mode="compact_worklog"' in INDEX_HTML
    assert 'data-chat-activity-mode="transparent_stream"' in INDEX_HTML
    assert 'data-chat-activity-mode="hide_all_activity"' in INDEX_HTML
    assert "_pickTransparentEventTimestamps(this.checked)" in INDEX_HTML
    assert 'value="compact_worklog"' in INDEX_HTML
    assert 'value="transparent_stream"' in INDEX_HTML
    assert 'value="hide_all_activity"' in INDEX_HTML
    assert 'data-i18n="settings_label_chat_activity_display_mode"' in INDEX_HTML
    assert 'data-i18n="settings_label_transparent_stream_event_timestamps"' in INDEX_HTML
    assert 'data-i18n="settings_desc_transparent_stream_event_timestamps"' in INDEX_HTML
    assert 'settings_option_final_answer_only' in INDEX_HTML
    assert "chat_activity_display_mode" in PANELS_JS
    assert "transparent_stream_event_timestamps" in PANELS_JS
    assert "settingsChatActivityDisplayMode" in PANELS_JS
    assert "function _syncChatActivityDisplayModeControl" in PANELS_JS
    assert "function _syncTransparentEventTimestampsControl" in PANELS_JS
    assert "function _pickChatActivityDisplayMode" in PANELS_JS
    assert "function _pickTransparentEventTimestamps" in PANELS_JS
    assert "body.chat_activity_display_mode" in PANELS_JS
    assert "renderMessages({preserveScroll:true})" in PANELS_JS
    assert "settings_label_chat_activity_display_mode" in I18N_JS
    assert "settings_desc_chat_activity_display_mode" in I18N_JS
    assert "settings_label_transparent_stream_event_timestamps" in I18N_JS
    assert "settings_desc_transparent_stream_event_timestamps" in I18N_JS
    assert I18N_JS.count("settings_option_final_answer_only") == I18N_JS.count("settings_option_transparent_stream")


def test_chat_activity_display_mode_plumbing_preserves_hide_all_activity():
    assert "s.chat_activity_display_mode==='transparent_stream'||s.chat_activity_display_mode==='hide_all_activity'" in BOOT_JS
    assert "window._transparentEventTimestamps=s.transparent_stream_event_timestamps!==false;" in BOOT_JS
    assert "window._transparentEventTimestamps=true;" in BOOT_JS
    assert "chatActivityModeSel&&(chatActivityModeSel.value==='transparent_stream'||chatActivityModeSel.value==='hide_all_activity')" in PANELS_JS
    assert "const next=mode==='transparent_stream'||mode==='hide_all_activity' ? mode : 'compact_worklog';" in PANELS_JS
    assert "if(typeof _syncTransparentEventTimestampsControl==='function') _syncTransparentEventTimestampsControl(window._transparentEventTimestamps,next);" in PANELS_JS
    assert "window._transparentEventTimestamps=next;" in PANELS_JS
    assert "hide_all_activity" in PANELS_JS


def test_chat_activity_display_mode_settled_worklog_suppression():
    script = f"""
const fs = require('fs');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "messages.js"))}, 'utf8');
{_EXTRACT_FUNC_JS}
eval(extractFunc('_anchorSceneActiveMode'));
eval(extractFunc('_anchorSceneRowDisplayHintForMode'));
eval(extractFunc('_anchorSceneHasWorklogWorthyRows'));
global.window = {{
  chatActivityMode() {{ return 'hide_all_activity'; }},
  _chatActivityDisplayMode: 'compact_worklog',
  _transparentStream: false,
  isFinalAnswerOnlyMode() {{ return false; }},
}};
const activeMode = _anchorSceneActiveMode();
const hiddenHint = _anchorSceneRowDisplayHintForMode({{display_hints:{{}}}}, 'hide_all_activity');
const compactHint = _anchorSceneRowDisplayHintForMode({{display_hints:{{compact_worklog:'activity_row'}}}}, 'compact_worklog');
const hiddenScene = _anchorSceneHasWorklogWorthyRows({{mode:'hide_all_activity', activity_rows:[{{role:'tool'}},{{role:'thinking'}}]}});
window.isFinalAnswerOnlyMode = () => true;
const activeFinalOnly = _anchorSceneHasWorklogWorthyRows({{mode:'compact_worklog', activity_rows:[{{role:'tool'}}]}});
window.isFinalAnswerOnlyMode = () => false;
const compactScene = _anchorSceneHasWorklogWorthyRows({{mode:'compact_worklog', activity_rows:[{{role:'tool'}}]}});
process.stdout.write(JSON.stringify({{activeMode, hiddenHint, compactHint, hiddenScene, activeFinalOnly, compactScene}}));
"""
    result = _run_node_script(script)

    assert result == {
        "activeMode": "hide_all_activity",
        "hiddenHint": "hidden_activity",
        "compactHint": "activity_row",
        "hiddenScene": False,
        "activeFinalOnly": False,
        "compactScene": True,
    }


def test_appearance_autosave_rerenders_only_when_activity_mode_changes():
    """Appearance autosave receives the full settings object back from the
    server, so the presence of chat_activity_display_mode alone is not a
    reason to rebuild the message list. Only an effective mode change should
    clear the render cache and re-render messages."""
    start = PANELS_JS.index("async function _autosaveAppearanceSettings(payload)")
    end = PANELS_JS.index("window._sessionEndlessScrollEnabled=", start)
    autosave_block = PANELS_JS[start:end]

    assert "const beforeMode=window._chatActivityDisplayMode;" in autosave_block
    assert "const beforeTimestamps=window._transparentEventTimestamps!==false;" in autosave_block
    assert "_syncChatActivityDisplayModeControl(saved.chat_activity_display_mode);" in autosave_block
    assert "_syncTransparentEventTimestampsControl(saved.transparent_stream_event_timestamps, saved.chat_activity_display_mode);" in autosave_block
    changed_guard = "if(window._chatActivityDisplayMode!==beforeMode||((window._transparentEventTimestamps!==false)!==beforeTimestamps)){"
    assert changed_guard in autosave_block
    guarded = autosave_block[autosave_block.index(changed_guard):]
    assert "clearMessageRenderCache()" in guarded
    assert "renderMessages({preserveScroll:true})" in guarded
    assert PANELS_JS.count("_syncTransparentEventTimestampsControl(settings.transparent_stream_event_timestamps, settings.chat_activity_display_mode);") == 1


def test_attach_copy_button_declares_local_button():
    """_attachCopyButton must not leak an implicit window.btn global."""
    start = UI_JS.index("function _attachCopyButton(header)")
    end = UI_JS.index("\nfunction _transparentToolDetailHtml", start)
    copy_block = UI_JS[start:end]

    assert "const btn=document.createElement('span');" in copy_block
    assert "\n  btn=document.createElement('span');" not in copy_block


def test_transparent_settled_rows_preserve_same_anchor_order():
    """When multiple transparent event groups resolve to the same anchor, each
    inserted row must advance a per-anchor cursor instead of recomputing
    anchor.nextElementSibling and reversing chronological order."""
    transparent_branch = UI_JS[
        UI_JS.index("// ── transparent_stream path: individual expandable event rows ──"):
        UI_JS.index("// Render per-turn duration", UI_JS.index("// ── transparent_stream path: individual expandable event rows ──"))
    ]

    assert "const transparentInsertCursors=new Map();" in transparent_branch
    assert "const cursor=transparentInsertCursors.get(anchorRow)||anchorRow;" in transparent_branch
    assert "transparentInsertCursors.set(anchorRow,row);" in transparent_branch
    assert "insertAfterCursor(toolRow);" in transparent_branch
    assert "anchorRow.nextElementSibling" not in transparent_branch


def test_transparent_settled_reasoning_thinking_stays_before_final_answer():
    """Reasoning-only final assistant messages should keep their Thinking row
    above the final answer, matching the live order and Compact Worklog's
    beforeAnchor behavior."""
    transparent_branch = UI_JS[
        UI_JS.index("// ── transparent_stream path: individual expandable event rows ──"):
        UI_JS.index("// Render per-turn duration", UI_JS.index("// ── transparent_stream path: individual expandable event rows ──"))
    ]

    assert "const anchorIsWorklogSource=anchorRow.classList&&anchorRow.classList.contains('assistant-segment-worklog-source');" in transparent_branch
    assert "const insertBeforeAnchor=(row)=>{" in transparent_branch
    assert "if(!anchorIsWorklogSource) insertBeforeAnchor(thinkingRow);" in transparent_branch
    assert "else insertAfterCursor(thinkingRow);" in transparent_branch


def test_transparent_live_tool_rows_append_at_turn_end_before_status():
    """Live Transparent Stream rows arrive chronologically, so new rows should
    append at the end of the live turn before #liveRunStatus instead of jumping
    after the previous transparent row."""
    start = UI_JS.index("function appendLiveToolCard(tc){")
    end = UI_JS.index("function clearLiveToolCards()", start)
    live_block = UI_JS[start:end]
    transparent_start = live_block.index("if(isTransparentStream()){")
    transparent_end = live_block.index("if(anchor) _removeEmptyLiveWorklogShells(inner);", transparent_start)
    transparent_live = live_block[transparent_start:transparent_end]

    assert "const liveFooter=inner.querySelector('#liveRunStatus');" in transparent_live
    assert "inner.insertBefore(row,liveFooter);" in transparent_live
    assert "previousRows" not in transparent_live
    assert "previous.insertAdjacentElement('afterend',row)" not in transparent_live


def test_cached_transparent_html_is_rehydrated_after_restore():
    """Cached HTML restores DOM shape but not property-assigned handlers. The
    fast path must rehydrate Transparent Stream controls before returning."""
    cache_start = UI_JS.index("if(cached&&cached.msgCount===msgCount")
    cache_end = UI_JS.index("return;", cache_start)
    cache_block = UI_JS[cache_start:cache_end]

    assert "_rehydrateTransparentStreamDom(inner);" in cache_block
    assert "function _rehydrateTransparentStreamDom(root)" in UI_JS
    assert "_wireTransparentTurnToggle(turn);" in UI_JS
    assert "_syncTransparentEventControls(turn);" in UI_JS
    assert "_wireTransparentHeaderToggle(header);" in UI_JS
    assert "_attachCopyButton(header);" in UI_JS


def test_transparent_existing_copy_buttons_are_rebound_after_cache_restore():
    """The serialized cached DOM keeps copy button elements but loses onclick
    properties, so _attachCopyButton must bind existing buttons too."""
    start = UI_JS.index("function _attachCopyButton(header)")
    end = UI_JS.index("\nfunction _transparentToolDetailHtml", start)
    copy_block = UI_JS[start:end]

    assert "const bindCopyButton=(btn)=>{" in copy_block
    assert "return bindCopyButton(existing);" in copy_block
    assert "bindCopyButton(btn);" in copy_block
    assert "const fallbackName=row.getAttribute('data-event-name')||row.getAttribute('data-tool-name')||'tool';" in UI_JS


def test_transparent_event_row_quiet_metadata_visual_rhythm():
    """Transparent stream rows are quiet transcript metadata, not highlighted
    cards. Collapsed rows are transparent by default, with only neutral
    hover/focus/expanded treatment. Thinking rows share the same quiet family
    instead of using a special accent banner.

    Body itself stays flush with the card's border (no inner chrome), but
    contains a bordered code block for output. Status is rendered as a
    small text badge only when the tool isn't Completed.
    """
    # Collapsed rows are inline metadata: no gradient, no shadow, no accent rail.
    assert "border:1px solid transparent" in STYLE_CSS
    assert "border-left:1px solid transparent" in STYLE_CSS
    assert "border-radius:6px" in STYLE_CSS
    assert "background:transparent" in STYLE_CSS
    assert "box-shadow:none" in STYLE_CSS

    # Header: single flex line, low height, hover subtle.
    # Compact rhythm: 19px header for tighter inline trace rows.
    assert "min-height:19px" in STYLE_CSS
    assert "display:flex" in STYLE_CSS

    # Thinking rows no longer get a special accent gradient or bright border.
    thinking_start = STYLE_CSS.index('.transparent-event-row[data-event-type="thinking"]')
    thinking_end = STYLE_CSS.index('.transparent-event-row[data-expanded="1"]', thinking_start)
    thinking_block = STYLE_CSS[thinking_start:thinking_end]
    assert "background:transparent;" in thinking_block
    assert "border-color:transparent;" in thinking_block
    assert "border-left-color:transparent;" in thinking_block
    assert "var(--accent)" not in thinking_block

    # Expanded body uses visible max-height / opacity animation for smooth height.
    assert "max-height:0" in STYLE_CSS
    assert "max-height:520px" in STYLE_CSS
    assert "transition:max-height" in STYLE_CSS
    assert "data-expanded" in UI_JS

    # Output / JSON: flattened to a quiet left-rail (no second bordered card),
    # so args + output read as ONE expanded zone. (Trifecta V5/V7.)
    assert ".transparent-event-row .tool-card-result pre{" in STYLE_CSS
    _pre_start = STYLE_CSS.index(".transparent-event-row .tool-card-result pre{")
    _pre_block = STYLE_CSS[_pre_start:_pre_start + 220]
    assert "background:transparent;" in _pre_block
    assert "border-left:2px solid var(--border-subtle);" in _pre_block
    assert "max-height:none;" in _pre_block
    assert ".transparent-event-row .thinking-card-body pre{" in STYLE_CSS
    assert "border:0;" in STYLE_CSS
    assert "border-radius:0;" in STYLE_CSS
    assert "margin-top:0;" in STYLE_CSS
    assert "padding:0 8px 6px 27px;" in STYLE_CSS
    assert ".transparent-event-row .thinking-card.open .thinking-card-body{\n  border-top-color:transparent;\n  padding:0 0 3px;\n  scrollbar-gutter:stable;\n}" in STYLE_CSS

    # Tabs: text-link style with an active underline (no pill background).
    assert ".transparent-detail-mode.active{color:var(--text);opacity:1;font-weight:600;box-shadow:inset 0 -1px 0 var(--accent);}" in STYLE_CSS
    assert ".transparent-detail-mode.active{background:var(--hover-bg)" not in STYLE_CSS

    # Args layout: compact inline key beside value (not stacked). (Trifecta V4.)
    assert ".tool-arg-pair{display:flex;flex-direction:row" in STYLE_CSS
    # Compact size: key font 10px.
    assert ".tool-arg-key{flex:0 0 auto;min-width:54px;color:var(--muted);font-size:10px" in STYLE_CSS
    assert "font-family:var(--font-mono);" in STYLE_CSS

    # Tool rows must show a useful compact preview while collapsed.
    assert ".transparent-event-row .tool-card-preview" in STYLE_CSS
    assert ".transparent-event-row .tool-card:not(.open) .tool-card-preview" not in STYLE_CSS
    assert "display:inline" in STYLE_CSS
    assert "flex-direction:row;" in STYLE_CSS
    assert ".transparent-event-row .thinking-card-label{\n  display:inline;\n  flex:0 0 auto;\n  color:var(--muted);" in STYLE_CSS
    assert ".transparent-event-thinking-preview{\n  display:inline;\n  flex:1 1 auto;\n  min-width:0;" in STYLE_CSS
    thinking_label_block = STYLE_CSS[
        STYLE_CSS.index(".transparent-event-row .thinking-card-label{"):
        STYLE_CSS.index(".transparent-event-thinking-preview{")
    ]
    thinking_preview_block = STYLE_CSS[
        STYLE_CSS.index(".transparent-event-thinking-preview{"):
        STYLE_CSS.index(".transparent-event-status{")
    ]
    assert "order:" not in thinking_label_block
    assert "order:" not in thinking_preview_block
    assert "opacity:.6;" in STYLE_CSS
    assert "font-size:.85em;" in STYLE_CSS

    # Per-row copy button still attached for both tool and thinking rows.
    assert "function _attachCopyButton" in UI_JS
    assert "function _copyEventToClipboard" in UI_JS
    assert "navigator.clipboard.writeText" in UI_JS
    decorator_block = UI_JS[UI_JS.index("function _decorateTransparentEventRow"):UI_JS.index("function _setTransparentRowsExpanded")]
    assert decorator_block.count("_attachCopyButton(header)") >= 2
    assert "_transparentToolDetailHtml(tc,status)" in decorator_block
    assert "_wireTransparentHeaderToggle(header)" in decorator_block
    assert "const btnRow=header.querySelector('.thinking-card-btn-row')" in decorator_block
    assert "if(copy&&copy.parentNode!==header) header.appendChild(copy)" in decorator_block
    assert "if(toggle&&toggle.parentNode!==header) header.appendChild(toggle)" in decorator_block
    assert "preview.className='transparent-event-preview transparent-event-thinking-preview'" in decorator_block

    # Bug fix: thinking blocks persist after the stream stops. removeThinking()
    # and finalizeThinkingCard() must not delete the transparent thinking row;
    # they only strip live attributes so the row can survive the settled render.
    remove_start = UI_JS.index("function removeThinking()")
    remove_end = UI_JS.index("\nfunction ", remove_start + 1)
    remove_block = UI_JS[remove_start:remove_end]
    transparent_remove_block = remove_block[remove_block.index("if(isTransparentStream())"):remove_block.index("const turn=$('liveAssistantTurn');")]
    assert "row.removeAttribute('id')" in transparent_remove_block
    assert "row.removeAttribute('data-thinking-active')" in transparent_remove_block
    assert "row.removeAttribute('data-live-thinking')" in transparent_remove_block
    assert "row.remove()" not in transparent_remove_block

    finalize_start = UI_JS.index("function finalizeThinkingCard(){")
    finalize_end = UI_JS.index("function appendThinking", finalize_start)
    finalize_block = UI_JS[finalize_start:finalize_end]
    transparent_finalize_block = finalize_block[finalize_block.index("if(isTransparentStream())"):finalize_block.index("if(!isSimplifiedToolCalling())")]
    assert "row.removeAttribute('id')" in transparent_finalize_block
    assert "row.removeAttribute('data-thinking-active')" in transparent_finalize_block
    assert "row.removeAttribute('data-live-thinking')" in transparent_finalize_block
    assert "row.remove()" not in transparent_finalize_block


def test_transparent_stream_static_branch_bypasses_worklog_summary_and_adds_event_hooks():
    marker = "// ── transparent_stream path: individual expandable event rows ──"
    start = UI_JS.index(marker)
    end = UI_JS.index("// Render per-turn duration", start)
    transparent_branch = UI_JS[start:end]

    assert "_decorateTransparentEventRow(_thinkingActivityNode(event.thinkingText,false)" in transparent_branch
    assert "_decorateTransparentEventRow(buildToolCard(event.toolCall)" in transparent_branch
    assert "type:'thinking'" in transparent_branch
    assert "type:'tool'" in transparent_branch
    assert "_syncTransparentEventControls(turn)" in transparent_branch
    assert "_syncToolCallGroupSummary" not in transparent_branch
    assert "ensureActivityGroup" not in transparent_branch
    assert "_toolWorklogSummary" not in transparent_branch

    render_message_start = UI_JS.index("const messageBelongsInWorklog=")
    render_message_end = UI_JS.index("if(messageBelongsInWorklog)", render_message_start)
    assert "isCompactWorklogMode()" in UI_JS[render_message_start:render_message_end]
    assert "isSimplifiedToolCalling()" not in UI_JS[render_message_start:render_message_end]

    thinking_store_start = UI_JS.index("if(thinkingText&&window._showThinking!==false)")
    thinking_store_end = UI_JS.index("const hasVisibleBody=", thinking_store_start)
    thinking_store_block = UI_JS[thinking_store_start:thinking_store_end]
    assert "isTransparentStream()" in thinking_store_block
    assert "assistantThinking.set(rawIdx, thinkingText)" in thinking_store_block


def test_fade_text_effect_uses_dynamic_window_check():
    """The fade text effect must read window._fadeTextEffect dynamically
    on every call, and the Settings checkbox must update the live window flag
    immediately so the current session can start fading without a reload."""
    MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
    # Locate the helper and confirm it reads the live value.
    helper_start = MESSAGES_JS.index("function _shouldUseStreamFade(")
    helper_end = MESSAGES_JS.index("\n  function ", helper_start + 1)
    helper_block = MESSAGES_JS[helper_start:helper_end]
    assert "window._fadeTextEffect" in helper_block
    # Should not return the captured const any more.
    assert "return _streamFadeEnabledForStream;" not in helper_block

    # The preferences listener must update the runtime flag immediately, not
    # only after autosave/save completes. Anchor on the listener occurrence —
    # the one immediately followed by the terminalAutoExpand field — rather than
    # a fragile byte offset (panels.js has two `fadeTextCb=` references: the
    # settings-body payload builder and this listener block).
    fade_cb_start = PANELS_JS.index(
        "const fadeTextCb=$('settingsFadeTextEffect');\n    if(fadeTextCb){"
    )
    fade_cb_end = PANELS_JS.index("const terminalAutoExpandCb", fade_cb_start)
    fade_cb_block = PANELS_JS[fade_cb_start:fade_cb_end]
    assert "window._fadeTextEffect=fadeTextCb.checked" in fade_cb_block
    assert "fadeTextCb.addEventListener('change',()=>{" in fade_cb_block
    assert "_schedulePreferencesAutosave();" in fade_cb_block
    # The change handler must not be the old direct autosave callback.
    assert "addEventListener('change',_schedulePreferencesAutosave" not in fade_cb_block


def test_thinking_blocks_persist_after_renderMessages():
    """renderMessages() rebuilds the settled DOM and used to wipe every
    .agent-activity-thinking row that lacked data-live-thinking="1" —
    including the promoted permanent event rows that removeThinking()
    now leaves behind. Guard the selector so promoted thinking rows
    survive the rebuild."""
    inner_sweep = ".agent-activity-thinking:not([data-live-thinking=\"1\"]):not([data-event-type=\"thinking\"])"
    assert inner_sweep in UI_JS
    # The promoted-row guard is the only thing that changed; the rest of
    # the selector must remain intact.
    head = ".tool-worklog-group:not([data-compression-card]),.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card]):not([data-event-type=\"tool\"]),.agent-activity-thinking"
    tail = ".wl-reason[data-worklog-reason-source=\"reasoning\"]"
    assert head in UI_JS
    assert tail in UI_JS


def test_mcp_tool_names_appear_in_event_rows():
    """MCP (Model Context Protocol) tools are namespaced as
    mcp__<server>__<tool>. Their full name must be preserved on the row's
    data-tool-name attribute, the header must use a friendly short form
    (e.g. github/create_issue), and a plug-style icon must be used when
    no canonical Hermes icon matches."""
    # toolIcon: plug icon for mcp__ / mcp. prefixed names.
    assert "startsWith('mcp__')" in UI_JS
    assert "startsWith('mcp.')" in UI_JS
    assert "li('plug')" in UI_JS
    # _toolShortName reduces the namespace; the decorator must use it.
    assert "function _toolShortName" in UI_JS
    decorator_block = UI_JS[UI_JS.index("function _decorateTransparentEventRow"):UI_JS.index("function _setTransparentRowsExpanded")]
    assert "_toolShortName(name)" in decorator_block
    # buildToolCard preserves the full tool name on data-tool-name.
    assert 'dataset.toolName' in UI_JS or 'row.dataset.toolName' in UI_JS


def test_transparent_turn_header_is_collapsible():
    """The Hermes chat name tag (assistant role label) must be clickable
    in transparent mode to collapse/expand the entire event stack
    underneath. A chevron is appended to the role to telegraph the
    affordance; toggling flips data-transparent-turn-collapsed on the
    turn and the CSS collapses the blocks body via max-height."""
    assert "function _wireTransparentTurnToggle" in UI_JS
    assert "data-transparent-turn-collapsed" in UI_JS
    assert "transparent-turn-chevron" in UI_JS
    # The CSS must collapse the blocks when the data attribute is set.
    assert 'data-transparent-turn-collapsed="1"] .transparent-event-row' in STYLE_CSS
    # The chevron rotates on collapse.
    assert "transparent-turn-chevron" in STYLE_CSS
    assert "transform:rotate(-90deg)" in STYLE_CSS


def test_old_event_fading_medium_to_low():
    """Older transparent event rows fade (medium → low) so the eye lands on
    the most recent activity. The fade applies to the LIVE turn only (settled
    history stays full-opacity / readable) with a WCAG-respecting floor."""
    assert "function _applyTransparentRowFading" in UI_JS
    # Fading is gated to the live turn in JS (settled history is not dimmed).
    assert "id==='liveAssistantTurn'" in UI_JS
    # CSS steps from medium down to a readable floor.
    assert 'data-transparent-fade="1"' in STYLE_CSS
    assert 'data-transparent-fade="5"' in STYLE_CSS
    assert "opacity:.54" in STYLE_CSS
    # Hover restores full opacity.
    assert ".transparent-event-row[data-transparent-fade]:hover{opacity:1" in STYLE_CSS


def test_transparent_turn_footer_shows_elapsed_tokens_ttft_status():
    """The bottom-of-turn footer mirrors the live run-status line for
    settled turns: duration, first-token time, token usage, and final
    status. Only renders for turns that have transparent event rows."""
    assert "function _renderTransparentTurnFooter" in UI_JS
    assert "function _formatFirstToken" in UI_JS
    assert "transparent-turn-footer" in STYLE_CSS
    assert "lf-ttft" in STYLE_CSS
    assert "lf-time" in STYLE_CSS
    assert "lf-status" in STYLE_CSS
    # The footer must read duration, TTFT, and tokens from the message.
    assert "_firstTokenMs" in UI_JS
    assert "_formatFirstToken(msg._firstTokenMs)" in UI_JS
    assert "_formatTurnDuration(msg._turnDuration)" in UI_JS
    # i18n key for the TTFT tooltip.
    assert "first_token_time" in I18N_JS


def test_transparent_turn_wiring_runs_after_per_turn_duration_block():
    """The turn-level wiring (toggle + fading + footer) must run AFTER
    the per-turn duration block so the footer can reuse the computed
    duration / tokens / TTFT for each settled turn."""
    wiring_idx = UI_JS.index("// Transparent mode per-turn wiring:")
    duration_block_end = UI_JS.index("}", UI_JS.index("targetFoot.classList.add('msg-foot-with-usage')"))
    assert duration_block_end != -1
    assert wiring_idx > duration_block_end, (
        "transparent turn wiring must run after the per-turn duration block "
        "so the footer can reuse computed duration/tokens/TTFT"
    )


def test_muted_progress_bar_attached_to_each_event_row():
    """Each transparent event row keeps a very quiet progress affordance.
    The strip is neutral, 1px high, and only becomes visible while running so
    completed internal traces do not compete with assistant prose."""
    assert "function _attachProgressBar" in UI_JS
    # The decorator wires the progress bar at the end of the function.
    assert "_attachProgressBar(row, opts)" in UI_JS
    # CSS: bar is present and can shimmer while running, but has no accent glow.
    assert ".transparent-event-progress" in STYLE_CSS
    assert "transparent-progress-shimmer" in STYLE_CSS
    assert "@keyframes transparent-progress-shimmer" in STYLE_CSS
    assert "height:1px" in STYLE_CSS
    assert "border-radius:0 0 6px 6px" in STYLE_CSS
    assert "background:color-mix(in srgb,var(--muted) 42%,transparent)" in STYLE_CSS
    assert "box-shadow:none" in STYLE_CSS
    assert "opacity:0" in STYLE_CSS
    assert '.transparent-event-progress[data-progress-running="1"]::before{opacity:.55;' in STYLE_CSS
    # No 3D ridge or accent separator between adjacent rows.
    assert ".transparent-event-progress::after{\n  content:none;\n}" in STYLE_CSS
    assert ".transparent-event-row + .transparent-event-row::before" in STYLE_CSS
    assert ".transparent-event-row + .transparent-event-row::before{\n  content:none;\n}" in STYLE_CSS


def test_copy_button_position_is_stable_and_dedup_handles_legacy_template():
    """The copy button uses flex `order:9` so its position is stable
    regardless of which other elements (status, preview) are in the
    header. The toggle uses `order:10` so copy + toggle cluster at the
    right edge in a predictable order. _attachCopyButton also reuses
    the legacy .thinking-copy-btn baked into the thinking-card HTML
    template, preventing duplicate copy buttons in the thinking box."""
    # Flex order pinning.
    assert "order:9" in STYLE_CSS
    assert "order:10" in STYLE_CSS
    assert ".transparent-event-copy{\n  border:0;\n  background:transparent;\n  color:var(--muted);\n  opacity:.28;" in STYLE_CSS
    assert ".transparent-event-copy:hover,\n.transparent-event-copy:focus-visible{opacity:1;color:var(--text);background:transparent;}" in STYLE_CSS
    # Dedup: _attachCopyButton checks for both .transparent-event-copy
    # and .thinking-copy-btn.
    assert "'.transparent-event-copy,.thinking-copy-btn'" in UI_JS
    # The function normalises and rebinds existing buttons so cached HTML
    # restores keep copy behavior.
    assert "btn.classList.add('transparent-event-copy')" in UI_JS
    assert "return bindCopyButton(existing);" in UI_JS


def test_transparent_rows_remove_card_chrome_by_default():
    """Default transparent rows avoid card/banner styling. Neutral border and
    low-tint background are reserved for hover, focus, or expanded states."""
    assert "border:1px solid transparent" in STYLE_CSS
    assert "border-left:1px solid transparent" in STYLE_CSS
    assert "border-color:var(--border-subtle)" in STYLE_CSS
    assert "background:color-mix(in srgb,var(--surface-subtle) 62%,transparent)" in STYLE_CSS
    assert "background:color-mix(in srgb,var(--surface-subtle) 48%,transparent)" in STYLE_CSS
    assert "box-shadow:none" in STYLE_CSS
    assert "border-radius:6px" in STYLE_CSS


def test_smaller_tool_icons_and_reduced_font_sizes():
    """Tool icons are constrained to 11px (was 14px), and the tool name
    font is 11.5px (was 12.5px — reduced by two notches). The arg-key
    font is 10px (was 11px). Status badge gets a pill background and
    letter-spacing for visual distinction from the copy button."""
    # Icon wrapper size.
    assert "width:14px;height:14px" in STYLE_CSS
    # Inner SVG size.
    assert "width:11px;height:11px" in STYLE_CSS
    # Tool name font 11.5px (down from 12.5px).
    assert "font-size:11.5px" in STYLE_CSS
    # Status badge: pill background + letter-spacing.
    assert "border-radius:6px" in STYLE_CSS
    assert "letter-spacing:.02em" in STYLE_CSS


# ── Trifecta review fixes (round 2) ───────────────────────────────────────


def test_live_turn_restore_rehydrates_transparent_dom():
    """restoreLiveTurnHtmlForSession() restores liveTurnHtml via template.innerHTML,
    which drops the property-bound toggle/copy/expand handlers. It must re-run
    _rehydrateTransparentStreamDom so Transparent Stream controls keep working
    after an active-session live-turn restore. (Trifecta C1.)"""
    start = UI_JS.index("function restoreLiveTurnHtmlForSession")
    end = UI_JS.index("function markInflight", start)
    body = UI_JS[start:end]
    assert "_rehydrateTransparentStreamDom(restored)" in body


def test_transparent_settled_path_dedupes_echoed_thinking():
    """The transparent settled render must dedupe echoed thinking per turn
    (mirroring the compact path's seenReasons) so the same reasoning does not
    render twice / out of chronological order. (Trifecta O-Bug1.)"""
    assert "transparentSeenThinking" in UI_JS
    assert "_normalizeThinkingEchoCompare(event.thinkingText)" in UI_JS


def test_transparent_thinking_card_is_reset_to_flat_quiet_row():
    """Thinking inner cards must be reset to flat/transparent inside a transparent
    event row (they previously kept their accent-bg/border/radius/msg-rail chrome
    and were the heaviest object in the stream). (Trifecta V1.)"""
    assert ".transparent-event-row .thinking-card," in STYLE_CSS
    # The reset block carries background:transparent!important + border:0!important.
    reset_start = STYLE_CSS.index(".transparent-event-row .tool-card,")
    reset_block = STYLE_CSS[reset_start:reset_start + 700]
    assert ".transparent-event-row .thinking-card" in reset_block
    assert "background:transparent!important" in reset_block
    assert "border:0!important" in reset_block


def test_transparent_skin_reset_beats_per_skin_card_rules():
    """The reset must also win against the per-skin :root[data-skin] .tool-card
    rules that otherwise re-card the rows. (Trifecta V3.)"""
    assert ":root[data-skin] .transparent-event-row .tool-card" in STYLE_CSS


def test_transparent_failed_status_is_legible():
    """A failed tool must be visually legible (error color + left border), not an
    invisible muted badge. (Trifecta V2.)"""
    assert '.transparent-event-status[data-status="failed"]' in STYLE_CSS
    assert '.transparent-event-row[data-event-status="Failed"]' in STYLE_CSS


def test_transparent_interrupted_status_on_settled_tools():
    """A settled/reloaded tool left in done===false renders as Interrupted (not a
    permanent Running shimmer). (Trifecta O-Edge.)"""
    assert "function _transparentToolStatus(tc, settled)" in UI_JS
    assert "settled?'Interrupted':'Running'" in UI_JS
    start = UI_JS.index("function _anchorSceneTransparentNodeForRow(row, opts){")
    depth = 0
    end = None
    for idx in range(start, len(UI_JS)):
        char = UI_JS[idx]
        if char == "{":
            depth += 1
        elif char == "}":
            depth -= 1
            if depth == 0:
                end = idx + 1
                break
    assert end is not None, "_anchorSceneTransparentNodeForRow body did not close"
    body = UI_JS[start:end]
    assert "else if(row.role==='tool')" in body
    assert "_transparentToolStatus(toolCall,settled)" in body
    assert "_transparentToolStatus(toolCall,true)" not in body
    assert "_transparentToolStatus(event.toolCall,true)" in UI_JS


def test_transparent_tool_completion_preserves_expand_state():
    """Tool completion rebuilds the row; it must carry over the user's open state
    and Full/Output detail tab. (Trifecta O-Bug2.)"""
    assert "_setTransparentCardOpen(_newCard,true)" in UI_JS


def test_transparent_entrance_animation_is_live_turn_only():
    """Transparent Stream must not keep the removed entrance animation rule."""
    assert "transparent-event-enter" not in STYLE_CSS


def test_live_worklog_reason_mirror_is_gated_to_compact_mode():
    """#4096: during a live multi-round turn in Transparent Stream mode, all
    assistant prose visually bunched at the top while every tool row clustered
    below, self-healing only when the turn settled.

    Root cause: _syncLiveWorklogReasonsForAnchor() runs on every live segment
    render (from _flushPendingSegmentRender + the RAF _doRender in messages.js).
    It builds the top-anchored `live-worklog` rail, mirrors each round's prose
    into a `wl-reason` row there, AND tags the real chronological inline
    `assistant-segment` as `assistant-segment-worklog-source` (-> display:none,
    style.css). That worklog-folding is the Compact Worklog presentation (#3401)
    and must NOT run in Transparent Stream mode, where prose stays as visible,
    chronologically-placed inline segments interleaved with tool rows.

    The fix gates the whole function on isCompactWorklogMode(). Assert the guard
    is the FIRST statement in the function body (before it touches
    ensureLiveWorklogContainer / _syncWorklogReasonFromAnchor) so it actually
    short-circuits in transparent mode rather than running the rail-build first.
    """
    start = UI_JS.index("function _syncLiveWorklogReasonsForAnchor(anchor, displayTextOverride){")
    end = UI_JS.index("\nfunction ", start + 1)
    body = UI_JS[start:end]

    # The compact-mode gate exists and short-circuits non-compact (transparent) mode.
    guard = "if(typeof isCompactWorklogMode==='function' && !isCompactWorklogMode()) return;"
    assert guard in body, "missing transparent-mode gate on _syncLiveWorklogReasonsForAnchor"

    # The guard must come BEFORE the rail is built / prose is mirrored, otherwise
    # it would not actually prevent the bunching.
    guard_idx = body.index(guard)
    assert guard_idx < body.index("ensureLiveWorklogContainer("), (
        "compact-mode gate must precede ensureLiveWorklogContainer() so transparent "
        "mode never builds the top worklog rail"
    )
    assert guard_idx < body.index("_syncWorklogReasonFromAnchor("), (
        "compact-mode gate must precede _syncWorklogReasonFromAnchor() so transparent "
        "mode never hides the inline assistant-segment or appends a wl-reason mirror"
    )

    # Both live-render call sites still invoke the (now-gated) helper — the gate
    # lives in the helper, not at the call sites, so live rendering is unchanged
    # in compact mode.
    MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
    assert MESSAGES_JS.count("_syncLiveWorklogReasonsForAnchor(assistantRow") >= 2

    # The settled-render worklog-folding gate is also compact-only (regression
    # guard against the symmetric settled-path bug).
    render_message_start = UI_JS.index("const messageBelongsInWorklog=")
    render_message_end = UI_JS.index("if(messageBelongsInWorklog)", render_message_start)
    assert "isCompactWorklogMode()" in UI_JS[render_message_start:render_message_end]
