mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-12 09:52:19 +02:00
Consolidate Odysseus agent harness and tool contracts
This commit is contained in:
+26
-14
@@ -3,16 +3,16 @@
|
||||
// ES6 module — entry point, no exports (wires all modules together)
|
||||
// ============================================
|
||||
import Storage from './js/storage.js';
|
||||
import uiModule from './js/ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './js/ui.js?v=20260916largetoolscroll1';
|
||||
import workspaceModule from './js/workspace.js';
|
||||
import fileHandlerModule from './js/fileHandler.js?v=20260909mobileattachmentedit1';
|
||||
import modelsModule from './js/models.js';
|
||||
import ragModule from './js/rag.js';
|
||||
import presetsModule from './js/presets.js?v=20260908personaname1';
|
||||
import searchModule from './js/search.js';
|
||||
import chatModule from './js/chat.js?v=20260910shelltoggle1';
|
||||
import chatModule from './js/chat.js?v=20260916largetoolscroll2';
|
||||
import compareModule from './js/compare/index.js?v=20260909mobilepaneaddscroll1';
|
||||
import documentModule from './js/document.js?v=20260910minimizedcontext1';
|
||||
import documentModule from './js/document.js?v=20260916docctx2';
|
||||
import searchChatModule from './js/search-chat.js';
|
||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
settleSessionHydration
|
||||
} from './js/startupShell.js';
|
||||
import markdownModule from './js/markdown.js';
|
||||
import chatRenderer from './js/chatRenderer.js?v=20260910streamlinks2';
|
||||
import chatRenderer from './js/chatRenderer.js?v=20260914pdfstrip1';
|
||||
// Keep this specifier identical to every consumer (especially chat.js).
|
||||
// Different query strings create separate ES-module instances with separate
|
||||
// current-session state, so the picker can display one model while chat sends
|
||||
@@ -34,18 +34,18 @@ import voiceRecorderModule from './js/voiceRecorder.js';
|
||||
import censorModule from './js/censor.js';
|
||||
import galleryModule from './js/gallery.js?v=20260910promptcopy1';
|
||||
import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=20260829chatstyle12';
|
||||
import tasksModule from './js/tasks.js?v=20260910tasksortpicker6';
|
||||
import calendarModule from './js/calendar.js?v=20260903weekscrollstable1';
|
||||
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
|
||||
import calendarModule from './js/calendar.js?v=20260914emailsource11';
|
||||
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
|
||||
import adminModule from './js/admin.js?v=20260908notificationcopy1';
|
||||
import settingsModule from './js/settings.js?v=20260909defaultmodelfix1';
|
||||
import adminModule from './js/admin.js?v=20260914toolschemaprofiles1';
|
||||
import settingsModule from './js/settings.js?v=20260912writingstyle3';
|
||||
// Eagerly bind unified minimize/restore behavior across all tool modals.
|
||||
import './js/modalManager.js';
|
||||
import './js/chipScroll.js?v=20260903calendarchips1';
|
||||
import './js/mobileBulkSelect.js?v=20260910selecthold1';
|
||||
// Desktop window tiling — drag a modal near an edge/corner to snap.
|
||||
import './js/tileManager.js?v=20260910responsivebounds1';
|
||||
import themeModule from './js/theme.js?v=20260909effectspeed1';
|
||||
import themeModule from './js/theme.js?v=20260911organsrain1';
|
||||
// IMPORTANT: import cookbook.js with NO ?v= query — the same plain specifier
|
||||
// every other importer (cookbook-hwfit.js / cookbook-diagnosis.js) uses. A query
|
||||
// mismatch makes the browser load cookbook.js twice as separate modules (two
|
||||
@@ -53,7 +53,7 @@ import themeModule from './js/theme.js?v=20260909effectspeed1';
|
||||
// unversioned so this can't recur.
|
||||
import cookbookModule from './js/cookbook.js';
|
||||
import groupModule from './js/group.js';
|
||||
import * as researchPanelModule from './js/research/panel.js?v=20260910researchdeeplink1';
|
||||
import * as researchPanelModule from './js/research/panel.js?v=20260913researchrailerrors1';
|
||||
import ttsModule from './js/tts-ai.js';
|
||||
import spinnerModule from './js/spinner.js';
|
||||
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js?v=20260829chatstyle12';
|
||||
@@ -298,6 +298,9 @@ function initializeEventListeners() {
|
||||
|
||||
// Paste handler
|
||||
window.addEventListener('paste', async (e)=>{
|
||||
// Document editors own image paste. The global chat attachment listener
|
||||
// must not stage the same clipboard file a second time.
|
||||
if (e.defaultPrevented || e.target?.closest?.('#doc-editor-pane, [contenteditable="true"]')) return;
|
||||
if (!e.clipboardData) return;
|
||||
let changed = false;
|
||||
for (const item of e.clipboardData.items){
|
||||
@@ -337,6 +340,12 @@ function initializeEventListeners() {
|
||||
// Scrolling
|
||||
el('chat-history').addEventListener('scroll', uiModule.debounce(() => {
|
||||
const box = el('chat-history');
|
||||
// scrollHistory() advances in several animation frames. Its early frames
|
||||
// are intentionally not at the bottom yet, so treating those events as a
|
||||
// user scroll cancels the animation before a synthesis below a large tool
|
||||
// trace can become visible. Wheel/touch handlers still disable follow
|
||||
// mode immediately for real user input.
|
||||
if (uiModule.isAutoScrolling?.()) return;
|
||||
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80;
|
||||
uiModule.setAutoScroll(atBottom);
|
||||
}, 100));
|
||||
@@ -412,10 +421,11 @@ function initializeEventListeners() {
|
||||
const deleteItem = exportMenu.querySelector('#export-delete-btn');
|
||||
if (deleteItem) exportMenu.insertBefore(settingsItem, deleteItem);
|
||||
else exportMenu.appendChild(settingsItem);
|
||||
settingsItem.addEventListener('click', (e) => {
|
||||
settingsItem.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.classList.remove('open');
|
||||
if (window.chatModule?.openContextSettings && window.chatModule.openContextSettings()) return;
|
||||
if (window.chatModule?.openContextSettings
|
||||
&& await window.chatModule.openContextSettings()) return;
|
||||
if (typeof settingsModule !== 'undefined' && settingsModule?.open) settingsModule.open();
|
||||
else if (typeof adminModule !== 'undefined' && adminModule?.open) adminModule.open();
|
||||
else if (window.settingsModule?.open) window.settingsModule.open();
|
||||
@@ -3382,8 +3392,8 @@ function initializeEventListeners() {
|
||||
uiModule?.styledConfirm
|
||||
? await uiModule.styledConfirm('Bring open document to new chat?', {
|
||||
title: 'New chat',
|
||||
confirmText: 'OK',
|
||||
cancelText: 'No',
|
||||
confirmText: 'Bring →',
|
||||
cancelText: 'Drop',
|
||||
})
|
||||
: window.confirm('Bring open document to new chat?')
|
||||
);
|
||||
@@ -4257,12 +4267,14 @@ function startOdysseusApp() {
|
||||
}
|
||||
|
||||
chatContainer.addEventListener('dragover', (e) => {
|
||||
if (e.target?.closest?.('#doc-editor-pane')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_showDropHighlight();
|
||||
});
|
||||
|
||||
chatContainer.addEventListener('drop', async (e) => {
|
||||
if (e.target?.closest?.('#doc-editor-pane')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_hideDropHighlight();
|
||||
|
||||
+25
-22
@@ -246,10 +246,10 @@
|
||||
real request is discarded and the font fetched a second time. -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260911docselectionclearclose1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260910shelltoggle3">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260910shelltoggle1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260908weekhoverfix1">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260914taskbutton1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260916autoscroll1">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||
<link rel="modulepreload" href="/static/js/markdown.js">
|
||||
</head>
|
||||
@@ -691,7 +691,7 @@
|
||||
</div>
|
||||
<div class="theme-fd-group" id="theme-bg-speed-group" style="flex:1 1 0;">
|
||||
<label class="theme-fd-label">Speed</label>
|
||||
<input type="range" id="theme-bg-speed" class="theme-fd-range" min="25" max="250" step="5" value="100">
|
||||
<input type="range" id="theme-bg-speed" class="theme-fd-range" min="5" max="250" step="5" value="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1683,15 +1683,18 @@
|
||||
working for anyone who wired it via `manage_settings` /
|
||||
settings backup. Re-add this card to surface the toggle
|
||||
again once the core experience is faster. -->
|
||||
<div class="admin-card" style="display:none">
|
||||
<div class="admin-card">
|
||||
<h2><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:5px;opacity:0.6"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Writing Style</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Used when AI drafts email replies. Keep this email-specific: greetings, sign-off, tone, and length.</div>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">General voice for normal documents. Email greetings and sign-offs remain under Email → Writing Style.</div>
|
||||
<div class="settings-col">
|
||||
<textarea id="set-email-style" rows="6" class="settings-select" style="font-family:inherit;resize:none" placeholder="e.g. I write emails in this style. I don't use exclamation marks. I sign emails with: ..."></textarea>
|
||||
<div class="settings-row" style="margin-top:4px">
|
||||
<span id="set-email-style-msg" style="font-size:11px;"></span>
|
||||
<button class="admin-btn-add" id="set-email-style-extract" style="margin-left:auto;display:inline-flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Extract from Sent (15 emails)</button>
|
||||
<button class="admin-btn-add" id="set-email-style-save">Save</button>
|
||||
<textarea id="set-document-style" rows="6" class="settings-select" style="font-family:inherit;resize:none" placeholder="e.g. Concise and conversational. Prefer short paragraphs, plain language, and concrete examples."></textarea>
|
||||
<div class="settings-row" style="margin-top:4px;align-items:center">
|
||||
<span id="set-document-style-msg" style="font-size:11px;min-height:18px;display:inline-flex;align-items:center;"></span>
|
||||
<input id="set-document-style-file" type="file" hidden accept=".txt,.md,.markdown,.pdf,.doc,.docx,.odt,.rtf,.html,.htm,.csv,.tsv,.json,.yaml,.yml">
|
||||
<span style="margin-left:auto;display:inline-flex;align-items:center;gap:6px">
|
||||
<button class="admin-btn-add" id="set-document-style-extract">Extract from file</button>
|
||||
<button class="admin-btn-add" id="set-document-style-save" style="display:inline-flex;align-items:center;gap:5px"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>Save</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2591,7 +2594,7 @@
|
||||
|
||||
<!-- Load modules in this order -->
|
||||
<script type="module" src="/static/js/storage.js"></script>
|
||||
<script type="module" src="/static/js/ui.js?v=20260908weekhoverfix1"></script>
|
||||
<script type="module" src="/static/js/ui.js?v=20260916largetoolscroll1"></script>
|
||||
<script type="module" src="/static/js/markdown.js"></script>
|
||||
<script type="module" src="/static/js/dragSort.js"></script>
|
||||
<script type="module" src="/static/js/sessions.js"></script>
|
||||
@@ -2607,22 +2610,22 @@
|
||||
<script type="module" src="/static/js/search.js"></script>
|
||||
<script type="module" src="/static/js/spinner.js"></script>
|
||||
<script type="module" src="/static/js/tts-ai.js"></script>
|
||||
<script type="module" src="/static/js/document.js?v=20260911removealignrightshortcut1"></script>
|
||||
<script type="module" src="/static/js/document.js?v=20260916docctx2"></script>
|
||||
<script type="module" src="/static/js/gallery.js?v=20260910promptcopy1"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260910streamlinks2"></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=20260909cardlayout1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260910shelltoggle1"></script>
|
||||
<script type="module" src="/static/js/chatStream.js?v=20260914aireply3"></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>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
<script type="module" src="/static/js/theme.js?v=20260909effectspeed1"></script>
|
||||
<script type="module" src="/static/js/theme.js?v=20260911organsrain1"></script>
|
||||
<script type="module" src="/static/js/censor.js"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260909defaultmodelfix1"></script>
|
||||
<script type="module" src="/static/js/assistant.js"></script>
|
||||
<script type="module" src="/static/app.js?v=20260910shelltoggle3"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
|
||||
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
|
||||
<script type="module" src="/static/app.js?v=20260916autoscroll1"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260909turncontract3').catch(()=>{});}</script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+36
-9
@@ -1,7 +1,7 @@
|
||||
// static/js/admin.js — Admin panel module (ES6)
|
||||
// Admin-only: users, endpoints, MCP, RAG, embeddings, tokens, webhooks, features
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import settingsModule from './settings.js?v=20260909defaultmodelfix1';
|
||||
import { providerLogo, providerLogoFromUrl } from './providers.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
@@ -21,6 +21,31 @@ const _selectedEndpointIds = new Set();
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { return uiModule.esc(s); }
|
||||
|
||||
// Clipboard API is unavailable on the HTTP LAN URL; keep copy actions usable
|
||||
// there with the browser's synchronous fallback.
|
||||
async function copyNotificationText(value) {
|
||||
const text = String(value ?? '');
|
||||
try {
|
||||
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try { textarea.setSelectionRange(0, text.length); } catch (_) {}
|
||||
let copied = false;
|
||||
try { copied = document.execCommand('copy'); } catch (_) {}
|
||||
textarea.remove();
|
||||
return copied;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
USERS TAB
|
||||
═══════════════════════════════════════════ */
|
||||
@@ -848,18 +873,19 @@ async function loadEndpoints() {
|
||||
</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())
|
||||
? String(m.tool_mode).toLowerCase()
|
||||
: 'full';
|
||||
: '';
|
||||
return `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row" style="display:flex;align-items:center;gap:8px;">
|
||||
<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;">
|
||||
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${(usesPinnedPicker ? m.is_pinned : !m.is_hidden) ? 'checked' : ''}>
|
||||
<span class="adm-check-dot" aria-hidden="true"></span>
|
||||
<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(m.display)}</span>
|
||||
</label>
|
||||
<span title="Controls how much native tool/function schema this model receives" style="font-size:10px;opacity:0.45;flex-shrink:0;">Tools</span>
|
||||
<select class="adm-model-tool-mode" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Native tools sent to this model: no tools, compact schemas for smaller models, or full schemas" style="height:24px;font-size:11px;max-width:112px;flex-shrink:0;">
|
||||
<option value="none" ${mode === 'none' ? 'selected' : ''}>No tools</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Compact tools</option>
|
||||
<option value="full" ${mode === 'full' ? 'selected' : ''}>Full tools</option>
|
||||
<span title="Select the tool schema profile for this model" style="font-size:10px;opacity:0.45;flex-shrink:0;">Tools</span>
|
||||
<select class="adm-model-tool-mode" 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" style="height:24px;font-size:11px;max-width:170px;flex-shrink:0;">
|
||||
<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="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
@@ -3314,9 +3340,10 @@ function renderNotificationLogs() {
|
||||
copyBtn.setAttribute('aria-label', 'Copy notification');
|
||||
const copyIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
copyBtn.innerHTML = copyIcon;
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
copyBtn.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(note.body));
|
||||
if (!await copyNotificationText(note.body)) throw new Error('copy failed');
|
||||
copyBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
copyBtn.classList.add('copied');
|
||||
setTimeout(() => { copyBtn.innerHTML = copyIcon; copyBtn.classList.remove('copied'); }, 1400);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// singleton via /api/assistant/session and hands it to selectSession() so we
|
||||
// reuse the full existing chat render path.
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { selectSession } from './sessions.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
|
||||
@@ -420,6 +420,10 @@ export async function openAssistantSettings() {
|
||||
// ── Chat-header affordances when the assistant session is active ───────────
|
||||
|
||||
async function _ensureHeaderAffordances(sessionId) {
|
||||
return;
|
||||
/* Legacy header-gear implementation removed; assistant controls now live
|
||||
in the Tasks modal. */
|
||||
/*
|
||||
try {
|
||||
const settings = await _getSettings();
|
||||
if (settings?.crew?.session_id !== sessionId) return;
|
||||
@@ -437,6 +441,7 @@ async function _ensureHeaderAffordances(sessionId) {
|
||||
gear.innerHTML = '<svg width="15" height="15" 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 0 1 0 2.83 2 2 0 0 1-2.83 0l-.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-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
|
||||
gear.addEventListener('click', openAssistantSettings);
|
||||
headerRight.appendChild(gear);
|
||||
*/
|
||||
}
|
||||
|
||||
// Run a short polling check after session loads so we can add the gear button
|
||||
@@ -458,7 +463,7 @@ function _watchForAssistantActivation() {
|
||||
// ── Boot ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function _boot() {
|
||||
_watchForAssistantActivation();
|
||||
document.getElementById('assistant-header-gear')?.remove();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -44,6 +44,18 @@ export function renderResearchCards(box, jobs) {
|
||||
region.setAttribute('aria-label', 'Chat research');
|
||||
box.append(region);
|
||||
}
|
||||
region.classList.toggle('streaming', visible.some(job => researchCardState(job).tone === 'running'));
|
||||
|
||||
// Keep the live rail immediately after the AI's synthesized response that
|
||||
// contains the matching research link. Appending it to the chat history
|
||||
// root can place it above the user's next prompt as the conversation grows.
|
||||
const origin = [...visible].reverse().map(job => {
|
||||
const href = `#research-${job.id}`;
|
||||
return [...box.querySelectorAll('.msg-ai')]
|
||||
.find(message => [...message.querySelectorAll('a[href]')].some(candidate => candidate.getAttribute('href') === href));
|
||||
}).find(Boolean);
|
||||
if (origin) origin.insertAdjacentElement('afterend', region);
|
||||
|
||||
const keep = new Set(visible.map(job => job.id));
|
||||
for (const card of Array.from(region.children)) if (!keep.has(card.dataset.jobId)) {
|
||||
stopResearchSpinner(card);
|
||||
@@ -55,7 +67,7 @@ export function renderResearchCards(box, jobs) {
|
||||
card = document.createElement('article');
|
||||
card.dataset.jobId = job.id;
|
||||
// Only constant markup; model-authored topics are assigned as text below.
|
||||
card.innerHTML = '<div class="agent-thread-dot" aria-hidden="true"></div><button type="button" class="agent-thread-header" aria-expanded="false"><span class="agent-thread-icon" aria-hidden="true"></span><span class="agent-thread-tool">Research</span><span class="agent-thread-status" data-stage role="status"></span><span class="chat-research-background"><span>BG task</span><span data-research-spinner aria-hidden="true"></span></span><span class="agent-thread-chevron" aria-hidden="true"></span></button><div class="agent-thread-content"><div class="research-job-query"></div><div class="chat-research-detail"></div><a class="chat-research-open">Open research</a></div>';
|
||||
card.innerHTML = '<div class="agent-thread-dot" aria-hidden="true"></div><button type="button" class="agent-thread-header" aria-expanded="false"><span class="agent-thread-icon" aria-hidden="true"></span><span class="agent-thread-tool">Research</span><span class="agent-thread-status chat-research-background">BG task <span data-research-spinner aria-hidden="true"></span></span><span class="agent-thread-status" data-stage role="status"></span><span class="agent-thread-chevron" aria-hidden="true"></span></button><div class="agent-thread-content"><div class="research-job-query"></div><div class="chat-research-detail"></div><a class="chat-research-open">Open research</a></div>';
|
||||
const header = card.querySelector('.agent-thread-header');
|
||||
const content = card.querySelector('.agent-thread-content');
|
||||
content.id = `chat-research-details-${job.id}`;
|
||||
|
||||
+44
-18
@@ -2,7 +2,7 @@
|
||||
* Calendar Module — CalDAV-backed month/week/year calendar.
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
@@ -494,7 +494,14 @@ function _eventReminderHtml(ev) {
|
||||
}
|
||||
|
||||
function _eventSourceHtml(ev) {
|
||||
if (!ev || _calendars.length <= 1) return '';
|
||||
if (!ev) return '';
|
||||
// Email provenance is useful even with only one calendar (or no loaded
|
||||
// calendar name). The calendar-initial badge alone is multi-calendar UI.
|
||||
if (ev.source_email_uid && ev.source_email_folder) {
|
||||
const href = `#email=${encodeURIComponent(ev.source_email_folder)}:${encodeURIComponent(ev.source_email_uid)}`;
|
||||
return `<a class="cal-event-source cal-event-source-email" href="${_e(href)}" title="Open source email" aria-label="Open source email" onclick="event.stopPropagation();"><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"><rect x="3" y="5" width="18" height="14" rx="2"></rect><polyline points="3 7 12 13 21 7"></polyline></svg></a>`;
|
||||
}
|
||||
if (_calendars.length <= 1) return '';
|
||||
const cal = _calendars.find(c => c.href === ev.calendar_href);
|
||||
const name = ev.calendar || cal?.name || '';
|
||||
if (!name) return '';
|
||||
@@ -890,7 +897,7 @@ function _updateDaySearchResults() {
|
||||
// Re-wire click handlers on the newly-inserted event rows.
|
||||
dayDetail.querySelectorAll('.cal-event-item').forEach(it => {
|
||||
it.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.cal-event-more')) return;
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
|
||||
const ev = _events.find(x => x.uid === it.dataset.uid);
|
||||
if (ev) _showEventForm(ev);
|
||||
});
|
||||
@@ -2207,7 +2214,7 @@ async function _renderAgenda() {
|
||||
<div class="cal-event-dot" style="background:${_calColor(ev)}"></div>
|
||||
<div class="cal-event-info">
|
||||
<div class="cal-event-name">${_impMark}${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)} ${_typeTag}</div>
|
||||
<div class="cal-event-time">${t}${ev.location ? ' · ' + _locHTML(ev.location) : ''}</div>
|
||||
<div class="cal-event-time">${t}${ev.location ? ' · ' + _locHTML(ev.location, ev.description) : ''}</div>
|
||||
</div>
|
||||
<button class="cal-event-more" data-uid="${_e(ev.uid)}" title="More">${_moreIcon}</button>
|
||||
</div>`;
|
||||
@@ -2223,7 +2230,7 @@ async function _renderAgenda() {
|
||||
_wireAll(body);
|
||||
_wireQuickDelete(body);
|
||||
body.querySelectorAll('.cal-agenda-event').forEach(el => el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.cal-event-more')) return;
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
|
||||
const ev = _events.find(e => e.uid === el.dataset.uid);
|
||||
if (ev) _showEventForm(ev);
|
||||
}));
|
||||
@@ -2273,8 +2280,8 @@ async function _renderSearch() {
|
||||
h += `<div class="cal-agenda-event" data-uid="${_e(ev.uid)}">
|
||||
<div class="cal-event-dot" style="background:${_calColor(ev)}"></div>
|
||||
<div class="cal-event-info">
|
||||
<div class="cal-event-name">${_e(ev.summary)}${_eventReminderHtml(ev)}</div>
|
||||
<div class="cal-event-time">${_fmtDate(evDate)} · ${t}${ev.location ? ' · ' + _locHTML(ev.location) : ''}</div>
|
||||
<div class="cal-event-name">${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)}</div>
|
||||
<div class="cal-event-time">${_fmtDate(evDate)} · ${t}${ev.location ? ' · ' + _locHTML(ev.location, ev.description) : ''}</div>
|
||||
</div>
|
||||
<button class="cal-event-more" data-uid="${_e(ev.uid)}" title="More">${_moreIcon}</button>
|
||||
</div>`;
|
||||
@@ -2288,7 +2295,7 @@ async function _renderSearch() {
|
||||
_wireAll(body);
|
||||
_wireQuickDelete(body);
|
||||
body.querySelectorAll('.cal-agenda-event').forEach(el => el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.cal-event-more')) return;
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
|
||||
const ev = _allEvents[el.dataset.uid];
|
||||
if (ev) _showEventForm(ev);
|
||||
}));
|
||||
@@ -2403,9 +2410,9 @@ function _dayDetailHTML(dateStr) {
|
||||
h += `<div class="cal-event-item${bgStyle ? ' cal-event-item-bg' : ''}" data-uid="${_e(ev.uid)}"${bgStyle ? ` style="${bgStyle}"` : ''}>
|
||||
<div class="cal-event-dot" style="background:${_calColor(ev)}"></div>
|
||||
<div class="cal-event-info">
|
||||
<div class="cal-event-name">${_e(ev.summary)}${_eventReminderHtml(ev)}</div>
|
||||
<div class="cal-event-name">${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)}</div>
|
||||
<div class="cal-event-time">${_fmtDate(date)} · ${t}</div>
|
||||
${ev.location ? `<div class="cal-event-loc">${_locHTML(ev.location)}</div>` : ''}
|
||||
${ev.location ? `<div class="cal-event-loc">${_locHTML(ev.location, ev.description)}</div>` : ''}
|
||||
</div>
|
||||
<button class="cal-event-more" data-uid="${_e(ev.uid)}" title="More">${_moreIcon}</button>
|
||||
</div>`;
|
||||
@@ -2418,7 +2425,7 @@ function _dayDetailHTML(dateStr) {
|
||||
else evs.forEach(ev => {
|
||||
const t = ev.all_day ? 'All day' : _fmtTime(ev.dtstart) + ' – ' + _fmtTime(ev.dtend);
|
||||
const _bgStyle = _calItemBgStyle(ev);
|
||||
h += `<div class="cal-event-item${_bgStyle ? ' cal-event-item-bg' : ''}" data-uid="${_e(ev.uid)}"${_bgStyle ? ` style="${_bgStyle}"` : ''}><div class="cal-event-dot" style="background:${_calColor(ev)}"></div><div class="cal-event-info"><div class="cal-event-name">${_e(ev.summary)}${_eventReminderHtml(ev)}</div><div class="cal-event-time">${t}</div>${ev.location ? `<div class="cal-event-loc">${_locHTML(ev.location)}</div>` : ''}</div><button class="cal-event-more" data-uid="${_e(ev.uid)}" title="More">${_moreIcon}</button></div>`;
|
||||
h += `<div class="cal-event-item${_bgStyle ? ' cal-event-item-bg' : ''}" data-uid="${_e(ev.uid)}"${_bgStyle ? ` style="${_bgStyle}"` : ''}><div class="cal-event-dot" style="background:${_calColor(ev)}"></div><div class="cal-event-info"><div class="cal-event-name">${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)}</div><div class="cal-event-time">${t}</div>${ev.location ? `<div class="cal-event-loc">${_locHTML(ev.location, ev.description)}</div>` : ''}</div><button class="cal-event-more" data-uid="${_e(ev.uid)}" title="More">${_moreIcon}</button></div>`;
|
||||
});
|
||||
return h + '</div>';
|
||||
}
|
||||
@@ -2977,7 +2984,7 @@ function _wireAll(body) {
|
||||
_render();
|
||||
}));
|
||||
body.querySelectorAll('.cal-event-item').forEach(it => it.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.cal-event-more')) return;
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
|
||||
const ev = _events.find(e => e.uid === it.dataset.uid);
|
||||
if (ev) _showEventForm(ev);
|
||||
}));
|
||||
@@ -3592,7 +3599,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
e.preventDefault();
|
||||
const taskId = e.currentTarget?.dataset?.taskId || '';
|
||||
try {
|
||||
const m = await import('/static/js/tasks.js?v=20260901taskskilldensity1');
|
||||
const m = await import('/static/js/tasks.js?v=20260914taskmodel1');
|
||||
const openTasks = m.openTasks || m.default?.openTasks;
|
||||
if (typeof openTasks === 'function') { openTasks(taskId); return; }
|
||||
} catch (_) {}
|
||||
@@ -4152,14 +4159,33 @@ function _eventDurationMinutes(ev) {
|
||||
function _e(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
|
||||
// Linkify a location string: URLs become clickable, plain addresses get a Maps link.
|
||||
function _locHTML(loc) {
|
||||
function _locHTML(loc, description = '') {
|
||||
if (!loc) return '';
|
||||
const urlRe = /(https?:\/\/[^\s]+)/gi;
|
||||
// Older email imports sometimes stored an OpenStreetMap URL after the
|
||||
// model mistook a virtual meeting for a physical location. Prefer the real
|
||||
// join URL preserved in the event description when one is available.
|
||||
const meetingMatch = String(description || '').match(
|
||||
/https?:\/\/(?:teams\.microsoft\.com|(?:[a-z0-9-]+\.)?zoom\.us|meet\.google\.com|(?:[a-z0-9-]+\.)?webex\.com|meet\.jit\.si)\/[^\s<>]+/i,
|
||||
);
|
||||
if (meetingMatch && !/https?:\/\/(?:teams\.microsoft\.com|(?:[a-z0-9-]+\.)?zoom\.us|meet\.google\.com|(?:[a-z0-9-]+\.)?webex\.com|meet\.jit\.si)\//i.test(String(loc))) {
|
||||
const meetingUrl = meetingMatch[0].replace(/[.,);\]]+$/, '');
|
||||
const safeMeetingUrl = _e(meetingUrl);
|
||||
return `<a href="${safeMeetingUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Join meeting">${safeMeetingUrl}</a>`;
|
||||
}
|
||||
const urlRe = /(https?:\/\/[^\s<>"']+)/gi;
|
||||
if (urlRe.test(loc)) {
|
||||
return loc.replace(urlRe, (url) => {
|
||||
// Escape every non-link fragment too; locations originate in emails/ICS.
|
||||
urlRe.lastIndex = 0;
|
||||
let html = '';
|
||||
let offset = 0;
|
||||
for (const match of String(loc).matchAll(urlRe)) {
|
||||
const url = match[0];
|
||||
html += _e(String(loc).slice(offset, match.index));
|
||||
const safe = _e(url);
|
||||
return `<a href="${safe}" target="_blank" rel="noopener" onclick="event.stopPropagation();">${safe}</a>`;
|
||||
}).replace(/\n/g, '<br>');
|
||||
html += `<a href="${safe}" target="_blank" rel="noopener" onclick="event.stopPropagation();">${safe}</a>`;
|
||||
offset = match.index + url.length;
|
||||
}
|
||||
return (html + _e(String(loc).slice(offset))).replace(/\n/g, '<br>');
|
||||
}
|
||||
// No URL — link the whole thing to OpenStreetMap.
|
||||
const mapUrl = 'https://www.openstreetmap.org/search?query=' + encodeURIComponent(loc);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// `start()` kicks off the poll loop + permission request. Call once from
|
||||
// the calendar's entry module.
|
||||
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
|
||||
+150
-21
@@ -6,18 +6,18 @@
|
||||
// ES6 module — IIFE removed
|
||||
|
||||
import Storage from './storage.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import sessionModule from './sessions.js';
|
||||
import chatRenderer, { renderToolIcon } from './chatRenderer.js?v=20260910streamlinks2';
|
||||
import chatStream from './chatStream.js?v=20260909cardlayout1';
|
||||
import chatRenderer, { renderToolIcon } from './chatRenderer.js?v=20260914metricssummary1';
|
||||
import chatStream from './chatStream.js?v=20260913richdiff1';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import presetsModule from './presets.js?v=20260908personaname1';
|
||||
import fileHandlerModule from './fileHandler.js?v=20260909mobileattachmentedit1';
|
||||
import searchModule from './search.js';
|
||||
import documentModule from './document.js?v=20260911removealignrightshortcut1';
|
||||
import * as emailInbox from './emailInbox.js?v=20260903emailsend2';
|
||||
import documentModule from './document.js?v=20260916docctx2';
|
||||
import * as emailInbox from './emailInbox.js?v=20260914aireply4';
|
||||
import codeRunnerModule from './codeRunner.js?v=20260831richtexttools91';
|
||||
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260902tuiharness1';
|
||||
import createResearchSynapse from './researchSynapse.js?v=20260910roundlabels2';
|
||||
@@ -277,7 +277,10 @@ import { invalidateSettings } from './appConfig.js';
|
||||
function _renderContextHeaderRing(pill, pct) {
|
||||
const value = Math.max(0, Math.min(100, Number(pct || 0)));
|
||||
pill.style.setProperty('--ctx-color', _contextRingColor(value));
|
||||
pill.innerHTML = _contextRingMarkup(value);
|
||||
if (false) {
|
||||
pill.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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 0 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 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 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 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.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 0 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>';
|
||||
}
|
||||
}
|
||||
|
||||
function _clampAutoCompactThreshold(value) {
|
||||
@@ -300,7 +303,18 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const hashId = _hashSessionCandidate();
|
||||
const lastSelectedId = String(window.__odysseusLastSelectedSessionId || '').trim();
|
||||
const targetId = activeRowId || hashId || lastSelectedId;
|
||||
if (!targetId) return '';
|
||||
if (!targetId) {
|
||||
// New chats are deliberately held in memory until the first prompt.
|
||||
// Per-chat settings are still actionable before that prompt, so create
|
||||
// the pending session when a setting needs a real session id.
|
||||
if (adopt && sm?.hasPendingChat?.() && sm?.materializePendingSession) {
|
||||
try {
|
||||
await sm.materializePendingSession();
|
||||
} catch (_) {}
|
||||
return (sm.getCurrentSessionId && sm.getCurrentSessionId()) || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (!adopt) return targetId;
|
||||
try {
|
||||
window.__odysseusComposerUserEdited = true;
|
||||
@@ -408,8 +422,35 @@ import { invalidateSettings } from './appConfig.js';
|
||||
await _setChatMemoryExtraction(!memoryToggle.classList.contains('active'), memoryToggle, memoryState);
|
||||
});
|
||||
memoryRow.appendChild(memoryCopy);
|
||||
memoryRow.appendChild(memoryToggle);
|
||||
popup.appendChild(memoryRow);
|
||||
memoryRow.appendChild(memoryToggle);
|
||||
popup.appendChild(memoryRow);
|
||||
|
||||
const memoryInjectionOn = d.memory_injection_enabled !== false;
|
||||
const memoryInjectionRow = document.createElement('div');
|
||||
memoryInjectionRow.className = 'chat-context-toggle-row';
|
||||
const memoryInjectionCopy = document.createElement('div');
|
||||
memoryInjectionCopy.className = 'chat-context-toggle-copy';
|
||||
const memoryInjectionLabel = document.createElement('span');
|
||||
memoryInjectionLabel.textContent = 'Memory injection';
|
||||
const memoryInjectionState = document.createElement('span');
|
||||
memoryInjectionState.className = 'chat-context-toggle-state';
|
||||
memoryInjectionState.textContent = memoryInjectionOn ? 'On' : 'Off';
|
||||
memoryInjectionCopy.appendChild(memoryInjectionLabel);
|
||||
memoryInjectionCopy.appendChild(memoryInjectionState);
|
||||
const memoryInjectionToggle = document.createElement('button');
|
||||
memoryInjectionToggle.type = 'button';
|
||||
memoryInjectionToggle.className = `chat-context-toggle${memoryInjectionOn ? ' active' : ''}`;
|
||||
memoryInjectionToggle.setAttribute('role', 'switch');
|
||||
memoryInjectionToggle.setAttribute('aria-label', 'Memory injection for this chat');
|
||||
memoryInjectionToggle.setAttribute('aria-checked', memoryInjectionOn ? 'true' : 'false');
|
||||
memoryInjectionToggle.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await _setChatMemoryInjection(!memoryInjectionToggle.classList.contains('active'), memoryInjectionToggle, memoryInjectionState);
|
||||
});
|
||||
memoryInjectionRow.appendChild(memoryInjectionCopy);
|
||||
memoryInjectionRow.appendChild(memoryInjectionToggle);
|
||||
popup.appendChild(memoryInjectionRow);
|
||||
|
||||
const skillsOn = d.skill_injection_enabled !== false;
|
||||
const skillsRow = document.createElement('div');
|
||||
@@ -438,6 +479,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
skillsRow.appendChild(skillsToggle);
|
||||
popup.appendChild(skillsRow);
|
||||
|
||||
if (d.thinking_supported) {
|
||||
const thinkingOn = d.thinking_mode === 'on';
|
||||
const thinkingRow = document.createElement('div');
|
||||
thinkingRow.className = 'chat-context-toggle-row';
|
||||
@@ -458,6 +500,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
});
|
||||
thinkingRow.appendChild(thinkingToggle);
|
||||
popup.appendChild(thinkingRow);
|
||||
}
|
||||
|
||||
const addGenerationSlider = (label, value, min, max, step, formatter, key) => {
|
||||
const row = document.createElement('div');
|
||||
@@ -657,6 +700,41 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
}
|
||||
|
||||
async function _setChatMemoryInjection(enabled, toggleBtn, stateText) {
|
||||
const sid = await _resolveCurrentSessionId({ adopt: true });
|
||||
if (!sid) {
|
||||
uiModule.showToast('Open a chat first');
|
||||
return false;
|
||||
}
|
||||
const next = !!enabled;
|
||||
if (toggleBtn) toggleBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/session/${encodeURIComponent(sid)}/memory-injection`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
_contextHeaderData = {
|
||||
...(_contextHeaderData || {}),
|
||||
memory_injection_enabled: next,
|
||||
};
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.toggle('active', next);
|
||||
toggleBtn.setAttribute('aria-checked', next ? 'true' : 'false');
|
||||
}
|
||||
if (stateText) stateText.textContent = next ? 'On' : 'Off';
|
||||
uiModule.showToast(next ? 'Memory injection on for this chat' : 'Memory injection off for this chat');
|
||||
return true;
|
||||
} catch (err) {
|
||||
uiModule.showError(`Could not save memory injection: ${err.message || err}`);
|
||||
return false;
|
||||
} finally {
|
||||
if (toggleBtn) toggleBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveChatGenerationSettings(change) {
|
||||
const sid = await _resolveCurrentSessionId({ adopt: true });
|
||||
if (!sid) return false;
|
||||
@@ -2189,6 +2267,10 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const _bubbleMode = (_toggleStateForBubble.mode || 'chat') === 'agent' ? 'agent' : 'chat';
|
||||
const _bubbleMeta = _pendingAttachInfo ? { attachments: _pendingAttachInfo } : {};
|
||||
_bubbleMeta.interaction_mode = _bubbleMode;
|
||||
if (docSel) {
|
||||
_bubbleMeta.document_id = documentModule?.getCurrentDocId?.() || '';
|
||||
_bubbleMeta.document_selections = Array.isArray(docSel) ? docSel : [docSel];
|
||||
}
|
||||
_userMsgEl = addMessage('user', userDisplay, null, _bubbleMeta);
|
||||
}
|
||||
_sendPerf.mark('user_bubble_visible');
|
||||
@@ -2408,6 +2490,11 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
fd.append('active_doc_id', activeDocIdForSend);
|
||||
}
|
||||
// A minimized mobile sheet remains linked to chat even though it is not
|
||||
// visually mounted. An explicit tab close returns no document id.
|
||||
fd.append('active_doc_state', activeDocIdForSend
|
||||
? (documentModule?.isPanelOpen?.() ? 'visible' : 'minimized')
|
||||
: 'none');
|
||||
// Active email context — when an email reader is open, pass its
|
||||
// uid/folder/account so "reply", "summarize", "what does this say"
|
||||
// resolve to the email the user is actually looking at instead of
|
||||
@@ -3157,6 +3244,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let _liveThinkHeader = null;
|
||||
let _liveThinkSpinnerSlot = null;
|
||||
let _liveThinkTimerEl = null;
|
||||
let _liveThinkSpinner = null;
|
||||
let _liveThinkTokenCount = 0;
|
||||
let _liveThinkToggle = null;
|
||||
let _liveThinkDomId = null;
|
||||
@@ -3254,6 +3342,22 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_startLiveThinkTimer();
|
||||
}
|
||||
|
||||
function _removeLiveThinkingSpinner() {
|
||||
const liveSpinner = _liveThinkSpinner;
|
||||
_liveThinkSpinner = null;
|
||||
if (liveSpinner) {
|
||||
const wrapper = liveSpinner.element;
|
||||
try { liveSpinner.destroy(); } catch (_) {}
|
||||
// createWhirlpool returns an outer wrapper around the Spinner's
|
||||
// inner element; destroy() removes only the inner element.
|
||||
wrapper?.remove?.();
|
||||
}
|
||||
if (_liveThinkSpinnerSlot) {
|
||||
_liveThinkSpinnerSlot.replaceChildren();
|
||||
_liveThinkSpinnerSlot = null;
|
||||
}
|
||||
}
|
||||
|
||||
_flushLiveThinking = ({ text = null, rich = false } = {}) => {
|
||||
if (text !== null) _queueLiveThinking(text, true);
|
||||
if (_liveThinkRenderThrottle) _liveThinkRenderThrottle.flush();
|
||||
@@ -3269,6 +3373,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_liveThinkRenderThrottle = null;
|
||||
_stopLiveThinkTimer();
|
||||
_cancelThinkingGrace();
|
||||
_removeLiveThinkingSpinner();
|
||||
};
|
||||
|
||||
function _finalizeLiveThinking(text, rich = true) {
|
||||
@@ -3314,7 +3419,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
|
||||
if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
|
||||
if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = elapsed ? _formatThinkStats(elapsed, _liveThinkTokenCount) : '';
|
||||
if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
|
||||
_removeLiveThinkingSpinner();
|
||||
}
|
||||
|
||||
function _cancelThinkingGrace() {
|
||||
@@ -3356,7 +3461,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
roundText = roundText.replace(/<think>/i, '<think time="' + elapsed + '">');
|
||||
}
|
||||
if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
|
||||
if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
|
||||
_removeLiveThinkingSpinner();
|
||||
if (_liveThinkTimerEl && elapsed) {
|
||||
_liveThinkTimerEl.textContent = _formatThinkStats(elapsed, _liveThinkTokenCount);
|
||||
_liveThinkTimerEl.style.marginLeft = 'auto';
|
||||
@@ -3710,7 +3815,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
roundText = roundText.replace(/<think>/i, '<think time="' + _elapsedDone + '">');
|
||||
}
|
||||
if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
|
||||
if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
|
||||
_removeLiveThinkingSpinner();
|
||||
if (_liveThinkTimerEl && _elapsedDone) {
|
||||
_liveThinkTimerEl.textContent = _formatThinkStats(_elapsedDone, _liveThinkTokenCount);
|
||||
_liveThinkTimerEl.style.marginLeft = 'auto';
|
||||
@@ -4016,12 +4121,12 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_queueLiveThinking(roundText);
|
||||
// Whirlpool spinner
|
||||
if (_liveThinkSpinnerSlot) {
|
||||
var _wp = spinnerModule.createWhirlpool(12);
|
||||
_wp.element.style.margin = '0';
|
||||
_wp.element.style.width = '12px';
|
||||
_wp.element.style.height = '12px';
|
||||
_wp.element.style.transform = 'translateY(-1px)'; // align the whirlpool with the header text
|
||||
_liveThinkSpinnerSlot.appendChild(_wp.element);
|
||||
_liveThinkSpinner = spinnerModule.createWhirlpool(12);
|
||||
_liveThinkSpinner.element.style.margin = '0';
|
||||
_liveThinkSpinner.element.style.width = '12px';
|
||||
_liveThinkSpinner.element.style.height = '12px';
|
||||
_liveThinkSpinner.element.style.transform = 'translateY(-1px)'; // align the whirlpool with the header text
|
||||
_liveThinkSpinnerSlot.appendChild(_liveThinkSpinner.element);
|
||||
}
|
||||
if (_thinkingRecheckAt) _scheduleThinkingGrace();
|
||||
} else if (hasUnclosedThink && isThinking) {
|
||||
@@ -4460,6 +4565,13 @@ import { invalidateSettings } from './appConfig.js';
|
||||
if (holder && json.id) holder.dataset.dbId = json.id;
|
||||
|
||||
} else if (json.type === 'tool_start') {
|
||||
// A tool call is the model's first completed output for this
|
||||
// round, even though it is rendered as a structured card
|
||||
// rather than prose. Stop the initial TTFT ticker here so
|
||||
// browser/search execution time is not later mislabeled as
|
||||
// "waiting for first token" on the continuation bubble. The
|
||||
// running tool card owns elapsed time until tool_output.
|
||||
markFirstVisibleOutput();
|
||||
_closeOpenThinkingMarkup(_isBg);
|
||||
if (_isBg) continue;
|
||||
_cancelThinkingTimer();
|
||||
@@ -6678,10 +6790,14 @@ import { invalidateSettings } from './appConfig.js';
|
||||
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'edit-save-btn';
|
||||
saveBtn.textContent = 'Send';
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.title = 'Send edited message';
|
||||
saveBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5"></path><path d="m5 12 7-7 7 7"></path></svg><span>Send</span>';
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'edit-cancel-btn';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.title = 'Cancel editing';
|
||||
cancelBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg><span>Cancel</span>';
|
||||
btnRow.appendChild(saveBtn);
|
||||
btnRow.appendChild(cancelBtn);
|
||||
|
||||
@@ -7821,7 +7937,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('open attachment as document failed', e);
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('Could not open attachment')).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Could not open attachment')).catch(() => {});
|
||||
window.open(url, '_blank'); // fallback so the file is still reachable
|
||||
}
|
||||
}
|
||||
@@ -7855,12 +7971,25 @@ import { invalidateSettings } from './appConfig.js';
|
||||
continueFrom,
|
||||
_appendViewReportLink,
|
||||
hasActiveStream,
|
||||
openContextSettings: () => {
|
||||
openContextSettings: async () => {
|
||||
const pill = document.getElementById('chat-context-pill');
|
||||
if (pill && !pill.hidden) {
|
||||
pill.click();
|
||||
return true;
|
||||
}
|
||||
const sm = _liveSessionModule();
|
||||
if (sm?.hasPendingChat?.() && sm?.materializePendingSession) {
|
||||
try {
|
||||
const materialized = await sm.materializePendingSession();
|
||||
if (materialized) {
|
||||
await refreshChatContextHeader('open-context-settings');
|
||||
if (pill && !pill.hidden) {
|
||||
pill.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
+60
-41
@@ -1,7 +1,7 @@
|
||||
// static/js/chatRenderer.js
|
||||
// Extracted from chat.js — message rendering, sources, images, metrics
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import markdownModule from './markdown.js';
|
||||
import { svgifyEmoji } from './markdown.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
@@ -1800,7 +1800,7 @@ function _activateEntityAnchor(e, forcedAnchor = null) {
|
||||
} else if (panel === 'skills') {
|
||||
document.getElementById('tool-skills-btn')?.click();
|
||||
} else if (panel === 'research') {
|
||||
import('./research/panel.js?v=20260911researchcardselect1').then(mod => {
|
||||
import('./research/panel.js?v=20260911researchmenu1').then(mod => {
|
||||
const open = mod.openPanel || (mod.default && mod.default.openPanel);
|
||||
if (open) open();
|
||||
}).catch(() => {});
|
||||
@@ -1848,7 +1848,7 @@ function _activateEntityAnchor(e, forcedAnchor = null) {
|
||||
} catch {}
|
||||
});
|
||||
} else if (kind === 'document') {
|
||||
import('./document.js?v=20260911removealignrightshortcut1').then(mod => {
|
||||
import('./document.js?v=20260916docctx2').then(mod => {
|
||||
const open = mod.loadDocument
|
||||
|| mod.openDocument
|
||||
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument));
|
||||
@@ -1875,7 +1875,7 @@ function _activateEntityAnchor(e, forcedAnchor = null) {
|
||||
if (open) open(id);
|
||||
}).catch(() => {});
|
||||
} else if (kind === 'email') {
|
||||
import('./emailLibrary.js?v=20260910replyactions1').then(mod => {
|
||||
import('./emailLibrary.js?v=20260915trashmove2').then(mod => {
|
||||
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (!open) return;
|
||||
const parts = String(id || '').split(':');
|
||||
@@ -1889,12 +1889,12 @@ function _activateEntityAnchor(e, forcedAnchor = null) {
|
||||
}
|
||||
}).catch(() => {});
|
||||
} else if (kind === 'event') {
|
||||
import('./calendar.js?v=20260903weekscrollstable1').then(mod => {
|
||||
import('./calendar.js?v=20260914emailsource11').then(mod => {
|
||||
const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo);
|
||||
if (open) open(id);
|
||||
}).catch(() => {});
|
||||
} else if (kind === 'task') {
|
||||
import('./tasks.js?v=20260901taskskilldensity1').then(mod => {
|
||||
import('./tasks.js?v=20260914taskmodel1').then(mod => {
|
||||
const open = mod.openTasks || (mod.default && mod.default.openTasks);
|
||||
if (open) open(id);
|
||||
else { const b = document.getElementById('tasks-btn'); if (b) b.click(); }
|
||||
@@ -1905,7 +1905,7 @@ function _activateEntityAnchor(e, forcedAnchor = null) {
|
||||
if (open) open(id);
|
||||
}).catch(() => {});
|
||||
} else if (kind === 'research') {
|
||||
import('./research/panel.js?v=20260911researchcardselect1').then(mod => {
|
||||
import('./research/panel.js?v=20260911researchmenu1').then(mod => {
|
||||
const open = mod.openPanel || (mod.default && mod.default.openPanel);
|
||||
if (open) open(id);
|
||||
}).catch(() => {});
|
||||
@@ -2609,14 +2609,8 @@ export function displayMetrics(messageElement, metrics) {
|
||||
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
|
||||
const hasTps = tps != null && tps !== 'undefined' && Number.isFinite(Number(tps));
|
||||
const tpsText = hasTps ? `${Number(tps).toFixed(2)} tok/s` : '';
|
||||
const ttftText = ttft != null && Number.isFinite(Number(ttft))
|
||||
? `${Number(ttft).toFixed(3)}s TTFT`
|
||||
: '';
|
||||
const injectedText = injectedTokens != null && Number.isFinite(Number(injectedTokens))
|
||||
? `${Number(injectedTokens).toLocaleString()} in`
|
||||
: '';
|
||||
const metricsLabel = hasTps
|
||||
? [tpsText, ttftText, injectedText].filter(Boolean).join(' · ')
|
||||
? tpsText
|
||||
: costStr0
|
||||
? costStr0
|
||||
: responseTime != null
|
||||
@@ -2636,7 +2630,7 @@ export function displayMetrics(messageElement, metrics) {
|
||||
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
|
||||
|
||||
const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : '';
|
||||
const costRows = costStr ? `<div><span class="ctx-label">Cost</span> ${costStr}</div>` : '';
|
||||
const costRows = costStr ? `<div class="ctx-stat-row"><span class="ctx-label">Cost</span><span class="ctx-stat-value">${costStr}</span></div>` : '';
|
||||
const speedStr = hasTps ? tpsText : 'n/a';
|
||||
const speedLabel = metrics.tps_source === 'computed' ? 'Speed (wall)' : 'Speed';
|
||||
const totalTok = inputTokens + outputTokens;
|
||||
@@ -2656,28 +2650,34 @@ export function displayMetrics(messageElement, metrics) {
|
||||
let sessionCostStr = '';
|
||||
const sc = getSessionCost();
|
||||
if (costStr && sc > 0) {
|
||||
sessionCostStr = `<div><span class="ctx-label">Session</span> $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}</div>`;
|
||||
sessionCostStr = `<div class="ctx-stat-row"><span class="ctx-label">Session</span><span class="ctx-stat-value">$${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}</span></div>`;
|
||||
}
|
||||
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'ctx-popup';
|
||||
popup.innerHTML = `
|
||||
<div style="font-weight:600;margin-bottom:6px;color:var(--fg);">Message Stats</div>
|
||||
<div><span class="ctx-label">Model</span> ${model.split('/').pop()}</div>
|
||||
<div><span class="ctx-label">Input (all rounds)</span> ${inputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</div>
|
||||
${injectedTokens != null ? `<div><span class="ctx-label">Injected (first request)</span> ${Number(injectedTokens).toLocaleString()} tokens</div>` : ''}
|
||||
<div><span class="ctx-label">Output</span> ${outputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</div>
|
||||
<div><span class="ctx-label">Total</span> ${totalTok.toLocaleString()} tokens</div>
|
||||
<div><span class="ctx-label">${speedLabel}</span> ${speedStr}</div>
|
||||
<div><span class="ctx-label">Time</span> ${Number(responseTime).toFixed(3)}s</div>
|
||||
${prepTime != null ? `<div><span class="ctx-label">Prep</span> ${prepTime}s</div>` : ''}
|
||||
${modelWaitTime != null ? `<div><span class="ctx-label">Model wait</span> ${modelWaitTime}s</div>` : ''}
|
||||
${visibleTtft != null ? `<div><span class="ctx-label">TTFT</span> ${Number(visibleTtft).toFixed(3)}s</div>` : ''}
|
||||
${schemaCount != null ? `<div><span class="ctx-label">Tool schemas</span> ${Number(schemaCount).toLocaleString()}</div>` : ''}
|
||||
${agentRounds != null ? `<div><span class="ctx-label">Agent rounds</span> ${Number(agentRounds).toLocaleString()}</div>` : ''}
|
||||
${toolCalls != null ? `<div><span class="ctx-label">Tool calls</span> ${Number(toolCalls).toLocaleString()}</div>` : ''}
|
||||
${costRows}
|
||||
${sessionCostStr}
|
||||
<div class="ctx-popup-title">Message stats</div>
|
||||
<div class="ctx-stat-section">
|
||||
<div class="ctx-stat-row"><span class="ctx-label">Model</span><span class="ctx-stat-value">${model.split('/').pop()}</span></div>
|
||||
<div class="ctx-stat-row"><span class="ctx-label">Input · all rounds</span><span class="ctx-stat-value">${inputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</span></div>
|
||||
${injectedTokens != null ? `<div class="ctx-stat-row"><span class="ctx-label">Injected · first request</span><span class="ctx-stat-value">${Number(injectedTokens).toLocaleString()} tokens</span></div>` : ''}
|
||||
<div class="ctx-stat-row"><span class="ctx-label">Output</span><span class="ctx-stat-value">${outputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</span></div>
|
||||
<div class="ctx-stat-row"><span class="ctx-label">Total</span><span class="ctx-stat-value">${totalTok.toLocaleString()} tokens</span></div>
|
||||
</div>
|
||||
<div class="ctx-stat-section">
|
||||
<div class="ctx-stat-row"><span class="ctx-label">${speedLabel}</span><span class="ctx-stat-value">${speedStr}</span></div>
|
||||
<div class="ctx-stat-row"><span class="ctx-label">Time</span><span class="ctx-stat-value">${Number(responseTime).toFixed(3)}s</span></div>
|
||||
${prepTime != null ? `<div class="ctx-stat-row"><span class="ctx-label">Prep</span><span class="ctx-stat-value">${prepTime}s</span></div>` : ''}
|
||||
${modelWaitTime != null ? `<div class="ctx-stat-row"><span class="ctx-label">Model wait</span><span class="ctx-stat-value">${modelWaitTime}s</span></div>` : ''}
|
||||
${visibleTtft != null ? `<div class="ctx-stat-row"><span class="ctx-label">TTFT</span><span class="ctx-stat-value">${Number(visibleTtft).toFixed(3)}s</span></div>` : ''}
|
||||
</div>
|
||||
<div class="ctx-stat-section">
|
||||
${schemaCount != null ? `<div class="ctx-stat-row"><span class="ctx-label">Tool schemas</span><span class="ctx-stat-value">${Number(schemaCount).toLocaleString()}</span></div>` : ''}
|
||||
${agentRounds != null ? `<div class="ctx-stat-row"><span class="ctx-label">Agent rounds</span><span class="ctx-stat-value">${Number(agentRounds).toLocaleString()}</span></div>` : ''}
|
||||
${toolCalls != null ? `<div class="ctx-stat-row"><span class="ctx-label">Tool calls</span><span class="ctx-stat-value">${Number(toolCalls).toLocaleString()}</span></div>` : ''}
|
||||
${costRows}
|
||||
${sessionCostStr}
|
||||
</div>
|
||||
${prepDetails ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;">
|
||||
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Agent prep</div>
|
||||
${prepDetails}
|
||||
@@ -3130,11 +3130,15 @@ export function renderAskUserCard(payload, options) {
|
||||
if (!isToolApproval) card.appendChild(other);
|
||||
|
||||
const previous = chatBox.lastElementChild;
|
||||
if (previous?.classList?.contains('agent-thread')) {
|
||||
const previousIsThread = previous?.classList?.contains('agent-thread');
|
||||
const previousIsAssistant = previous?.classList?.contains('msg-ai');
|
||||
if (previousIsThread || previousIsAssistant) {
|
||||
card.classList.add('ask-user-card-attached');
|
||||
const hadBottom = previous.classList.contains('has-bottom');
|
||||
previous.classList.add('has-bottom', 'has-ask-user-bottom');
|
||||
if (!hadBottom) previous.dataset.askUserAttachedBottom = 'true';
|
||||
if (previousIsThread) {
|
||||
const hadBottom = previous.classList.contains('has-bottom');
|
||||
previous.classList.add('has-bottom', 'has-ask-user-bottom');
|
||||
if (!hadBottom) previous.dataset.askUserAttachedBottom = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
chatBox.appendChild(card);
|
||||
@@ -3526,7 +3530,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
text = text
|
||||
.replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+```[\s\S]*?```/g, '')
|
||||
.replace(/\n*=== File: .+? ===\n\[Type: .+?\]\n+[\s\S]*?(?=\n*=== File:|$)/g, '')
|
||||
.replace(/\n*\[PDF content\]:[\s\S]*?(?=\n*\[PDF content\]|\n*=== File:|$)/g, '')
|
||||
.replace(/\n*\[PDF content[^\]]*\]:[\s\S]*?(?=\n*\[PDF content[^\]]*\]:|\n*=== File:|$)/g, '')
|
||||
.replace(/\n*\[Image attached: [^\]]+\]/g, '')
|
||||
.replace(/\n*\[Attached (?:document|non-text) file\]/g, '')
|
||||
.trim();
|
||||
@@ -3578,10 +3582,12 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
|
||||
// Style [Doc edit: ...] prefix in user messages
|
||||
if (role === 'user') {
|
||||
// Match compact format: [Doc edit: line X] instruction
|
||||
// Match both the optimistic live format (L1) and the persisted-history
|
||||
// format (line 1). Keep one interactive element in either path so the
|
||||
// bubble does not visibly gain styling only after a refresh.
|
||||
b.innerHTML = b.innerHTML.replace(
|
||||
/\[Doc edit: (lines? [\d–\-]+)\]\s*/,
|
||||
'<span class="doc-edit-tag">Doc edit: $1</span> '
|
||||
/\[Doc edit: ((?:L|lines?)\s*[\d–\-]+)\]\s*/i,
|
||||
'<button type="button" class="doc-edit-tag" data-doc-edit-ref="$1" title="Select this text again">Doc edit: $1</button> '
|
||||
);
|
||||
// Match raw format: "In the document, edit this specific text (line X):\n```\n...\n```\n\nInstruction: ..."
|
||||
// After markdown processing this becomes a <p> + <pre><code> block + <p>Instruction: text</p>
|
||||
@@ -3591,9 +3597,22 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
// Extract instruction text (after "Instruction: ")
|
||||
const instrMatch = b.textContent.match(/Instruction:\s*([\s\S]*)$/);
|
||||
const instrText = instrMatch ? instrMatch[1].trim() : '';
|
||||
b.innerHTML = '<span class="doc-edit-tag">Doc edit: ' + lineRef + '</span> ' + markdownModule.processWithThinking(instrText);
|
||||
b.innerHTML = '<button type="button" class="doc-edit-tag" data-doc-edit-ref="' + lineRef + '" title="Select this text again">Doc edit: ' + lineRef + '</button> ' + markdownModule.processWithThinking(instrText);
|
||||
}
|
||||
|
||||
b.querySelectorAll('[data-doc-edit-ref]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
import('./document.js?v=20260916docctx2').then(mod => {
|
||||
const restore = mod.restoreSelectionReference
|
||||
|| mod.default?.restoreSelectionReference;
|
||||
return restore?.(button.dataset.docEditRef || '', {
|
||||
documentId: metadata?.document_id || '',
|
||||
selections: metadata?.document_selections || null,
|
||||
});
|
||||
}).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
// Render attachment cards
|
||||
if (attachments?.length) {
|
||||
b.appendChild(buildAttachCards(attachments));
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
// SSE event handlers extracted from chat.js handleChatSubmit
|
||||
// Handles: ui_control events, background stream management
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import Storage from './storage.js';
|
||||
import themeModule from './theme.js?v=20260909effectspeed1';
|
||||
import themeModule from './theme.js?v=20260911organsrain1';
|
||||
import markdownModule from './markdown.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import documentModule from './document.js?v=20260911removealignrightshortcut1';
|
||||
import documentModule from './document.js?v=20260916docctx2';
|
||||
|
||||
// Tool approvals are control-plane submits for the current chat. chat.js
|
||||
// deliberately leaves the composer untouched, then programmatically clicks the
|
||||
@@ -186,14 +186,14 @@ export function handleUIControl(uiData) {
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'calendar') {
|
||||
import('./calendar.js?v=20260903weekscrollstable1').then(function(mod) {
|
||||
import('./calendar.js?v=20260914emailsource9').then(function(mod) {
|
||||
var viewFn = mod.openCalendarView || (mod.default && mod.default.openCalendarView);
|
||||
var fn = mod.openCalendar || (mod.default && mod.default.openCalendar);
|
||||
if (viewFn && (uiData.view || uiData.target_date)) viewFn(uiData.view || 'month', uiData.target_date || '');
|
||||
else if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'email') {
|
||||
import('./emailLibrary.js?v=20260910replyactions1').then(function(mod) {
|
||||
import('./emailLibrary.js?v=20260915trashmove2').then(function(mod) {
|
||||
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
@@ -205,7 +205,7 @@ export function handleUIControl(uiData) {
|
||||
} else if (panel === 'cookbook') {
|
||||
import('./cookbook.js').then(function(mod) {
|
||||
var fn = mod.open || (mod.default && mod.default.open);
|
||||
if (fn) fn();
|
||||
if (fn) fn(uiData.view ? { tab: uiData.view } : undefined);
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'notes') {
|
||||
import('./notes.js?v=20260910drawmerge1').then(function(mod) {
|
||||
@@ -213,7 +213,7 @@ export function handleUIControl(uiData) {
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'theme' || panel === 'themes') {
|
||||
import('./theme.js?v=20260909effectspeed1').then(function(mod) {
|
||||
import('./theme.js?v=20260911organsrain1').then(function(mod) {
|
||||
var fn = mod.togglePopup || (mod.default && mod.default.togglePopup);
|
||||
var modal = document.getElementById('theme-modal');
|
||||
if (modal && modal.classList.contains('hidden') && fn) fn();
|
||||
@@ -259,7 +259,7 @@ export function handleUIControl(uiData) {
|
||||
} catch (e) {
|
||||
console.warn('open_email_reply existing draft update failed:', e);
|
||||
}
|
||||
import('./emailInbox.js?v=20260903emailsend2').then(function(mod) {
|
||||
import('./emailInbox.js?v=20260914aireply4').then(function(mod) {
|
||||
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
|
||||
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
|
||||
}).catch(function(e) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// static/js/codeRunner.js
|
||||
|
||||
import * as uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import * as uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
|
||||
/**
|
||||
* In-browser code runner for Python (Pyodide), JavaScript, and HTML
|
||||
|
||||
@@ -35,10 +35,10 @@ import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
|
||||
|
||||
// ── External dependency imports ──
|
||||
import Storage from '../storage.js';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import sessionModule from '../sessions.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import themeModule from '../theme.js?v=20260909effectspeed1';
|
||||
import themeModule from '../theme.js?v=20260911organsrain1';
|
||||
import presetsModule from '../presets.js?v=20260908personaname1';
|
||||
import markdownModule from '../markdown.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// compare/models.js — model classification, fetching, display names, persistence
|
||||
import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import { sortModelObjects } from '../modelSort.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from './icons.js?v=20260908compareprompts1';
|
||||
import { _clearProbeWaves } from './probe.js';
|
||||
import Storage from '../storage.js';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// compare/probe.js — model probe/check system
|
||||
import state from './state.js';
|
||||
import { WAVE_FRAMES } from './icons.js?v=20260908compareprompts1';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from '../spinner.js';
|
||||
|
||||
function _clearProbeWaves() {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import { VOTES_STORAGE_KEY } from './icons.js?v=20260908compareprompts1';
|
||||
import themeModule from '../theme.js?v=20260909effectspeed1';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import themeModule from '../theme.js?v=20260911organsrain1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
|
||||
const escapeHtml = uiModule.esc;
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { fetchModels, _persistSelections, getExcludedModels } from './models.js'
|
||||
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
|
||||
import { EYE_OPEN, EYE_CLOSED, ICON_DICE, ICON_PARALLEL, ICON_SEQUENTIAL, SAVE_ICON, WAVE_FRAMES, CHAT_ICON } from './icons.js?v=20260908compareprompts1';
|
||||
import { _clearProbeWaves } from './probe.js';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import themeModule from '../theme.js?v=20260909effectspeed1';
|
||||
import themeModule from '../theme.js?v=20260911organsrain1';
|
||||
|
||||
const escapeHtml = uiModule.esc;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
|
||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260910streamlinks2';
|
||||
import markdownModule from '../markdown.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import presetsModule from '../presets.js?v=20260908personaname1';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -3,7 +3,7 @@ import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import { _modelDisplayNames } from './models.js';
|
||||
import { getModelCost } from '../chatRenderer.js?v=20260910streamlinks2';
|
||||
import uiModule from '../ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import { VOTES_STORAGE_KEY, VOTES_MAX } from './icons.js?v=20260908compareprompts1';
|
||||
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
// Plain specifier (no ?v=) — must match every other cookbook.js importer so the
|
||||
// browser loads it once. See cookbook-hwfit.js.
|
||||
} from './cookbook.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
|
||||
// Tiny HTML-escape — keeps the file standalone instead of leaning on a
|
||||
// shared helper that may not be exported from this module's import surface.
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
// importer uses. A query mismatch loads cookbook.js twice as two separate modules
|
||||
// (two _envState objects), which silently sent downloads to the wrong server.
|
||||
} from './cookbook.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { _loadTasks, _tmuxGracefulKill, _nextAvailablePort, _taskPort } from './cookbookRunning.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// What Fits? + Saved presets, inline action panels
|
||||
// ============================================
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// panel rendering, command building
|
||||
// ============================================
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
|
||||
|
||||
// Shared state/functions injected by init()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// stop/restart, diagnosis, auto-fix, background monitor
|
||||
// ============================================
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
|
||||
import { registerMenuDismiss } from './escMenuStack.js';
|
||||
import { computeProgressSignal } from './cookbookProgressSignal.js';
|
||||
|
||||
@@ -51,7 +51,7 @@ try { (function () {
|
||||
async function _getToast() {
|
||||
if (_toastFn) return _toastFn;
|
||||
try {
|
||||
const m = await import("/static/js/ui.js?v=20260908weekhoverfix1");
|
||||
const m = await import("/static/js/ui.js?v=20260916largetoolscroll1");
|
||||
_toastFn = m.default?.showToast || m.showToast || null;
|
||||
} catch (_) { _toastFn = null; }
|
||||
return _toastFn;
|
||||
@@ -70,7 +70,7 @@ try { (function () {
|
||||
let _tasksMod = null;
|
||||
async function _getTasksMod() {
|
||||
if (_tasksMod) return _tasksMod;
|
||||
try { _tasksMod = await import("/static/js/tasks.js?v=20260901taskskilldensity1"); } catch (_) {}
|
||||
try { _tasksMod = await import("/static/js/tasks.js?v=20260914taskmodel1"); } catch (_) {}
|
||||
return _tasksMod;
|
||||
}
|
||||
async function openTaskInTasksTab(taskId) {
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
// command building, preset slots, launch logic
|
||||
// ============================================
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { modelColor } from './chatRenderer.js?v=20260910streamlinks2';
|
||||
import { modelColor } from './chatRenderer.js?v=20260913richdiff1';
|
||||
import {
|
||||
bindMenuDismiss,
|
||||
dismissOrRemove,
|
||||
|
||||
+771
-65
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import sessionModule from './sessions.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import markdownModule from './markdown.js';
|
||||
@@ -37,17 +37,26 @@ function _isEmailDocument(doc) {
|
||||
return String(doc?.language || '').toLowerCase() === 'email';
|
||||
}
|
||||
|
||||
function _documentExport(doc, extMap) {
|
||||
function _documentExport(doc, extMap, format = 'original') {
|
||||
const email = _isEmailDocument(doc);
|
||||
const ext = email ? '.eml' : (extMap[doc?.language] || '.txt');
|
||||
const requestedFormat = String(format || 'original').toLowerCase();
|
||||
const forcedMarkdown = requestedFormat === 'markdown' || requestedFormat === 'md';
|
||||
const forcedText = requestedFormat === 'text' || requestedFormat === 'txt';
|
||||
const ext = forcedMarkdown ? '.md' : forcedText ? '.txt'
|
||||
: email ? '.eml' : (extMap[doc?.language] || '.txt');
|
||||
const title = String(doc?.title || 'document').trim() || 'document';
|
||||
const filename = title.toLowerCase().endsWith(ext) ? title : title + ext;
|
||||
// Email drafts use an internal header/body separator for the editor. Turn it
|
||||
// into the blank line required by RFC 5322 when exporting as .eml.
|
||||
const content = email
|
||||
const content = email && !forcedMarkdown && !forcedText
|
||||
? String(doc?.current_content || '').replace(/\r?\n---\r?\n/, '\r\n\r\n')
|
||||
: String(doc?.current_content || '');
|
||||
return { filename, content, type: email ? 'message/rfc822' : 'text/plain;charset=utf-8' };
|
||||
return {
|
||||
filename,
|
||||
content,
|
||||
type: forcedMarkdown ? 'text/markdown;charset=utf-8'
|
||||
: email && !forcedText ? 'message/rfc822' : 'text/plain;charset=utf-8',
|
||||
};
|
||||
}
|
||||
|
||||
const _LIBRARY_SORT_ICONS = {
|
||||
@@ -843,14 +852,14 @@ function _setLibraryCountChipContent(chip, label, count) {
|
||||
exportItem.className = 'dropdown-item-compact';
|
||||
exportItem.style.cssText = 'background:none;border:none;width:100%;';
|
||||
exportItem.innerHTML = _di(_exportIco) + '<span>Export</span>';
|
||||
const exportDocumentFile = async () => {
|
||||
const exportDocumentFile = async (format = 'original') => {
|
||||
hideCardDropdown();
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
const full = await res.json();
|
||||
const extMap = { javascript: '.js', python: '.py', html: '.html', svg: '.svg', css: '.css', markdown: '.md', json: '.json', yaml: '.yml', bash: '.sh', sql: '.sql', rust: '.rs', go: '.go', java: '.java', c: '.c', cpp: '.cpp', typescript: '.ts', ruby: '.rb', php: '.php', xml: '.xml', toml: '.toml', ini: '.ini' };
|
||||
const exported = _documentExport(full, extMap);
|
||||
const exported = _documentExport(full, extMap, format);
|
||||
const blob = new Blob([exported.content], { type: exported.type });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
@@ -1036,7 +1045,25 @@ function _setLibraryCountChipContent(chip, label, count) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_showLibDropdown(mobileMoreBtn, [
|
||||
{ label: 'Export file', icon: 'export', action: () => expandedExportBtn.click() },
|
||||
{
|
||||
label: 'Export file ›',
|
||||
icon: 'export',
|
||||
action: () => {
|
||||
const formatItems = [
|
||||
{ label: 'Original format', icon: 'export', action: () => expandedExportBtn.click() },
|
||||
{ label: 'Markdown (.md)', action: () => exportDocumentFile('markdown') },
|
||||
{ label: 'Plain text (.txt)', action: () => exportDocumentFile('text') },
|
||||
];
|
||||
const language = String(doc.language || '').toLowerCase();
|
||||
if (language === 'docx' || language === 'pdf') {
|
||||
formatItems.push(
|
||||
{ label: 'PDF (.pdf)', action: () => exportDocumentFile('pdf') },
|
||||
{ label: 'Word (.docx)', action: () => exportDocumentFile('docx') },
|
||||
);
|
||||
}
|
||||
_showLibDropdown(mobileMoreBtn, formatItems);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: _libraryArchivedView ? 'Restore document' : 'Archive document',
|
||||
icon: _libraryArchivedView ? 'restore' : 'archive',
|
||||
@@ -1688,7 +1715,7 @@ function _setLibraryCountChipContent(chip, label, count) {
|
||||
'.scss': 'css', '.sass': 'css', '.less': 'css',
|
||||
'.csv': 'csv', '.tsv': 'csv',
|
||||
'.xlsx': 'csv', '.xls': 'csv', '.ods': 'csv',
|
||||
'.docx': 'markdown', '.doc': 'markdown',
|
||||
'.docx': 'docx', '.doc': 'markdown',
|
||||
};
|
||||
|
||||
let imported = 0;
|
||||
@@ -1707,6 +1734,7 @@ function _setLibraryCountChipContent(chip, label, count) {
|
||||
|
||||
const isSpreadsheet = ['.xlsx', '.xls', '.ods'].includes(ext);
|
||||
const isPdf = ext === '.pdf';
|
||||
const isDocx = ext === '.docx';
|
||||
|
||||
if (isPdf) {
|
||||
// Backend handles save + AcroForm detection in one shot — picks the
|
||||
@@ -1727,6 +1755,24 @@ function _setLibraryCountChipContent(chip, label, count) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDocx) {
|
||||
// Preserve the original upload so the document panel can render a
|
||||
// Word-style preview and offer conversion/export actions later.
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch(`${API_BASE}/api/documents/import-docx`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let _e = `HTTP ${res.status}`;
|
||||
try { const _j = await res.json(); _e = _j.detail || _j.error || _e; } catch {}
|
||||
throw new Error('DOCX import failed: ' + _e);
|
||||
}
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSpreadsheet) {
|
||||
// Multi-sheet: create one document per sheet
|
||||
await ensureXLSX();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* el.querySelector after appending.
|
||||
*/
|
||||
|
||||
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
|
||||
|
||||
/** Keyboard-shortcuts popover. */
|
||||
export function shortcutsPopupHTML() {
|
||||
return `
|
||||
@@ -27,12 +29,11 @@ export function shortcutsPopupHTML() {
|
||||
<div><kbd>T</kbd> Text</div>
|
||||
<div><kbd>B</kbd> Brush</div>
|
||||
<div><kbd>E</kbd> Eraser</div>
|
||||
<div><kbd>K</kbd> Clone Stamp <span style="opacity:0.5">(Alt-click = set source)</span></div>
|
||||
<div><kbd>${TOOL_SHORTCUTS.clone}</kbd> Clone Stamp <span style="opacity:0.5">(Alt-click = set source)</span></div>
|
||||
<div><kbd>L</kbd> Lasso</div>
|
||||
<div><kbd>W</kbd> Wand</div>
|
||||
<div><kbd>M</kbd> Inpaint</div>
|
||||
<div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</div>
|
||||
<div><kbd>C</kbd> Crop</div>
|
||||
<div><kbd>S</kbd> Sharpen</div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Edit</h5>
|
||||
@@ -48,10 +49,11 @@ export function shortcutsPopupHTML() {
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Selection</h5>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>A</kbd> Select All</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>D</kbd> Deselect</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>C</kbd> Copy to layer</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>X</kbd> Cut lasso</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>D</kbd> Delete pixels</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>D</kbd> Deselect</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>C</kbd> Copy</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>X</kbd> Cut</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>J</kbd> Copy selection to layer / duplicate layer</div>
|
||||
<div><kbd>Delete</kbd> Delete pixels</div>
|
||||
<div><kbd>Esc</kbd> Cancel selection / crop</div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
|
||||
@@ -13,38 +13,41 @@
|
||||
* }} ctx
|
||||
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
|
||||
*/
|
||||
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
|
||||
|
||||
export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'ge-toolbar';
|
||||
const tools = [
|
||||
{ id: 'move', label: 'Move', icon: '✥', key: 'V' },
|
||||
{ id: 'hand', label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>', key: 'H' },
|
||||
{ id: 'crop', label: 'Crop', icon: '✂', key: 'C' },
|
||||
{ id: 'move', label: 'Move', icon: '✥' },
|
||||
{ id: 'hand', label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>' },
|
||||
{ id: 'crop', label: 'Crop', icon: '✂' },
|
||||
{ id: 'transform', label: 'Transform', icon: '⤢' },
|
||||
{ id: 'text', label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>', key: 'T' },
|
||||
{ id: 'shape', label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>', key: 'U' },
|
||||
{ id: 'text', label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>' },
|
||||
{ id: 'shape', label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'brush', label: 'Brush', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'B' },
|
||||
{ id: 'gradient', label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>', key: 'G' },
|
||||
{ id: 'eraser', label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>', key: 'E' },
|
||||
{ id: 'eyedropper', label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 3 2 2-9.5 9.5-3-3Z"/><path d="m8.5 11.5-5 5V21h4.5l5-5"/></svg>', key: 'I' },
|
||||
{ id: 'smudge', label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>', key: 'Y' },
|
||||
{ id: 'brush', label: 'Brush', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
|
||||
{ id: 'gradient', label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>' },
|
||||
{ id: 'eraser', label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>' },
|
||||
{ id: 'eyedropper', label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 3 2 2-9.5 9.5-3-3Z"/><path d="m8.5 11.5-5 5V21h4.5l5-5"/></svg>' },
|
||||
{ id: 'smudge', label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>', key: 'K' },
|
||||
{ id: 'heal', label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>', key: 'J' },
|
||||
{ id: 'dodge', label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>', key: 'O' },
|
||||
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>', key: 'D' },
|
||||
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>', key: 'R' },
|
||||
{ id: 'lasso', label: 'Lasso', icon: '⟡', key: 'L' },
|
||||
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>', key: 'W' },
|
||||
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>' },
|
||||
{ id: 'heal', label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>' },
|
||||
{ id: 'dodge', label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>' },
|
||||
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>' },
|
||||
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>' },
|
||||
{ id: 'lasso', label: 'Lasso', icon: '⟡' },
|
||||
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>' },
|
||||
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'M' },
|
||||
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
|
||||
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
|
||||
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈', key: 'S' },
|
||||
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈' },
|
||||
];
|
||||
const toolKeyMap = {};
|
||||
for (const t of tools) {
|
||||
t.key = TOOL_SHORTCUTS[t.id];
|
||||
if (t.sep) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'ge-tool-sep';
|
||||
|
||||
@@ -51,7 +51,6 @@ export function buildTopbar() {
|
||||
<span class="ge-topbar-sep"></span>
|
||||
</div>
|
||||
<div class="ge-topbar-right">
|
||||
<span class="ge-draft-status" id="ge-draft-status" role="status" aria-live="polite" title="Draft status">Not saved</span>
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
|
||||
<div class="ge-view-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
|
||||
@@ -74,6 +73,7 @@ export function buildTopbar() {
|
||||
<span class="ge-stacked-label">IMAGE</span>
|
||||
</button>
|
||||
<div class="ge-image-menu dropdown" id="ge-image-menu" hidden>
|
||||
<button class="dropdown-item-compact" data-image-action="fill"><span>Fill selection / mask</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="canvas-size">
|
||||
<span class="dropdown-icon">⤢</span>
|
||||
<span>Canvas Size...</span>
|
||||
@@ -107,8 +107,8 @@ export function buildTopbar() {
|
||||
<span class="ge-stacked-label">SELECT</span>
|
||||
</button>
|
||||
<div class="ge-selection-menu dropdown" id="ge-selection-menu" hidden>
|
||||
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+Alt+A</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+Shift+D</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+A</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+D</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="reselect"><span>Reselect</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+I</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button>
|
||||
@@ -177,8 +177,11 @@ export function buildTopbar() {
|
||||
</button>
|
||||
<div class="ge-save-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-primary ge-stacked-btn" id="ge-save-menu-btn" title="Save options">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">SAVE</span>
|
||||
<span class="ge-stacked-glyph">
|
||||
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
|
||||
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
|
||||
</button>
|
||||
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
|
||||
<div class="dropdown-section-label">Image</div>
|
||||
|
||||
@@ -43,7 +43,12 @@ import {
|
||||
syncPanCursor,
|
||||
} from './canvas-navigation.js';
|
||||
|
||||
let canvasWindowBindings;
|
||||
|
||||
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange }) {
|
||||
canvasWindowBindings?.abort();
|
||||
canvasWindowBindings = new AbortController();
|
||||
const { signal } = canvasWindowBindings;
|
||||
let suppressMouseUntil = 0;
|
||||
// Mouse — mousedown stays on the canvas; mousemove/up are bound to
|
||||
// the WINDOW so a drag can continue (and end) past the canvas edge.
|
||||
@@ -56,11 +61,11 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
continueDraw(e);
|
||||
});
|
||||
}, { signal });
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
endDraw(e);
|
||||
});
|
||||
}, { signal });
|
||||
// Preserve pressure and browser-coalesced samples for pen input.
|
||||
// Compatibility mouse events are briefly suppressed to avoid a
|
||||
// duplicate stroke after pointerup.
|
||||
@@ -266,6 +271,21 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
canvasArea.addEventListener('pointerup', endPan);
|
||||
canvasArea.addEventListener('pointercancel', endPan);
|
||||
// Treat focus loss as release: preserve the work already drawn, but never
|
||||
// resume the gesture when the user returns without a fresh pointer press.
|
||||
window.addEventListener('blur', () => {
|
||||
if (!state.editorOpen || !canvasArea.contains(state.mainCanvas)) return;
|
||||
endDraw();
|
||||
if (activePenId !== null) {
|
||||
try { state.mainCanvas.releasePointerCapture(activePenId); } catch {}
|
||||
activePenId = null;
|
||||
}
|
||||
multiActive = false;
|
||||
endPan();
|
||||
state.spacePanActive = false;
|
||||
syncPanCursor(state, canvasArea, false);
|
||||
if (state.cursorEl) state.cursorEl.style.display = 'none';
|
||||
}, { signal });
|
||||
// Reset offset whenever zoom/fit changes the canvas size.
|
||||
canvasArea._resetPan = () => applyOffset(0, 0);
|
||||
const navigation = {
|
||||
|
||||
@@ -29,15 +29,21 @@
|
||||
import { state } from './state.js';
|
||||
import { createPlacedData, renderPlacedLayer } from './placed-layer.js';
|
||||
|
||||
let clipboardBindings;
|
||||
|
||||
export function wireClipboardAndDrop({
|
||||
container, saveState, createLayer, renderLayerPanel, composite,
|
||||
handleImportedImage, uiModule,
|
||||
}) {
|
||||
clipboardBindings?.abort();
|
||||
clipboardBindings = new AbortController();
|
||||
const { signal } = clipboardBindings;
|
||||
// ── Paste ──
|
||||
window.addEventListener('paste', (e) => {
|
||||
if (!state.editorOpen) return;
|
||||
if (!state.editorOpen || state.container !== container || e.defaultPrevented) return;
|
||||
if (e.target?.isContentEditable || e.target?.closest?.('input, textarea, select, [role="dialog"]')) return;
|
||||
|
||||
function pasteAsLayer(imgSource, label) {
|
||||
function pasteAsLayer(imgSource, label, offset = { x: 0, y: 0 }) {
|
||||
if (!state.editorOpen) return; // user closed mid-paste
|
||||
saveState();
|
||||
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
|
||||
@@ -45,14 +51,15 @@ export function wireClipboardAndDrop({
|
||||
// Keep it source-backed with an identity matrix so future transforms do
|
||||
// not repeatedly resample the pasted pixels.
|
||||
layer.kind = 'placed';
|
||||
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, 0, 0], label || 'Pasted');
|
||||
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, offset.x, offset.y], label || 'Pasted');
|
||||
const rendered = renderPlacedLayer(layer);
|
||||
state.layerOffsets.set(layer.id, rendered.offset);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
state.tool = 'move';
|
||||
state.selectedLayerIds = [layer.id];
|
||||
state.activeGroupId = null;
|
||||
const tb = state.container?.querySelector('.ge-toolbar');
|
||||
if (tb) tb.querySelectorAll('.ge-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === 'move'));
|
||||
tb?.querySelector('[data-tool="move"]')?.click();
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Pasted as new layer');
|
||||
@@ -62,7 +69,7 @@ export function wireClipboardAndDrop({
|
||||
if (state.internalClipboard) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection');
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,7 +90,7 @@ export function wireClipboardAndDrop({
|
||||
img.src = url;
|
||||
break;
|
||||
}
|
||||
}, true); // capture phase so we beat chat input
|
||||
}, { capture: true, signal });
|
||||
|
||||
// ── Drag-and-drop ──
|
||||
// Visual drop-zone overlay appears mid-drag; routes via
|
||||
|
||||
@@ -8,22 +8,21 @@
|
||||
* Enter confirm in-progress transform
|
||||
* Esc cancel transform / lasso / crop (in priority order)
|
||||
* Ctrl+Z undo (Shift adds redo)
|
||||
* Ctrl+Shift+D deselect (clears wand + lasso)
|
||||
* Ctrl+D deselect (clears wand + lasso)
|
||||
* Ctrl+S save (Shift = save as / export to gallery)
|
||||
* Ctrl+Shift+T open resize popup
|
||||
* Ctrl+Alt+T start free transform
|
||||
* Ctrl+Alt+I invert wand / lasso selection
|
||||
* Ctrl+Alt+J new empty layer
|
||||
* Ctrl/Cmd+J duplicate the active layer
|
||||
* Ctrl/Cmd+J copy selected pixels to a layer, or duplicate the layer
|
||||
* Ctrl+Alt+G create/release clipping mask
|
||||
* Ctrl+Alt+A select all canvas (lasso polygon = full bounds)
|
||||
* Ctrl+C/X copy / cut wand or lasso selection (image clipboard
|
||||
* + internal clipboard)
|
||||
* Ctrl+A select all canvas
|
||||
* Ctrl+C/X copy / cut the selected surface (image clipboard
|
||||
* + internal clipboard, preserving its document offset)
|
||||
* Ctrl+V (handled by the paste event listener)
|
||||
* Tool keys (V, B, E, L, …) → toolbar click
|
||||
* Hold Space temporarily pan without changing the active tool
|
||||
* [ / ] shrink / grow brush size proportionally
|
||||
* D, C, M (when lasso has 3+ points) → delete / copy / convert mask
|
||||
* Delete / Backspace (wand or lasso) → delete pixels
|
||||
*
|
||||
* @param {{
|
||||
@@ -46,9 +45,7 @@
|
||||
* wandCopyToNewLayer: () => void,
|
||||
* lassoDeleteSelection: () => void,
|
||||
* lassoCopyToLayer: () => void,
|
||||
* lassoToMask: () => void,
|
||||
* buildLassoMask: (w: number, h: number, offX: number, offY: number, feather: number, grow: number) => HTMLCanvasElement,
|
||||
* drawLassoOverlay: () => void,
|
||||
* copyPixelsToClipboard: (options: {cut: boolean}) => Promise<void>,
|
||||
* activeLayer: () => object | null,
|
||||
* deleteSelectedLayers: () => boolean | Promise<boolean>,
|
||||
* duplicateActiveLayer: () => boolean,
|
||||
@@ -57,18 +54,22 @@
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { isAltGrEvent } from '../platform.js';
|
||||
import { createMarqueeMask, selectionMaskForLayer } from './selection-mask.js';
|
||||
import { createMarqueeMask } from './selection-mask.js';
|
||||
|
||||
let keyboardBindings;
|
||||
|
||||
export function wireKeyboardShortcuts(deps) {
|
||||
keyboardBindings?.abort();
|
||||
keyboardBindings = new AbortController();
|
||||
const { signal } = keyboardBindings;
|
||||
const {
|
||||
toolbar, toolKeyMap,
|
||||
composite, saveState, undo, redo,
|
||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
|
||||
invertSelection,
|
||||
wandDeleteSelection, wandCopyToNewLayer,
|
||||
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
|
||||
buildLassoMask, drawLassoOverlay,
|
||||
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
|
||||
lassoDeleteSelection, lassoCopyToLayer,
|
||||
activeLayer, deleteSelectedLayers, duplicateActiveLayer, uiModule,
|
||||
setTemporaryPan,
|
||||
nudgeActiveLayer, endLayerNudge,
|
||||
@@ -77,18 +78,22 @@ export function wireKeyboardShortcuts(deps) {
|
||||
} = deps;
|
||||
|
||||
const isTypingTarget = (target) => target && (
|
||||
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
||||
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' || target.isContentEditable
|
||||
);
|
||||
|
||||
const releaseTemporaryPan = () => setTemporaryPan?.(false);
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (e.code === 'Space') releaseTemporaryPan();
|
||||
if (e.key.startsWith('Arrow')) endLayerNudge?.();
|
||||
});
|
||||
window.addEventListener('blur', releaseTemporaryPan);
|
||||
}, { signal });
|
||||
window.addEventListener('blur', releaseTemporaryPan, { signal });
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!state.editorOpen) return;
|
||||
if (!state.editorOpen || e.defaultPrevented || e.isComposing) return;
|
||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||
// Fields and text layers own native editing, including undo and clipboard.
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if (e.code === 'Space' && !isTypingTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
setTemporaryPan?.(true);
|
||||
@@ -145,22 +150,26 @@ export function wireKeyboardShortcuts(deps) {
|
||||
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
|
||||
// AZERTY / QWERTZ.
|
||||
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
|
||||
if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
|
||||
// Ctrl+Shift+D = Deselect: clears the wand selection (and
|
||||
// lasso if active) without affecting layers.
|
||||
if (e.shiftKey && (e.key === 'D' || e.key === 'd')) {
|
||||
if (state.wandMask || state.lassoPoints.length) {
|
||||
e.preventDefault();
|
||||
deselectSelection?.();
|
||||
}
|
||||
if (e.key.toLowerCase() === 'z') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if (e.shiftKey) redo(); else undo();
|
||||
return;
|
||||
}
|
||||
// Preserve the old chord as an alias while supporting the familiar one.
|
||||
if (!e.altKey && e.key.toLowerCase() === 'd') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
deselectSelection?.();
|
||||
return;
|
||||
}
|
||||
// Save shortcuts — match the hints shown in the Save dropdown.
|
||||
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
|
||||
e.preventDefault();
|
||||
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && e.key === 'T') { e.preventDefault(); resizeCustomPrompt(); }
|
||||
if (e.altKey && e.key === 't') { e.preventDefault(); startTransform(); }
|
||||
if (e.shiftKey && e.key === 'T') { e.preventDefault(); e.stopPropagation(); resizeCustomPrompt(); return; }
|
||||
if (e.altKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; }
|
||||
// Ctrl+Alt+I — invert current selection. Uses e.code so
|
||||
// Alt-modified key values (e.g. `ˆ` on Mac with Option+I)
|
||||
// don't break the match.
|
||||
@@ -169,19 +178,23 @@ export function wireKeyboardShortcuts(deps) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+J — new empty layer.
|
||||
if (e.altKey && e.code === 'KeyJ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addEmptyLayer();
|
||||
return;
|
||||
}
|
||||
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
|
||||
// existing implementation, which preserves masks and effects.
|
||||
if (!e.altKey && e.code === 'KeyJ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
duplicateActiveLayer?.();
|
||||
if (state.wandMask) wandCopyToNewLayer();
|
||||
else if (state.lassoPoints.length >= 3) lassoCopyToLayer();
|
||||
else duplicateActiveLayer?.();
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+G — Photoshop-compatible clipping mask shortcut.
|
||||
@@ -194,128 +207,18 @@ export function wireKeyboardShortcuts(deps) {
|
||||
e.stopPropagation();
|
||||
button.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Wand selection: Delete = erase pixels. Ctrl+X = cut to
|
||||
// clipboard + new layer + erase. Ctrl+C = copy.
|
||||
// (Legacy `&& !_wandActive` clause referenced an undeclared
|
||||
// variable — removed; the wand is selection-only and has no
|
||||
// "active drag" state.)
|
||||
if (state.wandMask) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
wandDeleteSelection();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'c')) {
|
||||
e.preventDefault();
|
||||
const isCut = e.key === 'x';
|
||||
const src = activeLayer();
|
||||
if (!src) return;
|
||||
// Clip source by wand mask into a temp canvas.
|
||||
const w = src.canvas.width, h = src.canvas.height;
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.drawImage(src.canvas, 0, 0);
|
||||
tCtx.globalCompositeOperation = 'destination-in';
|
||||
const off = state.layerOffsets.get(src.id) || { x: 0, y: 0 };
|
||||
tCtx.drawImage(selectionMaskForLayer(
|
||||
state.wandMask,
|
||||
state.wandMaskSpace || 'layer',
|
||||
off,
|
||||
w,
|
||||
h,
|
||||
), 0, 0);
|
||||
state.internalClipboard = tmp;
|
||||
tmp.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
|
||||
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
|
||||
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
if (isCut) {
|
||||
// Cut is one user action: make one history checkpoint, then move
|
||||
// the selected pixels and erase the source without nested saves.
|
||||
saveState('Cut selection');
|
||||
const cutLayer = wandCopyToNewLayer({ saveHistory: false, activate: false, announce: false });
|
||||
wandDeleteSelection({ saveHistory: false, message: 'Selection cut' });
|
||||
if (cutLayer) {
|
||||
state.activeLayerId = cutLayer.id;
|
||||
document.querySelectorAll('.ge-layer-item[data-layer-id]').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.layerId === cutLayer.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((e.key === 'x' || e.key === 'c') && state.lassoPoints.length >= 3) {
|
||||
if (!e.altKey && (e.key.toLowerCase() === 'c' || e.key.toLowerCase() === 'x')) {
|
||||
e.preventDefault();
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
|
||||
const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const mask = buildLassoMask(w, h, off.x, off.y, feather, grow);
|
||||
const srcData = layer.ctx.getImageData(0, 0, w, h);
|
||||
const maskData = mask.getContext('2d').getImageData(0, 0, w, h);
|
||||
// Build clipped image.
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
const outData = tCtx.createImageData(w, h);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
const mv = maskData.data[i * 4] / 255;
|
||||
if (mv > 0) {
|
||||
outData.data[i*4] = srcData.data[i*4];
|
||||
outData.data[i*4+1] = srcData.data[i*4+1];
|
||||
outData.data[i*4+2] = srcData.data[i*4+2];
|
||||
outData.data[i*4+3] = Math.round(srcData.data[i*4+3] * mv);
|
||||
}
|
||||
}
|
||||
tCtx.putImageData(outData, 0, 0);
|
||||
state.internalClipboard = tmp;
|
||||
const isCut = e.key === 'x';
|
||||
tmp.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
|
||||
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
|
||||
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
if (e.key === 'x') {
|
||||
const savedPts = [...state.lassoPoints];
|
||||
state.lassoPoints = savedPts;
|
||||
lassoDeleteSelection();
|
||||
} else {
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
}
|
||||
e.stopPropagation();
|
||||
void copyPixelsToClipboard({ cut: e.key.toLowerCase() === 'x' });
|
||||
return;
|
||||
}
|
||||
// Ctrl+C with no active selection → copy the entire active layer
|
||||
// to the system clipboard as a PNG. Gives a "just copy this image"
|
||||
// shortcut without having to lasso-select-all first. The
|
||||
// selection-aware Ctrl+C paths above run first (wand + lasso),
|
||||
// so this only fires when neither is active.
|
||||
if (e.key === 'c' && !e.shiftKey && !state.wandMask && state.lassoPoints.length < 3) {
|
||||
const layer = activeLayer();
|
||||
if (layer && layer.canvas && layer.canvas.width > 0) {
|
||||
e.preventDefault();
|
||||
layer.canvas.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
.then(() => uiModule.showToast('Layer copied to clipboard'))
|
||||
.catch(() => uiModule.showToast('Copy failed (clipboard permission denied?)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Ctrl+Alt+A = select all canvas.
|
||||
if (e.altKey && e.key === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
|
||||
// Keep Ctrl+Alt+A as an alias for existing users.
|
||||
if (e.key.toLowerCase() === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
saveState('Select all');
|
||||
state.wandMask = createMarqueeMask(
|
||||
state.imgWidth,
|
||||
@@ -375,8 +278,11 @@ export function wireKeyboardShortcuts(deps) {
|
||||
}
|
||||
const toolId = toolKeyMap[e.key.toLowerCase()];
|
||||
if (toolId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`);
|
||||
if (toolBtn) toolBtn.click();
|
||||
return;
|
||||
}
|
||||
// Bracket keys for brush size — ±10% multiplier mirrors the
|
||||
// exponential slider curve so each press feels the same at any
|
||||
@@ -386,12 +292,5 @@ export function wireKeyboardShortcuts(deps) {
|
||||
state.brushSize = Math.max(1, Math.min(800, Math.round(state.brushSize * factor)));
|
||||
try { brushSizeSync(null); } catch {}
|
||||
}
|
||||
// Lasso shortcuts (when selection exists).
|
||||
if (state.lassoPoints.length >= 3) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); lassoDeleteSelection(); }
|
||||
if (e.key === 'd') { e.preventDefault(); lassoDeleteSelection(); }
|
||||
if (e.key === 'c') { e.preventDefault(); lassoCopyToLayer(); }
|
||||
if (e.key === 'm') { e.preventDefault(); lassoToMask(); }
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
}
|
||||
|
||||
@@ -117,6 +117,43 @@ export function createLayerPanelRenderer(deps) {
|
||||
: [layer];
|
||||
}
|
||||
|
||||
let previewSession;
|
||||
const previewSignatures = new Map();
|
||||
|
||||
function refreshPreviews() {
|
||||
const list = document.getElementById('ge-layers-list');
|
||||
if (!list || !state.editorOpen) return;
|
||||
if (previewSession !== state.editorSessionToken) {
|
||||
previewSession = state.editorSessionToken;
|
||||
previewSignatures.clear();
|
||||
}
|
||||
const initialized = previewSignatures.size > 0;
|
||||
const currentIds = new Set();
|
||||
for (const row of list.children) {
|
||||
const id = row.dataset.layerId || row.dataset.groupId || row.dataset.maskId || row.dataset.groupMaskId;
|
||||
const thumb = row.querySelector('.ge-layer-inline-thumb');
|
||||
if (!id || !thumb) continue;
|
||||
currentIds.add(id);
|
||||
thumb._refreshPreview?.();
|
||||
const layer = state.layers.find(item => item.id === id);
|
||||
const signature = thumb.toDataURL() + JSON.stringify(layer ? {
|
||||
offset: state.layerOffsets.get(id), opacity: layer.opacity,
|
||||
visible: layer.visible, locked: layer.locked, name: layer.name,
|
||||
} : {});
|
||||
const previous = previewSignatures.get(id);
|
||||
previewSignatures.set(id, signature);
|
||||
if (initialized && previous !== signature) {
|
||||
row.classList.remove('ge-layer-action-flash');
|
||||
void row.offsetWidth;
|
||||
row.classList.add('ge-layer-action-flash');
|
||||
row.addEventListener('animationend', () => row.classList.remove('ge-layer-action-flash'), { once: true });
|
||||
}
|
||||
}
|
||||
for (const id of previewSignatures.keys()) {
|
||||
if (!currentIds.has(id)) previewSignatures.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function createInlineThumbnail(source, title, extraClass = '') {
|
||||
const thumb = document.createElement('canvas');
|
||||
thumb.className = `ge-layer-inline-thumb${extraClass ? ` ${extraClass}` : ''}`;
|
||||
@@ -126,23 +163,28 @@ export function createLayerPanelRenderer(deps) {
|
||||
thumb.setAttribute('role', 'img');
|
||||
thumb.setAttribute('aria-label', title);
|
||||
const ctx = thumb.getContext('2d');
|
||||
if (!ctx || !source?.width || !source?.height) return thumb;
|
||||
const tile = 8;
|
||||
for (let y = 0; y < thumb.height; y += tile) {
|
||||
for (let x = 0; x < thumb.width; x += tile) {
|
||||
ctx.fillStyle = ((x / tile + y / tile) & 1) ? '#464646' : '#303030';
|
||||
ctx.fillRect(x, y, tile, tile);
|
||||
const draw = () => {
|
||||
const image = typeof source === 'function' ? source() : source;
|
||||
if (!ctx || !image?.width || !image?.height) return;
|
||||
const tile = 8;
|
||||
for (let y = 0; y < thumb.height; y += tile) {
|
||||
for (let x = 0; x < thumb.width; x += tile) {
|
||||
ctx.fillStyle = ((x / tile + y / tile) & 1) ? '#464646' : '#303030';
|
||||
ctx.fillRect(x, y, tile, tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
const scale = Math.min(thumb.width / source.width, thumb.height / source.height);
|
||||
const width = source.width * scale;
|
||||
const height = source.height * scale;
|
||||
ctx.drawImage(source, (thumb.width - width) / 2, (thumb.height - height) / 2, width, height);
|
||||
const scale = Math.min(thumb.width / image.width, thumb.height / image.height);
|
||||
const width = image.width * scale;
|
||||
const height = image.height * scale;
|
||||
ctx.drawImage(image, (thumb.width - width) / 2, (thumb.height - height) / 2, width, height);
|
||||
};
|
||||
thumb._refreshPreview = draw;
|
||||
draw();
|
||||
return thumb;
|
||||
}
|
||||
|
||||
function createMaskThumbnail(mask) {
|
||||
return createInlineThumbnail(mask.canvas, `${mask.name || 'Mask'} preview`, 'ge-mask-inline-thumb');
|
||||
return createInlineThumbnail(() => mask.canvas, `${mask.name || 'Mask'} preview`, 'ge-mask-inline-thumb');
|
||||
}
|
||||
|
||||
function applyLayerMask(layer, mask) {
|
||||
@@ -1152,8 +1194,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
// Retained text/shape layers keep their editable metadata separately
|
||||
// from the raster canvas. Use the normal renderer when available so
|
||||
// their layer previews match what the document actually displays.
|
||||
const previewCanvas = renderLayer?.(layer) || layer.canvas;
|
||||
const thumb = createInlineThumbnail(previewCanvas, `${layer.name} preview`);
|
||||
const thumb = createInlineThumbnail(() => renderLayer?.(layer) || layer.canvas, `${layer.name} preview`);
|
||||
item.appendChild(thumb);
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
@@ -2062,6 +2103,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
// shortcuts so undo, confirmations, and layer normalization stay aligned.
|
||||
return {
|
||||
render,
|
||||
refreshPreviews,
|
||||
deleteSelectedLayers: () => deleteLayers(selectedLayers(state)),
|
||||
duplicateActiveLayer: () => {
|
||||
const button = [...document.querySelectorAll('button[title="Duplicate layer"]')]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function selectionModeForEvent(event, fallback = 'replace') {
|
||||
if (event.shiftKey && event.altKey) return 'intersect';
|
||||
if (event.shiftKey) return 'add';
|
||||
if (event.altKey) return 'subtract';
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Shared by the palette, dispatch map and shortcut reference.
|
||||
export const TOOL_SHORTCUTS = Object.freeze({
|
||||
move: 'V', hand: 'H', crop: 'C', text: 'T', shape: 'U',
|
||||
brush: 'B', gradient: 'G', eraser: 'E', eyedropper: 'I',
|
||||
smudge: 'Y', clone: 'S', heal: 'J', dodge: 'O',
|
||||
marquee: 'M', lasso: 'L', wand: 'W',
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { buildLassoMask } from './lasso-mask.js';
|
||||
|
||||
@@ -23,9 +24,7 @@ export function createLassoTool({
|
||||
let pendingMode = 'replace';
|
||||
return {
|
||||
begin(e) {
|
||||
pendingMode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) pendingMode = 'add';
|
||||
else if (e.altKey) pendingMode = 'subtract';
|
||||
pendingMode = selectionModeForEvent(e, state.wandMode || 'replace');
|
||||
state.lassoPoints = [];
|
||||
state.lassoActive = true;
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Rectangle and ellipse marquee interaction producing a document-space mask. */
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import {
|
||||
createMarqueeMask,
|
||||
@@ -60,9 +61,7 @@ export function createMarqueeTool({
|
||||
gesture.begin(e, { mode: 'move' }, { captureTarget: e.currentTarget });
|
||||
return;
|
||||
}
|
||||
pendingMode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) pendingMode = 'add';
|
||||
else if (e.altKey) pendingMode = 'subtract';
|
||||
pendingMode = selectionModeForEvent(e, state.wandMode || 'replace');
|
||||
state.marqueeStart = coords;
|
||||
state.marqueeRect = normalizeConstrainedSelectionRect(
|
||||
coords,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from '../state.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
|
||||
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand, deselectSelection }) {
|
||||
@@ -28,9 +29,7 @@ export function createWandTool({ activeLayer, saveState, composite, wandHits, ru
|
||||
const coords = canvasCoords(e, state.mainCanvas);
|
||||
// Persistent toggle sets the default mode; Shift forces add, Alt
|
||||
// forces subtract regardless of the toggle (modifiers always win).
|
||||
let mode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) mode = 'add';
|
||||
else if (e.altKey) mode = 'subtract';
|
||||
const mode = selectionModeForEvent(e, state.wandMode || 'replace');
|
||||
// Click INSIDE the existing selection with no modifier → deselect.
|
||||
if (mode === 'replace' && wandHits(coords.x, coords.y)) {
|
||||
if (deselectSelection) {
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
let disposeTopbar;
|
||||
|
||||
export function wireTopbarOverflow({ container }) {
|
||||
disposeTopbar?.();
|
||||
const cleanups = [];
|
||||
disposeTopbar = () => cleanups.forEach(cleanup => cleanup());
|
||||
// Canvas-size badge updater (kept simple — it lives in the topbar).
|
||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||
function updateSizeLabel() {
|
||||
@@ -30,33 +35,53 @@ export function wireTopbarOverflow({ container }) {
|
||||
container.querySelector('#ge-ai-model'),
|
||||
...container.querySelectorAll('.ge-topbar span[style*="font-size:9px"]'),
|
||||
].filter(Boolean);
|
||||
let lastWidth = 0;
|
||||
|
||||
// Native popovers keep the existing DOM/event ownership while escaping
|
||||
// the toolbar's horizontal scroll clip and the editor's stacking context.
|
||||
topbar?.querySelectorAll('.dropdown[hidden]').forEach(menu => {
|
||||
if (!menu.showPopover) return;
|
||||
const anchor = menu.parentElement.querySelector('button');
|
||||
if (!anchor) return;
|
||||
menu.setAttribute('popover', 'manual');
|
||||
const position = () => {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
menu.style.top = `${rect.bottom + 4}px`;
|
||||
menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
|
||||
menu.style.left = 'auto';
|
||||
};
|
||||
const sync = () => {
|
||||
if (menu.hidden) {
|
||||
if (menu.matches(':popover-open')) menu.hidePopover();
|
||||
} else if (menu.isConnected) {
|
||||
position();
|
||||
if (!menu.matches(':popover-open')) menu.showPopover();
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(sync);
|
||||
observer.observe(menu, { attributes: true, attributeFilter: ['hidden'] });
|
||||
topbar.addEventListener('scroll', position, { passive: true });
|
||||
window.addEventListener('resize', position);
|
||||
cleanups.push(() => {
|
||||
observer.disconnect();
|
||||
topbar.removeEventListener('scroll', position);
|
||||
window.removeEventListener('resize', position);
|
||||
if (menu.matches(':popover-open')) menu.hidePopover();
|
||||
});
|
||||
});
|
||||
|
||||
function syncOverflow() {
|
||||
if (!topbar) return;
|
||||
// The reflow changes the topbar height but not its width. Ignore that
|
||||
// follow-up ResizeObserver notification so the class does not oscillate.
|
||||
const width = topbar.clientWidth;
|
||||
if (width === lastWidth && topbar.classList.contains('ge-topbar-overflow')) return;
|
||||
lastWidth = width;
|
||||
topbar.classList.remove('ge-topbar-overflow');
|
||||
aiGroup.forEach(el => { el.style.display = ''; });
|
||||
if (topbar.scrollWidth > topbar.clientWidth) {
|
||||
// Hide AI group first — bulky and least essential at narrow widths.
|
||||
aiGroup.forEach(el => { el.style.display = 'none'; });
|
||||
// If the essential controls still do not fit, make the right side a
|
||||
// second row instead of letting Save and its menu fall outside the
|
||||
// editor window.
|
||||
const isMobile = window.matchMedia?.('(max-width: 700px)').matches;
|
||||
if (!isMobile && topbar.scrollWidth > topbar.clientWidth) {
|
||||
topbar.classList.add('ge-topbar-overflow');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (topbar && window.ResizeObserver) {
|
||||
const ro = new ResizeObserver(() => syncOverflow());
|
||||
ro.observe(topbar);
|
||||
cleanups.push(() => ro.disconnect());
|
||||
}
|
||||
// Initial pass after layout settles.
|
||||
requestAnimationFrame(syncOverflow);
|
||||
|
||||
@@ -150,7 +150,7 @@ export function wireTopbar(deps) {
|
||||
});
|
||||
|
||||
// Edge popup — Width input + Feather / Delete action buttons.
|
||||
function applyEdgeAction(hardDelete) {
|
||||
async function applyEdgeAction(hardDelete) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || isLayerPixelLocked(state, layer) || isLayerTransparencyLocked(state, layer)) {
|
||||
uiModule.showToast('Unlock image and transparent pixels before changing edges');
|
||||
@@ -159,8 +159,7 @@ export function wireTopbar(deps) {
|
||||
const widthInput = document.getElementById('ge-edge-width');
|
||||
const width = parseInt(widthInput?.value || '8');
|
||||
if (isNaN(width) || width < 1) { uiModule.showToast('Invalid width'); return; }
|
||||
saveState();
|
||||
applyEdgeFeather(layer, width, hardDelete);
|
||||
if (!await applyEdgeFeather(layer, width, hardDelete)) return;
|
||||
composite();
|
||||
uiModule.showToast(hardDelete ? `Edges deleted ${width}px` : `Edges feathered ${width}px`);
|
||||
}
|
||||
|
||||
+21
-17
@@ -5,7 +5,7 @@
|
||||
|
||||
import spinnerModule from './spinner.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260910replyactions1';
|
||||
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260915trashmove2';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
|
||||
@@ -50,7 +50,7 @@ function _withoutMyAddresses(raw, myAddresses) {
|
||||
function _openCalendarEventFromEmail(uid) {
|
||||
const target = String(uid || '').trim();
|
||||
if (!target) return;
|
||||
import('./calendar.js?v=20260903weekscrollstable1').then(mod => {
|
||||
import('./calendar.js?v=20260914emailsource11').then(mod => {
|
||||
const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo);
|
||||
if (open) open(target);
|
||||
}).catch(() => {});
|
||||
@@ -818,7 +818,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
if (!isCurrentOpen()) return;
|
||||
if (data.error) {
|
||||
console.error('Failed to read email:', data.error);
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('Could not load email: ' + data.error)).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Could not load email: ' + data.error)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// The list row is already populated from the durable email index. Some
|
||||
@@ -852,7 +852,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
|
||||
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
|
||||
} else {
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showToast && m.showToast('Writing AI reply', {
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showToast && m.showToast('Writing AI reply', {
|
||||
duration: 8000,
|
||||
leadingIcon: 'spinner',
|
||||
aiReplyProgress: true,
|
||||
@@ -877,7 +877,11 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
uid: String(em.uid || ''),
|
||||
folder: folderAtStart,
|
||||
account_id: activeReplyAccount,
|
||||
fast: true,
|
||||
// The regular AI Reply action should use the full reply path.
|
||||
// Keep the explicit fast variant available for callers that
|
||||
// still request it, but do not silently downgrade normal
|
||||
// replies to the short-context generation budget.
|
||||
fast: aiReplyMode === 'fast',
|
||||
user_hint: (noteHint || '').trim() || undefined,
|
||||
}),
|
||||
});
|
||||
@@ -894,13 +898,13 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
? 'AI returned empty response.'
|
||||
: _rawMsg;
|
||||
console.error('AI reply generation failed:', _msg);
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isCurrentOpen()) return;
|
||||
console.error('AI reply generation failed:', e);
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1075,7 +1079,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
if (!isCurrentOpen()) return;
|
||||
if (!activeSid) {
|
||||
console.error('reply: could not obtain a session_id');
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1109,7 +1113,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
// import pattern the rest of this file uses. (Previously this
|
||||
// referenced a bare `uiModule`, throwing a ReferenceError that
|
||||
// the outer catch swallowed → reply silently did nothing.)
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('Failed to create reply draft (' + docRes.status + ')')).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Failed to create reply draft (' + docRes.status + ')')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const doc = await docRes.json();
|
||||
@@ -1143,7 +1147,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
// look like "nothing happened". Dynamic import — uiModule isn't a
|
||||
// static import in this file.
|
||||
const msg = e && e.message ? e.message : String(e);
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('Reply failed: ' + msg)).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Reply failed: ' + msg)).catch(() => {});
|
||||
} finally {
|
||||
if (spinner) { spinner.destroy(); spinner.element.remove(); }
|
||||
if (itemEl) {
|
||||
@@ -1290,7 +1294,7 @@ async function _createReplyReminder(em, dueDate) {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
const { showToast } = await import('./ui.js?v=20260908weekhoverfix1');
|
||||
const { showToast } = await import('./ui.js?v=20260916largetoolscroll1');
|
||||
const fmt = dueDate.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
showToast(`Reminder set for ${fmt}`);
|
||||
// Request notification permission if needed
|
||||
@@ -1298,7 +1302,7 @@ async function _createReplyReminder(em, dueDate) {
|
||||
try { Notification.requestPermission(); } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
const { showError } = await import('./ui.js?v=20260908weekhoverfix1');
|
||||
const { showError } = await import('./ui.js?v=20260916largetoolscroll1');
|
||||
showError('Failed to create reminder');
|
||||
}
|
||||
}
|
||||
@@ -1320,18 +1324,18 @@ async function _spamEmail(em) {
|
||||
if (!res.ok || data.success === false) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
_emails = _emails.filter(e => e.uid !== em.uid);
|
||||
_renderList();
|
||||
const { showToast } = await import('./ui.js?v=20260908weekhoverfix1');
|
||||
const { showToast } = await import('./ui.js?v=20260916largetoolscroll1');
|
||||
showToast('Moved to Spam');
|
||||
} catch (e) {
|
||||
console.error('Failed to mark as spam:', e);
|
||||
const { showError } = await import('./ui.js?v=20260908weekhoverfix1');
|
||||
const { showError } = await import('./ui.js?v=20260916largetoolscroll1');
|
||||
showError('Failed to move email to Spam');
|
||||
}
|
||||
}
|
||||
|
||||
async function _deleteEmail(em) {
|
||||
const subject = em.subject || '(no subject)';
|
||||
const { styledConfirm } = await import('./ui.js?v=20260908weekhoverfix1');
|
||||
const { styledConfirm } = await import('./ui.js?v=20260916largetoolscroll1');
|
||||
const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true });
|
||||
if (!ok) return;
|
||||
const row = document.querySelector(`.email-item[data-uid="${CSS.escape(String(em.uid))}"]`);
|
||||
@@ -1468,7 +1472,7 @@ async function _composeNew() {
|
||||
let sid = await _createEmailChat({ subject: 'New Email' });
|
||||
if (!sid) {
|
||||
console.error('compose: could not obtain a session_id');
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('Could not start a new email (no session).')).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Could not start a new email (no session).')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const createComposeDoc = (sessionId) => fetch(`${API_BASE}/api/document`, {
|
||||
@@ -1489,7 +1493,7 @@ async function _composeNew() {
|
||||
}
|
||||
if (!res.ok) {
|
||||
console.error('compose POST failed', res.status, await res.text().catch(() => ''));
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('Failed to create new email (' + res.status + ')')).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Failed to create new email (' + res.status + ')')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const doc = await res.json();
|
||||
|
||||
+94
-35
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import spinnerModule from './spinner.js';
|
||||
import { styledConfirm, showToast, emptyStateIcon } from './ui.js?v=20260908weekhoverfix1';
|
||||
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260903emailsend2';
|
||||
import { styledConfirm, showToast, emptyStateIcon } from './ui.js?v=20260916largetoolscroll1';
|
||||
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260914aireply4';
|
||||
import settingsModule from './settings.js?v=20260909defaultmodelfix1';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
@@ -997,7 +997,7 @@ function _showEmailReaderLoadError(reader, message, onRetry) {
|
||||
function _openCalendarEventFromEmail(uid) {
|
||||
const target = String(uid || '').trim();
|
||||
if (!target) return;
|
||||
import('./calendar.js?v=20260903weekscrollstable1').then(mod => {
|
||||
import('./calendar.js?v=20260914emailsource11').then(mod => {
|
||||
const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo);
|
||||
if (open) open(target);
|
||||
}).catch(() => {});
|
||||
@@ -1095,7 +1095,7 @@ function _loadedEmailsHaveVisibleTags() {
|
||||
|
||||
async function _openTasksForEmailTags() {
|
||||
try {
|
||||
const mod = await import('./tasks.js?v=20260901taskskilldensity1');
|
||||
const mod = await import('./tasks.js?v=20260914taskmodel1');
|
||||
const openTasks = mod.openTasks || mod.default?.openTasks;
|
||||
if (typeof openTasks === 'function') {
|
||||
openTasks(null, { filter: 'Email', focusAction: 'check_email_urgency' });
|
||||
@@ -1821,7 +1821,8 @@ async function _deleteEmailAndAdvance(em, card, opts = {}) {
|
||||
: null;
|
||||
const nextUid = sibling ? sibling.dataset.uid : null;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
|
||||
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
|
||||
} catch (err) {
|
||||
console.error('Failed to delete email:', err);
|
||||
busy?.remove?.();
|
||||
@@ -2061,6 +2062,23 @@ function _acct() {
|
||||
return state._libAccountId ? `&account_id=${encodeURIComponent(state._libAccountId)}` : '';
|
||||
}
|
||||
|
||||
function _emailMutationQuery(em, fallbackFolder = state._libFolder) {
|
||||
const folder = String(em?.folder || fallbackFolder || 'INBOX');
|
||||
const accountId = em?.account_id || state._libAccountId || '';
|
||||
const params = new URLSearchParams({ folder });
|
||||
if (accountId) params.set('account_id', String(accountId));
|
||||
if (em?.message_id) params.set('message_id', String(em.message_id));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
async function _requireSuccessfulEmailMutation(response, fallback = 'Email operation failed') {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || data?.success !== true) {
|
||||
throw new Error(data?.error || `${fallback} (${response.status})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function _unsubscribeMethodLabel(method) {
|
||||
if (!method) return 'No unsubscribe method';
|
||||
if (method.kind === 'mailto') return 'Request Unsubscribe';
|
||||
@@ -6991,7 +7009,7 @@ async function _loadScheduled(grid, sp) {
|
||||
cancelBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
cancelBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const { styledConfirm } = await import('./ui.js?v=20260908weekhoverfix1');
|
||||
const { styledConfirm } = await import('./ui.js?v=20260916largetoolscroll1');
|
||||
const ok = await styledConfirm(`Cancel scheduled email "${subject}"?`, { confirmText: 'Cancel Send', cancelText: 'Keep', danger: true });
|
||||
if (!ok) return;
|
||||
try {
|
||||
@@ -7872,7 +7890,7 @@ async function _toggleCardPreview(card, em) {
|
||||
});
|
||||
reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
_showReaderMoreMenu(em, card, reader, ev.currentTarget);
|
||||
_showReaderMoreMenu(em, card, reader, ev.currentTarget, data);
|
||||
});
|
||||
reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
@@ -8026,6 +8044,22 @@ function _renderEmailBody(data) {
|
||||
return _foldSignature(_escLinkify(plain).replace(/\n/g, '<br>'), null);
|
||||
}
|
||||
|
||||
// Prefer the normalized plain-text thread when the message contains
|
||||
// explicit reply markers. HTML mail from Outlook/Gmail commonly wraps the
|
||||
// same quoted message in several nested blockquotes; feeding that markup
|
||||
// to the HTML walker creates phantom chains such as "Earlier reply >
|
||||
// Earlier reply > sender". The plain representation has the actual quote
|
||||
// levels and gives us one stable fold per real quoted message.
|
||||
const hasPlainThreadMarkers = plain && (
|
||||
/^\s*>/m.test(plain)
|
||||
|| /^\s*-{5,}\s*(?:Previous message|Original message)\s*-{5,}\s*$/im.test(plain)
|
||||
|| /^\s*On\s.+?\s(?:wrote|skrev|schrieb|écrit|escribió)\s*:\s*$/im.test(plain)
|
||||
);
|
||||
if (hasPlainThreadMarkers) {
|
||||
const plainThread = _renderPlaintextThread(plain);
|
||||
if (plainThread) return _foldSignature(plainThread, data && data.sender_signature || null);
|
||||
}
|
||||
|
||||
// Prefer the server-cached thread parse — that's the richest structure
|
||||
// and the one the chat-bubble layout is built around. Skip when the user
|
||||
// has manually disabled bubble rendering.
|
||||
@@ -8893,7 +8927,7 @@ function _wireAttachmentHandlers(reader, folder) {
|
||||
try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {}
|
||||
} catch (e) {
|
||||
console.error('attachments zip download error', e);
|
||||
try { const { showError } = await import('./ui.js?v=20260908weekhoverfix1'); showError('Could not download attachments'); } catch (_) {}
|
||||
try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError('Could not download attachments'); } catch (_) {}
|
||||
} finally {
|
||||
delete btn.dataset.downloading;
|
||||
btn.classList.remove('is-loading');
|
||||
@@ -8936,12 +8970,16 @@ function _wireAttachmentHandlers(reader, folder) {
|
||||
if (!importRes.ok || !result.ok) {
|
||||
throw new Error(result.detail || result.error || `HTTP ${importRes.status}`);
|
||||
}
|
||||
const existingEventUid = Array.isArray(result.event_uids) ? String(result.event_uids[0] || '').trim() : '';
|
||||
if (existingEventUid && Number(result.imported || 0) === 0 && Number(result.skipped || 0) > 0) {
|
||||
_openCalendarEventFromEmail(existingEventUid);
|
||||
}
|
||||
try { uiModule.showToast && uiModule.showToast(`${result.imported || 0} event${result.imported === 1 ? '' : 's'} added to ${result.calendar || 'calendar'}`); } catch (_) {}
|
||||
window.dispatchEvent(new CustomEvent('calendar-refresh'));
|
||||
} catch (e) {
|
||||
console.error('calendar attachment import failed', e);
|
||||
try {
|
||||
const { showError } = await import('./ui.js?v=20260908weekhoverfix1');
|
||||
const { showError } = await import('./ui.js?v=20260916largetoolscroll1');
|
||||
showError(`Couldn't add ${name} to calendar: ${e?.message || 'Import failed'}`);
|
||||
} catch (_) {}
|
||||
} finally {
|
||||
@@ -8986,7 +9024,7 @@ function _wireAttachmentHandlers(reader, folder) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.doc_id) {
|
||||
const msg = (json && json.error) || `HTTP ${res.status}`;
|
||||
try { const { showError } = await import('./ui.js?v=20260908weekhoverfix1'); showError(`Couldn't open ${name}: ${msg}`); } catch (_) { alert(`Couldn't open ${name}: ${msg}`); }
|
||||
try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError(`Couldn't open ${name}: ${msg}`); } catch (_) { alert(`Couldn't open ${name}: ${msg}`); }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -9002,7 +9040,7 @@ function _wireAttachmentHandlers(reader, folder) {
|
||||
ownerModal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
const docMod = await import('./document.js?v=20260911removealignrightshortcut1');
|
||||
const docMod = await import('./document.js?v=20260916docctx2');
|
||||
const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
|
||||
if (typeof load === 'function') {
|
||||
await load(json.doc_id);
|
||||
@@ -9011,14 +9049,14 @@ function _wireAttachmentHandlers(reader, folder) {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Open document failed:', e);
|
||||
try { const { showError } = await import('./ui.js?v=20260908weekhoverfix1'); showError('Document opened but panel could not mount'); } catch (_) {}
|
||||
try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError('Document opened but panel could not mount'); } catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('attachment-as-doc error', e);
|
||||
const msg = e && e.name === 'AbortError'
|
||||
? `Opening ${name} timed out. Try downloading it instead.`
|
||||
: `Couldn't open ${name}`;
|
||||
try { const { showError } = await import('./ui.js?v=20260908weekhoverfix1'); showError(msg); } catch (_) {}
|
||||
try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError(msg); } catch (_) {}
|
||||
} finally {
|
||||
delete openBtn.dataset.opening;
|
||||
openBtn.classList.remove('is-loading');
|
||||
@@ -9052,7 +9090,7 @@ function _wireAttachmentHandlers(reader, folder) {
|
||||
if (chip.dataset.wired === '1') return;
|
||||
chip.dataset.wired = '1';
|
||||
chip.addEventListener('click', async (ev) => {
|
||||
if (ev.target.closest('.email-attachment-open')) return;
|
||||
if (ev.target.closest('.email-attachment-open, .email-attachment-calendar-open, .email-attachment-download')) return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const uid = chip.dataset.attUid;
|
||||
@@ -9629,7 +9667,7 @@ async function _openEmailAsTab(em, folder) {
|
||||
_wireReaderActionOverflow(reader);
|
||||
reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget); } catch {}
|
||||
try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget, data); } catch {}
|
||||
});
|
||||
} catch (err) {
|
||||
showFailedTab(err?.message ? `Failed to load email: ${err.message}` : 'Failed to load email');
|
||||
@@ -9791,7 +9829,7 @@ async function _openEmailWindow(em, folder) {
|
||||
// element and the email data. The card param is mostly used to find
|
||||
// the next sibling; the standalone window has none so we just pass
|
||||
// bodyEl as a stand-in.
|
||||
try { _showReaderMoreMenu(em, modal, bodyEl, ev.currentTarget); } catch {}
|
||||
try { _showReaderMoreMenu(em, modal, bodyEl, ev.currentTarget, data); } catch {}
|
||||
});
|
||||
} catch (err) {
|
||||
bodyEl.innerHTML = `<div style="color:var(--red,#e55);padding:16px;">Failed to load: ${_esc(String(err))}</div>`;
|
||||
@@ -10052,9 +10090,9 @@ function _fitReaderActions(meta) {
|
||||
const compact = buttonsWidth > Math.max(190, meta.clientWidth - 150);
|
||||
row.classList.toggle('email-reader-actions-compact', compact);
|
||||
if (compact) {
|
||||
// Reply is the primary reader action and must survive compact layouts.
|
||||
// Move the optional AI and Reply All variants into More first.
|
||||
row.querySelectorAll('[data-act="ai-reply"], [data-act="reply-all"]')
|
||||
// Keep the two common drafting actions visible on narrow screens. Put
|
||||
// the less frequent recipient variants in More first.
|
||||
row.querySelectorAll('[data-act="reply-all"], [data-act="forward"]')
|
||||
.forEach(button => button.classList.add('reader-action-overflowed'));
|
||||
}
|
||||
}
|
||||
@@ -10072,7 +10110,7 @@ function _wireReaderActionOverflow(reader) {
|
||||
_readerActionFitObserver?.observe(meta);
|
||||
}
|
||||
|
||||
function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
function _showReaderMoreMenu(em, card, reader, anchor, data) {
|
||||
// Toggle: if a dropdown for THIS anchor is already open, close it.
|
||||
const existing = document.querySelector('.email-card-dropdown');
|
||||
if (existing && existing._anchor === anchor) {
|
||||
@@ -10131,7 +10169,17 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
const overflowActions = Array.from(reader.querySelectorAll('.reader-action-overflowed')).map(button => ({
|
||||
label: button.querySelector('.reader-btn-label')?.textContent?.trim() || button.title || 'Action',
|
||||
icon: button.querySelector('svg')?.outerHTML || '',
|
||||
action: () => button.click(),
|
||||
button,
|
||||
action: (positionAnchor) => {
|
||||
if (button.dataset.act === 'ai-reply') {
|
||||
_handleAiReplyButton({
|
||||
currentTarget: button,
|
||||
stopPropagation() {},
|
||||
}, em, data, positionAnchor || button);
|
||||
} else {
|
||||
button.click();
|
||||
}
|
||||
},
|
||||
}));
|
||||
const actions = [
|
||||
...overflowActions,
|
||||
@@ -10231,7 +10279,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
action: async () => {
|
||||
const email = (em.from_address || em.from || '').trim();
|
||||
if (!email) {
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('No sender address')).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('No sender address')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const name = (em.from_name || '').trim() || email.split('@')[0];
|
||||
@@ -10242,14 +10290,14 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
body: JSON.stringify({ name, email }),
|
||||
});
|
||||
const d = await r.json();
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => {
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => {
|
||||
if (!m.showToast) return;
|
||||
if (d.success && d.message === 'Already exists') m.showToast('Already in contacts');
|
||||
else if (d.success) m.showToast('Saved to contacts');
|
||||
else m.showError && m.showError('Failed to save contact');
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
import('./ui.js?v=20260908weekhoverfix1').then(m => m.showError && m.showError('Failed to save contact')).catch(() => {});
|
||||
import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Failed to save contact')).catch(() => {});
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -10271,7 +10319,8 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
const busy = _showEmailDeleteOverlay(card);
|
||||
await busy?.ready;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
|
||||
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
busy?.remove?.();
|
||||
@@ -10296,7 +10345,8 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
const busy = _showEmailDeleteOverlay(card);
|
||||
await busy?.ready;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/delete-permanent/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
const response = await fetch(`${API_BASE}/api/email/delete-permanent/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
|
||||
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
busy?.remove?.();
|
||||
@@ -10330,6 +10380,14 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
_showEmailTranslateSubmenu(reader, dropdown);
|
||||
return;
|
||||
}
|
||||
// A hidden overflowed button has a zero-sized client rect. Invoke the
|
||||
// AI chooser while the visible More item is still mounted, then close
|
||||
// the action menu after the chooser has captured its position.
|
||||
if (a.button?.dataset.act === 'ai-reply') {
|
||||
a.action(item);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
@@ -10518,11 +10576,12 @@ function _showCardMenu(em, anchor) {
|
||||
const busy = _showEmailDeleteOverlay(card);
|
||||
await busy?.ready;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
|
||||
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
|
||||
} catch (e) {
|
||||
busy?.remove?.();
|
||||
showToast('Failed to delete email');
|
||||
throw e;
|
||||
showToast(e?.message || 'Could not move email to Trash');
|
||||
return;
|
||||
}
|
||||
busy?.remove?.();
|
||||
await _animateEmailCardRemoval([em.uid]);
|
||||
@@ -10953,9 +11012,9 @@ function _closeAiReplyChoice() {
|
||||
});
|
||||
}
|
||||
|
||||
function _showAiReplyChoice(btn, em, data) {
|
||||
function _showAiReplyChoice(btn, em, data, positionAnchor = btn) {
|
||||
_closeAiReplyChoice();
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const rect = positionAnchor.getBoundingClientRect();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'email-ai-reply-choice';
|
||||
/* Clamp width to viewport minus 16px margin so the menu never spills off
|
||||
@@ -11043,7 +11102,7 @@ function _showAiReplyChoice(btn, em, data) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _handleAiReplyButton(ev, em, data) {
|
||||
function _handleAiReplyButton(ev, em, data, positionAnchor = ev.currentTarget) {
|
||||
ev.stopPropagation();
|
||||
const btn = ev.currentTarget;
|
||||
// First click on a cached email surfaces the cached draft. Second
|
||||
@@ -11058,7 +11117,7 @@ function _handleAiReplyButton(ev, em, data) {
|
||||
data.cached_ai_reply = null;
|
||||
btn.dataset.shownOnce = '';
|
||||
}
|
||||
_showAiReplyChoice(btn, em, data);
|
||||
_showAiReplyChoice(btn, em, data, positionAnchor);
|
||||
}
|
||||
|
||||
function _hasMultipleRecipients(data) {
|
||||
@@ -11284,7 +11343,7 @@ async function _createEmailReplyReminder(em, dueDate, customText = '') {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
const { showToast } = await import('./ui.js?v=20260908weekhoverfix1');
|
||||
const { showToast } = await import('./ui.js?v=20260916largetoolscroll1');
|
||||
if (dueDate) {
|
||||
const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
|
||||
showToast(`Todo reminder set for ${fmt}`);
|
||||
@@ -11295,7 +11354,7 @@ async function _createEmailReplyReminder(em, dueDate, customText = '') {
|
||||
try { Notification.requestPermission(); } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
const { showError } = await import('./ui.js?v=20260908weekhoverfix1');
|
||||
const { showError } = await import('./ui.js?v=20260916largetoolscroll1');
|
||||
showError('Failed to create reminder');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* File attachment and upload handling
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from './spinner.js';
|
||||
|
||||
let pendingFiles = [];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Gallery Module — photo backup + AI-generated image library.
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { loadPanel } from './panels.js?v=20260909movepicklayer1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
|
||||
+252
-168
@@ -2,7 +2,7 @@
|
||||
* Gallery Editor — canvas-based image editor with layers, brush, eraser, text, crop, inpaint mask.
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import dragSortModule from './dragSort.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
@@ -244,6 +244,19 @@ function _galleryEditMounted() {
|
||||
if (!window.__galleryEditEscHardGuardInstalled) {
|
||||
window.__galleryEditEscHardGuardInstalled = true;
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (_activeFilterPrompt) {
|
||||
_activeFilterPrompt.handleKey(e);
|
||||
return;
|
||||
}
|
||||
if (_rasterizePromptPending && (e.key === 'Escape' || e.key === 'Enter')) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const target = e.key === 'Escape' ? 'styled-confirm-cancel'
|
||||
: (e.target?.id === 'styled-confirm-cancel' ? 'styled-confirm-cancel' : 'styled-confirm-ok');
|
||||
document.getElementById(target)?.click();
|
||||
return;
|
||||
}
|
||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||
const isSamCancel = !!_samAbortController
|
||||
&& (e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'c'));
|
||||
if (isSamCancel) {
|
||||
@@ -1498,8 +1511,13 @@ function _refreshSelectionOverlay() {
|
||||
else _ensureSelectionAnimation();
|
||||
}
|
||||
|
||||
let _layerPreviewTimer;
|
||||
function _finishComposite(render, documentCanvas) {
|
||||
if (!render.isCurrent()) return;
|
||||
clearTimeout(_layerPreviewTimer);
|
||||
_layerPreviewTimer = setTimeout(() => {
|
||||
if (render.isCurrent() && !state.drawing) _layerPanelRenderer.refreshPreviews();
|
||||
}, 100);
|
||||
state.documentRenderReady = true;
|
||||
if (!state.compareBaselineCanvas && !state.compareActive && documentCanvas.width && documentCanvas.height) {
|
||||
state.compareBaselineCanvas = document.createElement('canvas');
|
||||
@@ -1930,9 +1948,19 @@ function _writeActiveEditorSession() {
|
||||
function _setDraftStatus(label, stateName = '') {
|
||||
const el = document.getElementById('ge-draft-status');
|
||||
if (!el) return;
|
||||
el.textContent = label;
|
||||
const normalizedState = stateName === 'saved' ? 'saved' : 'dirty';
|
||||
const normalizedLabel = normalizedState === 'saved' ? 'Saved' : 'Unsaved';
|
||||
el.textContent = normalizedLabel;
|
||||
el.dataset.state = stateName;
|
||||
el.title = stateName === 'error' ? 'Draft autosave needs attention' : `Draft status: ${label}`;
|
||||
const iconHost = el.closest('#ge-save-menu-btn');
|
||||
iconHost?.querySelectorAll('.ge-save-state-icon').forEach(icon => {
|
||||
icon.hidden = !icon.classList.contains(`ge-save-state-${normalizedState}`);
|
||||
});
|
||||
const saveButton = document.getElementById('ge-save-menu-btn');
|
||||
if (saveButton) {
|
||||
saveButton.title = normalizedState === 'error' ? 'Draft autosave needs attention' : `Draft status: ${normalizedLabel}`;
|
||||
saveButton.setAttribute('aria-label', normalizedLabel);
|
||||
}
|
||||
}
|
||||
|
||||
function _clearActiveEditorSession() {
|
||||
@@ -2801,6 +2829,41 @@ const _refreshHistoryPanelIfOpen = _historyPanel.refreshHistoryPanelIfOpen;
|
||||
|
||||
// ── Drawing ──
|
||||
|
||||
let _rasterizePromptPending = false;
|
||||
const _pixelPaintTools = new Set(['brush', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'gradient']);
|
||||
|
||||
async function _offerRasterizeForTool(tool = state.tool) {
|
||||
const layer = activeLayer() || _activeParentLayer();
|
||||
if (!_pixelPaintTools.has(tool) || _getActiveMaskLayer() || state.quickMaskActive ||
|
||||
!['text', 'shape', 'placed'].includes(layer?.kind)) return false;
|
||||
return _confirmRasterizeLayer(layer);
|
||||
}
|
||||
|
||||
async function _confirmRasterizeLayer(layer) {
|
||||
if (_rasterizePromptPending || _isLayerPixelLocked(state, layer)) return false;
|
||||
_rasterizePromptPending = true;
|
||||
try {
|
||||
const accepted = await uiModule.styledConfirm(
|
||||
`Rasterize "${layer.name || layer.kind}" to edit its pixels? You can undo this change.`,
|
||||
{ title: 'Rasterize layer', confirmText: 'Rasterize', cancelText: 'Cancel' },
|
||||
);
|
||||
// The document or active target may have changed while the dialog was open.
|
||||
if (!accepted || !state.editorOpen || !state.layers.includes(layer) ||
|
||||
(activeLayer() || _activeParentLayer()) !== layer || _getActiveMaskLayer() ||
|
||||
_isLayerPixelLocked(state, layer)) return false;
|
||||
_saveState(`Rasterize "${layer.name}"`);
|
||||
_rasterizeTextLayer(layer);
|
||||
_rasterizeShapeLayer(layer);
|
||||
_rasterizePlacedLayer(layer);
|
||||
composite();
|
||||
_renderLayerPanel();
|
||||
_schedulePersist();
|
||||
} finally {
|
||||
_rasterizePromptPending = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _beginDraw(e) {
|
||||
// Move always follows the object under the pointer. Selecting it before the
|
||||
// drag starts keeps the active layer, layer panel, and dragged pixels aligned.
|
||||
@@ -2817,6 +2880,11 @@ function _beginDraw(e) {
|
||||
// Fall back to the parent resolver so a stale activeLayerId doesn't
|
||||
// block strokes when there ARE layers present.
|
||||
const layer = activeLayer() || _activeParentLayer();
|
||||
if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&
|
||||
['text', 'shape', 'placed'].includes(layer?.kind)) {
|
||||
void _offerRasterizeForTool();
|
||||
return;
|
||||
}
|
||||
// Transform-tool drag (handle grab or move-fallback) — handler in
|
||||
// editor/tools/transform-drag.js.
|
||||
if (_transformDragTool.tryBegin(e)) return;
|
||||
@@ -2830,10 +2898,6 @@ function _beginDraw(e) {
|
||||
if (state.tool === 'eyedropper') return _eyedropperTool.pick(e);
|
||||
if (state.tool === 'gradient') return _gradientTool.begin(e);
|
||||
if (state.tool === 'marquee') return _marqueeTool.begin(e);
|
||||
if (['text', 'shape'].includes(layer?.kind) && ['brush', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'gradient'].includes(state.tool) && !_getActiveMaskLayer()) {
|
||||
uiModule.showToast(`Rasterize the ${layer.kind} layer before painting on its pixels`);
|
||||
return;
|
||||
}
|
||||
// Inpaint can create its own layer + mask on the fly, so skip the
|
||||
// "no active layer → bail" gate for it specifically.
|
||||
const activeMask = _getActiveMaskLayer();
|
||||
@@ -4610,26 +4674,97 @@ function _hasMaskPixels() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function _canMutateLayerPixels(layer, action = 'editing pixels') {
|
||||
if (!layer || !['placed', 'text', 'shape'].includes(layer.kind)) return true;
|
||||
uiModule?.showToast(`Rasterize ${layer.name || `the ${layer.kind} layer`} before ${action}`);
|
||||
return false;
|
||||
async function _canMutateLayerPixels(layer, action = 'editing pixels') {
|
||||
if (!layer) return false;
|
||||
if (layer.kind === 'adjustment') {
|
||||
uiModule?.showToast(`Select a pixel layer or mask before ${action}`);
|
||||
return false;
|
||||
}
|
||||
if (!['placed', 'text', 'shape'].includes(layer.kind)) return true;
|
||||
return _confirmRasterizeLayer(layer);
|
||||
}
|
||||
|
||||
function _wandDeleteSelection({ saveHistory = true, message = 'Selection deleted' } = {}) {
|
||||
if (!state.wandMask) return;
|
||||
const layer = activeLayer();
|
||||
if (!layer || _isLayerPixelLocked(state, layer) || _isLayerTransparencyLocked(state, layer)) {
|
||||
uiModule?.showToast('Unlock image and transparent pixels before erasing');
|
||||
return;
|
||||
function _readPixelTarget() {
|
||||
const parent = activeLayer();
|
||||
const mask = _getActiveMaskLayer();
|
||||
const surface = mask || parent;
|
||||
if (!surface?.canvas) return null;
|
||||
const parentOffset = state.layerOffsets.get(parent?.id) || { x: 0, y: 0 };
|
||||
const offset = mask
|
||||
? (mask.mode === 'layer' && mask.space !== 'document'
|
||||
? { x: parentOffset.x + (mask.offset?.x || 0), y: parentOffset.y + (mask.offset?.y || 0) }
|
||||
: { x: 0, y: 0 })
|
||||
: parentOffset;
|
||||
return { parent, mask, canvas: surface.canvas, ctx: surface.ctx || surface.canvas.getContext('2d'), offset };
|
||||
}
|
||||
|
||||
async function _preparePixelTarget(action, { erase = false } = {}) {
|
||||
const parent = activeLayer();
|
||||
const mask = _getActiveMaskLayer();
|
||||
const group = (state.layerGroups || []).find(item => item.id === state.activeGroupId);
|
||||
const ownerLocked = group && mask?.mode === 'group'
|
||||
? group.locked || _groupAncestors(state, group).some(item => item.locked)
|
||||
: _isLayerEffectivelyLocked(state, parent);
|
||||
if (!parent && !mask) { uiModule?.showToast('Select a layer or mask first'); return null; }
|
||||
if (ownerLocked || mask?.locked || (!mask && _isLayerPixelLocked(state, parent))) {
|
||||
uiModule?.showToast('Unlock the selected layer or mask first'); return null;
|
||||
}
|
||||
if (!_canMutateLayerPixels(layer, 'erasing pixels')) return;
|
||||
if (!mask && erase && _isLayerTransparencyLocked(state, parent)) {
|
||||
uiModule?.showToast('Unlock transparent pixels before erasing'); return null;
|
||||
}
|
||||
if (!mask && !await _canMutateLayerPixels(parent, action)) return null;
|
||||
if (activeLayer() !== parent || _getActiveMaskLayer() !== mask) return null;
|
||||
return _readPixelTarget();
|
||||
}
|
||||
|
||||
function _captureSelectedPixels(target) {
|
||||
const selection = _selectionMaskAsDocument({ materializeLasso: true });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = target.canvas.width;
|
||||
canvas.height = target.canvas.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(target.canvas, 0, 0);
|
||||
if (selection) {
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.drawImage(_selectionMaskForLayer(selection, 'document', target.offset, canvas.width, canvas.height), 0, 0);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function _copyPixelsToClipboard({ cut = false } = {}) {
|
||||
const target = cut ? await _preparePixelTarget('cutting pixels', { erase: true }) : _readPixelTarget();
|
||||
if (!target) return;
|
||||
const canvas = _captureSelectedPixels(target);
|
||||
state.internalClipboard = canvas;
|
||||
state.internalClipboardOffset = { ...target.offset };
|
||||
if (cut) {
|
||||
if (state.wandMask) await _wandDeleteSelection({ message: 'Selection cut' });
|
||||
else {
|
||||
_saveState('Cut pixels');
|
||||
target.ctx.clearRect(0, 0, target.canvas.width, target.canvas.height);
|
||||
composite();
|
||||
_renderLayerPanel();
|
||||
}
|
||||
}
|
||||
canvas.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
.then(() => uiModule?.showToast(cut ? 'Cut to clipboard' : 'Copied to clipboard'))
|
||||
.catch(() => uiModule?.showToast(cut ? 'Cut (editor only)' : 'Copied (editor only)'));
|
||||
} else uiModule?.showToast(cut ? 'Cut (editor only)' : 'Copied (editor only)');
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
async function _wandDeleteSelection({ saveHistory = true, message = 'Selection deleted' } = {}) {
|
||||
if (!state.wandMask) return;
|
||||
const selection = _selectionMaskAsDocument({ materializeLasso: true });
|
||||
const layer = await _preparePixelTarget('erasing pixels', { erase: true });
|
||||
if (!layer || !selection) return;
|
||||
if (saveHistory) _saveState();
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const layerMask = _selectionMaskForLayer(
|
||||
state.wandMask,
|
||||
state.wandMaskSpace || 'layer',
|
||||
off,
|
||||
selection,
|
||||
'document',
|
||||
layer.offset,
|
||||
layer.canvas.width,
|
||||
layer.canvas.height,
|
||||
);
|
||||
@@ -4639,35 +4774,26 @@ function _wandDeleteSelection({ saveHistory = true, message = 'Selection deleted
|
||||
layer.ctx.drawImage(layerMask, 0, 0);
|
||||
layer.ctx.restore();
|
||||
_deselectSelection({ saveHistory: false, remember: false });
|
||||
_renderLayerPanel();
|
||||
uiModule?.showToast(message);
|
||||
}
|
||||
|
||||
function _wandCopyToNewLayer({ saveHistory = true, activate = true, announce = true } = {}) {
|
||||
if (!state.wandMask) return;
|
||||
const src = activeLayer();
|
||||
if (!_selectionMaskAsDocument({ materializeLasso: true })) return;
|
||||
const src = _readPixelTarget();
|
||||
if (!src) return;
|
||||
if (saveHistory) _saveState();
|
||||
// Clip the source by the mask, put it on a new layer.
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = src.canvas.width;
|
||||
tmp.height = src.canvas.height;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.drawImage(src.canvas, 0, 0);
|
||||
tCtx.globalCompositeOperation = 'destination-in';
|
||||
const srcOff = state.layerOffsets.get(src.id) || { x: 0, y: 0 };
|
||||
tCtx.drawImage(_selectionMaskForLayer(
|
||||
state.wandMask,
|
||||
state.wandMaskSpace || 'layer',
|
||||
srcOff,
|
||||
src.canvas.width,
|
||||
src.canvas.height,
|
||||
), 0, 0);
|
||||
const newLayer = createLayer('Wand copy', src.canvas.width, src.canvas.height);
|
||||
const tmp = _captureSelectedPixels(src);
|
||||
const newLayer = createLayer('Selection', src.canvas.width, src.canvas.height);
|
||||
newLayer.ctx.drawImage(tmp, 0, 0);
|
||||
state.layerOffsets.set(newLayer.id, { ...srcOff });
|
||||
const idx = state.layers.findIndex(l => l.id === src.id);
|
||||
state.layerOffsets.set(newLayer.id, { ...src.offset });
|
||||
const idx = state.layers.findIndex(l => l.id === src.parent?.id);
|
||||
state.layers.splice(idx + 1, 0, newLayer);
|
||||
if (activate) state.activeLayerId = newLayer.id;
|
||||
if (activate) {
|
||||
state.activeLayerId = newLayer.id;
|
||||
state.selectedLayerIds = [newLayer.id];
|
||||
state.activeGroupId = null;
|
||||
}
|
||||
composite();
|
||||
_renderLayerPanel();
|
||||
_revealLayerPanel();
|
||||
@@ -4675,78 +4801,13 @@ function _wandCopyToNewLayer({ saveHistory = true, activate = true, announce = t
|
||||
return newLayer;
|
||||
}
|
||||
|
||||
function _lassoDeleteSelection() {
|
||||
const layer = activeLayer();
|
||||
if (!layer || state.lassoPoints.length < 3) return;
|
||||
if (_isLayerPixelLocked(state, layer) || _isLayerTransparencyLocked(state, layer)) {
|
||||
uiModule?.showToast('Unlock image and transparent pixels before erasing');
|
||||
return;
|
||||
}
|
||||
if (!_canMutateLayerPixels(layer, 'erasing pixels')) return;
|
||||
const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
|
||||
const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
|
||||
_saveState();
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
|
||||
const mask = _buildLassoMask(w, h, off.x, off.y, feather, grow);
|
||||
const maskData = mask.getContext('2d').getImageData(0, 0, w, h);
|
||||
const imgData = layer.ctx.getImageData(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
const maskVal = maskData.data[i * 4]; // red channel
|
||||
if (maskVal > 0) {
|
||||
const fade = maskVal / 255;
|
||||
imgData.data[i * 4 + 3] = Math.round(imgData.data[i * 4 + 3] * (1 - fade));
|
||||
}
|
||||
}
|
||||
layer.ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
uiModule.showToast('Selection deleted');
|
||||
async function _lassoDeleteSelection() {
|
||||
if (!_selectionMaskAsDocument({ materializeLasso: true })) return;
|
||||
return _wandDeleteSelection();
|
||||
}
|
||||
|
||||
function _lassoCopyToLayer() {
|
||||
const layer = activeLayer();
|
||||
if (!layer || state.lassoPoints.length < 3) return;
|
||||
const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
|
||||
const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
|
||||
_saveState();
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
|
||||
const mask = _buildLassoMask(w, h, off.x, off.y, feather, grow);
|
||||
// Keep the copied pixels in the source layer's coordinate space. A
|
||||
// document-sized layer at (0, 0) makes selections from moved layers jump
|
||||
// when the new layer becomes active.
|
||||
const newLayer = createLayer('Selection', w, h);
|
||||
state.layerOffsets.set(newLayer.id, { ...off });
|
||||
|
||||
// Copy layer pixels masked by the selection
|
||||
const srcData = layer.ctx.getImageData(0, 0, w, h);
|
||||
const maskData = mask.getContext('2d').getImageData(0, 0, w, h);
|
||||
const outData = newLayer.ctx.createImageData(w, h);
|
||||
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
const maskVal = maskData.data[i * 4];
|
||||
if (maskVal > 0) {
|
||||
const fade = maskVal / 255;
|
||||
outData.data[i * 4] = srcData.data[i * 4];
|
||||
outData.data[i * 4 + 1] = srcData.data[i * 4 + 1];
|
||||
outData.data[i * 4 + 2] = srcData.data[i * 4 + 2];
|
||||
outData.data[i * 4 + 3] = Math.round(srcData.data[i * 4 + 3] * fade);
|
||||
}
|
||||
}
|
||||
newLayer.ctx.putImageData(outData, 0, 0);
|
||||
|
||||
state.layers.push(newLayer);
|
||||
state.activeLayerId = newLayer.id;
|
||||
state.lassoPoints = [];
|
||||
_renderLayerPanel();
|
||||
_revealLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Selection copied to new layer');
|
||||
return _wandCopyToNewLayer();
|
||||
}
|
||||
|
||||
function _lassoToMask() {
|
||||
@@ -4796,9 +4857,20 @@ function _lassoToMask() {
|
||||
// with the final values; Cancel / Esc resolves with null. The caller
|
||||
// is responsible for snapshotting state BEFORE opening (so Cancel can
|
||||
// restore the layer's pixels).
|
||||
function _filterSliderPrompt(title, params, onPreview) {
|
||||
let _activeFilterPrompt = null;
|
||||
|
||||
function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
_activeFilterPrompt?.cancel();
|
||||
return new Promise((resolve) => {
|
||||
if (!state.container) { resolve(null); return; }
|
||||
const session = state.editorSessionToken;
|
||||
const parent = _activeParentLayer();
|
||||
const mask = _getActiveMaskLayer();
|
||||
const groupId = state.activeGroupId;
|
||||
const previousFocus = document.activeElement;
|
||||
const isCurrent = () => state.editorOpen && state.editorSessionToken === session
|
||||
&& _activeParentLayer() === parent && _getActiveMaskLayer() === mask
|
||||
&& state.activeGroupId === groupId;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'ge-filter-overlay';
|
||||
let rows = '';
|
||||
@@ -4817,7 +4889,7 @@ function _filterSliderPrompt(title, params, onPreview) {
|
||||
`;
|
||||
}
|
||||
overlay.innerHTML = `
|
||||
<div class="ge-filter-modal">
|
||||
<div class="ge-filter-modal" role="dialog" aria-modal="true" aria-label="${title}">
|
||||
<div class="ge-filter-modal-head">${title}</div>
|
||||
${rows}
|
||||
<div class="ge-filter-modal-actions">
|
||||
@@ -4833,6 +4905,7 @@ function _filterSliderPrompt(title, params, onPreview) {
|
||||
try { onPreview(values); } catch {}
|
||||
overlay.querySelectorAll('input[data-key]').forEach(inp => {
|
||||
inp.addEventListener('input', (e) => {
|
||||
if (!isCurrent()) { cleanup(null); return; }
|
||||
const k = e.target.dataset.key;
|
||||
const param = params.find(p => p.key === k);
|
||||
const v = param?.type === 'color' ? e.target.value : parseFloat(e.target.value);
|
||||
@@ -4842,16 +4915,38 @@ function _filterSliderPrompt(title, params, onPreview) {
|
||||
try { onPreview(values); } catch {}
|
||||
});
|
||||
});
|
||||
let settled = false;
|
||||
const cleanup = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const current = isCurrent();
|
||||
if (result === null || !current) onCancel?.();
|
||||
observer.disconnect();
|
||||
try { overlay.remove(); } catch {}
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
resolve(result);
|
||||
if (_activeFilterPrompt === prompt) _activeFilterPrompt = null;
|
||||
if (current && previousFocus?.isConnected) previousFocus.focus({ preventScroll: true });
|
||||
resolve(current ? result : null);
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cleanup(null); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); cleanup(values); }
|
||||
e.stopImmediatePropagation();
|
||||
if (e.key === 'Escape') { e.preventDefault(); cleanup(null); }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
cleanup(e.target?.dataset.action === 'cancel' ? null : values);
|
||||
} else if (e.key === 'Tab') {
|
||||
const fields = [...overlay.querySelectorAll('input, button')];
|
||||
const index = fields.indexOf(document.activeElement);
|
||||
e.preventDefault();
|
||||
fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
const prompt = { handleKey: onKey, cancel: () => cleanup(null) };
|
||||
_activeFilterPrompt = prompt;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!overlay.isConnected || !isCurrent()) cleanup(null);
|
||||
});
|
||||
observer.observe(state.container, { childList: true, subtree: true });
|
||||
overlay.querySelector('input, button')?.focus({ preventScroll: true });
|
||||
overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values));
|
||||
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null));
|
||||
// Click outside the modal (on the dim backdrop) = cancel.
|
||||
@@ -4859,45 +4954,39 @@ function _filterSliderPrompt(title, params, onPreview) {
|
||||
});
|
||||
}
|
||||
|
||||
// Generic helper for live-preview blur filters. Saves the PRE-blur
|
||||
// state to the undo stack first (so Ctrl-Z reverts cleanly), snapshots
|
||||
// the layer for re-rendering, applies `renderer(snap, values)` into
|
||||
// the layer on every slider change for instant feedback. Apply keeps
|
||||
// the result; Cancel / Esc restores the snapshot AND pops the undo
|
||||
// entry we pre-saved so the canceled run leaves no trace.
|
||||
// Preview from a fixed source; only acceptance creates a history entry.
|
||||
async function _applyLiveBlur({ title, params, label, renderer }) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || _isLayerPixelLocked(state, layer)) { if (uiModule) uiModule.showToast('Unlock image pixels before applying a filter'); return; }
|
||||
if (!_canMutateLayerPixels(layer, 'applying a pixel filter')) return;
|
||||
const layer = await _preparePixelTarget('applying a pixel filter');
|
||||
if (!layer) return;
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const snap = document.createElement('canvas');
|
||||
snap.width = w; snap.height = h;
|
||||
snap.getContext('2d').drawImage(layer.canvas, 0, 0);
|
||||
// Save state BEFORE any preview — the undo stack now holds the
|
||||
// pre-blur pixels. Apply leaves it; Cancel pops it.
|
||||
_saveState(label);
|
||||
const session = state.editorSessionToken;
|
||||
const isCurrent = () => state.editorOpen && state.editorSessionToken === session
|
||||
&& _readPixelTarget()?.canvas === layer.canvas;
|
||||
const draw = (values) => {
|
||||
if (!isCurrent()) return;
|
||||
layer.ctx.clearRect(0, 0, w, h);
|
||||
try { renderer(snap, values, layer.ctx); } catch (_) { layer.ctx.drawImage(snap, 0, 0); }
|
||||
composite();
|
||||
};
|
||||
const result = await _filterSliderPrompt(title, params, draw);
|
||||
if (result === null) {
|
||||
const restore = () => {
|
||||
layer.ctx.clearRect(0, 0, w, h);
|
||||
layer.ctx.drawImage(snap, 0, 0);
|
||||
composite();
|
||||
// Drop the snapshot we pushed — there's nothing to undo to.
|
||||
if (state.undoStack.length) state.undoStack.pop();
|
||||
_refreshHistoryPanelIfOpen();
|
||||
};
|
||||
const result = await _filterSliderPrompt(title, params, draw, restore);
|
||||
restore();
|
||||
if (result === null || !isCurrent()) {
|
||||
if (state.editorSessionToken === session) composite();
|
||||
return;
|
||||
}
|
||||
_saveState(label);
|
||||
// Final render from snapshot for a clean commit.
|
||||
layer.ctx.clearRect(0, 0, w, h);
|
||||
renderer(snap, result, layer.ctx);
|
||||
const rasterized = _rasterizeTextLayer(layer);
|
||||
const shapeRasterized = _rasterizeShapeLayer(layer);
|
||||
composite();
|
||||
if (rasterized || shapeRasterized) _renderLayerPanel();
|
||||
_renderLayerPanel();
|
||||
if (uiModule) uiModule.showToast(label + ' applied');
|
||||
}
|
||||
|
||||
@@ -5210,8 +5299,9 @@ function _applyMotionBlur() {
|
||||
});
|
||||
}
|
||||
|
||||
function _applyEdgeFeather(layer, width, hardDelete) {
|
||||
if (!_canMutateLayerPixels(layer, 'feathering pixels')) return false;
|
||||
async function _applyEdgeFeather(layer, width, hardDelete) {
|
||||
if (!await _canMutateLayerPixels(layer, 'feathering pixels')) return false;
|
||||
_saveState(hardDelete ? 'Delete edges' : 'Feather edges');
|
||||
const w = layer.canvas.width;
|
||||
const h = layer.canvas.height;
|
||||
const imgData = layer.ctx.getImageData(0, 0, w, h);
|
||||
@@ -5455,6 +5545,8 @@ function _buildEditor(container) {
|
||||
},
|
||||
onSelectTool: (toolId, _btn, toolbarEl) => {
|
||||
if (state.tool !== toolId) {
|
||||
// Finish a paint gesture before its tool identity changes.
|
||||
if (state.drawing) _strokeTool.tryEnd();
|
||||
_cropTool.cancel('tool-switch');
|
||||
_marqueeTool.cancel('tool-switch');
|
||||
if (state.gradientActive) _gradientTool.cancel();
|
||||
@@ -5469,10 +5561,11 @@ function _buildEditor(container) {
|
||||
// controls live in the right panel.
|
||||
const reactivated = state.tool === toolId;
|
||||
state.tool = toolId;
|
||||
void _offerRasterizeForTool(toolId);
|
||||
state.hoveredHandle = null;
|
||||
const controls = document.getElementById('ge-controls') || document.querySelector('.ge-controls');
|
||||
if (controls) {
|
||||
if (reactivated) controls.classList.toggle('dismissed');
|
||||
if (reactivated && window.innerWidth <= 820) controls.classList.toggle('dismissed');
|
||||
else controls.classList.remove('dismissed');
|
||||
}
|
||||
// On mobile, picking a tool that's about to SHOW its controls
|
||||
@@ -6019,41 +6112,27 @@ function _buildEditor(container) {
|
||||
// the mask (uses mask alpha as a stencil).
|
||||
// - lasso closed → fills the polygon area on the active layer.
|
||||
// - wand selection → fills the wand mask area on the active layer.
|
||||
function _doFillSelection() {
|
||||
const layer = activeLayer();
|
||||
if (!layer || _isLayerPixelLocked(state, layer)) {
|
||||
uiModule?.showToast('Unlock image pixels before filling');
|
||||
return;
|
||||
}
|
||||
if (!_canMutateLayerPixels(layer, 'filling pixels')) return;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
async function _doFillSelection() {
|
||||
const selection = _selectionMaskAsDocument({ materializeLasso: true });
|
||||
const layer = await _preparePixelTarget('filling pixels');
|
||||
if (!layer) return;
|
||||
const off = layer.offset;
|
||||
const w = layer.canvas.width;
|
||||
const h = layer.canvas.height;
|
||||
const mask = _getActiveMaskLayer();
|
||||
const hasLasso = state.lassoPoints.length >= 3 && !state.lassoActive;
|
||||
const stencil = document.createElement('canvas');
|
||||
stencil.width = w; stencil.height = h;
|
||||
const sctx = stencil.getContext('2d');
|
||||
if (mask) {
|
||||
if (mask.mode === 'layer') {
|
||||
sctx.drawImage(mask.canvas, mask.offset?.x || 0, mask.offset?.y || 0);
|
||||
} else {
|
||||
sctx.drawImage(mask.canvas, -off.x, -off.y);
|
||||
}
|
||||
} else if (hasLasso) {
|
||||
const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
|
||||
const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
|
||||
sctx.drawImage(_buildLassoMask(w, h, off.x, off.y, feather, grow), 0, 0);
|
||||
} else if (state.wandMask) {
|
||||
if (selection) {
|
||||
sctx.drawImage(_selectionMaskForLayer(
|
||||
state.wandMask,
|
||||
state.wandMaskSpace || 'layer',
|
||||
selection,
|
||||
'document',
|
||||
off,
|
||||
w,
|
||||
h,
|
||||
), 0, 0);
|
||||
} else {
|
||||
return;
|
||||
sctx.fillStyle = '#fff';
|
||||
sctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
_saveState('Fill selection');
|
||||
sctx.globalCompositeOperation = 'source-in';
|
||||
@@ -6061,7 +6140,7 @@ function _buildEditor(container) {
|
||||
sctx.fillRect(0, 0, w, h);
|
||||
sctx.globalCompositeOperation = 'source-over';
|
||||
layer.ctx.save();
|
||||
if (_isLayerTransparencyLocked(state, layer)) layer.ctx.globalCompositeOperation = 'source-atop';
|
||||
if (!layer.mask && _isLayerTransparencyLocked(state, layer.parent)) layer.ctx.globalCompositeOperation = 'source-atop';
|
||||
layer.ctx.drawImage(stencil, 0, 0);
|
||||
layer.ctx.restore();
|
||||
composite();
|
||||
@@ -6323,6 +6402,7 @@ function _buildEditor(container) {
|
||||
// accidentally close the gallery modal.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!state.editorOpen) return;
|
||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||
// Inline layer renaming owns Escape so it can restore the original name
|
||||
// without the editor-wide guard swallowing the event first.
|
||||
const renameInput = e.key === 'Escape' && e.target?.closest?.('.ge-layer-name-input');
|
||||
@@ -6385,6 +6465,7 @@ function _buildEditor(container) {
|
||||
// Keyboard shortcuts — full implementation in
|
||||
// editor/keyboard-shortcuts.js.
|
||||
wireKeyboardShortcuts({
|
||||
copyPixelsToClipboard: _copyPixelsToClipboard,
|
||||
toolbar, toolKeyMap: _toolKeyMap,
|
||||
composite, saveState: _saveState, undo, redo,
|
||||
toggleShortcuts: _toggleShortcuts,
|
||||
@@ -6510,6 +6591,7 @@ const _layerPanelRenderer = createLayerPanelRenderer({
|
||||
});
|
||||
function _renderLayerPanel() {
|
||||
const result = _layerPanelRenderer.render();
|
||||
_layerPanelRenderer.refreshPreviews();
|
||||
_syncTextControls();
|
||||
_syncShapeControls();
|
||||
_layerGeometry.sync();
|
||||
@@ -7140,6 +7222,7 @@ function _unmountEditorLoading() {
|
||||
}
|
||||
|
||||
export function openEditor(imageUrl, imageId, presetSize, displayName, draftId) {
|
||||
_activeFilterPrompt?.cancel();
|
||||
_setEditTabLabel(displayName || (presetSize ? 'New canvas' : 'Untitled'));
|
||||
state.imageId = imageId || null;
|
||||
// Track original file extension so save-over-original can re-encode in the
|
||||
@@ -7430,6 +7513,7 @@ export function closeEditor(options = {}) {
|
||||
try { uiModule.showToast('Close the edit tab first'); } catch {}
|
||||
return false;
|
||||
}
|
||||
_activeFilterPrompt?.cancel();
|
||||
if (_textEditor?.isOpen()) _textEditor.close(true);
|
||||
// Flush any pending debounced persist + fire one final save so closing
|
||||
// the editor mid-stroke doesn't lose work. The call is fire-and-forget;
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
// static/js/group.js
|
||||
// Group Chat — multi-model conversations (parallel or round-robin)
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import markdownModule from './markdown.js';
|
||||
import chatRenderer from './chatRenderer.js?v=20260910streamlinks2';
|
||||
import chatRenderer from './chatRenderer.js?v=20260913richdiff1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { PROMPT_TEMPLATES, getUserTemplates } from './presets.js?v=20260908personaname1';
|
||||
|
||||
@@ -9,7 +9,20 @@ function markComposerUserEdited() {
|
||||
msgInput.dataset.startupPreserveBound = '1';
|
||||
msgInput.addEventListener('input', () => {
|
||||
window.__odysseusComposerUserEdited = !!msgInput.value;
|
||||
syncComposerModelPicker(msgInput);
|
||||
});
|
||||
syncComposerModelPicker(msgInput);
|
||||
}
|
||||
|
||||
// Keep the model picker while the composer is empty or short. Once the user
|
||||
// has enough text for the picker to compete with the input area, hide only
|
||||
// the picker so the prompt gets the full row width instead of being clipped.
|
||||
function syncComposerModelPicker(msgInput) {
|
||||
const wrap = document.getElementById('model-picker-wrap');
|
||||
if (!wrap || !msgInput) return;
|
||||
const lineHeight = parseFloat(getComputedStyle(msgInput).lineHeight) || 21;
|
||||
const typedEnough = msgInput.value.trim().length >= 64 || msgInput.scrollHeight > lineHeight * 2.2;
|
||||
wrap.classList.toggle('picker-auto-hidden', typedEnough);
|
||||
}
|
||||
|
||||
function clearFreshComposerRestore() {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Markdown rendering and content processing utilities
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { splitTableRow } from './markdown/tableRow.js';
|
||||
import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js';
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// Memory Management Functions
|
||||
// This module handles all memory-related operations
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import sessionModule from './sessions.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Extracted from sessions.js
|
||||
|
||||
import { providerLogo } from './providers.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import settingsModule from './settings.js?v=20260909defaultmodelfix1';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
@@ -952,7 +952,7 @@ export function updateModelPicker() {
|
||||
: normalizeRouteUrl(item.url) === normalizeRouteUrl(selectedUrl));
|
||||
const routeName = s?.endpoint_name || selectedEndpoint?.endpoint_name;
|
||||
if (routeName) {
|
||||
displayName = `${routeName} · ${displayName}`;
|
||||
displayName = `${displayName} · ${routeName}`;
|
||||
}
|
||||
// The header indicator clips long names with ellipsis; show the full model
|
||||
// identifier on hover (#1982). No tooltip on the "Select model" placeholder.
|
||||
|
||||
+2
-2
@@ -5,11 +5,11 @@
|
||||
*/
|
||||
|
||||
import Storage from './storage.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import sessionModule from './sessions.js';
|
||||
import dragSortModule from './dragSort.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { modelColor } from './chatRenderer.js?v=20260910streamlinks2';
|
||||
import { modelColor } from './chatRenderer.js?v=20260913richdiff1';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
|
||||
|
||||
+15
-6
@@ -3,7 +3,7 @@
|
||||
* Renders as a sidebar panel (like document editor), not a modal.
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { spawnConfetti } from './compare/vote.js?v=20260828resendcaldrag1';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
@@ -4122,7 +4122,10 @@ function _buildDrawHtml() {
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="note-form-draw-text" title="Add text — click to cycle size">T<span class="note-form-draw-text-badge"></span></button>
|
||||
<button type="button" class="note-form-draw-undo" title="Undo" aria-label="Undo">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"></polyline><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"></path></svg>
|
||||
</button>
|
||||
<button type="button" class="note-form-draw-text" title="Add text — click to cycle size" aria-label="Add text">T<span class="note-form-draw-text-badge"></span></button>
|
||||
<button type="button" class="note-form-draw-line" title="Line — click to cycle size">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="20" x2="20" y2="4"/></svg>
|
||||
<span class="note-form-draw-shape-badge"></span>
|
||||
@@ -4131,9 +4134,6 @@ function _buildDrawHtml() {
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>
|
||||
<span class="note-form-draw-shape-badge"></span>
|
||||
</button>
|
||||
<button type="button" class="note-form-draw-undo" title="Undo">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -4257,17 +4257,24 @@ function _wireCanvas(container, initialImageUrl) {
|
||||
// on Undo. Cap to 30 to keep memory bounded.
|
||||
const _undoStack = [];
|
||||
const UNDO_LIMIT = 30;
|
||||
const _syncUndoButton = () => {
|
||||
const available = _undoStack.length > 0;
|
||||
undoBtn?.classList.toggle('has-undo', available);
|
||||
undoBtn?.toggleAttribute('disabled', !available);
|
||||
undoBtn?.setAttribute('aria-disabled', available ? 'false' : 'true');
|
||||
};
|
||||
const _markCanvasDirty = () => container.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const _snapshot = () => {
|
||||
try {
|
||||
const w = canvas.width, h = canvas.height;
|
||||
_undoStack.push(ctx.getImageData(0, 0, w, h));
|
||||
if (_undoStack.length > UNDO_LIMIT) _undoStack.shift();
|
||||
_syncUndoButton();
|
||||
} catch {}
|
||||
};
|
||||
const _undo = () => {
|
||||
const prev = _undoStack.pop();
|
||||
if (!prev) return;
|
||||
if (!prev) { _syncUndoButton(); return; }
|
||||
// Restore against the raw backing store: temporarily reset the active
|
||||
// ctx scale, paint the snapshot 1:1, then reapply our standard transform.
|
||||
ctx.save();
|
||||
@@ -4275,6 +4282,7 @@ function _wireCanvas(container, initialImageUrl) {
|
||||
ctx.putImageData(prev, 0, 0);
|
||||
ctx.restore();
|
||||
_markCanvasDirty();
|
||||
_syncUndoButton();
|
||||
};
|
||||
|
||||
const _pos = (e) => {
|
||||
@@ -4506,6 +4514,7 @@ function _wireCanvas(container, initialImageUrl) {
|
||||
lineBtn?.addEventListener('click', () => _setMode(_cycle('line-')));
|
||||
circleBtn?.addEventListener('click', () => _setMode(_cycle('circle-')));
|
||||
undoBtn?.addEventListener('click', () => _undo());
|
||||
_syncUndoButton();
|
||||
|
||||
// Stash so the save handler can read it later without re-resolving DOM.
|
||||
canvas._cssW = cssW;
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
* RAG (Retrieval Augmented Generation) management
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from './spinner.js';
|
||||
|
||||
let API_BASE = '';
|
||||
|
||||
+31
-25
@@ -2,7 +2,7 @@
|
||||
* Deep Research side panel — open/close, form, job rendering, library.
|
||||
*/
|
||||
import * as jobs from './jobs.js?v=20260910researcherrorpersist1';
|
||||
import themeModule from '../theme.js?v=20260909effectspeed1';
|
||||
import themeModule from '../theme.js?v=20260911organsrain1';
|
||||
import createResearchSynapse from '../researchSynapse.js?v=20260910roundlabels2';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
@@ -10,26 +10,6 @@ import { searchProviderLogo } from '../searchProviderIcons.js';
|
||||
import { orderActionMenuItems, actionMenuRank, SELECT_MENU_ICON } from '../actionMenuOrder.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
// Rotating research textarea placeholders — pick one at random each
|
||||
// time the panel is rendered so the example keeps feeling fresh.
|
||||
const _RESEARCH_HINTS = [
|
||||
"e.g. Trace Odysseus's ten-year journey home from Troy — every island, monster, and detour, and why each one cost him",
|
||||
"e.g. Compare Rust and Go for building a high-throughput web API in 2026",
|
||||
"e.g. Fact-check whether honey actually never spoils",
|
||||
"e.g. How to roast a duck so the skin stays crispy",
|
||||
"e.g. The collapse of Bronze Age civilizations — leading theories and the evidence behind each",
|
||||
"e.g. Best M.2 NVMe SSDs under $200 for a home AI workstation",
|
||||
"e.g. Why do cats knead with their paws? Cover the leading behavioural explanations",
|
||||
"e.g. Side effects and benefits of long-term creatine supplementation",
|
||||
"e.g. How does end-to-end encryption work in Signal, step by step",
|
||||
"e.g. The history of the printing press in East Asia, 700 CE → 1600 CE",
|
||||
];
|
||||
function _pickResearchHint() {
|
||||
const i = Math.floor(Math.random() * _RESEARCH_HINTS.length);
|
||||
// Escape double-quotes so we can safely splice into a placeholder="…" attribute.
|
||||
return _RESEARCH_HINTS[i].replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// jobId -> { synapse, status } — survives across _renderJobs() rebuilds so
|
||||
// the SVG keeps its accumulated nodes/edges between progress events.
|
||||
const _jobSynapses = new Map();
|
||||
@@ -41,6 +21,7 @@ const _vizCollapseIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="
|
||||
const _vizExpandIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||
|
||||
let _open = false;
|
||||
let _researchRailUnread = false;
|
||||
let _onDocKeydown = null;
|
||||
let _apiBase = '';
|
||||
let _endpoints = [];
|
||||
@@ -123,7 +104,9 @@ function _syncResearchRail() {
|
||||
} catch { return; }
|
||||
const railBtn = document.getElementById('rail-research');
|
||||
const toolBtn = document.getElementById('tool-research-btn');
|
||||
const active = running > 0 || errored > 0;
|
||||
// Historical failures belong in Research history; they must not keep the
|
||||
// mini-sidebar notification lit after the panel has been opened.
|
||||
const active = running > 0 || _researchRailUnread;
|
||||
// Shared flag so sessions.js:_updateRailNotifs (which lights the same
|
||||
// rail button for INLINE research mode) ORs with us instead of
|
||||
// clobbering — otherwise a session re-render would clear our dot.
|
||||
@@ -131,7 +114,7 @@ function _syncResearchRail() {
|
||||
if (railBtn) {
|
||||
railBtn.classList.remove('rail-notify', 'rail-notify-success', 'rail-notify-error', 'research-notif-active');
|
||||
if (active) {
|
||||
railBtn.classList.add('rail-notify', errored ? 'rail-notify-error' : 'rail-notify-success', 'research-notif-active');
|
||||
railBtn.classList.add('rail-notify', errored && running > 0 ? 'rail-notify-error' : 'rail-notify-success', 'research-notif-active');
|
||||
}
|
||||
}
|
||||
if (toolBtn) {
|
||||
@@ -226,7 +209,13 @@ export function init(apiBase, markdownMod, sessionMod) {
|
||||
_sessionModule = sessionMod;
|
||||
jobs.init(apiBase);
|
||||
jobs.setRenderCallback(_renderJobs);
|
||||
jobs.onComplete(() => { if (!_open) _showBadge(); });
|
||||
jobs.onComplete(() => {
|
||||
if (!_open) {
|
||||
_researchRailUnread = true;
|
||||
_showBadge();
|
||||
_syncResearchRail();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function isOpen() { return _open; }
|
||||
@@ -259,6 +248,8 @@ export function openPanel(focusJobId) {
|
||||
return;
|
||||
}
|
||||
_open = true;
|
||||
_researchRailUnread = false;
|
||||
_syncResearchRail();
|
||||
_researchTab = 'research';
|
||||
|
||||
const container = document.getElementById('chat-container');
|
||||
@@ -407,7 +398,7 @@ function _buildPanelHTML() {
|
||||
<p class="memory-desc doclib-desc research-new-job-desc">
|
||||
<span>Multi-step web research with an LLM-in-the-loop agent</span>
|
||||
</p>
|
||||
<textarea id="research-query" class="research-query" placeholder="${_pickResearchHint()}" rows="4"></textarea>
|
||||
<textarea id="research-query" class="research-query" placeholder="Set sail on a question — Odysseus will chart the course." rows="4"></textarea>
|
||||
<button id="research-settings-toggle" class="research-settings-toggle${chevronCls}">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.85;flex-shrink:0;"><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 0 1 0 2.83 2 2 0 0 1-2.83 0l-.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-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings<span class="research-settings-chevron">${_chevronIcon}</span>
|
||||
</button>
|
||||
@@ -996,6 +987,18 @@ function _renderJobs() {
|
||||
_renderHistoryFilters(past);
|
||||
_syncResearchTabs(allJobs);
|
||||
|
||||
// Active cards are rebuilt on every progress event. Preserve an open
|
||||
// overflow menu across that rebuild so live research updates do not make
|
||||
// the kebab appear to open and immediately close.
|
||||
const openActiveOverflowIds = new Set(
|
||||
[...activeList.querySelectorAll('.research-job-overflow.open')]
|
||||
.map(overflow => overflow.closest('[data-job-id]')?.dataset.jobId)
|
||||
.filter(Boolean),
|
||||
);
|
||||
activeList.querySelectorAll('.research-job-overflow.open').forEach((overflow) => {
|
||||
overflow.querySelector('.research-job-more')?.click();
|
||||
});
|
||||
|
||||
activeList.innerHTML = '';
|
||||
pastList.innerHTML = '';
|
||||
|
||||
@@ -1044,6 +1047,9 @@ function _renderJobs() {
|
||||
};
|
||||
appendCards(activeList, active, 'No active research.');
|
||||
appendCards(pastList, visiblePast, past.length ? 'No research matches your filters.' : 'No research history yet.');
|
||||
openActiveOverflowIds.forEach((jobId) => {
|
||||
activeList.querySelector(`[data-job-id="${CSS.escape(jobId)}"] .research-job-more`)?.click();
|
||||
});
|
||||
if (_historyCascadePending && _researchTab === 'history') _playHistoryCascade();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Search Chat Module — Ctrl+K command palette for searching conversations
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import sessionModule from './sessions.js';
|
||||
|
||||
let API_BASE = '';
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// This module handles all session-related operations
|
||||
|
||||
import Storage from './storage.js';
|
||||
import uiModule, { autoResize, styledPrompt } from './ui.js?v=20260908weekhoverfix1';
|
||||
import chatRenderer from './chatRenderer.js?v=20260910streamlinks2';
|
||||
import uiModule, { autoResize, styledPrompt } from './ui.js?v=20260916largetoolscroll1';
|
||||
import chatRenderer from './chatRenderer.js?v=20260913richdiff1';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260909routeidentity1';
|
||||
import themeModule from './theme.js?v=20260909effectspeed1';
|
||||
import themeModule from './theme.js?v=20260911organsrain1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { actionMenuRank, orderActionMenuItems, SELECT_MENU_ICON } from './actionMenuOrder.js';
|
||||
import { registerEscapeLayer, bindMenuDismiss } from './escMenuStack.js';
|
||||
@@ -361,7 +361,7 @@ function _renderSessionRunState(state, isRunning) {
|
||||
state.title = 'Agent is working';
|
||||
state.setAttribute('aria-label', 'Agent is working');
|
||||
} else {
|
||||
state.textContent = 'Done';
|
||||
state.textContent = '✓';
|
||||
state.title = 'Agent finished while you were away';
|
||||
state.setAttribute('aria-label', 'Agent finished while you were away');
|
||||
}
|
||||
@@ -2819,7 +2819,7 @@ function _updateResearchDots() {
|
||||
listItem.insertBefore(state, menu || null);
|
||||
}
|
||||
const alreadyRunning = state.classList.contains('is-working') && !!state._whirlpool;
|
||||
const alreadyDone = state.classList.contains('is-done') && state.textContent === 'Done';
|
||||
const alreadyDone = state.classList.contains('is-done') && state.textContent.trim() === '✓';
|
||||
if ((isRunning && !alreadyRunning) || (isCompleted && !alreadyDone)) {
|
||||
_renderSessionRunState(state, isRunning);
|
||||
}
|
||||
|
||||
+68
-3
@@ -1,7 +1,7 @@
|
||||
// static/js/settings.js — Settings panel module (ES6)
|
||||
// User-facing preferences: AI models, search, appearance
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import searchModule from './search.js';
|
||||
import { byId } from './settings/dom.js';
|
||||
import {
|
||||
@@ -2193,6 +2193,71 @@ function initAll() {
|
||||
initEmailAccountsSettings();
|
||||
initReminderSettings();
|
||||
initUnifiedIntegrations();
|
||||
initDocumentWritingStyle();
|
||||
}
|
||||
|
||||
async function initDocumentWritingStyle() {
|
||||
const styleEl = el('set-document-style');
|
||||
const saveBtn = el('set-document-style-save');
|
||||
const extractBtn = el('set-document-style-extract');
|
||||
const fileEl = el('set-document-style-file');
|
||||
const msg = el('set-document-style-msg');
|
||||
if (!styleEl || !saveBtn) return;
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
styleEl.value = String(data.document_writing_style || '');
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to load';
|
||||
}
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (msg) msg.textContent = 'Saving...';
|
||||
try {
|
||||
const res = await _postSettings({ document_writing_style: styleEl.value });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (msg) msg.textContent = '✓ Saved';
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to save';
|
||||
}
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
});
|
||||
extractBtn?.addEventListener('click', () => fileEl?.click());
|
||||
fileEl?.addEventListener('change', async () => {
|
||||
const file = fileEl.files?.[0];
|
||||
if (!file) return;
|
||||
extractBtn.disabled = true;
|
||||
let whirlpool = null;
|
||||
if (msg) {
|
||||
msg.replaceChildren();
|
||||
try {
|
||||
const spinner = window.spinnerModule || (await import('./spinner.js')).default;
|
||||
whirlpool = spinner.createWhirlpool(14);
|
||||
whirlpool.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin-right:7px;';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = 'Analyzing...';
|
||||
msg.append(whirlpool.element, label);
|
||||
} catch (_) {
|
||||
msg.textContent = 'Analyzing...';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const res = await fetch('/api/auth/settings/document-style/extract', {
|
||||
method: 'POST', credentials: 'same-origin', body,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.style) throw new Error(data.detail || data.error || 'Extraction failed');
|
||||
styleEl.value = String(data.style);
|
||||
if (msg) msg.textContent = '✓ Extracted — review and save';
|
||||
} catch (error) {
|
||||
if (msg) msg.textContent = error.message || 'Extraction failed';
|
||||
} finally {
|
||||
whirlpool?.destroy?.();
|
||||
extractBtn.disabled = false;
|
||||
fileEl.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notifyIntegrationsChanged() {
|
||||
@@ -2668,7 +2733,7 @@ async function initEmailAccountsSettings() {
|
||||
if (emailPreferences && emailPreferences.dataset.emailSettingsBound !== '1') {
|
||||
emailPreferences.dataset.emailSettingsBound = '1';
|
||||
try {
|
||||
const mod = await import('./emailLibrary.js?v=20260910replyactions1');
|
||||
const mod = await import('./emailLibrary.js?v=20260915trashmove2');
|
||||
if (typeof mod.mountEmailSettings === 'function') {
|
||||
await mod.mountEmailSettings(emailPreferences);
|
||||
}
|
||||
@@ -2686,7 +2751,7 @@ async function initEmailAccountsSettings() {
|
||||
tasksBtn.dataset.bound = '1';
|
||||
tasksBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const mod = await import('./tasks.js?v=20260901taskskilldensity1');
|
||||
const mod = await import('./tasks.js?v=20260914taskmodel1');
|
||||
const openTasks = mod.openTasks || (mod.default && mod.default.openTasks);
|
||||
if (typeof openTasks === 'function') openTasks(null, { filter: 'Email' });
|
||||
else document.getElementById('tool-tasks-btn')?.click();
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
// content), publish/draft toggle, delete, and "run as slash" via the
|
||||
// /<skill-name> path.
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import * as spinnerModule from './spinner.js';
|
||||
import {
|
||||
bindMenuDismiss,
|
||||
|
||||
@@ -10,13 +10,13 @@ window.cancelActiveTour = function cancelActiveTour() {
|
||||
};
|
||||
|
||||
import Storage from './storage.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import sessionModule from './sessions.js';
|
||||
import modelsModule from './models.js';
|
||||
import chatRenderer from './chatRenderer.js?v=20260910streamlinks2';
|
||||
import chatRenderer from './chatRenderer.js?v=20260913richdiff1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import themeModule from './theme.js?v=20260909effectspeed1';
|
||||
import documentModule from './document.js?v=20260911removealignrightshortcut1';
|
||||
import themeModule from './theme.js?v=20260911organsrain1';
|
||||
import documentModule from './document.js?v=20260916docctx2';
|
||||
import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js?v=20260909defaultmodelfix1';
|
||||
import cookbookModule from './cookbook.js';
|
||||
|
||||
+54
-8
@@ -2,7 +2,7 @@
|
||||
* Tasks Module — scheduled recurring LLM prompts.
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import markdownModule from './markdown.js';
|
||||
import * as spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
@@ -49,7 +49,7 @@ function _setTaskCompletionPending(active) {
|
||||
|
||||
async function _fetchTasks() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/tasks`, { credentials: 'same-origin' });
|
||||
const res = await fetch(`${API_BASE}/api/tasks?include_last_run=true`, { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
_tasks = data.tasks || [];
|
||||
} catch (e) {
|
||||
@@ -1001,6 +1001,25 @@ function _renderList() {
|
||||
_doRunNow(task.id);
|
||||
});
|
||||
detailActions.appendChild(runBtn);
|
||||
|
||||
// A manually-triggered run can remain queued behind the interactive
|
||||
// model stream. Offer the existing force-run backend path directly in
|
||||
// the expanded task card so the user does not have to find the run in
|
||||
// Activity first.
|
||||
const waitingForIdle = /waiting\s+for\s+.*?to\s+be\s+idle|queued/i.test(
|
||||
String(task.last_run_result || '')
|
||||
);
|
||||
if (waitingForIdle) {
|
||||
const forceRunBtn = document.createElement('button');
|
||||
forceRunBtn.className = 'memory-toolbar-btn task-detail-force-run-btn';
|
||||
forceRunBtn.title = 'Run anyway, even while Odysseus is busy';
|
||||
forceRunBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:4px;"><polygon points="6 4 20 12 6 20 6 4"/></svg>Run anyway';
|
||||
forceRunBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_doRunNow(task.id, true);
|
||||
});
|
||||
detailActions.appendChild(forceRunBtn);
|
||||
}
|
||||
}
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'memory-toolbar-btn task-detail-edit-btn';
|
||||
@@ -1323,9 +1342,9 @@ function _showForm(existing, initTaskType, initTriggerType) {
|
||||
</select>
|
||||
<div id="task-form-output-extra"></div>
|
||||
|
||||
<label class="task-form-label">Model <span style="opacity:0.5;font-weight:normal;font-size:10px;">(optional — overrides session default)</span></label>
|
||||
<label class="task-form-label">Run with model <span style="opacity:0.5;font-weight:normal;font-size:10px;">(optional — uses Utility by default)</span></label>
|
||||
<select id="task-form-model" class="task-form-input">
|
||||
<option value="">Use session default</option>
|
||||
<option value="">Use Utility default</option>
|
||||
</select>
|
||||
|
||||
<label class="task-form-label">Chain</label>
|
||||
@@ -1660,7 +1679,7 @@ function _showForm(existing, initTaskType, initTriggerType) {
|
||||
|
||||
// Populate model dropdown from /api/models. Value is "endpoint_url::model"
|
||||
// so a single field encodes both the model name and which endpoint to call.
|
||||
// Blank value (option 0) = inherit session default.
|
||||
// Blank value (option 0) = inherit the background-task Utility/Default chain.
|
||||
fetch(`${API_BASE}/api/models`, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -2551,6 +2570,12 @@ function _renderCompletedPreviewEntry(entry) {
|
||||
}
|
||||
const title = _escHtml(entry.taskName || 'Task');
|
||||
const time = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
|
||||
const reportButton = entry.researchId
|
||||
? `<button class="doclib-chat-open-btn task-completed-report-btn" type="button" title="Open the visual research report">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Visual Report
|
||||
</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="memory-item doclib-chat-row task-completed-preview-row" data-entry-idx="${entryIdx}">
|
||||
<div class="doclib-chat-header task-completed-preview-head">
|
||||
@@ -2569,6 +2594,7 @@ function _renderCompletedPreviewEntry(entry) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="doclib-chat-preview-actions">
|
||||
${reportButton}
|
||||
<button class="doclib-chat-copy-btn task-completed-copy-btn" type="button">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy
|
||||
@@ -2584,6 +2610,17 @@ function _renderCompletedPreviewEntry(entry) {
|
||||
}
|
||||
|
||||
function _wireCompletedPreviewRows(list) {
|
||||
list.querySelectorAll('.task-completed-report-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const row = btn.closest('.task-completed-preview-row');
|
||||
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
|
||||
const entry = _activityEntries[idx];
|
||||
if (entry?.researchId) {
|
||||
window.open(`${API_BASE}/api/research/report/${encodeURIComponent(entry.researchId)}`, '_blank');
|
||||
}
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.task-completed-open-chat').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -2846,7 +2883,17 @@ function _renderActivityEntry(entry, opts = {}) {
|
||||
if (hasResult && entry.taskId) {
|
||||
actionBtn += `<button class="task-log-run-again" type="button" title="Run this task again">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
Run again
|
||||
Run again
|
||||
</button>`;
|
||||
}
|
||||
// Keep the bypass action in the same visible action strip as the other
|
||||
// task controls. It is specifically for queued runs waiting on the model
|
||||
// idle gate; completed rows retain the normal "Run again" action.
|
||||
const waitingForIdle = /waiting\s+for\s+.*?to\s+be\s+idle/i.test(String(entry.result || ''));
|
||||
if (entry.taskId && (_isRunning && entry.status === 'queued' || waitingForIdle)) {
|
||||
actionBtn += `<button class="task-log-run-again task-log-force-run task-log-force-run-action" type="button" title="Run anyway, even while Odysseus is busy">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
Run anyway
|
||||
</button>`;
|
||||
}
|
||||
// Running rows replace the relative-time on the right with "Running NN" + a
|
||||
@@ -2861,9 +2908,8 @@ function _renderActivityEntry(entry, opts = {}) {
|
||||
const stale = !isQueued && (Date.now() - startMs) > 30 * 60 * 1000;
|
||||
const label = isQueued ? 'Queued' : stale ? 'Still running' : 'Running';
|
||||
const elapsedInit = isQueued ? '' : `<span class="task-log-running-elapsed" data-since="${startMs}">${_fmtElapsed(Date.now() - startMs)}</span>`;
|
||||
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Start now</span></button>` : '';
|
||||
const stopBtn = entry.taskId ? `<button class="task-log-stop" type="button" title="Stop this task"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg></button>` : '';
|
||||
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${forceBtn}${stopBtn}</span>`;
|
||||
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${stopBtn}</span>`;
|
||||
} else {
|
||||
rightHtml = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
|
||||
}
|
||||
|
||||
+17
-9
@@ -2,7 +2,7 @@
|
||||
// ES6 module
|
||||
|
||||
import Storage from './storage.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { initColorPickers, attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
import { hexToRgb } from './color/hex.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
@@ -24,7 +24,7 @@ export const THEMES = {
|
||||
advanced: { sendBtnBg: '#949494', sendBtnHover: '#7f7f7f',
|
||||
userBubbleBg: '#2f2f2f', aiBubbleBg: '#171717',
|
||||
inputBg: '#2f2f2f', brandColor: '#ffffff', brandMixTo: '#ffffff' } },
|
||||
claude: { bg:'#262624', fg:'#f5f4f0', panel:'#30302e', border:'#4a4a47', red:'#c6613f' },
|
||||
claude: { bg:'#1f1e1b', fg:'#f5f1e8', panel:'#2b2926', border:'#514b43', red:'#d97757' },
|
||||
cute: { bg:'#fff4f7', fg:'#63394d', panel:'#fffafd', border:'#edc7d5', red:'#d65f88' },
|
||||
eclipse: { bg:'#17191c', fg:'#e8e4dc', panel:'#0f1113', border:'#3c4248', red:'#46c2b3' },
|
||||
porcelain: { bg:'#edf0f2', fg:'#252a30', panel:'#ffffff', border:'#c7cdd2', red:'#3478c9' },
|
||||
@@ -87,7 +87,15 @@ const THEME_DEFAULT_INTENSITY = {
|
||||
midnight: 0.5,
|
||||
cyberpunk: 0.55,
|
||||
terminal: 0.8,
|
||||
organs: 0.65,
|
||||
organs: 0.75,
|
||||
};
|
||||
|
||||
const THEME_DEFAULT_SIZE = {
|
||||
organs: 0.75,
|
||||
};
|
||||
|
||||
const THEME_DEFAULT_SPEED = {
|
||||
organs: 0.05,
|
||||
};
|
||||
|
||||
// Default frosted-glass state per theme. Themes not listed default to false.
|
||||
@@ -429,8 +437,8 @@ export function applyBgEffectSize(v) {
|
||||
}
|
||||
|
||||
export function applyBgEffectSpeed(v) {
|
||||
// v is a multiplier 0.25..2.5. Canvas effects read this every frame.
|
||||
const n = (v === undefined || v === null || isNaN(v)) ? 1 : Math.max(0.25, Math.min(2.5, Number(v)));
|
||||
// v is a multiplier 0.05..2.5. Canvas effects read this every frame.
|
||||
const n = (v === undefined || v === null || isNaN(v)) ? 1 : Math.max(0.05, Math.min(2.5, Number(v)));
|
||||
document.documentElement.style.setProperty('--bg-effect-speed', String(n));
|
||||
}
|
||||
|
||||
@@ -748,8 +756,8 @@ export function initThemeUI() {
|
||||
const p = ct && ct.bgPattern ? ct.bgPattern : (THEME_DEFAULT_PATTERN[name] || 'none');
|
||||
const ec = ct && ct.bgEffectColor ? ct.bgEffectColor : (THEME_DEFAULT_EFFECT_COLOR[name] || '');
|
||||
const ei = (ct && ct.bgEffectIntensity !== undefined) ? ct.bgEffectIntensity : (THEME_DEFAULT_INTENSITY[name] !== undefined ? THEME_DEFAULT_INTENSITY[name] : 1);
|
||||
const sz = (ct && ct.bgEffectSize !== undefined) ? ct.bgEffectSize : 1;
|
||||
const sp = (ct && ct.bgEffectSpeed !== undefined) ? ct.bgEffectSpeed : 1;
|
||||
const sz = (ct && ct.bgEffectSize !== undefined) ? ct.bgEffectSize : (THEME_DEFAULT_SIZE[name] !== undefined ? THEME_DEFAULT_SIZE[name] : 1);
|
||||
const sp = (ct && ct.bgEffectSpeed !== undefined) ? ct.bgEffectSpeed : (THEME_DEFAULT_SPEED[name] !== undefined ? THEME_DEFAULT_SPEED[name] : 1);
|
||||
const fr = (ct && ct.frosted !== undefined)
|
||||
? !!ct.frosted
|
||||
: (THEME_DEFAULT_FROSTED[name] === true);
|
||||
@@ -1139,8 +1147,8 @@ export function initThemeUI() {
|
||||
const _initEffectIntensity = (saved && saved.bgEffectIntensity !== undefined)
|
||||
? saved.bgEffectIntensity
|
||||
: (saved && THEME_DEFAULT_INTENSITY[saved.name] !== undefined ? THEME_DEFAULT_INTENSITY[saved.name] : 1);
|
||||
const _initEffectSize = (saved && saved.bgEffectSize !== undefined) ? saved.bgEffectSize : 1;
|
||||
const _initEffectSpeed = (saved && saved.bgEffectSpeed !== undefined) ? saved.bgEffectSpeed : 1;
|
||||
const _initEffectSize = (saved && saved.bgEffectSize !== undefined) ? saved.bgEffectSize : (saved && THEME_DEFAULT_SIZE[saved.name] !== undefined ? THEME_DEFAULT_SIZE[saved.name] : 1);
|
||||
const _initEffectSpeed = (saved && saved.bgEffectSpeed !== undefined) ? saved.bgEffectSpeed : (saved && THEME_DEFAULT_SPEED[saved.name] !== undefined ? THEME_DEFAULT_SPEED[saved.name] : 1);
|
||||
const _initFrosted = (saved && saved.frosted !== undefined)
|
||||
? !!saved.frosted
|
||||
: (saved && THEME_DEFAULT_FROSTED[saved.name] === true);
|
||||
|
||||
+10
-9
@@ -4,7 +4,7 @@
|
||||
* UI utilities for toasts, modals, scrolling, and user feedback
|
||||
*/
|
||||
|
||||
import themeModule from './theme.js?v=20260909effectspeed1';
|
||||
import themeModule from './theme.js?v=20260911organsrain1';
|
||||
import * as Modals from './modalManager.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { registerMenuDismiss, dismissTopEscapeLayer, dismissOrRemove } from './escMenuStack.js';
|
||||
@@ -590,12 +590,6 @@ function _smoothScrollStep() {
|
||||
const current = box.scrollTop;
|
||||
const diff = target - current;
|
||||
|
||||
// If user scrolled up significantly, don't force them down
|
||||
if (diff > 300) {
|
||||
_scrollRafId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (diff <= 1) {
|
||||
box.scrollTop = target;
|
||||
_scrollRafId = null;
|
||||
@@ -635,6 +629,11 @@ export function getAutoScroll() {
|
||||
return autoScrollEnabled;
|
||||
}
|
||||
|
||||
/** True while scrollHistory() is actively moving the chat viewport. */
|
||||
export function isAutoScrolling() {
|
||||
return !!_scrollRafId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-resize textarea based on content
|
||||
*/
|
||||
@@ -753,7 +752,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
altBtn.removeEventListener('click', onAlt);
|
||||
overlay.removeEventListener('click', onBackdrop);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
|
||||
resolve(result);
|
||||
}
|
||||
@@ -787,7 +786,8 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
altBtn.addEventListener('click', onAlt);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
overlay.addEventListener('click', onBackdrop);
|
||||
document.addEventListener('keydown', onKey);
|
||||
// Capture Escape before global shortcuts or focused controls can swallow it.
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
okBtn.focus();
|
||||
});
|
||||
}
|
||||
@@ -985,6 +985,7 @@ const uiModule = {
|
||||
restoreHistoryScroll,
|
||||
setAutoScroll,
|
||||
getAutoScroll,
|
||||
isAutoScrolling,
|
||||
autoResize,
|
||||
debounce,
|
||||
el,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// to that folder (see routes/chat_routes.py + src/tool_execution.py).
|
||||
|
||||
import Storage, { KEYS } from './storage.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
+494
-67
@@ -2620,6 +2620,9 @@ body.bg-pattern-ascii-fireflies {
|
||||
.chat-input-top {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
}
|
||||
.plan-mode-status {
|
||||
position: absolute;
|
||||
@@ -2672,9 +2675,11 @@ body.bg-pattern-ascii-fireflies {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 30%, transparent);
|
||||
}
|
||||
.chat-input-top > .model-picker-wrap {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
top: 0;
|
||||
right: 0;
|
||||
right: auto;
|
||||
z-index: 250;
|
||||
transform-origin: top right;
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
@@ -2687,6 +2692,7 @@ body.bg-pattern-ascii-fireflies {
|
||||
}
|
||||
.chat-input-top > .model-picker-wrap.model-picker-autohide,
|
||||
.chat-input-top > .model-picker-wrap.picker-auto-hidden {
|
||||
display: none !important;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-4px) scale(0.96);
|
||||
@@ -2711,6 +2717,9 @@ body.bg-pattern-ascii-fireflies {
|
||||
color: color-mix(in srgb, var(--fg) 30%, transparent);
|
||||
}
|
||||
.chat-input-bar textarea#message {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -2743,9 +2752,6 @@ body.bg-pattern-ascii-fireflies {
|
||||
@container chatbar (max-width: 340px) {
|
||||
.chat-input-right .mode-toggle { display: none !important; }
|
||||
}
|
||||
@container chatbar (max-width: 260px) {
|
||||
#model-picker-wrap { display: none !important; }
|
||||
}
|
||||
.chat-input-left {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -2933,7 +2939,9 @@ body.bg-pattern-ascii-fireflies {
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font: 12px/1.55 inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
font-family: inherit;
|
||||
}
|
||||
.plan-review-actions {
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -4533,19 +4541,65 @@ body.bg-pattern-ascii-fireflies {
|
||||
background:var(--panel);
|
||||
border:1px solid var(--border);
|
||||
border-radius:8px;
|
||||
padding:10px 14px;
|
||||
font-size:0.8rem;
|
||||
padding:11px 13px 12px;
|
||||
font-size:0.78rem;
|
||||
color:var(--fg);
|
||||
box-shadow:0 8px 24px rgba(0,0,0,0.4);
|
||||
min-width:180px;
|
||||
line-height:1.7;
|
||||
min-width:250px;
|
||||
max-width:min(360px, calc(100vw - 16px));
|
||||
line-height:1.45;
|
||||
}
|
||||
.ctx-label {
|
||||
display:inline-block;
|
||||
width:60px;
|
||||
display:block;
|
||||
width:auto;
|
||||
color:var(--color-muted-alt);
|
||||
font-size:0.75rem;
|
||||
}
|
||||
.ctx-popup-title {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:6px;
|
||||
margin-bottom:8px;
|
||||
padding-bottom:7px;
|
||||
border-bottom:1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||
font-weight:650;
|
||||
letter-spacing:.01em;
|
||||
}
|
||||
.ctx-popup-title::before {
|
||||
content:'';
|
||||
width:6px;
|
||||
height:6px;
|
||||
flex:0 0 6px;
|
||||
border-radius:50%;
|
||||
background:var(--accent, var(--red));
|
||||
box-shadow:0 0 0 3px color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);
|
||||
}
|
||||
.ctx-stat-section {
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:4px;
|
||||
}
|
||||
.ctx-stat-section + .ctx-stat-section {
|
||||
margin-top:9px;
|
||||
padding-top:8px;
|
||||
border-top:1px solid color-mix(in srgb, var(--border) 45%, transparent);
|
||||
}
|
||||
.ctx-stat-row {
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0, 1fr) auto;
|
||||
align-items:baseline;
|
||||
gap:14px;
|
||||
min-height:18px;
|
||||
}
|
||||
.ctx-stat-row .ctx-label {
|
||||
white-space:nowrap;
|
||||
}
|
||||
.ctx-stat-value {
|
||||
color:var(--fg);
|
||||
text-align:right;
|
||||
font-variant-numeric:tabular-nums;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.edit-btn {
|
||||
background:none; border:none;
|
||||
color:var(--color-muted-alt); font-size:1.1rem; cursor:pointer;
|
||||
@@ -4566,6 +4620,7 @@ body.bg-pattern-ascii-fireflies {
|
||||
background:var(--bg); color:var(--fg);
|
||||
border:1px solid var(--border); border-radius:6px;
|
||||
padding:4px 12px; cursor:pointer; font-size:0.8rem;
|
||||
display:inline-flex; align-items:center; justify-content:center; gap:5px;
|
||||
}
|
||||
.edit-save-btn:hover { background:var(--panel); }
|
||||
.edit-cancel-btn:hover { background:var(--panel); }
|
||||
@@ -4820,7 +4875,9 @@ body.bg-pattern-ascii-fireflies {
|
||||
transform: translateX(120%);
|
||||
transition: opacity .35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform .45s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
z-index: 9999; pointer-events: none;
|
||||
/* Toasts must stay above the chat-context settings popup so a setting
|
||||
change is visible while that popup remains open. */
|
||||
z-index: 10070; pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(12px);
|
||||
max-width: min(420px, calc(100vw - 32px));
|
||||
@@ -5902,6 +5959,30 @@ body.bg-pattern-ascii-fireflies {
|
||||
#memory-modal .admin-card:has(.doclib-card-expanded) > .doclib-grid {
|
||||
height: 100% !important;
|
||||
}
|
||||
/* Documents need the same bounded flex chain as skills when a card is
|
||||
opened. Without this, the grid falls back to its list layout and the
|
||||
expanded preview only grows to its content height on mobile. */
|
||||
#doclib-modal .admin-card:has(.doclib-card-expanded) > .doclib-grid {
|
||||
display: flex !important;
|
||||
flex: 1 1 0 !important;
|
||||
flex-direction: column !important;
|
||||
min-height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
#doclib-modal .doclib-card.doclib-card-expanded {
|
||||
flex: 1 1 auto !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
#doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview {
|
||||
flex: 1 1 0 !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
#doclib-modal .doclib-card.doclib-card-expanded .doclib-card-pdf-frame {
|
||||
height: auto !important;
|
||||
flex: 1 1 0 !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
/* Skills modal: keep the header + tab strip visible; the expanded
|
||||
card fills the skills-list area below them via position:absolute
|
||||
(see the #skills-list rule above). Just make sure the tab-panel +
|
||||
@@ -5928,16 +6009,21 @@ body.bg-pattern-ascii-fireflies {
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
/* The Documents preview footer only exposes Open and More on phones.
|
||||
Export/archive/delete stay available without crowding the bottom bar. */
|
||||
#doclib-modal .doclib-card-expanded-actions > .doclib-expanded-delete-btn,
|
||||
/* On phones, keep Delete explicit while the other secondary actions
|
||||
stay available from the kebab. */
|
||||
#doclib-modal .doclib-card-expanded-actions > .doclib-expanded-archive-btn,
|
||||
#doclib-modal .doclib-card-expanded-actions .doclib-expanded-clone-btn,
|
||||
#doclib-modal .doclib-card-expanded-actions .doclib-expanded-export-btn {
|
||||
display: none !important;
|
||||
}
|
||||
#doclib-modal .doclib-card-expanded-actions > .doclib-expanded-delete-btn {
|
||||
display: inline-flex !important;
|
||||
order: 0;
|
||||
left: 0 !important;
|
||||
margin-left: 0;
|
||||
}
|
||||
#doclib-modal .doclib-card-expanded-actions > .doclib-action-group {
|
||||
order: 1;
|
||||
order: 2;
|
||||
margin-left: 6px !important;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -5955,11 +6041,11 @@ body.bg-pattern-ascii-fireflies {
|
||||
}
|
||||
#doclib-modal .doclib-card-expanded-actions > .doclib-expanded-mobile-more {
|
||||
display: inline-flex;
|
||||
order: 0;
|
||||
order: 1;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
min-height: 30px;
|
||||
margin-left: 0;
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
}
|
||||
/* Chat top bar — adjusted for reduced height */
|
||||
@@ -8380,7 +8466,9 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; }
|
||||
/* Eval-prompts picker — only shown during compare; absolute top-right
|
||||
inside .chat-input-top, matching .model-picker-wrap's slot. */
|
||||
.chat-input-top > .cmp-eval-wrap {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
top: -2px; right: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -11939,7 +12027,7 @@ details .source-link {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75em;
|
||||
font-size: 0.68em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -12704,7 +12792,7 @@ details a:hover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 78vh;
|
||||
height: min(78vh, max-content);
|
||||
height: 78vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
#memory-modal .memory-modal-content:has(
|
||||
@@ -15775,6 +15863,7 @@ textarea.memory-add-input {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32);
|
||||
color: var(--fg);
|
||||
user-select: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.doc-rich-selection-toolbar button {
|
||||
width: 30px;
|
||||
@@ -15805,10 +15894,31 @@ textarea.memory-add-input {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
.doc-rich-selection-toolbar .doc-rich-selection-close {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
right: -9px;
|
||||
z-index: 2;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--panel, var(--bg));
|
||||
color: var(--fg);
|
||||
font: 700 15px/1 system-ui, sans-serif;
|
||||
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.doc-rich-selection-toolbar .doc-rich-selection-close:hover,
|
||||
.doc-rich-selection-toolbar .doc-rich-selection-close:focus-visible {
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 16%, var(--panel, var(--bg)));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.doc-rich-selection-toolbar { padding: 4px; }
|
||||
.doc-rich-selection-toolbar button { width: 38px; height: 34px; font-size: 13px; }
|
||||
.doc-rich-selection-toolbar .doc-rich-selection-close { width: 20px; height: 20px; top: -10px; right: -10px; }
|
||||
}
|
||||
|
||||
.doc-rich-slash-menu {
|
||||
@@ -16183,6 +16293,8 @@ mark.doc-find-mark.current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.doc-selection-clear {
|
||||
display: inline-flex;
|
||||
@@ -16208,14 +16320,63 @@ mark.doc-find-mark.current {
|
||||
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
}
|
||||
.doc-selection-chip-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 3px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--panel, var(--bg));
|
||||
color: var(--fg);
|
||||
font: 700 15px/1 system-ui, sans-serif;
|
||||
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.35);
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-selection-chip-clear:hover,
|
||||
.doc-selection-chip-clear:focus-visible {
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 16%, var(--panel, var(--bg)));
|
||||
outline: none;
|
||||
}
|
||||
.doc-selection-clear {
|
||||
width: auto;
|
||||
height: 18px;
|
||||
margin-left: 3px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
font: 700 11px/1 system-ui, sans-serif;
|
||||
opacity: 1;
|
||||
}
|
||||
.doc-edit-tag {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.5;
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 32%, transparent);
|
||||
color: var(--accent, var(--red));
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.68em;
|
||||
line-height: 1;
|
||||
opacity: 0.78;
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 1px 5px;
|
||||
margin-right: 2px;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.12s, background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.doc-edit-tag:hover,
|
||||
.doc-edit-tag:focus-visible {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);
|
||||
}
|
||||
.doc-edit-tag:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent, var(--red)) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Attachment cards in user messages */
|
||||
.attach-cards {
|
||||
@@ -16307,9 +16468,58 @@ mark.doc-find-mark.current {
|
||||
background: color-mix(in srgb, var(--red) 10%, transparent);
|
||||
border-left: 2px solid color-mix(in srgb, var(--red) 50%, transparent);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
z-index: 2;
|
||||
transition: top 0.05s;
|
||||
}
|
||||
.doc-selection-overlay-clear {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--panel) 82%, transparent);
|
||||
color: var(--fg);
|
||||
font: 16px/1 inherit;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.doc-selection-overlay-clear:hover,
|
||||
.doc-selection-overlay-clear:focus-visible {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 18%, var(--panel));
|
||||
outline: none;
|
||||
}
|
||||
.doc-selection-rich-clear {
|
||||
position: fixed;
|
||||
z-index: 10040;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--panel, var(--bg));
|
||||
color: var(--fg);
|
||||
font: 700 15px/1 system-ui, sans-serif;
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.doc-selection-rich-clear:hover,
|
||||
.doc-selection-rich-clear:focus-visible {
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 16%, var(--panel, var(--bg)));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Suggestion comments (Google Docs style) ── */
|
||||
.doc-suggestion-highlight {
|
||||
@@ -17208,6 +17418,26 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
}
|
||||
.doc-md-preview.doc-rich-preview {
|
||||
background: #fff;
|
||||
color: #111;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
padding: 32px max(24px, calc((100% - 780px) / 2));
|
||||
}
|
||||
.doc-rich-preview > :not(.doc-preview-hover-edit) {
|
||||
max-width: 780px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.doc-rich-preview-empty {
|
||||
position: relative;
|
||||
left: -16px;
|
||||
top: -8px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
.doc-preview-hover-edit {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -17241,6 +17471,54 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 12%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, var(--border));
|
||||
}
|
||||
|
||||
/* DOCX visual preview — keep the Word document on a readable white sheet even
|
||||
when the surrounding workspace uses a dark theme. */
|
||||
.doc-docx-preview {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 28px 18px 42px;
|
||||
background: #d9dce1;
|
||||
color: #111827;
|
||||
}
|
||||
.doc-docx-paper {
|
||||
width: min(816px, 100%);
|
||||
min-height: 1056px;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
padding: 72px 76px;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
box-shadow: 0 4px 18px rgba(0,0,0,.22);
|
||||
}
|
||||
.doc-docx-paper h1, .doc-docx-paper h2, .doc-docx-paper h3,
|
||||
.doc-docx-paper h4, .doc-docx-paper h5, .doc-docx-paper h6 {
|
||||
color: #111;
|
||||
line-height: 1.2;
|
||||
margin: 1.2em 0 .55em;
|
||||
}
|
||||
.doc-docx-paper p { margin: 0 0 .8em; }
|
||||
.doc-docx-paper img { max-width: 100%; height: auto; }
|
||||
.doc-docx-paper table { width: 100%; border-collapse: collapse; margin: 1em 0; }
|
||||
.doc-docx-paper td, .doc-docx-paper th { border: 1px solid #c7cbd1; padding: 6px 8px; vertical-align: top; }
|
||||
.doc-docx-paper th { background: #f2f3f5; }
|
||||
.doc-docx-paper a { color: #1757a6; }
|
||||
.doc-docx-preview-loading, .doc-docx-preview-error {
|
||||
max-width: 816px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
color: var(--fg);
|
||||
text-align: center;
|
||||
}
|
||||
.doc-docx-preview-error { color: var(--color-error, #c2413d); }
|
||||
@media (max-width: 700px) {
|
||||
.doc-docx-preview { padding: 12px 8px 28px; }
|
||||
.doc-docx-paper { min-height: 0; padding: 34px 24px; font-size: 14px; }
|
||||
}
|
||||
@media (hover: none) {
|
||||
.doc-preview-hover-edit {
|
||||
opacity: 1;
|
||||
@@ -18899,6 +19177,9 @@ body.right-dock-active:not(.email-doc-split-active) .doc-editor-pane {
|
||||
[data-doclib-panel="documents"] .doclib-chip-scroll-arrow {
|
||||
top: calc(50% - 9px) !important;
|
||||
}
|
||||
#doclib-modal .doclib-chip-scroll-arrow {
|
||||
top: calc(50% - 19px) !important;
|
||||
}
|
||||
[data-doclib-panel="documents"] #doclib-tidy-btn,
|
||||
[data-doclib-panel="documents"] #doclib-select-btn {
|
||||
top: 1px;
|
||||
@@ -19613,6 +19894,10 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
|
||||
.doclib-card-expanded-actions > .doclib-action-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
.doclib-card-expanded-actions > .doclib-expanded-delete-btn,
|
||||
.doclib-card-expanded-actions > .doclib-expanded-archive-btn {
|
||||
left: 0 !important;
|
||||
}
|
||||
.doclib-expanded-mobile-more { display: none; }
|
||||
.doclib-btn-hint {
|
||||
font-weight: normal;
|
||||
@@ -19746,6 +20031,10 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.doclib-chat-preview .task-completed-report-btn {
|
||||
color: var(--accent, var(--red));
|
||||
border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, var(--border));
|
||||
}
|
||||
.doclib-chat-preview .doclib-chat-open-btn:hover {
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--accent, var(--red));
|
||||
@@ -20045,6 +20334,14 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Document cards can be expanded in the normal windowed library, where the
|
||||
grid has no definite height. A zero-basis <pre> then collapses to nothing
|
||||
while the footer remains visible. Keep the document text's intrinsic height
|
||||
in that layout; bounded/fullscreen layouts can still shrink it and scroll. */
|
||||
#doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview > pre {
|
||||
flex: 1 1 auto;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
/* Collapsible skills section headers (Your skills / Built-in). */
|
||||
.skills-section-header {
|
||||
@@ -20492,6 +20789,14 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
/* The completed-task filters are a compact toolbar, not a flexible panel.
|
||||
Without this override the shared chip frame grows into the available
|
||||
height of the card, which is especially visible the first time the mobile
|
||||
view switches to Completed. */
|
||||
.tasks-completed-card > .doclib-chip-scroll-frame:has(> #tasks-completed-status-chips) {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
#memory-category-filters {
|
||||
flex: 0 0 auto;
|
||||
min-height: 25px;
|
||||
@@ -20921,6 +21226,29 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--fg) 45%, transparent);
|
||||
}
|
||||
.pdf-loading-state {
|
||||
min-height: 180px;
|
||||
margin: 16px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 22%, var(--border));
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 5%, var(--bg));
|
||||
color: color-mix(in srgb, var(--fg) 68%, transparent);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--fg) 7%, transparent),
|
||||
0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.pdf-loading-state .ai-spinner-whirlpool {
|
||||
color: var(--accent, var(--red));
|
||||
opacity: 0.95;
|
||||
}
|
||||
.pdf-loading-state .ai-spinner-whirlpool canvas {
|
||||
display: block;
|
||||
}
|
||||
.doclib-load-more {
|
||||
display: block;
|
||||
margin: 10px auto 0;
|
||||
@@ -27918,7 +28246,8 @@ body:not(.welcome-ready) #welcome-screen {
|
||||
.task-log-open-report,
|
||||
.task-log-copy,
|
||||
.task-log-clear-cache,
|
||||
.task-log-run-again {
|
||||
.task-log-run-again,
|
||||
.task-log-force-run-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
@@ -27937,7 +28266,8 @@ body:not(.welcome-ready) #welcome-screen {
|
||||
.task-log-open-report:hover,
|
||||
.task-log-copy:hover,
|
||||
.task-log-clear-cache:hover,
|
||||
.task-log-run-again:hover {
|
||||
.task-log-run-again:hover,
|
||||
.task-log-force-run-action:hover {
|
||||
color: var(--fg);
|
||||
border-color: color-mix(in srgb, var(--fg) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--fg) 5%, transparent);
|
||||
@@ -30398,6 +30728,10 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
|
||||
/* Top bar */
|
||||
.ge-topbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
@@ -30408,32 +30742,19 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
|
||||
}
|
||||
.ge-topbar-left, .ge-topbar-right {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
/* Narrow editor windows cannot fit the full undo/zoom/action set in one
|
||||
line. The overflow handler adds this class after hiding optional AI
|
||||
controls, keeping the essential right-side actions visible below. */
|
||||
.ge-topbar.ge-topbar-overflow {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 3px;
|
||||
}
|
||||
.ge-topbar-overflow .ge-topbar-left {
|
||||
flex: 1 1 auto;
|
||||
min-width: max-content;
|
||||
}
|
||||
.ge-topbar-overflow .ge-topbar-right {
|
||||
flex: 1 0 100%;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
padding-top: 3px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
}
|
||||
.ge-topbar-overflow .ge-topbar-right > * {
|
||||
.ge-topbar-right > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ge-topbar-overflow .ge-topbar-right .ge-draft-status {
|
||||
flex: 0 1 auto;
|
||||
.ge-topbar .dropdown[popover] {
|
||||
position: fixed;
|
||||
margin: 0;
|
||||
bottom: auto;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ge-draft-status {
|
||||
flex: 0 1 auto;
|
||||
@@ -30551,6 +30872,14 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
|
||||
}
|
||||
|
||||
/* Toolbar (left) */
|
||||
@keyframes ge-layer-action-flash {
|
||||
from { box-shadow: inset 0 0 0 2px var(--red); background-color: color-mix(in srgb, var(--red) 24%, transparent); }
|
||||
to { box-shadow: inset 0 0 0 2px transparent; }
|
||||
}
|
||||
.ge-layer-action-flash { animation: ge-layer-action-flash 650ms ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ge-layer-action-flash { animation-duration: 1ms; }
|
||||
}
|
||||
.ge-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -33407,7 +33736,11 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
|
||||
.ge-layer-btn.danger { top: -4px; }
|
||||
/* Save / Save as new / Download dropdown anchored to the primary Save
|
||||
button in the topbar. */
|
||||
.ge-save-wrap { position: relative; display: inline-block; }
|
||||
.ge-save-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ge-save-menu {
|
||||
position: fixed;
|
||||
min-width: 160px;
|
||||
@@ -33422,6 +33755,11 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
|
||||
gap: 2px;
|
||||
}
|
||||
.ge-save-menu[hidden] { display: none; }
|
||||
.ge-save-state-icon { display: none; }
|
||||
.ge-stacked-btn .ge-stacked-glyph .ge-save-state-icon[hidden] { display: none !important; }
|
||||
.ge-stacked-btn .ge-stacked-glyph .ge-save-state-icon:not([hidden]) { display: block; }
|
||||
.ge-save-label { white-space: nowrap; min-width: 0; }
|
||||
.ge-save-state-saved { color: var(--fg); }
|
||||
.ge-save-menu .dropdown-item-compact {
|
||||
width: 100%;
|
||||
background: none;
|
||||
@@ -38589,11 +38927,15 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
}
|
||||
.doc-stats-btn.doc-stats-selected {
|
||||
color: var(--accent, var(--red));
|
||||
font-weight: 600;
|
||||
}
|
||||
.doc-stats-popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 7px);
|
||||
z-index: 80;
|
||||
position: fixed;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
z-index: 100000;
|
||||
width: 190px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -38648,6 +38990,29 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
.doc-email-richbody.richtext-mode {
|
||||
background: var(--bg);
|
||||
}
|
||||
.doc-rich-empty-import {
|
||||
position: absolute;
|
||||
top: 90px;
|
||||
right: 18px;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
}
|
||||
.doc-rich-empty-import .doc-rich-empty-import-btn {
|
||||
position: static;
|
||||
float: none;
|
||||
margin: 0;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.doc-rich-empty-import {
|
||||
top: 106px;
|
||||
}
|
||||
}
|
||||
.doc-email-richbody.richtext-mode:empty::before {
|
||||
content: "Start writing\2026";
|
||||
}
|
||||
@@ -39211,11 +39576,11 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
.email-attachment-chip.is-expanded {
|
||||
max-width: 90vw;
|
||||
}
|
||||
.email-attachment-chip > span:not(.att-size):not(.email-attachment-open):not(.email-attachment-download) {
|
||||
.email-attachment-chip > span:not(.att-size):not(.email-attachment-open):not(.email-attachment-download):not(.email-attachment-calendar-open) {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
}
|
||||
.email-attachment-chip.is-expanded > span:not(.att-size):not(.email-attachment-open):not(.email-attachment-download) {
|
||||
.email-attachment-chip.is-expanded > span:not(.att-size):not(.email-attachment-open):not(.email-attachment-download):not(.email-attachment-calendar-open) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -39230,7 +39595,9 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
(was 24px / dim / no border on desktop, easy to miss). Accent-tinted
|
||||
background + border makes it read as a real action. */
|
||||
.email-attachment-open,
|
||||
.email-attachment-download {
|
||||
.email-attachment-download,
|
||||
.email-attachment-calendar-open,
|
||||
.cal-event-email-source {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
height: 22px; padding: 0 9px; border-radius: 999px;
|
||||
margin-left: 6px; flex: 0 0 auto;
|
||||
@@ -39250,12 +39617,18 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
.email-attachment-open svg,
|
||||
.email-attachment-open > svg,
|
||||
.email-attachment-download svg,
|
||||
.email-attachment-download > svg {
|
||||
.email-attachment-download > svg,
|
||||
.email-attachment-calendar-open svg,
|
||||
.email-attachment-calendar-open > svg,
|
||||
.cal-event-email-source svg,
|
||||
.cal-event-email-source > svg {
|
||||
width: 12px; height: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.email-attachment-open:hover,
|
||||
.email-attachment-download:hover {
|
||||
.email-attachment-download:hover,
|
||||
.email-attachment-calendar-open:hover,
|
||||
.cal-event-email-source:hover {
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--red)) 22%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 70%, transparent);
|
||||
}
|
||||
@@ -39265,6 +39638,18 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.email-attachment-calendar-open {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.cal-event-email-source {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.email-attachment-open.is-loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.82;
|
||||
@@ -39279,7 +39664,8 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
}
|
||||
.email-attachment-open-label { line-height: 1; }
|
||||
.email-attachment-chip.is-expanded .email-attachment-open,
|
||||
.email-attachment-chip.is-expanded .email-attachment-download {
|
||||
.email-attachment-chip.is-expanded .email-attachment-download,
|
||||
.email-attachment-chip.is-expanded .email-attachment-calendar-open {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
@@ -39325,7 +39711,7 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
align-items: center;
|
||||
max-width: min(92vw, 360px);
|
||||
}
|
||||
.email-attachment-chip.is-expanded > span:not(.att-size):not(.email-attachment-open):not(.email-attachment-download) {
|
||||
.email-attachment-chip.is-expanded > span:not(.att-size):not(.email-attachment-open):not(.email-attachment-download):not(.email-attachment-calendar-open) {
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -39337,7 +39723,8 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
margin-left: 0;
|
||||
}
|
||||
.email-attachment-chip.is-expanded .email-attachment-open,
|
||||
.email-attachment-chip.is-expanded .email-attachment-download {
|
||||
.email-attachment-chip.is-expanded .email-attachment-download,
|
||||
.email-attachment-chip.is-expanded .email-attachment-calendar-open {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
padding: 0;
|
||||
@@ -44338,6 +44725,16 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active {
|
||||
transition: opacity 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.note-form-draw-text { font-weight: 700; font-family: Georgia, serif; font-size: 16px; }
|
||||
.note-form-draw-undo.has-undo {
|
||||
opacity: 1;
|
||||
color: var(--accent-primary, var(--red));
|
||||
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent-primary, var(--red)) 12%, transparent);
|
||||
}
|
||||
.note-form-draw-undo:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
/* Size badge in the bottom-right of T — empty until a size is chosen. */
|
||||
.note-form-draw-text-badge {
|
||||
position: absolute;
|
||||
@@ -45886,6 +46283,11 @@ button.cal-view-btn {
|
||||
box-shadow:inset 0 0 0 1px color-mix(in srgb, currentColor 20%, transparent);
|
||||
vertical-align:1px;
|
||||
}
|
||||
.cal-event-source svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
}
|
||||
.cal-event-row .cal-event-source {
|
||||
width:11px;
|
||||
height:11px;
|
||||
@@ -47617,8 +48019,9 @@ button.cal-btn.cal-btn-danger:hover { background:var(--accent, var(--red)); colo
|
||||
.chat-research-card .agent-thread-header:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
|
||||
.chat-research-card.agent-thread-node .agent-thread-dot { top: 23px; }
|
||||
.chat-research-card .agent-thread-status { min-width: 0; }
|
||||
.chat-research-background { display: inline-flex; align-items: center; gap: 5px; margin-left: auto; white-space: nowrap; flex-shrink: 0; font-size: 10px; opacity: 0.7; }
|
||||
.chat-research-background { display: inline-flex; align-items: center; gap: 5px; margin-left: 0; white-space: nowrap; flex-shrink: 0; }
|
||||
.chat-research-background .spinner-whirlpool { margin: 0 !important; }
|
||||
.chat-research-background canvas { transform: translateY(-2px); }
|
||||
.chat-research-card .research-job-query { white-space: normal; overflow-wrap: anywhere; font-size: 12px; }
|
||||
.chat-research-detail { margin-top: 5px; font-size: 12px; color: var(--fg-dim, #888); }
|
||||
.chat-research-open { display: inline-flex; align-items: center; min-height: 44px; padding: 0 8px; margin-top: 4px; color: var(--red); text-decoration: underline; }
|
||||
@@ -49822,7 +50225,9 @@ body.theme-frosted .modal {
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
|
||||
color: color-mix(in srgb, var(--fg) 62%, transparent);
|
||||
font: 500 10px/1 inherit;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
scroll-snap-align: start;
|
||||
transition: border-color .15s ease, background .15s ease, color .15s ease;
|
||||
@@ -49839,7 +50244,9 @@ body.theme-frosted .modal {
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
|
||||
color: color-mix(in srgb, var(--fg) 70%, transparent);
|
||||
font: 600 10px/1 inherit;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -50479,7 +50886,7 @@ body.settings-sidebar-resize-active {
|
||||
}
|
||||
#research-pane #research-history-bulk-delete {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
top: -4px;
|
||||
right: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -50497,6 +50904,7 @@ body.settings-sidebar-resize-active {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
right: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
#research-pane #research-past-list {
|
||||
position: relative;
|
||||
@@ -50558,6 +50966,25 @@ body.settings-sidebar-resize-active {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
#research-pane #research-past-list .research-job-header:has(.research-job-title-error) {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
}
|
||||
#research-pane #research-past-list .research-job-header:has(.research-job-title-error) .research-job-query {
|
||||
order: 1;
|
||||
}
|
||||
#research-pane #research-past-list .research-job-header:has(.research-job-title-error) .research-job-retry-badge,
|
||||
#research-pane #research-past-list .research-job-header:has(.research-job-title-error) .research-job-edit-badge,
|
||||
#research-pane #research-past-list .research-job-header:has(.research-job-title-error) .research-job-overflow {
|
||||
order: 2;
|
||||
}
|
||||
#research-pane #research-past-list .research-job-title-error {
|
||||
order: 3;
|
||||
flex: 0 0 100%;
|
||||
max-width: none;
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
#research-pane #research-past-list .research-job-report-badge,
|
||||
#research-pane #research-past-list .research-job-discuss-badge,
|
||||
#research-pane #research-past-list .research-job-overflow {
|
||||
|
||||
+12
-12
@@ -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-v636-shell-toggle-authority';
|
||||
const CACHE_NAME = 'odysseus-v649-large-tool-synthesis-scroll';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -39,11 +39,11 @@ const KATEX_FONTS = [
|
||||
// exact URL the browser requests, query string included.
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/static/style.css?v=20260910researchmobilebuttons41',
|
||||
'/static/app.js?v=20260910shelltoggle3',
|
||||
'/static/style.css?v=20260914pdfstrip1',
|
||||
'/static/app.js?v=20260916autoscroll1',
|
||||
'/static/js/storage.js',
|
||||
'/static/js/appConfig.js',
|
||||
'/static/js/ui.js?v=20260908weekhoverfix1',
|
||||
'/static/js/ui.js?v=20260916largetoolscroll1',
|
||||
'/static/js/markdown.js',
|
||||
'/static/js/dragSort.js',
|
||||
'/static/js/sessions.js',
|
||||
@@ -60,12 +60,12 @@ const PRECACHE = [
|
||||
'/static/js/search.js',
|
||||
'/static/js/spinner.js',
|
||||
'/static/js/tts-ai.js',
|
||||
'/static/js/document.js?v=20260910minimizedcontext1',
|
||||
'/static/js/document.js?v=20260916docctx2',
|
||||
'/static/js/gallery.js?v=20260910promptcopy1',
|
||||
'/static/js/chatRenderer.js?v=20260910streamlinks2',
|
||||
'/static/js/chatRenderer.js?v=20260914pdfstrip1',
|
||||
'/static/js/codeRunner.js?v=20260831richtexttools91',
|
||||
'/static/js/chatStream.js?v=20260909cardlayout1',
|
||||
'/static/js/chat.js?v=20260910shelltoggle1',
|
||||
'/static/js/chatStream.js?v=20260914pdfstrip1',
|
||||
'/static/js/chat.js?v=20260917toolttft1',
|
||||
'/static/js/cookbook.js',
|
||||
'/static/js/search-chat.js',
|
||||
'/static/js/compare/index.js?v=20260909mobilepaneaddscroll1',
|
||||
@@ -74,8 +74,8 @@ const PRECACHE = [
|
||||
'/static/js/panels.js?v=20260909movepicklayer1',
|
||||
'/static/js/theme.js?v=20260909effectspeed1',
|
||||
'/static/js/censor.js',
|
||||
'/static/js/settings.js?v=20260909defaultmodelfix1',
|
||||
'/static/js/admin.js?v=20260908notificationcopy1',
|
||||
'/static/js/settings.js?v=20260912writingstyle3',
|
||||
'/static/js/admin.js?v=20260914toolschemaprofiles1',
|
||||
'/static/js/init.js?v=20260829chatstyle12',
|
||||
'/static/js/slashCommands.js?v=20260902tuiharness1',
|
||||
'/static/js/research/jobs.js?v=20260910researcherrorpersist1',
|
||||
@@ -84,8 +84,8 @@ const PRECACHE = [
|
||||
'/static/js/emailLibrary/signatureFold.js',
|
||||
'/static/js/emailLibrary/state.js',
|
||||
'/static/js/notes.js?v=20260910drawmerge2',
|
||||
'/static/js/tasks.js?v=20260910tasksortpicker1',
|
||||
'/static/js/calendar.js?v=20260903weekscrollstable1',
|
||||
'/static/js/tasks.js?v=20260914taskmodel1',
|
||||
'/static/js/calendar.js?v=20260914emailsource11',
|
||||
'/static/js/calendar/utils.js',
|
||||
'/static/js/calendar/reminders.js',
|
||||
'/static/js/group.js',
|
||||
|
||||
Reference in New Issue
Block a user