Compare commits

...
Author SHA1 Message Date
pewdiepie-archdaemon da8ba69c74 Keep streamed-chat module identity consistently cache-busted 2026-10-26 16:57:01 +00:00
pewdiepie-archdaemon 2c48e7fdab Verify the Gallery model picker in the UI review that ships it 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 76ab72412d Preserve completion gating and handle explicit idle stream status safely 2026-10-26 16:34:56 +00:00
pewdiepie-archdaemon 04ca6c26c3 Normalize final newlines in integrated review files 2026-10-26 16:31:23 +00:00
pewdiepie-archdaemon fdb2a330d9 Remove duplicate session copy-header helpers after integration 2026-10-26 16:30:56 +00:00
pewdiepie-archdaemon e461a6b5c5 Keep chat module URLs consistent across imports and precache 2026-10-26 16:29:17 +00:00
pewdiepie-archdaemon 22144050fe Update canonical Notes modules alongside their deployment files 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon ace6be44f3 Merge current dev and refresh PR #6740 from the 7011 integration 2026-10-26 16:21:34 +00:00
pewdiepie-archdaemonandClaude Opus 5.5 8fda203d28 feat(ui): chat frontend, model picker, streaming, admin, a11y
Preview/UI work: chat renderer, streaming/think-trace, model picker
(instant-open/refresh-backoff), admin panel, a11y, gallery landing.

