2026-05-31 23:58:26 +09:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" / >
< title > Odysseus Chat< / title >
< link rel = "icon" type = "image/svg+xml" href = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E" >
< link rel = "manifest" href = "/static/manifest.json" >
< meta name = "theme-color" content = "#282c34" >
< meta name = "apple-mobile-web-app-capable" content = "yes" >
< meta name = "apple-mobile-web-app-status-bar-style" content = "black-translucent" >
<!-- Disable iOS / Android auto - linkifying of numbers (phone/date/address)
in email bodies — was wrapping random digits in < a href = "tel:..." > with
browser-default styling that didn't match the Odysseus theme. -->
< meta name = "format-detection" content = "telephone=no, date=no, address=no, email=no" >
< link rel = "apple-touch-icon" href = "/static/icon-192.png" >
< script nonce = "{{CSP_NONCE}}" >
window._odysseusLoadTime = Date.now();
(function(){
try {
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
if (t & & t.colors) {
var s = document.documentElement.style;
var c = t.colors;
s.setProperty('--bg', c.bg);
s.setProperty('--fg', c.fg);
s.setProperty('--panel', c.panel);
s.setProperty('--border', c.border);
if (c.red) s.setProperty('--red', c.red);
// Set --brand-color now (= custom brand or the theme red) so the
// loading-screen ASCII wave shows its final colour from the first
// paint. Otherwise it starts on --red and visibly switches once the
// app boots and applyColors() fills --brand-color.
var _bc = (c.advanced & & c.advanced.brandColor) || c.red;
if (_bc) s.setProperty('--brand-color', _bc);
// Match the mobile browser toolbar / status bar to the saved theme bg
// from the very first paint. Otherwise the hardcoded dark meta sits
// there during load and the bar "switches" colour once the app boots.
var mtc = document.querySelector('meta[name="theme-color"]');
if (mtc & & c.bg) mtc.setAttribute('content', c.bg);
// Update favicon to match accent color
var ac = c.red || '#e06c75';
var fav = document.querySelector("link[rel='icon']");
if (fav) fav.href = 'data:image/svg+xml,' + encodeURIComponent("< svg xmlns = 'http://www.w3.org/2000/svg' viewBox = '0 0 32 32' > < path d = 'M16 4L16 22L6 22Z' fill = '" + ac + "' / > < path d = 'M16 8L16 22L24 22Z' fill = '" + ac + "' opacity = '0.6' / > < path d = 'M4 24Q10 20 16 24Q22 28 28 24' stroke = '" + ac + "' stroke-width = '2.5' fill = 'none' stroke-linecap = 'round' / > < / svg > ");
// Derive syntax highlighting colors from theme
function h2hsl(hex) {
hex = hex.replace('#','');
var r=parseInt(hex.substring(0,2),16)/255, g=parseInt(hex.substring(2,4),16)/255, b=parseInt(hex.substring(4,6),16)/255;
var mx=Math.max(r,g,b), mn=Math.min(r,g,b), h, sv, l=(mx+mn)/2;
if(mx===mn){h=sv=0}else{var d=mx-mn;sv=l>0.5?d/(2-mx-mn):d/(mx+mn);if(mx===r)h=((g-b)/d+(g< b ? 6:0 ) ) / 6 ; else if ( mx = ==g)h=((b-r)/d+2)/6;else h = ((r-g)/d+4)/6}
return[h*360,sv*100,l*100];
}
function hsl2h(h,sv,l) {
h=((h%360)+360)%360;sv=Math.max(0,Math.min(100,sv))/100;l=Math.max(0,Math.min(100,l))/100;
var a=sv*Math.min(l,1-l);function f(n){var k=(n+h/30)%12;return l-a*Math.max(-1,Math.min(k-3,9-k,1))}
function th(v){return Math.round(v*255).toString(16).padStart(2,'0')}
return'#'+th(f(0))+th(f(8))+th(f(4));
}
var fH=h2hsl(c.fg),bH=h2hsl(c.bg),rH=h2hsl(c.red||'#e06c75');
var dk=bH[2]< 50 ;
s.setProperty('--hl-bg', hsl2h(bH[0],bH[1],dk?Math.max(bH[2]-4,0):Math.min(bH[2]+4,100)));
s.setProperty('--hl-fg', c.fg);
s.setProperty('--hl-keyword', hsl2h((rH[0]+280)%360,Math.min(rH[1]+10,80),dk?70:45));
s.setProperty('--hl-string', hsl2h(40,Math.min(fH[1]+20,70),dk?72:42));
s.setProperty('--hl-comment', hsl2h(fH[0],Math.max(fH[1]-20,5),(fH[2]*0.5+bH[2]*0.5)));
s.setProperty('--hl-function', hsl2h(210,Math.min(fH[1]+20,75),dk?70:45));
s.setProperty('--hl-number', hsl2h(20,Math.min(fH[1]+15,65),dk?68:48));
s.setProperty('--hl-builtin', hsl2h(180,Math.min(fH[1]+15,60),dk?65:40));
s.setProperty('--hl-variable', hsl2h((fH[0]+30)%360,Math.min(fH[1]+5,60),fH[2]));
s.setProperty('--hl-params', hsl2h(fH[0],Math.max(fH[1]-5,10),dk?Math.min(fH[2]+8,85):Math.max(fH[2]-8,25)));
// Apply advanced overrides if present
if (c.advanced) {
var a = c.advanced;
var advMap = {userBubbleBg:'--user-bubble-bg',aiBubbleBg:'--ai-bubble-bg',bubbleBorder:'--bubble-border',sidebarBg:'--sidebar-bg',sectionAccent:'--section-accent',brandColor:'--brand-color',inputBg:'--input-bg',inputBorder:'--input-border',sendBtnBg:'--send-btn-bg',sendBtnHover:'--send-btn-hover',codeBg:'--code-bg',codeFg:'--code-fg',toggleBg:'--toggle-bg',toggleActive:'--toggle-active',accentPrimary:'--accent-primary',accentError:'--accent-error'};
for (var k in advMap) { if (a[k]) s.setProperty(advMap[k], a[k]); }
}
}
// Apply font early
if (t & & t.font) {
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif"};
if (fm[t.font]) { s.setProperty('--font-family', fm[t.font]); }
else { s.setProperty('--font-family', "'" + t.font.replace(/'/g,'') + "', sans-serif"); }
}
// Apply density class on html
if (t & & t.density & & t.density !== 'comfortable') {
document.documentElement.classList.add('density-' + t.density);
}
// Apply background pattern on body once available
if (t & & t.bgPattern & & t.bgPattern !== 'none') {
document.addEventListener('DOMContentLoaded', function() {
document.body.classList.add('bg-pattern-' + t.bgPattern);
}, {once:true});
}
} catch(e){}
})();
< / script >
<!-- Per - route favicon. Bookmarking /calendar shows a calendar icon,
/notes a notes icon, etc. Each shape is rendered in the current
theme accent color. Falls back to the boat logo on the root path. -->
< script nonce = "{{CSP_NONCE}}" >
(function(){
try {
var path = (window.location.pathname || '').toLowerCase();
var theme = (function(){ try { return JSON.parse(localStorage.getItem('odysseus-theme')); } catch(_) { return null; } })();
var ac = (theme & & (theme.red || theme.colors & & theme.colors.red)) || '#e06c75';
// Shapes are line-stroke SVGs (no emoji glyphs). Each entry returns
// the inner SVG markup rendered against currentColor=`ac`.
var SHAPES = {
'/calendar':
"< rect x = '4' y = '6' width = '24' height = '22' rx = '2' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' / > " +
"< line x1 = '4' y1 = '12' x2 = '28' y2 = '12' stroke = '" + ac + "' stroke-width = '2.5' / > " +
"< line x1 = '10' y1 = '3' x2 = '10' y2 = '9' stroke = '" + ac + "' stroke-width = '2.5' stroke-linecap = 'round' / > " +
"< line x1 = '22' y1 = '3' x2 = '22' y2 = '9' stroke = '" + ac + "' stroke-width = '2.5' stroke-linecap = 'round' / > ",
'/notes':
"< rect x = '6' y = '4' width = '20' height = '24' rx = '2' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' / > " +
"< line x1 = '10' y1 = '10' x2 = '22' y2 = '10' stroke = '" + ac + "' stroke-width = '2' / > " +
"< line x1 = '10' y1 = '15' x2 = '22' y2 = '15' stroke = '" + ac + "' stroke-width = '2' / > " +
"< line x1 = '10' y1 = '20' x2 = '18' y2 = '20' stroke = '" + ac + "' stroke-width = '2' / > ",
'/cookbook':
"< path d = 'M5 8 L5 26 A2 2 0 0 0 7 28 L25 28 A2 2 0 0 0 27 26 L27 8' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' stroke-linejoin = 'round' / > " +
"< path d = 'M9 4 L23 4 L23 8 L9 8 Z' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' stroke-linejoin = 'round' / > " +
"< line x1 = '11' y1 = '14' x2 = '21' y2 = '14' stroke = '" + ac + "' stroke-width = '2' / > " +
"< line x1 = '11' y1 = '19' x2 = '17' y2 = '19' stroke = '" + ac + "' stroke-width = '2' / > ",
'/email':
"< rect x = '4' y = '7' width = '24' height = '18' rx = '2' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' / > " +
"< path d = 'M5 9 L16 17 L27 9' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' stroke-linecap = 'round' stroke-linejoin = 'round' / > ",
'/memory':
"< path d = 'M16 5 C10 5 6 9 6 14 C6 19 10 21 11 22 L11 26 L21 26 L21 22 C22 21 26 19 26 14 C26 9 22 5 16 5 Z' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' stroke-linejoin = 'round' / > " +
"< line x1 = '12' y1 = '28' x2 = '20' y2 = '28' stroke = '" + ac + "' stroke-width = '2' / > ",
'/gallery':
"< rect x = '4' y = '4' width = '24' height = '24' rx = '2' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' / > " +
"< circle cx = '12' cy = '12' r = '2.5' fill = '" + ac + "' / > " +
"< path d = 'M4 22 L11 16 L18 21 L23 17 L28 22' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' stroke-linejoin = 'round' / > ",
'/tasks':
"< rect x = '4' y = '4' width = '24' height = '24' rx = '3' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' / > " +
"< path d = 'M9 16 L14 21 L23 11' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' stroke-linecap = 'round' stroke-linejoin = 'round' / > ",
'/library':
"< rect x = '5' y = '5' width = '5' height = '22' rx = '1' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' / > " +
"< rect x = '13' y = '5' width = '5' height = '22' rx = '1' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' / > " +
"< rect x = '21' y = '8' width = '6' height = '19' rx = '1' fill = 'none' stroke = '" + ac + "' stroke-width = '2.5' transform = 'rotate(8 24 17)' / > ",
};
var inner = SHAPES[path];
if (!inner) return; // Root path keeps the default boat icon.
var svg = "< svg xmlns = 'http://www.w3.org/2000/svg' viewBox = '0 0 32 32' > " + inner + "< / svg > ";
var href = 'data:image/svg+xml,' + encodeURIComponent(svg);
var fav = document.querySelector("link[rel='icon']");
if (!fav) { fav = document.createElement('link'); fav.rel = 'icon'; fav.type = 'image/svg+xml'; document.head.appendChild(fav); }
fav.href = href;
var apple = document.querySelector("link[rel='apple-touch-icon']");
if (!apple) { apple = document.createElement('link'); apple.rel = 'apple-touch-icon'; document.head.appendChild(apple); }
apple.href = href;
// Also customize the page title so the bookmark inherits a useful name.
var titles = {
'/calendar': 'Calendar — Odysseus',
'/notes': 'Notes — Odysseus',
'/cookbook': 'Cookbook — Odysseus',
'/email': 'Email — Odysseus',
'/memory': 'Memory — Odysseus',
'/gallery': 'Gallery — Odysseus',
'/tasks': 'Tasks — Odysseus',
'/library': 'Library — Odysseus',
};
if (titles[path]) document.title = titles[path];
// Per-route Android home-screen icon. We swap the < link rel = "manifest" >
// to a per-page Blob URL with this route's SVG icon — that way "Add to
// Home Screen" picks up the route-specific glyph instead of the shared
// boat logo. Falls back silently on browsers without Blob URL support.
try {
if (inner & & typeof Blob !== 'undefined') {
var pwa = {
name: (titles[path] || 'Odysseus'),
short_name: (titles[path] || 'Odysseus').split('—')[0].trim(),
start_url: path,
scope: '/',
display: 'standalone',
background_color: '#0e0e10',
theme_color: ac,
icons: [
{ src: href, sizes: '192x192', type: 'image/svg+xml', purpose: 'any maskable' },
{ src: href, sizes: '512x512', type: 'image/svg+xml', purpose: 'any maskable' },
],
};
var blob = new Blob([JSON.stringify(pwa)], { type: 'application/manifest+json' });
var url = URL.createObjectURL(blob);
var ml = document.querySelector("link[rel='manifest']");
if (!ml) { ml = document.createElement('link'); ml.rel = 'manifest'; document.head.appendChild(ml); }
ml.href = url;
}
} catch(_) {}
} catch(e){}
})();
< / script >
< script defer src = "/static/lib/highlight.min.js" > < / script >
<!-- Inter font — self - hosted, no Google dependencies -->
< style >
@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-SemiBold.woff2') format('woff2'); }
< / style >
<!-- KaTeX CSS is loaded with media="print" so it doesn't block render,
then flipped to "all" via JS after load. Mermaid init runs once the
library finishes loading. Both hooks are wired via addEventListener
below (inline onload= attrs are blocked by CSP script-src-attr). -->
< link id = "katex-css" rel = "stylesheet" href = "https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" media = "print" >
< script async src = "https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js" > < / script >
< script id = "mermaid-script" async src = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js" > < / script >
< script nonce = "{{CSP_NONCE}}" >
(function(){
var k = document.getElementById('katex-css');
if (k) k.addEventListener('load', function(){ k.media = 'all'; }, { once: true });
var m = document.getElementById('mermaid-script');
if (m) m.addEventListener('load', function(){
if (window.odysseusInitMermaid) window.odysseusInitMermaid();
}, { once: true });
})();
< / script >
< link rel = "stylesheet" href = "/static/style.css" >
< link rel = "modulepreload" href = "/static/app.js" >
< link rel = "modulepreload" href = "/static/js/chat.js" >
< link rel = "modulepreload" href = "/static/js/ui.js" >
< link rel = "modulepreload" href = "/static/js/sessions.js" >
< link rel = "modulepreload" href = "/static/js/markdown.js" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0, interactive-widget=resizes-content, viewport-fit=cover" / >
< / head >
< body >
<!-- Loading overlay — hides all flashing until app is ready -->
< div id = "app-loader" style = "position:fixed;inset:0;z-index:99999;background:var(--bg,#282c34);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;transition:opacity .3s" >
< div id = "loader-wave" style = "color:var(--brand-color,var(--red,#e06c75));font-family:monospace;font-size:11px;opacity:.5" > ▁▂▃< / div >
< / div >
< script nonce = "{{CSP_NONCE}}" >
(function(){
var el=document.getElementById('loader-wave');
if(!el)return;
var frames=['▁▂▃','▂▃▄','▃▄▅','▄▅▆','▅▆▅','▆▅▄','▅▄▃','▄▃▂','▃▂▁'];
var i=0;
var iv=setInterval(function(){
if(!document.getElementById('app-loader')){clearInterval(iv);return}
el.textContent=frames[i%frames.length];
i++;
},150);
setTimeout(function(){var l=document.getElementById('app-loader');if(l){l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
})();
< / script >
<!-- Memory Management Modal -->
< div id = "memory-modal" class = "modal hidden" >
Add dialog accessibility semantics
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
2026-06-01 23:41:25 -04:00
< div class = "modal-content memory-modal-content" role = "dialog" aria-label = "Brain" style = "background:var(--bg)" >
2026-05-31 23:58:26 +09:00
< div class = "modal-header" >
< h4 > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-2px;margin-right:6px" > < path d = "M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" / > < path d = "M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" / > < path d = "M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" / > < / svg > Brain< / h4 >
< button class = "close-btn" id = "close-memory-modal" aria-label = "Close memory modal" > ✖< / button >
< / div >
< div class = "modal-body memory-modal-body" >
< div class = "memory-tabs" >
< button class = "memory-tab active" data-memory-tab = "browse" > < 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:5px" > < path d = "M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z" / > < line x1 = "10" y1 = "22" x2 = "14" y2 = "22" / > < / svg > Memories < span id = "memory-count" class = "memory-count" style = "font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px" > 0< / span > < / button >
< button class = "memory-tab" data-memory-tab = "skills" > < 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:5px" > < polygon points = "13 2 3 14 12 14 11 22 21 10 12 10 13 2" / > < / svg > Skills < span id = "skills-count" class = "memory-count" style = "font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px" > 0< / span > < / button >
< button class = "memory-tab" data-memory-tab = "add" > < 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:5px" > < circle cx = "12" cy = "12" r = "10" / > < line x1 = "12" y1 = "8" x2 = "12" y2 = "16" / > < line x1 = "8" y1 = "12" x2 = "16" y2 = "12" / > < / svg > Add< / button >
< button class = "memory-tab" data-memory-tab = "settings" > < 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:5px" > < 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 .33-1.82 1.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 1.82.33H9a1.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-.33 1.82V9a1.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 > Settings< / button >
< / div >
<!-- ── Browse tab ── -->
< div class = "memory-tab-panel" data-memory-panel = "browse" >
< div class = "admin-card" style = "display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;" >
< div style = "display:flex;align-items:center;gap:8px;margin-bottom:2px;" >
< h2 style = "display:flex;align-items:center;gap:6px;margin:0;padding:0;line-height:1;" > Memories < span id = "memory-count-h2" class = "memory-count" style = "font-size:0.6em;opacity:0.6;font-weight:normal" > < / span > < / h2 >
< span style = "flex:1" > < / span >
< label class = "admin-switch" title = "Include memories in chat context" > < input type = "checkbox" id = "memory-enabled-header-toggle" checked / > < span class = "admin-slider" > < / span > < / label >
< / div >
< p class = "memory-desc doclib-desc" style = "margin-top:6px;" > Long-term facts the AI remembers across chats — recall, edit, or curate.< / p >
< div class = "memory-toolbar" >
< div class = "memory-toolbar-row" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< select id = "memory-sort" class = "memory-sort-select" aria-label = "Sort memories" >
2026-05-31 23:58:26 +09:00
< option value = "newest" > Newest< / option >
< option value = "oldest" > Oldest< / option >
< option value = "alpha" > A-Z< / option >
< option value = "uses" > Most used< / option >
< / select >
< button id = "memory-select-btn" class = "memory-toolbar-btn" title = "Select multiple memories" > Select< / button >
< button id = "memory-tidy-btn" class = "memory-toolbar-btn" title = "AI tidy: deduplicate and clean up memories" > < svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "currentColor" style = "vertical-align:-1px;margin-right:2px;" > < path d = "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" / > < / svg > Tidy< / button >
< / div >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< input type = "text" id = "memory-search" placeholder = "Search memories…" class = "memory-search-input" aria-label = "Search memories" / >
2026-05-31 23:58:26 +09:00
< div id = "memory-category-filters" class = "memory-category-filters" >
< button class = "memory-cat-chip active" data-cat = "all" > all< / button >
< / div >
< / div >
< div id = "memory-bulk-bar" class = "memory-bulk-bar hidden" >
< label class = "memory-bulk-check-all" style = "position:relative;top:0px;" > < input type = "checkbox" id = "memory-select-all" / > All< / label >
< span id = "memory-selected-count" > 0 Selected< / span >
< button id = "memory-bulk-delete" class = "memory-toolbar-btn danger" disabled > < svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-1px;margin-right:3px;" > < polyline points = "3 6 5 6 21 6" / > < path d = "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" / > < path d = "M10 11v6" / > < path d = "M14 11v6" / > < / svg > Delete< / button >
< button id = "memory-bulk-cancel" class = "memory-toolbar-btn" title = "Cancel (Esc)" style = "margin-left:4px;padding:3px 6px;" > < svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < / svg > < / button >
< / div >
< div id = "memory-list" class = "memory-list" > < / div >
< div id = "memory-suggestions-body" class = "memory-suggestions hidden" > < / div >
< / div >
< / div >
<!-- ── Add tab ── -->
< div class = "memory-tab-panel hidden" data-memory-panel = "add" >
< div class = "admin-card" >
< div style = "display:flex;align-items:center;gap:8px;margin-bottom:2px;" >
< h2 style = "margin:0;padding:0;line-height:1;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-2px;margin-right:6px" > < path d = "M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z" / > < line x1 = "10" y1 = "22" x2 = "14" y2 = "22" / > < / svg > Add Memory< / h2 >
< span style = "flex:1" > < / span >
< button id = "memory-import-btn" class = "theme-io-btn" title = "Import memories from a file" style = "height:26px;font-size:12px;" > < svg width = "13" height = "13" 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;" > < path d = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" / > < polyline points = "7 10 12 15 17 10" / > < line x1 = "12" y1 = "15" x2 = "12" y2 = "3" / > < / svg > Import< / button >
< button id = "memory-export-btn" class = "theme-io-btn" title = "Export all memories as JSON" style = "height:26px;font-size:12px;" > < svg width = "13" height = "13" 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;" > < path d = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" / > < polyline points = "17 8 12 3 7 8" / > < line x1 = "12" y1 = "3" x2 = "12" y2 = "15" / > < / svg > Export< / button >
< input type = "file" id = "memory-import-file" accept = ".txt,.md,.pdf,.csv,.log,.json,.py,.js,.html" hidden / >
< / div >
< p class = "memory-desc doclib-desc" style = "margin:4px 0 6px;" >
2026-06-01 15:32:17 +02:00
Import a < code > .txt< / code > , < code > .md< / code > , < code > .pdf< / code > , < code > .csv< / code > , < code > .log< / code > , < code > .json< / code > , < code > .py< / code > , < code > .js< / code > , or < code > .html< / code > file — the AI reads it and suggests candidate memories you can approve.
2026-05-31 23:58:26 +09:00
< / p >
< div class = "memory-add-row" style = "margin-top:8px;" >
< div class = "skill-ph-wrap" style = "flex:1;min-width:0;" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< input type = "text" id = "new-memory-input" placeholder = " " class = "memory-add-input skill-hint-input" aria-label = "New memory text" / >
2026-05-31 23:58:26 +09:00
< span class = "skill-rich-ph" > < span class = "k" > Add a memory< / span > — e.g. 'I prefer concise replies' < svg class = "k" 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-left:4px;" aria-hidden = "true" > < polyline points = "9 10 4 15 9 20" / > < path d = "M20 4v7a4 4 0 0 1-4 4H4" / > < / svg > < / span >
< / div >
2026-06-05 13:17:14 +07:00
< select id = "new-memory-category" class = "memory-edit-cat-select" aria-label = "Memory category" > < / select >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< div class = "admin-card" >
< div style = "display:flex;align-items:baseline;gap:8px;margin-bottom:2px;" >
< h2 style = "margin:0;padding:0;line-height:1;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-2px;margin-right:6px" > < polygon points = "13 2 3 14 12 14 11 22 21 10 12 10 13 2" / > < / svg > Add Skill< / h2 >
< / div >
2026-06-05 19:48:23 +02:00
< p class = "memory-desc doclib-desc" style = "margin-top:6px;" > Import a skill from GitHub or < a href = "https://skills.sh" target = "_blank" rel = "noopener noreferrer" > skills.sh< / a > (folder with < code > SKILL.md< / code > and optional templates).< / p >
< div class = "memory-add-row" style = "margin-top:6px;margin-bottom:10px;" >
< div class = "skill-ph-wrap" style = "flex:1;min-width:0;" >
< input type = "url" id = "skill-import-url" placeholder = " " class = "memory-add-input skill-hint-input" aria-label = "Skill import URL" / >
< span class = "skill-rich-ph" > < span class = "k" > Import URL< / span > — e.g. GitHub tree link to a skill folder< / span >
< / div >
< button type = "button" id = "skill-import-url-btn" class = "theme-io-btn" title = "Import skill from URL" style = "flex:none;height:28px;font-size:12px;" > < svg width = "13" height = "13" 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;" aria-hidden = "true" > < path d = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" / > < polyline points = "7 10 12 15 17 10" / > < line x1 = "12" y1 = "15" x2 = "12" y2 = "3" / > < / svg > Import< / button >
< / div >
< p class = "memory-desc doclib-desc" style = "margin-top:0;" > Or create a skill by hand — title, what it solves, and an approach.< / p >
2026-05-31 23:58:26 +09:00
< div class = "skill-ph-wrap" style = "margin-top:4px;margin-bottom:6px;" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< input type = "text" id = "new-skill-title" placeholder = " " class = "memory-add-input skill-hint-input" aria-label = "Skill title" / >
2026-05-31 23:58:26 +09:00
< span class = "skill-rich-ph" > < span class = "k" > Title< / span > — short name, e.g. “build-vllm-wheel”< / span >
< / div >
< div class = "skill-ph-wrap" style = "margin-bottom:6px;" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< input type = "text" id = "new-skill-problem" placeholder = " " class = "memory-add-input skill-hint-input" aria-label = "When to use this skill" / >
2026-05-31 23:58:26 +09:00
< span class = "skill-rich-ph" > < span class = "k" > When to use< / span > — what problem does this skill solve?< / span >
< / div >
< div class = "skill-ph-wrap" style = "margin-bottom:6px;" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< textarea id = "new-skill-solution" placeholder = " " class = "memory-add-input skill-hint-input" rows = "2" style = "resize:vertical;" aria-label = "How — the approach or steps" > < / textarea >
2026-05-31 23:58:26 +09:00
< span class = "skill-rich-ph skill-rich-ph-top" > < span class = "k" > How< / span > — the approach, steps, commands, or rules to follow< / span >
< / div >
< div class = "skill-ph-wrap" style = "margin-bottom:8px;" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< input type = "text" id = "new-skill-tags" placeholder = " " class = "memory-add-input skill-hint-input" aria-label = "Tags" / >
2026-05-31 23:58:26 +09:00
< span class = "skill-rich-ph" > < span class = "k" > Tags< / span > — comma-separated, e.g. python, build, vllm< / span >
< / div >
< div style = "display:flex;justify-content:flex-end;" >
2026-06-05 19:48:23 +02:00
< button id = "add-skill-btn" class = "confirm-btn confirm-btn-primary" > Add Skill< / button >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< / div >
<!-- ── Skills tab ── -->
< div class = "memory-tab-panel hidden" data-memory-panel = "skills" >
< div class = "admin-card" style = "display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;" >
< div style = "display:flex;align-items:center;gap:8px;margin-bottom:2px;" >
< h2 style = "margin:0;padding:0;line-height:1;" > Skills < span id = "skills-count-h2" class = "memory-count" style = "font-size:0.6em;opacity:0.6;font-weight:normal" > < / span > < / h2 >
< span style = "flex:1" > < / span >
< label class = "admin-switch" title = "Inject relevant skills into chat context" > < input type = "checkbox" id = "skills-enabled-header-toggle" checked / > < span class = "admin-slider" > < / span > < / label >
< / div >
< p class = "memory-desc doclib-desc" style = "margin-top:6px;" > Reusable procedures the AI can call via /skill — sort by confidence to surface the proven ones.< / p >
< div class = "memory-toolbar" >
< div class = "memory-toolbar-row" >
< select id = "skills-sort" class = "memory-sort-select" >
< optgroup label = "Sort" >
< option value = "sort:confidence" > Confidence< / option >
< option value = "sort:uses" > Most used< / option >
< option value = "sort:alpha" > A-Z< / option >
< option value = "sort:recent" > Recent< / option >
< / optgroup >
< optgroup label = "Filter" >
< option value = "filter:all" > All skills< / option >
< option value = "filter:drafts" > Drafts only< / option >
< option value = "filter:published" > Published only< / option >
< option value = "filter:conf95" > Confidence ≤ 95%< / option >
< option value = "filter:conf90" > Confidence ≤ 90%< / option >
< option value = "filter:conf85" > Confidence ≤ 85%< / option >
< option value = "filter:conf80" > Confidence ≤ 80%< / option >
< option value = "filter:conf75" > Confidence ≤ 75%< / option >
< option value = "filter:conf70" > Confidence ≤ 70%< / option >
< / optgroup >
< / select >
< button id = "skills-select-btn" class = "memory-toolbar-btn" title = "Select multiple skills" > Select< / button >
< button id = "skills-audit-btn" class = "memory-toolbar-btn" title = "Test every skill, auto-fix the weak ones, flag what still fails" > < svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "currentColor" style = "vertical-align:-1px;margin-right:3px;" > < path d = "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" / > < / svg > Audit all< / button >
< / div >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< input type = "text" id = "skills-search" placeholder = "Search skills…" class = "memory-search-input" aria-label = "Search skills" / >
2026-05-31 23:58:26 +09:00
< / div >
< div id = "skills-audit-panel" class = "skills-audit-panel hidden" > < / div >
< div id = "skills-bulk-bar" class = "memory-bulk-bar hidden" >
< label class = "memory-bulk-check-all" style = "position:relative;top:0px;" > < input type = "checkbox" id = "skills-select-all" / > All< / label >
< span id = "skills-selected-count" > 0 Selected< / span >
< button id = "skills-bulk-publish" class = "memory-toolbar-btn" disabled title = "Publish selected drafts" style = "margin-left:auto;" > < svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-1px;margin-right:3px;" > < polyline points = "20 6 9 17 4 12" / > < / svg > Approve< / button >
< button id = "skills-bulk-audit" class = "memory-toolbar-btn" disabled title = "Audit selected draft skills" > < svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-1px;margin-right:3px;" > < polygon points = "5 3 19 12 5 21 5 3" / > < / svg > Audit< / button >
< button id = "skills-bulk-delete-nonpassing" class = "memory-toolbar-btn danger" disabled title = "Delete selected duplicates, generic/irrelevant skills, failed audits, and skills below threshold" > < svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-1px;margin-right:3px;" > < path d = "M3 6h18" / > < path d = "M8 6V4h8v2" / > < path d = "M19 6l-1 14H6L5 6" / > < path d = "M10 11l4 4" / > < path d = "M14 11l-4 4" / > < / svg > Delete non passing< / button >
< button id = "skills-bulk-delete" class = "memory-toolbar-btn danger" disabled > < svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-1px;margin-right:3px;" > < polyline points = "3 6 5 6 21 6" / > < path d = "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" / > < path d = "M10 11v6" / > < path d = "M14 11v6" / > < / svg > Delete< / button >
< button id = "skills-bulk-cancel" class = "memory-toolbar-btn" title = "Cancel (Esc)" style = "padding:3px 6px;" > < svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < / svg > < / button >
< / div >
< div id = "skills-list" class = "memory-list" > < / div >
< / div >
< / div >
<!-- ── Settings tab ── -->
< div class = "memory-tab-panel hidden" data-memory-panel = "settings" >
< div class = "admin-card" >
< div style = "display:flex;align-items:center;justify-content:space-between;gap:12px" >
< h2 style = "margin:0" > Auto-extract memories< / h2 >
< label class = "admin-switch" style = "flex-shrink:0" > < input type = "checkbox" id = "auto-memory-toggle" checked / > < span class = "admin-slider" > < / span > < / label >
< / div >
< span class = "admin-toggle-sub" style = "display:block;margin-top:6px" > Automatically extract memories from conversations.< / span >
< / div >
< div class = "admin-card" >
< div style = "display:flex;align-items:center;justify-content:space-between;gap:12px" >
< h2 style = "margin:0" > Auto-extract skills< / h2 >
< label class = "admin-switch" style = "flex-shrink:0" > < input type = "checkbox" id = "auto-skills-toggle" / > < span class = "admin-slider" > < / span > < / label >
< / div >
< span class = "admin-toggle-sub" style = "display:block;margin-top:6px;opacity:0.6" > Automatically draft reusable skills from your workflows. Audit all can publish passing skills using the threshold below.< / span >
< span class = "admin-toggle-sub" style = "display:block;margin-top:6px;opacity:0.6" > The library can grow; cleanup retires weak/duplicate skills only after review.< / span >
< / div >
< div class = "admin-card" >
< div style = "display:flex;align-items:center;justify-content:space-between;gap:12px" >
< h2 style = "margin:0" > Inject Skills< / h2 >
< / div >
< span class = "admin-toggle-sub" style = "display:block;margin-top:6px;opacity:0.6" > Controls how many relevant published or approved skills are added to each agent request.< / span >
< div style = "display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px" >
< span class = "admin-toggle-sub" style = "margin:0" > Max skills per request< / span >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< input type = "number" id = "skill-max-input" min = "0" max = "12" step = "1" value = "3" aria-label = "Max skills to inject" style = "flex-shrink:0;width:72px;background:var(--input-bg,var(--panel));color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px;text-align:right;font-variant-numeric:tabular-nums" / >
2026-05-31 23:58:26 +09:00
< / div >
< span class = "admin-toggle-sub" style = "display:block;margin-top:6px;opacity:0.5" > Set to 0 to disable skill injection.< / span >
< / div >
< div class = "admin-card" >
< div style = "display:flex;align-items:center;justify-content:space-between;gap:12px" >
< h2 style = "margin:0" > Auto-approve skills< / h2 >
< label class = "admin-switch" style = "flex-shrink:0" > < input type = "checkbox" id = "auto-approve-skills-toggle" checked / > < span class = "admin-slider" > < / span > < / label >
< / div >
< span class = "admin-toggle-sub" style = "display:block;margin-top:6px;opacity:0.6" > Audit all publishes passing, necessary skills at or above this confidence. Off = keep audit results as drafts unless manually approved.< / span >
< div style = "display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:6px" >
< span class = "admin-toggle-sub" style = "margin:0" > Minimum confidence< / span >
< span style = "display:flex;align-items:center;gap:8px;flex-shrink:0" >
< span id = "skill-confidence-label" class = "admin-toggle-sub" style = "margin:0;min-width:42px;text-align:right;font-variant-numeric:tabular-nums" > ≥ 85%< / span >
< input type = "range" id = "skill-confidence-slider" class = "preset-range" min = "50" max = "100" step = "5" value = "85" style = "width:120px;margin:0" / >
< / span >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
<!-- Theme Popup (floating panel) -->
< div id = "theme-modal" class = "modal hidden" >
Add dialog accessibility semantics
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
2026-06-01 23:41:25 -04:00
< div id = "theme-popup" class = "modal-content admin-modal-content" role = "dialog" aria-label = "Theme" style = "background:var(--bg)" >
2026-05-31 23:58:26 +09:00
< div class = "modal-header theme-popup-header" id = "theme-popup-header" >
< h4 > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-2px;margin-right:6px" > < circle cx = "12" cy = "12" r = "10" / > < path d = "M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8" / > < circle cx = "8" cy = "9" r = "1.5" fill = "currentColor" / > < circle cx = "15" cy = "14" r = "1.5" fill = "currentColor" / > < circle cx = "9" cy = "15" r = "1.5" fill = "currentColor" / > < / svg > Theme< / h4 >
< button type = "button" class = "theme-opacity-wrap theme-opacity-toggle hidden" id = "theme-opacity-wrap" title = "Fade this window to preview the page behind it" aria-pressed = "false" >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" > < path d = "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" / > < circle cx = "12" cy = "12" r = "3" / > < / svg >
< span class = "theme-opacity-label" > Peek< / span >
< / button >
Add dialog accessibility semantics
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
2026-06-01 23:41:25 -04:00
< button class = "close-btn" id = "close-theme-popup" aria-label = "Close theme" > ✖ < / button >
2026-05-31 23:58:26 +09:00
< / div >
<!-- Theme tabs -->
< div class = "admin-tabs" id = "theme-tabs" >
< button class = "admin-tab active" data-tab = "theme-tab-browse" > < 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:5px" > < circle cx = "12" cy = "12" r = "10" / > < path d = "M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8" / > < circle cx = "8" cy = "9" r = "1.4" fill = "currentColor" / > < circle cx = "15" cy = "14" r = "1.4" fill = "currentColor" / > < circle cx = "9" cy = "15" r = "1.4" fill = "currentColor" / > < / svg > Themes< / button >
< button class = "admin-tab" data-tab = "theme-tab-customize" > < 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:5px" > < path d = "M9.06 11.9l-3.5 3.5a2.85 2.85 0 1 0 4.03 4.03l8.49-8.49a4.5 4.5 0 1 0-6.36-6.36L3.18 12.62" / > < path d = "M14 7l3 3" / > < / svg > Customize< / button >
< / div >
<!-- Tab: Browse themes -->
< div id = "theme-tab-browse" class = "theme-tab-panel" >
< div class = "admin-card" >
< h2 > Default Themes< / h2 >
< div class = "theme-grid" id = "themeGrid" > < / div >
< / div >
< div class = "admin-card" id = "themeUserCard" style = "display:none" >
< h2 > Your Themes< / h2 >
< div class = "theme-grid" id = "themeUserGrid" > < / div >
< / div >
< / div >
<!-- Tab: Customize -->
< div id = "theme-tab-customize" class = "theme-tab-panel" style = "display:none" >
< div class = "admin-card" >
< h2 > Colors< / h2 >
< div class = "theme-custom" id = "themeCustom" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< div class = "color-row" > < label > Background< / label > < input type = "color" id = "clr-bg" > < button class = "color-reset-btn" data-reset = "bg" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > Text< / label > < input type = "color" id = "clr-fg" > < button class = "color-reset-btn" data-reset = "fg" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > Panel< / label > < input type = "color" id = "clr-panel" > < button class = "color-reset-btn" data-reset = "panel" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > Sidebar< / label > < input type = "color" id = "adv-sidebarBg" > < button class = "color-reset-btn" data-reset-adv = "sidebarBg" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > Border< / label > < input type = "color" id = "clr-border" > < button class = "color-reset-btn" data-reset = "border" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > Accent< / label > < input type = "color" id = "clr-red" > < button class = "color-reset-btn" data-reset = "red" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< div class = "theme-adv-toggle" id = "theme-adv-toggle" >
< span class = "theme-adv-arrow" > ▶ < / span > More Colors
< / div >
< div class = "theme-adv-section hidden" id = "themeAdvanced" >
< div class = "theme-adv-group" >
< div class = "theme-adv-group-label" > Chat Bubbles< / div >
< div class = "theme-custom" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< div class = "color-row" > < label > User Chat Bubble< / label > < input type = "color" id = "adv-userBubbleBg" > < button class = "color-reset-btn" data-reset-adv = "userBubbleBg" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > AI Chat Bubble< / label > < input type = "color" id = "adv-aiBubbleBg" > < button class = "color-reset-btn" data-reset-adv = "aiBubbleBg" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > Border Chat Bubble< / label > < input type = "color" id = "adv-bubbleBorder" > < button class = "color-reset-btn" data-reset-adv = "bubbleBorder" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< div class = "theme-adv-group" >
< div class = "theme-adv-group-label" > Sidebar< / div >
< div class = "theme-custom" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< div class = "color-row" > < label > Odysseus Logo< / label > < input type = "color" id = "adv-brandColor" > < button class = "color-reset-btn" data-reset-adv = "brandColor" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label title = "Hamburger menu" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" style = "vertical-align:-2px;" > < line x1 = "3" y1 = "6" x2 = "21" y2 = "6" / > < line x1 = "3" y1 = "12" x2 = "21" y2 = "12" / > < line x1 = "3" y1 = "18" x2 = "21" y2 = "18" / > < / svg > < / label > < input type = "color" id = "adv-hamburgerColor" > < button class = "color-reset-btn" data-reset-adv = "hamburgerColor" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< div class = "theme-adv-group" >
< div class = "theme-adv-group-label" > Chat Input / Prompt Area< / div >
< div class = "theme-custom" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< div class = "color-row" > < label > Input Bg< / label > < input type = "color" id = "adv-inputBg" > < button class = "color-reset-btn" data-reset-adv = "inputBg" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > Input Border< / label > < input type = "color" id = "adv-inputBorder" > < button class = "color-reset-btn" data-reset-adv = "inputBorder" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > Send Btn< / label > < input type = "color" id = "adv-sendBtnBg" > < button class = "color-reset-btn" data-reset-adv = "sendBtnBg" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > Send Hover< / label > < input type = "color" id = "adv-sendBtnHover" > < button class = "color-reset-btn" data-reset-adv = "sendBtnHover" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< div class = "theme-adv-group" >
< div class = "theme-adv-group-label" > Code Blocks< / div >
< div class = "theme-custom" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< div class = "color-row" > < label > Code Bg< / label > < input type = "color" id = "adv-codeBg" > < button class = "color-reset-btn" data-reset-adv = "codeBg" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
< div class = "color-row" > < label > Code Text< / label > < input type = "color" id = "adv-codeFg" > < button class = "color-reset-btn" data-reset-adv = "codeFg" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< div class = "theme-adv-group" >
< div class = "theme-adv-group-label" > Controls< / div >
< div class = "theme-custom" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< div class = "color-row" > < label > Toggle On< / label > < input type = "color" id = "adv-toggleActive" > < button class = "color-reset-btn" data-reset-adv = "toggleActive" title = "Reset this color" aria-label = "Reset color" > ↺ < / button > < / div >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< div class = "theme-adv-group" >
< div class = "theme-adv-group-label" > Custom Fonts< / div >
< div class = "theme-fd-label" style = "opacity:0.4;" > Drop < code > .woff2< / code > , < code > .ttf< / code > , or < code > .otf< / code > files into < code > static/fonts/custom/< / code > and reload — they'll appear in the Font dropdown above.< / div >
< / div >
< button class = "theme-adv-clear-btn" id = "theme-adv-clear" > Clear Advanced Overrides< / button >
< / div >
<!-- Color Harmony Generator (its own card) -->
< div class = "admin-card" id = "theme-harmony-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:6px" > < circle cx = "13.5" cy = "6.5" r = "2.5" / > < circle cx = "19" cy = "13" r = "2.5" / > < circle cx = "6" cy = "12" r = "2.5" / > < circle cx = "10" cy = "20" r = "2.5" / > < / svg > Color Harmony< / h2 >
< div class = "theme-harmony-row" >
< div class = "theme-fd-group" >
< label class = "theme-fd-label" > Accent Color< / label >
< div class = "color-row" style = "justify-content:flex-start;gap:8px;" >
< input type = "color" id = "harmony-accent" value = "#e06c75" >
< span id = "harmony-accent-hex" style = "font-size:11px;opacity:0.65;font-family:ui-monospace,monospace;letter-spacing:0.02em;" > #e06c75< / span >
< / div >
< / div >
< div class = "theme-fd-group" >
< label class = "theme-fd-label" > Harmony< / label >
< select id = "harmony-type" class = "theme-fd-select" >
< option value = "complementary" > Complementary< / option >
< option value = "analogous" > Analogous< / option >
< option value = "triadic" > Triadic< / option >
< option value = "monochromatic" > Monochromatic< / option >
< / select >
< / div >
< / div >
< div class = "theme-harmony-row" style = "margin-top:6px;" >
< div class = "theme-fd-group" >
< label class = "theme-fd-label" > Mode< / label >
< select id = "harmony-mode" class = "theme-fd-select" >
< option value = "dark" > Dark< / option >
< option value = "light" > Light< / option >
< / select >
< / div >
< div class = "theme-fd-group" style = "justify-content:flex-end;" >
< button id = "harmony-generate-btn" class = "harmony-generate-btn" > Generate< / button >
< / div >
< / div >
< div id = "harmony-preview" class = "harmony-preview" > < / div >
< / div >
< div class = "admin-card" >
< h2 > Font & Layout< / h2 >
< div class = "theme-fd-row" >
< div class = "theme-fd-group" >
< label class = "theme-fd-label" > Font< / label >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< select id = "theme-font-select" class = "theme-fd-select" aria-label = "Font" >
2026-05-31 23:58:26 +09:00
< option value = "mono" > Monospace< / option >
< option value = "sans" > Sans-serif< / option >
< option value = "serif" > Serif< / option >
< / select >
< / div >
< div class = "theme-fd-group" >
< label class = "theme-fd-label" > Density< / label >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< select id = "theme-density-select" class = "theme-fd-select" aria-label = "Density" >
2026-05-31 23:58:26 +09:00
< option value = "compact" > Compact< / option >
< option value = "comfortable" > Comfortable< / option >
< option value = "spacious" > Spacious< / option >
< / select >
< / div >
< div class = "theme-fd-group" id = "theme-frosted-group" >
< label class = "theme-fd-label" for = "theme-frosted-toggle" > Frosted< / label >
< label class = "admin-switch" style = "margin-top:4px;" >
< input type = "checkbox" id = "theme-frosted-toggle" >
< span class = "admin-slider" > < / span >
< / label >
< / div >
< / div >
< div class = "theme-fd-row" >
< div class = "theme-fd-group" style = "flex:1 1 0;" >
< label class = "theme-fd-label" > Background / Effect< / label >
< select id = "theme-bg-pattern-select" class = "theme-fd-select" >
< option value = "none" > Solid< / option >
< option value = "dots" > Dots< / option >
< option value = "synapse" > Synapse< / option >
< option value = "rain" > Rain< / option >
< option value = "constellations" > Constellations< / option >
< option value = "perlin-flow" > Perlin Flow< / option >
< option value = "petals" > Petals< / option >
< option value = "sparkles" > Sparkles< / option >
< option value = "embers" > Embers< / option >
< / select >
< / div >
< div class = "theme-fd-group" style = "flex:0 0 auto;align-items:center;" >
< label class = "theme-fd-label" > < / label >
< div class = "color-row" style = "margin:0;padding:0;" >
< input type = "color" id = "theme-bg-effect-color" value = "#9cdef2" title = "Effect color" >
< button class = "color-reset-btn" data-reset-effect title = "Reset to text color" > ↺ < / button >
< / div >
< / div >
< / div >
< div class = "theme-fd-row" >
< div class = "theme-fd-group" id = "theme-bg-intensity-group" style = "flex:1 1 0;" >
< label class = "theme-fd-label" > Intensity< / label >
< input type = "range" id = "theme-bg-intensity" class = "theme-fd-range" min = "0" max = "100" step = "5" value = "100" >
< / div >
< div class = "theme-fd-group" id = "theme-bg-size-group" style = "flex:1 1 0;" >
< label class = "theme-fd-label" > Size< / label >
< input type = "range" id = "theme-bg-size" class = "theme-fd-range" min = "30" max = "250" step = "10" value = "100" >
< / div >
< / div >
< / div >
< div class = "admin-card" style = "margin-top:8px;" >
< h2 > Save / Share< / h2 >
< div class = "theme-save-row" id = "theme-save-row" >
< input type = "text" id = "theme-save-name" placeholder = "Theme name..." maxlength = "32" >
< button id = "theme-save-go" > Save< / button >
< / div >
< div class = "theme-save-error" id = "theme-save-error" > < / div >
< div class = "theme-io-row" style = "margin-top:6px;" >
< button id = "theme-import-btn" class = "theme-io-btn" title = "Import a theme from JSON" > ⤒ Import< / button >
< button id = "theme-export-btn" class = "theme-io-btn" title = "Export current colors as JSON" > ⤓ Export< / button >
< / div >
< textarea id = "theme-import-area" class = "theme-import-area hidden" placeholder = "Paste theme JSON here..." rows = "3" > < / textarea >
< div class = "theme-import-actions hidden" id = "theme-import-actions" >
< button id = "theme-import-go" class = "theme-io-btn" > Apply< / button >
< button id = "theme-import-cancel" class = "theme-io-btn" > Cancel< / button >
< / div >
< / div >
< button id = "theme-reset-btn" > Reset to Default< / button >
< / div >
< / div >
< / div >
< div id = "mobile-backdrop" > < / div >
< button id = "mobile-menu-btn" aria-label = "Toggle sidebar" > ☰ < / button >
< button class = "hamburger-btn" id = "hamburger-btn" title = "Show sidebar" > < svg width = "18" height = "18" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < line x1 = "3" y1 = "6" x2 = "21" y2 = "6" / > < line x1 = "3" y1 = "12" x2 = "21" y2 = "12" / > < line x1 = "3" y1 = "18" x2 = "21" y2 = "18" / > < / svg > < / button >
<!-- new session button removed — use + in send button instead -->
< div class = "icon-rail" id = "icon-rail" >
< div class = "rail-resize-handle" id = "rail-resize-handle" > < / div >
<!-- Static: core actions -->
< button class = "icon-rail-btn" id = "rail-search-btn" title = "Search conversations (Ctrl+K)" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < circle cx = "10" cy = "10" r = "7" / > < path d = "M21 21l-4.35-4.35" / > < / svg > < / button >
< button class = "icon-rail-btn rail-new-chat" id = "rail-new-session" title = "New chat" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" stroke-linejoin = "round" > < line x1 = "12" y1 = "5" x2 = "12" y2 = "19" / > < line x1 = "5" y1 = "12" x2 = "19" y2 = "12" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-delete-session" title = "Delete session" > ✕ < / button >
< div class = "rail-separator" > < / div >
<!-- Dynamic contextual indicators (shown only while active) -->
< button class = "icon-rail-btn rail-dynamic" id = "rail-chats" title = "Chat ready" style = "display:none" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" / > < / svg > < / button >
< button class = "icon-rail-btn rail-dynamic" id = "rail-documents" title = "Documents" style = "display:none" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" / > < polyline points = "14 2 14 8 20 8" / > < line x1 = "8" y1 = "13" x2 = "16" y2 = "13" / > < line x1 = "8" y1 = "17" x2 = "13" y2 = "17" / > < / svg > < / button >
<!-- Tool launchers — always visible, alphabetical -->
< button class = "icon-rail-btn" id = "rail-calendar" title = "Calendar" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "4" width = "18" height = "18" rx = "2" / > < line x1 = "16" y1 = "2" x2 = "16" y2 = "6" / > < line x1 = "8" y1 = "2" x2 = "8" y2 = "6" / > < line x1 = "3" y1 = "10" x2 = "21" y2 = "10" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-compare" title = "Compare" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "18" cy = "18" r = "3" / > < circle cx = "6" cy = "6" r = "3" / > < path d = "M13 6h3a2 2 0 0 1 2 2v7" / > < path d = "M11 18H8a2 2 0 0 1-2-2V9" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-cookbook" title = "Cookbook" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "1.4" stroke-linecap = "round" stroke-linejoin = "round" style = "opacity:0.7" > < path d = "M12 7v14" / > < path d = "M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-research" title = "Deep Research" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" > < circle cx = "11" cy = "11" r = "8" / > < path d = "M21 21l-4.35-4.35" / > < line x1 = "11" y1 = "8" x2 = "11" y2 = "14" / > < line x1 = "8" y1 = "11" x2 = "14" y2 = "11" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-email" title = "Email" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "2" y = "4" width = "20" height = "16" rx = "2" / > < path d = "m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-gallery" title = "Gallery" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "3" width = "18" height = "18" rx = "2" / > < circle cx = "8.5" cy = "8.5" r = "1.5" / > < path d = "M21 15l-5-5L5 21" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-archive" title = "Library" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M4 19.5A2.5 2.5 0 0 1 6.5 17H20" / > < path d = "M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" / > < path d = "M9 7h6M9 11h4" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-memory" title = "Brain" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" / > < path d = "M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" / > < path d = "M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-notes" title = "Notes" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M5 3h10l4 4v14H5z" / > < path d = "M15 3v5h5" / > < path d = "M8 17.5 15.5 10l2.5 2.5L10.5 20H8z" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-tasks" title = "Tasks" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "4" width = "18" height = "18" rx = "2" / > < line x1 = "16" y1 = "2" x2 = "16" y2 = "6" / > < line x1 = "8" y1 = "2" x2 = "8" y2 = "6" / > < line x1 = "3" y1 = "10" x2 = "21" y2 = "10" / > < path d = "M9 16l2 2 4-4" / > < / svg > < / button >
< button class = "icon-rail-btn" id = "rail-theme" title = "Theme" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "12" cy = "12" r = "10" / > < path d = "M12 2a10 10 0 0 0 0 20 5 5 0 0 0 5-5 3 3 0 0 0-3-3h-2a3 3 0 0 1-3-3 5 5 0 0 1 5-5" / > < / svg > < / button >
< div style = "flex:1" > < / div >
<!-- Static: bottom -->
< button class = "icon-rail-btn" id = "rail-settings" title = "Settings" > < svg width = "16" height = "16" 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-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 > < / button >
< / div >
< nav class = "sidebar" id = "sidebar" role = "navigation" aria-label = "Sidebar" >
< div class = "sidebar-resize-handle" id = "sidebar-resize-handle" > < / div >
< div class = "sidebar-header" >
< button class = "sidebar-hamburger" id = "sidebar-toggle-btn" title = "Toggle sidebar" > < svg width = "18" height = "18" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < line x1 = "3" y1 = "6" x2 = "21" y2 = "6" / > < line x1 = "3" y1 = "12" x2 = "21" y2 = "12" / > < line x1 = "3" y1 = "18" x2 = "21" y2 = "18" / > < / svg > < / button >
< div class = "sidebar-brand" id = "sidebar-brand-btn" style = "cursor:pointer;" title = "New chat" >
< span class = "sidebar-brand-title" > Odysseus< / span >
< / div >
< / div >
< div class = "sidebar-inner" >
< div class = "list-item" id = "sidebar-new-chat-btn" title = "New chat" >
< svg class = "sidebar-action-icon" width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" stroke-linejoin = "round" style = "position:relative;left:-2px;" > < line x1 = "12" y1 = "5" x2 = "12" y2 = "19" / > < line x1 = "5" y1 = "12" x2 = "19" y2 = "12" / > < / svg >
< span class = "grow" style = "position:relative;left:-3px;" > New Chat< / span >
< / div >
< div class = "list-item" id = "sidebar-search-btn" title = "Search conversations (Ctrl+K)" >
< svg class = "sidebar-action-icon" width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < circle cx = "10" cy = "10" r = "7" / > < path d = "M21 21l-4.35-4.35" / > < / svg >
< span class = "grow" style = "position:relative;left:-1px;" > Search< / span >
< / div >
<!-- Assistant sidebar entry removed — its log + settings live inside
the Tasks modal (Activity + Settings tabs). The backend
assistant session is still maintained for log_to_assistant()
writes from scheduled tasks. -->
< div class = "section" id = "sessions-section" >
< div class = "section-header-flex" >
2026-06-01 03:52:10 +01:00
< span class = "section-title" id = "chats-section-title" > < svg class = "section-icon" width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" / > < / svg > < span id = "chats-section-label" class = "section-title-label" > Chats< / span > < span id = "chats-notif-dot" class = "sidebar-notif-dot" style = "display:none" > < / span > < / span >
< div style = "position:relative; display:inline-block; display:flex; gap:4px; align-items:center;" >
< button type = "button" class = "section-header-btn chats-manage-btn" id = "chats-library-btn" title = "Manage Chats (Library)" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
2026-06-01 20:56:11 +09:00
< path d = "M4 19.5A2.5 2.5 0 0 1 6.5 17H20" / >
< path d = "M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" / >
< path d = "M9 7h6M9 11h4" / >
2026-06-01 03:52:10 +01:00
< / svg >
< / button >
2026-05-31 23:58:26 +09:00
< button type = "button" class = "section-header-btn" id = "session-sort-btn" title = "Sort sessions" >
< svg class = "sort-icon" width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
< line x1 = "4" y1 = "6" x2 = "20" y2 = "6" / >
< line x1 = "4" y1 = "12" x2 = "14" y2 = "12" / >
< line x1 = "4" y1 = "18" x2 = "9" y2 = "18" / >
< / svg >
< svg class = "sort-spinner" width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" style = "display:none; animation: spin 0.7s linear infinite;" >
< path d = "M12 2a10 10 0 0 1 10 10" / >
< / svg >
< / button >
< div id = "session-sort-dropdown" class = "dropdown sort-dropdown" style = "display:none;" >
< div class = "dropdown-item sort-option sort-dropdown-item" data-sort = "active" > Last Active< / div >
< div class = "dropdown-item sort-option sort-dropdown-item" data-sort = "newest" > Newest First< / div >
< div class = "dropdown-item sort-option sort-dropdown-item" data-sort = "group" > By Folder< / div >
< div class = "dropdown-item sort-dropdown-item sort-dropdown-sep" id = "auto-sort-sessions-row" style = "display:flex;align-items:center;padding:0;" >
< span id = "auto-sort-sessions-btn" style = "flex:1;padding:5px 10px;cursor:pointer;display:inline-flex;align-items:center;gap:4px;" >
< span class = "auto-sort-icon" > < svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "currentColor" style = "vertical-align:-1px;margin-right:2px;" > < path d = "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" / > < / svg > Tidy< / span >
< span class = "auto-sort-spinner" style = "display:none;" > Sorting...< / span >
< / span >
< button type = "button" id = "auto-sort-sessions-more" title = "Tidy options" aria-label = "Tidy options" style = "background:none;border:none;border-left:1px solid var(--border);color:inherit;cursor:pointer;padding:5px 8px;font-size:9px;opacity:0.7;" > < svg width = "8" height = "8" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" stroke-linejoin = "round" > < polyline points = "6 9 12 15 18 9" / > < / svg > < / button >
< / div >
< div class = "dropdown-item sort-dropdown-item" id = "auto-sort-sessions-noai-btn" style = "display:none;padding-left:24px;" >
Tidy < span class = "auto-sort-noai-spinner" style = "display:none;font-size:9px;opacity:0.6;margin-left:4px;" > Cleaning...< / span >
< / div >
< div class = "dropdown-item rearrange-toggle sort-dropdown-item sort-dropdown-sep" id = "session-rearrange-toggle" >
↑ ↓ Rearrange < span class = "rearrange-check" style = "float:right; opacity:0;" > • < / span >
< / div >
< div class = "dropdown-item sort-dropdown-item" id = "session-select-from-dropdown" >
● Select
< / div >
< / div >
< / div >
< / div >
< div id = "session-bulk-bar" class = "session-bulk-bar hidden" >
< span id = "session-select-all-dot" style = "cursor:pointer;font-size:14px;opacity:0.4;user-select:none;" > ○< / span > < input type = "checkbox" id = "session-select-all" style = "display:none;" > < span style = "font-size:10px;cursor:pointer;opacity:0.5;" id = "session-select-all-label" > All< / span >
< div style = "margin-left:auto;display:flex;gap:2px;align-items:center;" >
< button class = "session-bulk-btn" id = "session-bulk-archive" title = "Archive selected" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "2" y = "3" width = "20" height = "5" rx = "1" / > < path d = "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" / > < path d = "M10 12h4" / > < / svg > < / button >
< button class = "session-bulk-btn session-bulk-btn-danger" id = "session-bulk-delete" title = "Delete selected" > < 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 = "M3 6h18" / > < path d = "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" / > < path d = "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" / > < / svg > < / button >
< button class = "session-bulk-btn" id = "session-bulk-cancel" title = "Cancel" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < / svg > < / button >
< / div >
< / div >
< div id = "session-list" role = "listbox" > < / div >
< / div >
<!-- Hidden dropdown for session actions -->
< div id = "session-actions-dropdown" class = "dropdown hidden" >
< div class = "dropdown-item" id = "rename-session-option" >
< h4 > Rename< / h4 >
< p > Change the session name< / p >
< / div >
< div class = "dropdown-item" id = "delete-session-option" >
< h4 > Delete< / h4 >
< p > Remove this session permanently< / p >
< / div >
< div class = "dropdown-item" id = "memory-session-option" >
< h4 > Memory< / h4 >
< p > Extract memories from this session< / p >
< / div >
< / div >
< div class = "section" id = "email-section" >
< div class = "section-header-flex" >
< span class = "section-title" id = "email-section-title" style = "cursor:pointer;" title = "Open email inbox" > < svg class = "section-icon" width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "2" y = "4" width = "20" height = "16" rx = "2" / > < path d = "m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" / > < / svg > < span > Email< / span > < / span >
< button type = "button" class = "section-header-btn list-item-plus-btn" id = "email-compose-btn" title = "Compose email" >
< svg class = "list-item-plus-icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" stroke-linejoin = "round" > < line x1 = "12" y1 = "5" x2 = "12" y2 = "19" / > < line x1 = "5" y1 = "12" x2 = "19" y2 = "12" / > < / svg >
< span class = "list-item-plus-label" > new< / span >
< / button >
<!-- Unread dot is a direct header child placed AFTER the compose
button (it used to live inside the title, before the button).
With the title's flex:1 eating the free space, this lands the
dot at the far-right corner with the + just to its left —
i.e. the dot and + swap places when the dot appears. -->
< span id = "email-unread-dot" class = "sidebar-notif-dot" style = "display:none" > < / span >
< / div >
< / div >
< div class = "section" id = "models-section" >
< div class = "section-header-flex" >
< span class = "section-title" > Models< / span >
< div style = "position:relative; display:inline-block;" >
< button type = "button" class = "section-header-btn" id = "model-sort-btn" title = "Sort models" >
< svg class = "sort-icon" width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
< line x1 = "4" y1 = "6" x2 = "20" y2 = "6" / >
< line x1 = "4" y1 = "12" x2 = "14" y2 = "12" / >
< line x1 = "4" y1 = "18" x2 = "9" y2 = "18" / >
< / svg >
< / button >
< div id = "model-sort-dropdown" class = "dropdown sort-dropdown" style = "display:none;" >
< div class = "dropdown-item sort-option sort-dropdown-item" data-sort = "alpha" > A-Z< / div >
< div class = "dropdown-item sort-option sort-dropdown-item" data-sort = "last-used" > Last used< / div >
< div class = "dropdown-item sort-option sort-dropdown-item" data-sort = "most-used" > Most used< / div >
< div class = "dropdown-item rearrange-toggle sort-dropdown-item sort-dropdown-sep" id = "model-rearrange-toggle" >
↑ ↓ Rearrange < span class = "rearrange-check" style = "float:right; opacity:0;" > • < / span >
< / div >
< / div >
< / div >
< / div >
< div id = "models" >
< div class = "models-row" >
< select id = "model-select" aria-label = "Select model" style = "flex: 1; padding: 6px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg);" > < / select >
< button type = "button" id = "btn-model-chat" class = "model-chat-btn" aria-label = "Add model chat" style = "transition: all 0.2s ease;" > < span class = "model-chat-btn-label" > + Chat< / span > < / button >
< / div >
< / div >
< / div >
< div class = "section" id = "tools-section" >
< div class = "section-header-flex" >
< span class = "section-title" > < svg class = "section-icon" width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" / > < / svg > Tools< / span >
< / div >
< div class = "list-item" id = "tool-memory-btn" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="flex-shrink:0;opacity:0.5;">
< path d = "M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" / >
< path d = "M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" / >
< path d = "M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" / >
< / svg >
< span class = "grow" > Brain< / span >
< / div >
< div class = "list-item" id = "tool-calendar-btn" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="flex-shrink:0;opacity:0.5;">
< rect x = "3" y = "4" width = "18" height = "18" rx = "2" / >
< line x1 = "16" y1 = "2" x2 = "16" y2 = "6" / >
< line x1 = "8" y1 = "2" x2 = "8" y2 = "6" / >
< line x1 = "3" y1 = "10" x2 = "21" y2 = "10" / >
< / svg >
< span class = "grow" > Calendar< / span >
< / div >
< div class = "list-item" id = "tool-compare-btn" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "flex-shrink:0;opacity:0.5;" > < circle cx = "18" cy = "18" r = "3" / > < circle cx = "6" cy = "6" r = "3" / > < path d = "M13 6h3a2 2 0 0 1 2 2v7" / > < path d = "M11 18H8a2 2 0 0 1-2-2V9" / > < / svg >
< span class = "grow" > Compare< / span >
< / div >
< div class = "list-item" id = "tool-cookbook-btn" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"
style="flex-shrink:0;opacity:0.5;">
< path d = "M12 7v14" / >
< path d = "M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" / >
< / svg >
< span class = "grow" > Cookbook< / span >
Cookbook polish: auto-reconnect, ctx slider fixes, scoring, lots of UI
Backend (services/hwfit + routes):
- VRAM column sort now shows global highest first (was special-cased to
ascending then truncated top-N, which made "highest VRAM" mathematically
unreachable). Every column path uses reverse=True for the truncation.
- Hardware probe cache TTL 30min -> 24h so changing filters doesn't keep
re-probing the rig during a session; Rescan button still forces fresh.
- Multi-GPU rigs filter GGUF Q*/IQ quants (vLLM/SGLang can't serve them);
default non-prequantized to BF16 on 2+ GPUs.
- AWQ / AWQ-8bit / GPTQ-8bit get a -1.0 quality penalty so FP8 wins ties.
- Version-aware tiebreaker (parse Mn.n / Vn) — MiniMax-M2.7 ranks above M2.5.
- hf_models.json: zai-org/GLM-5.1 added; zai-org/GLM-5 quantization flipped
Q4_K_M -> BF16. DeepSeek-V4-Flash / -Pro + their -Base variants registered
with new FP4-MoE-Mixed / FP8-Mixed quant keys (calibrated BPP from the
actual 156 GB / 284 GB disk footprints).
- New FP4-MoE-Mixed + FP8-Mixed entries in QUANT_BPP / QUANT_SPEED_MULT /
QUANT_QUALITY_PENALTY / QUANT_BYTES_PER_PARAM / PREQUANTIZED_PREFIXES.
Frontend — Scan/Download:
- Engine + Quant swapped in the toolbar; Quant defaults to "All".
- Ctx (range slider) ported from origin/main: 8k/16k/32k/50k/128k/Max. Drag
re-sorts by vram ascending (smallest fitting first); back to Max → score.
- Ctx slider rail now visible — was background:transparent in a duplicate
later-cascade rule. Hardcoded grey + !important.
- Search input moved to the far right of the toolbar.
- Type/Standard default; "Context" not uppercased; Search placeholder dimmed.
- Engine "?" + Quant "?" inline help chips inside their dropdown boxes.
- Fit-column dot toggles fit-only filter; un-toggling re-sorts by VRAM desc.
- Quant column truncates to 9 chars + ellipsis ("FP4-MoE-M..."), full in
tooltip. Smart title-suffix strips the parts already in the repo name
(QuantTrio/MiniMax-M2-AWQ + quant AWQ-4bit -> just "(4bit)").
- Conditional warning for safetensors models on non-GPU rigs only.
- Dependency Install / Installed / Installed▾ / N/A all 75.85px wide.
- Rebuild llama.cpp moved into the llama_cpp dep row, styled as a tag.
- Foldable Download admin-card (h2 chevron); line under h2 only when folded.
- HF token save gets a green ✓ + "Saved" flash.
- Cached scan no longer counts stalled rows as downloaded.
- Footer: "Request it →" link with GitHub mark to the public discussion
(#1962) for model-add requests.
Frontend — Running tab:
- Strict download-finish check (DOWNLOAD_OK or /snapshots/, not bare
"Download complete"). True overall % for multi-shard downloads:
((N-1)+frac)/total instead of hf_transfer's per-shard aggregate.
- ETA in the uptime ticker: "downloading: 12m 34s · ETA 1h 23m".
- Clear button kills the tmux session too; if the output still shows a
live shard line, the pill is hidden + relabels as "reconnect" + revives
on click.
- Self-heal: on cookbook open AND every bg-monitor cycle (10s, throttled
to 8s), scan persisted done/error/crashed downloads and probe their
tmux session — if alive, flip status back to running and reattach.
- Per-launch zombie probe: clicking Download on a model whose persisted
state is done but tmux is still alive revives the existing task and
refuses to start a duplicate.
- Pre-launch GPU probe: vllm / sglang / diffusers serve check
/api/cookbook/gpus first; warns + confirms if no GPU is visible.
- Server-side state guard: rejects "done" POSTs for downloads lacking
DOWNLOAD_OK / DOWNLOAD_FAILED / /snapshots/ when the last-mentioned
shard is N<total — stale tabs can't poison persisted state any more.
- Running count includes tasks whose output looks active even if persisted
status got stuck. Dir text on the running row, font matched to uptime.
Serve panel:
- Ctx text input always resets to model max on open (default 20000 when
metadata is missing).
- Max Seqs default 8 -> 4. KV Cache dtype select 32px tall.
- Lightning icon on Launch (same as Action toggle).
- Diagnosis card simplified (no fold/copy/dismiss), suggestion font
matches body; action buttons get icons on the left (Retry/Copy/Edit/
Install/Kill/Switch/etc.).
- Incomplete-download serve warning when model status is
downloading / stalled / has_incomplete.
- MTP "?" tooltip ("supported on a few model families … up to ~3× faster").
2026-06-03 20:25:25 +09:00
< span id = "cookbook-bg-status" style = "display:none;font-size:9px;opacity:0.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-right:12px;flex-shrink:1;min-width:0;position:relative;top:-1px;" > < / span >
2026-05-31 23:58:26 +09:00
< span class = "cookbook-notif-dot" id = "cookbook-notif-dot" style = "display:none;margin-left:6px;margin-right:4px;position:relative;top:-1px;left:0px;" > < / span >
< / div >
< div class = "list-item" id = "tool-research-btn" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" style = "flex-shrink:0;opacity:0.5;" > < circle cx = "11" cy = "11" r = "8" / > < path d = "M21 21l-4.35-4.35" / > < line x1 = "11" y1 = "8" x2 = "11" y2 = "14" / > < line x1 = "8" y1 = "11" x2 = "14" y2 = "11" / > < / svg >
< span class = "grow" > Deep Research< / span >
< / div >
< div class = "list-item" id = "tool-gallery-btn" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="flex-shrink:0;opacity:0.5;">
< rect x = "3" y = "3" width = "18" height = "18" rx = "2" / >
< circle cx = "8.5" cy = "8.5" r = "1.5" / >
< path d = "M21 15l-5-5L5 21" / >
< / svg >
< span class = "grow" > Gallery< / span >
< / div >
< div class = "list-item" id = "tool-library-btn" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "flex-shrink:0;opacity:0.5;" >
< path d = "M4 19.5A2.5 2.5 0 0 1 6.5 17H20" / > < path d = "M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" / > < path d = "M9 7h6M9 11h4" / >
< / svg >
< span class = "grow" > Library< / span >
< button type = "button" class = "list-item-plus-btn" id = "library-new-doc-btn" title = "New document" >
< svg class = "list-item-plus-icon" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" stroke-linejoin = "round" style = "width:11px;height:11px;" > < line x1 = "12" y1 = "5" x2 = "12" y2 = "19" / > < line x1 = "5" y1 = "12" x2 = "19" y2 = "12" / > < / svg >
< span class = "list-item-plus-label" > new< / span >
< / button >
< / div >
< div class = "list-item" id = "tool-notes-btn" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="flex-shrink:0;opacity:0.5;">
< path d = "M5 3h10l4 4v14H5z" / >
< path d = "M15 3v5h5" / >
< path d = "M8 17.5 15.5 10l2.5 2.5L10.5 20H8z" / >
< / svg >
< span class = "grow" > Notes< / span >
< / div >
< div class = "list-item" id = "tool-tasks-btn" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="flex-shrink:0;opacity:0.5;">
< rect x = "3" y = "4" width = "18" height = "18" rx = "2" / >
< line x1 = "16" y1 = "2" x2 = "16" y2 = "6" / >
< line x1 = "8" y1 = "2" x2 = "8" y2 = "6" / >
< line x1 = "3" y1 = "10" x2 = "21" y2 = "10" / >
< path d = "M9 16l2 2 4-4" / >
< / svg >
< span class = "grow" > Tasks< / span >
< span id = "assistant-notif-dot" class = "sidebar-notif-dot" style = "display:none" > < / span >
< / div >
< div class = "list-item" id = "tool-theme-btn" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="flex-shrink:0;opacity:0.5;">
< circle cx = "12" cy = "12" r = "10" / >
< path d = "M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8" / >
< circle cx = "8" cy = "9" r = "1.5" fill = "currentColor" / >
< circle cx = "15" cy = "14" r = "1.5" fill = "currentColor" / >
< circle cx = "9" cy = "15" r = "1.5" fill = "currentColor" / >
< / svg >
< span class = "grow" > Theme< / span >
< / div >
< / div >
< / div >
< div class = "sidebar-user-bar" id = "sidebar-user-bar" >
< div class = "user-bar-left" id = "user-bar-profile" >
< div class = "user-bar-avatar" id = "user-bar-avatar" > < / div >
< span class = "user-bar-name" id = "user-bar-name" > User< / span >
< / div >
< div class = "user-bar-actions" >
< button type = "button" class = "user-bar-btn" id = "user-bar-settings" title = "Settings" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" > < 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 >
< / button >
< / div >
< / div >
< / nav >
Improve accessibility across core flows (#86)
First incremental pass at issue #86, focused on the universal entry
points and primary navigation. All changes verified in-browser with the
axe-core engine (0 violations on the surfaces below) plus manual keyboard
testing, on both desktop (1280px) and mobile (390px).
Login / first-run setup (static/login.html)
- Add a real <h1>, wrap content in <main> + <footer> landmarks.
- Mark the decorative boat SVG aria-hidden.
- Errors now use role="alert" so screen readers announce them.
- "Remember me" checkbox is keyboard-focusable (was display:none) with an
accessible name and a focus ring; dynamic 2FA field gets a linked label.
- Darken the brand-red submit button so white text clears WCAG AA 4.5:1
(was ~3.2:1); add visible :focus-visible rings.
App shell (static/index.html, static/style.css)
- Remove invalid role="region" from the <main> chat container (it was
overriding the implicit main landmark).
- Add a persistent, visually-hidden <h1> inside <main> so the page always
exposes one logical level-1 heading — works even on mobile where the
sidebar (with the visible brand) is hidden off-canvas.
- Add a reusable .a11y-visually-hidden utility.
- Raise chat-title, model-picker, settings-helper and notes text contrast
above 4.5:1 (were 2.8-3.9:1).
Keyboard nav + dialogs (static/js/a11y.js - new)
- Make the click-only <div> sidebar navigation (New Chat, Search, Brain,
Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes,
Tasks, Theme, account) focusable and Enter/Space-activatable, announced
as buttons (skipping role=button where a nested control would create a
nested-interactive violation). Visible focus ring reused from existing
.list-item:focus-visible.
- Upgrade modals (.modal-content and the docked .notes-pane) to labelled
role="dialog" + aria-modal, and normalise their title to heading level 2
so heading order stays valid. A MutationObserver covers runtime-rendered
rows and modals.
Decorative background canvases (static/js/theme.js)
- Mark all 7 bg-effect canvases aria-hidden.
Notes & Tasks (static/js/notes.js, static/js/tasks.js)
- Label the icon-only Note/To-do toggle pills (fixes a critical
button-name issue) and track aria-pressed state.
- Improve Notes header-button + empty-state contrast.
- Give the Tasks sort <select> an accessible name (fixes a critical
select-name issue).
Remaining data-dense tool modals (Tasks cards, Calendar, Gallery, Email,
Cookbook, Compare, Deep Research) still have muted-text contrast to polish
and are the next incremental step, per the issue's own guidance.
2026-06-01 21:05:43 +02:00
< main class = "chat-container welcome-active" id = "chat-container" aria-label = "Chat area" aria-busy = "false" >
<!-- Persistent page heading for assistive tech. Visually hidden so it
never affects layout, but always present inside the main landmark
(the sidebar that shows the visible brand is hidden off-canvas on
mobile) so the page always exposes a single level-1 heading. -->
< h1 class = "a11y-visually-hidden" > Odysseus< / h1 >
2026-05-31 23:58:26 +09:00
< div class = "chat-top-bar" >
< button type = "button" class = "incognito-indicator" id = "incognito-indicator" title = "Nobody mode active — click to deactivate" style = "display:none;" > < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" / > < line x1 = "8" y1 = "16" x2 = "16" y2 = "8" / > < line x1 = "8" y1 = "8" x2 = "16" y2 = "16" / > < / svg > < / button >
< div class = "chat-meta-overlay" > < span id = "current-meta" > Odysseus Chat< / span > < span id = "current-meta-count" class = "chat-meta-count" aria-hidden = "true" > < / span > < span id = "session-cost-display" class = "session-cost-display" style = "display:none;" > < / span > < span class = "export-dropdown-wrap" id = "export-dropdown-wrap" > < button type = "button" class = "export-dl-btn" id = "export-dl-btn" title = "More" > < svg width = "13" height = "13" 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 > < / button > < div class = "export-dropdown-menu" id = "export-dropdown-menu" > < div class = "export-dropdown-item" id = "export-rename-btn" > < span class = "dropdown-icon" > < 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 = "M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" / > < / svg > < / span > < span > Rename< / span > < / div > < div class = "export-dropdown-item" id = "export-copy-btn" > < span class = "dropdown-icon" > < svg width = "14" height = "14" 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 > < / span > < span > Copy Chat< / span > < / div > < div class = "export-dropdown-item" id = "export-pdf-btn" > < span class = "dropdown-icon" > < 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 = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" / > < polyline points = "14 2 14 8 20 8" / > < path d = "M9 15v-2h2a1.5 1.5 0 0 1 0 3H9z" / > < / svg > < / span > < span > PDF< / span > < / div > < div class = "export-dropdown-item" id = "export-doc-btn" > < span class = "dropdown-icon" > < 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 = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" / > < polyline points = "14 2 14 8 20 8" / > < line x1 = "16" y1 = "13" x2 = "8" y2 = "13" / > < line x1 = "16" y1 = "17" x2 = "8" y2 = "17" / > < polyline points = "10 9 9 9 8 9" / > < / svg > < / span > < span > Save to Documents< / span > < / div > < / div > < / span > < / div > < / div >
< div id = "welcome-screen" >
< div class = "welcome-name" > < svg class = "welcome-boat" viewBox = "0 0 32 32" > < path d = "M16 4L16 22L6 22Z" fill = "currentColor" / > < path d = "M16 8L16 22L24 22Z" fill = "currentColor" opacity = "0.6" / > < path d = "M4 24Q10 20 16 24Q22 28 28 24" stroke = "currentColor" stroke-width = "2.5" fill = "none" stroke-linecap = "round" / > < / svg > Odysseus< / div >
2026-06-01 21:11:47 +03:00
< div class = "welcome-sub" id = "welcome-sub" > Welcome, < span class = "setup-trigger-link" style = "color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title = "Click to launch setup" > type /setup< / span > to get started.< / div >
2026-05-31 23:58:26 +09:00
< div class = "welcome-tip" id = "welcome-tip" > < / div >
< button type = "button" class = "incognito-btn" id = "incognito-btn" title = "Enable Nobody mode — no memory, no history saved" >
< svg class = "eye-open" width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
< path d = "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" / > < circle cx = "12" cy = "12" r = "3" / >
< / svg >
< svg class = "eye-blinded" width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "display:none" >
< path d = "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" / > < line x1 = "8" y1 = "16" x2 = "16" y2 = "8" / > < line x1 = "8" y1 = "8" x2 = "16" y2 = "16" / >
< / svg >
< span class = "incognito-label" > Nobody< / span >
< / button >
< div id = "welcome-setup" style = "display:none" > < / div >
< / div >
< script nonce = "{{CSP_NONCE}}" >
(function(){
var mobile = window.matchMedia('(max-width: 768px)').matches;
var desktop = [
'Tip: Press Ctrl+K to search across all your conversations.',
'Tip: Press Ctrl+B to quickly toggle the sidebar.',
'Tip: Shift-click the sidebar toggle to swap it to the other side.',
'Tip: Drag and drop files onto the chat to attach them.',
'Tip: Right-click a session for rename, delete, and memory options.',
];
var phone = [
'Tip: Long-press a session for rename, delete, and memory options.',
'Tip: Tap the eye icon for Nobody mode — no history saved.',
'Tip: Switch to Agent mode for web search and code execution.',
'Tip: Use Compare mode to test different models side by side.',
'Tip: Attach images or files using the + button next to the input.',
];
var tips = mobile ? phone : desktop;
var el = document.getElementById('welcome-tip');
if (el) {
el.textContent = 'Type /setup, then choose Local models or API.';
}
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
if (d.version) window._appVersion = d.version;
}).catch(function(){});
})();
< / script >
< div id = "chat-history" class = "chat-history" role = "log" aria-live = "polite" > < / div >
<!-- Attachments strip -->
< div id = "attach-strip" class = "attach-strip" > < / div >
<!-- Hidden elements for form logic -->
< input type = "checkbox" id = "research-toggle" style = "display:none;" >
< input type = "checkbox" id = "rag-toggle" style = "display:none;" >
< input type = "checkbox" id = "incognito-toggle" style = "display:none;" >
2026-06-02 14:55:30 +03:00
< input type = "file" id = "file-input" class = "hidden" multiple / >
2026-05-31 23:58:26 +09:00
<!-- Unified chat input bar -->
< div class = "chat-input-bar" >
< div class = "chat-input-top" >
< div id = "message-ghost" class = "ghost-text-overlay" aria-hidden = "true" > < / div >
< textarea id = "message" placeholder = "Message Odysseus..." required autocomplete = "off" aria-label = "Message input" rows = "1" autofocus > < / textarea >
<!-- Model picker (inside chatbox, top - right) -->
< div class = "model-picker-wrap" id = "model-picker-wrap" >
< button type = "button" class = "model-picker-btn" id = "model-picker-btn" title = "Switch model" > < span id = "model-picker-label" > Select model< / span > < svg width = "10" height = "10" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" stroke-linejoin = "round" > < polyline points = "6 15 12 9 18 15" / > < / svg > < / button >
< div class = "model-picker-menu hidden" id = "model-picker-menu" >
2026-06-01 10:00:15 +09:00
< div class = "model-picker-search-row" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< input type = "text" id = "model-picker-search" placeholder = "Search models..." autocomplete = "off" aria-label = "Search models" >
2026-06-01 10:00:15 +09:00
< button type = "button" class = "model-picker-action-btn primary" id = "model-picker-add-models-btn" title = "Add model endpoints" aria-label = "Add model endpoints" >
< svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.4" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M12 5v14" / > < path d = "M5 12h14" / > < / svg >
< / button >
< / div >
2026-05-31 23:58:26 +09:00
< div class = "model-picker-list" id = "model-picker-list" > < / div >
< / div >
< / div >
< / div >
< div id = "pinned-tools-bar" > < / div >
< div class = "chat-input-bottom" style = "visibility:hidden" >
< div class = "chat-input-left" >
<!-- Overflow menu (+) — always first/left -->
< div class = "overflow-wrapper" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< button type = "button" class = "input-icon-btn overflow-plus-btn" id = "overflow-plus-btn" title = "More tools" aria-label = "More tools" aria-haspopup = "true" >
2026-05-31 23:58:26 +09:00
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" stroke-linejoin = "round" >
< polyline points = "6 15 12 9 18 15" / >
< / svg >
< span class = "plus-active-dot" > < / span >
< / button >
< div id = "overflow-menu" class = "overflow-menu hidden" >
< button type = "button" class = "overflow-menu-item" id = "overflow-attach-btn" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" / > < / svg >
< span > Attach files< / span >
< / button >
< button type = "button" class = "overflow-menu-item" id = "overflow-doc-btn" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" >
< path d = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" / > < polyline points = "14 2 14 8 20 8" / > < line x1 = "16" y1 = "13" x2 = "8" y2 = "13" / > < line x1 = "16" y1 = "17" x2 = "8" y2 = "17" / > < polyline points = "10 9 9 9 8 9" / >
< / svg >
< span > Documents< / span >
< / button >
< button type = "button" class = "overflow-menu-item" id = "overflow-rag-btn" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" >
< ellipse cx = "12" cy = "5" rx = "9" ry = "3" / > < path d = "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" / > < path d = "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" / >
< / svg >
< span > RAG< / span >
< span class = "overflow-active-dot" > < / span >
< / button >
2026-06-05 00:06:37 +02:00
< button type = "button" class = "overflow-menu-item" id = "overflow-workspace-btn" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
< path d = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" / >
< / svg >
< span > Workspace< / span >
< span class = "overflow-active-dot" > < / span >
< / button >
2026-05-31 23:58:26 +09:00
<!-- Inline "deep research mode" toggle removed (superseded by the
Deep Research sidebar / trigger_research). The hidden
#research-toggle checkbox is kept inert so existing JS refs
don't break; without this entry point it can't be enabled. -->
<!-- Group Chat moved to Characters modal Group tab -->
<!-- TTS Mode hidden — read - aloud feature is off in this build. -->
< button type = "button" class = "overflow-menu-item" id = "overflow-tts-btn" hidden style = "display:none" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < polygon points = "11 5 6 9 2 9 2 15 6 15 11 19 11 5" / > < path d = "M19.07 4.93a10 10 0 0 1 0 14.14" / > < path d = "M15.54 8.46a5 5 0 0 1 0 7.07" / > < / svg >
< span > TTS Mode< / span >
< span class = "overflow-active-dot" > < / span >
< / button >
< button type = "button" class = "overflow-menu-item" id = "overflow-preset-btn" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
< path d = "m18 2 4 4" / > < path d = "m17 7 3-3" / > < path d = "M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5" / > < path d = "m9 11 4 4" / > < path d = "m5 19-3 3" / > < path d = "m14 4 6 6" / >
< / svg >
< span > Prompt< / span >
< / button >
< / div >
< / div >
<!-- Web search (magnifying glass) -->
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< button type = "button" class = "input-icon-btn" title = "Web search" id = "web-toggle-btn" data-mode-tool = "true" aria-label = "Web search" aria-pressed = "false" >
2026-05-31 23:58:26 +09:00
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
< circle cx = "11" cy = "11" r = "8" / > < line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" / >
< / svg >
< / button >
<!-- Shell commands (terminal) -->
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< button type = "button" class = "input-icon-btn" title = "Shell Access" id = "bash-toggle-btn" data-mode-tool = "true" aria-label = "Shell access" aria-pressed = "false" >
2026-05-31 23:58:26 +09:00
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
< polyline points = "4 17 10 11 4 5" / > < line x1 = "12" y1 = "19" x2 = "20" y2 = "19" / >
< / svg >
< / button >
2026-06-05 00:06:37 +02:00
<!-- Workspace indicator (hidden until a folder is set) -->
< button type = "button" class = "input-icon-btn tool-indicator" title = "Workspace — click to clear" id = "workspace-indicator-btn" aria-label = "Clear workspace" style = "display:none;" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" / > < / svg >
< span style = "font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id = "workspace-indicator-name" > < / span >
< svg class = "tool-indicator-x" width = "10" height = "10" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < / svg >
< / button >
feat: Add plan mode to the chat agent (#638)
* feat: Add plan mode to the chat agent
Adds a plan mode: the agent investigates read-only, proposes a checklist, and
waits for approval before changing anything. On approval it runs with full
tools and checks items off as it goes. Enforcement reuses the existing
disabled_tools gate.
Includes a slash command: `/plan [on|off]` (and `/toggle plan`) to flip the
plan toggle from the chat input.
- src/tool_security.py, src/mcp_manager.py: read-only allowlist (tools + MCP).
- src/agent_loop.py, routes/chat_routes.py: union the disabled set, prepend the
plan directive, force agent mode.
- static/: plan toggle pill, Approve & Run, dockable plan window, task-list
checkboxes, and the /plan slash command.
- tests/test_plan_mode.py.
* Plan mode: persistent re-referenceable plan + agent write-back
Three improvements so a long plan survives a weak model and stays in reach:
1. Re-reference the plan (out-of-context fix). On the execution turn the frontend
sends the approved checklist back (`approved_plan`); the backend pins it as a
top-of-context `## ACTIVE PLAN` system note (kept by the context trimmer), so
the agent can always re-read the plan instead of losing the thread on a long
run. New `build_active_plan_note()` (unit-tested).
2. Re-open / dock the plan anytime. The plan checklist is stored per-session
(localStorage). When a plan exists, the plan-mode button opens a small menu
("Show plan" / "Plan mode: On/Off") that re-opens the side-dockable plan
window — so it can stay docked while the agent works. The window live-refreshes
as the plan changes.
3. Agent write-back: new `update_plan` tool. The agent calls it to tick steps
`- [x]` after finishing them, or to revise steps when the user asks. Marker
tool (no I/O) → `plan_update` SSE event → the stored plan + docked window
update live. The ACTIVE PLAN note instructs the agent to use it.
Backend: src/agent_loop.py (param + pin + note builder + emit + prompt blurb),
src/tool_execution.py (update_plan handler), routes/chat_routes.py (parse
`approved_plan`, relay `plan_update`), registration in tool_schemas / agent_tools
/ tool_index (always-available, not admin-gated).
Frontend: static/js/chat.js (plan store, send `approved_plan`, handle
`plan_update`, capture restated checklists), static/app.js (plan-button menu),
static/js/planWindow.js (`isPlanWindowOpen`), static/js/storage.js (PLAN key).
Tests: tests/test_plan_mode.py (plan-note), tests/test_update_plan_tool.py.
* Plan mode: drop bash/python, rely on read-only discovery tools
Shell can mutate (write files, hit the network) and can't be constrained to
read-only at the tool layer, so plan mode no longer relies on a prompt to keep
it well-behaved — bash/python are removed from the read-only allowlist and added
to the fail-closed block set. Discovery is covered by the dedicated read-only
tools (read_file, grep, glob, ls) instead.
Rewrites the plan-mode directive to state shell is disabled and lists the
available read-only tools positively. Addresses review feedback on #638.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Comment: note _MCP_READONLY_VERBS are prefixes not whole words
Clarifies that entries like "summar" are intentional stems matched via
startswith (covers summarise/summarize/summary), not typos. Addresses review
feedback on #638.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Plan mode: clarify why gating inverts the allowlist into a denylist
Rename _PLAN_MODE_FALLBACK_BLOCK -> _PLAN_MODE_KNOWN_MUTATORS and rewrite the
comments. The tool gate is a denylist (disabled_tools); plan mode's policy is an
allowlist, so it returns the inverse (all known tool names minus the allowlist).
The static mutator set is a backstop for the schema-derived name list, which
misses XML-only tools and can fail to import. Addresses review feedback on #638.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Plan mode: stop hardcoding the read-only tool list in the directive
The model is already shown its available (read-only) tools by _assemble_prompt,
which removes every disabled tool. Enumerating them again in the directive only
duplicated that list and would drift as tools change. Point at the tools listed
below instead. Addresses review feedback on #638.
2026-06-05 16:32:25 +02:00
<!-- Plan mode (investigate read - only, propose a plan to approve) -->
< button type = "button" class = "input-icon-btn" title = "Plan mode — investigate read-only, then propose a plan to approve" id = "plan-toggle-btn" data-mode-tool = "true" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >
< path d = "M9 11l3 3L22 4" / > < path d = "M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" / >
< / svg >
< / button >
2026-05-31 23:58:26 +09:00
<!-- RAG toolbar indicator (hidden until active) -->
< button type = "button" class = "input-icon-btn tool-indicator" title = "RAG active — click to deactivate" id = "rag-indicator-btn" style = "display:none;" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" >
< ellipse cx = "12" cy = "5" rx = "9" ry = "3" / > < path d = "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" / > < path d = "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" / >
< / svg >
< span style = "font-size:11px;margin-left:2px;" > RAG< / span >
< svg class = "tool-indicator-x" width = "10" height = "10" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < / svg >
< / button >
<!-- 6. Deep Research (hidden until active) -->
< button type = "button" class = "input-icon-btn tool-indicator" title = "Deep Research active — click to deactivate" id = "research-toggle-btn" style = "display:none;" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" > < circle cx = "11" cy = "11" r = "8" / > < path d = "M21 21l-4.35-4.35" / > < line x1 = "11" y1 = "8" x2 = "11" y2 = "14" / > < line x1 = "8" y1 = "11" x2 = "14" y2 = "11" / > < / svg >
< span style = "font-size:11px;margin-left:2px;" > Research< / span >
< svg class = "tool-indicator-x" width = "10" height = "10" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < / svg >
< / button >
<!-- 7. Group Chat (hidden until active) -->
< button type = "button" class = "input-icon-btn tool-indicator" title = "Group Chat active — click to deactivate" id = "group-toggle-btn" style = "display:none;" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" / > < circle cx = "9" cy = "7" r = "4" / > < path d = "M23 21v-2a4 4 0 0 0-3-3.87" / > < path d = "M16 3.13a4 4 0 0 1 0 7.75" / > < / svg >
< span style = "font-size:11px;margin-left:2px;" > Group< / span >
< svg class = "tool-indicator-x" width = "10" height = "10" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < / svg >
< / button >
< input type = "checkbox" id = "group-toggle" style = "display:none;" >
<!-- Character indicator (hidden until active) -->
Rename Character copy to Persona
Issue #234: the "Character" tab and its "Style of response" label made it
unclear that this is where a system prompt is set. Rename the user-facing
labels for clarity:
- "Character" tab + section heading -> "Persona"
- "Style of response" -> "System prompt"
- supporting strings: select placeholder, name placeholder, button/title
text, toasts, confirm/notice text, the chat-bar indicator tooltip, the
settings visibility toggle, and the assistant personality picker
("Characters" optgroup -> "Personas").
Used "Persona" rather than the issue's suggested "Preset" because the app
already has a distinct, user-facing "Presets" concept (built-in presets
like Code Analyze/Brainstorm/Reason, shown as their own group in the
assistant picker). "Persona" matches what this tab actually creates -- a
named persona with its own memories -- without colliding with that term.
Internal identifiers (element IDs, data-chartab attributes, function names)
and the character_name backend field are intentionally left unchanged so
existing saved presets and JS wiring keep working.
2026-06-02 05:42:15 +02:00
< button type = "button" class = "input-icon-btn tool-indicator" title = "Persona active — click to deactivate" id = "character-indicator-btn" style = "display:none;" >
2026-05-31 23:58:26 +09:00
< svg id = "char-indicator-icon" width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" / > < circle cx = "12" cy = "7" r = "4" / > < / svg >
< span id = "character-indicator-name" style = "font-size:11px;margin-left:2px;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" > < / span >
< svg class = "tool-indicator-x" width = "10" height = "10" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < / svg >
< / button >
<!-- Compare toolbar indicator (hidden until active) -->
< button type = "button" class = "input-icon-btn tool-indicator" title = "Compare active — click to deactivate" id = "compare-indicator-btn" style = "display:none;" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "18" cy = "18" r = "3" / > < circle cx = "6" cy = "6" r = "3" / > < path d = "M13 6h3a2 2 0 0 1 2 2v7" / > < path d = "M11 18H8a2 2 0 0 1-2-2V9" / > < / svg >
< span style = "font-size:11px;margin-left:2px;" > Compare< / span >
< svg class = "tool-indicator-x" width = "10" height = "10" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" > < line x1 = "6" y1 = "6" x2 = "18" y2 = "18" / > < line x1 = "18" y1 = "6" x2 = "6" y2 = "18" / > < / svg >
< / button >
< / div >
< div class = "chat-input-right" >
<!-- Agent / Chat mode toggle -->
< div class = "mode-toggle" >
Accessibility: add labels and toggle states
* Accessibility: ARIA labels and toggle states
Screen readers couldn't name several icon-only controls or tell whether the
tool toggles were on. This adds accessible names and exposes toggle state,
with no behavior or layout change.
- Icon-only buttons get aria-label: web/shell tool toggles, the "more tools"
overflow button (+ aria-haspopup), and the color-reset buttons.
- Unlabeled inputs/selects get aria-label: memory + skills search, model-picker
search, memory sort, theme font/density selects, and the new-memory / skill
(title, when-to-use, how, tags) fields, which only had a visual floating label.
- Toggle state via aria-pressed, kept in sync at the existing .active write
sites: web/shell toggles (setupToggle) and the Agent/Chat mode buttons
(initModeToggle). Static aria-pressed added in the markup so the attribute
exists before JS runs.
Scope: first slice of the ROADMAP accessibility pass. Focus-visible/contrast,
reduced-motion, and modal dialog roles/focus-trap are left for follow-ups.
Checks: node --check static/app.js. No Python touched.
* Accessibility: mark chat log busy while streaming
The chat log is an aria-live="polite" region, so streaming a response
token-by-token made screen readers announce every partial update — noisy and
unreadable. Set aria-busy="true" on #chat-history while a response streams and
back to "false" in the stream's finally block. Assistive tech then waits for
the settled message and announces it once.
Checks: node --check static/js/chat.js.
2026-06-02 13:55:05 +02:00
< button type = "button" class = "mode-toggle-btn active" id = "mode-agent-btn" aria-pressed = "true" > Agent< / button >
< button type = "button" class = "mode-toggle-btn" id = "mode-chat-btn" aria-pressed = "false" > Chat< / button >
2026-05-31 23:58:26 +09:00
< / div >
< button type = "submit" form = "chat-form" class = "send-btn newchat-mode" data-mode = "newchat" aria-label = "New chat" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" stroke-linejoin = "round" > < line x1 = "12" y1 = "5" x2 = "12" y2 = "19" / > < line x1 = "5" y1 = "12" x2 = "19" y2 = "12" / > < / svg > < span class = "send-btn-label" > + New< / span >
< / button >
< / div >
< / div >
<!-- Hidden checkboxes for state -->
< input type = "checkbox" id = "web-toggle" style = "display:none;" >
< input type = "checkbox" id = "bash-toggle" style = "display:none;" >
feat: Add plan mode to the chat agent (#638)
* feat: Add plan mode to the chat agent
Adds a plan mode: the agent investigates read-only, proposes a checklist, and
waits for approval before changing anything. On approval it runs with full
tools and checks items off as it goes. Enforcement reuses the existing
disabled_tools gate.
Includes a slash command: `/plan [on|off]` (and `/toggle plan`) to flip the
plan toggle from the chat input.
- src/tool_security.py, src/mcp_manager.py: read-only allowlist (tools + MCP).
- src/agent_loop.py, routes/chat_routes.py: union the disabled set, prepend the
plan directive, force agent mode.
- static/: plan toggle pill, Approve & Run, dockable plan window, task-list
checkboxes, and the /plan slash command.
- tests/test_plan_mode.py.
* Plan mode: persistent re-referenceable plan + agent write-back
Three improvements so a long plan survives a weak model and stays in reach:
1. Re-reference the plan (out-of-context fix). On the execution turn the frontend
sends the approved checklist back (`approved_plan`); the backend pins it as a
top-of-context `## ACTIVE PLAN` system note (kept by the context trimmer), so
the agent can always re-read the plan instead of losing the thread on a long
run. New `build_active_plan_note()` (unit-tested).
2. Re-open / dock the plan anytime. The plan checklist is stored per-session
(localStorage). When a plan exists, the plan-mode button opens a small menu
("Show plan" / "Plan mode: On/Off") that re-opens the side-dockable plan
window — so it can stay docked while the agent works. The window live-refreshes
as the plan changes.
3. Agent write-back: new `update_plan` tool. The agent calls it to tick steps
`- [x]` after finishing them, or to revise steps when the user asks. Marker
tool (no I/O) → `plan_update` SSE event → the stored plan + docked window
update live. The ACTIVE PLAN note instructs the agent to use it.
Backend: src/agent_loop.py (param + pin + note builder + emit + prompt blurb),
src/tool_execution.py (update_plan handler), routes/chat_routes.py (parse
`approved_plan`, relay `plan_update`), registration in tool_schemas / agent_tools
/ tool_index (always-available, not admin-gated).
Frontend: static/js/chat.js (plan store, send `approved_plan`, handle
`plan_update`, capture restated checklists), static/app.js (plan-button menu),
static/js/planWindow.js (`isPlanWindowOpen`), static/js/storage.js (PLAN key).
Tests: tests/test_plan_mode.py (plan-note), tests/test_update_plan_tool.py.
* Plan mode: drop bash/python, rely on read-only discovery tools
Shell can mutate (write files, hit the network) and can't be constrained to
read-only at the tool layer, so plan mode no longer relies on a prompt to keep
it well-behaved — bash/python are removed from the read-only allowlist and added
to the fail-closed block set. Discovery is covered by the dedicated read-only
tools (read_file, grep, glob, ls) instead.
Rewrites the plan-mode directive to state shell is disabled and lists the
available read-only tools positively. Addresses review feedback on #638.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Comment: note _MCP_READONLY_VERBS are prefixes not whole words
Clarifies that entries like "summar" are intentional stems matched via
startswith (covers summarise/summarize/summary), not typos. Addresses review
feedback on #638.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Plan mode: clarify why gating inverts the allowlist into a denylist
Rename _PLAN_MODE_FALLBACK_BLOCK -> _PLAN_MODE_KNOWN_MUTATORS and rewrite the
comments. The tool gate is a denylist (disabled_tools); plan mode's policy is an
allowlist, so it returns the inverse (all known tool names minus the allowlist).
The static mutator set is a backstop for the schema-derived name list, which
misses XML-only tools and can fail to import. Addresses review feedback on #638.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Plan mode: stop hardcoding the read-only tool list in the directive
The model is already shown its available (read-only) tools by _assemble_prompt,
which removes every disabled tool. Enumerating them again in the directive only
duplicated that list and would drift as tools change. Point at the tools listed
below instead. Addresses review feedback on #638.
2026-06-05 16:32:25 +02:00
< input type = "checkbox" id = "plan-toggle" style = "display:none;" >
2026-05-31 23:58:26 +09:00
< / div >
< form id = "chat-form" autocomplete = "off" action = "javascript:void(0);" style = "display:none;" > < / form >
<!-- Character (custom preset) modal -->
< div id = "custom-preset-modal" class = "modal hidden" >
Add dialog accessibility semantics
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
2026-06-01 23:41:25 -04:00
< div class = "modal-content preset-modal-content" role = "dialog" aria-label = "Prompt" style = "background:var(--bg)" >
2026-05-31 23:58:26 +09:00
< div class = "modal-header" >
< h4 > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-2px;margin-right:6px" > < path d = "m18 2 4 4" / > < path d = "m17 7 3-3" / > < path d = "M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5" / > < path d = "m9 11 4 4" / > < path d = "m5 19-3 3" / > < path d = "m14 4 6 6" / > < / svg > Prompt< / h4 >
Add dialog accessibility semantics
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
2026-06-01 23:41:25 -04:00
< button class = "close-btn" id = "close-custom-preset" aria-label = "Close prompt" > ✖< / button >
2026-05-31 23:58:26 +09:00
< / div >
< div class = "modal-body preset-modal-body" >
< div id = "char-fields-wrap" >
< div class = "preset-tabs" >
< button class = "preset-tab active" data-chartab = "inject" > < svg class = "preset-tab-icon" width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "m18 2 4 4" / > < path d = "m17 7 3-3" / > < path d = "M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5" / > < path d = "m9 11 4 4" / > < path d = "m5 19-3 3" / > < path d = "m14 4 6 6" / > < / svg > < span > Inject< / span > < / button >
Rename Character copy to Persona
Issue #234: the "Character" tab and its "Style of response" label made it
unclear that this is where a system prompt is set. Rename the user-facing
labels for clarity:
- "Character" tab + section heading -> "Persona"
- "Style of response" -> "System prompt"
- supporting strings: select placeholder, name placeholder, button/title
text, toasts, confirm/notice text, the chat-bar indicator tooltip, the
settings visibility toggle, and the assistant personality picker
("Characters" optgroup -> "Personas").
Used "Persona" rather than the issue's suggested "Preset" because the app
already has a distinct, user-facing "Presets" concept (built-in presets
like Code Analyze/Brainstorm/Reason, shown as their own group in the
assistant picker). "Persona" matches what this tab actually creates -- a
named persona with its own memories -- without colliding with that term.
Internal identifiers (element IDs, data-chartab attributes, function names)
and the character_name backend field are intentionally left unchanged so
existing saved presets and JS wiring keep working.
2026-06-02 05:42:15 +02:00
< button class = "preset-tab" data-chartab = "character" > < svg class = "preset-tab-icon" 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 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" / > < circle cx = "12" cy = "7" r = "4" / > < / svg > < span > Persona< / span > < / button >
2026-05-31 23:58:26 +09:00
< button class = "preset-tab" data-chartab = "group" > < svg class = "preset-tab-icon" width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" / > < circle cx = "9" cy = "7" r = "4" / > < path d = "M22 21v-2a4 4 0 0 0-3-3.87" / > < path d = "M16 3.13a4 4 0 0 1 0 7.75" / > < / svg > < span > Group< / span > < / button >
< / div >
<!-- Inject tab (also holds model tuning: temperature + max tokens) -->
< div class = "preset-chartab" data-chartab-panel = "inject" >
< label for = "inject-prefix" > Prefix< / label >
< textarea id = "inject-prefix" rows = "2" placeholder = "Added before your message" style = "margin-bottom:8px" > < / textarea >
< label for = "inject-suffix" > Suffix< / label >
< textarea id = "inject-suffix" rows = "2" placeholder = "Added after your message" style = "margin-bottom:12px" > < / textarea >
< div class = "preset-slider-row" >
< label > Temperature < span class = "preset-hint-icon" title = "Controls randomness. Lower values give focused, deterministic answers (good for code). Higher values give more creative, varied responses." > ?< / span > < / label >
< span class = "preset-slider-value" id = "temp-value" > 1.0< / span >
< / div >
< input type = "range" class = "preset-range" id = "custom-temperature" min = "0" max = "2" step = "0.1" value = "1.0" >
< div class = "preset-temp-hints" >
< span > Precise / Code< / span >
< span > Balanced< / span >
< span > Creative< / span >
< / div >
< div class = "preset-slider-row" >
< label > Max Tokens < span class = "preset-hint-icon" title = "Maximum length of the AI response. 'No limit' lets the model decide when to stop." > ?< / span > < / label >
< span class = "preset-slider-value" id = "tokens-value" > No limit< / span >
< / div >
< input type = "range" class = "preset-range" id = "custom-max-tokens" min = "256" max = "8448" step = "256" value = "8448" >
< / div >
<!-- Prompt (character/persona) tab -->
< div class = "preset-chartab" data-chartab-panel = "character" style = "display:none" >
Rename Character copy to Persona
Issue #234: the "Character" tab and its "Style of response" label made it
unclear that this is where a system prompt is set. Rename the user-facing
labels for clarity:
- "Character" tab + section heading -> "Persona"
- "Style of response" -> "System prompt"
- supporting strings: select placeholder, name placeholder, button/title
text, toasts, confirm/notice text, the chat-bar indicator tooltip, the
settings visibility toggle, and the assistant personality picker
("Characters" optgroup -> "Personas").
Used "Persona" rather than the issue's suggested "Preset" because the app
already has a distinct, user-facing "Presets" concept (built-in presets
like Code Analyze/Brainstorm/Reason, shown as their own group in the
assistant picker). "Persona" matches what this tab actually creates -- a
named persona with its own memories -- without colliding with that term.
Internal identifiers (element IDs, data-chartab attributes, function names)
and the character_name backend field are intentionally left unchanged so
existing saved presets and JS wiring keep working.
2026-06-02 05:42:15 +02:00
< label > Persona< / label >
2026-05-31 23:58:26 +09:00
< div class = "char-name-combo" >
< select id = "char-template-select" class = "char-template-select" >
Rename Character copy to Persona
Issue #234: the "Character" tab and its "Style of response" label made it
unclear that this is where a system prompt is set. Rename the user-facing
labels for clarity:
- "Character" tab + section heading -> "Persona"
- "Style of response" -> "System prompt"
- supporting strings: select placeholder, name placeholder, button/title
text, toasts, confirm/notice text, the chat-bar indicator tooltip, the
settings visibility toggle, and the assistant personality picker
("Characters" optgroup -> "Personas").
Used "Persona" rather than the issue's suggested "Preset" because the app
already has a distinct, user-facing "Presets" concept (built-in presets
like Code Analyze/Brainstorm/Reason, shown as their own group in the
assistant picker). "Persona" matches what this tab actually creates -- a
named persona with its own memories -- without colliding with that term.
Internal identifiers (element IDs, data-chartab attributes, function names)
and the character_name backend field are intentionally left unchanged so
existing saved presets and JS wiring keep working.
2026-06-02 05:42:15 +02:00
< option value = "" > Select persona...< / option >
2026-05-31 23:58:26 +09:00
< / select >
Rename Character copy to Persona
Issue #234: the "Character" tab and its "Style of response" label made it
unclear that this is where a system prompt is set. Rename the user-facing
labels for clarity:
- "Character" tab + section heading -> "Persona"
- "Style of response" -> "System prompt"
- supporting strings: select placeholder, name placeholder, button/title
text, toasts, confirm/notice text, the chat-bar indicator tooltip, the
settings visibility toggle, and the assistant personality picker
("Characters" optgroup -> "Personas").
Used "Persona" rather than the issue's suggested "Preset" because the app
already has a distinct, user-facing "Presets" concept (built-in presets
like Code Analyze/Brainstorm/Reason, shown as their own group in the
assistant picker). "Persona" matches what this tab actually creates -- a
named persona with its own memories -- without colliding with that term.
Internal identifiers (element IDs, data-chartab attributes, function names)
and the character_name backend field are intentionally left unchanged so
existing saved presets and JS wiring keep working.
2026-06-02 05:42:15 +02:00
< button type = "button" id = "char-new-btn" class = "char-action-btn" title = "Create a new persona" > + New< / button >
2026-05-31 23:58:26 +09:00
< / div >
< div id = "char-name-row" >
< label for = "custom-character-name" > Name< / label >
< div class = "char-name-combo" >
Rename Character copy to Persona
Issue #234: the "Character" tab and its "Style of response" label made it
unclear that this is where a system prompt is set. Rename the user-facing
labels for clarity:
- "Character" tab + section heading -> "Persona"
- "Style of response" -> "System prompt"
- supporting strings: select placeholder, name placeholder, button/title
text, toasts, confirm/notice text, the chat-bar indicator tooltip, the
settings visibility toggle, and the assistant personality picker
("Characters" optgroup -> "Personas").
Used "Persona" rather than the issue's suggested "Preset" because the app
already has a distinct, user-facing "Presets" concept (built-in presets
like Code Analyze/Brainstorm/Reason, shown as their own group in the
assistant picker). "Persona" matches what this tab actually creates -- a
named persona with its own memories -- without colliding with that term.
Internal identifiers (element IDs, data-chartab attributes, function names)
and the character_name backend field are intentionally left unchanged so
existing saved presets and JS wiring keep working.
2026-06-02 05:42:15 +02:00
< input type = "text" id = "custom-character-name" maxlength = "50" placeholder = "Give your persona a name..." autocomplete = "off" style = "flex:1" >
< button type = "button" id = "char-delete-template-btn" class = "char-action-btn" title = "Delete this persona and its memories" style = "display:none;margin-top:-6px !important" > < svg width = "13" height = "13" 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" > < polyline points = "3 6 5 6 21 6" / > < path d = "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" / > < line x1 = "10" y1 = "11" x2 = "10" y2 = "17" / > < line x1 = "14" y1 = "11" x2 = "14" y2 = "17" / > < / svg > Delete< / button >
2026-05-31 23:58:26 +09:00
< button type = "button" id = "reset-character-btn" class = "char-action-btn" title = "Reset to default" style = "margin-top:-6px !important" > ↺ Reset< / button >
< / div >
< / div >
Rename Character copy to Persona
Issue #234: the "Character" tab and its "Style of response" label made it
unclear that this is where a system prompt is set. Rename the user-facing
labels for clarity:
- "Character" tab + section heading -> "Persona"
- "Style of response" -> "System prompt"
- supporting strings: select placeholder, name placeholder, button/title
text, toasts, confirm/notice text, the chat-bar indicator tooltip, the
settings visibility toggle, and the assistant personality picker
("Characters" optgroup -> "Personas").
Used "Persona" rather than the issue's suggested "Preset" because the app
already has a distinct, user-facing "Presets" concept (built-in presets
like Code Analyze/Brainstorm/Reason, shown as their own group in the
assistant picker). "Persona" matches what this tab actually creates -- a
named persona with its own memories -- without colliding with that term.
Internal identifiers (element IDs, data-chartab attributes, function names)
and the character_name backend field are intentionally left unchanged so
existing saved presets and JS wiring keep working.
2026-06-02 05:42:15 +02:00
< label for = "custom-system-prompt" > System prompt< / label >
2026-05-31 23:58:26 +09:00
< div class = "char-prompt-wrap" >
< textarea id = "custom-system-prompt" rows = "4" placeholder = "Write rough notes and click Expand, or leave empty" > < / textarea >
Rename Character copy to Persona
Issue #234: the "Character" tab and its "Style of response" label made it
unclear that this is where a system prompt is set. Rename the user-facing
labels for clarity:
- "Character" tab + section heading -> "Persona"
- "Style of response" -> "System prompt"
- supporting strings: select placeholder, name placeholder, button/title
text, toasts, confirm/notice text, the chat-bar indicator tooltip, the
settings visibility toggle, and the assistant personality picker
("Characters" optgroup -> "Personas").
Used "Persona" rather than the issue's suggested "Preset" because the app
already has a distinct, user-facing "Presets" concept (built-in presets
like Code Analyze/Brainstorm/Reason, shown as their own group in the
assistant picker). "Persona" matches what this tab actually creates -- a
named persona with its own memories -- without colliding with that term.
Internal identifiers (element IDs, data-chartab attributes, function names)
and the character_name backend field are intentionally left unchanged so
existing saved presets and JS wiring keep working.
2026-06-02 05:42:15 +02:00
< button type = "button" id = "char-expand-btn" class = "char-expand-btn" title = "AI expand — turn your notes into a full system prompt" >
2026-05-31 23:58:26 +09:00
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "currentColor" style = "vertical-align:-1px;margin-right:2px;" > < path d = "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" / > < / svg >
Expand
< / button >
< / div >
< / div >
<!-- Group tab -->
< div class = "preset-chartab" data-chartab-panel = "group" style = "display:none" >
< div style = "display:flex;gap:4px;margin-bottom:8px;" >
< button type = "button" class = "compare-parallel-toggle" id = "group-mode-btn" style = "flex:1;height:auto;width:auto;" >
< svg width = "18" height = "18" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" > < line x1 = "8" y1 = "6" x2 = "20" y2 = "6" / > < line x1 = "8" y1 = "12" x2 = "20" y2 = "12" / > < line x1 = "8" y1 = "18" x2 = "20" y2 = "18" / > < circle cx = "4" cy = "6" r = "1.5" fill = "currentColor" / > < circle cx = "4" cy = "12" r = "1.5" fill = "currentColor" / > < circle cx = "4" cy = "18" r = "1.5" fill = "currentColor" / > < / svg >
< span class = "compare-toggle-label" > Sequential< / span >
< / button >
< / div >
< div id = "group-participants" style = "display:flex;flex-direction:column;gap:4px;margin-bottom:0;max-height:220px;overflow-y:auto;" > < / div >
< button type = "button" id = "group-add-btn" class = "preset-save-btn" style = "width:100%;background:none;border:1px dashed var(--border);color:var(--fg);opacity:0.6;font-size:11px;padding:4px;margin-top:0;" > + Add participant< / button >
< / div >
< / div >
< / div >
< div class = "modal-footer" >
< div style = "flex:1" > < / div >
< button type = "button" id = "cancel-custom-preset" style = "margin-right:8px;display:none;" > Cancel< / button >
< button type = "button" id = "save-custom-preset" > Start< / button >
< / div >
< / div >
< / div >
< / main >
< button id = "scroll-bottom-btn" class = "scroll-nav-btn" title = "Scroll to bottom" > ▼< / button >
< script nonce = "{{CSP_NONCE}}" >
(function(){
const container = document.getElementById('chat-history');
const chatBar = document.querySelector('.chat-input-bar');
const bottomBtn = document.getElementById('scroll-bottom-btn');
if (!container || !chatBar || !bottomBtn) return;
function reposition() {
const barRect = chatBar.getBoundingClientRect();
bottomBtn.style.bottom = (window.innerHeight - barRect.top + 16) + 'px';
bottomBtn.style.right = (window.innerWidth - barRect.right + 8) + 'px';
}
function update() {
reposition();
const scrollable = container.scrollHeight > container.clientHeight + 10;
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300 ;
if (scrollable & & !atBottom) {
bottomBtn.classList.remove('slide-out');
bottomBtn.classList.add('show');
} else if (bottomBtn.classList.contains('show')) {
bottomBtn.classList.add('slide-out');
bottomBtn.classList.remove('show');
}
}
let _scrollRaf = null;
let _scrollTimeout = null;
let _scrollingToBottom = false;
function cancelAutoScroll() {
if (_scrollRaf) { cancelAnimationFrame(_scrollRaf); _scrollRaf = null; }
if (_scrollTimeout) { clearTimeout(_scrollTimeout); _scrollTimeout = null; }
_scrollingToBottom = false;
}
container.addEventListener('scroll', update, {passive:true});
container.addEventListener('wheel', () => { if (_scrollingToBottom) cancelAutoScroll(); }, {passive:true});
container.addEventListener('touchstart', () => { if (_scrollingToBottom) cancelAutoScroll(); }, {passive:true});
bottomBtn.addEventListener('click', () => {
cancelAutoScroll();
const target = container.scrollHeight - container.clientHeight;
_scrollingToBottom = true;
function step() {
if (!_scrollingToBottom) return;
const diff = target - container.scrollTop;
if (diff < = 8) { container.scrollTop = target; _scrollingToBottom = false; return; }
container.scrollTop += diff * 0.2;
_scrollRaf = requestAnimationFrame(step);
}
step();
_scrollTimeout = setTimeout(() => { if (_scrollingToBottom) { container.scrollTop = target; _scrollingToBottom = false; } }, 1500);
});
new ResizeObserver(reposition).observe(chatBar);
new MutationObserver(update).observe(container, {childList:true, subtree:true});
window.addEventListener('resize', reposition);
update();
})();
< / script >
<!-- Rename Session Modal -->
< div id = "rename-session-modal" class = "modal hidden" >
Add dialog accessibility semantics
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
2026-06-01 23:41:25 -04:00
< div class = "modal-content" role = "dialog" aria-label = "Rename session" style = "width: 400px;" >
2026-05-31 23:58:26 +09:00
< div class = "modal-header" >
< h4 > Rename Session< / h4 >
< button class = "close-btn" id = "close-rename-session" aria-label = "Close rename session modal" > ✖< / button >
< / div >
< div class = "modal-body" >
< div style = "margin-bottom: 12px;" >
< label for = "session-name-input" style = "display: block; margin-bottom: 6px; font-weight: 500;" > Session Name< / label >
2026-06-01 21:11:47 +03:00
< input
type="text"
id="session-name-input"
placeholder="Enter session name"
2026-05-31 23:58:26 +09:00
style="width: 100%; padding: 8px; border-radius: 4px;"
/>
< / div >
< div style = "display: flex; justify-content: flex-end; gap: 8px;" >
< button id = "cancel-rename-session" style = "padding: 6px 12px;" > Cancel< / button >
< button id = "save-session-name" style = "padding: 6px 12px; background: var(--fg); color: var(--bg);" > Save< / button >
< / div >
< / div >
< / div >
< / div >
<!-- Cookbook Modal -->
< div id = "cookbook-modal" class = "modal hidden" >
Add dialog accessibility semantics
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
2026-06-01 23:41:25 -04:00
< div class = "modal-content" role = "dialog" aria-label = "Cookbook" style = "width: min(780px, 92vw); height: 94vh; max-height: 94vh; background: var(--bg);" >
2026-05-31 23:58:26 +09:00
< div class = "modal-header" >
< h4 style = "margin:0;margin-right:auto" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-2px;margin-right:6px" > < path d = "M12 7v14" / > < path d = "M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" / > < / svg > Cookbook< / h4 >
Add dialog accessibility semantics
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
2026-06-01 23:41:25 -04:00
< button class = "close-btn" id = "close-cookbook-modal" aria-label = "Close cookbook" > ✖< / button >
2026-05-31 23:58:26 +09:00
< / div >
< div class = "modal-body cookbook-body" > < / div >
< / div >
< / div >
<!-- Settings Modal (all users) -->
< div id = "settings-modal" class = "modal hidden" >
Add dialog accessibility semantics
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
2026-06-01 23:41:25 -04:00
< div class = "modal-content settings-modal-content" role = "dialog" aria-label = "Settings" >
2026-05-31 23:58:26 +09:00
< div class = "modal-header" >
< h4 > < span style = "vertical-align:-1px;margin-right:6px;font-size:15px" > ⚙ < / span > Settings< / h4 >
< button type = "button" class = "theme-opacity-wrap theme-opacity-toggle hidden" id = "settings-opacity-wrap" title = "Fade this window to preview the page behind it" aria-pressed = "false" >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" > < path d = "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" > < / path > < circle cx = "12" cy = "12" r = "3" > < / circle > < / svg >
< span class = "theme-opacity-label" > Peek< / span >
< / button >
Add dialog accessibility semantics
Screen readers got no signal that a dialog opened — not one modal carried
role="dialog" — and several close buttons had no accessible name.
- The 6 static tool windows (Brain, Theme, Prompt, Rename session, Cookbook,
Settings) now carry role="dialog" + an accessible name. They are dockable,
tiling windows, so they are non-modal dialogs (intentionally no aria-modal).
- The four unlabelled close buttons (theme, prompt, cookbook, settings) get an
aria-label so they no longer read as just "heavy multiplication x".
- styledConfirm / styledPrompt ARE blocking modals: they get role="dialog" +
aria-modal="true" + aria-labelledby/aria-describedby, and now manage focus —
restore focus to the triggering element on close and trap Tab within the
dialog (they already moved focus in on open).
tests/test_dialog_aria.py pins the roles, labels, and focus management.
2026-06-01 23:41:25 -04:00
< button class = "close-btn" aria-label = "Close settings" > ✖< / button >
2026-05-31 23:58:26 +09:00
< / div >
< div class = "admin-toggle-sub" style = "padding:0 12px 8px;opacity:0.6;font-size:11px;" > Toggle on/off visibility of tools and modules across the interface.< / div >
< div class = "settings-layout" >
< div class = "settings-sidebar" >
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
< button class = "settings-nav-item active" data-settings-tab = "services" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "2" y = "2" width = "20" height = "8" rx = "2" / > < rect x = "2" y = "14" width = "20" height = "8" rx = "2" / > < circle cx = "6" cy = "6" r = "1" / > < circle cx = "6" cy = "18" r = "1" / > < / svg >
< span > Add Models< / span >
< / button >
< button class = "settings-nav-item" data-settings-tab = "ai" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" > < path d = "M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z" / > < / svg >
< span > AI Defaults< / span >
< / button >
< button class = "settings-nav-item" data-settings-tab = "search" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" > < circle cx = "11" cy = "11" r = "8" / > < line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" / > < / svg >
< span > Search< / span >
< / button >
< div class = "settings-sidebar-divider" > < / div >
<!-- Section 2: Comms (Integrations → Email → Reminders).
Integrations leads here because it's where the underlying
accounts the other two tabs depend on actually live. -->
< button class = "settings-nav-item" data-settings-tab = "integrations" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" / > < path d = "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" / > < / svg >
< span > Integrations< / span >
< / button >
< button class = "settings-nav-item" data-settings-tab = "email" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "2" y = "4" width = "20" height = "16" rx = "2" / > < path d = "m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" / > < / svg >
< span > Email< / span >
< / button >
< button class = "settings-nav-item" data-settings-tab = "reminders" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" / > < path d = "M13.73 21a2 2 0 0 1-3.46 0" / > < / svg >
< span > Reminders< / span >
< / button >
< div class = "settings-sidebar-divider" > < / div >
<!-- Section 3: UX (Appearance → Shortcuts) -->
< button class = "settings-nav-item" data-settings-tab = "appearance" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" > < circle cx = "12" cy = "12" r = "10" / > < path d = "M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8" / > < / svg >
< span > Appearance< / span >
< / button >
< button class = "settings-nav-item" data-settings-tab = "shortcuts" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "2" y = "4" width = "20" height = "16" rx = "2" / > < path d = "M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10" / > < / svg >
< span > Shortcuts< / span >
< / button >
< div class = "settings-sidebar-divider" > < / div >
<!-- Section 4: Account (sits at the bottom of the user section) -->
< button class = "settings-nav-item" data-settings-tab = "account" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" > < path d = "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" / > < circle cx = "12" cy = "7" r = "4" / > < / svg >
< span > Account< / span >
< / button >
< div class = "settings-sidebar-divider admin-only" > < / div >
< div class = "settings-sidebar-label admin-only" > Admin< / div >
< button class = "settings-nav-item admin-only" data-settings-tab = "tools" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" / > < / svg >
< span > Agent Tools< / span >
< / button >
< button class = "settings-nav-item admin-only" data-settings-tab = "users" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" / > < circle cx = "9" cy = "7" r = "4" / > < path d = "M23 21v-2a4 4 0 0 0-3-3.87" / > < path d = "M16 3.13a4 4 0 0 1 0 7.75" / > < / svg >
< span > Users< / span >
< / button >
< button class = "settings-nav-item admin-only" data-settings-tab = "system" >
< 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-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 >
< span > System< / span >
< / button >
< / div >
< div class = "settings-panels" >
<!-- ═══ AI TAB ═══ -->
< div data-settings-panel = "ai" class = "hidden" >
< 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 = "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" / > < / svg > Default Chat Model< / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > The model used when creating a new chat session.< / div >
< div class = "settings-col" >
< div class = "settings-row" >
< label class = "settings-label" > Endpoint< / label >
< select id = "set-defaultEpSelect" class = "settings-select" > < / select >
< / div >
< div class = "settings-row" >
< label class = "settings-label" > Model< / label >
< select id = "set-defaultModelSelect" class = "settings-select" > < / select >
< / div >
< div id = "set-defaultFallbacks" class = "settings-fallbacks" > < / div >
< button type = "button" class = "settings-fallback-add" id = "set-defaultAddFallback" title = "Add a model to try if the one above fails" > + Add fallback< / button >
< div id = "set-defaultChatMsg" style = "font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);" > < / div >
< / div >
< / div >
< div class = "admin-card" >
< h2 style = "display:flex;align-items:center;gap:6px;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "margin-right:1px;opacity:0.6;flex-shrink:0" > < path d = "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" / > < / svg > Utility Model < span style = "font-size:0.72em;opacity:0.55;font-weight:normal;" > (Recommended: Local Endpoint)< / span > < / h2 >
2026-06-01 15:32:17 +02:00
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Runs background tasks (compaction, cleanup, auto-naming, retrieving memories from files) on a small/local model instead of your chat model. Leave blank to use the chat model.< / div >
2026-05-31 23:58:26 +09:00
< div class = "settings-col" >
< div class = "settings-row" >
< label class = "settings-label" > Endpoint< / label >
< select id = "set-utilityEpSelect" class = "settings-select" > < option value = "" > —< / option > < / select >
< / div >
< div class = "settings-row" >
< label class = "settings-label" > Model< / label >
< select id = "set-utilityModelSelect" class = "settings-select" > < option value = "" > —< / option > < / select >
< / div >
< div id = "set-utilityFallbacks" class = "settings-fallbacks" > < / div >
< button type = "button" class = "settings-fallback-add" id = "set-utilityAddFallback" title = "Add a model to try if the utility model fails" > + Add fallback< / button >
< div id = "set-utilityChatMsg" style = "font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);" > < / div >
< / div >
< / div >
< div class = "admin-card" >
< h2 style = "display:flex;align-items:center;gap:6px;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "margin-right:1px;opacity:0.6;flex-shrink:0" > < path d = "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" / > < circle cx = "12" cy = "12" r = "3" / > < / svg > Vision< span style = "flex:1" > < / span > < label class = "admin-switch" title = "Analyze images with a vision-capable model" > < input type = "checkbox" id = "set-visionEnabledToggle" checked > < span class = "admin-slider" > < / span > < / label > < / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Analyze images with a vision-capable model.< / div >
< div style = "display:flex;flex-direction:column;gap:0.5rem;" >
< div style = "display:flex;align-items:center;gap:0.75rem;" >
< label class = "settings-label" > Model< / label >
< select id = "set-vlModelSelect" class = "settings-select" > < option value = "" > Auto-detect< / option > < / select >
< / div >
< div id = "set-visionFallbacks" class = "settings-fallbacks" > < / div >
< button type = "button" class = "settings-fallback-add" id = "set-visionAddFallback" title = "Add a vision model to try if the one above fails" > + Add fallback< / button >
< div id = "set-visionSettingsMsg" style = "font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);" > < / div >
< / div >
< / div >
< 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" > < circle cx = "11" cy = "11" r = "8" / > < path d = "M21 21l-4.35-4.35" / > < line x1 = "11" y1 = "8" x2 = "11" y2 = "14" / > < line x1 = "8" y1 = "11" x2 = "14" y2 = "11" / > < / svg > Research Model< / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Model used for Deep Research. Falls back to the default chat model if not set.< / div >
< div class = "settings-col" >
< div class = "settings-row" >
< label class = "settings-label" > Endpoint< / label >
< select id = "set-researchEndpoint" class = "settings-select" >
< option value = "" > Same as chat< / option >
< / select >
< / div >
< div class = "settings-row" >
< label class = "settings-label" > Model< / label >
< select id = "set-researchModel" class = "settings-select" >
< option value = "" > Same as chat< / option >
< / select >
< / div >
< div class = "settings-row" >
< label class = "settings-label" > Search< / label >
< select id = "set-researchSearch" class = "settings-select" >
< option value = "" > Same as web search< / option >
< option value = "searxng" > SearXNG< / option >
< option value = "duckduckgo" > DuckDuckGo< / option >
< option value = "tavily" > Tavily< / option >
< option value = "brave" > Brave< / option >
< option value = "google" > Google< / option >
< option value = "serper" > Serper< / option >
< / select >
< / div >
< div class = "settings-row" >
< label class = "settings-label" > Max Tokens< / label >
< input id = "set-researchMaxTokens" type = "text" inputmode = "numeric" placeholder = "8192 (default)" class = "settings-select" style = "width:120px;" >
< / div >
2026-06-01 14:55:24 +09:00
< div class = "settings-row" >
< label class = "settings-label" > Extract Timeout< / label >
< input id = "set-researchExtractTimeout" type = "text" inputmode = "numeric" placeholder = "90 sec" class = "settings-select" style = "width:120px;" >
< / div >
< div class = "settings-row" >
< label class = "settings-label" > Extract Parallel< / label >
< input id = "set-researchExtractConcurrency" type = "text" inputmode = "numeric" placeholder = "3" class = "settings-select" style = "width:120px;" >
< / div >
2026-06-02 13:57:57 +02:00
< div class = "settings-row" >
< label class = "settings-label" > Max Time< / label >
< input id = "set-researchRunTimeout" type = "text" inputmode = "numeric" placeholder = "1800 sec (0 = no limit)" class = "settings-select" style = "width:120px;" >
< / div >
2026-05-31 23:58:26 +09:00
< div id = "set-researchMsg" style = "font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);" > < / div >
< / div >
< / div >
< 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 = "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" / > < / svg > Agent< / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Controls for the agent tool loop.< / div >
< div class = "settings-col" >
< div class = "settings-row" >
< label class = "settings-label" > Tool call limit< / label >
< input id = "set-agentMaxTools" type = "text" inputmode = "numeric" placeholder = "0 = unlimited" class = "settings-select" style = "width:120px;" >
< / div >
2026-06-04 22:36:05 +02:00
< div class = "settings-row" >
< label class = "settings-label" > Max steps per message< / label >
< input id = "set-agentMaxRounds" type = "text" inputmode = "numeric" placeholder = "20" class = "settings-select" style = "width:120px;" >
< / div >
2026-05-31 23:58:26 +09:00
< div id = "set-agentMsg" style = "font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);" > < / div >
< / div >
< / div >
<!-- Image Generation removed — only inpaint remains in this build,
and inpaint is configured via the gallery editor not this card.
Keeping the DOM (hidden) so JS wiring against the inputs
doesn't throw. -->
< div class = "admin-card" hidden style = "display:none" >
< h2 style = "display:flex;align-items:center;gap:6px;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "margin-right:1px;opacity:0.6;flex-shrink:0" > < rect x = "3" y = "3" width = "18" height = "18" rx = "2" / > < circle cx = "8.5" cy = "8.5" r = "1.5" / > < path d = "M21 15l-5-5L5 21" / > < / svg > Image Generation< span style = "flex:1" > < / span > < label class = "admin-switch" > < input type = "checkbox" id = "set-imgEnabledToggle" checked > < span class = "admin-slider" > < / span > < / label > < / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Configure which model to use for image generation.< / div >
< div style = "display:flex;flex-direction:column;gap:0.5rem;" >
< div style = "display:flex;align-items:center;gap:0.75rem;" >
< label class = "settings-label" > Model< / label >
< select id = "set-imgModelSelect" class = "settings-select" > < option value = "" > Auto-detect< / option > < / select >
< / div >
< div style = "display:flex;align-items:center;gap:0.75rem;" >
< label class = "settings-label" > Quality< / label >
< select id = "set-imgQualitySelect" class = "settings-select" >
< option value = "low" > Low (fastest, cheapest)< / option >
< option value = "medium" > Medium (default)< / option >
< option value = "high" > High (best quality)< / option >
< / select >
< / div >
< div id = "set-imgSettingsMsg" style = "font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);" > < / div >
< / div >
< / div >
<!-- TTS panel hidden — user opted out of read - aloud entirely. Kept
in DOM so the JS that wires set-ttsProviderSelect etc. doesn't
throw on missing nodes; not visible to anyone. -->
< div class = "admin-card" hidden style = "display:none" >
< h2 style = "display:flex;align-items:center;gap:6px;" > < 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" > < polygon points = "11 5 6 9 2 9 2 15 6 15 11 19 11 5" / > < path d = "M19.07 4.93a10 10 0 0 1 0 14.14" / > < path d = "M15.54 8.46a5 5 0 0 1 0 7.07" / > < / svg > Text to Speech< span style = "flex:1" > < / span > < label class = "admin-switch" > < input type = "checkbox" id = "set-ttsEnabledToggle" checked > < span class = "admin-slider" > < / span > < / label > < / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Configure TTS provider for assistant message read-aloud.< / div >
< div style = "display:flex;flex-direction:column;gap:0.5rem;" >
< div style = "display:flex;align-items:center;gap:0.75rem;" >
< label class = "settings-label" > Provider< / label >
< select id = "set-ttsProviderSelect" class = "settings-select" >
< option value = "disabled" > Disabled< / option >
< option value = "browser" > Browser (built-in)< / option >
< option value = "local" > Local (Kokoro-82M)< / option >
< / select >
< / div >
< div id = "set-ttsModelRow" style = "display:flex;align-items:center;gap:0.75rem;" >
< label class = "settings-label" > Model< / label >
< select id = "set-ttsModelSelect" class = "settings-select" >
< option value = "tts-1" > tts-1 (fast)< / option >
< option value = "tts-1-hd" > tts-1-hd (quality)< / option >
< option value = "gpt-4o-mini-tts" > gpt-4o-mini-tts (steerable)< / option >
< / select >
< input id = "set-ttsModelInput" type = "text" placeholder = "model name" style = "flex:1;padding:5px;display:none;" >
< / div >
< div id = "set-ttsVoiceRow" style = "display:flex;align-items:center;gap:0.75rem;" >
< label class = "settings-label" > Voice< / label >
< select id = "set-ttsVoiceSelect" class = "settings-select" >
< option value = "alloy" > Alloy< / option >
< option value = "ash" > Ash< / option >
< option value = "coral" > Coral< / option >
< option value = "echo" > Echo< / option >
< option value = "fable" > Fable< / option >
< option value = "nova" > Nova< / option >
< option value = "onyx" > Onyx< / option >
< option value = "sage" > Sage< / option >
< option value = "shimmer" > Shimmer< / option >
< / select >
< input id = "set-ttsVoiceInput" type = "text" placeholder = "af_heart" style = "flex:1;padding:5px;display:none;" >
< / div >
< div id = "set-ttsSpeedRow" style = "display:flex;align-items:center;gap:0.75rem;" >
< label class = "settings-label" > Speed< / label >
< select id = "set-ttsSpeedSelect" class = "settings-select" >
< option value = "0.5" > 0.5x< / option >
< option value = "0.75" > 0.75x< / option >
< option value = "1" selected > 1x (normal)< / option >
< option value = "1.25" > 1.25x< / option >
< option value = "1.5" > 1.5x< / option >
< option value = "2" > 2x< / option >
< / select >
< / div >
< button type = "button" id = "set-ttsPreviewBtn" class = "admin-btn-sm" style = "margin-top:4px;padding:5px 16px;font-size:12px;" > Preview< / button >
< div id = "set-ttsSettingsMsg" style = "font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);" > < / div >
< / div >
< / div >
< div class = "admin-card" >
< h2 style = "display:flex;align-items:center;gap:6px;" > < 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 = "M22 10v6M2 10l10-5 10 5-10 5z" / > < path d = "M6 12v5c3 3 9 3 12 0v-5" / > < / svg > Teacher Model < span style = "font-size:0.72em;opacity:0.55;font-weight:normal;" > (Experimental)< / span > < span style = "flex:1" > < / span > < label class = "admin-switch" > < input type = "checkbox" id = "set-teacherEnabledToggle" > < span class = "admin-slider" > < / span > < / label > < / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > When a self-hosted student fails an agent-mode task, escalate to a SOTA teacher that writes a SKILL.md procedure so the student can do it next time. Off by default.< / div >
< div class = "settings-col" >
< div class = "settings-row" >
< label class = "settings-label" > Endpoint< / label >
< select id = "set-teacherEpSelect" class = "settings-select" > < option value = "" > —< / option > < / select >
< / div >
< div class = "settings-row" >
< label class = "settings-label" > Model< / label >
< select id = "set-teacherModelSelect" class = "settings-select" > < option value = "" > —< / option > < / select >
< / div >
< div id = "set-teacherChatMsg" style = "font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);" > < / div >
< / div >
< / div >
< / div >
<!-- ═══ SEARCH TAB ═══ -->
< div data-settings-panel = "search" class = "hidden" >
< 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" > < circle cx = "11" cy = "11" r = "8" / > < line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" / > < / svg > Web Search< / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Search API used for web search and deep research.< / div >
< div class = "settings-col" >
< div class = "settings-row" >
< label class = "settings-label" > Provider< / label >
<!-- Custom picker (with logos). Hidden native <select> mirrors
its value so the existing JS that reads
set-searchProvider keeps working unchanged. -->
< div class = "adm-provider-picker search-provider-picker" id = "search-provider-picker" style = "flex:1;position:relative;top:5px;" >
< button type = "button" class = "adm-provider-btn" id = "search-provider-btn" >
< span class = "adm-provider-current" > < span class = "adm-provider-logo" > < / span > < span class = "adm-provider-name" > SearXNG< / span > < / span >
< svg class = "adm-provider-caret" width = "10" height = "10" 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 >
< / button >
< div class = "adm-provider-menu hidden" id = "search-provider-menu" > < / div >
< / div >
< select id = "set-searchProvider" class = "settings-select" style = "display:none;" >
< option value = "searxng" data-search-logo = "searxng" > SearXNG (self-hosted)< / option >
< option value = "duckduckgo" data-search-logo = "duckduckgo" > DuckDuckGo (free, no key)< / option >
< option value = "brave" data-search-logo = "brave" > Brave Search< / option >
< option value = "google_pse" data-search-logo = "google_pse" > Google PSE< / option >
< option value = "tavily" data-search-logo = "tavily" > Tavily< / option >
< option value = "serper" data-search-logo = "serper" > Serper.dev< / option >
< option value = "disabled" data-search-logo = "disabled" > Disabled< / option >
< / select >
< button type = "button" class = "admin-btn-sm" id = "set-searchTestBtn" title = "Run a test query against the configured provider" style = "margin-left:6px;flex-shrink:0;position:relative;top:2px;" > Test< / button >
< / div >
< div class = "settings-row" >
< label class = "settings-label" > Results< / label >
2026-06-02 06:55:15 +03:00
< div style = "display:flex;gap:8px;flex:1;" >
< select id = "set-searchResultCount" class = "settings-select" style = "flex:1;" >
< option value = "3" > 3< / option >
< option value = "5" selected > 5< / option >
< option value = "10" > 10< / option >
< option value = "20" > 20< / option >
< option value = "custom" > Custom< / option >
< / select >
< input id = "set-searchResultCountCustom" type = "number" class = "settings-select" placeholder = "Enter custom value" style = "flex:1;display:none;min-width:120px;" min = "1" max = "100" >
< / div >
2026-05-31 23:58:26 +09:00
< / div >
< div id = "set-searchUrlRow" class = "settings-row" >
< label class = "settings-label" > URL< / label >
< input id = "set-searchUrl" type = "text" placeholder = "http://localhost:8080" class = "settings-select" >
< / div >
< div id = "set-searchKeyRow" class = "settings-row" style = "display:none;" >
< label class = "settings-label" > API Key< / label >
< input id = "set-searchApiKey" type = "password" placeholder = "API key" class = "settings-select" >
< / div >
< div id = "set-searchCxRow" class = "settings-row" style = "display:none;" >
< label class = "settings-label" > CX ID< / label >
< input id = "set-searchCx" type = "text" placeholder = "Google PSE engine ID" class = "settings-select" >
< / div >
< div class = "settings-row" >
< label class = "settings-label" title = "Providers tried in order when the primary fails or hits a rate limit" > Fallbacks< / label >
< div class = "search-fallback-chain" id = "set-searchFallbackChain" > < / div >
< / div >
< div id = "set-searchHint" class = "admin-toggle-sub" > < / div >
< div id = "set-searchMsg" style = "font-size:11px;" > < / div >
< / div >
< / div >
< / div >
<!-- ═══ APPEARANCE TAB ═══ -->
< div data-settings-panel = "appearance" class = "settings-appearance-panel hidden" >
< div class = "admin-card" style = "padding-bottom:6px;" >
< 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" > < rect x = "3" y = "3" width = "18" height = "18" rx = "2" / > < line x1 = "9" y1 = "3" x2 = "9" y2 = "21" / > < / svg > Sidebar< / h2 >
< div class = "vis-toggles" >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < circle cx = "12" cy = "12" r = "10" / > < path d = "M8 12l2.5 2.5L16 9" / > < / svg > < / span >
< span class = "vis-label" > Odysseus < span class = "vis-hint" > Brand name< / span > < / span >
< input type = "checkbox" checked data-ui-key = "sidebar-brand" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < circle cx = "10" cy = "10" r = "7" / > < path d = "M21 21l-4.35-4.35" / > < / svg > < / span >
< span class = "vis-label" > Search< / span >
< input type = "checkbox" checked data-ui-key = "sidebar-search" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" > < line x1 = "12" y1 = "5" x2 = "12" y2 = "19" / > < line x1 = "5" y1 = "12" x2 = "19" y2 = "12" / > < / svg > < / span >
< span class = "vis-label" > New Chat< / span >
< input type = "checkbox" checked data-ui-key = "sidebar-new-chat" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < 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 = "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" / > < / svg > < / span >
< span class = "vis-label" > Chats < span class = "vis-hint" > Chat history list< / span > < / span >
< input type = "checkbox" checked data-ui-key = "sessions-section" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "2" y = "4" width = "20" height = "16" rx = "2" / > < path d = "m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" / > < / svg > < / span >
< span class = "vis-label" > Email< / span >
< input type = "checkbox" checked data-ui-key = "email-section" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" > < path d = "M12 2L2 7l10 5 10-5-10-5z" / > < path d = "M2 17l10 5 10-5" / > < path d = "M2 12l10 5 10-5" / > < / svg > < / span >
< span class = "vis-label" > Models < span class = "vis-hint" > Model selector & quick-chat< / span > < / span >
< input type = "checkbox" checked data-ui-key = "models-section" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" > < path d = "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" / > < / svg > < / span >
< span class = "vis-label" > Tools < span class = "vis-hint" > Whole section (header + all tools)< / span > < / span >
< input type = "checkbox" checked data-ui-key = "tools-section" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < 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 = "M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" / > < path d = "M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" / > < path d = "M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" / > < / svg > < / span >
< span class = "vis-label" > Brain< / span >
< input type = "checkbox" checked data-ui-key = "tool-memory" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "4" width = "18" height = "18" rx = "2" / > < line x1 = "16" y1 = "2" x2 = "16" y2 = "6" / > < line x1 = "8" y1 = "2" x2 = "8" y2 = "6" / > < line x1 = "3" y1 = "10" x2 = "21" y2 = "10" / > < / svg > < / span >
< span class = "vis-label" > Calendar< / span >
< input type = "checkbox" checked data-ui-key = "tool-calendar" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "18" cy = "18" r = "3" / > < circle cx = "6" cy = "6" r = "3" / > < path d = "M13 6h3a2 2 0 0 1 2 2v7" / > < path d = "M11 18H8a2 2 0 0 1-2-2V9" / > < / svg > < / span >
< span class = "vis-label" > Compare< / span >
< input type = "checkbox" checked data-ui-key = "tool-compare" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < 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 = "M4 19.5A2.5 2.5 0 0 1 6.5 17H20" / > < path d = "M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" / > < path d = "M9 7h6M9 11h4" / > < / svg > < / span >
< span class = "vis-label" > Cookbook< / span >
< input type = "checkbox" checked data-ui-key = "tool-cookbook" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" > < circle cx = "11" cy = "11" r = "8" / > < path d = "M21 21l-4.35-4.35" / > < line x1 = "11" y1 = "8" x2 = "11" y2 = "14" / > < line x1 = "8" y1 = "11" x2 = "14" y2 = "11" / > < / svg > < / span >
< span class = "vis-label" > Deep Research< / span >
< input type = "checkbox" checked data-ui-key = "tool-research" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "3" width = "18" height = "18" rx = "2" / > < circle cx = "8.5" cy = "8.5" r = "1.5" / > < path d = "M21 15l-5-5L5 21" / > < / svg > < / span >
< span class = "vis-label" > Gallery< / span >
< input type = "checkbox" checked data-ui-key = "tool-gallery" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < 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 = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" / > < polyline points = "14 2 14 8 20 8" / > < line x1 = "8" y1 = "13" x2 = "16" y2 = "13" / > < line x1 = "8" y1 = "17" x2 = "13" y2 = "17" / > < / svg > < / span >
< span class = "vis-label" > Library< / span >
< input type = "checkbox" checked data-ui-key = "tool-library" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "3" width = "18" height = "18" rx = "2" / > < path d = "M9 7h6M9 11h6M9 15h4" / > < / svg > < / span >
< span class = "vis-label" > Notes< / span >
< input type = "checkbox" checked data-ui-key = "tool-notes" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "4" width = "18" height = "18" rx = "2" / > < line x1 = "16" y1 = "2" x2 = "16" y2 = "6" / > < line x1 = "8" y1 = "2" x2 = "8" y2 = "6" / > < line x1 = "3" y1 = "10" x2 = "21" y2 = "10" / > < path d = "M9 16l2 2 4-4" / > < / svg > < / span >
< span class = "vis-label" > Tasks< / span >
< input type = "checkbox" checked data-ui-key = "tool-tasks" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "12" cy = "12" r = "10" / > < path d = "M12 2a10 10 0 0 0 0 20 5 5 0 0 0 5-5 3 3 0 0 0-3-3h-2a3 3 0 0 1-3-3 5 5 0 0 1 5-5" / > < / svg > < / span >
< span class = "vis-label" > Theme< / span >
< input type = "checkbox" checked data-ui-key = "tool-theme" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < 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 = "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" / > < circle cx = "12" cy = "7" r = "4" / > < / svg > < / span >
< span class = "vis-label" > User < span class = "vis-hint" > Avatar & name< / span > < / span >
< input type = "checkbox" checked data-ui-key = "user-bar" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "12" cy = "12" r = "3" / > < path d = "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 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-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 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 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 > < / span >
< span class = "vis-label" > Settings Button < span class = "vis-hint" > Cog next to user — re-open with < code > /settings< / code > < / span > < / span >
< input type = "checkbox" checked data-ui-key = "sidebar-settings-btn" > < span class = "vis-switch" > < / span >
< / label >
< / div >
< / div >
< div class = "admin-card" style = "padding-bottom:6px;" >
< 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 = "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" / > < / svg > Chat Area< / h2 >
< div class = "vis-toggles" >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" > < path d = "M4 6h16" / > < path d = "M4 10h8" / > < / svg > < / span >
< span class = "vis-label" > Session Header < span class = "vis-hint" > Model name & export above chat< / span > < / span >
< input type = "checkbox" checked data-ui-key = "chat-meta" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" > < path d = "M12 3v2m0 14v2m-7-9H3m18 0h-2m-1.5-6.5L16 7m-8-1.5L6.5 7m11 11l-1.5-1.5M8 18l-1.5 1.5" / > < circle cx = "12" cy = "12" r = "4" / > < / svg > < / span >
< span class = "vis-label" > Welcome Message < span class = "vis-hint" > Logo & tips on empty chat< / span > < / span >
< input type = "checkbox" checked data-ui-key = "welcome-text" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < 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 = "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" / > < circle cx = "12" cy = "12" r = "3" / > < / svg > < / span >
< span class = "vis-label" > Incognito Mode < span class = "vis-hint" > No memory, no history saved< / span > < / span >
< input type = "checkbox" checked data-ui-key = "incognito-btn" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" style = "font-size:13px;line-height:14px;" > Aa< / span >
< span class = "vis-label" > Text-only Emojis < span class = "vis-hint" > Strip emojis from AI replies< / span > < / span >
< input type = "checkbox" data-ui-key = "text-emojis" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" > < path d = "M12 2a7 7 0 0 0-7 7c0 3 2 5.5 4.5 7.5.7.6 1.2 1.3 1.5 2h2c.3-.7.8-1.4 1.5-2C17 14.5 19 12 19 9a7 7 0 0 0-7-7z" / > < line x1 = "10" y1 = "22" x2 = "14" y2 = "22" / > < / svg > < / span >
< span class = "vis-label" > Thinking Process < span class = "vis-hint" > Show < think> collapsible bars< / span > < / span >
< input type = "checkbox" checked data-ui-key = "show-thinking" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < 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 = "M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z" / > < circle cx = "12" cy = "12" r = "3" / > < path d = "M4 4l16 16" / > < / svg > < / span >
< span class = "vis-label" > Sensitive Blur < span class = "vis-hint" > Blur emails, tokens, and secrets in AI output< / span > < / span >
< input type = "checkbox" data-privacy-key = "sensitive-blur" > < span class = "vis-switch" > < / span >
< / label >
< / div >
< / div >
< div class = "admin-card" style = "padding-bottom:6px;" >
< 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" > < line x1 = "17" y1 = "10" x2 = "3" y2 = "10" / > < line x1 = "21" y1 = "6" x2 = "3" y2 = "6" / > < line x1 = "21" y1 = "14" x2 = "3" y2 = "14" / > < line x1 = "17" y1 = "18" x2 = "3" y2 = "18" / > < / svg > Chat Bar< / h2 >
< div class = "vis-toggles" >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "11" cy = "11" r = "8" / > < line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" / > < / svg > < / span >
< span class = "vis-label" > Web Search< / span >
< input type = "checkbox" checked data-ui-key = "web-toggle-btn" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" > < path d = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" / > < polyline points = "14 2 14 8 20 8" / > < line x1 = "16" y1 = "13" x2 = "8" y2 = "13" / > < line x1 = "16" y1 = "17" x2 = "8" y2 = "17" / > < / svg > < / span >
< span class = "vis-label" > Document Editor< / span >
< input type = "checkbox" checked data-ui-key = "doc-toggle-btn" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < polyline points = "4 17 10 11 4 5" / > < line x1 = "12" y1 = "19" x2 = "20" y2 = "19" / > < / svg > < / span >
< span class = "vis-label" > Shell< / span >
< input type = "checkbox" checked data-ui-key = "bash-toggle-btn" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.5" stroke-linecap = "round" stroke-linejoin = "round" > < polyline points = "18 15 12 9 6 15" / > < / svg > < / span >
< span class = "vis-label" > More Tools < span class = "vis-hint" > Overflow menu< / span > < / span >
< input type = "checkbox" checked data-ui-key = "overflow-plus-btn" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon vis-icon-text" > A|C< / span >
< span class = "vis-label" > Agent / Chat < span class = "vis-hint" > Mode switcher< / span > < / span >
< input type = "checkbox" checked data-ui-key = "mode-toggle" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < 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 = "M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" / > < / svg > < / span >
< span class = "vis-label" > Attach Files< / span >
< input type = "checkbox" checked data-ui-key = "attach-btn" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "11" cy = "11" r = "8" / > < path d = "M21 21l-4.35-4.35" / > < line x1 = "11" y1 = "8" x2 = "11" y2 = "14" / > < line x1 = "8" y1 = "11" x2 = "14" y2 = "11" / > < / svg > < / span >
< span class = "vis-label" > Deep Research< / span >
< input type = "checkbox" checked data-ui-key = "research-btn" > < span class = "vis-switch" > < / span >
< / label >
< label class = "vis-row" >
< span class = "vis-icon" > < 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 = "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" / > < circle cx = "12" cy = "7" r = "4" / > < / svg > < / span >
Rename Character copy to Persona
Issue #234: the "Character" tab and its "Style of response" label made it
unclear that this is where a system prompt is set. Rename the user-facing
labels for clarity:
- "Character" tab + section heading -> "Persona"
- "Style of response" -> "System prompt"
- supporting strings: select placeholder, name placeholder, button/title
text, toasts, confirm/notice text, the chat-bar indicator tooltip, the
settings visibility toggle, and the assistant personality picker
("Characters" optgroup -> "Personas").
Used "Persona" rather than the issue's suggested "Preset" because the app
already has a distinct, user-facing "Presets" concept (built-in presets
like Code Analyze/Brainstorm/Reason, shown as their own group in the
assistant picker). "Persona" matches what this tab actually creates -- a
named persona with its own memories -- without colliding with that term.
Internal identifiers (element IDs, data-chartab attributes, function names)
and the character_name backend field are intentionally left unchanged so
existing saved presets and JS wiring keep working.
2026-06-02 05:42:15 +02:00
< span class = "vis-label" > Personas < span class = "vis-hint" > Persona picker & system prompt< / span > < / span >
2026-05-31 23:58:26 +09:00
< input type = "checkbox" checked data-ui-key = "preset-mini-btn" > < span class = "vis-switch" > < / span >
< / label >
< / div >
< / div >
< div style = "text-align:right;padding:0 4px;" >
< button type = "button" class = "admin-btn-sm" id = "set-uiVisResetBtn" style = "opacity:0.5;" > Reset All< / button >
< / div >
< / div >
<!-- ═══ THEME TAB ═══ -->
<!-- ═══ MEMORY TAB ═══ -->
<!-- ═══ SHORTCUTS TAB ═══ -->
< div data-settings-panel = "shortcuts" class = "hidden" >
< div class = "admin-card" style = "display:flex;align-items:center;justify-content:space-between;padding:10px 16px;" >
< div >
< h2 style = "margin:0;font-size:13px;" > < 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" > < rect x = "2" y = "4" width = "20" height = "16" rx = "2" / > < path d = "M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10" / > < / svg > Keyboard Shortcuts< / h2 >
< p style = "font-size:10px;opacity:0.4;margin:2px 0 0;" > Click a shortcut to rebind. Press Escape to cancel.< / p >
< / div >
< button type = "button" class = "shortcut-action-btn is-reset" id = "shortcuts-reset-btn" title = "Reset Shortcuts" style = "width:28px;height:28px;font-size:15px;" > ↩ < / button >
< / div >
< div class = "admin-card" >
< div id = "shortcuts-list" > < / div >
< / div >
< / div >
<!-- ═══ ACCOUNT TAB ═══ -->
< div data-settings-panel = "account" class = "hidden" >
< 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 = "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" / > < circle cx = "12" cy = "7" r = "4" / > < / svg > Account< / h2 >
< div style = "display:flex;align-items:center;gap:10px;margin:4px 0 12px;" >
< div class = "user-bar-avatar" id = "settings-account-avatar" style = "width:32px;height:32px;font-size:14px;" > < / div >
< div style = "flex:1;" >
< div id = "settings-account-username" style = "font-size:13px;font-weight:600;" > < / div >
< div id = "settings-account-role" style = "font-size:11px;opacity:0.5;" > < / div >
< / div >
< button id = "settings-logout-btn" style = "background:color-mix(in srgb, var(--color-error) 10%, transparent);border:1px solid var(--color-error);border-radius:6px;padding:6px 12px;color:var(--color-error);font-family:inherit;font-size:12px;font-weight:500;cursor:pointer;transition:opacity 0.15s,background 0.15s;display:inline-flex;align-items:center;gap:6px;" >
< svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" / > < polyline points = "16 17 21 12 16 7" / > < line x1 = "21" y1 = "12" x2 = "9" y2 = "12" / > < / svg >
Logout
< / button >
< / div >
< / div >
< 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" > < rect x = "3" y = "11" width = "18" height = "11" rx = "2" ry = "2" / > < path d = "M7 11V7a5 5 0 0 1 10 0v4" / > < / svg > Change Password< / h2 >
< div class = "settings-col" >
< input id = "settings-pw-current" type = "password" placeholder = "Current password" autocomplete = "current-password" style = "padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;" >
< input id = "settings-pw-new" type = "password" placeholder = "New password (min 8)" autocomplete = "new-password" style = "padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;" >
< input id = "settings-pw-confirm" type = "password" placeholder = "Confirm new password" autocomplete = "new-password" style = "padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;" >
< div class = "settings-row" style = "margin-top:2px;justify-content:flex-end;" >
< span id = "settings-pw-msg" style = "font-size:11px;margin-right:auto;" > < / span >
< button class = "admin-btn-add" id = "settings-pw-save" > Update Password< / button >
< / div >
< / div >
< / div >
< div class = "admin-card" id = "settings-2fa-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" > < rect x = "3" y = "11" width = "18" height = "11" rx = "2" ry = "2" / > < path d = "M7 11V7a5 5 0 0 1 10 0v4" / > < circle cx = "12" cy = "16" r = "1" / > < / svg > Two-Factor Authentication< / h2 >
< div id = "settings-2fa-content" >
<!-- Populated by JS -->
< / div >
< / div >
< / div >
<!-- ═══ EMAIL TAB ═══ -->
< div data-settings-panel = "email" class = "hidden" >
< 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" > < rect x = "2" y = "4" width = "20" height = "16" rx = "2" / > < path d = "m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" / > < / svg > Email Accounts< / h2 >
2026-06-01 13:01:33 +09:00
< div class = "settings-row" style = "align-items:center;" >
< div class = "admin-toggle-sub" style = "margin:0;flex:1;" > Add, edit, delete, and test accounts in Integrations.< / div >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< button class = "admin-btn-add" id = "set-email-open-integrations" style = "display:inline-flex;align-items:center;gap:6px;" > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" style = "opacity:0.7" > < path d = "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" / > < path d = "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" / > < / svg > Manage in Integrations< / button >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< div class = "admin-card" >
2026-06-01 20:56:11 +09:00
< 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" > < rect x = "3" y = "4" width = "18" height = "18" rx = "2" / > < line x1 = "16" y1 = "2" x2 = "16" y2 = "6" / > < line x1 = "8" y1 = "2" x2 = "8" y2 = "6" / > < line x1 = "3" y1 = "10" x2 = "21" y2 = "10" / > < path d = "M9 16l2 2 4-4" / > < / svg > Email Tasks< / h2 >
< div class = "settings-row" style = "align-items:center;" >
< div class = "admin-toggle-sub" style = "margin:0;flex:1;" > Manage email background tasks in Tasks.< / div >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< button class = "admin-btn-add" id = "set-email-open-tasks" style = "display:inline-flex;align-items:center;gap:6px;" > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" aria-hidden = "true" style = "opacity:0.7" > < rect x = "3" y = "4" width = "18" height = "18" rx = "2" / > < line x1 = "16" y1 = "2" x2 = "16" y2 = "6" / > < line x1 = "8" y1 = "2" x2 = "8" y2 = "6" / > < line x1 = "3" y1 = "10" x2 = "21" y2 = "10" / > < path d = "M9 16l2 2 4-4" / > < / svg > Open Tasks< / button >
2026-06-01 20:56:11 +09:00
< / div >
< / div >
< div class = "admin-card" >
2026-05-31 23:58:26 +09:00
< 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" > AI-extracted from your sent emails. Used when AI drafts replies.< / div >
< div class = "settings-col" >
< textarea id = "set-email-style" rows = "4" class = "settings-select" style = "font-family:inherit;resize:vertical" 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;" > Extract from Sent (15 emails)< / button >
< button class = "admin-btn-add" id = "set-email-style-save" > Save< / button >
< / div >
< / div >
< / div >
< / div >
<!-- ═══ REMINDERS TAB ═══ -->
< div data-settings-panel = "reminders" class = "hidden" >
< 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 = "M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" / > < path d = "M13.73 21a2 2 0 0 1-3.46 0" / > < / svg > How you're reminded< / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Controls how fired note reminders are delivered.< / div >
< div class = "settings-col" >
< div class = "settings-row" >
< label class = "settings-label" > Channel< / label >
< select id = "set-reminder-channel" class = "settings-select" >
< option value = "browser" > Browser notification (default)< / option >
< option value = "email" id = "set-reminder-channel-email-opt" > Email< / option >
< option value = "ntfy" id = "set-reminder-channel-ntfy-opt" > ntfy< / option >
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:47:57 -04:00
< option value = "webhook" id = "set-reminder-channel-webhook-opt" > Webhook< / option >
2026-05-31 23:58:26 +09:00
< / select >
< / div >
< div id = "set-reminder-email-from-row" class = "settings-row" style = "display:none" >
< label class = "settings-label" > Send from< / label >
< select id = "set-reminder-email-account" class = "settings-select" > < / select >
< / div >
< div id = "set-reminder-email-to-row" class = "settings-row" style = "display:none" >
< label class = "settings-label" > Send to< / label >
< input id = "set-reminder-email-to" class = "settings-select" type = "email" placeholder = "you@example.com" / >
< / div >
< div id = "set-reminder-ntfy-topic-row" class = "settings-row" style = "display:none" >
< label class = "settings-label" > ntfy topic< / label >
< input id = "set-reminder-ntfy-topic" class = "settings-select" type = "text" placeholder = "reminders" / >
< / div >
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:47:57 -04:00
< div id = "set-reminder-webhook-intg-row" class = "settings-row" style = "display:none" >
< label class = "settings-label" > Integration< / label >
< select id = "set-reminder-webhook-intg" class = "settings-select" > < / select >
< / div >
< div id = "set-reminder-webhook-template-row" class = "settings-row" style = "display:none;align-items:flex-start" >
< label class = "settings-label" style = "padding-top:6px" > Payload< / label >
< textarea id = "set-reminder-webhook-template" class = "settings-select" rows = "3" style = "font-family:inherit;resize:vertical;flex:1" placeholder = '{"content": "{{title}}: {{message}}"}' > < / textarea >
< / div >
2026-05-31 23:58:26 +09:00
< div id = "set-reminder-channel-hint" style = "font-size:11px;opacity:0.6;" > < / div >
< div style = "font-size:11px;opacity:0.6;margin-top:4px;" > Configure email account, ntfy server, etc. in < a href = "#" id = "set-reminders-open-integrations" style = "color:var(--accent, var(--red));text-decoration:none;font-weight:600;" > Integrations< / a > .< / div >
< / div >
< / div >
< div class = "admin-card" >
< h2 style = "display:flex;align-items:center;gap:6px;" > < svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "margin-right:1px;opacity:0.6;flex-shrink:0" > < path d = "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" / > < / svg > AI Synthesis< span style = "flex:1" > < / span > < label class = "admin-switch" title = "Use the utility model to write reminder messages" > < input type = "checkbox" id = "set-reminder-llm-toggle" > < span class = "admin-slider" > < / span > < / label > < / h2 >
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:47:57 -04:00
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > When on, the utility model writes a short, warm one-line reminder for browser, email, ntfy, AND webhook reminders instead of just the raw note content.< / div >
2026-05-31 23:58:26 +09:00
< / div >
< 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 = "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" / > < path d = "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" / > < / svg > Public App URL< / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Used to build clickable links back to Odysseus inside outgoing reminder / urgent-email emails (e.g. < code > https://chat.yourdomain.com< / code > ). Leave blank to omit links.< / div >
< div class = "settings-col" >
< div class = "settings-row" >
< label class = "settings-label" > URL< / label >
< input id = "set-app-public-url" class = "settings-select" type = "url" placeholder = "https://chat.example.com" style = "flex:1;" / >
< / div >
< div id = "set-app-public-url-msg" style = "font-size:11px;color:color-mix(in srgb, var(--fg) 55%, transparent);" > < / div >
< / div >
< / div >
< 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 = "M22 11.08V12a10 10 0 1 1-5.93-9.14" / > < polyline points = "22 4 12 14.01 9 11.01" / > < / svg > Test< / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Fire a test reminder using your current settings to verify everything works.< / div >
< div class = "settings-row" >
< span id = "set-reminder-test-msg" style = "font-size:11px;" > < / span >
< button class = "admin-btn-add" id = "set-reminder-test-btn" style = "margin-left:auto;" > Send Test Reminder< / button >
< / div >
< / div >
< / div >
<!-- ═══ ADMIN: USERS TAB ═══ -->
< div data-settings-panel = "users" class = "hidden" >
< 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 = "M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" / > < circle cx = "8.5" cy = "7" r = "4" / > < line x1 = "20" y1 = "8" x2 = "20" y2 = "14" / > < line x1 = "23" y1 = "11" x2 = "17" y2 = "11" / > < / svg > Registration< / h2 >
< div class = "admin-toggle-row" >
< div >
< div class = "admin-toggle-label" > Open signup< / div >
< div class = "admin-toggle-sub" > Allow anyone to create an account from the login page< / div >
< / div >
< label class = "admin-switch" > < input type = "checkbox" id = "adm-signupToggle" > < span class = "admin-slider" > < / span > < / label >
< / div >
< / div >
< 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 = "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" / > < circle cx = "9" cy = "7" r = "4" / > < path d = "M23 21v-2a4 4 0 0 0-3-3.87" / > < path d = "M16 3.13a4 4 0 0 1 0 7.75" / > < / svg > Users< / h2 >
< div id = "adm-userList" > < div class = "admin-empty" > Loading...< / div > < / div >
< / div >
< 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 = "M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" / > < circle cx = "8.5" cy = "7" r = "4" / > < line x1 = "20" y1 = "8" x2 = "20" y2 = "14" / > < line x1 = "23" y1 = "11" x2 = "17" y2 = "11" / > < / svg > Add User< / h2 >
< div class = "admin-add-form" >
2026-06-07 16:50:18 +01:00
< input id = "adm-newUsername" type = "text" placeholder = "Username" >
2026-05-31 23:58:26 +09:00
< input id = "adm-newPassword" type = "password" placeholder = "Password (min 8)" >
< div class = "admin-switch-inline" title = "Grant full admin access" > < label class = "admin-switch" > < input type = "checkbox" id = "adm-newIsAdmin" > < span class = "admin-slider" > < / span > < / label > Admin< / div >
< / div >
< div class = "settings-row" style = "margin-top:6px;" >
< button class = "admin-btn-add" id = "adm-addBtn" > Add User< / button >
< span id = "adm-addMsg" style = "font-size:11px" > < / span >
< / div >
< / div >
< / div >
<!-- ═══ SERVICES TAB ═══ -->
< div data-settings-panel = "services" >
< 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" > < rect x = "2" y = "2" width = "20" height = "8" rx = "2" / > < rect x = "2" y = "14" width = "20" height = "8" rx = "2" / > < circle cx = "6" cy = "6" r = "1" / > < circle cx = "6" cy = "18" r = "1" / > < / svg > Add Models < span style = "opacity:0.45;font-weight:normal;font-size:0.82em" > (Endpoints)< / span > < / h2 >
2026-06-01 10:00:15 +09:00
< div class = "admin-toggle-sub" style = "margin-bottom:10px" > Connect local models first, or add a cloud API.< / div >
2026-05-31 23:58:26 +09:00
<!-- Local subsection -->
< div class = "adm-add-section collapsible collapsed" id = "adm-add-local" >
< div class = "adm-ep-section-head adm-section-toggle" role = "button" tabindex = "0" aria-expanded = "false" >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-1px;margin-right:4px;" > < rect x = "2" y = "3" width = "20" height = "14" rx = "2" / > < path d = "M8 21h8" / > < path d = "M12 17v4" / > < / svg >
< span > Local< / span >
< svg class = "adm-section-caret" width = "11" height = "11" 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 >
< / div >
< div class = "admin-model-form" >
< div class = "admin-model-form-row" >
2026-06-01 10:00:15 +09:00
< input id = "adm-epLocalUrl" type = "text" placeholder = "Paste endpoint URL, e.g. http://localhost:11434/v1" style = "flex:1" >
< select id = "adm-epLocalType" style = "padding:5px;width:72px;flex-shrink:0;" >
2026-05-31 23:58:26 +09:00
< option value = "llm" > LLM< / option >
< option value = "image" > Image< / option >
< / select >
2026-06-01 10:00:15 +09:00
< / div >
2026-06-02 07:36:54 -04:00
< div class = "admin-model-form-row" >
< input id = "adm-epLocalApiKey" type = "password" placeholder = "API key (optional — for protected local endpoints)" autocomplete = "off" style = "flex:1" >
< / div >
2026-06-01 10:00:15 +09:00
< div class = "admin-model-form-row" >
< span style = "flex:1" > < / span >
< button class = "admin-btn-sm" id = "adm-epLocalTestBtn" style = "width:55px;text-align:center;" > Test< / button >
2026-05-31 23:58:26 +09:00
< button class = "admin-btn-add" id = "adm-epLocalAddBtn" style = "width:55px;text-align:center;" > Add< / button >
< / div >
2026-06-01 10:00:15 +09:00
< div class = "adm-quickstart-section collapsed" id = "adm-add-local-quickstart" >
< div class = "adm-quickstart-toggle" role = "button" tabindex = "0" aria-expanded = "false" >
< span > Quickstart< / span >
< svg class = "adm-section-caret" width = "11" height = "11" 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 >
< / div >
< div class = "adm-quickstart-body" >
< button class = "admin-btn-sm" id = "adm-epDiscoverBtn" title = "Scan your network for running model servers" >
< svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" style = "vertical-align:-1px;margin-right:4px;" > < circle cx = "11" cy = "11" r = "8" / > < line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" / > < / svg > Scan for Servers
< / button >
< button class = "admin-btn-sm" id = "adm-epOllamaBtn" title = "Fill the default Ollama endpoint" > Ollama< / button >
< / div >
< / div >
< div id = "adm-epLocalMsg" class = "adm-ep-inline-msg" > < / div >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
<!-- API subsection -->
< div class = "adm-add-section collapsible collapsed" id = "adm-add-api" style = "margin-top:14px" >
< div class = "adm-ep-section-head adm-section-toggle" role = "button" tabindex = "0" aria-expanded = "false" >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-1px;margin-right:4px;" > < circle cx = "12" cy = "12" r = "10" / > < line x1 = "2" y1 = "12" x2 = "22" y2 = "12" / > < path d = "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" / > < / svg >
< span > API< / span >
< svg class = "adm-section-caret" width = "11" height = "11" 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 >
< / div >
< div class = "admin-model-form" >
<!-- Custom picker (with logos). Hidden native <select> mirrors
its value so the existing JS that reads adm-epProvider
keeps working unchanged. -->
2026-06-01 10:00:15 +09:00
< div class = "adm-provider-picker adm-provider-combo" id = "adm-provider-picker" >
< input id = "adm-epUrl" type = "text" placeholder = "Base URL or pick provider" autocomplete = "off" >
< button type = "button" class = "adm-provider-btn" id = "adm-provider-btn" title = "Pick provider" >
< span class = "adm-provider-current" > < span class = "adm-provider-logo" > < / span > < span class = "adm-provider-name" > Provider< / span > < / span >
2026-05-31 23:58:26 +09:00
< svg class = "adm-provider-caret" width = "10" height = "10" 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 >
< / button >
< div class = "adm-provider-menu hidden" id = "adm-provider-menu" > < / div >
< / div >
< select id = "adm-epProvider" style = "display:none" >
< option value = "" > Custom URL< / option >
< option value = "https://api.anthropic.com" data-logo = "anthropic" > Anthropic< / option >
2026-06-01 10:00:15 +09:00
< option value = "https://api.deepseek.com/v1" data-logo = "deepseek" selected > DeepSeek< / option >
2026-05-31 23:58:26 +09:00
< option value = "https://api.openai.com/v1" data-logo = "openai" > OpenAI< / option >
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
< option value = "copilot" data-logo = "github" data-auth-flow = "copilot" > GitHub Copilot< / option >
< option value = "chatgpt-subscription" data-logo = "openai" data-auth-flow = "chatgpt-subscription" > ChatGPT Subscription< / option >
2026-05-31 23:58:26 +09:00
< option value = "https://openrouter.ai/api/v1" data-logo = "openrouter" > OpenRouter< / option >
2026-06-01 15:26:10 +10:00
< option value = "https://ollama.com/api" data-logo = "ollama" > Ollama Cloud< / option >
2026-05-31 23:58:26 +09:00
< option value = "https://api.groq.com/openai/v1" data-logo = "groq" > Groq< / option >
< option value = "https://api.mistral.ai/v1" data-logo = "mistral" > Mistral< / option >
< option value = "https://api.together.xyz/v1" data-logo = "together" > Together AI< / option >
< option value = "https://api.fireworks.ai/inference/v1" data-logo = "fireworks" > Fireworks AI< / option >
< option value = "https://generativelanguage.googleapis.com/v1beta/openai" data-logo = "gemini" > Google Gemini< / option >
< option value = "https://api.x.ai/v1" data-logo = "grok" > xAI Grok< / option >
< option value = "https://api.z.ai/api/paas/v4" data-logo = "zhipu" > Z.AI (Zhipu)< / option >
2026-06-07 17:43:00 +03:00
< option value = "https://opencode.ai/zen/v1" data-logo = "opencode" > OpenCode Zen< / option >
< option value = "https://opencode.ai/zen/go/v1" data-logo = "opencode" > OpenCode Go< / option >
2026-06-02 13:59:17 +02:00
< option value = "https://api.z.ai/api/coding/paas/v4" data-logo = "zhipu" > Z.AI Coding Plan< / option >
2026-05-31 23:58:26 +09:00
< / select >
< div class = "admin-model-form-row" >
< input id = "adm-epApiKey" type = "password" placeholder = "API key" >
2026-06-04 00:56:11 -03:00
< select id = "adm-epKind" style = "padding:5px;width:82px;" >
< option value = "proxy" > Proxy< / option >
< option value = "api" > API< / option >
< / select >
2026-05-31 23:58:26 +09:00
< select id = "adm-epType" style = "padding:5px;width:80px;" >
< option value = "llm" > LLM< / option >
< option value = "image" > Image< / option >
< / select >
2026-06-01 10:00:15 +09:00
< button class = "admin-btn-sm" id = "adm-epApiTestBtn" style = "width:55px;text-align:center;" > Test< / button >
< button class = "admin-btn-sm hidden" id = "adm-epApiCancelTestBtn" style = "width:62px;text-align:center;" > Cancel< / button >
2026-05-31 23:58:26 +09:00
< button class = "admin-btn-add" id = "adm-epAddBtn" style = "width:55px;text-align:center;" > Add< / button >
< / div >
2026-06-01 10:00:15 +09:00
< div id = "adm-epApiMsg" class = "adm-ep-inline-msg" > < / div >
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
< div id = "adm-deviceAuthStatus" class = "adm-ep-inline-msg" > < / div >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< / div >
< 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" > < rect x = "2" y = "3" width = "20" height = "14" rx = "2" / > < line x1 = "8" y1 = "21" x2 = "16" y2 = "21" / > < line x1 = "12" y1 = "17" x2 = "12" y2 = "21" / > < / svg > Added Models < span style = "opacity:0.45;font-weight:normal;font-size:0.82em" > (Endpoints)< / span > < / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:10px" > Manage the endpoints you've added.< / div >
< div class = "adm-ep-section" >
< div class = "adm-ep-section-head" >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-1px;margin-right:4px;" > < rect x = "2" y = "3" width = "20" height = "14" rx = "2" / > < path d = "M8 21h8" / > < path d = "M12 17v4" / > < / svg >
< span > Local< / span >
< / div >
< div id = "adm-epList-local" > < div class = "admin-empty" > Loading...< / div > < / div >
< / div >
< div class = "adm-ep-section" style = "margin-top:14px" >
< div class = "adm-ep-section-head" >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "vertical-align:-1px;margin-right:4px;" > < circle cx = "12" cy = "12" r = "10" / > < line x1 = "2" y1 = "12" x2 = "22" y2 = "12" / > < path d = "M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" / > < / svg >
< span > API< / span >
< / div >
< div id = "adm-epList-api" > < / div >
< / div >
< / div >
< / div >
<!-- ═══ TOOLS TAB ═══ -->
<!-- ═══ INTEGRATIONS TAB ═══ -->
< div data-settings-panel = "integrations" class = "hidden" >
< div class = "admin-card" >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< 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 = "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" / > < path d = "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" / > < / svg > Integrations< / h2 >
2026-05-31 23:58:26 +09:00
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > All external service connections in one place.< / div >
< div id = "unified-integrations-list" > < / div >
< div id = "unified-intg-form" style = "display:none" > < / div >
< div style = "text-align:center;padding:8px 0;" >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< button type = "button" class = "admin-btn-sm" id = "unified-intg-add-btn" style = "display:inline-flex;align-items:center;gap:6px;" > + Add Integration< svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" style = "opacity:0.7;" > < path d = "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" / > < path d = "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" / > < / svg > < / button >
2026-05-31 23:58:26 +09:00
< / div >
< / div >
< / div >
<!-- ═══ TOOLS TAB ═══ -->
< div data-settings-panel = "tools" class = "hidden" >
< div class = "admin-card" style = "margin-bottom:12px;" >
< 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 = "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" / > < / svg > Built-in Tools< / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Enable or disable tools available to the AI agent.< / div >
< div id = "adm-builtin-tools-list" class = "admin-user-list" > < / div >
< / div >
< / div >
<!-- ═══ SYSTEM TAB ═══ -->
< div data-settings-panel = "system" class = "hidden" >
2026-06-01 21:11:47 +03:00
2026-05-31 23:58:26 +09:00
< 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 = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" / > < polyline points = "17 8 12 3 7 8" / > < line x1 = "12" y1 = "3" x2 = "12" y2 = "15" / > < / svg > Data Backup< / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Export or import your user data (memories, presets, settings, skills, preferences) as a JSON file.< / div >
< div style = "display:flex;gap:8px;flex-wrap:wrap;" >
< button class = "admin-btn-add" id = "adm-exportDataBtn" > Export Data< / button >
< button class = "admin-btn-add" id = "adm-importDataBtn" > Import Data< / button >
< input type = "file" id = "adm-importFile" accept = ".json" style = "display:none;" >
< / div >
< div id = "adm-backupMsg" style = "margin-top:6px;" > < / div >
< / div >
< div class = "admin-card admin-danger-card" >
< h2 style = "color:#e55;" > Danger Zone< / h2 >
< div class = "admin-toggle-sub" style = "margin-bottom:8px" > Irreversible. Each wipe targets one category — pick exactly what you want gone.< / div >
< div style = "display:flex;justify-content:space-between;align-items:center;" >
< div >
< div class = "admin-toggle-label" > Wipe all chats< / div >
< div class = "admin-toggle-sub" > Every session, message, and chat history. Documents/notes/etc. stay.< / div >
< / div >
< button class = "admin-btn-delete" data-wipe-kind = "chats" style = "white-space:nowrap;" > Wipe< / button >
< / div >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-top:8px;" >
< div >
< div class = "admin-toggle-label" > Wipe all memory< / div >
< div class = "admin-toggle-sub" > Clears `memory.json`, the Memory table, and the vector store. Skills not affected.< / div >
< / div >
< button class = "admin-btn-delete" data-wipe-kind = "memory" style = "white-space:nowrap;" > Wipe< / button >
< / div >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-top:8px;" >
< div >
< div class = "admin-toggle-label" > Wipe all skills< / div >
< div class = "admin-toggle-sub" > Drops `data/skills/` (all SKILL.md files). Memory not affected.< / div >
< / div >
< button class = "admin-btn-delete" data-wipe-kind = "skills" style = "white-space:nowrap;" > Wipe< / button >
< / div >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-top:8px;" >
< div >
< div class = "admin-toggle-label" > Wipe all notes< / div >
< div class = "admin-toggle-sub" > Every note, todo, and checklist.< / div >
< / div >
< button class = "admin-btn-delete" data-wipe-kind = "notes" style = "white-space:nowrap;" > Wipe< / button >
< / div >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-top:8px;" >
< div >
< div class = "admin-toggle-label" > Wipe all tasks< / div >
< div class = "admin-toggle-sub" > Every scheduled task and its run history (Tasks tool).< / div >
< / div >
< button class = "admin-btn-delete" data-wipe-kind = "tasks" style = "white-space:nowrap;" > Wipe< / button >
< / div >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-top:8px;" >
< div >
< div class = "admin-toggle-label" > Wipe all documents< / div >
< div class = "admin-toggle-sub" > Every document and version. Drafts, exports, library — all gone.< / div >
< / div >
< button class = "admin-btn-delete" data-wipe-kind = "documents" style = "white-space:nowrap;" > Wipe< / button >
< / div >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-top:8px;" >
< div >
< div class = "admin-toggle-label" > Wipe all gallery< / div >
< div class = "admin-toggle-sub" > Every image record and the upload directory on disk.< / div >
< / div >
< button class = "admin-btn-delete" data-wipe-kind = "gallery" style = "white-space:nowrap;" > Wipe< / button >
< / div >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-top:8px;" >
< div >
< div class = "admin-toggle-label" > Wipe all calendar< / div >
< div class = "admin-toggle-sub" > Every event and every calendar (incl. CalDAV-synced ones; resync to restore).< / div >
< / div >
< button class = "admin-btn-delete" data-wipe-kind = "calendar" style = "white-space:nowrap;" > Wipe< / button >
< / div >
< div id = "adm-wipeMsg" style = "margin-top:8px;" > < / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
<!-- Search overlay (Ctrl+K command palette) -->
< div class = "search-overlay hidden" id = "search-overlay" >
< div class = "search-popup" >
< input type = "text" id = "search-input" placeholder = "Search conversations..." autocomplete = "off" / >
< div class = "search-results" id = "search-results" > < / div >
< / div >
< / div >
<!-- Toast container (aria - live for screen readers) -->
< div id = "toast" class = "toast" role = "status" aria-live = "polite" > < / div >
<!-- Load modules in this order -->
< script type = "module" src = "/static/js/storage.js" > < / script >
< script type = "module" src = "/static/js/ui.js" > < / 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 >
< script type = "module" src = "/static/js/memory.js" > < / script >
< script type = "module" src = "/static/js/skills.js" > < / script >
< script type = "module" src = "/static/js/tourHints.js" > < / script >
< script type = "module" src = "/static/js/tourAutoplay.js" > < / script >
< script type = "module" src = "/static/js/fileHandler.js" > < / script >
< script type = "module" src = "/static/js/voiceRecorder.js" > < / script >
< script type = "module" src = "/static/js/models.js" > < / script > <!-- This must come BEFORE app.js -->
< script type = "module" src = "/static/js/rag.js" > < / script >
< script type = "module" src = "/static/js/presets.js" > < / script >
< 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" > < / script >
< script type = "module" src = "/static/js/gallery.js" > < / script >
< script type = "module" src = "/static/js/chatRenderer.js" > < / script >
< script type = "module" src = "/static/js/codeRunner.js" > < / script >
< script type = "module" src = "/static/js/chatStream.js" > < / script >
2026-06-05 00:06:37 +02:00
< script type = "module" src = "/static/js/chat.js?v=20260604s" > < / script >
2026-05-31 23:58:26 +09:00
< script type = "module" src = "/static/js/cookbook.js" > < / script >
Cookbook scheduler + serve: schedule via Tasks, Stop verifies kill, Ollama auto port-pick
- Schedule cookbook serves through the existing ScheduledTask system: the
serve preset gets a ^ button next to Launch that opens a daily/hourly/
weekly form mirroring the admin-switch style; the schedule action runs
action_cookbook_serve, which delegates to /api/model/serve and stamps
the resulting task with _scheduledStopAtMs. A background
cookbook_serve_lifecycle loop ticks every 60s and kills any serve
whose window has ended, also dropping the auto-registered endpoint
so the model picker doesn't keep pointing at a dead server.
- Stop and remove on a Running serve now awaits the SSH/tmux kill,
re-checks tmux has-session, and surfaces an error toast (leaving the
row) when the kill failed. Previously fire-and-forget, so a failed
SSH/tmux call silently left the live serve running while the row
vanished from the UI.
- Cookbook tasks/status orphan-adoption sweep no longer requires the
serve-/cookbook- session-id prefix; any tmux session whose pane is
running a known model-server process gets auto-pulled into Running.
Without this loosening, a cookbook-launched serve whose tmux id
fell back to a bare number was invisible — you couldn't see it,
let alone stop it.
- Ollama serve always launches a fresh process under cookbook's tmux
(no more monitor-mode reattach to a systemd/Docker ollama Stop can't
reach). The handler pre-picks a free port by probing the target
host over SSH and mutates req.cmd's OLLAMA_HOST so the runner script
AND the auto-registered endpoint agree on the same bind port.
- Auto-register uses host.docker.internal (when running inside Docker)
instead of localhost, matching the URL /setup adds for Ollama by
hand. Local cookbook serves now produce a chat-reachable endpoint
on first launch.
- Cascade-delete: removing a scheduled cookbook task also deletes any
linked calendar event (cookbook_task_id marker in the description).
- Tasks list groups cookbook_serve under a "Cookbook" category that
sorts above the rest, so scheduler-launched serves are easy to find.
2026-06-05 14:41:43 +09:00
< script src = "/static/js/cookbookSchedule.js" > < / script >
2026-05-31 23:58:26 +09:00
< script type = "module" src = "/static/js/search-chat.js" > < / script >
< script type = "module" src = "/static/js/compare/index.js" > < / script >
< script type = "module" src = "/static/js/theme.js" > < / script >
< script type = "module" src = "/static/js/censor.js" > < / script >
< script type = "module" src = "/static/js/settings.js" > < / script >
< script type = "module" src = "/static/js/admin.js" > < / script >
< script type = "module" src = "/static/js/assistant.js" > < / script >
< script type = "module" src = "/static/app.js" > < / script > <!-- app.js must be LAST -->
< script type = "module" src = "/static/js/init.js" > < / script >
Improve accessibility across core flows (#86)
First incremental pass at issue #86, focused on the universal entry
points and primary navigation. All changes verified in-browser with the
axe-core engine (0 violations on the surfaces below) plus manual keyboard
testing, on both desktop (1280px) and mobile (390px).
Login / first-run setup (static/login.html)
- Add a real <h1>, wrap content in <main> + <footer> landmarks.
- Mark the decorative boat SVG aria-hidden.
- Errors now use role="alert" so screen readers announce them.
- "Remember me" checkbox is keyboard-focusable (was display:none) with an
accessible name and a focus ring; dynamic 2FA field gets a linked label.
- Darken the brand-red submit button so white text clears WCAG AA 4.5:1
(was ~3.2:1); add visible :focus-visible rings.
App shell (static/index.html, static/style.css)
- Remove invalid role="region" from the <main> chat container (it was
overriding the implicit main landmark).
- Add a persistent, visually-hidden <h1> inside <main> so the page always
exposes one logical level-1 heading — works even on mobile where the
sidebar (with the visible brand) is hidden off-canvas.
- Add a reusable .a11y-visually-hidden utility.
- Raise chat-title, model-picker, settings-helper and notes text contrast
above 4.5:1 (were 2.8-3.9:1).
Keyboard nav + dialogs (static/js/a11y.js - new)
- Make the click-only <div> sidebar navigation (New Chat, Search, Brain,
Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes,
Tasks, Theme, account) focusable and Enter/Space-activatable, announced
as buttons (skipping role=button where a nested control would create a
nested-interactive violation). Visible focus ring reused from existing
.list-item:focus-visible.
- Upgrade modals (.modal-content and the docked .notes-pane) to labelled
role="dialog" + aria-modal, and normalise their title to heading level 2
so heading order stays valid. A MutationObserver covers runtime-rendered
rows and modals.
Decorative background canvases (static/js/theme.js)
- Mark all 7 bg-effect canvases aria-hidden.
Notes & Tasks (static/js/notes.js, static/js/tasks.js)
- Label the icon-only Note/To-do toggle pills (fixes a critical
button-name issue) and track aria-pressed state.
- Improve Notes header-button + empty-state contrast.
- Give the Tasks sort <select> an accessible name (fixes a critical
select-name issue).
Remaining data-dense tool modals (Tasks cards, Calendar, Gallery, Email,
Cookbook, Compare, Deep Research) still have muted-text contrast to polish
and are the next incremental step, per the issue's own guidance.
2026-06-01 21:05:43 +02:00
< script type = "module" src = "/static/js/a11y.js" > < / script >
2026-05-31 23:58:26 +09:00
< script nonce = "{{CSP_NONCE}}" > if ( 'serviceWorker' in navigator ) { navigator . serviceWorker . register ( '/static/sw.js' ) . catch ( ( ) => { } ) ; } < / script >
< / body >
< / html >