Skip to content

Commit 00bbc3f

Browse files
authored
Merge pull request #19 from bertheto/feat/author-grouped-bubbles
feat(ui): group consecutive messages by author
2 parents 20e0d86 + 7adfb65 commit 00bbc3f

3 files changed

Lines changed: 81 additions & 56 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, it, expect } from 'vitest';
2+
import '../../../src/static/js/shared-utils.js';
3+
4+
const { shouldGroupWithPrevious } = window.AcbUtils;
5+
6+
const T0 = '2026-03-01T12:00:00.000Z';
7+
const T1 = '2026-03-01T12:02:00.000Z'; // +2 min (within group)
8+
const T2 = '2026-03-01T12:06:00.000Z'; // +6 min (breaks group)
9+
10+
describe('shouldGroupWithPrevious', () => {
11+
it('returns true when same author within 5 minutes', () => {
12+
expect(shouldGroupWithPrevious('agent-a', T0, 'agent-a', T1, false, false)).toBe(true);
13+
});
14+
15+
it('returns false when authors differ', () => {
16+
expect(shouldGroupWithPrevious('agent-a', T0, 'agent-b', T1, false, false)).toBe(false);
17+
});
18+
19+
it('returns false when time gap is >= 5 minutes', () => {
20+
expect(shouldGroupWithPrevious('agent-a', T0, 'agent-a', T2, false, false)).toBe(false);
21+
});
22+
23+
it('returns false when isSystem is true', () => {
24+
expect(shouldGroupWithPrevious('system', T0, 'system', T1, true, false)).toBe(false);
25+
});
26+
27+
it('returns false when isHuman is true', () => {
28+
expect(shouldGroupWithPrevious('human', T0, 'human', T1, false, true)).toBe(false);
29+
});
30+
31+
it('returns false when prevAuthorKey is null (first message in thread)', () => {
32+
expect(shouldGroupWithPrevious(null, null, 'agent-a', T0, false, false)).toBe(false);
33+
});
34+
});

src/static/index.html

Lines changed: 38 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!DOCTYPE html>
1+
<!DOCTYPE html>
22
<html lang="en">
33

44
<head>
@@ -10,7 +10,8 @@
1010
<link
1111
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
1212
rel="stylesheet" />
13-
<style>
13+
<link rel="stylesheet" href="/static/css/main.css" />
14+
<style>PLACEHOLDER_TO_REMOVE
1415
/* ====================================================================================
1516
Reset & Base
1617
==================================================================================== */
@@ -707,6 +708,15 @@
707708
cursor: pointer;
708709
}
709710

711+
/* ---- Author grouping ---- */
712+
.msg-group-cont {
713+
margin-top: -10px;
714+
}
715+
716+
.msg-group-cont .msg-avatar {
717+
visibility: hidden;
718+
}
719+
710720
.msg-col {
711721
display: flex;
712722
flex-direction: column;
@@ -1124,14 +1134,16 @@
11241134
/** Agent Status Bar **/
11251135
#agent-status-bar {
11261136
display: flex;
1137+
align-items: center;
11271138
background: var(--bg-base);
11281139
border-top: 1px solid var(--border-light);
1129-
padding: 8px 12px;
1130-
height: 56px;
1140+
padding: 6px 12px;
1141+
min-height: 64px;
11311142
overflow-x: auto;
1132-
overflow-y: hidden;
1143+
overflow-y: visible;
11331144
gap: 10px;
11341145
font-size: 12px;
1146+
flex-shrink: 0;
11351147
}
11361148

