Frontend: TaskCreate/TaskUpdate als aggregierte Checkliste rendern

Die CLI nutzt inzwischen TaskCreate/TaskUpdate statt TodoWrite fuers
Todo-Tracking. Diese fielen in den generischen Tool-Box-Pfad und wurden
als rohe Tool-Calls mit JSON-Input angezeigt statt als Checkliste.

Da die neuen Tools inkrementell sind (ein Call pro Anlage/Statuswechsel),
werden die Einzel-Events zu EINER Checkliste aggregiert:

- tool-ui.js: Task-Tracker (applyTaskEvent/ingestTaskEvent), Box-Aufbau
  mit wiederverwendeten todo-list-Styles + Fortschritts-Badge n/m;
  sealTaskBox/sealLiveTaskList zum Segmentieren
- chat.js: Live-Pfad (SSE) haengt Task-Events in die aggregierte Box
  statt generischer Tool-Boxen; Reset beim Stream-Start
- history.js: Replay-Pfad mit eigenem Tracker pro Chain; zugehoerige
  tool_result-Messages werden uebersprungen (kein Doppel-Render)

Segmentierung: ein zusammenhaengender Task-Lauf bleibt EINE Box. Sobald
dazwischen echter Inhalt gerendert wird (Fliesstext oder anderes Tool),
wird die Box versiegelt und das naechste Task-Event oeffnet eine neue Box
darunter - so bleibt die chronologische Reihenfolge erhalten und
Narration zwischen den Tasks sichtbar. Der Task-Stand bleibt kumulativ,
der Fortschritt zaehlt ueber die Segmente hinweg hoch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 5 committed 2026-07-27 07:27:31 +02:00
1 parent be44235ca9
commit f38f3a42d1
3 files changed
+205 -1

No files matched your search

