From cedce8e28f791483d098cf0b9a80d54bcbe3e873 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Fri, 10 Apr 2026 19:09:19 +0300 Subject: [PATCH] feat(gateway): add approval queue tray to web UI sidebar Adds a cross-thread approval queue tray to the chat sidebar that shows all pending tool approval gates across all threads, not just the active one. This solves the visibility gap where background routines, heartbeat threads, or other non-active threads could be silently blocked waiting for approval. Design: - Sidebar tray appears between assistant-item and thread list only when gates are pending (zero UI cost when empty) - Each item shows tool name, source thread, description, and inline approve/always/deny buttons - Amber badge on the Chat tab pulses when new gates arrive - Thread items show pulsing amber dot when they have pending approvals - Batch approve-all / deny-all on header hover - Collapsible tray with smooth spring animations - Full light/dark theme support - Touch-friendly targets on coarse pointer devices Wired into both legacy approval_needed and v2 gate_required/gate_resolved SSE events. sendApprovalAction now resolves using the gate's owning thread rather than the currently-viewed thread. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/ironclaw_gateway/static/app.js | 250 +++++++++++++++++++- crates/ironclaw_gateway/static/index.html | 23 +- crates/ironclaw_gateway/static/style.css | 270 ++++++++++++++++++++++ 3 files changed, 540 insertions(+), 3 deletions(-) diff --git a/crates/ironclaw_gateway/static/app.js b/crates/ironclaw_gateway/static/app.js index 9c6da50f6fa..39f78a670bd 100644 --- a/crates/ironclaw_gateway/static/app.js +++ b/crates/ironclaw_gateway/static/app.js @@ -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 pendingGates = new Map(); // request_id -> { request_id, tool_name, description, parameters, thread_id, allow_always, created_at } let _loadThreadsTimer = null; const JOB_EVENTS_CAP = 500; const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100; @@ -979,6 +980,9 @@ function connectSSE(lastEventIdOverride) { debouncedLoadThreads(); } + // Track in the approval queue tray for cross-thread visibility. + aqAddGate(data); + // Extension setup flows can surface approvals from any settings subtab. if (currentTab === 'settings') refreshCurrentSettingsTab(); }); @@ -1005,11 +1009,25 @@ function connectSSE(lastEventIdOverride) { addTrackedEventListener('gate_required', (e) => { const data = JSON.parse(e.data); handleGateRequired(data); + // Track approval-type gates in the sidebar tray for cross-thread visibility. + var resume = parseGateResumeKind(data.resume_kind); + if (!resume || resume.type === 'approval') { + aqAddGate({ + request_id: data.request_id, + tool_name: data.tool_name, + description: data.description, + parameters: data.parameters, + thread_id: data.thread_id || currentThreadId, + allow_always: !(resume && resume.type === 'approval' && resume.allow_always === false), + }); + } }); addTrackedEventListener('gate_resolved', (e) => { const data = JSON.parse(e.data); handleGateResolved(data); + // Remove from sidebar tray. + aqResolveGate(data.request_id, data.resolution === 'denied' ? 'deny' : 'approve'); }); addTrackedEventListener('extension_status', (e) => { @@ -1417,11 +1435,18 @@ function filterSlashCommands(value) { } function sendApprovalAction(requestId, action) { + // Resolve using the gate's owning thread, not the currently-viewed thread. + var gateThreadId = currentThreadId; + var gateData = pendingGates.get(requestId); + if (gateData && gateData.thread_id) { + gateThreadId = gateData.thread_id; + } + apiFetch('/api/chat/gate/resolve', { method: 'POST', body: { request_id: requestId, - thread_id: currentThreadId, + thread_id: gateThreadId, resolution: action === 'deny' ? 'denied' : 'approved', always: action === 'always', }, @@ -1429,7 +1454,7 @@ function sendApprovalAction(requestId, action) { addMessage('system', 'Failed to send approval: ' + err.message); }); - // Disable buttons and show confirmation on the card + // Disable buttons and show confirmation on the inline card const card = document.querySelector('.approval-card[data-request-id="' + requestId + '"]'); if (card) { const buttons = card.querySelectorAll('.approval-actions button'); @@ -1445,6 +1470,9 @@ function sendApprovalAction(requestId, action) { // Remove the card after showing the confirmation briefly setTimeout(() => { card.remove(); }, 1500); } + + // Also resolve in the approval queue tray + aqResolveGate(requestId, action); } function renderMarkdown(text) { @@ -3192,6 +3220,14 @@ function loadThreads() { meta.textContent = relativeTime(thread.updated_at); item.appendChild(meta); + // Pending approval dot (amber pip) + if (aqThreadHasPendingGate(thread.id)) { + const gateDot = document.createElement('span'); + gateDot.className = 'thread-item-gate-dot'; + gateDot.title = 'Pending approval'; + item.appendChild(gateDot); + } + // Unread dot const unread = unreadThreads.get(thread.id) || 0; if (unread > 0 && !isActive) { @@ -9218,3 +9254,213 @@ if (window.__IRONCLAW_LAYOUT__ && !window.location.hash) { switchTab(window.__IRONCLAW_LAYOUT__.tabs.default_tab); } + +// ============================================================ +// Approval Queue Tray — cross-thread pending gate visibility +// ============================================================ + +/** Add a pending gate to the tray and update all indicators. */ +function aqAddGate(data) { + if (!data.request_id) return; + if (pendingGates.has(data.request_id)) return; // Avoid duplicates on reconnect + + var entry = { + request_id: data.request_id, + tool_name: data.tool_name || 'unknown', + description: data.description || '', + parameters: data.parameters || null, + thread_id: data.thread_id || currentThreadId, + allow_always: data.allow_always !== false, + created_at: Date.now(), + }; + pendingGates.set(data.request_id, entry); + aqRenderItem(entry); + aqUpdateIndicators(); +} + +/** Resolve (approve/deny) a gate: animate out, remove from map, update indicators. */ +function aqResolveGate(requestId, action) { + var itemEl = document.querySelector('.aq-item[data-request-id="' + CSS.escape(requestId) + '"]'); + if (itemEl && !itemEl.classList.contains('resolved')) { + var actions = itemEl.querySelector('.aq-item-actions'); + var labelText = action === 'deny' ? 'Denied' : 'Approved'; + var cls = action === 'deny' ? 'denied' : 'approved'; + var symbol = action === 'deny' ? '\u2717' : '\u2713'; + actions.innerHTML = '' + symbol + ' ' + labelText + ''; + itemEl.classList.add('resolved'); + setTimeout(function () { itemEl.remove(); aqUpdateIndicators(); }, 500); + } + pendingGates.delete(requestId); + // If no animated element, just update now + if (!itemEl) aqUpdateIndicators(); +} + +/** Render a single tray item and prepend it to the items container. */ +function aqRenderItem(entry) { + var container = document.getElementById('aq-items'); + if (!container) return; + + var item = document.createElement('div'); + item.className = 'aq-item'; + item.setAttribute('data-request-id', entry.request_id); + item.setAttribute('data-thread-id', entry.thread_id || ''); + item.addEventListener('click', function () { + if (entry.thread_id && entry.thread_id !== currentThreadId) { + switchThread(entry.thread_id); + } + }); + + // Top row: tool name + thread badge + time + var top = document.createElement('div'); + top.className = 'aq-item-top'; + + var toolSpan = document.createElement('span'); + toolSpan.className = 'aq-item-tool'; + toolSpan.textContent = entry.tool_name; + top.appendChild(toolSpan); + + var threadSpan = document.createElement('span'); + threadSpan.className = 'aq-item-thread'; + threadSpan.textContent = aqThreadLabel(entry.thread_id); + top.appendChild(threadSpan); + + var timeSpan = document.createElement('span'); + timeSpan.className = 'aq-item-time'; + timeSpan.textContent = 'just now'; + top.appendChild(timeSpan); + + item.appendChild(top); + + // Description + if (entry.description) { + var desc = document.createElement('div'); + desc.className = 'aq-item-desc'; + desc.textContent = entry.description; + item.appendChild(desc); + } + + // Actions row + var actions = document.createElement('div'); + actions.className = 'aq-item-actions'; + + var approveBtn = document.createElement('button'); + approveBtn.className = 'aq-btn aq-approve'; + approveBtn.textContent = I18n.t('approval.approve'); + approveBtn.addEventListener('click', function (e) { + e.stopPropagation(); + sendApprovalAction(entry.request_id, 'approve'); + }); + actions.appendChild(approveBtn); + + if (entry.allow_always) { + var alwaysBtn = document.createElement('button'); + alwaysBtn.className = 'aq-btn aq-always'; + alwaysBtn.textContent = I18n.t('approval.always'); + alwaysBtn.addEventListener('click', function (e) { + e.stopPropagation(); + sendApprovalAction(entry.request_id, 'always'); + }); + actions.appendChild(alwaysBtn); + } + + var denyBtn = document.createElement('button'); + denyBtn.className = 'aq-btn aq-deny'; + denyBtn.textContent = I18n.t('approval.deny'); + denyBtn.addEventListener('click', function (e) { + e.stopPropagation(); + sendApprovalAction(entry.request_id, 'deny'); + }); + actions.appendChild(denyBtn); + + var viewBtn = document.createElement('button'); + viewBtn.className = 'aq-btn aq-view'; + viewBtn.innerHTML = '→'; + viewBtn.title = 'Jump to thread'; + viewBtn.addEventListener('click', function (e) { + e.stopPropagation(); + if (entry.thread_id) switchThread(entry.thread_id); + }); + actions.appendChild(viewBtn); + + item.appendChild(actions); + container.insertBefore(item, container.firstChild); +} + +/** Update all approval queue indicators: tray visibility, badge, count. */ +function aqUpdateIndicators() { + var count = pendingGates.size; + var tray = document.getElementById('approval-tray'); + var badge = document.getElementById('tab-approval-badge'); + var countEl = document.getElementById('aq-count'); + + if (tray) { + tray.classList.toggle('visible', count > 0); + } + if (badge) { + badge.textContent = count; + badge.classList.toggle('empty', count === 0); + if (count > 0) { + badge.classList.remove('pulse'); + void badge.offsetWidth; // reflow to re-trigger animation + badge.classList.add('pulse'); + } + } + if (countEl) { + countEl.textContent = count; + } + + // Refresh thread list to update gate dots + debouncedLoadThreads(); +} + +/** Check if a thread has any pending gates. */ +function aqThreadHasPendingGate(threadId) { + for (var entry of pendingGates.values()) { + if (entry.thread_id === threadId) return true; + } + return false; +} + +/** Get a short label for a thread ID (e.g. "main", truncated ID). */ +function aqThreadLabel(threadId) { + if (!threadId) return 'main'; + if (threadId === assistantThreadId) return 'main'; + // Truncate long UUIDs + if (threadId.length > 12) return threadId.substring(0, 8); + return threadId; +} + +// Tray collapse/expand toggle +(function () { + var header = document.getElementById('aq-header'); + if (header) { + header.addEventListener('click', function () { + var tray = document.getElementById('approval-tray'); + if (tray) tray.classList.toggle('collapsed'); + }); + } + + // Batch approve all + var approveAll = document.getElementById('aq-approve-all'); + if (approveAll) { + approveAll.addEventListener('click', function (e) { + e.stopPropagation(); + var ids = Array.from(pendingGates.keys()); + ids.forEach(function (id, i) { + setTimeout(function () { sendApprovalAction(id, 'approve'); }, i * 100); + }); + }); + } + + // Batch deny all + var denyAll = document.getElementById('aq-deny-all'); + if (denyAll) { + denyAll.addEventListener('click', function (e) { + e.stopPropagation(); + var ids = Array.from(pendingGates.keys()); + ids.forEach(function (id, i) { + setTimeout(function () { sendApprovalAction(id, 'deny'); }, i * 100); + }); + }); + } +})(); diff --git a/crates/ironclaw_gateway/static/index.html b/crates/ironclaw_gateway/static/index.html index 45622f1a757..0b4daabbdb0 100644 --- a/crates/ironclaw_gateway/static/index.html +++ b/crates/ironclaw_gateway/static/index.html @@ -154,7 +154,7 @@

Restart IronClaw Instance

- + @@ -229,6 +229,27 @@

Restart IronClaw Instance

Assistant
+ +
+
+ + + + Awaiting + 0 +
+
+ + +
+ + + +
+
+
+
+
Conversations
diff --git a/crates/ironclaw_gateway/static/style.css b/crates/ironclaw_gateway/static/style.css index 17cec366832..1e9f32bedf0 100644 --- a/crates/ironclaw_gateway/static/style.css +++ b/crates/ironclaw_gateway/static/style.css @@ -4454,6 +4454,270 @@ mark { flex-shrink: 0; } +/* --- Approval Queue Tray (sidebar) --- */ + +.tab-approval-badge { + position: absolute; + top: 6px; + right: 4px; + min-width: 16px; + height: 16px; + font-size: 10px; + font-weight: 700; + background: var(--warning); + color: var(--bg); + border-radius: 8px; + padding: 0 4px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + animation: aqBadgePop 300ms var(--ease-spring) forwards; + box-shadow: 0 0 0 2px var(--tab-bg); +} + +.tab-approval-badge.empty { display: none; } +.tab-approval-badge.pulse { + animation: aqBadgePop 300ms var(--ease-spring) forwards, + aqBadgePulse 2s ease-in-out 300ms 3; +} + +@keyframes aqBadgePop { + 0% { transform: scale(0); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} +@keyframes aqBadgePulse { + 0%, 100% { box-shadow: 0 0 0 2px var(--tab-bg); } + 50% { box-shadow: 0 0 0 2px var(--tab-bg), 0 0 8px rgba(245, 166, 35, 0.4); } +} + +/* Tray container: hidden by default, animated in */ +.approval-tray { + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 300ms var(--ease-out-expo), opacity 200ms ease; +} +.approval-tray.visible { max-height: 400px; opacity: 1; } +.approval-tray.collapsed .aq-items { max-height: 0; opacity: 0; margin: 0; } + +/* Tray header */ +.aq-header { + display: flex; + align-items: center; + padding: 8px 10px 4px; + gap: 6px; + cursor: pointer; + user-select: none; + border-radius: var(--radius); + transition: background var(--transition-fast); +} +.aq-header:hover { background: var(--hover-subtle); } + +.aq-header-icon { width: 14px; height: 14px; color: var(--warning); flex-shrink: 0; } + +.aq-header-label { + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--warning); +} + +.aq-header-count { + font-size: 10px; + font-weight: 700; + background: var(--warning); + color: var(--bg); + min-width: 16px; + height: 16px; + border-radius: 8px; + padding: 0 4px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.aq-header .spacer { flex: 1; } + +.aq-header-chevron { + width: 12px; + height: 12px; + color: var(--text-dimmed); + transition: transform 200ms ease; + flex-shrink: 0; +} +.approval-tray.collapsed .aq-header-chevron { transform: rotate(-90deg); } + +/* Batch actions */ +.aq-batch { display: flex; gap: 4px; opacity: 0; transition: opacity var(--transition-fast); } +.aq-header:hover .aq-batch { opacity: 1; } + +.aq-batch-btn { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + border: 1px solid var(--border); + background: none; + cursor: pointer; + font-family: inherit; + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); +} +.aq-batch-btn.approve-all { color: var(--success); border-color: var(--accent-border-subtle); } +.aq-batch-btn.approve-all:hover { background: var(--accent-subtle); } +.aq-batch-btn.deny-all { color: var(--danger); border-color: var(--danger-border-subtle); } +.aq-batch-btn.deny-all:hover { background: var(--danger-subtle); } + +/* Items container */ +.aq-items { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; + max-height: 200px; + overflow-y: auto; + overflow-x: hidden; + transition: max-height 200ms ease, opacity 200ms ease, margin 200ms ease; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.aq-divider { + height: 1px; + background: rgba(245, 166, 35, 0.15); + margin: 6px 10px; + opacity: 0; + transition: opacity 200ms ease; +} +.approval-tray.visible .aq-divider { opacity: 1; } + +/* Tray item */ +.aq-item { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + background: rgba(245, 166, 35, 0.04); + border: 1px solid rgba(245, 166, 35, 0.15); + border-radius: var(--radius); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); + animation: aqItemSlideIn 250ms var(--ease-spring-gentle) backwards; +} +.aq-item:nth-child(1) { animation-delay: 0ms; } +.aq-item:nth-child(2) { animation-delay: 50ms; } +.aq-item:nth-child(3) { animation-delay: 100ms; } +.aq-item:nth-child(4) { animation-delay: 150ms; } + +@keyframes aqItemSlideIn { + from { opacity: 0; transform: translateY(-6px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.aq-item:hover { + background: rgba(245, 166, 35, 0.08); + border-color: rgba(245, 166, 35, 0.25); + transform: translateX(2px); +} + +.aq-item-top { display: flex; align-items: center; gap: 6px; } +.aq-item-tool { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} +.aq-item-thread { + font-size: 10px; + font-weight: 500; + color: var(--text-dimmed); + background: var(--bg-tertiary); + padding: 1px 5px; + border-radius: 3px; + white-space: nowrap; + flex-shrink: 0; +} +.aq-item-time { font-size: 10px; color: var(--text-dimmed); flex-shrink: 0; } + +.aq-item-desc { + font-size: var(--text-xs); + color: var(--text-secondary); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Tray item actions */ +.aq-item-actions { display: flex; gap: 4px; align-items: center; } + +.aq-btn { + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 5px; + border: 1px solid var(--border); + background: none; + cursor: pointer; + font-family: inherit; + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), transform 100ms ease; + line-height: 1.3; +} +.aq-btn:active { transform: scale(0.95); } +.aq-btn.aq-approve { background: var(--success); border-color: var(--success); color: var(--text-on-accent); } +.aq-btn.aq-approve:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +.aq-btn.aq-always { color: var(--accent); border-color: var(--accent-border-subtle); } +.aq-btn.aq-always:hover { background: var(--accent-subtle); } +.aq-btn.aq-deny { color: var(--danger); border-color: var(--danger-border-subtle); } +.aq-btn.aq-deny:hover { background: var(--danger-subtle); } +.aq-btn.aq-view { color: var(--text-secondary); border-color: transparent; padding: 3px 6px; margin-left: auto; } +.aq-btn.aq-view:hover { color: var(--text); background: var(--hover-subtle); } + +/* Resolved tray item */ +.aq-item.resolved { + opacity: 0.5; + pointer-events: none; + animation: aqItemFadeOut 400ms ease forwards; +} +@keyframes aqItemFadeOut { + 0% { opacity: 0.5; max-height: 100px; margin-bottom: 2px; } + 60% { opacity: 0; max-height: 100px; } + 100% { opacity: 0; max-height: 0; margin-bottom: 0; padding: 0; border-width: 0; } +} +.aq-resolved-label { font-size: 11px; font-weight: 500; display: flex; align-items: center; gap: 4px; } +.aq-resolved-label.approved { color: var(--success); } +.aq-resolved-label.denied { color: var(--danger); } + +/* Thread gate dot (amber pip on thread items with pending approvals) */ +.thread-item-gate-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--warning); + flex-shrink: 0; + margin-left: 4px; + animation: aqGateDotPulse 2.5s ease-in-out infinite; +} +@keyframes aqGateDotPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Hide tray content when sidebar is collapsed */ +.thread-sidebar.collapsed .approval-tray { display: none; } + +/* Touch targets */ +@media (pointer: coarse) { + .aq-btn { min-height: 36px; padding: 6px 12px; } + .aq-batch-btn { min-height: 28px; padding: 4px 8px; } +} + /* --- Memory editing --- */ #memory-breadcrumb-path { @@ -5817,6 +6081,12 @@ input[type="checkbox"]:focus-visible { --warning-soft: var(--warning-subtle); } +/* Approval queue light-theme overrides */ +[data-theme="light"] .tab-approval-badge { box-shadow: 0 0 0 2px var(--tab-bg); } +[data-theme="light"] .aq-item { background: rgba(217, 119, 6, 0.04); border-color: rgba(217, 119, 6, 0.18); } +[data-theme="light"] .aq-item:hover { background: rgba(217, 119, 6, 0.08); border-color: rgba(217, 119, 6, 0.25); } +[data-theme="light"] .aq-divider { background: rgba(217, 119, 6, 0.18); } + /* ============================================================ Theme transition (delayed via JS to avoid FOUC) ============================================================ */