-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat(gateway): rich tool cards in history + thread processing indicator #2477
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
964804d
8af4062
5aeeb17
0345913
8f02477
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -88,6 +88,7 @@ let jobEvents = new Map(); // job_id -> Array of events | |
| let jobListRefreshTimer = null; | ||
| let pairingPollInterval = null; | ||
| let unreadThreads = new Map(); // thread_id -> unread count | ||
| let processingThreads = new Set(); // thread IDs with active agent work | ||
| let _loadThreadsTimer = null; | ||
| const JOB_EVENTS_CAP = 500; | ||
| const JOB_EVENTS_MAX_JOBS = 50; | ||
|
|
@@ -780,6 +781,10 @@ function connectSSE(lastEventIdOverride) { | |
| finalizeActivityGroup(); | ||
| loadHistory(); | ||
| } | ||
| // Clear stale processing state — agents may have finished during disconnect. | ||
| // Refresh sidebar so stale spinners are removed immediately. | ||
| processingThreads.clear(); | ||
| debouncedLoadThreads(); | ||
| sseHasConnectedBefore = true; | ||
| }; | ||
|
|
||
|
|
@@ -883,7 +888,10 @@ function connectSSE(lastEventIdOverride) { | |
| addTrackedEventListener('thinking', (e) => { | ||
| const data = JSON.parse(e.data); | ||
| if (!isCurrentThread(data.thread_id)) { | ||
| if (data.thread_id) debouncedLoadThreads(); | ||
| if (data.thread_id) { | ||
| processingThreads.add(data.thread_id); | ||
| debouncedLoadThreads(); | ||
| } | ||
| return; | ||
| } | ||
| clearSuggestionChips(); | ||
|
|
@@ -900,7 +908,13 @@ function connectSSE(lastEventIdOverride) { | |
|
|
||
| addTrackedEventListener('tool_started', (e) => { | ||
| const data = JSON.parse(e.data); | ||
| if (!isCurrentThread(data.thread_id)) return; | ||
| if (!isCurrentThread(data.thread_id)) { | ||
| if (data.thread_id) { | ||
| processingThreads.add(data.thread_id); | ||
| debouncedLoadThreads(); | ||
| } | ||
| return; | ||
| } | ||
| addToolCard(data.name); | ||
| }); | ||
|
|
||
|
|
@@ -923,7 +937,13 @@ function connectSSE(lastEventIdOverride) { | |
|
|
||
| addTrackedEventListener('stream_chunk', (e) => { | ||
| const data = JSON.parse(e.data); | ||
| if (!isCurrentThread(data.thread_id)) return; | ||
| if (!isCurrentThread(data.thread_id)) { | ||
| if (data.thread_id) { | ||
| processingThreads.add(data.thread_id); | ||
| debouncedLoadThreads(); | ||
| } | ||
| return; | ||
| } | ||
| finalizeActivityGroup(); | ||
|
|
||
| // Mark the active assistant message as streaming | ||
|
|
@@ -959,7 +979,14 @@ function connectSSE(lastEventIdOverride) { | |
| addTrackedEventListener('status', (e) => { | ||
| const data = JSON.parse(e.data); | ||
| if (!isCurrentThread(data.thread_id)) { | ||
| if (data.thread_id) debouncedLoadThreads(); | ||
| if (data.thread_id) { | ||
| if (data.message === 'Done' || data.message === 'Awaiting approval' | ||
| || data.message === 'Interrupted' || data.message === 'Rejected' | ||
| || data.message === 'Tool call denied.') { | ||
| processingThreads.delete(data.thread_id); | ||
| } | ||
|
Comment on lines
+983
to
+987
|
||
| debouncedLoadThreads(); | ||
| } | ||
|
henrypark133 marked this conversation as resolved.
|
||
| return; | ||
| } | ||
| // "Done" and "Awaiting approval" are terminal signals from the agent: | ||
|
|
@@ -3082,12 +3109,19 @@ function loadHistory(before) { | |
| if (!isPaginating) { | ||
| // Fresh load: clear and render | ||
| container.innerHTML = ''; | ||
| for (const turn of data.turns) { | ||
| const lastTurnIndex = data.turns.length - 1; | ||
| for (let i = 0; i < data.turns.length; i++) { | ||
| const turn = data.turns[i]; | ||
| if (turn.user_input) { | ||
| addMessage('user', turn.user_input); | ||
| } | ||
| if (turn.tool_calls && turn.tool_calls.length > 0) { | ||
| addToolCallsSummary(turn.tool_calls); | ||
| if (i === lastTurnIndex) { | ||
| // Rich activity cards for the most recent turn | ||
| container.appendChild(createActivityGroupFromHistory(turn.tool_calls)); | ||
| } else { | ||
| addToolCallsSummary(turn.tool_calls); | ||
| } | ||
|
Comment on lines
3118
to
+3124
|
||
| } | ||
| if (turn.generated_images && turn.generated_images.length > 0) { | ||
| for (const image of turn.generated_images) { | ||
|
|
@@ -3293,6 +3327,93 @@ function createToolCallsSummaryElement(toolCalls) { | |
| return div; | ||
| } | ||
|
|
||
| function createActivityGroupFromHistory(toolCalls) { | ||
| const hasError = toolCalls.some(tc => tc.has_error); | ||
| const group = document.createElement('div'); | ||
| group.className = 'activity-group' + (hasError ? '' : ' collapsed'); | ||
|
|
||
| const toolCount = toolCalls.length; | ||
| const toolWord = toolCount === 1 ? 'tool' : 'tools'; | ||
|
|
||
| // Build summary header (matches finalizeActivityGroup output) | ||
| const summary = document.createElement('div'); | ||
| summary.className = 'activity-summary'; | ||
| summary.innerHTML = '<span class="activity-summary-chevron' + (hasError ? ' expanded' : '') + '">▸</span>' | ||
| + '<span class="activity-summary-text">Used ' + toolCount + ' ' + toolWord + '</span>'; | ||
|
|
||
| // Build cards container (auto-expand when errors present) | ||
| const cardsContainer = document.createElement('div'); | ||
| cardsContainer.className = 'activity-cards-container'; | ||
| cardsContainer.style.display = hasError ? 'block' : 'none'; | ||
|
|
||
| for (const tc of toolCalls) { | ||
| // Map status: has_error → fail, has_result → success, neither → running | ||
| const status = tc.has_error ? 'fail' : (tc.has_result ? 'success' : 'running'); | ||
| const card = document.createElement('div'); | ||
| card.className = 'activity-tool-card'; | ||
| card.setAttribute('data-tool-name', tc.name); | ||
| card.setAttribute('data-status', status); | ||
|
|
||
| const header = document.createElement('div'); | ||
| header.className = 'activity-tool-header'; | ||
|
|
||
| const icon = document.createElement('span'); | ||
| icon.className = 'activity-tool-icon'; | ||
| if (tc.has_error) { | ||
| icon.innerHTML = '<span class="activity-icon-fail">✗</span>'; | ||
| } else if (tc.has_result) { | ||
| icon.innerHTML = '<span class="activity-icon-success">✓</span>'; | ||
| } else { | ||
| icon.innerHTML = '<div class="spinner"></div>'; | ||
| } | ||
|
|
||
|
henrypark133 marked this conversation as resolved.
|
||
| const toolName = document.createElement('span'); | ||
| toolName.className = 'activity-tool-name'; | ||
| toolName.textContent = tc.name; | ||
|
|
||
| const chevron = document.createElement('span'); | ||
| chevron.className = 'activity-tool-chevron'; | ||
| chevron.innerHTML = '▸'; | ||
|
|
||
| header.appendChild(icon); | ||
| header.appendChild(toolName); | ||
| header.appendChild(chevron); | ||
|
|
||
| const body = document.createElement('div'); | ||
| body.className = 'activity-tool-body'; | ||
|
|
||
| const output = document.createElement('pre'); | ||
| output.className = 'activity-tool-output'; | ||
| if (tc.error) { | ||
| output.textContent = tc.error; | ||
| body.classList.add('expanded'); | ||
| chevron.classList.add('expanded'); | ||
| } else if (tc.result_preview) { | ||
| output.textContent = tc.result_preview; | ||
| } | ||
| body.appendChild(output); | ||
|
|
||
| header.addEventListener('click', () => { | ||
| body.classList.toggle('expanded'); | ||
| chevron.classList.toggle('expanded', body.classList.contains('expanded')); | ||
| }); | ||
|
|
||
| card.appendChild(header); | ||
| card.appendChild(body); | ||
| cardsContainer.appendChild(card); | ||
| } | ||
|
|
||
| summary.addEventListener('click', () => { | ||
| const isOpen = cardsContainer.style.display !== 'none'; | ||
| cardsContainer.style.display = isOpen ? 'none' : 'block'; | ||
| summary.querySelector('.activity-summary-chevron').classList.toggle('expanded', !isOpen); | ||
| }); | ||
|
|
||
| group.appendChild(summary); | ||
| group.appendChild(cardsContainer); | ||
| return group; | ||
| } | ||
|
|
||
| function removeScrollSpinner() { | ||
| const spinner = document.getElementById('scroll-load-spinner'); | ||
| if (spinner) spinner.remove(); | ||
|
|
@@ -3363,6 +3484,7 @@ function loadThreads() { | |
| const item = document.createElement('div'); | ||
| const isActive = thread.id === currentThreadId; | ||
| item.className = 'thread-item' + (isActive ? ' active' : ''); | ||
| item.setAttribute('data-thread-id', thread.id); | ||
|
|
||
| // Channel badge for non-gateway threads | ||
| const ch = thread.channel || 'gateway'; | ||
|
|
@@ -3384,6 +3506,14 @@ function loadThreads() { | |
| meta.textContent = relativeTime(thread.updated_at); | ||
| item.appendChild(meta); | ||
|
|
||
| // Processing spinner | ||
| if (processingThreads.has(thread.id) && !isActive) { | ||
| const spinner = document.createElement('span'); | ||
| spinner.className = 'thread-processing'; | ||
| spinner.innerHTML = '<div class="spinner"></div>'; | ||
| item.appendChild(spinner); | ||
| } | ||
|
|
||
| // Unread dot | ||
| const unread = unreadThreads.get(thread.id) || 0; | ||
| if (unread > 0 && !isActive) { | ||
|
|
@@ -3483,6 +3613,7 @@ function switchThread(threadId) { | |
| } | ||
| currentThreadId = threadId; | ||
| unreadThreads.delete(threadId); | ||
| processingThreads.delete(threadId); | ||
| hasMore = false; | ||
| oldestTimestamp = null; | ||
| loadHistory(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4375,6 +4375,18 @@ mark { | |||||||||||||||||||||||||||||
| flex-shrink: 0; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| .thread-processing { | ||||||||||||||||||||||||||||||
| display: inline-flex; | ||||||||||||||||||||||||||||||
| align-items: center; | ||||||||||||||||||||||||||||||
| margin-left: auto; | ||||||||||||||||||||||||||||||
| flex-shrink: 0; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| .thread-processing .spinner { | ||||||||||||||||||||||||||||||
| width: 12px; | ||||||||||||||||||||||||||||||
| height: 12px; | ||||||||||||||||||||||||||||||
| border-width: 2px; | ||||||||||||||||||||||||||||||
|
Comment on lines
+4384
to
+4387
|
||||||||||||||||||||||||||||||
| .thread-processing .spinner { | |
| width: 12px; | |
| height: 12px; | |
| border-width: 2px; | |
| .thread-processing .spinner { | |
| display: inline-block; | |
| width: 12px; | |
| height: 12px; | |
| border-width: 2px; | |
| border-style: solid; | |
| border-color: var(--border); | |
| border-top-color: var(--accent); | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; |
Uh oh!
There was an error while loading. Please reload this page.