diff --git a/static/style.css b/static/style.css index a178d697..63cb9f2f 100644 --- a/static/style.css +++ b/static/style.css @@ -2318,6 +2318,13 @@ .provider-error-details{margin:12px 0 0;border:1px solid var(--border);border-radius:10px;background:var(--surface);overflow:hidden;} .provider-error-details>summary{cursor:pointer;color:var(--muted);font-size:12px;font-weight:600;padding:8px 12px;} .provider-error-details>pre{margin:0;border:0;border-top:1px solid var(--border);border-radius:0;max-height:220px;} + .process-wakeup-row{padding:6px 0;} + .process-wakeup-notice{margin-left:30px;max-width:680px;padding:8px 10px;border:1px solid var(--border);border-left:2px solid var(--accent-bg-strong);border-radius:9px;background:var(--surface-subtle,var(--hover-bg));color:var(--muted);} + .process-wakeup-label{display:flex;align-items:center;gap:6px;margin-bottom:5px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--accent-text);} + .process-wakeup-label svg{width:13px;height:13px;flex:0 0 auto;} + .process-wakeup-row .msg-body.process-wakeup-body{padding-left:0;max-width:none;color:var(--muted);} + .process-wakeup-row .msg-body pre.process-wakeup-text{margin:0;padding:0;border:0;border-radius:0;background:transparent;color:var(--muted);font-family:var(--font-mono);font-size:12px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;} + .process-wakeup-row .msg-foot{padding-left:0;margin-top:5px;} /* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */ .msg-body pre[class*="language-"],.msg-body pre code[class*="language-"]{background:var(--code-bg) !important;} /* Fix #1463: Prism YAML grammar collapses newlines inside token spans — force pre */ diff --git a/static/ui.js b/static/ui.js index b804fc3e..35c69fb1 100644 --- a/static/ui.js +++ b/static/ui.js @@ -526,6 +526,7 @@ const MESSAGE_VIRTUAL_THRESHOLD_ROWS=80; const MESSAGE_VIRTUAL_BUFFER_PX=900; const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={ user:120, + process_wakeup:96, assistant:160, tool_call:400, default:140, @@ -616,7 +617,7 @@ function _cancelMessageVirtualizedRender(){ } function _messageIsRenderable(m){ if(!m||!m.role||m.role==='tool') return false; - if(m._source === 'process_wakeup') return false; + if(m._source === 'process_wakeup') return !!(msgContent(m)||m.attachments?.length); if(_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)) return false; if(_isRecoveryControlMessage(m)) return false; const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; @@ -811,6 +812,7 @@ function _syncMessageVirtualHeightCache(visWithIdx){ function _messageVirtualRoleForEntry(entry){ const m=entry&&entry.m; if(!m) return 'default'; + if(m._source === 'process_wakeup') return 'process_wakeup'; if(m.role==='user') return 'user'; if(m.role==='assistant'){ if((Array.isArray(m.tool_calls)&&m.tool_calls.length>0)|| @@ -14419,11 +14421,13 @@ function renderMessages(options){ } } } + const isProcessWakeup=m&&m._source==='process_wakeup'; const isUser=m.role==='user'; if(!isUser&&_isMarkerOnlyAssistantCompressionMessage(m)){ content='**Error:** No response received after context compression. Please retry.'; } const displayContent=isUser?_stripAttachedFilesMarkerForDisplay(_stripWorkspaceDisplayPrefix(content)):content; + const rowDisplayContent=isProcessWakeup?content:displayContent; if(!isUser&&_isAssistantEmptyPlaceholderContent(m, displayContent)){ content=''; } @@ -14493,6 +14497,41 @@ function renderMessages(options){ continue; } + if(isProcessWakeup){ + currentAssistantTurn=null; + let row=_msgNodeRecycleEnabled?_recycleStash.get(rawIdx):null; + if(row&&(!row.classList.contains('msg-row')||row.classList.contains('assistant-turn'))) row=null; + const processText=String(rowDisplayContent||'').trim(); + const processFootHtml=`
${timeHtml}${copyBtn}
`; + const nextRowHtml=`
${li('terminal',13)}Background wakeup
${esc(processText)}
${processFootHtml}
`; + if(row){ + row.className='msg-row process-wakeup-row'; + row.id=_userMessageDomId(rawIdx); + row.dataset.msgIdx=rawIdx; + row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx); + row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m); + row.dataset.role='process_wakeup'; + delete row.dataset.editing; + if(row.dataset.rawText!==processText||row.innerHTML!==nextRowHtml){ + row.dataset.rawText=processText; + row.innerHTML=nextRowHtml; + } + }else{ + row=document.createElement('div'); + row.className='msg-row process-wakeup-row'; + row.id=_userMessageDomId(rawIdx); + row.dataset.msgIdx=rawIdx; + row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx); + row.dataset.messageAnchorKey=_messageViewportAnchorKeyForMessage(m); + row.dataset.role='process_wakeup'; + row.dataset.rawText=processText; + row.innerHTML=nextRowHtml; + } + inner.appendChild(row); + userRows.set(rawIdx, row); + continue; + } + if(isUser){ currentAssistantTurn=null; let row=_msgNodeRecycleEnabled?_recycleStash.get(rawIdx):null; @@ -14500,6 +14539,7 @@ function renderMessages(options){ const newRawText=String(displayContent).trim(); const nextRowHtml=`${filesHtml}
${bodyHtml}
${footHtml}`; if(row){ + row.className='msg-row'; row.id=_userMessageDomId(rawIdx); row.dataset.msgIdx=rawIdx; row.dataset.sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx); diff --git a/tests/test_process_wakeup_rendering.py b/tests/test_process_wakeup_rendering.py new file mode 100644 index 00000000..d7df1bcf --- /dev/null +++ b/tests/test_process_wakeup_rendering.py @@ -0,0 +1,175 @@ +"""Regression coverage for process-wakeup transcript rendering. + +A background-process wakeup is stored as a synthetic user turn +(`_source: "process_wakeup"`). It must be visible by default, but it must not +look like a human-authored chat bubble. Keeping it in the visible message list +also preserves the user-turn boundary between the assistant response that +preceded the notification and the assistant response produced by the wakeup. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS_PATH = ROOT / "static" / "ui.js" +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +_DRIVER = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[1], 'utf8'); +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 (p && (p.text || p.content)) || '').join('\n'); + return String(m.content || ''); +} +function _isContextCompactionMessage(){ return false; } +function _isPreservedCompressionTaskListMessage(){ return false; } +function _isRecoveryControlMessage(){ return false; } +function _messageHasReasoningPayload(){ return false; } +function _assistantMessageHasVisibleContent(m){ return !!String(msgContent(m)).trim(); } + +global.window = {}; +global.S = {messages: []}; +let _visWithIdxCache = null; +let _visWithIdxCacheLen = 0; +let _visWithIdxCacheSrc = null; + +const heightStart = src.indexOf('const MESSAGE_RENDER_WINDOW_DEFAULT'); +const heightEnd = src.indexOf('const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS', heightStart); +if(heightStart !== -1 && heightEnd !== -1) eval(src.slice(heightStart, heightEnd)); +if(src.indexOf('function _isProcessWakeupMessage') !== -1) eval(extractFunc('_isProcessWakeupMessage')); +eval(extractFunc('_messageIsRenderable')); +eval(extractFunc('_getVisibleMessagesWithIdx')); +eval(extractFunc('_messageVirtualRoleForEntry')); + +const wakeup = { + role: 'user', + content: '[IMPORTANT: Background process proc_123 completed (exit_code=0).\nCommand: sleep 1\nOutput:\ndone]', + _source: 'process_wakeup', + timestamp: 1783405253.72, +}; +S.messages = [ + {role: 'assistant', content: 'previous assistant report', timestamp: 1783405252.05}, + wakeup, + {role: 'assistant', content: 'assistant response to wakeup', timestamp: 1783405254.10}, +]; + +const visible = _getVisibleMessagesWithIdx(); +const turns = []; +let current = []; +for(const entry of visible){ + const source = entry.m._source || ''; + if(entry.m.role === 'user'){ + if(current.length) turns.push(current); + turns.push(['user:' + source + ':' + String(entry.m.content).slice(0, 35)]); + current = []; + }else if(entry.m.role === 'assistant'){ + current.push('assistant:' + entry.m.content); + } +} +if(current.length) turns.push(current); +const virtualRole = _messageVirtualRoleForEntry({m: wakeup}); +const virtualHeight = typeof _messageVirtualDefaultHeightForRole === 'function' + ? _messageVirtualDefaultHeightForRole(virtualRole) + : null; + +process.stdout.write(JSON.stringify({ + visible: visible.map(e => ({rawIdx: e.rawIdx, role: e.m.role, source: e.m._source || '', text: String(e.m.content).slice(0, 32)})), + turns, + virtualRole, + virtualHeight, +})); +""" + + +def _run_driver(): + assert NODE is not None + proc = subprocess.run( + [NODE, "-e", _DRIVER, str(UI_JS_PATH)], + text=True, + capture_output=True, + timeout=30, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_process_wakeup_message_stays_visible_and_preserves_turn_boundary(): + result = _run_driver() + + assert result["visible"] == [ + {"rawIdx": 0, "role": "assistant", "source": "", "text": "previous assistant report"}, + {"rawIdx": 1, "role": "user", "source": "process_wakeup", "text": "[IMPORTANT: Background process p"}, + {"rawIdx": 2, "role": "assistant", "source": "", "text": "assistant response to wakeup"}, + ] + assert result["turns"] == [ + ["assistant:previous assistant report"], + ["user:process_wakeup:[IMPORTANT: Background process proc"], + ["assistant:assistant response to wakeup"], + ] + + +def test_process_wakeup_has_its_own_virtual_height_role(): + result = _run_driver() + + assert result["virtualRole"] == "process_wakeup" + assert isinstance(result["virtualHeight"], int) + assert 1 <= result["virtualHeight"] <= 120 + + +def test_process_wakeup_uses_compact_status_row_not_normal_user_bubble(): + ui = UI_JS_PATH.read_text(encoding="utf-8") + marker = "const isProcessWakeup=" + marker_idx = ui.find(marker) + assert marker_idx != -1, "render loop must classify process-wakeup messages" + process_branch_idx = ui.find("if(isProcessWakeup)", marker_idx) + user_branch_idx = ui.find("if(isUser)", marker_idx) + + assert process_branch_idx != -1, "process-wakeup render branch missing" + assert user_branch_idx != -1, "normal user render branch missing" + assert process_branch_idx < user_branch_idx, ( + "process-wakeup rows must render through the compact status branch " + "before the normal user-bubble branch" + ) + process_branch = ui[process_branch_idx:user_branch_idx] + assert "process-wakeup-row" in process_branch + assert "process-wakeup-notice" in process_branch + assert "data-role='process_wakeup'" in process_branch or "dataset.role='process_wakeup'" in process_branch + + assert ".process-wakeup-row" in STYLE_CSS + assert ".process-wakeup-notice" in STYLE_CSS + assert ".process-wakeup-text" in STYLE_CSS