Source: vendor/photocraft (ad1cff44) static files, 3-way merged onto
upstream/dev (a8c147b2). Resolution policy in pr_work/D_conflicts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:33 +00:00
42 changed files with 983 additions and 38 deletions
@@ -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);
});
+2 -2
View File
@@ -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">&times;</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">&times;</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">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</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.
+1 -1
View File
@@ -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) : '');
});
}
+24 -1
View File
@@ -155,11 +155,34 @@ body {
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
/* Self-hosted editor text fonts (SIL OFL 1.1). These are bundled so the text
tool renders distinct, expected faces on every device — phones (Android/iOS)
do not ship Arial/Verdana/Georgia/Times/Courier/Impact, which previously
collapsed every font choice onto the same system fallback. */
@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-SemiBold.woff2') format('woff2'); }
@font-face { font-family: 'Roboto'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Playfair Display'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Playfair Display'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Lobster'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Lobster-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Bebas Neue'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/BebasNeue-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto Mono'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Roboto Mono'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Fraunces'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Syne'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Syne'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Bungee'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Bungee-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Instrument Serif'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/InstrumentSerif-Regular.woff2') format('woff2'); }
/* Code block baseline */
pre, code, .hljs {
font-size: 11px;
+33
View File
@@ -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 <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); }
+14
View File
@@ -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; }
+1
View File
@@ -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;
}
+6
View File
@@ -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;
+3 -2
View File
@@ -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>
@@ -2686,7 +2687,7 @@
<script type="module" src="/static/js/gallery.js?v=20260910promptcopy1"></script>
<script type="module" src="/static/js/chatRenderer.js?v=20260914metricssummary1"></script>
<script type="module" src="/static/js/codeRunner.js?v=20260831richtexttools91"></script>
<script type="module" src="/static/js/chatStream.js?v=20260914pdfstrip1"></script>
<script type="module" src="/static/js/chatStream.js?v=20261026devsync1"></script>
<script type="module" src="/static/js/chat.js?v=20260917toolttft1"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script>
+61 -1
View File
@@ -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
View File
@@ -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;
+72 -2
View File
@@ -9,7 +9,7 @@ import Storage from './storage.js';
import uiModule from './ui.js?v=20260916largetoolscroll1';
import sessionModule from './sessions.js';
import chatRenderer, { renderToolIcon } from './chatRenderer.js?v=20260914metricssummary1';
import chatStream from './chatStream.js?v=20260914pdfstrip1';
import chatStream from './chatStream.js?v=20261026devsync1';
import { addAITTSButton } from './tts-ai.js';
import markdownModule from './markdown.js';
import spinnerModule from './spinner.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 = '<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;
@@ -6149,7 +6210,15 @@ import { invalidateSettings } from './appConfig.js';
cache: 'no-store',
});
if (!_getForegroundStreamState() || _backgroundStreams.has(sid)) return;
if (res.status !== 404) return;
// The server now reports "no active stream" as 200 {status:"idle"} (an
// older server used 404); a body with status "streaming" means it is NOT
// stale. Accept both so the watchdog keeps working across deploys.
if (res.status !== 404 && !res.ok) return;
let _probe = null;
try { _probe = await res.json(); } catch (_) { _probe = null; }
const _noActiveStream = res.status === 404 || (res.ok && _probe &&
typeof _probe.status === 'string' && _probe.status !== 'streaming');
if (!_noActiveStream) return;
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
if (active.abortCtrl && !active.abortCtrl.signal.aborted) {
@@ -6511,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;
+9 -1
View File
@@ -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);
}
}
}
+2
View File
@@ -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);
+3 -3
View File
@@ -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()) {
+15
View File
@@ -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');
+2 -1
View File
@@ -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 : '';
+33 -1
View File
@@ -31,10 +31,15 @@ export function enable(containerId, itemSelector, options = {}) {
onReorder: options.onReorder || null,
handleSelector: options.handleSelector || null,
excludeSelector: options.excludeSelector || null,
ignoreSelector: options.ignoreSelector || null,
// When > 0 the pointer must travel this many pixels before the drag
// starts, so rows that also handle plain clicks stay clickable.
threshold: Math.max(0, Number(options.threshold) || 0),
storageKey: options.storageKey || null,
};
let draggedEl = null;
let pending = null;
let placeholder = null;
let offsetY = 0;
let items = [];
@@ -130,6 +135,12 @@ export function enable(containerId, itemSelector, options = {}) {
draggedEl.style.top = snapTop + 'px';
setTimeout(() => {
// The list may have been re-rendered mid-drag (e.g. dropping a layer
// effect copies it and rebuilds the panel), detaching both nodes.
if (!placeholder.parentNode || !draggedEl.isConnected) {
placeholder.remove();
return;
}
placeholder.parentNode.insertBefore(draggedEl, placeholder);
placeholder.remove();
draggedEl.classList.remove('dragging');
@@ -158,10 +169,21 @@ export function enable(containerId, itemSelector, options = {}) {
function onMouseDown(e) {
if (e.button !== 0) return;
if (config.handleSelector && !e.target.closest(config.handleSelector)) return;
if (config.ignoreSelector && e.target.closest(config.ignoreSelector)) return;
const item = e.target.closest(itemSelector);
if (!item || !container.contains(item)) return;
if (config.excludeSelector && item.matches(config.excludeSelector)) return;
if (config.threshold > 0) {
// Defer the lift until the pointer actually moves, so a plain click
// (select layer, open the rename field) is left untouched.
pending = { item, startY: e.clientY };
e.preventDefault();
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
}
e.preventDefault();
e.stopPropagation();
startDrag(e.clientY, item);
@@ -169,11 +191,21 @@ export function enable(containerId, itemSelector, options = {}) {
document.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e) { moveDrag(e.clientY); }
function onMouseMove(e) {
if (pending) {
if (Math.abs(e.clientY - pending.startY) < config.threshold) return;
const { item, startY } = pending;
pending = null;
e.preventDefault();
startDrag(startY, item);
}
moveDrag(e.clientY);
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
pending = null;
endDrag();
}
+35
View File
@@ -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);
});
+2 -2
View File
@@ -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">&times;</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">&times;</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">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</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.
+1 -1
View File
@@ -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) : '');
});
}
+93
View File
@@ -1278,6 +1278,7 @@ function _renderEditorLanding() {
<p>Start a blank canvas, or open a photo from your gallery to edit it.</p>
<div class="gallery-editor-landing-actions">
<button class="gallery-select-btn" id="gallery-editor-new">New canvas...</button>
<button class="gallery-select-btn" id="gallery-editor-import"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><path d="M12 3v12"/><path d="M7 8l5-5 5 5"/><path d="M5 21h14a2 2 0 0 0 2-2v-4"/><path d="M3 15v4a2 2 0 0 0 2 2"/></svg>Import</button>
<button class="gallery-select-btn" id="gallery-editor-pick"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Browse gallery</button>
</div>
<section class="gallery-editor-template-picker" aria-labelledby="gallery-editor-template-title">
@@ -1328,6 +1329,28 @@ function _renderEditorLanding() {
document.getElementById('gallery-editor-pick')?.addEventListener('click', () => {
document.querySelector('#gallery-modal .gallery-tab[data-tab="images"]')?.click();
});
document.getElementById('gallery-editor-import')?.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) return;
const name = (file.name || 'Imported image').replace(/\.[^.]+$/, '') || 'Imported image';
const reader = new FileReader();
reader.onload = async () => {
try {
await openEditor(reader.result, null, null, name);
} catch (e) {
console.error('[gallery] image import failed', e);
uiModule?.showError?.('Could not import that image');
}
};
reader.onerror = () => uiModule?.showError?.('Could not read that file');
reader.readAsDataURL(file);
});
input.click();
});
}
// Wire the first-tile Upload affordance in the Photos grid. Opens the same
@@ -1351,6 +1374,11 @@ function _wireUploadTile() {
});
input.click();
});
tile.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
e.preventDefault();
tile.click();
});
}
// Shimmer placeholder tiles shown while the FIRST page loads, so the grid
@@ -1464,6 +1492,14 @@ function _renderGrid() {
const img = _items.find(i => i.id === card.dataset.id);
if (img) _openDetail(img);
});
card.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
e.preventDefault();
const selectBtn = document.getElementById('gallery-select-btn');
if (selectBtn && selectBtn.classList.contains('active')) return;
const img = _items.find(i => i.id === card.dataset.id);
if (img) _openDetail(img);
});
});
// Download buttons
@@ -2251,6 +2287,7 @@ export function openGallery() {
<span class="gallery-tab-label">Edit</span>
<span class="gallery-tab-close" id="gallery-editor-tab-close" title="Close edit" aria-label="Close edit">×</span>
</button>
<span class="gallery-editor-tabs-extra" id="gallery-editor-tabs-extra"></span>
<button class="gallery-tab" data-tab="settings">
<span class="gallery-tab-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="gallery-tab-label">Settings</span>
@@ -2318,6 +2355,13 @@ export function openGallery() {
</button>
</div>
</div>
<div class="admin-card">
<h2>Image model</h2>
<p class="memory-desc doclib-desc">Model used for AI edits and generated content in the editor (Quick Edit). Shared with the Quick Edit model picker.</p>
<select id="gallery-image-model" style="margin-top:8px;width:100%;">
<option value="">Loading…</option>
</select>
</div>
</div>
</div>
</div>
@@ -2358,6 +2402,9 @@ export function openGallery() {
if (editorTabClose) {
editorTabClose.addEventListener('click', async (e) => {
e.stopPropagation();
// With several document tabs open the × closes only the active one and
// switches to its neighbour; the editor itself stays open.
if (window.geEditorCloseActiveDocument?.()) return;
if (isEditorOpen()) {
const ok = await uiModule.styledConfirm(
'Close the edit? Any unsaved changes will be lost.',
@@ -2558,6 +2605,52 @@ export function openGallery() {
});
}
// ── Image model (Settings tab) ──
// Mirror of the Quick Edit picker: same source (GET /api/editor/image-models)
// and the SAME localStorage key `ge-ai-command-model` (value `<base_url>::<id>`)
// so the two stay in sync.
const _GE_MODEL_KEY = 'ge-ai-command-model';
const imageModelSelect = document.getElementById('gallery-image-model');
if (imageModelSelect) {
const _fillImageModels = async () => {
try {
const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' });
const data = r.ok ? await r.json() : null;
const models = (data && Array.isArray(data.models)) ? data.models : [];
const saved = localStorage.getItem(_GE_MODEL_KEY) || '';
imageModelSelect.innerHTML = '';
const def = document.createElement('option');
def.value = '';
def.textContent = 'Default (configured provider)';
imageModelSelect.appendChild(def);
for (const m of models) {
const opt = document.createElement('option');
opt.value = `${m.base_url || ''}::${m.id}`;
opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim();
imageModelSelect.appendChild(opt);
}
imageModelSelect.value = saved;
if (imageModelSelect.value !== saved) imageModelSelect.value = '';
} catch (e) {
imageModelSelect.innerHTML = '<option value="">Default (configured provider)</option>';
}
};
imageModelSelect.addEventListener('change', () => {
const val = imageModelSelect.value || '';
try {
if (val) localStorage.setItem(_GE_MODEL_KEY, val);
else localStorage.removeItem(_GE_MODEL_KEY);
} catch (e) {}
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
});
_fillImageModels();
}
window.addEventListener('storage', (e) => {
if (e && e.key === _GE_MODEL_KEY && imageModelSelect) {
imageModelSelect.value = e.newValue || '';
}
});
// ── Tag All Untagged ──
let _tagCancelRequested = false;
let _tagging = false;
+50 -3
View File
@@ -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 `<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('://', ':&#47;&#47;');
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);
+4 -1
View File
@@ -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(() => {});
+6
View File
@@ -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);
+4 -1
View File
@@ -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
View File
@@ -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) {
+3
View File
@@ -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');
+25 -2
View File
@@ -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>
+21
View File
@@ -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
View File
@@ -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">
+1
View File
@@ -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",
+51 -2
View File
@@ -31,9 +31,43 @@ function _targetEl(target) {
return target && target.nodeType === 1 ? target : target?.parentElement || null;
}
function _editorPanelVisible() {
if (!document.body || !document.body.classList.contains('ge-editor-open')) return false;
const sel = '.ge-adj-popup, .ge-fx-popup, .ge-fx-menu, .ge-transform-popup, .ge-filter-overlay, .ge-history-panel';
return [...document.querySelectorAll(sel)].some((candidate) => {
if (!document.contains(candidate)) return false;
const style = getComputedStyle(candidate);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = candidate.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
}
function _positionToastForOpenPanel(el) {
if (!el) return;
// The Free Transform hint is pinned by CSS to one fixed spot under the
// canvas; letting the panel rules move it made it hop between the top-right
// corner and the bottom banner depending on which panel happened to be open.
if (el.classList.contains('toast-transform-hint')) {
el.classList.remove('toast-editor-bottom', 'toast-panel-offset');
el.style.left = '';
el.style.right = '';
el.style.maxWidth = '';
return;
}
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
el.classList.remove('toast-editor-bottom');
// In the editor, a floating panel (Adjust/Develop, FX, Transform, filter
// modal, History) is anchored to the top/right; drop the toast to the
// bottom-centre so it can never cover a control (EVAL: "never over an
// active panel"). On phones the CSS already anchors it at the bottom.
if (_editorPanelVisible()) {
el.style.left = '';
el.style.right = '';
el.style.maxWidth = '';
el.classList.add('toast-editor-bottom');
return;
}
const panel = [...document.querySelectorAll('.notes-pane')]
.filter((candidate) => {
if (!document.contains(candidate)) return false;
@@ -174,7 +208,9 @@ function _closeHoveredWindow() {
function _spaceIsBlocked(e, surface) {
const target = _targetEl(e.target);
if (!target) return false;
if (_isTextEditingTarget(target)) return !surface || surface.contains(target);
// Space must always type in a field, even when that field lives in a fixed
// overlay outside the hovered surface (e.g. the editor's on-canvas text box).
if (_isTextEditingTarget(target)) return true;
const blocked = target.closest?.(SPACE_BLOCKED_SELECTOR);
return !!(blocked && (!surface || surface.contains(blocked)));
}
@@ -221,6 +257,10 @@ function _initHoverCardSpaceToggle() {
}, true);
document.addEventListener('keydown', (e) => {
if (e.code !== 'Space' || e.repeat) return;
// The image editor owns Space (hold to pan, otherwise text fields type a
// space). A card/window toggle must never fire while it is open, or the
// hovered gallery card can click itself closed underneath the editor.
if (document.body?.classList.contains('ge-editor-open')) return;
if (hoveredToggleCard && _isSpaceVisible(hoveredToggleCard)) {
if (_spaceIsBlocked(e, hoveredToggleCard)) return;
e.preventDefault();
@@ -342,7 +382,7 @@ export function showToast(msg, durationOrOpts) {
_wireToastSwipe(toastEl);
toastEl.textContent = '';
toastEl.classList.remove('error');
toastEl.classList.remove('toast-ai-reply-progress', 'toast-ai-reply-result', 'toast-sending', 'toast-message-sent');
toastEl.classList.remove('toast-ai-reply-progress', 'toast-ai-reply-result', 'toast-sending', 'toast-message-sent', 'toast-transform-hint');
let duration = 1200, actionLabel = null, onAction = null, actionHint = null, actionIcon = null, leadingIcon = null, toastClass = null, aiReplyProgress = false, aiReplyResult = false;
if (typeof durationOrOpts === 'object' && durationOrOpts) {
@@ -358,6 +398,14 @@ export function showToast(msg, durationOrOpts) {
} else if (typeof durationOrOpts === 'number') {
duration = durationOrOpts;
}
// While the editor is open, cap every toast so it auto-dismisses within
// ~2.5s and can never linger over the canvas or a panel (EVAL: "auto-dismiss
// in 2.5s"). Longer informational durations (persist warnings etc.) are
// still readable but do not pile up.
if (typeof document !== 'undefined' && document.body
&& document.body.classList.contains('ge-editor-open')) {
duration = Math.min(duration, 2500);
}
if (aiReplyProgress) toastEl.classList.add('toast-ai-reply-progress');
if (aiReplyResult) toastEl.classList.add('toast-ai-reply-result');
if (toastClass) toastEl.classList.add(toastClass);
@@ -473,6 +521,7 @@ export function showError(msg) {
_wireToastSwipe(toastEl);
toastEl.textContent = '';
toastEl.classList.add('error');
toastEl.classList.remove('toast-transform-hint');
toastEl.style.pointerEvents = '';
toastEl.style.transform = '';
toastEl.classList.remove('exiting');
+2 -2
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v659-notes-reference';
const CACHE_NAME = 'odysseus-v1113-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -84,7 +84,7 @@ const PRECACHE = [
'/static/js/gallery.js?v=20260910promptcopy1',
'/static/js/chatRenderer.js?v=20260914metricssummary1',
'/static/js/codeRunner.js?v=20260831richtexttools91',
'/static/js/chatStream.js?v=20260914pdfstrip1',
'/static/js/chatStream.js?v=20261026devsync1',
'/static/js/chat.js?v=20260917toolttft1',
'/static/js/cookbook.js',
'/static/js/search-chat.js',
+67
View File
@@ -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');
});
+45
View File
@@ -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();
}
+58
View File
@@ -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();
}
+39
View File
@@ -0,0 +1,39 @@
"""Source checks for the gallery Settings-tab image-model picker.
The Quick Edit model picker lives in the editor; HARNESS mirrors it into the
gallery Settings tab, fed by GET /api/editor/image-models and sharing the
same localStorage key (`ge-ai-command-model`) so the two stay in sync.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
def test_settings_tab_has_image_model_picker():
assert 'id="gallery-image-model"' in GALLERY_JS
assert "/api/editor/image-models" in GALLERY_JS
assert "ge-ai-command-model" in GALLERY_JS
def test_image_model_picker_lives_in_the_settings_container():
settings_idx = GALLERY_JS.index('id="gallery-settings-container"')
picker_idx = GALLERY_JS.index('id="gallery-image-model"')
assert picker_idx > settings_idx
def test_picker_persists_with_the_shared_localstorage_key():
assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS
assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS
def test_cloud_image_models_are_listed_alongside_the_local_ones():
"""Gateway endpoints (OpenRouter) register as llm but carry image models.
The picker must surface those ids too — otherwise the only selectable
edit model is the local Qwen server.
"""
routes = (ROOT / "routes/editor_agent_routes.py").read_text(encoding="utf-8")
assert "from src.image_model_ids import looks_like_image_generation_model" in routes
assert 'image_endpoint = getattr(ep, "model_type", None) == "image"' in routes
assert "if not image_endpoint and not looks_like_image_generation_model(name):" in routes
@@ -0,0 +1,25 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY = ROOT / "static/js/gallery.js"
def test_editor_landing_has_an_import_button_next_to_new_canvas():
"""The editor landing must offer a direct "Import" action.
It sits in the same action row as "New canvas..." / "Browse gallery" and,
on file pick, opens the editor on the chosen raster image (``openEditor``
takes an image data URL as its first argument).
"""
src = GALLERY.read_text(encoding="utf-8")
assert 'id="gallery-editor-import"' in src, "landing Import button missing"
# Button order: New canvas → Import → Browse gallery.
new_at = src.index('id="gallery-editor-new"')
import_at = src.index('id="gallery-editor-import"')
pick_at = src.index('id="gallery-editor-pick"')
assert new_at < import_at < pick_at, "Import must sit next to New canvas"
# Wiring opens the editor on the selected file's data URL.
assert "getElementById('gallery-editor-import')?.addEventListener('click'" in src
assert "readAsDataURL(file)" in src
assert "await openEditor(reader.result, null, null, name)" in src
+45
View File
@@ -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 `![alt](url)` 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"
"![Map of Sweden](https://upload.wikimedia.org/wikipedia/commons/thumb/3/32/Sweden_location_map.svg/600px-Sweden_location_map.svg.png)"
)
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:&#47;&#47;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: ![a http pic](http://example.com/pic.png) 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("![x](javascript:alert(1))")
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).
+22
View File
@@ -0,0 +1,22 @@
"""Space in the image editor must type/pan, never toggle a hovered card."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UI = (ROOT / "static/js/ui.js").read_text(encoding="utf-8")
def test_space_toggles_are_disabled_while_the_editor_is_open():
start = UI.index("document.addEventListener('keydown', (e) => {\n if (e.code !== 'Space'")
block = UI[start:start + 900]
guard = block.index("if (document.body?.classList.contains('ge-editor-open')) return;")
card = block.index("if (hoveredToggleCard && _isSpaceVisible(hoveredToggleCard)) {")
assert guard < card, "the editor guard must run before any card toggle"
def test_space_always_types_in_a_text_field_even_outside_the_hovered_surface():
start = UI.index("function _spaceIsBlocked(e, surface) {")
end = UI.index("function _activateSpaceCard(card) {")
block = UI[start:end]
assert "if (_isTextEditingTarget(target)) return true;" in block
assert "_isTextEditingTarget(target)) return !surface" not in block