11371149
#agent-status-list {
@@ -1663,8 +1675,8 @@
16631675
<script src="/static/js/components/acb-compose-shell.js?v=3"></script>
16641676
<script src="/static/js/components/acb-agent-status-shell.js?v=2"></script>
16651677
<script src="/static/js/shared-threads.js?v=2"></script>
1666-
<script src="/static/js/shared-agent-status.js?v=2"></script>
1667-
<script src="/static/js/components/acb-agent-status-item.js?v=2"></script>
1678+
<script src="/static/js/shared-agent-status.js?v=3"></script>
1679+
<script src="/static/js/components/acb-agent-status-item.js?v=3"></script>
16681680
<script src="/static/js/shared-agents.js?v=2"></script>
16691681
<script src="/static/js/shared-chat.js?v=2"></script>
16701682
<script src="/static/js/shared-tooltip.js?v=2"></script>
@@ -1909,35 +1921,6 @@
19091921
// SSE
19101922
// ==============================================================================================
19111923
function startSSE() {
1912-
const es = new EventSource('/events');
1913-
es.onopen = () => setConnected(true);
1914-
es.onerror = () => { setConnected(false); setTimeout(startSSE, 3000); es.close(); };
1915-
1916-
es.onmessage = async (e) => {
1917-
const ev = JSON.parse(e.data);
1918-
const p = ev.payload;
1919-
1920-
if (ev.type === 'msg.new') {
1921-
if (p.thread_id === activeThreadId) await loadNewMessages();
1922-
await refreshThreads(); // update sidebar counts
1923-
}
1924-
if (ev.type === 'thread.new' || ev.type === 'thread.state' || ev.type === 'thread.closed' || ev.type === 'thread.archived' || ev.type === 'thread.deleted') {
1925-
await refreshThreads();
1926-
if (ev.type === 'thread.deleted' && p.thread_id === activeThreadId) {
1927-
activeThreadId = null;
1928-
document.getElementById('msg-pane').innerHTML = '';
1929-
const hdr = document.getElementById('thread-header');
1930-
if (hdr) hdr.textContent = '';
1931-
}
1932-
}
1933-
if (ev.type === 'agent.online' || ev.type === 'agent.offline') {
1934-
await refreshAgents();
1935-
}
1936-
if (ev.type === 'agent.typing' && p.thread_id === activeThreadId) {
1937-
if (p.is_typing) showTyping(p.agent_id);
1938-
else hideTyping(p.agent_id);
1939-
}
1940-
};
19411924
return window.AcbSSE.startSSE({
19421925
getActiveThreadId: () => activeThreadId,
19431926
onMsgNew: async () => loadNewMessages(),
@@ -2073,7 +2056,7 @@
20732056

20742057
function openThreadContextMenu(event, thread) {
20752058
contextMenuThread = window.AcbThreads.openThreadContextMenu(event, thread);
2076-
// 保存右键点击的位置,用于确认对话框定位
2059+
// Store right-click position for confirm dialog positioning
20772060
contextMenuThread._clickX = event.clientX;
20782061
contextMenuThread._clickY = event.clientY;
20792062
}
@@ -2213,9 +2196,18 @@
22132196
const avatarEmoji = window.AcbUtils?.getAgentAvatarEmoji ? window.AcbUtils.getAgentAvatarEmoji(authorKey) : '🤖';
22142197
const bgAlpha = `${color}22`; // ~13% alpha tint for avatar bg
22152198

2199+
// ---- Author grouping: detect if this message continues a group ----
2200+
const prevRow = box.querySelector('.msg-row[data-seq]:last-of-type') ?? [...box.querySelectorAll('.msg-row[data-seq]')].at(-1);
2201+
const prevAuthorKey = prevRow?.getAttribute('data-author-id') ?? null;
2202+
const prevTimestamp = prevRow?.getAttribute('data-created-at') ?? null;
2203+
const isContinuation = window.AcbUtils?.shouldGroupWithPrevious
2204+
? window.AcbUtils.shouldGroupWithPrevious(prevAuthorKey, prevTimestamp, authorKey, m.created_at, isSystem, isHuman)
2205+
: false;
2206+
22162207
const row = document.createElement('div');
2217-
row.className = `msg-row ${isHuman ? 'msg-row-right' : 'msg-row-left'}`;
2208+
row.className = `msg-row ${isHuman ? 'msg-row-right' : 'msg-row-left'} ${isContinuation ? 'msg-group-cont' : 'msg-group-start'}`;
22182209
row.setAttribute('data-author-id', String(m.author_id ?? authorLabel));
2210+
row.setAttribute('data-created-at', String(m.created_at ?? ''));
22192211
row.setAttribute('data-seq', String(m.seq));
22202212

22212213
// Check if this message has mentions (look in metadata if string/object)
@@ -2231,10 +2223,10 @@
22312223
row.innerHTML = `
22322224
<div class="msg-avatar" style="background:${bgAlpha};color:${color};border:1px solid ${color}44">${avatarEmoji}</div>
22332225
<div class="msg-col">
2234-
<div class="msg-header">
2226+
${isContinuation ? '' : `<div class="msg-header">
22352227
<span class="msg-author-label" style="color:${color}">${esc(authorLabel)}</span>
22362228
<span class="msg-time-label">seq ${m.seq} ${fmtTime(m.created_at)}</span>
2237-
</div>
2229+
</div>`}
22382230
<div class="bubble-v2" style="border-color:${color}55;${isHuman ? 'border-right-color' : 'border-left-color'}:${color}"></div>
22392231
</div>`;
22402232

@@ -2244,27 +2236,20 @@
22442236

22452237
// Render images if any
22462238
let images = null;
2247-
console.log(`[Bubble] Message metadata:`, m.metadata, `Type: ${typeof m.metadata}`);
22482239
if (m.metadata) {
2249-
// Parse metadata if it's a string
22502240
if (typeof m.metadata === 'string') {
22512241
try {
22522242
const meta = JSON.parse(m.metadata);
22532243
images = meta.images;
2254-
console.log(`[Bubble] Parsed metadata as string, images:`, images);
22552244
} catch (e) {
2256-
console.error(`[Bubble] Failed to parse metadata JSON:`, e);
2245+
console.warn(`[Bubble] Failed to parse metadata JSON:`, e);
22572246
}
22582247
} else if (typeof m.metadata === 'object') {
22592248
images = m.metadata.images;
2260-
console.log(`[Bubble] Got metadata as object, images:`, images);
22612249
}
2262-
} else {
2263-
console.log(`[Bubble] No metadata in message`);
22642250
}
22652251

22662252
if (images && Array.isArray(images) && images.length > 0) {
2267-
console.log(`[Bubble] Rendering ${images.length} images`);
22682253
const imgContainer = document.createElement('div');
22692254
imgContainer.style.cssText = 'display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px;';
22702255
images.forEach(img => {
@@ -2274,11 +2259,8 @@
22742259
imgEl.style.cssText = 'width: min(520px, 80vw); max-width: 100%; max-height: 70vh; border-radius: 8px; object-fit: contain; cursor: zoom-in; background: rgba(0,0,0,0.05);';
22752260
imgEl.onclick = () => window.open(img.url, '_blank');
22762261
imgContainer.appendChild(imgEl);
2277-
console.log(`[Bubble] Added image: ${img.url}`);
22782262
});
22792263
bubbleEl.appendChild(imgContainer);
2280-
} else {
2281-
console.log(`[Bubble] No images to render`);
22822264
}
22832265
}
22842266

@@ -2384,23 +2366,23 @@
23842366
}
23852367

23862368
function getAgentState(agent) {
2387-
// 优先使用last_activity_time判断状态,而非is_online(后者基于heartbeat超时)
2369+
// Prefer last_activity_time for state detection; is_online relies on heartbeat timeout
23882370
const activityTime = agent.last_activity_time ? new Date(agent.last_activity_time) : null;
23892371
const now = new Date();
23902372

23912373
if (!activityTime) {
2392-
// 完全没有活动记录
2374+
// No activity record at all
23932375
return agent.is_online ? 'Waiting' : 'Offline';
23942376
}
23952377

23962378
const secondsAgo = (now - activityTime) / 1000;
23972379

2398-
// 根据活动类型和时间判断状态
2380+
// Determine state based on activity type and elapsed time
23992381
if (agent.last_activity === 'msg_wait' && secondsAgo < 60) return 'Waiting';
24002382
if (secondsAgo < 30) return 'Active';
2401-
if (secondsAgo < 300) return 'Idle'; // 5σêΘÆƒσൣëµ┤╗σè¿τ«ù Idle
2383+
if (secondsAgo < 300) return 'Idle'; // active within 5 minutes → Idle
24022384

2403-
// Φ╢àΦ┐ç5σêΘÆƒµ▓íµ┤╗σè¿Σ╕öis_online=falseµëìµÿ╛τñ║Offline
2385+
// More than 5 minutes inactive and is_online=false → Offline
24042386
return agent.is_online ? 'Idle' : 'Offline';
24052387
}
24062388

src/static/js/shared-utils.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,14 @@
217217
return color;
218218
}
219219

220+
function shouldGroupWithPrevious(prevAuthorKey, prevTimestamp, currentAuthorKey, currentTimestamp, isSystem, isHuman) {
221+
if (isSystem || isHuman) return false;
222+
if (!prevAuthorKey || prevAuthorKey !== currentAuthorKey) return false;
223+
if (!prevTimestamp || !currentTimestamp) return false;
224+
const deltaMs = new Date(currentTimestamp) - new Date(prevTimestamp);
225+
return deltaMs < 5 * 60 * 1000;
226+
}
227+
220228
window.AcbUtils = {
221229
escapeHtml,
222230
esc,
@@ -226,5 +234,6 @@
226234
copyTextWithFallback,
227235
authorColor,
228236
getAgentAvatarEmoji,
237+
shouldGroupWithPrevious,
229238
};
230239
})();

0 commit comments

Comments
 (0)