diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js
index af60947..0d839de 100644
--- a/wwwroot/js/history.js
+++ b/wwwroot/js/history.js
@@ -161,6 +161,7 @@ function _mergeConsecutiveAssistantTexts(msgs) {
const prev = result.length > 0 ? result[result.length - 1] : null;
if (prev && prev.type === 'text' && prev.role === 'assistant'
&& prev.iteration === msg.iteration
+ && !msg.tag && !prev.tag
&& !_isReviewMessage(msg.content) && !_isReviewMessage(prev.content)) {
prev.content = (prev.content || '') + ' ' + (msg.content || '');
if (msg.id) prev.id = msg.id;
@@ -187,6 +188,18 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
const archived = opts && opts.archived;
for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) {
const msg = msgs[_msgIdx];
+
+ // ── Grouped Review/Preparer/Finalizer Bubble (tag-based) ──
+ if (msg.tag && (msg.tag === 'review' || msg.tag === 'preparer' || msg.tag === 'finalizer')) {
+ // Collect all consecutive messages with the same tag
+ var tagGroup = [msg];
+ while (_msgIdx + 1 < msgs.length && msgs[_msgIdx + 1].tag === msg.tag) {
+ _msgIdx++;
+ tagGroup.push(msgs[_msgIdx]);
+ }
+ fragment.appendChild(_renderReviewGroup(tagGroup, archived));
+ continue;
+ }
switch (msg.type) {
case 'text': {
const isReviewMsg = _isReviewMessage(msg.content);
@@ -266,6 +279,77 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
}
}
+/**
+ * Renders a group of consecutive messages with the same tag (review/preparer/finalizer)
+ * into a single Review-Bubble matching the streaming UI structure.
+ */
+function _renderReviewGroup(tagGroup, archived) {
+ var tag = tagGroup[0].tag;
+ var bubble = document.createElement('div');
+ bubble.className = 'msg review-bubble' + (archived ? ' archived' : '');
+ if (tag === 'finalizer') bubble.classList.add('finalizer-bubble');
+ if (tag === 'preparer') bubble.classList.add('preparer-bubble');
+
+ // Extract agent name from first text message: "[Review: AgentName] text..."
+ var agentName = tag === 'preparer' ? 'Vorbereiter-Agent' : tag === 'finalizer' ? 'Finalizer-Agent' : 'Review-Agent';
+ var firstText = tagGroup.find(function(m) { return m.type === 'text' && m.role === 'assistant'; });
+ if (firstText) {
+ var nameMatch = firstText.content.match(/^\[(Review|Preparer|Finalizer): ([^\]]+)\]/);
+ if (nameMatch) agentName = nameMatch[2];
+ }
+
+ // Header
+ var header = document.createElement('div');
+ header.className = 'review-header';
+ if (tag === 'preparer') {
+ header.innerHTML = ' ' + esc(agentName) + '';
+ } else if (tag === 'finalizer') {
+ header.innerHTML = ' ' + esc(agentName) + '';
+ } else {
+ header.innerHTML = ' ' + esc(agentName) + '';
+ }
+ bubble.appendChild(header);
+
+ // Content: collect text (strip prefix) and tool-use indicators
+ var textParts = [];
+ for (var i = 0; i < tagGroup.length; i++) {
+ var m = tagGroup[i];
+ if (m.type === 'text' && m.role === 'assistant') {
+ var text = m.content;
+ // Strip [Tag: AgentName] prefix
+ text = text.replace(/^\[(Review|Preparer|Finalizer): [^\]]+\]\s*/, '');
+ if (text) textParts.push(text);
+ } else if (m.type === 'tool_use') {
+ // Insert tool-use indicator
+ var toolDiv = document.createElement('div');
+ toolDiv.className = 'review-tool-use';
+ toolDiv.innerHTML = ' ' + esc(m.toolName) + '';
+ // Flush accumulated text before tool indicator
+ if (textParts.length > 0) {
+ var contentDiv = document.createElement('div');
+ contentDiv.className = 'review-content markdown';
+ contentDiv.innerHTML = renderMarkdown(textParts.join('\n\n'));
+ parseFluentEmojis(contentDiv);
+ bubble.appendChild(contentDiv);
+ textParts = [];
+ }
+ bubble.appendChild(toolDiv);
+ }
+ // tool_result and web_search_result are skipped (same as streaming)
+ }
+
+ // Flush remaining text
+ if (textParts.length > 0) {
+ var contentDiv = document.createElement('div');
+ contentDiv.className = 'review-content markdown';
+ contentDiv.innerHTML = renderMarkdown(textParts.join('\n\n'));
+ parseFluentEmojis(contentDiv);
+ bubble.appendChild(contentDiv);
+ }
+
+ return bubble;
+}
+
function _isReviewMessage(content) {
return content && (content.startsWith('[Review:') || content.startsWith('[Review-Feedback') || content.startsWith('[Finalizer:') || content.startsWith('[Preparer:'));
}