diff --git a/static/js/chat.js b/static/js/chat.js index df68d7e33..ab280d9b8 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -205,6 +205,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let _streamSessionId = null; // Session ID for the currently active reader loop let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams let _webLockRelease = null; // Function to release the Web Lock held during streaming + let _staleStreamProbeInFlight = false; + const STALE_LOCAL_STREAM_MS = 15000; /** Check if an SSE reader is still actively connected for a session. */ function hasActiveStream(sessionId) { @@ -3108,6 +3110,21 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer return; } + if (abortReason === 'stale-local') { + const staleMsg = 'Stream connection ended. Composer unlocked; send again if needed.'; + if (holder && !accumulated) { + holder.querySelector('.body').innerHTML = + `
[${staleMsg}]
`; + } else if (holder && accumulated) { + const staleNote = document.createElement('div'); + staleNote.className = 'stopped-indicator'; + staleNote.innerHTML = `[${staleMsg}]`; + holder.querySelector('.body').appendChild(staleNote); + } + currentAbort = null; + return; + } + // User-initiated stop (or browser navigation abort). // Stopped before any text arrived — keep the bubble as a // "Cancelled by user" record (so it survives a refresh). @@ -3414,12 +3431,51 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer box.appendChild(bar); if (uiModule.scrollHistory) uiModule.scrollHistory(); } + async function _probeStaleLocalStream() { + if (!isStreaming || _staleStreamProbeInFlight) return; + if (Date.now() - _lastReaderActivity < STALE_LOCAL_STREAM_MS) return; + const sid = _streamSessionId || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId()); + if (!sid) return; + if (_backgroundStreams.has(sid) || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() !== sid)) return; + _staleStreamProbeInFlight = true; + try { + const res = await fetch(`${API_BASE}/api/chat/stream_status/${encodeURIComponent(sid)}`, { + credentials: 'same-origin', + cache: 'no-store', + }); + if (!isStreaming || _backgroundStreams.has(sid)) return; + if (res.status !== 404) return; + + console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.'); + if (currentAbort && !currentAbort.signal.aborted) { + currentAbort._reason = 'stale-local'; + currentAbort.abort(); + } + isStreaming = false; + _setForegroundChatBusy(false); + _sendInFlight = false; + if (_webLockRelease) { + _webLockRelease(); + _webLockRelease = null; + } + const submitBtn = document.querySelector('.send-btn'); + if (submitBtn) updateSubmitButton('idle', submitBtn); + const messageInput = uiModule.el('message'); + if (messageInput) messageInput.disabled = false; + _drainQueuedAgentRequests(); + } catch (err) { + console.warn('[stream-watchdog] Stream status probe failed:', err); + } finally { + _staleStreamProbeInFlight = false; + } + } + function _startStallWatchdog() { - // Disabled: the server-side stall detector / auto-continue (agent - // loop-breaker) handles quiet/stalled streams now, so the manual - // "Quiet for Nm — still working?" banner is redundant (and annoying). + // Keep the old noisy stall banner disabled. This watchdog only unlocks + // a dead local stream after the backend confirms no active stream exists. if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; } _removeStallBanner(); + _stallWatchdog = setInterval(_probeStaleLocalStream, 5000); } function _stopStallWatchdog() { if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; } diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index 3fd77cfad..3f2a2e2d6 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -57,9 +57,24 @@ function _downloadDisplayName(name, task) { return part ? `${name} · ${part}` : name; } +function _downloadNameFromPayload(name, payload) { + const rawName = String(name || '').trim(); + // Defensive: failed/restarted downloads can inherit the wrapper executable + // name if older state was saved from a command preview. The row title should + // always be the model/repo, never "bash" or "python". + const looksLikeLauncher = /^(?:bash|sh|zsh|python|python3|pwsh|powershell|cmd|tmux)$/i.test(rawName); + const base = (!rawName || looksLikeLauncher) + ? String(payload?.repo_id || payload?.repo || '').split('/').pop() + : rawName; + const include = payload?.include || ''; + if (!include || String(base || '').includes(' · ')) return base || rawName || 'download'; + const part = _ggufDisplayPartFromPath(String(include).replace(/\*/g, '')); + return part ? `${base} · ${part}` : (base || rawName || 'download'); +} + function _taskDisplayName(task) { const name = String(task?.name || '').trim(); - if (task?.type === 'download') return _downloadDisplayName(name, task); + if (task?.type === 'download') return _downloadDisplayName(_downloadNameFromPayload(name, task?.payload), task); if (task?.type !== 'serve') return name; const gguf = task?.payload?._fields?.gguf_file || task?.payload?.gguf_file || ''; if (!gguf || name.includes(' · ')) return name; @@ -1384,6 +1399,7 @@ async function _retryDownload(name, payload, replaceSessionId = '') { const tasks = _loadTasks(); const task = tasks.find(t => t.sessionId === replaceSessionId); if (task) { + task.name = _downloadNameFromPayload(name || task.name, _payload); task.id = data.session_id; task.sessionId = data.session_id; task.status = 'running'; @@ -2344,10 +2360,18 @@ export function _renderRunningTab() { el.addEventListener('touchcancel', _lpCancel, { passive: true }); menuBtn.addEventListener('click', (e) => { e.stopPropagation(); + const existing = document.querySelector('.cookbook-task-dropdown'); + if (existing && existing._anchor === menuBtn) { + if (typeof existing._dismiss === 'function') existing._dismiss(); + else existing.remove(); + return; + } document.querySelectorAll('.cookbook-task-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); }); const dropdown = document.createElement('div'); dropdown.className = 'cookbook-task-dropdown'; + dropdown._anchor = menuBtn; + menuBtn.classList.add('cookbook-menu-active'); const items = []; // ── Run section ───────────────────────────────────────────── @@ -2549,7 +2573,7 @@ export function _renderRunningTab() { } const closeHandler = (ev) => { - if (!dropdown.contains(ev.target) && ev.target !== menuBtn) { + if (!dropdown.contains(ev.target) && ev.target !== menuBtn && !menuBtn.contains(ev.target)) { _cleanup(); } }; @@ -2561,6 +2585,7 @@ export function _renderRunningTab() { const _cleanup = () => { _unreg(); _unreg = () => {}; dropdown.remove(); + menuBtn.classList.remove('cookbook-menu-active'); document.removeEventListener('click', closeHandler); window.removeEventListener('scroll', scrollClose, true); window.visualViewport?.removeEventListener('scroll', scrollClose); diff --git a/static/style.css b/static/style.css index 3d48db469..c3869f596 100644 --- a/static/style.css +++ b/static/style.css @@ -20795,6 +20795,12 @@ body.gallery-selecting .gallery-dl-btn, border-color: var(--border); color: var(--fg); } +.cookbook-task-menu-btn.cookbook-menu-active { + opacity: 1 !important; + background: color-mix(in srgb, var(--fg) 9%, transparent); + border-color: var(--border); + color: var(--fg); +} @media (max-width: 768px) { .cookbook-task .cookbook-task-menu-btn { opacity: 0.72;