diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index aba944582a..f11189c090 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -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 }) => { diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index ce04e62124..1107125e75 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -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 ? '
' : '') + 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 = + '' + + '' + + '' + + '' + + '' + + ''; + 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 = ''; + + 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 + ? '' + : ''; + 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 = '▸' + + 'Used ' + toolCount + ' ' + toolWord + '' + + '(' + durationStr + ')'; + + 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; diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 87cf6e4884..d9ffcd0f6b 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -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;