Skip to content
This repository was archived by the owner on May 30, 2026. It is now read-only.

Commit 4dafe79

Browse files
AntonAnton
authored andcommitted
v4.7.0: simplify chat progress UX
Make the live task card read like a user-facing chat surface instead of exposing raw LLM and tool telemetry. Keep the technical event stream as internal fallback state so progress stays concise, readable, and easier to expand. Made-with: Cursor
1 parent 81ff70e commit 4dafe79

4 files changed

Lines changed: 331 additions & 74 deletions

File tree

tests/test_chat_logs_ui.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ def test_chat_progress_updates_route_into_live_card():
1414
source = _read("web/modules/chat.js")
1515

1616
assert "liveCard.id = 'chat-live-card';" in source
17+
assert "summarizeChatLiveEvent" in source
18+
assert "Show details" in source
1719
assert "if (msg.is_progress) {" in source
1820
assert "updateLiveCardFromProgressMessage(msg);" in source
1921
assert "ws.on('log', (msg) => {" in source
@@ -32,6 +34,7 @@ def test_logs_use_shared_log_event_helpers_and_group_task_cards():
3234
assert "createTaskGroupCard" in logs_source
3335
assert "renderTaskTimeline" in logs_source
3436
assert "export function summarizeLogEvent" in shared_source
37+
assert "export function summarizeChatLiveEvent" in shared_source
3538
assert "export function isGroupedTaskEvent" in shared_source
3639
assert "export function getLogTaskGroupId" in shared_source
3740

@@ -45,6 +48,8 @@ def test_styles_cover_chat_header_controls_and_grouped_cards():
4548
assert ".chat-live-card {" in css
4649
assert '.chat-live-card[data-finished="1"] {' in css
4750
assert ".chat-live-timeline {" in css
51+
assert ".chat-live-toggle {" in css
52+
assert ".chat-live-card[open] .chat-live-chevron {" in css
4853
assert ".log-task-card {" in css
4954
assert ".log-task-timeline {" in css
5055

web/modules/chat.js

Lines changed: 100 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { escapeHtml, renderMarkdown } from './utils.js';
22
import {
3-
duplicateLogEventKey,
43
getLogTaskGroupId,
54
isGroupedTaskEvent,
65
normalizeLogTs,
7-
summarizeLogEvent,
6+
summarizeChatLiveEvent,
87
} from './log_events.js';
98

