mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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) <[email protected]> * 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) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2477923af2
commit
443b120272
@@ -212,6 +212,15 @@ impl Agent {
|
||||
);
|
||||
}
|
||||
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Thinking("Calling LLM...".into()),
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
|
||||
let output = match reasoning.respond_with_tools(&context).await {
|
||||
Ok(output) => output,
|
||||
Err(crate::error::LlmError::ContextLengthExceeded { used, limit }) => {
|
||||
|
||||
@@ -15,6 +15,11 @@ let jobListRefreshTimer = null;
|
||||
const JOB_EVENTS_CAP = 500;
|
||||
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
|
||||
|
||||
// --- Tool Activity State ---
|
||||
let _activeGroup = null;
|
||||
let _activeToolCards = {};
|
||||
let _activityThinking = null;
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
function authenticate() {
|
||||
@@ -113,6 +118,7 @@ function connectSSE() {
|
||||
document.getElementById('sse-dot').classList.remove('disconnected');
|
||||
document.getElementById('sse-status').textContent = 'Connected';
|
||||
if (sseHasConnectedBefore && currentThreadId) {
|
||||
finalizeActivityGroup();
|
||||
loadHistory();
|
||||
}
|
||||
sseHasConnectedBefore = true;
|
||||
@@ -126,6 +132,7 @@ function connectSSE() {
|
||||
eventSource.addEventListener('response', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
finalizeActivityGroup();
|
||||
addMessage('assistant', data.content);
|
||||
setStatus('');
|
||||
enableChatInput();
|
||||
@@ -136,25 +143,31 @@ function connectSSE() {
|
||||
eventSource.addEventListener('thinking', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
setStatus(data.message, true);
|
||||
showActivityThinking(data.message);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('tool_started', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
setStatus('Running tool: ' + data.name, true);
|
||||
addToolCard(data.name);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('tool_completed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
const icon = data.success ? '\u2713' : '\u2717';
|
||||
setStatus('Tool ' + data.name + ' ' + icon);
|
||||
completeToolCard(data.name, data.success);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('tool_result', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
setToolCardOutput(data.name, data.preview);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('stream_chunk', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
finalizeActivityGroup();
|
||||
appendToLastAssistant(data.content);
|
||||
});
|
||||
|
||||
@@ -166,6 +179,7 @@ function connectSSE() {
|
||||
// the agentic loop finished, so re-enable input as a safety net in case
|
||||
// the response SSE event is empty or lost.
|
||||
if (data.message === 'Done' || data.message === 'Awaiting approval') {
|
||||
finalizeActivityGroup();
|
||||
enableChatInput();
|
||||
}
|
||||
});
|
||||
@@ -197,6 +211,7 @@ function connectSSE() {
|
||||
if (e.data) {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
finalizeActivityGroup();
|
||||
addMessage('system', 'Error: ' + data.message);
|
||||
enableChatInput();
|
||||
}
|
||||
@@ -256,8 +271,6 @@ function sendMessage() {
|
||||
addMessage('user', content);
|
||||
input.value = '';
|
||||
autoResizeTextarea(input);
|
||||
setStatus('Sending...', true);
|
||||
|
||||
sendBtn.disabled = true;
|
||||
input.disabled = true;
|
||||
|
||||
@@ -377,13 +390,239 @@ function appendToLastAssistant(chunk) {
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(text, spinning) {
|
||||
function setStatus(text) {
|
||||
const el = document.getElementById('chat-status');
|
||||
if (!text) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = (spinning ? '<div class="spinner"></div>' : '') + escapeHtml(text);
|
||||
el.innerHTML = escapeHtml(text);
|
||||
}
|
||||
|
||||
// --- Inline Tool Activity Cards ---
|
||||
|
||||
function getOrCreateActivityGroup() {
|
||||
if (_activeGroup) return _activeGroup;
|
||||
const container = document.getElementById('chat-messages');
|
||||
const group = document.createElement('div');
|
||||
group.className = 'activity-group';
|
||||
container.appendChild(group);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
_activeGroup = group;
|
||||
_activeToolCards = {};
|
||||
return group;
|
||||
}
|
||||
|
||||
function showActivityThinking(message) {
|
||||
const group = getOrCreateActivityGroup();
|
||||
if (_activityThinking) {
|
||||
// Already exists — just update text and un-hide
|
||||
_activityThinking.style.display = '';
|
||||
_activityThinking.querySelector('.activity-thinking-text').textContent = message;
|
||||
} else {
|
||||
_activityThinking = document.createElement('div');
|
||||
_activityThinking.className = 'activity-thinking';
|
||||
_activityThinking.innerHTML =
|
||||
'<span class="activity-thinking-dots">'
|
||||
+ '<span class="activity-thinking-dot"></span>'
|
||||
+ '<span class="activity-thinking-dot"></span>'
|
||||
+ '<span class="activity-thinking-dot"></span>'
|
||||
+ '</span>'
|
||||
+ '<span class="activity-thinking-text"></span>';
|
||||
group.appendChild(_activityThinking);
|
||||
_activityThinking.querySelector('.activity-thinking-text').textContent = message;
|
||||
}
|
||||
const container = document.getElementById('chat-messages');
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function removeActivityThinking() {
|
||||
if (_activityThinking) {
|
||||
_activityThinking.remove();
|
||||
_activityThinking = null;
|
||||
}
|
||||
}
|
||||
|
||||
function addToolCard(name) {
|
||||
// Hide thinking instead of destroying — it may reappear between tool rounds
|
||||
if (_activityThinking) _activityThinking.style.display = 'none';
|
||||
const group = getOrCreateActivityGroup();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'activity-tool-card';
|
||||
card.setAttribute('data-tool-name', name);
|
||||
card.setAttribute('data-status', 'running');
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'activity-tool-header';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'activity-tool-icon';
|
||||
icon.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
const toolName = document.createElement('span');
|
||||
toolName.className = 'activity-tool-name';
|
||||
toolName.textContent = name;
|
||||
|
||||
const duration = document.createElement('span');
|
||||
duration.className = 'activity-tool-duration';
|
||||
duration.textContent = '';
|
||||
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'activity-tool-chevron';
|
||||
chevron.innerHTML = '▸';
|
||||
|
||||
header.appendChild(icon);
|
||||
header.appendChild(toolName);
|
||||
header.appendChild(duration);
|
||||
header.appendChild(chevron);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'activity-tool-body';
|
||||
body.style.display = 'none';
|
||||
|
||||
const output = document.createElement('pre');
|
||||
output.className = 'activity-tool-output';
|
||||
body.appendChild(output);
|
||||
|
||||
header.addEventListener('click', () => {
|
||||
const isOpen = body.style.display !== 'none';
|
||||
body.style.display = isOpen ? 'none' : 'block';
|
||||
chevron.classList.toggle('expanded', !isOpen);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
card.appendChild(body);
|
||||
group.appendChild(card);
|
||||
|
||||
const startTime = Date.now();
|
||||
const timerInterval = setInterval(() => {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
if (elapsed > 300) { clearInterval(timerInterval); return; }
|
||||
duration.textContent = elapsed < 10 ? elapsed.toFixed(1) + 's' : Math.floor(elapsed) + 's';
|
||||
}, 100);
|
||||
|
||||
if (!_activeToolCards[name]) _activeToolCards[name] = [];
|
||||
_activeToolCards[name].push({ card, startTime, timer: timerInterval, duration, icon, finalDuration: null });
|
||||
|
||||
const container = document.getElementById('chat-messages');
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function completeToolCard(name, success) {
|
||||
const entries = _activeToolCards[name];
|
||||
if (!entries || entries.length === 0) return;
|
||||
// Find first running card
|
||||
let entry = null;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (entries[i].card.getAttribute('data-status') === 'running') {
|
||||
entry = entries[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!entry) entry = entries[entries.length - 1];
|
||||
|
||||
clearInterval(entry.timer);
|
||||
const elapsed = (Date.now() - entry.startTime) / 1000;
|
||||
entry.finalDuration = elapsed;
|
||||
entry.duration.textContent = elapsed < 10 ? elapsed.toFixed(1) + 's' : Math.floor(elapsed) + 's';
|
||||
entry.icon.innerHTML = success
|
||||
? '<span class="activity-icon-success">✓</span>'
|
||||
: '<span class="activity-icon-fail">✗</span>';
|
||||
entry.card.setAttribute('data-status', success ? 'success' : 'fail');
|
||||
}
|
||||
|
||||
function setToolCardOutput(name, preview) {
|
||||
const entries = _activeToolCards[name];
|
||||
if (!entries || entries.length === 0) return;
|
||||
// Find first card with empty output
|
||||
let entry = null;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const out = entries[i].card.querySelector('.activity-tool-output');
|
||||
if (out && !out.textContent) {
|
||||
entry = entries[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!entry) entry = entries[entries.length - 1];
|
||||
|
||||
const output = entry.card.querySelector('.activity-tool-output');
|
||||
if (output) {
|
||||
const truncated = preview.length > 2000 ? preview.substring(0, 2000) + '\n... (truncated)' : preview;
|
||||
output.textContent = truncated;
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeActivityGroup() {
|
||||
removeActivityThinking();
|
||||
if (!_activeGroup) return;
|
||||
|
||||
// Stop all timers
|
||||
for (const name in _activeToolCards) {
|
||||
const entries = _activeToolCards[name];
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
clearInterval(entries[i].timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Count tools and total duration
|
||||
let toolCount = 0;
|
||||
let totalDuration = 0;
|
||||
for (const tname in _activeToolCards) {
|
||||
const tentries = _activeToolCards[tname];
|
||||
for (let j = 0; j < tentries.length; j++) {
|
||||
const entry = tentries[j];
|
||||
toolCount++;
|
||||
if (entry.finalDuration !== null) {
|
||||
totalDuration += entry.finalDuration;
|
||||
} else {
|
||||
// Tool was still running when finalized
|
||||
totalDuration += (Date.now() - entry.startTime) / 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCount === 0) {
|
||||
// No tools were used — remove the empty group
|
||||
_activeGroup.remove();
|
||||
_activeGroup = null;
|
||||
_activeToolCards = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap existing cards into a hidden container
|
||||
const cardsContainer = document.createElement('div');
|
||||
cardsContainer.className = 'activity-cards-container';
|
||||
cardsContainer.style.display = 'none';
|
||||
|
||||
const cards = _activeGroup.querySelectorAll('.activity-tool-card');
|
||||
for (let k = 0; k < cards.length; k++) {
|
||||
cardsContainer.appendChild(cards[k]);
|
||||
}
|
||||
|
||||
// Build summary line
|
||||
const durationStr = totalDuration < 10 ? totalDuration.toFixed(1) + 's' : Math.floor(totalDuration) + 's';
|
||||
const toolWord = toolCount === 1 ? 'tool' : 'tools';
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'activity-summary';
|
||||
summary.innerHTML = '<span class="activity-summary-chevron">▸</span>'
|
||||
+ '<span class="activity-summary-text">Used ' + toolCount + ' ' + toolWord + '</span>'
|
||||
+ '<span class="activity-summary-duration">(' + durationStr + ')</span>';
|
||||
|
||||
summary.addEventListener('click', () => {
|
||||
const isOpen = cardsContainer.style.display !== 'none';
|
||||
cardsContainer.style.display = isOpen ? 'none' : 'block';
|
||||
summary.querySelector('.activity-summary-chevron').classList.toggle('expanded', !isOpen);
|
||||
});
|
||||
|
||||
// Clear group and add summary + hidden cards
|
||||
_activeGroup.innerHTML = '';
|
||||
_activeGroup.classList.add('collapsed');
|
||||
_activeGroup.appendChild(summary);
|
||||
_activeGroup.appendChild(cardsContainer);
|
||||
|
||||
_activeGroup = null;
|
||||
_activeToolCards = {};
|
||||
}
|
||||
|
||||
function showApproval(data) {
|
||||
@@ -761,6 +1000,7 @@ function loadThreads() {
|
||||
|
||||
function switchToAssistant() {
|
||||
if (!assistantThreadId) return;
|
||||
finalizeActivityGroup();
|
||||
currentThreadId = assistantThreadId;
|
||||
hasMore = false;
|
||||
oldestTimestamp = null;
|
||||
@@ -769,6 +1009,7 @@ function switchToAssistant() {
|
||||
}
|
||||
|
||||
function switchThread(threadId) {
|
||||
finalizeActivityGroup();
|
||||
currentThreadId = threadId;
|
||||
hasMore = false;
|
||||
oldestTimestamp = null;
|
||||
|
||||
@@ -483,6 +483,7 @@ body {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
.scroll-load-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -502,6 +503,225 @@ body {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
/* === Tool Activity Cards === */
|
||||
|
||||
.activity-group {
|
||||
align-self: flex-start;
|
||||
max-width: 80%;
|
||||
padding: 4px 0 4px 12px;
|
||||
border-left: 2px solid var(--border);
|
||||
margin: 4px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.activity-group.collapsed {
|
||||
border-left-color: transparent;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Thinking indicator */
|
||||
|
||||
.activity-thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.activity-thinking-dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.activity-thinking-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
animation: thinkingPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.activity-thinking-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.activity-thinking-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes thinkingPulse {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.activity-thinking-text {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Tool card */
|
||||
|
||||
.activity-tool-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.activity-tool-card[data-status="running"] {
|
||||
border-color: rgba(52, 211, 153, 0.3);
|
||||
}
|
||||
|
||||
.activity-tool-card[data-status="fail"] {
|
||||
border-color: rgba(230, 76, 76, 0.3);
|
||||
}
|
||||
|
||||
.activity-tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.activity-tool-header:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.activity-tool-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-tool-icon .spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.activity-icon-success {
|
||||
color: var(--success);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.activity-icon-fail {
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.activity-tool-name {
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-tool-duration {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.activity-tool-chevron {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.15s ease;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.activity-tool-chevron.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.activity-tool-body {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.activity-tool-output {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
background: var(--code-bg);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Collapsed summary */
|
||||
|
||||
.activity-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.activity-summary:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.activity-summary-chevron {
|
||||
font-size: 10px;
|
||||
transition: transform 0.15s ease;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.activity-summary-chevron.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.activity-summary-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.activity-summary-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.activity-cards-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--border);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.activity-group {
|
||||
max-width: 95%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Approval card (inline in chat) */
|
||||
.approval-card {
|
||||
align-self: flex-start;
|
||||
|
||||
Reference in New Issue
Block a user