Merge pull request #2851 from nesquena/release/stage-batch8

Release CX: stage-batch8 — 2-PR batch (v0.51.126) — kanban markdown + live activity timeline
This commit is contained in:
nesquena-hermes
2026-05-24 09:22:05 -07:00
committed by GitHub
5 changed files with 335 additions and 13 deletions
+10
View File
@@ -3,6 +3,16 @@
## [Unreleased]
## [v0.51.126] — 2026-05-24 — Release CX (stage-batch8 — 2-PR low-risk batch — kanban markdown + live activity timeline)
### Added
- **PR #2819** by @humayunak — Kanban task descriptions and comments now render as full GFM Markdown instead of plain-text. `_kanbanRenderMarkdown()` in `static/panels.js` rewrote the line-per-`<p>` wrapper as a block-parsing pipeline supporting headings, code blocks (fenced + indented), ordered/unordered lists, task lists with checkboxes, tables, blockquotes, horizontal rules, and strikethrough. `_kanbanRenderMarkdownInline()` gains `~~strikethrough~~` and tightens the italic regex to avoid mid-identifier `*` matches. CSS adds table borders, code-block background, checkbox styling, blockquote accent, and heading sizing scoped to `.hermes-kanban-md`. Frontend-only, scoped to the kanban panel. 95 existing kanban tests pass.
### Changed
- **PR #2847** by @AJV20 — Live chat Activity disclosure now shows observable run telemetry instead of an empty `Thinking…` placeholder when no reasoning text is available (squashed from 2 author commits). New baseline rows surface run-start metadata (model, profile), `Waiting on model` / `Waiting on tool result` / `Working for …` status, tool start/finish in the timeline alongside the existing compact tool cards, and a `No recent activity for …` state after quiet periods. Frontend-only telemetry derived from existing stream events — no new backend event types. Adds `tests/test_live_activity_timeline.py` (4 tests). The compact/calm default Activity disclosure is preserved; it only becomes informative when expanded.
## [v0.51.125] — 2026-05-24 — Release CW (stage-batch7 — 10-PR low-risk batch — UI/UX polish + bug fixes + diagnostics)
### Fixed
+175 -4
View File
@@ -1274,17 +1274,188 @@ function _kanbanRenderSidebar(columns){
}
/**
* Render inline markdown (bold, italic, code, links, strikethrough).
* Input is already HTML-escaped.
*/
function _kanbanRenderMarkdownInline(escaped){
return String(escaped || '')
.replace(/~~([^~\n]+)~~/g, (_m, text) => `<del>${text}</del>`)
.replace(/`([^`\n]+)`/g, (_m, code) => `<code>${code}</code>`)
.replace(/\*\*([^*\n]+)\*\*/g, (_m, text) => `<strong>${text}</strong>`)
.replace(/(^|[^*])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}<em>${text}</em>`)
.replace(/(^|[^*a-zA-Z0-9])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}<em>${text}</em>`)
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_m, text, href) => `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`);
}
/**
* Render full markdown block content: headings, code blocks, lists, tables,
* task lists, blockquotes, horizontal rules, paragraphs + inline formatting.
*/
function _kanbanRenderMarkdown(source){
if (!source) return '';
return `<div class="hermes-kanban-md">${esc(source).split(/\r?\n/).map(line => line.trim() ? `<p>${_kanbanRenderMarkdownInline(line)}</p>` : '').join('')}</div>`;
const lines = esc(source).split(/\r?\n/);
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// ── Code block ──
if (/^```/.test(trimmed)) {
const lang = trimmed.slice(3).trim();
const codeLines = [];
i++;
while (i < lines.length && !/^```/.test(lines[i].trim())) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
const codeHtml = codeLines.join('\n');
out.push(lang
? `<pre class="hermes-kanban-code"><code class="language-${_kanbanRenderMarkdownInline(lang)}">${codeHtml}</code></pre>`
: `<pre class="hermes-kanban-code"><code>${codeHtml}</code></pre>`);
continue;
}
// ── Horizontal rule ──
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) {
out.push('<hr>');
i++;
continue;
}
// ── Heading ──
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
out.push(`<h${level}>${_kanbanRenderMarkdownInline(headingMatch[2])}</h${level}>`);
i++;
continue;
}
// ── Blockquote ──
if (/^>\s?/.test(trimmed)) {
const quoteLines = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''));
i++;
}
out.push(`<blockquote>${_kanbanRenderMarkdownInline(quoteLines.join('<br>'))}</blockquote>`);
continue;
}
// ── Table row ──
if (/^\|.+\|$/.test(trimmed)) {
const tableRows = [];
const tableAligns = [];
while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) {
const row = lines[i].trim();
// Detect alignment separator row
if (/^\|[\s:]*-{3,}[\s:]*\|/.test(row)) {
const cells = row.split('|').filter(c => c.trim().length > 0);
cells.forEach(c => {
const t = c.trim();
if (t.startsWith(':') && t.endsWith(':')) tableAligns.push('center');
else if (t.endsWith(':')) tableAligns.push('right');
else tableAligns.push('left');
});
} else {
const cells = row.split('|').filter(c => c.trim().length > 0);
tableRows.push(cells.map((c, ci) => {
const align = tableAligns[ci] ? ` style="text-align:${tableAligns[ci]}"` : '';
return `<td${align}>${_kanbanRenderMarkdownInline(c.trim())}</td>`;
}).join(''));
}
i++;
}
if (tableRows.length) {
out.push(`<table><tbody>${tableRows.map(r => `<tr>${r}</tr>`).join('')}</tbody></table>`);
}
continue;
}
// ── Task list item ──
const taskMatch = trimmed.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
if (taskMatch) {
const checked = taskMatch[1] !== ' ';
const text = taskMatch[2];
const items = [];
items.push(`<li class="hermes-kanban-task${checked ? ' checked' : ''}"><input type="checkbox"${checked ? ' checked' : ''} disabled> ${_kanbanRenderMarkdownInline(text)}</li>`);
i++;
// Collect continuation items
while (i < lines.length) {
const next = lines[i].trim();
const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
const nextLi = next.match(/^[-*+]\s+(.+)$/);
if (nextTask) {
const c = nextTask[1] !== ' ';
items.push(`<li class="hermes-kanban-task${c ? ' checked' : ''}"><input type="checkbox"${c ? ' checked' : ''} disabled> ${_kanbanRenderMarkdownInline(nextTask[2])}</li>`);
i++;
} else if (nextLi) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextLi[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
// ── Unordered list item ──
const ulMatch = trimmed.match(/^[-*+]\s+(.+)$/);
if (ulMatch) {
const items = [];
items.push(`<li>${_kanbanRenderMarkdownInline(ulMatch[1])}</li>`);
i++;
while (i < lines.length) {
const next = lines[i].trim();
const nextUl = next.match(/^[-*+]\s+(.+)$/);
const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
if (nextTask) break; // let task list handler get it
if (nextUl) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextUl[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
// ── Ordered list item ──
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
const items = [];
items.push(`<li>${_kanbanRenderMarkdownInline(olMatch[1])}</li>`);
i++;
while (i < lines.length) {
const next = lines[i].trim();
const nextOl = next.match(/^\d+\.\s+(.+)$/);
if (nextOl) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextOl[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ol>${items.join('')}</ol>`);
continue;
}
// ── Empty line ──
if (!trimmed) {
out.push('');
i++;
continue;
}
// ── Paragraph ──
out.push(`<p>${_kanbanRenderMarkdownInline(trimmed)}</p>`);
i++;
}
return `<div class="hermes-kanban-md">${out.join('\n')}</div>`;
}
function _kanbanFormatDuration(seconds){
@@ -1851,7 +2022,7 @@ function _kanbanCommentHtml(comment){
const by = comment.author || comment.created_by || comment.actor || '';
const at = _kanbanFormatTimestamp(comment.created_at || comment.ts || '');
return `<div class="kanban-detail-row">
<div class="kanban-detail-row-main">${esc(body)}</div>
<div class="kanban-detail-row-main">${_kanbanRenderMarkdown(body)}</div>
<div class="kanban-detail-row-meta">${esc([by, at].filter(Boolean).join(' · '))}</div>
</div>`;
}
@@ -2403,7 +2574,7 @@ function _kanbanRenderTaskDetail(data){
<div class="kanban-task-preview-title">${esc(title)}</div>
<button class="btn secondary kanban-edit-btn" onclick="openKanbanEdit('${esc(task.id)}')" data-i18n="kanban_edit_task" title="${esc(t('kanban_edit_task') || 'Edit task')}">${esc(t('kanban_edit_task') || 'Edit task')}</button>
</div>
<div class="kanban-task-preview-body">${esc(body)}</div>
<div class="kanban-task-preview-body">${_kanbanRenderMarkdown(body)}</div>
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}
<div class="kanban-status-actions">${statusButtons}</div>
<div class="kanban-detail-grid">
+12 -3
View File
@@ -2330,6 +2330,15 @@ body.resizing .sidebar{transition:none!important;}
.tool-call-group:not(.tool-call-group-collapsed) .tool-call-group-chevron{transform:rotate(90deg);}
.tool-call-group-body{display:block;padding-left:var(--space-3);}
.tool-call-group.tool-call-group-collapsed .tool-call-group-body{display:none;}
.agent-activity-status{display:grid;grid-template-columns:18px minmax(0,1fr) auto;align-items:start;gap:var(--space-2);padding:5px 0;color:var(--muted);font-size:var(--font-size-xs);line-height:1.45;border-bottom:1px solid color-mix(in srgb,var(--border-subtle) 60%,transparent);}
.agent-activity-status:last-child{border-bottom:0;}
.agent-activity-status-icon{display:inline-flex;align-items:center;justify-content:center;min-height:18px;opacity:.72;color:var(--muted);}
.agent-activity-status-copy{display:flex;flex-direction:column;min-width:0;gap:1px;}
.agent-activity-status-label{color:var(--text);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.agent-activity-status-detail{color:var(--muted);opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.agent-activity-status-time{font-variant-numeric:tabular-nums;opacity:.55;white-space:nowrap;}
.agent-activity-status-waiting .agent-activity-status-label{color:var(--muted);}
.agent-activity-status-error .agent-activity-status-label{color:var(--error);}
.tool-call-group-label{font-weight:600;color:var(--muted);position:relative;display:inline-block;overflow:hidden;}
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label{
color:var(--muted);
@@ -4221,7 +4230,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-run-dispatch-btn:hover{
background:color-mix(in srgb,var(--accent,#FFD700) 24%,transparent);
}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;margin-bottom:6px;}
.kanban-status-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px;}
.kanban-status-actions .btn{font-size:11px;padding:4px 8px;}
/* Generic styled buttons used throughout the Kanban panel. The Kanban PR
@@ -4291,7 +4300,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-detail-section h3{font-size:12px;font-weight:650;color:var(--text);margin:0 0 8px;}
.kanban-detail-row{padding:8px 0;border-top:1px solid var(--border);}
.kanban-detail-row:first-of-type{border-top:0;padding-top:0;}
.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;white-space:pre-wrap;}
.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;}.kanban-detail-row-main .hermes-kanban-md p:last-child{margin-bottom:0;}
.kanban-detail-row-meta{font-size:10px;color:var(--muted);margin-top:4px;}
.kanban-detail-pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-word;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:6px;margin:6px 0 0;color:var(--muted);}
.kanban-detail-empty{font-size:12px;color:var(--muted);}
@@ -4323,7 +4332,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-card-stale-amber{border-color:rgba(245,197,66,.55)}
.kanban-card-stale-red{border-color:rgba(255,95,95,.65)}
.kanban-column.drop-target{outline:2px solid var(--accent);outline-offset:-2px}
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}.hermes-kanban-md h1,.hermes-kanban-md h2,.hermes-kanban-md h3,.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{margin:10px 0 4px;font-weight:650;color:var(--text)}.hermes-kanban-md h1{font-size:15px}.hermes-kanban-md h2{font-size:14px}.hermes-kanban-md h3{font-size:13px}.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{font-size:12px}.hermes-kanban-md ul,.hermes-kanban-md ol{margin:4px 0;padding-left:20px}.hermes-kanban-md li{margin:2px 0}.hermes-kanban-md li.checked{opacity:.6}.hermes-kanban-md li input[type=checkbox]{margin:0 4px 0 0;vertical-align:middle}.hermes-kanban-md table{border-collapse:collapse;margin:6px 0;font-size:11px;width:100%}.hermes-kanban-md td{border:1px solid var(--border);padding:4px 6px;vertical-align:top}.hermes-kanban-md blockquote{margin:4px 0;padding:2px 8px;border-left:3px solid var(--accent);color:var(--muted);font-size:12px}.hermes-kanban-md pre{background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:8px;margin:6px 0;overflow-x:auto;font-size:11px;line-height:1.4;color:var(--text)}.hermes-kanban-md hr{border:none;border-top:1px solid var(--border);margin:8px 0}
@media (max-width: 640px){
.kanban-board{scroll-snap-type:x mandatory;}
+93 -6
View File
@@ -2208,6 +2208,7 @@ function _startCompressionElapsedTimer(){if(!_compressionElapsedTimer)_compressi
function _clearCompressionElapsedTimer(){if(_compressionElapsedTimer){clearInterval(_compressionElapsedTimer);_compressionElapsedTimer=null;}}
let _activityElapsedTimer=null;
let _activityElapsedTimerGroup=null;
function _activityNowSeconds(){return Date.now()/1000;}
function _activityElapsedStartedAt(group){
if(!group)return null;
const raw=(group.dataset&&group.dataset.turnStartedAt!==undefined&&group.dataset.turnStartedAt!=='')
@@ -2219,7 +2220,52 @@ function _activityElapsedStartedAt(group){
function _activityElapsedLabel(group){
const started=_activityElapsedStartedAt(group);
if(!started)return'';
return _formatActiveElapsedTimer((Date.now()/1000)-started);
return _formatActiveElapsedTimer(_activityNowSeconds()-started);
}
function _activityMarkObserved(group, ts){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
const stamp=Number(ts||_activityNowSeconds());
if(Number.isFinite(stamp)&&stamp>0) group.setAttribute('data-last-activity-at',String(stamp));
}
function _activityLastObservedAge(group){
const stamp=Number(group&&group.getAttribute('data-last-activity-at'));
if(!Number.isFinite(stamp)||stamp<=0)return null;
return Math.max(0,_activityNowSeconds()-stamp);
}
function _activityClockLabel(ts){
const stamp=Number(ts||_activityNowSeconds());
if(!Number.isFinite(stamp)||stamp<=0)return'';
try{return new Date(stamp*1000).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});}catch(_){return'';}
}
function _activityStatusNode({kind='info',label='',detail='',status='done',ts=null,id=''}){
const row=document.createElement('div');
row.className=`agent-activity-status agent-activity-status-${kind} agent-activity-status-${status}`;
if(id) row.setAttribute('data-activity-event-id',id);
if(ts) row.setAttribute('data-activity-at',String(ts));
const iconMap={run:li('play',13),model:li('bot',13),waiting:'<span class="tool-card-running-dot"></span>',thinking:li('lightbulb',13),tool:li('wrench',13),done:li('check',13),warning:li('alert-triangle',13)};
row.innerHTML=`<span class="agent-activity-status-icon">${iconMap[kind]||li('clock',13)}</span><span class="agent-activity-status-copy"><span class="agent-activity-status-label">${esc(label)}</span>${detail?`<span class="agent-activity-status-detail">${esc(detail)}</span>`:''}</span><span class="agent-activity-status-time">${esc(_activityClockLabel(ts))}</span>`;
return row;
}
function _appendActivityEvent(group, event){
if(!group)return null;
const body=group.querySelector('.tool-call-group-body');
if(!body)return null;
const eventId=event&&event.id;
let row=eventId?body.querySelector(`.agent-activity-status[data-activity-event-id="${CSS.escape(eventId)}"]`):null;
const next=_activityStatusNode(event||{});
if(row){row.replaceWith(next);row=next;}
else{body.appendChild(next);row=next;}
_activityMarkObserved(group,event&&event.ts);
return row;
}
function _ensureLiveActivityBaseline(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
const started=_activityElapsedStartedAt(group)||_activityNowSeconds();
if(!group.getAttribute('data-turn-started-at')) group.setAttribute('data-turn-started-at',String(started));
if(!group.getAttribute('data-last-activity-at')) group.setAttribute('data-last-activity-at',String(started));
_appendActivityEvent(group,{id:'run-started',kind:'run',label:'Run started',detail:'Observable activity will appear here as the agent works.',status:'done',ts:started});
const modelLabel=(S.session&&S.session.model)?getModelLabel(S.session.model):'';
if(modelLabel)_appendActivityEvent(group,{id:'run-model',kind:'model',label:`Model: ${modelLabel}`,detail:S.activeProfile&&S.activeProfile!=='default'?`Profile: ${S.activeProfile}`:'',status:'done',ts:started});
}
function _setActivityElapsedStartedAt(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
@@ -2240,8 +2286,10 @@ function _updateActiveActivityElapsedTimer(){
group.removeAttribute('data-active-turn-elapsed');
}
if(durationEl){
durationEl.textContent=label?`Working ${label}`:'';
durationEl.style.display=label?'':'none';
const activeText=label?`Working for ${label}`:'';
const progressText=_activityLiveProgressLabel(group);
durationEl.textContent=[progressText, activeText].filter(Boolean).join(' · ');
durationEl.style.display=durationEl.textContent?'':'none';
}
}
function _startActivityElapsedTimer(group){
@@ -5220,7 +5268,10 @@ function ensureActivityGroup(inner, opts){
}else if(activityKey&&!group.getAttribute('data-activity-disclosure-key')){
group.setAttribute('data-activity-disclosure-key',activityKey);
}
if(live) _setActivityElapsedStartedAt(group);
if(live){
_setActivityElapsedStartedAt(group);
_ensureLiveActivityBaseline(group);
}
_syncToolCallGroupSummary(group);
if(live) _startActivityElapsedTimer(group);
return group;
@@ -6658,7 +6709,9 @@ function _syncToolCallGroupSummary(group){
const label=group.querySelector('.tool-call-group-label');
const durationEl=group.querySelector('.tool-call-group-duration');
if(label){
if(toolCount) label.textContent=`Activity: ${toolCount} tool${toolCount===1?'':'s'}`;
if(group.getAttribute('data-live-tool-call-group')==='1'){
label.textContent=toolCount?`Activity: ${toolCount} tool${toolCount===1?'':'s'}`:'Activity · Running';
}else if(toolCount) label.textContent=`Activity: ${toolCount} tool${toolCount===1?'':'s'}`;
else label.textContent='Activity';
label.setAttribute('data-sweep-label', label.textContent);
}
@@ -6692,9 +6745,14 @@ function _activityProgressLabelForToolName(name){
function _activityLiveProgressLabel(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1') return '';
const idleAge=_activityLastObservedAge(group);
if(idleAge!==null&&idleAge>=90) return `No recent activity for ${_formatActiveElapsedTimer(idleAge)}`;
const running=group.querySelector('.tool-card.tool-card-running .tool-card-name');
const latest=running || Array.from(group.querySelectorAll('.tool-card-name')).pop();
return _activityProgressLabelForToolName(latest?latest.textContent:'');
const waiting=group.querySelector('.agent-activity-status-waiting .agent-activity-status-label');
if(latest) return _activityProgressLabelForToolName(latest.textContent);
if(waiting&&waiting.textContent) return waiting.textContent;
return 'Starting agent';
}
// ── Live tool card helpers (called during SSE streaming) ──
@@ -6760,6 +6818,19 @@ function appendLiveToolCard(tc){
const anchor=children.filter(el=>el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')).pop();
const group=ensureActivityGroup(inner,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()});
const body=group.querySelector('.tool-call-group-body');
const toolName=_toolDisplayName(tc);
const toolEventId=tid?`tool-${tid}`:`tool-${String(tc.name||'tool').replace(/[^a-z0-9_-]/gi,'_')}`;
const toolDone=tc.done!==false;
_appendActivityEvent(group,{
id:toolEventId,
kind:'tool',
label:toolDone?`Tool finished: ${toolName}`:`Running tool: ${toolName}`,
detail:tc.preview||tc.snippet||'',
status:toolDone?(tc.is_error?'error':'done'):'waiting',
ts:_activityNowSeconds(),
});
const waiting=body.querySelector('.agent-activity-status[data-activity-event-id="thinking-placeholder"] .agent-activity-status-label');
if(waiting&&!toolDone) waiting.textContent='Waiting on tool result';
// Update existing card in place (tool_complete after tool_start)
if(tid){
const existing=body.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
@@ -7584,6 +7655,7 @@ function appendThinking(text='', options){
return;
}
const thinkingText=String(text||'').trim()||'Thinking…';
const cleanThinking=_sanitizeThinkingDisplayText(thinkingText);
const allChildren=Array.from(blocks.children);
const anchor=allChildren.filter(el=>
el.id!=='toolRunningRow' &&
@@ -7592,6 +7664,20 @@ function appendThinking(text='', options){
const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()});
const body=group&&group.querySelector('.tool-call-group-body');
if(!body) return;
if(!cleanThinking||cleanThinking==='Thinking…'){
const label=body.querySelector('.tool-card.tool-card-running')?'Waiting on tool result':'Waiting on model';
const detail=body.querySelector('.tool-card-row')
? 'The agent is running; tool results and response text will appear here.'
: 'No tool activity has been reported yet.';
_appendActivityEvent(group,{id:'thinking-placeholder',kind:'waiting',label,detail,status:'waiting',ts:_activityNowSeconds()});
const active=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
if(active) active.removeAttribute('data-thinking-active');
_syncToolCallGroupSummary(group);
scrollIfPinned();
return;
}
const placeholder=body.querySelector('.agent-activity-status[data-activity-event-id="thinking-placeholder"]');
if(placeholder) placeholder.remove();
let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
if(!row){
const thinkingCards=Array.from(body.querySelectorAll('.agent-activity-thinking'));
@@ -7605,6 +7691,7 @@ function appendThinking(text='', options){
}else{
_renderThinkingInto(row,thinkingText);
}
_activityMarkObserved(group);
_syncToolCallGroupSummary(group);
scrollIfPinned();
if(_scrollPinned){
+45
View File
@@ -0,0 +1,45 @@
"""Regression coverage for live Activity timeline UX.
The live Activity disclosure should surface observable run telemetry instead of a
blank Thinking placeholder while preserving the quiet tool/thinking metadata
family.
"""
import pathlib
REPO = pathlib.Path(__file__).parent.parent
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
def test_live_activity_group_has_observable_baseline_events():
assert "function _ensureLiveActivityBaseline(group)" in UI_JS
assert "Run started" in UI_JS
assert "Observable activity will appear here as the agent works." in UI_JS
assert "Model: ${modelLabel}" in UI_JS
assert "_ensureLiveActivityBaseline(group);" in UI_JS
def test_empty_thinking_placeholder_becomes_status_row_not_raw_thinking_card():
assert "data-activity-event-id=\"thinking-placeholder\"" in UI_JS
assert "Waiting on model" in UI_JS
assert "No tool activity has been reported yet." in UI_JS
assert "Waiting on tool result" in UI_JS
assert "_thinkingActivityNode(thinkingText, false)" in UI_JS
def test_tool_events_update_activity_timeline_and_summary():
assert "Tool finished: ${toolName}" in UI_JS
assert "Running tool: ${toolName}" in UI_JS
assert "No recent activity for ${_formatActiveElapsedTimer(idleAge)}" in UI_JS
assert "Activity · Running" in UI_JS
assert "Working for ${label}" in UI_JS
def test_activity_status_rows_have_quiet_metadata_styling():
assert ".agent-activity-status{" in STYLE_CSS
assert "grid-template-columns:18px minmax(0,1fr) auto" in STYLE_CSS
assert ".agent-activity-status-detail" in STYLE_CSS
assert ".agent-activity-status-time" in STYLE_CSS
assert ".agent-activity-status-error .agent-activity-status-label{color:var(--error);}" in STYLE_CSS