109
const CHAT_STORAGE_KEY = 'ouro_chat';
@@ -94,9 +93,17 @@ export function initChat({ ws, state, updateUnreadBadge }) {
9493
liveCard.innerHTML = `
9594
<summary>
9695
<div class="chat-live-summary">
97-
<span class="chat-live-phase info" data-live-phase>idle</span>
98-
<span class="chat-live-title" data-live-title>Waiting for work</span>
99-
<span class="chat-live-count" data-live-count hidden>0 updates</span>
96+
<div class="chat-live-summary-main">
97+
<span class="chat-live-phase working" data-live-phase>Working</span>
98+
<span class="chat-live-title" data-live-title>Waiting for work</span>
99+
</div>
100+
<div class="chat-live-summary-side">
101+
<span class="chat-live-count" data-live-count hidden>2 notes</span>
102+
<span class="chat-live-toggle" data-live-toggle>Show details</span>
103+
<svg class="chat-live-chevron" width="14" height="14" viewBox="0 0 20 20" fill="none" aria-hidden="true">
104+
<path d="M5 7.5 10 12.5 15 7.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
105+
</svg>
106+
</div>
100107
</div>
101108
<div class="chat-live-meta" data-live-meta></div>
102109
</summary>
@@ -106,13 +113,16 @@ export function initChat({ ws, state, updateUnreadBadge }) {
106113
const liveCardTitle = liveCard.querySelector('[data-live-title]');
107114
const liveCardCount = liveCard.querySelector('[data-live-count]');
108115
const liveCardMeta = liveCard.querySelector('[data-live-meta]');
116+
const liveCardToggle = liveCard.querySelector('[data-live-toggle]');
109117
const liveCardTimeline = liveCard.querySelector('[data-live-timeline]');
110118
const liveCardState = {
111119
groupId: '',
112120
updates: 0,
113121
finished: false,
114122
items: [],
123+
lastHumanHeadline: '',
115124
};
125+
liveCard.addEventListener('toggle', syncLiveCardToggle);
116126

117127
function buildMessageKey(role, text, timestamp, opts = {}) {
118128
if (opts.clientMessageId) return `client|${opts.clientMessageId}`;
@@ -221,15 +231,18 @@ export function initChat({ ws, state, updateUnreadBadge }) {
221231
liveCardState.updates = 0;
222232
liveCardState.finished = false;
223233
liveCardState.items = [];
234+
liveCardState.lastHumanHeadline = '';
224235
liveCardTitle.textContent = 'Working...';
225-
liveCardPhase.textContent = 'progress';
226-
liveCardPhase.className = 'chat-live-phase progress';
236+
liveCardPhase.dataset.phase = 'working';
237+
liveCardPhase.textContent = 'Working';
238+
liveCardPhase.className = 'chat-live-phase working';
227239
liveCardCount.hidden = true;
228-
liveCardCount.textContent = '0 updates';
240+
liveCardCount.textContent = '0 notes';
229241
liveCardMeta.innerHTML = '';
230242
liveCardTimeline.innerHTML = '';
231243
liveCard.open = false;
232244
liveCard.dataset.finished = '0';
245+
syncLiveCardToggle();
233246
}
234247

235248
function ensureLiveCardVisible() {
@@ -241,17 +254,27 @@ export function initChat({ ws, state, updateUnreadBadge }) {
241254
insertMessageNode(liveCard);
242255
}
243256

257+
function formatLiveCardPhaseLabel(phase) {
258+
if (phase === 'thinking') return 'Thinking';
259+
if (phase === 'working') return 'Working';
260+
if (phase === 'done') return 'Done';
261+
if (phase === 'error' || phase === 'timeout') return 'Issue';
262+
if (!phase) return 'Working';
263+
return phase.charAt(0).toUpperCase() + phase.slice(1);
264+
}
265+
266+
function syncLiveCardToggle() {
267+
if (!liveCardToggle) return;
268+
liveCardToggle.textContent = liveCard.open ? 'Hide details' : 'Show details';
269+
}
270+
244271
function renderLiveCardTimeline() {
245272
liveCardTimeline.innerHTML = liveCardState.items.map((item) => `
246-
<div class="chat-live-line">
247-
<div class="chat-live-line-main">
248-
<span class="chat-live-line-phase ${item.phase || 'info'}">${escapeHtml(item.phase || 'info')}</span>
273+
<div class="chat-live-line ${item.phase || 'working'}">
274+
<div class="chat-live-line-head">
249275
<span class="chat-live-line-title">${escapeHtml(item.headline)}</span>
250-
<span class="chat-live-line-repeat" ${item.count > 1 ? '' : 'hidden'}>${item.count > 1 ? `x${item.count}` : ''}</span>
251-
</div>
252-
<div class="chat-live-line-meta">
253-
${item.ts ? `<span>${escapeHtml(item.ts)}</span>` : ''}
254-
${item.meta.map((meta) => `<span class="chat-live-pill">${escapeHtml(meta)}</span>`).join('')}
276+
<span class="chat-live-line-repeat" ${item.count > 1 ? '' : 'hidden'}>${item.count > 1 ? `${item.count}x` : ''}</span>
277+
${item.ts ? `<span class="chat-live-line-time">${escapeHtml(item.ts)}</span>` : ''}
255278
</div>
256279
${item.body ? `<div class="chat-live-line-body">${escapeHtml(item.body)}</div>` : ''}
257280
</div>
@@ -270,34 +293,52 @@ export function initChat({ ws, state, updateUnreadBadge }) {
270293
liveCardState.updates += 1;
271294
liveCardState.finished = ['done', 'error', 'timeout'].includes(summary.phase || '');
272295
liveCard.dataset.finished = liveCardState.finished ? '1' : '0';
273-
liveCardPhase.textContent = summary.phase || 'info';
274-
liveCardPhase.className = `chat-live-phase ${summary.phase || 'info'}`;
275-
liveCardTitle.textContent = summary.headline || 'Working...';
276-
liveCardCount.hidden = false;
277-
liveCardCount.textContent = `${liveCardState.updates} updates`;
278-
liveCardMeta.innerHTML = [
279-
nextGroupId === 'bg-consciousness' ? 'background' : `task=${nextGroupId}`,
280-
ts || '',
281-
...summary.meta,
282-
].filter(Boolean).map((item) => `<span class="chat-live-pill">${escapeHtml(item)}</span>`).join('');
283-
284-
const last = liveCardState.items[liveCardState.items.length - 1];
285-
const syntheticKey = dedupeKey || `${summary.phase || 'info'}|${summary.headline || ''}|${summary.body || ''}`;
286-
if (last && last.dedupeKey === syntheticKey) {
287-
last.count += 1;
288-
last.ts = ts || last.ts;
289-
} else {
290-
liveCardState.items.push({
291-
phase: summary.phase || 'info',
292-
headline: summary.headline || 'Update',
293-
body: summary.body || '',
294-
meta: summary.meta || [],
295-
ts: ts || '',
296-
count: 1,
297-
dedupeKey: syntheticKey,
298-
});
299-
if (liveCardState.items.length > 20) liveCardState.items.shift();
296+
const headline = summary.headline || 'Working...';
297+
if (summary.human && headline) {
298+
liveCardState.lastHumanHeadline = headline;
299+
}
300+
301+
const shouldPromote =
302+
Boolean(summary.promote)
303+
|| !liveCardState.lastHumanHeadline
304+
|| liveCardState.finished;
305+
const activeHeadline = shouldPromote
306+
? headline
307+
: (liveCardState.lastHumanHeadline || headline);
308+
const activePhase = liveCardState.finished
309+
? (summary.phase || 'done')
310+
: (shouldPromote ? (summary.phase || 'working') : (liveCardPhase.dataset.phase || 'working'));
311+
312+
liveCardPhase.dataset.phase = activePhase;
313+
liveCardPhase.textContent = formatLiveCardPhaseLabel(activePhase);
314+
liveCardPhase.className = `chat-live-phase ${activePhase}`;
315+
liveCardTitle.textContent = activeHeadline;
316+
317+
const syntheticKey = summary.dedupeKey || dedupeKey || `${summary.phase || 'working'}|${headline}|${summary.body || ''}`;
318+
const shouldRenderLine = summary.visible !== false && Boolean(headline || summary.body);
319+
if (shouldRenderLine) {
320+
const last = liveCardState.items[liveCardState.items.length - 1];
321+
if (last && last.dedupeKey === syntheticKey) {
322+
last.count += 1;
323+
last.ts = ts || last.ts;
324+
} else {
325+
liveCardState.items.push({
326+
phase: summary.phase || 'working',
327+
headline: headline || 'Update',
328+
body: summary.body || '',
329+
ts: ts || '',
330+
count: 1,
331+
dedupeKey: syntheticKey,
332+
});
333+
if (liveCardState.items.length > 20) liveCardState.items.shift();
334+
}
300335
}
336+
liveCardCount.hidden = liveCardState.items.length < 2;
337+
liveCardCount.textContent = `${liveCardState.items.length} notes`;
338+
liveCardMeta.innerHTML = [
339+
nextGroupId === 'bg-consciousness' ? 'Background thinking' : '',
340+
ts ? `Latest ${ts}` : '',
341+
].filter(Boolean).map((item) => `<span class="chat-live-meta-text">${escapeHtml(item)}</span>`).join('');
301342
renderLiveCardTimeline();
302343
insertMessageNode(liveCard);
303344
hideTypingIndicatorOnly();
@@ -309,32 +350,31 @@ export function initChat({ ws, state, updateUnreadBadge }) {
309350
}
310351

311352
function updateLiveCardFromProgressMessage(msg) {
312-
const content = String(msg?.content || '').replace(/^💬\s*/, '').trim();
313-
if (!content) return;
353+
const summary = summarizeChatLiveEvent({
354+
type: 'send_message',
355+
is_progress: true,
356+
content: msg?.content || '',
357+
text: msg?.content || '',
358+
task_id: liveCardState.groupId || 'chat',
359+
});
360+
if (!summary) return;
314361
applyLiveCardState(
315-
{
316-
phase: 'progress',
317-
headline: content,
318-
body: '',
319-
meta: ['thought'],
320-
},
362+
summary,
321363
liveCardState.groupId || 'chat',
322364
normalizeLogTs(msg.ts || new Date().toISOString()),
323-
`progress:${content}`,
365+
summary.dedupeKey || '',
324366
);
325367
}
326368

327369
function updateLiveCardFromLogEvent(evt) {
328370
if (!evt || !isGroupedTaskEvent(evt)) return;
329-
const summary = summarizeLogEvent(evt);
330-
const dedupeKey = evt.type === 'send_message'
331-
? `progress:${summary.headline || ''}`
332-
: (duplicateLogEventKey(evt) || `${evt.type || evt.event || 'event'}|${summary.phase || ''}|${summary.headline || ''}`);
371+
const summary = summarizeChatLiveEvent(evt);
372+
if (!summary) return;
333373
applyLiveCardState(
334374
summary,
335375
getLogTaskGroupId(evt) || liveCardState.groupId || 'active',
336376
normalizeLogTs(evt.ts || evt.timestamp),
337-
dedupeKey,
377+
summary.dedupeKey || '',
338378
);
339379
}
340380

@@ -621,8 +661,9 @@ export function initChat({ ws, state, updateUnreadBadge }) {
621661
if (liveCardState.groupId && !liveCardState.finished) {
622662
liveCardState.finished = true;
623663
liveCard.dataset.finished = '1';
624-
if (liveCardPhase.textContent === 'progress' || liveCardPhase.textContent === 'calling') {
625-
liveCardPhase.textContent = 'done';
664+
if (['working', 'thinking'].includes(liveCardPhase.dataset.phase || '')) {
665+
liveCardPhase.dataset.phase = 'done';
666+
liveCardPhase.textContent = 'Done';
626667
liveCardPhase.className = 'chat-live-phase done';
627668
}
628669
}

0 commit comments

Comments
 (0)