boxes. Marking
// them as ARIA dialogs lets screen readers announce them as dialogs and
@@ -118,9 +149,38 @@
el.click();
});
+ // Disclosure buttons: keep aria-expanded in sync with their controlled
+ // menu/dropdown, regardless of which module toggles its visibility.
+ var DISCLOSURE_MAP = [
+ { button: 'session-sort-btn', menu: 'session-sort-dropdown' },
+ { button: 'export-dl-btn', menu: 'export-dropdown-menu' },
+ { button: 'overflow-plus-btn', menu: 'overflow-menu' }
+ ];
+
+ function syncDisclosures() {
+ DISCLOSURE_MAP.forEach(function (d) {
+ var btn = document.getElementById(d.button);
+ if (!btn) return;
+ var menu = document.getElementById(d.menu);
+ var open = false;
+ if (menu) {
+ var cs = window.getComputedStyle(menu);
+ open = cs.display !== 'none' && menu.offsetWidth > 0;
+ }
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
+ });
+ }
+
function init() {
enhanceAll(document);
enhanceModals(document);
+ associateSettingsLabels(document);
+
+ // Any click (the dropdown toggles, outside-click closes, etc.) may change
+ // a disclosure's open state; re-sync on the next tick.
+ document.addEventListener('click', function () {
+ if (window.setTimeout) setTimeout(syncDisclosures, 0);
+ });
// Sidebar content is re-rendered as the user navigates (session lists,
// tool sub-rows, etc.). Watch for new rows and enhance them too.
diff --git a/static/js/admin.js b/static/js/admin.js
index 9c7a1ea32..375c3d1cf 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -763,7 +763,7 @@ function renderFeatherlessPanel(panel, ep, row) {
const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b));
enabledListEl.innerHTML = sortedIds.map(id => {
const displayName = id.split('/').pop() || id;
- const mode = ['none', 'compact', 'full'].includes(String(toolModes[id] || '').toLowerCase())
+ const mode = ['none', 'compact', 'full', 'spec'].includes(String(toolModes[id] || '').toLowerCase())
? String(toolModes[id]).toLowerCase()
: '';
return `
@@ -777,7 +777,8 @@ function renderFeatherlessPanel(panel, ep, row) {
@@ -1356,7 +1357,7 @@ async function loadEndpoints() {
None
${warningHtml}${showSearch ? `
${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 (``) 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 `
})
`;
+ 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 (
+ `` +
+ `
` +
+ `${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(`
`);
@@ -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) {