+18 -1
View File
@@ -42,6 +42,9 @@ function handleStreamEvent(eventType, data) {
break; break;
case 'text_start': case 'text_start':
// Fließtext zwischen Task-Events → aktuelle Task-Box versiegeln, damit das nächste
// Task-Event eine neue Box UNTER dem Text öffnet (chronologische Reihenfolge).
sealLiveTaskList();
if (!_streamDiv) startStreamBubble(); if (!_streamDiv) startStreamBubble();
break; break;
@@ -55,7 +58,13 @@ function handleStreamEvent(eventType, data) {
case 'tool_start': case 'tool_start':
endStreamBubble(); endStreamBubble();
if (isToolVisible(data.name)) { // Task*-Tools werden zu EINER aggregierten Checkliste gebündelt (erst bei tool_end,
// da wir dort Input+Result haben) → keine generische Lade-Box.
if (isTaskListTool(data.name)) {
_currentToolBox = null;
} else if (isToolVisible(data.name)) {
// Anderes Tool zwischen Task-Events → Task-Box versiegeln (nächstes Task-Event = neue Box)
sealLiveTaskList();
_currentToolBox = addToolBoxLoading(data.name); _currentToolBox = addToolBoxLoading(data.name);
} else { } else {
_currentToolBox = null; _currentToolBox = null;
@@ -63,6 +72,12 @@ function handleStreamEvent(eventType, data) {
break; break;
case 'tool_end': case 'tool_end':
if (isTaskListTool(data.name)) {
if (isToolVisible(data.name)) handleLiveTaskEvent(data.name, data.input, data.result);
_currentToolBox = null;
_lastFinishedToolBox = null;
break;
}
if (_currentToolBox) { if (_currentToolBox) {
finishToolBox(_currentToolBox, data); finishToolBox(_currentToolBox, data);
_lastFinishedToolBox = _currentToolBox; _lastFinishedToolBox = _currentToolBox;
@@ -78,6 +93,7 @@ function handleStreamEvent(eventType, data) {
break; break;
case 'web_search_start': case 'web_search_start':
sealLiveTaskList();
pauseStreamBubble(); pauseStreamBubble();
_currentToolBox = addToolBoxLoading('web_search'); _currentToolBox = addToolBoxLoading('web_search');
break; break;
@@ -473,6 +489,7 @@ async function _runStream(fetchPromise) {
_streamingPollInterval = null; _streamingPollInterval = null;
} }
_currentToolBox = null; _currentToolBox = null;
resetLiveTaskList();
_chatAbandoned = false; _chatAbandoned = false;
_stopClickCount = 0; _stopClickCount = 0;
startTypingIndicator(); startTypingIndicator();
+30
View File
@@ -216,6 +216,10 @@ function _mergeConsecutiveAssistantTexts(msgs) {
*/ */
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
const archived = opts && opts.archived; const archived = opts && opts.archived;
// Task*-Tools (TaskCreate/TaskUpdate) → EINE aggregierte Checkliste pro Aufruf. Diese Funktion
// wird pro Chain aufgerufen (siehe loadHistory/loadOlderMessages), daher genügt ein Tracker.
const _taskTracker = makeTaskTracker();
const _taskResultSkip = {};
for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) { for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) {
const msg = msgs[_msgIdx]; const msg = msgs[_msgIdx];
@@ -357,6 +361,24 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
_renderChoiceBubbleInFragment(fragment, msgs, _msgIdx); _renderChoiceBubbleInFragment(fragment, msgs, _msgIdx);
continue; continue;
} }
if (isTaskListTool(msg.toolName)) {
if (!isToolVisible(msg.toolName)) continue;
if (msg.toolCallId) _taskResultSkip[msg.toolCallId] = true;
// Result (für die #N-ID des Create) per toolCallId vorausschauend suchen
var _taskResult = null;
if (msg.toolCallId) {
for (var _tj = _msgIdx + 1; _tj < msgs.length; _tj++) {
if (msgs[_tj].type === 'tool_result' && msgs[_tj].toolCallId === msg.toolCallId) {
_taskResult = msgs[_tj].content; break;
}
}
}
ingestTaskEvent(_taskTracker, msg.toolName, msg.content, _taskResult, function(box) {
if (archived) box.classList.add('archived');
fragment.appendChild(box);
});
continue;
}
if (!isToolVisible(msg.toolName)) continue; if (!isToolVisible(msg.toolName)) continue;
const toolDiv = document.createElement('div'); const toolDiv = document.createElement('div');
toolDiv.className = 'tool-box' + (archived ? ' archived' : ''); toolDiv.className = 'tool-box' + (archived ? ' archived' : '');
@@ -378,6 +400,8 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
} }
case 'tool_result': { case 'tool_result': {
if (msg._choiceHandled) continue; if (msg._choiceHandled) continue;
// Task*-Result wurde bereits in die aggregierte Checkliste eingearbeitet → nicht als Box zeigen
if (msg.toolCallId && _taskResultSkip[msg.toolCallId]) continue;
// Passendes Tool-Box per toolCallId finden (falls verfügbar), // Passendes Tool-Box per toolCallId finden (falls verfügbar),
// sonst Fallback auf letztes Tool-Box (ältere DB-Einträge ohne toolCallId) // sonst Fallback auf letztes Tool-Box (ältere DB-Einträge ohne toolCallId)
let lastToolBox = null; let lastToolBox = null;
@@ -425,6 +449,12 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
default: default:
continue; continue;
} }
// Es wurde echter Inhalt gerendert (Fließtext, anderes Tool, Ergebnis, …) – Task*-Events
// nutzen oben `continue` und erreichen diese Stelle NICHT. Damit wird die aktuelle Task-Box
// versiegelt: ein folgendes Task-Event öffnet eine NEUE Box UNTER dem Zwischeninhalt →
// chronologische Reihenfolge bleibt erhalten (Text zwischen den Tasks sichtbar). Ein
// zusammenhängender Task-Lauf ohne Zwischeninhalt bleibt weiterhin EINE Box.
sealTaskBox(_taskTracker);
} }
} }
+157
View File
@@ -377,6 +377,163 @@ function buildTodoListFromInput(todos, isError) {
return container; return container;
} }
// ── Task-Liste (CLI Task*-Tools) ──
// Die CLI nutzt inzwischen TaskCreate/TaskUpdate (statt des früheren TodoWrite) für das
// Todo-Tracking. Diese Tools sind inkrementell (ein Call pro Task-Anlage/Statuswechsel).
// Damit die frühere Checklisten-Visualisierung erhalten bleibt, aggregieren wir sie zu
// EINER Box, die in-place aktualisiert wird – statt viele generische Tool-Boxen zu zeigen.
/** True für die Task-Tracking-Tools, die zur aggregierten Checkliste gehören. */
function isTaskListTool(rawName) {
return rawName === 'TaskCreate' || rawName === 'TaskUpdate';
}
/** Neuer, leerer Task-Tracker (pro Chain bzw. pro Live-Stream). */
function makeTaskTracker() {
return { tasks: [], byId: {}, box: null };
}
/**
* Wendet ein TaskCreate/TaskUpdate-Event auf den Tracker an.
* - TaskCreate: neuer Task; ID sequentiell (die CLI vergibt #1..#N in Erstell-Reihenfolge),
* ein evtl. vorhandenes Result "Task #N ..." bestätigt/überschreibt die ID.
* - TaskUpdate: setzt den Status des per taskId referenzierten Tasks.
*/
function applyTaskEvent(tracker, rawName, inputJson, resultText) {
var input = null;
try { input = JSON.parse(inputJson); } catch (e) {}
if (!input || typeof input !== 'object') input = {};
if (rawName === 'TaskCreate') {
var id = tracker.tasks.length + 1;
var m = resultText && /Task #(\d+)/.exec(resultText);
if (m) id = parseInt(m[1], 10);
var task = {
id: String(id),
subject: input.subject || input.description || ('Aufgabe ' + id),
status: 'pending'
};
if (!tracker.byId[task.id]) tracker.tasks.push(task);
tracker.byId[task.id] = task;
} else if (rawName === 'TaskUpdate') {
var tid = String(input.taskId != null ? input.taskId : (input.id != null ? input.id : ''));
if (!tid) return tracker;
var existing = tracker.byId[tid];
if (existing) {
if (input.status) existing.status = input.status;
} else {
// Update ohne vorheriges Create (z.B. paginierter History-Ausschnitt) → Platzhalter
var placeholder = { id: tid, subject: 'Aufgabe ' + tid, status: input.status || 'pending' };
tracker.byId[tid] = placeholder;
tracker.tasks.push(placeholder);
}
}
return tracker;
}
/** Rendert die Task-Items (Checkboxen) in den Listen-Container (in-place, leert vorher). */
function renderTaskListItems(list, tracker) {
list.innerHTML = '';
var done = 0;
for (var i = 0; i < tracker.tasks.length; i++) {
var task = tracker.tasks[i];
var status = task.status || 'pending';
if (status === 'completed') done++;
var icon = status === 'completed' ? '✅' : status === 'in_progress' ? '🔄' : '⬜';
var statusClass = status === 'completed' ? 'todo-done' : status === 'in_progress' ? 'todo-progress' : 'todo-pending';
var row = document.createElement('div');
row.className = 'todo-item ' + statusClass;
var iconSpan = document.createElement('span');
iconSpan.className = 'todo-icon';
iconSpan.textContent = icon;
var idSpan = document.createElement('span');
idSpan.className = 'todo-id';
idSpan.textContent = task.id + '.';
var textSpan = document.createElement('span');
textSpan.className = 'todo-text';
textSpan.textContent = task.subject;
row.appendChild(iconSpan);
row.appendChild(idSpan);
row.appendChild(textSpan);
list.appendChild(row);
}
// Fortschritts-Badge im Header aktualisieren
var box = list.parentElement;
var badge = box && box.querySelector('.task-progress');
if (badge) badge.textContent = done + '/' + tracker.tasks.length;
parseFluentEmojis(list);
}
/** Baut die komplette Task-Listen-Box (Header + Checkliste) für den aktuellen Tracker-Stand. */
function buildTaskListBox(tracker) {
var box = document.createElement('div');
box.className = 'tool-box task-list-box';
var header = document.createElement('div');
header.className = 'tool-box-header';
var nameSpan = document.createElement('span');
nameSpan.className = 'tool-name';
nameSpan.textContent = 'Aufgabenliste';
header.appendChild(nameSpan);
var progress = document.createElement('span');
progress.className = 'tool-write-meta task-progress';
header.appendChild(progress);
box.appendChild(header);
var list = document.createElement('div');
list.className = 'todo-list task-list';
box.appendChild(list);
renderTaskListItems(list, tracker);
return box;
}
/**
* Verarbeitet ein Task-Event für einen Tracker und stellt die (einzige) Box sicher:
* beim ersten Event wird sie erzeugt und via appendBox() eingehängt, danach in-place aktualisiert.
*/
function ingestTaskEvent(tracker, rawName, inputJson, resultText, appendBox) {
applyTaskEvent(tracker, rawName, inputJson, resultText);
if (!tracker.box) {
tracker.box = buildTaskListBox(tracker);
appendBox(tracker.box);
} else {
renderTaskListItems(tracker.box.querySelector('.task-list'), tracker);
}
return tracker.box;
}
/**
* "Versiegelt" die aktuelle Task-Box: der Tracker behält seinen Task-Stand (kumulativ),
* aber die nächste Task-Event erzeugt eine NEUE Box statt die alte in-place zu aktualisieren.
* Wird aufgerufen, sobald zwischen zwei Task-Events echter Inhalt (Fließtext, anderes Tool)
* gerendert wurde → so bleibt die chronologische Reihenfolge (Text zwischen den Tasks sichtbar).
* Ein zusammenhängender Task-Lauf ohne Zwischentext bleibt weiterhin EINE Box.
*/
function sealTaskBox(tracker) {
if (tracker) tracker.box = null;
}
// Live-Stream-Tracker (ein aktiver Chain zur Zeit; Reset beim Stream-Start).
var _liveTaskTracker = null;
function resetLiveTaskList() { _liveTaskTracker = null; }
/** Versiegelt die Live-Task-Box (nächstes Task-Event → neue Box). No-op wenn keine offen. */
function sealLiveTaskList() { if (_liveTaskTracker) _liveTaskTracker.box = null; }
/** Live-Handler: nimmt ein TaskCreate/TaskUpdate aus dem SSE-Stream entgegen. */
function handleLiveTaskEvent(rawName, inputJson, resultText) {
if (!_liveTaskTracker) _liveTaskTracker = makeTaskTracker();
ingestTaskEvent(_liveTaskTracker, rawName, inputJson, resultText, function(box) {
chatEl().appendChild(box);
});
scrollDown();
}
function finishToolBox(div, data) { function finishToolBox(div, data) {
if (!div) return; if (!div) return;
renderToolBoxContent(div, data.name, data.input, data.result, data.isError, data.sources); renderToolBoxContent(div, data.name, data.input, data.result, data.isError, data.sources);