diff --git a/frontend/src/features/notes/ui/cardInteractions.js b/frontend/src/features/notes/ui/cardInteractions.js index 09e083c41..4160e5c1b 100644 --- a/frontend/src/features/notes/ui/cardInteractions.js +++ b/frontend/src/features/notes/ui/cardInteractions.js @@ -112,7 +112,7 @@ export function createCardInteractions({ if (state.selectMode) { body.querySelectorAll('.note-card').forEach(card => { card.addEventListener('click', (e) => { - if (e.target.closest('.note-card-cb')) return; // checkbox handles itself + if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection e.stopPropagation(); tapToEditOrSelect(card); }); diff --git a/frontend/src/features/notes/ui/editor.js b/frontend/src/features/notes/ui/editor.js index f5bdc7c9f..678b08804 100644 --- a/frontend/src/features/notes/ui/editor.js +++ b/frontend/src/features/notes/ui/editor.js @@ -51,7 +51,7 @@ export function createEditor({ - ${currentImageUrl && type !== 'draw' ? `
` : ''} + ${currentImageUrl && type !== 'draw' ? `
` : ''}
${type === 'note' ? `` @@ -297,7 +297,7 @@ export function createEditor({ form.querySelector('.note-form-image-wrap')?.remove(); const wrap = document.createElement('div'); wrap.className = 'note-form-image-wrap'; - wrap.innerHTML = ``; + wrap.innerHTML = ``; // Insert AFTER the whole header (a flex-row), not after the // title input itself : otherwise the image lands as a sibling // of the title inside the header and flex puts them side-by-side. diff --git a/frontend/src/features/notes/ui/text.js b/frontend/src/features/notes/ui/text.js index 75ecbaf78..bb71cb690 100644 --- a/frontend/src/features/notes/ui/text.js +++ b/frontend/src/features/notes/ui/text.js @@ -31,7 +31,7 @@ export function createText({ url = url.slice(0, -1); } const href = url.startsWith('www.') ? `https://${url}` : url; - return `${url}` + (url !== m ? m.slice(url.length) : ''); + return `${url}` + (url !== m ? m.slice(url.length) : ''); }); } diff --git a/static/css/01-agent-chat.css b/static/css/01-agent-chat.css index 506d04e81..32afd8917 100644 --- a/static/css/01-agent-chat.css +++ b/static/css/01-agent-chat.css @@ -7230,6 +7230,39 @@ body.bg-pattern-ascii-fireflies { text-align: center; } + /* Inline assistant markdown images (`![alt](url)`) — rendered by + static/js/markdown.js as a.clickable wrapper. */ + a.chat-image { + display: inline-block; + max-width: 100%; + margin: 0.35rem 0; + } + a.chat-image img.chat-inline-image { + display: block; + max-width: 100%; + height: auto; + border-radius: 8px; + cursor: zoom-in; + } + a.chat-image .chat-image-fallback { + display: none; + font-size: 0.8rem; + color: var(--color-muted, #94a3b8); + word-break: break-all; + padding: 0.5rem 0.75rem; + border: 1px dashed var(--border, #475569); + border-radius: 8px; + background: var(--panel, #202428); + } + a.chat-image.image-broken img.chat-inline-image { + display: none; + } + a.chat-image.image-broken .chat-image-fallback { + display: block; + text-decoration: underline; + cursor: pointer; + } + /* Setup wizard */ .setup-wizard { padding: 16px; max-width: 500px; } .setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); } diff --git a/static/css/03-agent-chat.css b/static/css/03-agent-chat.css index 0e8c95f91..c245899c2 100644 --- a/static/css/03-agent-chat.css +++ b/static/css/03-agent-chat.css @@ -22,6 +22,20 @@ /* Session items */ .session-item { position: relative; } + +/* Title fade: the server auto-renames a chat shortly after its first + prompt, which re-renders the sidebar. Fade the new title in so the + change reads as smooth rather than a snap. */ +.session-item .grow.session-title-updated { + animation: sessionTitleFade 0.5s ease; +} +@keyframes sessionTitleFade { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } +} +@media (prefers-reduced-motion: reduce) { + .session-item .grow.session-title-updated { animation: none; } +} .text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .session-menu-btn { padding: 0 2px !important; min-width: 20px; height: 20px; display: inline-flex !important; align-items: center; justify-content: center; background: none !important; border-color: transparent !important; } .session-menu-btn:hover { background: none !important; border-color: transparent !important; } diff --git a/static/css/04-memory.css b/static/css/04-memory.css index 71b2537bd..67cb7ca16 100644 --- a/static/css/04-memory.css +++ b/static/css/04-memory.css @@ -704,6 +704,7 @@ textarea.memory-add-input { #serve-bulk-bar, #gallery-bulk-bar, #gallery-editor-drafts-bulk, +#notes-bulk-bar, #memory-modal .memory-bulk-bar { padding-right: 18px; } diff --git a/static/css/12-gallery.css b/static/css/12-gallery.css index 16f47e863..dad05ec52 100644 --- a/static/css/12-gallery.css +++ b/static/css/12-gallery.css @@ -90,6 +90,12 @@ .lib-tabs, .gallery-tabs, .preset-tabs { + /* A flex-item tab strip defaults to min-width:auto, so it grows to its + content width inside a narrow modal and the end tabs get clipped by the + modal's overflow instead of scrolling. min-width:0 lets the strip shrink + to the container so the overflow-x:auto below actually engages. */ + min-width: 0; + max-width: 100%; flex-wrap: nowrap !important; overflow-x: auto !important; overflow-y: hidden; diff --git a/static/index.html b/static/index.html index 72a586a20..b9e19cc6e 100644 --- a/static/index.html +++ b/static/index.html @@ -257,7 +257,7 @@ - + @@ -664,6 +664,7 @@
- + +
@@ -1356,7 +1357,7 @@ async function loadEndpoints() {
None
${warningHtml}${showSearch ? `` : ''}
` + sortedModels.map(m => { - const mode = ['none', 'compact', 'full'].includes(String(m.tool_mode || '').toLowerCase()) + const mode = ['none', 'compact', 'full', 'spec'].includes(String(m.tool_mode || '').toLowerCase()) ? String(m.tool_mode).toLowerCase() : ''; return `
@@ -1370,7 +1371,8 @@ async function loadEndpoints() {
@@ -1440,7 +1442,7 @@ async function _saveEpModelState(epId, panel) { const value = String(sel.value || '').toLowerCase(); const original = String(sel.dataset.originalToolMode || '').toLowerCase(); if (modelId && (sel.dataset.toolModeTouched === '1' || original)) { - modelToolModes[modelId] = ['none', 'compact', 'full'].includes(value) ? value : ''; + modelToolModes[modelId] = ['none', 'compact', 'full', 'spec'].includes(value) ? value : ''; } }); const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length; diff --git a/static/js/chat.js b/static/js/chat.js index 9dbb0e268..4c08cd8cc 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -44,6 +44,66 @@ import { invalidateSettings } from './appConfig.js'; const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting const RESEARCH_SVG = ''; + // URL of the photo attached to the current turn (chat uploads are served at + // /api/upload/). Used by the "Open in editor & apply" action. + function _currentAttachedImageUrl() { + try { + const box = document.getElementById('chat-history'); + if (!box) return null; + // Use the newest user bubble that carries an image preview. Note + // ':last-of-type' is wrong here (it selects the last sibling of the + // element type, and the assistant reply follows the user bubble), so + // walk the user bubbles from the end instead. + const users = box.querySelectorAll('.msg-user'); + for (let i = users.length - 1; i >= 0; i--) { + const img = users[i].querySelector('.attach-image-preview[data-file-id] img'); + if (img && img.getAttribute('src')) return img.getAttribute('src'); + } + // Fall back to any attachment preview/card in the history (newest). + const previews = box.querySelectorAll( + '.attach-image-preview[data-file-id], .attach-card[data-file-id]' + ); + if (previews.length) { + const last = previews[previews.length - 1]; + const id = last.dataset ? last.dataset.fileId : null; + if (id) return API_BASE + '/api/upload/' + id; + } + return null; + } catch (_) { + return null; + } + } + + // Render the one-click "Open in editor & apply" button on a photo-edit reply + // that had no open editor (photo_editor_action kind 'open_and_apply'). + function renderPhotoEditorApplyButton(container, action) { + if (!container || !action || action.kind !== 'open_and_apply') return; + if (container.querySelector('.chat-open-in-editor-btn')) return; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'chat-open-in-editor-btn'; + btn.textContent = 'Open in editor & apply'; + btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;'; + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + if (typeof window.gePhotoEditorApply !== 'function') { + await import('/static/js/galleryEditor.js?v=20260909movepicklayer1'); + } + await window.gePhotoEditorApply({ + imageId: null, + imageUrl: _currentAttachedImageUrl(), + prompt: action.prompt || '', + displayName: 'Chat edit', + }); + } catch (err) { + console.error('[chat] open_and_apply failed', err); + btn.disabled = false; + } + }); + container.appendChild(btn); + } + let API_BASE = ''; let currentAbort = null; let isStreaming = false; @@ -4095,6 +4155,7 @@ import { invalidateSettings } from './appConfig.js'; threadAbove.classList.add('has-bottom'); } terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope); + if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action); lastContentRoundHolder = roundHolder || lastContentRoundHolder; uiModule.scrollHistory(); continue; @@ -6519,6 +6580,7 @@ import { invalidateSettings } from './appConfig.js'; gotDelta = true; try { spinner.destroy(); } catch (_) {} renderDelta(json); + if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action); replayRendering.settle(); } else if (json.delta) { if (!replayRendering.accepts(json)) continue; diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index dceec9b24..c96083f5f 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -457,6 +457,7 @@ function _makeActionBtn(className, title, text, handler) { btn.className = className; btn.type = 'button'; btn.title = title; + btn.setAttribute('aria-label', title); btn.textContent = text; btn.addEventListener('click', handler); return btn; @@ -2375,6 +2376,7 @@ export function createMsgFooter(msgElement, { animate = false } = {}) { moreBtn.className = 'msg-action-btn msg-more-btn'; moreBtn.type = 'button'; moreBtn.title = 'More actions'; + moreBtn.setAttribute('aria-label', 'More actions'); moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -2571,6 +2573,7 @@ export function createUserMsgFooter(msgElement) { moreBtn.className = 'msg-action-btn msg-more-btn'; moreBtn.type = 'button'; moreBtn.title = 'More actions'; + moreBtn.setAttribute('aria-label', 'More actions'); moreBtn.textContent = '\u00B7\u00B7\u00B7'; moreBtn.addEventListener('click', (e) => { e.stopPropagation(); @@ -3274,6 +3277,7 @@ export function addMessage(role, content, modelName, metadata) { let lastWrap = null; let firstMsgAi = null; let lastMsgAi = null; + const newNodes = []; const toolsByRound = {}; let lastPrivateBrowserUrl = ''; @@ -3367,6 +3371,7 @@ export function addMessage(role, content, modelName, metadata) { wrap.dataset.raw = txt; if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id; box.appendChild(wrap); + newNodes.push(wrap); lastWrap = wrap; if (!firstMsgAi) firstMsgAi = wrap; lastMsgAi = wrap; @@ -3384,6 +3389,7 @@ export function addMessage(role, content, modelName, metadata) { // Extend line up if there's a chat bubble above if (txt) threadWrap.classList.add('has-top'); box.appendChild(threadWrap); + newNodes.push(threadWrap); } for (const ev of roundTools) { if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user; @@ -3443,7 +3449,9 @@ export function addMessage(role, content, modelName, metadata) { for (const ev of roundTools) { const image = generatedImageResult(ev); if (image) { - box.appendChild(buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id)); + const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id); + box.appendChild(imgBubble); + newNodes.push(imgBubble); } } } diff --git a/static/js/chatStream.js b/static/js/chatStream.js index 4981ac34d..84497c163 100644 --- a/static/js/chatStream.js +++ b/static/js/chatStream.js @@ -212,6 +212,8 @@ export function handleUIControl(uiData) { var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes)); if (fn) fn(); }).catch(function(){}); + } else if (panel === 'tasks') { + document.getElementById('tool-tasks-btn')?.click(); } else if (panel === 'theme' || panel === 'themes') { import('./theme.js?v=20260911organsrain1').then(function(mod) { var fn = mod.togglePopup || (mod.default && mod.default.togglePopup); diff --git a/static/js/codeRunner.js b/static/js/codeRunner.js index fd896883d..c9ea1ac9d 100644 --- a/static/js/codeRunner.js +++ b/static/js/codeRunner.js @@ -390,11 +390,11 @@ export function runHTML(code, panel) { * Main entry point — called when a Run button is clicked */ export function run(btn) { - const code = btn.getAttribute('data-code'); - const lang = (btn.getAttribute('data-lang') || '').toLowerCase(); - const pre = btn.closest('pre'); if (!pre) return; + const codeEl = pre.querySelector('code'); + const code = codeEl ? codeEl.textContent : btn.getAttribute('data-code'); + const lang = (btn.getAttribute('data-lang') || '').toLowerCase(); const panel = getOrCreatePanel(pre); if (!code || !code.trim()) { diff --git a/static/js/colorPicker.js b/static/js/colorPicker.js index e28424a3b..e90e85b44 100644 --- a/static/js/colorPicker.js +++ b/static/js/colorPicker.js @@ -96,6 +96,21 @@ function computeSuggestions() { ]; } +// ── Reusable helpers for hosts that surface swatches inline ────────── +// (e.g. the editor's SWATCHES panel showing suggestions + recents). +export function getRecentColors() { return getRecents(); } +export function recordRecentColor(hex) { addRecent(hex); } +export function suggestColorsFor(hex) { + const { h, s, v } = hexToHsv(hex || '#000000'); + return [ + { hex: hsvToHex(h + 180, s, v), label: 'Complement' }, + { hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' }, + { hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' }, + { hex: hsvToHex(h + 150, s, v), label: 'Split-complement' }, + { hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' }, + ]; +} + // ── Popover build ───────────────────────────────────────────────────── function buildPopover() { const p = document.createElement('div'); diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index f9511ed60..2ebf18086 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -1498,7 +1498,8 @@ function _rerenderCachedModels() { const _ggufOptions = _ggufChoices.map(f => `` ).join(''); - const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo); + const _minimaxM3Snapshot = '~/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b'; + const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo); const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim(); const _modelPathValue = _savedModelPath || _defaultServeModel; const _defaultServedModelName = _isMiniMaxM3 ? repo : ''; diff --git a/static/js/emailLibrary/index.js b/static/js/emailLibrary/index.js index afb21d76c..39a607368 100644 --- a/static/js/emailLibrary/index.js +++ b/static/js/emailLibrary/index.js @@ -1498,8 +1498,11 @@ let _libSyncStatus = { source: '', warming: false, loading: false, + stale: false, }; let _libSyncTicker = null; +let _libStaleRefetchTimer = null; +let _libStaleRefetchCount = 0; function _libSyncDateFrom(value) { if (!value) return null; @@ -1554,9 +1557,37 @@ function _setEmailSyncStatus(next = {}) { if (Object.prototype.hasOwnProperty.call(next, 'loading')) { _libSyncStatus.loading = Boolean(next.loading); } + if (Object.prototype.hasOwnProperty.call(next, 'stale')) { + _libSyncStatus.stale = Boolean(next.stale); + } _renderEmailSyncStatus(); } +// A server-side stale (sync.stale) response means the SWR snapshot was served +// while a background IMAP refresh is still running. Re-fetch in place after a +// short delay (bounded) so the fresh rows land with no skeleton and no scroll +// jump. We only keep retrying while the response is still marked stale. +function _clearStaleEmailRefetch() { + if (_libStaleRefetchTimer) { + clearTimeout(_libStaleRefetchTimer); + _libStaleRefetchTimer = null; + } + _libStaleRefetchCount = 0; +} + +function _scheduleStaleEmailRefetch() { + if (_libStaleRefetchTimer || _libStaleRefetchCount >= 3) return; + const folderAtSchedule = state._libFolder; + const accountAtSchedule = state._libAccountId || ''; + _libStaleRefetchCount++; + _libStaleRefetchTimer = setTimeout(() => { + _libStaleRefetchTimer = null; + if (document.hidden) return; + if (folderAtSchedule !== state._libFolder || accountAtSchedule !== (state._libAccountId || '')) return; + _loadEmails({ force: false, useCache: false, skipFastPath: true }); + }, 2500); +} + function _libCacheKeyFor(accountId, folder, filter, hasAttachments) { return [ accountId || '', @@ -5044,11 +5075,15 @@ export async function _loadEmails({ force = false, useCache = true } = {}) { } const stats = document.getElementById('email-lib-stats'); if (stats) stats.textContent = `${state._libTotal} emails`; + const _isStale = Boolean(sync.stale); _setEmailSyncStatus({ updatedAt: sync.updated_at || '', source: sync.source || (cacheSource ? 'client_cache' : ''), loading: false, + stale: _isStale, }); + if (_isStale) _scheduleStaleEmailRefetch(); + else _clearStaleEmailRefetch(); if (cacheable && !cacheSource) { _libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal, sync }); } diff --git a/static/js/features/notes/ui/cardInteractions.js b/static/js/features/notes/ui/cardInteractions.js index 09e083c41..4160e5c1b 100644 --- a/static/js/features/notes/ui/cardInteractions.js +++ b/static/js/features/notes/ui/cardInteractions.js @@ -112,7 +112,7 @@ export function createCardInteractions({ if (state.selectMode) { body.querySelectorAll('.note-card').forEach(card => { card.addEventListener('click', (e) => { - if (e.target.closest('.note-card-cb')) return; // checkbox handles itself + if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection e.stopPropagation(); tapToEditOrSelect(card); }); diff --git a/static/js/features/notes/ui/editor.js b/static/js/features/notes/ui/editor.js index f5bdc7c9f..678b08804 100644 --- a/static/js/features/notes/ui/editor.js +++ b/static/js/features/notes/ui/editor.js @@ -51,7 +51,7 @@ export function createEditor({
- ${currentImageUrl && type !== 'draw' ? `
` : ''} + ${currentImageUrl && type !== 'draw' ? `
` : ''}
${type === 'note' ? `` @@ -297,7 +297,7 @@ export function createEditor({ form.querySelector('.note-form-image-wrap')?.remove(); const wrap = document.createElement('div'); wrap.className = 'note-form-image-wrap'; - wrap.innerHTML = ``; + wrap.innerHTML = ``; // Insert AFTER the whole header (a flex-row), not after the // title input itself : otherwise the image lands as a sibling // of the title inside the header and flex puts them side-by-side. diff --git a/static/js/features/notes/ui/text.js b/static/js/features/notes/ui/text.js index 75ecbaf78..bb71cb690 100644 --- a/static/js/features/notes/ui/text.js +++ b/static/js/features/notes/ui/text.js @@ -31,7 +31,7 @@ export function createText({ url = url.slice(0, -1); } const href = url.startsWith('www.') ? `https://${url}` : url; - return `${url}` + (url !== m ? m.slice(url.length) : ''); + return `${url}` + (url !== m ? m.slice(url.length) : ''); }); } diff --git a/static/js/markdown.js b/static/js/markdown.js index abf7bcf36..b69f0de92 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -294,14 +294,61 @@ function moreListPayloadFromHref(href) { return _moreListPayloads.get(`${match[1]}:${match[2]}`) || ''; } +// Assistant markdown images (`![alt](url)`) render inline as a clickable +// thumbnail that opens the full-size image in a new tab. Only https URLs are +// allowed — an http URL (or anything unparseable) is downgraded to its alt +// text rather than rendered as an image. A broken image (the model can +// fabricate a URL) must never leave an empty gap, so each image is paired with +// a hidden fallback caption (alt text + URL) that _bindChatImageFallback() +// reveals when the load fails. function imageHtml(alt, url, title) { - const safeUrl = safeLinkUrl(url); - if (!safeUrl || safeUrl.startsWith('#')) return escapeHtml(alt || ''); + const rawUrl = String(url || '').trim(); + let safeUrl = ''; + try { + const parsed = new URL(rawUrl, window.location.origin); + if (parsed.protocol === 'https:') safeUrl = parsed.href; + } catch (_) { + safeUrl = ''; + } + if (!safeUrl) return escapeHtml(alt || rawUrl || ''); const safeAlt = escapeHtml(alt || ''); const safeTitle = title ? ` title="${escapeHtml(title)}"` : ''; - return `${safeAlt}`; + const displayUrl = escapeHtml(safeUrl); + const fallbackLabel = escapeHtml(alt || 'Image'); + // The fallback caption shows the URL as readable text, but a bare + // `https://` in that text would be caught by the later bare-URL autolink + // pass and wrapped in a nested — which then truncates this outer + // when it is extracted as an allowed-HTML block. Encode the `//` so it + // displays as `https://` yet is invisible to the autolink regex. + const fallbackUrl = displayUrl.replace('://', '://'); + return ( + `` + + `${safeAlt}` + + `${fallbackLabel} — ${fallbackUrl}` + + `` + ); } +// Reveal the alt-text + link fallback whenever an inline chat image fails to +// load, and hide it again if a late load succeeds. `error`/`load` on do +// not bubble, so this listens in the capture phase (document → target). +function _bindChatImageFallback() { + if (typeof document === 'undefined' || !document.addEventListener) return; + const wrapOf = (target) => { + if (!target || target.tagName !== 'IMG' || !target.classList || !target.classList.contains('chat-inline-image')) return null; + return target.closest ? target.closest('a.chat-image') : null; + }; + document.addEventListener('error', (event) => { + const wrap = wrapOf(event && event.target); + if (wrap) wrap.classList.add('image-broken'); + }, true); + document.addEventListener('load', (event) => { + const wrap = wrapOf(event && event.target); + if (wrap) wrap.classList.remove('image-broken'); + }, true); +} +_bindChatImageFallback(); + function _isModelEndpointUrl(rawUrl) { try { const parsed = new URL(String(rawUrl || ''), window.location.origin); diff --git a/static/js/modelPicker.js b/static/js/modelPicker.js index 45150c67f..324364c7c 100644 --- a/static/js/modelPicker.js +++ b/static/js/modelPicker.js @@ -810,7 +810,10 @@ async function _pick(m) { _renderLoading('Loading models…'); } if (window.modelsModule && window.modelsModule.refreshModels) { - _refreshPickerModels({ force: true, showLoading: !hasCache }).then(isLatest => { + // Open instantly from cache; never force a re-probe on open (a dead + // endpoint would otherwise stall every open). The explicit refresh + // button still forces a full re-probe. + _refreshPickerModels({ force: false, showLoading: !hasCache }).then(isLatest => { if (isLatest && !menu.classList.contains('hidden')) _populate(search.value || ''); updateModelPicker(); }).catch(() => {}); diff --git a/static/js/models.js b/static/js/models.js index b75b86907..a377cd2ba 100644 --- a/static/js/models.js +++ b/static/js/models.js @@ -589,6 +589,12 @@ export async function refreshModels(force = false, opts = {}) { + 'Ask an admin to configure model endpoints'; } box.appendChild(noModels); + const openAdmin = noModels.querySelector('#models-open-admin'); + if (openAdmin) openAdmin.addEventListener('click', (e) => { + e.preventDefault(); + const btn = document.getElementById('user-bar-admin'); + if (btn) btn.click(); + }); } } catch (e) { console.error(e); diff --git a/static/js/research/jobs.js b/static/js/research/jobs.js index 1bd316a1f..4340f9c4d 100644 --- a/static/js/research/jobs.js +++ b/static/js/research/jobs.js @@ -80,7 +80,10 @@ export function init(apiBase) { // sidebar — _reconnectActive only ran once at load before, so // agent-started jobs never appeared until a page reload. if (_activePollInterval) clearInterval(_activePollInterval); - _activePollInterval = setInterval(() => { _reconnectActive(); }, 20000); + _activePollInterval = setInterval(() => { + if (document.hidden) return; + _reconnectActive(); + }, 60000); } // Allow an immediate adopt when the chat stream signals a new research diff --git a/static/js/sessions.js b/static/js/sessions.js index 0a910ef00..111e4b791 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -38,6 +38,89 @@ let _historyPager = null; const _serverStreamChecksInFlight = new Set(); const _serverStreamAbsentUntil = new Map(); const SERVER_STREAM_ABSENT_TTL_MS = 30000; +// Last title we rendered for each session, so the sidebar can fade a title +// in when the server auto-renames a chat (e.g. the spec model titles a new +// chat from its first prompt) instead of snapping to it. +const _prevSessionTitles = new Map(); + +// === copy-chat header (debug trace block) === +function _toolSurfaceLabel(mode) { + const m = String(mode == null ? '' : mode).toLowerCase(); + if (m === 'full') return 'Regular tools'; + if (m === 'compact') return 'Ajax Compact'; + if (m === 'spec') return 'Ajax Spec (router)'; + if (m === 'none') return 'Tools off'; + if (!m) return 'Auto'; + return 'unknown'; +} + +function _formatJst(now) { + const jst = new Date(now.getTime() + 9 * 60 * 60 * 1000); + const p = (n) => String(n).padStart(2, '0'); + return `${jst.getUTCFullYear()}-${p(jst.getUTCMonth() + 1)}-${p(jst.getUTCDate())} ${p(jst.getUTCHours())}:${p(jst.getUTCMinutes())}`; +} + +function _buildCopyChatHeader(info) { + const version = info.version || 'unknown'; + const build = info.build || 'unknown'; + const commit = (info.sourceCommit || 'unknown').slice(0, 8); + const model = info.model || 'unknown'; + const endpointUrl = info.endpointUrl || 'unknown'; + const toolSurface = info.toolSurface || 'unknown'; + const copiedAt = info.copiedAt ? _formatJst(info.copiedAt) : 'unknown'; + const sessionId = info.sessionId || 'unknown'; + return [ + '--- Odysseus info ---', + `Odysseus: ${version} | Harness build: ${build} | Commit: ${commit}`, + `Model: ${model} @ ${endpointUrl} | Tool surface: ${toolSurface}`, + `Copied: ${copiedAt} JST | Session: ${sessionId}`, + '----------------------', + ].join('\n'); +} +// === end copy-chat header === + +async function _copyChatHeaderFor(s) { + let version = 'unknown'; + let build = 'unknown'; + let sourceCommit = 'unknown'; + try { + const res = await fetch(`${API_BASE}/api/version`); + const v = await res.json(); + if (v && typeof v === 'object') { + version = v.version || 'unknown'; + build = v.build || 'unknown'; + sourceCommit = v.source_commit || 'unknown'; + } + } catch (_) {} + + let toolSurface = 'unknown'; + try { + const res = await fetch(`${API_BASE}/api/model-endpoints`); + if (res.ok) { + const eps = await res.json(); + const list = Array.isArray(eps) ? eps : []; + const model = (s && s.model) || ''; + const ep = list.find((x) => x && ( + (s && s.endpoint_id != null && x.id === s.endpoint_id) || + (s && s.endpoint_url && x.base_url === s.endpoint_url) + )); + if (ep && ep.model_tool_modes && model) { + toolSurface = _toolSurfaceLabel(ep.model_tool_modes[model]); + } + } + } catch (_) {} + + return _buildCopyChatHeader({ + version, + build, + sourceCommit, + model: (s && s.model) || undefined, + endpointUrl: (s && s.endpoint_url) || undefined, + toolSurface, + sessionId: (s && s.id) || undefined, + copiedAt: new Date(), + }); +} function _shouldPreserveStartupComposer(msgInput) { if (!msgInput || !msgInput.value) return false; @@ -684,6 +767,14 @@ function createSessionItem(s) { let chatTitle = s.name || ''; if (_isFork) chatTitle = chatTitle.replace(/^Fork:\s*/, '').replace(/^\u2ADD\s*/, ''); if (_isGroup) chatTitle = chatTitle.replace(/^\[GRP\]\s*/, ''); + // Fade the row's title in only when it actually changed since the last + // render; everything else about the re-render stays silent. + const _prevTitle = _prevSessionTitles.get(s.id); + if (chatTitle && _prevTitle !== undefined && _prevTitle !== chatTitle) { + span.classList.add('session-title-updated'); + } + if (chatTitle) _prevSessionTitles.set(s.id, chatTitle); + let label = chatTitle; if (s.model) label += ' · ' + s.model.split('/').pop(); if (s.archived) label += ' [archived]'; @@ -930,7 +1021,8 @@ function createSessionItem(s) { const text = typeof m.content === 'string' ? m.content.trim() : JSON.stringify(m.content); return `${label}: ${text}`; }); - const text = lines.join('\n\n'); + const header = await _copyChatHeaderFor(s); + const text = header + '\n\n' + lines.join('\n\n'); try { await navigator.clipboard.writeText(text); } catch (_clipErr) { diff --git a/static/js/sidebar-layout.js b/static/js/sidebar-layout.js index 745c1e334..9a94ebcd5 100644 --- a/static/js/sidebar-layout.js +++ b/static/js/sidebar-layout.js @@ -106,6 +106,9 @@ export function initSidebarLayout(Storage, opts) { document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden); document.body.classList.toggle('sidebar-collapsed', sidebarHidden); _setSidebarModeClasses(!sidebarHidden ? 'full' : (railHidden ? 'off' : 'mini')); + hamburgerBtn.setAttribute('aria-expanded', String(!sidebarHidden)); + const sidebarToggleBtn = document.getElementById('sidebar-toggle-btn'); + if (sidebarToggleBtn) sidebarToggleBtn.setAttribute('aria-expanded', String(!sidebarHidden)); } // Keep incognito button clear of hamburger const incogBtn = document.getElementById('incognito-btn'); diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js index d288cb3a1..06dc5f336 100644 --- a/static/js/slashCommands.js +++ b/static/js/slashCommands.js @@ -5336,6 +5336,29 @@ function _eggRender(html) { uiModule.scrollHistory(); } +// Delegated handlers for egg commands that used inline onclick (blocked by +// Firefox CSP script-src-attr). Dice re-roll + color copy. +if (!window._eggDelegatedBound) { + window._eggDelegatedBound = true; + document.addEventListener('click', (e) => { + const dice = e.target.closest && e.target.closest('.egg-dice'); + if (dice) { + const sides = Math.min(parseInt(dice.dataset.diceSides) || 6, 1000); + dice.style.animation = 'none'; + void dice.offsetHeight; + dice.textContent = Math.floor(Math.random() * sides) + 1; + dice.style.animation = 'egg-shake 0.3s ease'; + return; + } + const swatch = e.target.closest && e.target.closest('.egg-color-copy'); + if (swatch) { + navigator.clipboard.writeText(swatch.dataset.color || ''); + swatch.style.transform = 'scale(0.9)'; + setTimeout(() => { swatch.style.transform = ''; }, 150); + } + }); +} + async function _cmdFlip(args, ctx) { const isHeads = Math.random() < 0.5; const edge = Math.random() < 0.001; @@ -5373,7 +5396,7 @@ async function _cmdRoll(args, ctx) { const results = Array.from({ length: count }, () => Math.floor(Math.random() * sides) + 1); const total = results.reduce((a, b) => a + b, 0); const dice = results.map((v, i) => { - return `
${v}
`; + return `
${v}
`; }).join(''); const totalHtml = count > 1 ? `
${count}d${sides} = ${total}
` : ''; _eggRender(`
${dice}
${totalHtml}
`); @@ -5690,7 +5713,7 @@ async function _cmdColor(args, ctx) { const hex = args[0] || '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0'); const c = hex.startsWith('#') ? hex : '#' + hex; _eggRender(`
-
+
${ctx.esc(c)} click swatch to copy
diff --git a/static/js/streamingRenderer.js b/static/js/streamingRenderer.js index 5aa05ec66..0a134bfba 100644 --- a/static/js/streamingRenderer.js +++ b/static/js/streamingRenderer.js @@ -34,6 +34,8 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) { let tailShownLen = 0; // rendered-text length of the live tail (drives token fade) let appendMode = null; // { codeText: Text, appendedLen } while an open fence streams let degraded = !ENABLED; // true once we fall back to full re-render + let tailRaf = null; // pending requestAnimationFrame id for the live-tail re-render + let pendingTail = ''; // most recent tail text awaiting a rAF re-render function start() { contentEl.textContent = ''; @@ -79,6 +81,24 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) { while (holder.firstChild) contentEl.appendChild(holder.firstChild); } + // Coalesce the per-token tail re-render into at most one render per animation + // frame. Fast providers emit many deltas per frame; the frozen-block pass is + // cheap and stays synchronous, but the markdown re-parse + innerHTML write of + // the still-growing tail only needs to happen once per frame for smooth output. + function scheduleTailRender(tailText) { + pendingTail = tailText; + if (tailRaf !== null) return; + tailRaf = requestAnimationFrame(() => { + tailRaf = null; + // The container may have been rewritten out from under us (chat.js writes + // contentEl.innerHTML for thinking indicators and tool blocks). Skip and + // let the next update() self-heal rather than appending stale tail nodes. + if (tailMarker && tailMarker.parentNode === contentEl) { + renderTail(pendingTail); + } + }); + } + // Stream the body of an unterminated code fence by appending only the new // characters to a stable
 text node — no re-parse, no re-highlight.
   function appendOpenFence(tailText, fence) {
@@ -187,6 +207,7 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
   function finalize() {
     if (degraded) return;
     try {
+      if (tailRaf !== null) { cancelAnimationFrame(tailRaf); tailRaf = null; }
       if (!started) start();
       clearTail();
       appendMode = null;
diff --git a/static/js/tasks.js b/static/js/tasks.js
index 5c511dc0c..ee00af11f 100644
--- a/static/js/tasks.js
+++ b/static/js/tasks.js
@@ -3185,7 +3185,7 @@ export function openTasks(focusId, opts) {