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;