Skip to content

Commit 443b120

Browse files
henrypark133claude
andauthored
feat(web): inline tool activity cards with auto-collapsing (#376)
* feat(web): inline tool activity cards with auto-collapsing Add Claude/Codex-style inline tool activity cards to the web UI that show tool execution progress directly in the chat conversation. While processing: - Animated thinking dots with message text (e.g. "Calling LLM...") - Individual tool cards with live spinner and elapsed timer - Cards show tool name, duration, and expandable output preview After response arrives: - Activity group auto-collapses to "Used N tools (Xs)" - Click summary to expand and see individual tool cards - Click card header to see tool output in monospace Also includes: - "Calling LLM..." thinking status from dispatcher (all channels) - 5-minute max timer guard to prevent leaks on dropped SSE - Handles parallel tools, same tool twice, failures, thread switching Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(web): use frozen duration for completed tools in activity summary The collapsed activity summary was showing inflated total duration because finalizeActivityGroup() recalculated elapsed time from Date.now() for already-completed tools. Now each tool card stores its final duration at completion time and the summary uses that frozen value instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2477923 commit 443b120

3 files changed

Lines changed: 478 additions & 8 deletions

File tree

src/agent/dispatcher.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,15 @@ impl Agent {
212212
);
213213
}
214214

215+
let _ = self
216+
.channels
217+
.send_status(
218+
&message.channel,
219+
StatusUpdate::Thinking("Calling LLM...".into()),
220+
&message.metadata,
221+
)
222+
.await;
223+
215224
let output = match reasoning.respond_with_tools(&context).await {
216225
Ok(output) => output,
217226
Err(crate::error::LlmError::ContextLengthExceeded { used, limit }) => {

src/channels/web/static/app.js

Lines changed: 249 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ let jobListRefreshTimer = null;
1515
const JOB_EVENTS_CAP = 500;
1616
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
1717

18+
// --- Tool Activity State ---
19+
let _activeGroup = null;
20+
let _activeToolCards = {};
21+
let _activityThinking = null;
22+
1823
// --- Auth ---
1924

2025
function authenticate() {
@@ -113,6 +118,7 @@ function connectSSE() {
113118
document.getElementById('sse-dot').classList.remove('disconnected');
114119
document.getElementById('sse-status').textContent = 'Connected';
115120
if (sseHasConnectedBefore && currentThreadId) {
121+
finalizeActivityGroup();
116122
loadHistory();
117123
}
118124
sseHasConnectedBefore = true;
@@ -126,6 +132,7 @@ function connectSSE() {
126132
eventSource.addEventListener('response', (e) => {
127133
const data = JSON.parse(e.data);
128134
if (!isCurrentThread(data.thread_id)) return;
135+
finalizeActivityGroup();
129136
addMessage('assistant', data.content);
130137
setStatus('');
131138
enableChatInput();
@@ -136,25 +143,31 @@ function connectSSE() {
136143
eventSource.addEventListener('thinking', (e) => {
137144
const data = JSON.parse(e.data);
138145
if (!isCurrentThread(data.thread_id)) return;
139-
setStatus(data.message, true);
146+
showActivityThinking(data.message);
140147
});
141148

142149
eventSource.addEventListener('tool_started', (e) => {
143150
const data = JSON.parse(e.data);
144151
if (!isCurrentThread(data.thread_id)) return;
145-
setStatus('Running tool: ' + data.name, true);
152+
addToolCard(data.name);
146153
});
147154

148155
eventSource.addEventListener('tool_completed', (e) => {
149156
const data = JSON.parse(e.data);
150157
if (!isCurrentThread(data.thread_id)) return;
151-
const icon = data.success ? '\u2713' : '\u2717';
152-
setStatus('Tool ' + data.name + ' ' + icon);
158+
completeToolCard(data.name, data.success);
159+
});
160+
161+
eventSource.addEventListener('tool_result', (e) => {
162+
const data = JSON.parse(e.data);
163+
if (!isCurrentThread(data.thread_id)) return;
164+
setToolCardOutput(data.name, data.preview);
153165
});
154166

155167
eventSource.addEventListener('stream_chunk', (e) => {
156168
const data = JSON.parse(e.data);
157169
if (!isCurrentThread(data.thread_id)) return;
170+
finalizeActivityGroup();
158171
appendToLastAssistant(data.content);
159172
});
160173

@@ -166,6 +179,7 @@ function connectSSE() {
166179
// the agentic loop finished, so re-enable input as a safety net in case
167180
// the response SSE event is empty or lost.
168181
if (data.message === 'Done' || data.message === 'Awaiting approval') {
182+
finalizeActivityGroup();
169183
enableChatInput();
170184
}
171185
});
@@ -197,6 +211,7 @@ function connectSSE() {
197211
if (e.data) {
198212
const data = JSON.parse(e.data);
199213
if (!isCurrentThread(data.thread_id)) return;
214+
finalizeActivityGroup();
200215
addMessage('system', 'Error: ' + data.message);
201216
enableChatInput();
202217
}
@@ -256,8 +271,6 @@ function sendMessage() {
256271
addMessage('user', content);
257272
input.value = '';
258273
autoResizeTextarea(input);
259-
setStatus('Sending...', true);
260-
261274
sendBtn.disabled = true;
262275
input.disabled = true;
263276

@@ -377,13 +390,239 @@ function appendToLastAssistant(chunk) {
377390
}
378391
}
379392

380-
function setStatus(text, spinning) {
393+
function setStatus(text) {
381394
const el = document.getElementById('chat-status');
382395
if (!text) {
383396
el.innerHTML = '';
384397
return;
385398
}
386-
el.innerHTML = (spinning ? '<div class="spinner"></div>' : '') + escapeHtml(text);
399+
el.innerHTML = escapeHtml(text);
400+
}
401+
402+
// --- Inline Tool Activity Cards ---
403+
404+
function getOrCreateActivityGroup() {
405+
if (_activeGroup) return _activeGroup;
406+
const container = document.getElementById('chat-messages');
407+
const group = document.createElement('div');
408+
group.className = 'activity-group';
409+
container.appendChild(group);
410+
container.scrollTop = container.scrollHeight;
411+
_activeGroup = group;
412+
_activeToolCards = {};
413+
return group;
414+
}
415+
416+
function showActivityThinking(message) {
417+
const group = getOrCreateActivityGroup();
418+
if (_activityThinking) {
419+
// Already exists — just update text and un-hide
420+
_activityThinking.style.display = '';
421+
_activityThinking.querySelector('.activity-thinking-text').textContent = message;
422+
} else {
423+
_activityThinking = document.createElement('div');
424+
_activityThinking.className = 'activity-thinking';
425+
_activityThinking.innerHTML =
426+
'<span class="activity-thinking-dots">'
427+
+ '<span class="activity-thinking-dot"></span>'
428+
+ '<span class="activity-thinking-dot"></span>'
429+
+ '<span class="activity-thinking-dot"></span>'
430+
+ '</span>'
431+
+ '<span class="activity-thinking-text"></span>';
432+
group.appendChild(_activityThinking);
433+
_activityThinking.querySelector('.activity-thinking-text').textContent = message;
434+
}
435+
const container = document.getElementById('chat-messages');
436+
container.scrollTop = container.scrollHeight;
437+
}
438+
439+
function removeActivityThinking() {
440+
if (_activityThinking) {
441+
_activityThinking.remove();
442+
_activityThinking = null;
443+
}
444+
}
445+
446+
function addToolCard(name) {
447+
// Hide thinking instead of destroying — it may reappear between tool rounds
448+
if (_activityThinking) _activityThinking.style.display = 'none';
449+
const group = getOrCreateActivityGroup();
450+
451+
const card = document.createElement('div');
452+
card.className = 'activity-tool-card';
453+
card.setAttribute('data-tool-name', name);
454+
card.setAttribute('data-status', 'running');
455+
456+
const header = document.createElement('div');
457+
header.className = 'activity-tool-header';
458+
459+
const icon = document.createElement('span');
460+
icon.className = 'activity-tool-icon';
461+
icon.innerHTML = '<div class="spinner"></div>';
462+
463+
const toolName = document.createElement('span');
464+
toolName.className = 'activity-tool-name';
465+
toolName.textContent = name;
466+
467+
const duration = document.createElement('span');
468+
duration.className = 'activity-tool-duration';
469+
duration.textContent = '';
470+
471+
const chevron = document.createElement('span');
472+
chevron.className = 'activity-tool-chevron';
473+
chevron.innerHTML = '&#9656;';
474+
475+
header.appendChild(icon);
476+
header.appendChild(toolName);
477+
header.appendChild(duration);
478+
header.appendChild(chevron);
479+
480+
const body = document.createElement('div');
481+
body.className = 'activity-tool-body';
482+
body.style.display = 'none';
483+
484+
const output = document.createElement('pre');
485+
output.className = 'activity-tool-output';
486+
body.appendChild(output);
487+
488+
header.addEventListener('click', () => {
489+
const isOpen = body.style.display !== 'none';
490+
body.style.display = isOpen ? 'none' : 'block';
491+
chevron.classList.toggle('expanded', !isOpen);
492+
});
493+
494+
card.appendChild(header);
495+
card.appendChild(body);
496+
group.appendChild(card);
497+
498+
const startTime = Date.now();
499+
const timerInterval = setInterval(() => {
500+
const elapsed = (Date.now() - startTime) / 1000;
501+
if (elapsed > 300) { clearInterval(timerInterval); return; }
502+
duration.textContent = elapsed < 10 ? elapsed.toFixed(1) + 's' : Math.floor(elapsed) + 's';
503+
}, 100);
504+
505+
if (!_activeToolCards[name]) _activeToolCards[name] = [];
506+
_activeToolCards[name].push({ card, startTime, timer: timerInterval, duration, icon, finalDuration: null });
507+
508+
const container = document.getElementById('chat-messages');
509+
container.scrollTop = container.scrollHeight;
510+
}
511+
512+
function completeToolCard(name, success) {
513+
const entries = _activeToolCards[name];
514+
if (!entries || entries.length === 0) return;
515+
// Find first running card
516+
let entry = null;
517+
for (let i = 0; i < entries.length; i++) {
518+
if (entries[i].card.getAttribute('data-status') === 'running') {
519+
entry = entries[i];
520+
break;
521+
}
522+
}
523+
if (!entry) entry = entries[entries.length - 1];
524+
525+
clearInterval(entry.timer);
526+
const elapsed = (Date.now() - entry.startTime) / 1000;
527+
entry.finalDuration = elapsed;
528+
entry.duration.textContent = elapsed < 10 ? elapsed.toFixed(1) + 's' : Math.floor(elapsed) + 's';
529+
entry.icon.innerHTML = success
530+
? '<span class="activity-icon-success">&#10003;</span>'
531+
: '<span class="activity-icon-fail">&#10007;</span>';
532+
entry.card.setAttribute('data-status', success ? 'success' : 'fail');
533+
}
534+
535+
function setToolCardOutput(name, preview) {
536+
const entries = _activeToolCards[name];
537+
if (!entries || entries.length === 0) return;
538+
// Find first card with empty output
539+
let entry = null;
540+
for (let i = 0; i < entries.length; i++) {
541+
const out = entries[i].card.querySelector('.activity-tool-output');
542+
if (out && !out.textContent) {
543+
entry = entries[i];
544+
break;
545+
}
546+
}
547+
if (!entry) entry = entries[entries.length - 1];
548+
549+
const output = entry.card.querySelector('.activity-tool-output');
550+
if (output) {
551+
const truncated = preview.length > 2000 ? preview.substring(0, 2000) + '\n... (truncated)' : preview;
552+
output.textContent = truncated;
553+
}
554+
}
555+
556+
function finalizeActivityGroup() {
557+
removeActivityThinking();
558+
if (!_activeGroup) return;
559+
560+
// Stop all timers
561+
for (const name in _activeToolCards) {
562+
const entries = _activeToolCards[name];
563+
for (let i = 0; i < entries.length; i++) {
564+
clearInterval(entries[i].timer);
565+
}
566+
}
567+
568+
// Count tools and total duration
569+
let toolCount = 0;
570+
let totalDuration = 0;
571+
for (const tname in _activeToolCards) {
572+
const tentries = _activeToolCards[tname];
573+
for (let j = 0; j < tentries.length; j++) {
574+
const entry = tentries[j];
575+
toolCount++;
576+
if (entry.finalDuration !== null) {
577+
totalDuration += entry.finalDuration;
578+
} else {
579+
// Tool was still running when finalized
580+
totalDuration += (Date.now() - entry.startTime) / 1000;
581+
}
582+
}
583+
}
584+
585+
if (toolCount === 0) {
586+
// No tools were used — remove the empty group
587+
_activeGroup.remove();
588+
_activeGroup = null;
589+
_activeToolCards = {};
590+
return;
591+
}
592+
593+
// Wrap existing cards into a hidden container
594+
const cardsContainer = document.createElement('div');
595+
cardsContainer.className = 'activity-cards-container';
596+
cardsContainer.style.display = 'none';
597+
598+
const cards = _activeGroup.querySelectorAll('.activity-tool-card');
599+
for (let k = 0; k < cards.length; k++) {
600+
cardsContainer.appendChild(cards[k]);
601+
}
602+
603+
// Build summary line
604+
const durationStr = totalDuration < 10 ? totalDuration.toFixed(1) + 's' : Math.floor(totalDuration) + 's';
605+
const toolWord = toolCount === 1 ? 'tool' : 'tools';
606+
const summary = document.createElement('div');
607+
summary.className = 'activity-summary';
608+
summary.innerHTML = '<span class="activity-summary-chevron">&#9656;</span>'
609+
+ '<span class="activity-summary-text">Used ' + toolCount + ' ' + toolWord + '</span>'
610+
+ '<span class="activity-summary-duration">(' + durationStr + ')</span>';
611+
612+
summary.addEventListener('click', () => {
613+
const isOpen = cardsContainer.style.display !== 'none';
614+
cardsContainer.style.display = isOpen ? 'none' : 'block';
615+
summary.querySelector('.activity-summary-chevron').classList.toggle('expanded', !isOpen);
616+
});
617+
618+
// Clear group and add summary + hidden cards
619+
_activeGroup.innerHTML = '';
620+
_activeGroup.classList.add('collapsed');
621+
_activeGroup.appendChild(summary);
622+
_activeGroup.appendChild(cardsContainer);
623+
624+
_activeGroup = null;
625+
_activeToolCards = {};
387626
}
388627

389628
function showApproval(data) {
@@ -761,6 +1000,7 @@ function loadThreads() {
7611000

7621001
function switchToAssistant() {
7631002
if (!assistantThreadId) return;
1003+
finalizeActivityGroup();
7641004
currentThreadId = assistantThreadId;
7651005
hasMore = false;
7661006
oldestTimestamp = null;
@@ -769,6 +1009,7 @@ function switchToAssistant() {
7691009
}
7701010

7711011
function switchThread(threadId) {
1012+
finalizeActivityGroup();
7721013
currentThreadId = threadId;
7731014
hasMore = false;
7741015
oldestTimestamp = null;

0 commit comments

Comments
 (0)