mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 00:02:21 +01:00
Integrate updated preview UI review
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createEditor({
|
||||
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
|
||||
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
|
||||
</div>
|
||||
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button></div>` : ''}
|
||||
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button></div>` : ''}
|
||||
<div class="note-form-body">
|
||||
${type === 'note'
|
||||
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
|
||||
@@ -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 = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button>`;
|
||||
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button>`;
|
||||
// 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.
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createText({
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7230,6 +7230,39 @@ body.bg-pattern-ascii-fireflies {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Inline assistant markdown images (``) — rendered by
|
||||
static/js/markdown.js as a.clickable <a class="chat-image"> 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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-1
@@ -257,7 +257,7 @@
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261017titlefade1">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
||||
@@ -664,6 +664,7 @@
|
||||
<div class="theme-fd-group">
|
||||
<label class="theme-fd-label">Font</label>
|
||||
<select id="theme-font-select" class="theme-fd-select" aria-label="Font">
|
||||
<option value="gohu">GohuFont</option>
|
||||
<option value="mono">Monospace</option>
|
||||
<option value="sans">Sans-serif</option>
|
||||
<option value="serif">Serif</option>
|
||||
|
||||
+61
-1
@@ -18,7 +18,13 @@
|
||||
'use strict';
|
||||
|
||||
// Click-as-button rows we want reachable by keyboard.
|
||||
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
|
||||
var ROW_SELECTOR = [
|
||||
'#sidebar .list-item',
|
||||
'#user-bar-profile',
|
||||
'.sidebar-brand',
|
||||
'.export-dropdown-item',
|
||||
'.dropdown-item'
|
||||
].join(',');
|
||||
|
||||
// Native interactive descendants. If a row contains one of these we must
|
||||
// NOT give the row role="button" — a button inside a button is invalid
|
||||
@@ -55,6 +61,31 @@
|
||||
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
|
||||
}
|
||||
|
||||
// Settings form labels are written as <label class="settings-label">Text</label>
|
||||
// with the control as a sibling/descendant, but are not associated via for/id,
|
||||
// so screen readers cannot name the inputs. Associate each label with its
|
||||
// control when the mapping is unambiguous (a single form control per row).
|
||||
var settingsLabelSeq = 0;
|
||||
function associateSettingsLabels(root) {
|
||||
(root || document).querySelectorAll('.settings-label').forEach(function (label) {
|
||||
if (label.hasAttribute('for')) return;
|
||||
var control = null;
|
||||
var sib = label.nextElementSibling;
|
||||
if (sib && /^(INPUT|SELECT|TEXTAREA)$/.test(sib.tagName)) {
|
||||
control = sib;
|
||||
} else {
|
||||
var row = label.closest('.settings-row') || label.parentElement;
|
||||
if (row) {
|
||||
var controls = row.querySelectorAll('input,select,textarea');
|
||||
if (controls.length === 1) control = controls[0];
|
||||
}
|
||||
}
|
||||
if (!control || control.closest('label')) return;
|
||||
if (!control.id) control.id = 'a11y-settings-' + (++settingsLabelSeq);
|
||||
if (!control.getAttribute('aria-label')) label.setAttribute('for', control.id);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Modal dialogs -----------------------------------------------------
|
||||
// Odysseus modals are plain <div class="modal-content"> 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.
|
||||
|
||||
+7
-5
@@ -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 `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row">
|
||||
@@ -777,7 +777,8 @@ function renderFeatherlessPanel(panel, ep, row) {
|
||||
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(id)}" data-original-tool-mode="${esc(toolModes[id] || '')}">
|
||||
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
|
||||
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
|
||||
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
|
||||
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -1356,7 +1357,7 @@ async function loadEndpoints() {
|
||||
<a href="#" data-ep-select-none="${epId}">None</a>
|
||||
</span>
|
||||
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + 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 `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
|
||||
@@ -1370,7 +1371,8 @@ async function loadEndpoints() {
|
||||
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model">
|
||||
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
|
||||
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
|
||||
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
|
||||
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||
|
||||
// URL of the photo attached to the current turn (chat uploads are served at
|
||||
// /api/upload/<id>). 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1498,7 +1498,8 @@ function _rerenderCachedModels() {
|
||||
const _ggufOptions = _ggufChoices.map(f =>
|
||||
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
|
||||
).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 : '';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createEditor({
|
||||
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
|
||||
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
|
||||
</div>
|
||||
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button></div>` : ''}
|
||||
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button></div>` : ''}
|
||||
<div class="note-form-body">
|
||||
${type === 'note'
|
||||
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
|
||||
@@ -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 = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button>`;
|
||||
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button>`;
|
||||
// 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.
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createText({
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+50
-3
@@ -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 `<img src="${escapeHtml(safeUrl)}" alt="${safeAlt}"${safeTitle} loading="lazy" decoding="async">`;
|
||||
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 <a> — which then truncates this outer <a>
|
||||
// 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 (
|
||||
`<a class="chat-image" href="${displayUrl}" target="_blank" rel="noopener noreferrer" title="Open image in a new tab">` +
|
||||
`<img class="chat-inline-image" src="${displayUrl}" alt="${safeAlt}"${safeTitle} loading="lazy" decoding="async" referrerpolicy="no-referrer">` +
|
||||
`<span class="chat-image-fallback">${fallbackLabel} — ${fallbackUrl}</span>` +
|
||||
`</a>`
|
||||
);
|
||||
}
|
||||
|
||||
// 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 <img> 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);
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -589,6 +589,12 @@ export async function refreshModels(force = false, opts = {}) {
|
||||
+ '<span class="muted-sm">Ask an admin to configure model endpoints</span>';
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+93
-1
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 `<div style="min-width:42px;height:42px;border-radius:6px;border:2px solid var(--border);background:var(--panel);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--red);animation:egg-spin 0.5s ease-out ${i*0.08}s both;cursor:pointer" title="d${sides}" onclick="this.style.animation='none';this.offsetHeight;var r=Math.floor(Math.random()*${sides})+1;this.textContent=r;this.style.animation='egg-shake 0.3s ease'">${v}</div>`;
|
||||
return `<div style="min-width:42px;height:42px;border-radius:6px;border:2px solid var(--border);background:var(--panel);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--red);animation:egg-spin 0.5s ease-out ${i*0.08}s both;cursor:pointer" class="egg-dice" data-dice-sides="${sides}" title="d${sides}">${v}</div>`;
|
||||
}).join('');
|
||||
const totalHtml = count > 1 ? `<div style="font-size:0.8em;opacity:0.5;margin-top:4px">${count}d${sides} = ${total}</div>` : '';
|
||||
_eggRender(`<div style="display:flex;flex-direction:column;align-items:center;gap:4px"><div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:center">${dice}</div>${totalHtml}</div>`);
|
||||
@@ -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(`<div style="display:flex;align-items:center;gap:12px;animation:egg-fade 0.3s ease-out">
|
||||
<div style="width:48px;height:48px;border-radius:4px;border:1px solid var(--border);background:${ctx.esc(c)};cursor:pointer" title="Click to copy" onclick="navigator.clipboard.writeText('${ctx.esc(c)}');this.style.transform='scale(0.9)';setTimeout(()=>this.style.transform='',150)"></div>
|
||||
<div style="width:48px;height:48px;border-radius:4px;border:1px solid var(--border);background:${ctx.esc(c)};cursor:pointer" class="egg-color-copy" data-color="${ctx.esc(c)}" title="Click to copy"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:2px"><code style="font-size:1.1em">${ctx.esc(c)}</code>
|
||||
<span style="font-size:0.75em;opacity:0.4">click swatch to copy</span>
|
||||
</div>
|
||||
|
||||
@@ -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 <pre><code> 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;
|
||||
|
||||
+1
-1
@@ -3185,7 +3185,7 @@ export function openTasks(focusId, opts) {
|
||||
<div class="modal-header">
|
||||
<h4 style="position:relative;top:-2px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3L2 6"/><path d="M22 6l-3-3"/></svg>Tasks</h4>
|
||||
<span style="flex:1"></span>
|
||||
<button class="close-btn" id="tasks-close">✖</button>
|
||||
<button class="close-btn" id="tasks-close" aria-label="Close">✖</button>
|
||||
</div>
|
||||
<div class="memory-tabs tasks-tabs" role="tablist">
|
||||
<button class="memory-tab tasks-tab active" data-tab="tasks" role="tab" aria-selected="true">
|
||||
|
||||
@@ -40,6 +40,7 @@ const LS_KEY = 'odysseus-theme';
|
||||
const CUSTOM_THEMES_KEY = 'odysseus-custom-themes';
|
||||
|
||||
const FONT_MAP = {
|
||||
gohu: "'GohuFont', monospace",
|
||||
mono: "'Fira Code', monospace",
|
||||
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const src = await readFile(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
|
||||
const START = '// === copy-chat header (debug trace block) ===';
|
||||
const END = '// === end copy-chat header ===';
|
||||
const block = src.slice(src.indexOf(START) + START.length, src.indexOf(END));
|
||||
const extract = new Function(block + '\nreturn { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst };');
|
||||
const { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst } = extract();
|
||||
|
||||
test('copy-chat header has exact shape and values', () => {
|
||||
const header = _buildCopyChatHeader({
|
||||
version: '1.0.3',
|
||||
build: '20261006.1',
|
||||
sourceCommit: 'a1b2c3d4e5f6',
|
||||
model: 'gpt-4o',
|
||||
endpointUrl: 'https://api.example.com/v1',
|
||||
toolSurface: 'Ajax Spec (router)',
|
||||
sessionId: 'sess-123',
|
||||
copiedAt: new Date(Date.UTC(2026, 9, 13, 5, 30)),
|
||||
});
|
||||
const expected = [
|
||||
'--- Odysseus info ---',
|
||||
'Odysseus: 1.0.3 | Harness build: 20261006.1 | Commit: a1b2c3d4',
|
||||
'Model: gpt-4o @ https://api.example.com/v1 | Tool surface: Ajax Spec (router)',
|
||||
'Copied: 2026-10-13 14:30 JST | Session: sess-123',
|
||||
'----------------------',
|
||||
].join('\n');
|
||||
assert.equal(header, expected);
|
||||
});
|
||||
|
||||
test('unknown fields are never omitted', () => {
|
||||
const header = _buildCopyChatHeader({});
|
||||
const expected = [
|
||||
'--- Odysseus info ---',
|
||||
'Odysseus: unknown | Harness build: unknown | Commit: unknown',
|
||||
'Model: unknown @ unknown | Tool surface: unknown',
|
||||
'Copied: unknown JST | Session: unknown',
|
||||
'----------------------',
|
||||
].join('\n');
|
||||
assert.equal(header, expected);
|
||||
});
|
||||
|
||||
test('closing separator is exactly 22 dashes', () => {
|
||||
const header = _buildCopyChatHeader({});
|
||||
const lines = header.split('\n');
|
||||
assert.equal(lines.length, 5);
|
||||
assert.equal(lines[lines.length - 1], '----------------------');
|
||||
assert.equal(lines[lines.length - 1].length, 22);
|
||||
});
|
||||
|
||||
test('tool surface labels map modes to human labels', () => {
|
||||
assert.equal(_toolSurfaceLabel('full'), 'Regular tools');
|
||||
assert.equal(_toolSurfaceLabel('compact'), 'Ajax Compact');
|
||||
assert.equal(_toolSurfaceLabel('spec'), 'Ajax Spec (router)');
|
||||
assert.equal(_toolSurfaceLabel('none'), 'Tools off');
|
||||
assert.equal(_toolSurfaceLabel(''), 'Auto');
|
||||
assert.equal(_toolSurfaceLabel(undefined), 'Auto');
|
||||
assert.equal(_toolSurfaceLabel(null), 'Auto');
|
||||
assert.equal(_toolSurfaceLabel('weird'), 'unknown');
|
||||
});
|
||||
|
||||
test('JST timestamp is UTC+9 with HH:MM padding', () => {
|
||||
assert.equal(_formatJst(new Date(Date.UTC(2026, 0, 1, 0, 5))), '2026-01-01 09:05');
|
||||
assert.equal(_formatJst(new Date(Date.UTC(2026, 11, 31, 15, 0))), '2027-01-01 00:00');
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const width of [390, 900]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.setContent('<link rel="stylesheet" href="/static/css/00-tokens.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?mobile-export-regression=1');
|
||||
mod.init('/api');
|
||||
mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' });
|
||||
});
|
||||
await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important');
|
||||
});
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
const menu = page.locator('#doc-export-menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
const bounds = await menu.boundingBox();
|
||||
assert(bounds.x >= 0 && bounds.y >= 0);
|
||||
assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844);
|
||||
assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000));
|
||||
await page.screenshot({ path: `/tmp/document-export-${width}.png` });
|
||||
// A second click closes the menu, then reopening must still allow export.
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
assert.equal(await menu.count(), 0);
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
const pending = page.waitForEvent('download');
|
||||
await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click();
|
||||
const download = await pending;
|
||||
assert(download.suggestedFilename().endsWith('.md'));
|
||||
assert.equal(await download.failure(), null);
|
||||
const stream = await download.createReadStream();
|
||||
let content = '';
|
||||
for await (const chunk of stream) content += chunk;
|
||||
assert.equal(content, '# Mobile export\n\nActual document content.');
|
||||
await page.close();
|
||||
console.log(`PASS ${width}px: visible menu, toggle, download contents`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const source = readFileSync(new URL('../static/js/document.js', import.meta.url), 'utf8');
|
||||
const build = source.slice(source.indexOf(' function _buildAnnotation('), source.indexOf(' // Prompt user for an instruction'));
|
||||
const state = source.slice(source.indexOf(' let _pdfAnnotationDragging'), source.indexOf(' // Per-doc last-used line spacing'));
|
||||
const placement = source.slice(source.indexOf(' const isBlankPageTarget'), source.indexOf(' pane.appendChild(pageWrap);', source.indexOf(' const isBlankPageTarget')));
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const touch of [true, false]) {
|
||||
const page = await browser.newPage({ viewport: { width: 600, height: 800 }, hasTouch: touch });
|
||||
await page.setContent('<div id="page" style="position:relative;width:500px;height:700px;background:white"></div>');
|
||||
await page.evaluate(({ build, state, placement }) => {
|
||||
new Function(`
|
||||
const activeDocId = 'test', docId = activeDocId;
|
||||
const _pdfPaneAnnotationsByDoc = new Map([['test', []]]);
|
||||
const annotationRefs = _pdfPaneAnnotationsByDoc.get('test');
|
||||
const _pdfLastLineHeight = new Map(), _pdfLastFontSize = new Map();
|
||||
let _pdfDropMode = 'check', serial = 0;
|
||||
const _newAnnotationId = () => 'new-' + (++serial);
|
||||
const _pushPdfUndoSnapshot = () => {};
|
||||
const _schedulePdfPaneSave = () => {};
|
||||
const pageWrap = document.getElementById('page');
|
||||
const page = {page: 1};
|
||||
${state}
|
||||
${build}
|
||||
${placement}
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const {ref} = _buildAnnotation(pageWrap, {id:'a'+i, page:1, x:20+i*35, y:30, w:5, h:5, kind:'check'});
|
||||
annotationRefs.push(ref);
|
||||
ref._setHandlesVisible(true);
|
||||
}
|
||||
window.refs = annotationRefs;
|
||||
`)();
|
||||
}, { build, state, placement });
|
||||
const grip = page.locator('[data-ann-id="a0"] [title="Drag to move"]');
|
||||
const box = await grip.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(300, 220);
|
||||
assert(await page.evaluate(() => refs.every(ref => [...ref.wrap.querySelectorAll('[title]')].every(el => el.style.display === 'none'))));
|
||||
// Proximity updates must not bring controls back while dragging.
|
||||
await page.evaluate(() => refs.forEach(ref => ref._setHandlesVisible(true)));
|
||||
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isHidden());
|
||||
await page.mouse.up();
|
||||
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
|
||||
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isVisible());
|
||||
await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').click();
|
||||
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 1);
|
||||
await page.locator('#page').click({ position: { x: 100, y: 450 } });
|
||||
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
|
||||
console.log(`PASS ${touch ? 'touch' : 'mouse'}: drag controls hidden, delete only deletes, blank page still places`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -401,6 +401,51 @@ def test_url_outside_inline_code_is_still_autolinked(node_available):
|
||||
assert 'href="https://example.com/page"' in html
|
||||
|
||||
|
||||
def test_https_markdown_image_renders_inline_as_clickable_thumbnail(node_available):
|
||||
# Assistant `` images must render inline: https-only, lazy,
|
||||
# max-width-100% (via CSS), and click-to-open the full size in a new tab.
|
||||
html = _run_markdown_case(
|
||||
"Here is a map:\n\n"
|
||||
""
|
||||
)
|
||||
|
||||
assert html.count('class="chat-image"') == 1
|
||||
assert html.count('class="chat-inline-image"') == 1
|
||||
assert 'alt="Map of Sweden"' in html
|
||||
assert 'loading="lazy"' in html
|
||||
assert 'decoding="async"' in html
|
||||
assert 'referrerpolicy="no-referrer"' in html
|
||||
assert 'target="_blank"' in html
|
||||
assert 'rel="noopener noreferrer"' in html
|
||||
# The fallback caption (alt + URL) is emitted so a broken load never leaves
|
||||
# an empty gap; it stays hidden until the capture-phase error listener
|
||||
# toggles .image-broken.
|
||||
assert 'class="chat-image-fallback"' in html
|
||||
# The fallback caption carries the alt text plus the URL (the `//` is
|
||||
# entity-encoded so the bare-URL autolink pass can't nest another <a>).
|
||||
assert "Map of Sweden — https://upload.wikimedia.org" in html
|
||||
|
||||
|
||||
def test_http_markdown_image_is_downgraded_to_alt_text(node_available):
|
||||
# Non-https image URLs must not render as an <img> (https-only policy); the
|
||||
# alt text is kept as plain text so nothing is silently dropped.
|
||||
html = _run_markdown_case("Look:  done.")
|
||||
|
||||
assert "<img" not in html
|
||||
assert 'class="chat-image"' not in html
|
||||
assert "a http pic" in html
|
||||
|
||||
|
||||
def test_javascript_scheme_image_url_is_never_rendered(node_available):
|
||||
# A javascript: URL inside image syntax must be rejected outright, never
|
||||
# emitted as a src/href.
|
||||
html = _run_markdown_case(")")
|
||||
|
||||
assert "<img" not in html
|
||||
assert "javascript:" not in html
|
||||
assert 'class="chat-image"' not in html
|
||||
|
||||
|
||||
def test_inline_code_content_is_html_escaped(node_available):
|
||||
# Inline code is now extracted before the global escape pass, so it must be
|
||||
# escaped at extraction time (matching the fenced-code-block handling).
|
||||
|
||||
Reference in New Issue
Block a user