Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 137 additions & 6 deletions crates/ironclaw_gateway/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Comment thread
henrypark133 marked this conversation as resolved.
debouncedLoadThreads();
sseHasConnectedBefore = true;
};

Expand Down Expand Up @@ -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();
Expand All @@ -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);
});

Expand All @@ -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
Expand Down Expand Up @@ -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

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processingThreads is cleared for background threads only when data.message exactly matches hard-coded strings. Server-side status strings are treated case-insensitively and trimmed in multiple places (e.g., terminal detection uses trim() + eq_ignore_ascii_case), so a status like "done" or " Done " would leave a stale processing spinner forever. Normalize the status text in the client (e.g., trim + lowercase) before comparing, and use that for both the background-thread cleanup and the current-thread terminal checks.

Copilot uses AI. Check for mistakes.
debouncedLoadThreads();
}
Comment thread
henrypark133 marked this conversation as resolved.
return;
}
// "Done" and "Awaiting approval" are terminal signals from the agent:
Expand Down Expand Up @@ -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

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadHistory() renders rich activity tool cards for the last turn unconditionally. If the last turn is still Processing, those cards are built from in-memory history but are not registered in _activeToolCards, so subsequent SSE tool_completed / tool_result events won’t update them (and new live cards may be created in a separate activity group). This can leave stuck “running” spinners or duplicate cards after a reload mid-turn. Consider only using createActivityGroupFromHistory() when the last turn is terminal (e.g., state === 'Completed' or has a response), and fall back to the compact summary for in-progress turns, or explicitly hydrate _activeGroup/_activeToolCards from history so SSE updates apply to the same DOM.

Copilot uses AI. Check for mistakes.
}
if (turn.generated_images && turn.generated_images.length > 0) {
for (const image of turn.generated_images) {
Expand Down Expand Up @@ -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' : '') + '">&#9656;</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">&#10007;</span>';
} else if (tc.has_result) {
icon.innerHTML = '<span class="activity-icon-success">&#10003;</span>';
} else {
icon.innerHTML = '<div class="spinner"></div>';
}

Comment thread
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 = '&#9656;';

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();
Expand Down Expand Up @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -3483,6 +3613,7 @@ function switchThread(threadId) {
}
currentThreadId = threadId;
unreadThreads.delete(threadId);
processingThreads.delete(threadId);
hasMore = false;
oldestTimestamp = null;
loadHistory();
Expand Down
12 changes: 12 additions & 0 deletions crates/ironclaw_gateway/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.thread-processing .spinner only sets width/height/border-width, but never sets border style/colors, border-radius, or the spin animation. Unlike the spinners in .activity-tool-icon and .scroll-load-spinner, this will likely render as an invisible/unstyled div, so the sidebar processing indicator won’t actually show a spinner. Consider reusing the same spinner styling (border + border-top-color + border-radius + animation) or introducing a shared .spinner base class that all contexts inherit from.

Suggested change
.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;

Copilot uses AI. Check for mistakes.
}

/* --- Memory editing --- */

#memory-breadcrumb-path {
Expand Down
12 changes: 12 additions & 0 deletions tests/e2e/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,18 @@
"tool_permission_toggle": ".tool-permission-toggle",
"tool_lock_icon": ".tool-lock-icon",
"tool_default_badge": ".tool-default-badge",
# Activity / tool cards (live and history)
"activity_group": ".activity-group",
"activity_tool_card": ".activity-tool-card",
"activity_tool_name": ".activity-tool-name",
"activity_tool_output": ".activity-tool-output",
"activity_summary": ".activity-summary",
"activity_cards_container": ".activity-cards-container",
"activity_tool_body": ".activity-tool-body",
"activity_thinking": ".activity-thinking",
"activity_thinking_text": ".activity-thinking-text",
# Thread processing indicator
"thread_processing": ".thread-processing",
}

TABS = ["chat", "memory", "jobs", "routines", "settings"]
Expand Down
Loading
Loading