Files
Cortex-Inara/cortex/static/app.js
Scott Idem 6405dd338d feat: proper confirmation-resume flow + per-user tool policy
Fixes the broken confirmation gate where users had no way to approve
or deny a blocked tool call in the web UI.

Changes:
- orchestrator_engine.py: add OrchestrateCheckpoint dataclass, extract
  loop into _run_from_contents(), add resume() function
- openai_orchestrator.py: same treatment — _run_from_messages(), resume()
- routers/orchestrator.py: POST /{job_id}/confirm and /deny endpoints,
  separate _checkpoints store, _resume_job() + _finalize_job() helpers,
  "awaiting_confirmation" job status with pending_confirmation payload
- auth_utils.py: get_tool_policy() and save_tool_policy() helpers reading
  home/{user}/tool_policy.json (allow/deny lists)
- routers/orchestrator.py: loads tool_policy per user and passes
  confirm_allow/confirm_deny to both engines
- app.js: poll loop handles awaiting_confirmation — shows Confirm/Deny
  buttons inline, resumes polling after user action
- settings.html + settings.py: Tool Permissions section with allow/deny
  textareas, POST /settings/tool-policy route
- style.css: .confirm-gate, .confirm-btn, .deny-btn styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 19:14:53 -04:00

1773 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const sessionEl = document.getElementById('session-id');
const headerEmoji = document.querySelector('.header-emoji');
const backendToggle = document.getElementById('backend-toggle');
const sessionsBtn = document.getElementById('sessions-btn');
const sessionsPanel = document.getElementById('sessions-panel');
const enterToggle = document.getElementById('enter-toggle');
const stopBtn = document.getElementById('stop');
const mode_select_btn_el = document.getElementById('mode-select-btn');
const mode_dropdown_el = document.getElementById('mode-dropdown');
const mode_icon_el = document.getElementById('mode-icon');
const mode_label_el = document.getElementById('mode-label');
const note_vis_btn_el = document.getElementById('note-vis-btn');
const tools_toggle_el = document.getElementById('tools-toggle');
const settings_btn_el = document.getElementById('settings-btn');
const settings_dd_el = document.getElementById('settings-dropdown');
const sessionsBackdrop = document.getElementById('sessions-backdrop');
// ── Close all panels/dropdowns (mutual exclusion) ─────────────
function closeAllPanels() {
if (mode_dropdown_el) mode_dropdown_el.classList.remove('open');
if (settings_dd_el) settings_dd_el.classList.remove('open');
if (sessionsPanel) { sessionsPanel.classList.remove('open'); sessionsBackdrop.classList.remove('open'); }
const pd = document.getElementById('persona-dropdown');
if (pd) pd.classList.remove('open');
const cp = document.getElementById('ctx-panel');
if (cp) cp.classList.remove('open');
}
// ── Toasts ────────────────────────────────────────────────────
const toastContainer = document.getElementById('toast-container');
function showToast(message, type = 'info', duration = 2500) {
const el = document.createElement('div');
el.className = 'toast' + (type !== 'info' ? ' ' + type : '');
el.textContent = message;
toastContainer.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add('show'));
});
setTimeout(() => {
el.classList.remove('show');
el.addEventListener('transitionend', () => el.remove(), { once: true });
}, duration);
}
// ── Syntax highlighting ───────────────────────────────────────
function highlight_code(container) {
if (typeof hljs === 'undefined') return;
container.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
}
// ── Utility helpers ───────────────────────────────────────────
function _esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Lucide icon helpers ───────────────────────────────────────
function icon_html(name, size = 16) {
return `<svg data-lucide="${name}" width="${size}" height="${size}" class="btn-icon"></svg>`;
}
function render_icons() { if (window.lucide) lucide.createIcons(); }
// User/persona injected by the server at /{user}/{persona}
const CORTEX_USER = (window.CORTEX_CONFIG || {}).user || 'scott';
const CORTEX_PERSONA = (window.CORTEX_CONFIG || {}).persona || 'inara';
const CORTEX_EMOJI = (window.CORTEX_CONFIG || {}).emoji || '✨';
const personaLabel = CORTEX_PERSONA.charAt(0).toUpperCase() + CORTEX_PERSONA.slice(1);
const _fileParams = `user=${encodeURIComponent(CORTEX_USER)}&persona=${encodeURIComponent(CORTEX_PERSONA)}`;
if (headerEmoji) headerEmoji.textContent = CORTEX_EMOJI;
// Set favicon to persona emoji
{
const favicon = document.querySelector("link[rel='icon']");
if (favicon && CORTEX_EMOJI) {
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>${CORTEX_EMOJI}</text></svg>`;
favicon.href = `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
}
// Wire help link to preserve current persona on return
const helpLink = document.getElementById('help-link');
if (helpLink) helpLink.href = `/help?persona=${encodeURIComponent(CORTEX_PERSONA)}`;
let sessionId = null;
let primaryBackend = null; // null = auto / role-based routing
let activeController = null;
let currentHistory = []; // mirrors backend session [{role, content}, ...]
let talkThinkingDiv = null; // pending "thinking…" bubble for live Talk updates
// ── Session persistence ───────────────────────────────────────
// Survives page navigation (help, settings, etc.) within the same browser.
// Expires after SESSION_TTL_MS of inactivity.
const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
const _sid_key = `cx_sid_${CORTEX_USER}_${CORTEX_PERSONA}`;
const _sid_ts_key = `cx_sid_ts_${CORTEX_USER}_${CORTEX_PERSONA}`;
function persist_session() {
if (!sessionId) return;
localStorage.setItem(_sid_key, sessionId);
localStorage.setItem(_sid_ts_key, String(Date.now()));
}
function clear_stored_session() {
localStorage.removeItem(_sid_key);
localStorage.removeItem(_sid_ts_key);
}
function get_stored_session() {
const id = localStorage.getItem(_sid_key);
const ts = parseInt(localStorage.getItem(_sid_ts_key) || '0', 10);
if (!id || Date.now() - ts > SESSION_TTL_MS) return null;
return id;
}
// ── Enter toggle ─────────────────────────────────────────────
// Default: Ctrl+Enter sends. Stored in localStorage.
let ctrlEnterMode = localStorage.getItem('ctrlEnterSend') !== 'false';
function updateEnterToggleUI() {
enterToggle.textContent = ctrlEnterMode ? '⌃↵' : '↵';
enterToggle.title = ctrlEnterMode
? 'Ctrl+Enter sends — click for Enter mode'
: 'Enter sends — click for Ctrl+Enter mode';
updateInputPlaceholder();
}
enterToggle.addEventListener('click', () => {
ctrlEnterMode = !ctrlEnterMode;
localStorage.setItem('ctrlEnterSend', ctrlEnterMode);
updateEnterToggleUI();
});
// ── Textarea height ──────────────────────────────────────────
const HEIGHT_SIZES = [120, 240, 480];
const HEIGHT_LABELS = ['S', 'M', 'L'];
const HEIGHT_TITLES = [
'Input size: Compact — click to cycle',
'Input size: Medium — click to cycle',
'Input size: Large — click to cycle',
];
let maxHeight = parseInt(localStorage.getItem('maxHeight') || '120');
const heightCycleBtn = document.getElementById('height-cycle-btn');
function syncHeight() {
inputEl.style.transition = '';
inputEl.style.height = 'auto';
inputEl.style.maxHeight = maxHeight + 'px';
const sh = inputEl.scrollHeight;
// Minimum height is 1/3 of maxHeight so each setting is visually distinct
const minH = Math.round(maxHeight / 3);
inputEl.style.height = Math.max(Math.min(sh, maxHeight), minH) + 'px';
}
const modeSelectEl = document.getElementById('mode-select');
function updateHeightUI() {
if (!heightCycleBtn) return;
const idx = HEIGHT_SIZES.indexOf(maxHeight);
const i = idx >= 0 ? idx : 0;
heightCycleBtn.textContent = HEIGHT_LABELS[i];
heightCycleBtn.title = HEIGHT_TITLES[i];
// Drive row/column layout via data attribute
if (modeSelectEl) modeSelectEl.dataset.size = HEIGHT_LABELS[i].toLowerCase();
}
if (heightCycleBtn) {
heightCycleBtn.addEventListener('click', () => {
const idx = HEIGHT_SIZES.indexOf(maxHeight);
const nextIdx = (idx + 1) % HEIGHT_SIZES.length;
maxHeight = HEIGHT_SIZES[nextIdx];
localStorage.setItem('maxHeight', maxHeight);
updateHeightUI();
syncHeight();
});
}
// ── Input mode — dropdown select with MRU ordering ──────────
const MODES = {
chat: { icon: 'message-circle', label: 'Chat' },
note: { icon: 'pencil', label: 'Note' },
otr: { icon: 'lock', label: 'OTR' },
};
const send_defs = {
chat: { icon: 'arrow-up', label: 'Send' },
note: { icon: 'pencil', label: 'Note' },
otr: { icon: 'arrow-up', label: 'Send' },
};
let current_mode = localStorage.getItem('current_mode') || 'chat';
if (!(current_mode in MODES)) current_mode = 'chat'; // migrate stored 'agent'
let note_public = false;
// MRU list — most recent first; used to sort dropdown options
let mode_mru = JSON.parse(localStorage.getItem('mode_mru') || '["chat","note","otr"]');
mode_mru = mode_mru.filter(m => m in MODES); // strip stale 'agent' entries
function push_mru(mode) {
mode_mru = [mode, ...mode_mru.filter(m => m !== mode)];
localStorage.setItem('mode_mru', JSON.stringify(mode_mru));
}
function set_mode(mode) {
push_mru(mode);
current_mode = mode;
localStorage.setItem('current_mode', current_mode);
close_mode_dropdown();
update_mode_ui();
inputEl.focus();
}
function open_mode_dropdown() {
closeAllPanels();
// Build options in MRU order (least recent at top, most recent at bottom)
// — bottom is visually closest to the button since dropdown opens upward
const ordered = [...mode_mru].reverse();
mode_dropdown_el.innerHTML = '';
ordered.forEach(mode => {
const m = MODES[mode];
const btn = document.createElement('button');
btn.className = 'mode-option' + (mode === current_mode ? ' current' : '');
btn.innerHTML =
`<span class="opt-icon">${icon_html(m.icon, 15)}</span>${m.label}`
+ (mode === current_mode ? '<span class="opt-check">✓</span>' : '');
btn.addEventListener('click', () => set_mode(mode));
mode_dropdown_el.appendChild(btn);
});
mode_dropdown_el.classList.add('open');
render_icons();
}
function close_mode_dropdown() {
mode_dropdown_el.classList.remove('open');
}
mode_select_btn_el.addEventListener('click', (e) => {
e.stopPropagation();
if (mode_dropdown_el.classList.contains('open')) close_mode_dropdown();
else open_mode_dropdown();
});
document.addEventListener('click', (e) => {
if (!mode_dropdown_el.contains(e.target) && e.target !== mode_select_btn_el) {
close_mode_dropdown();
}
});
function update_mode_ui() {
const m = MODES[current_mode] || MODES.chat;
const sd = send_defs[current_mode] || send_defs.chat;
// Update trigger button
mode_icon_el.innerHTML = icon_html(m.icon, 15);
mode_label_el.textContent = m.label;
mode_select_btn_el.className = current_mode === 'chat'
? '' : `mode-${current_mode}`;
// Note visibility sub-button
const is_note = current_mode === 'note';
note_vis_btn_el.style.display = is_note ? '' : 'none';
note_vis_btn_el.textContent = note_public ? 'pub' : 'prv';
note_vis_btn_el.classList.toggle('pub', note_public);
// Textarea mode classes
inputEl.classList.toggle('mode-note', current_mode === 'note');
inputEl.classList.toggle('public', current_mode === 'note' && note_public);
inputEl.classList.toggle('mode-otr', current_mode === 'otr');
// Send button label + icon (tools active → "Run", otherwise per-mode)
const effectiveSd = toolsEnabled && current_mode !== 'note'
? { icon: 'zap', label: 'Run' }
: sd;
sendBtn.innerHTML = icon_html(effectiveSd.icon) + ' ' + effectiveSd.label;
render_icons();
updateInputPlaceholder();
}
function updateInputPlaceholder() {
if (current_mode === 'note') {
inputEl.placeholder = note_public
? 'Public note — LLM sees this next turn…'
: 'Private note — only you see this…';
} else if (current_mode === 'otr') {
inputEl.placeholder = toolsEnabled
? `Task for ${personaLabel}… ⚡ tools + off the record`
: 'Off the record — not logged or distilled…';
} else if (toolsEnabled) {
inputEl.placeholder = ctrlEnterMode
? `Task for ${personaLabel}… ⚡ tools (Ctrl+Enter to run)`
: `Task for ${personaLabel}… ⚡ tools`;
} else {
inputEl.placeholder = ctrlEnterMode
? `Message ${personaLabel}… (Ctrl+Enter to send)`
: `Message ${personaLabel}`;
}
}
// Note private/public sub-toggle
note_vis_btn_el.addEventListener('click', (e) => {
e.stopPropagation();
note_public = !note_public;
update_mode_ui();
});
// ── Tools toggle ─────────────────────────────────────────────
// When on: submit goes to POST /orchestrate (Gemini tool loop → active role responds).
// When off: submit goes to POST /chat (direct to active role, no tools).
let toolsEnabled = localStorage.getItem('tools-enabled') === 'true';
function updateToolsToggleUI() {
tools_toggle_el.classList.toggle('local-on', toolsEnabled);
tools_toggle_el.title = toolsEnabled
? '⚡ Tools enabled — click to disable'
: 'Tools disabled — click to enable';
update_mode_ui();
}
tools_toggle_el.addEventListener('click', (e) => {
e.stopPropagation();
toolsEnabled = !toolsEnabled;
localStorage.setItem('tools-enabled', toolsEnabled);
updateToolsToggleUI();
});
// ── Settings dropdown ─────────────────────────────────────────
settings_btn_el.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = settings_dd_el.classList.contains('open');
closeAllPanels();
if (!isOpen) settings_dd_el.classList.add('open');
});
document.addEventListener('click', (e) => {
if (!settings_dd_el.contains(e.target) && e.target !== settings_btn_el) {
settings_dd_el.classList.remove('open');
}
});
// ── Persona name + switcher ──────────────────────────────────
const personaNameEl = document.getElementById('persona-name');
const personaDropEl = document.getElementById('persona-dropdown');
const personaSwitcher = document.getElementById('persona-switcher');
if (personaNameEl && CORTEX_PERSONA) {
personaNameEl.textContent = CORTEX_PERSONA.charAt(0).toUpperCase() + CORTEX_PERSONA.slice(1);
}
// Load persona list and build dropdown
async function loadPersonaSwitcher() {
try {
const res = await fetch('/api/personas');
if (!res.ok) return;
const data = await res.json();
const personas = data.personas || [];
if (personas.length === 0) return;
personaDropEl.innerHTML = '';
personas.forEach(p => {
const name = p.name || p;
const emoji = p.emoji || '✨';
const a = document.createElement('a');
a.href = `/${CORTEX_USER}/${name}`;
if (name === CORTEX_PERSONA) a.classList.add('active');
const emojiEl = document.createElement('span');
emojiEl.className = 'pd-emoji';
emojiEl.textContent = emoji;
a.appendChild(emojiEl);
const nameEl = document.createElement('span');
nameEl.textContent = name.charAt(0).toUpperCase() + name.slice(1);
a.appendChild(nameEl);
personaDropEl.appendChild(a);
});
const divider = document.createElement('div');
divider.className = 'pd-divider';
personaDropEl.appendChild(divider);
const addLink = document.createElement('a');
addLink.href = '/setup/persona';
addLink.className = 'pd-add';
addLink.textContent = '+ New persona';
personaDropEl.appendChild(addLink);
} catch (_) {}
}
loadPersonaSwitcher();
// Toggle dropdown on click
if (personaSwitcher) {
personaSwitcher.addEventListener('click', (e) => {
if (personaDropEl.children.length === 0) return;
const isOpen = personaDropEl.classList.contains('open');
closeAllPanels();
if (!isOpen) personaDropEl.classList.add('open');
e.stopPropagation();
});
document.addEventListener('click', () => personaDropEl.classList.remove('open'));
}
// ── Role toggle ──────────────────────────────────────────────
// Cycles through roles that have a primary model assigned (excluding orchestrator).
// Sends chat_role ("chat"|"coder"|"research"|...) in chat requests.
// Falls back to "chat" when no roles are configured in the registry.
const TYPE_CLASS = { claude_cli: '', gemini_api: 'mem-on', gemini_cli: 'mem-on', local_openai: 'local-on' };
const backendModelHint = document.getElementById('backend-model-hint');
let availableRoles = []; // [{role, label, model_label, type}] from /backend
let roleIdx = 0;
function activeRole() {
return availableRoles.length > 0 ? availableRoles[roleIdx] : null;
}
function setRoleToggleUI(entry) {
if (!entry) {
backendToggle.textContent = 'chat';
backendToggle.className = 'ctx-btn';
} else {
backendToggle.textContent = entry.label;
backendToggle.className = 'ctx-btn ' + (TYPE_CLASS[entry.type] || '');
}
if (backendModelHint) {
const hint = entry?.model_label || '';
backendModelHint.textContent = hint;
backendModelHint.style.display = hint ? '' : 'none';
}
}
fetch('/backend').then(r => r.json()).then(d => {
availableRoles = d.available_roles || [];
roleIdx = 0;
setRoleToggleUI(availableRoles[0] || null);
});
backendToggle.addEventListener('click', () => {
if (availableRoles.length <= 1) return;
roleIdx = (roleIdx + 1) % availableRoles.length;
const entry = availableRoles[roleIdx];
setRoleToggleUI(entry);
addMessage('system', `Role: ${entry.label} · ${entry.model_label}`);
});
// ── Sessions panel ───────────────────────────────────────────
sessionsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (sessionsPanel.classList.contains('open')) {
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
return;
}
closeAllPanels();
const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
sessionsPanel.classList.add('open');
sessionsBackdrop.classList.add('open');
});
sessionsBackdrop.addEventListener('click', () => {
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
});
document.addEventListener('click', (e) => {
if (!sessionsPanel.contains(e.target) && e.target !== sessionsBtn) {
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
}
});
// session_id → friendly name (populated on each panel render)
const sessionNames = new Map();
function renderPanel(sessions) {
sessionsPanel.innerHTML = '';
sessionNames.clear();
const newItem = makeItem('new', '+ New session', '');
newItem.addEventListener('click', () => {
sessionId = null;
clear_stored_session();
currentHistory = [];
messagesEl.innerHTML = '';
sessionEl.textContent = '';
addMessage('system', 'New session');
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
inputEl.focus();
});
sessionsPanel.appendChild(newItem);
if (!sessions.length) {
const empty = makeItem('', 'No sessions yet', '');
empty.style.cursor = 'default';
empty.style.color = 'var(--muted)';
sessionsPanel.appendChild(empty);
return;
}
for (const s of sessions) {
const displayName = s.name || s.session_id;
sessionNames.set(s.session_id, displayName);
const item = makeItem(
s.session_id === sessionId ? 'active' : '',
displayName,
`${s.message_count} msgs · ${timeAgo(s.updated)}`
);
item.addEventListener('click', () => resumeSession(s.session_id));
// Rename button (✎)
const renameBtn = document.createElement('button');
renameBtn.className = 'session-rename-btn';
renameBtn.textContent = '✎';
renameBtn.title = 'Rename session';
renameBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const labelEl = item.querySelector('.session-id');
const current = s.name || '';
const input = document.createElement('input');
input.className = 'session-rename-input';
input.value = current;
input.placeholder = s.session_id;
labelEl.replaceWith(input);
input.focus();
input.select();
async function commitRename() {
const newName = input.value.trim();
await fetch(`/sessions/${s.session_id}?${_fileParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
});
const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
// Update status bar if this is the active session
if (sessionId === s.session_id) {
sessionEl.textContent = `session: ${newName || s.session_id}`;
}
if (newName) showToast('Session renamed', 'success');
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
if (e.key === 'Escape') { renderPanel(sessions); }
});
input.addEventListener('blur', commitRename);
});
item.appendChild(renameBtn);
const delBtn = document.createElement('button');
delBtn.className = 'session-delete-btn';
delBtn.textContent = '×';
delBtn.title = 'Delete session';
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await fetch(`/sessions/${s.session_id}?${_fileParams}`, { method: 'DELETE' });
if (sessionId === s.session_id) {
sessionId = null;
clear_stored_session();
currentHistory = [];
messagesEl.innerHTML = '';
sessionEl.textContent = '';
showToast('Session deleted');
}
const res = await fetch(`/sessions?${_fileParams}`);
const data = await res.json();
renderPanel(data.sessions);
});
item.appendChild(delBtn);
sessionsPanel.appendChild(item);
}
}
function makeItem(cls, label, meta) {
const item = document.createElement('div');
item.className = 'session-item' + (cls ? ' ' + cls : '');
const idEl = document.createElement('span');
idEl.className = cls === 'new' ? '' : 'session-id';
idEl.textContent = label;
item.appendChild(idEl);
if (meta) {
const metaEl = document.createElement('span');
metaEl.className = 'session-meta';
metaEl.textContent = meta;
item.appendChild(metaEl);
}
return item;
}
async function resumeSession(id, silent = false) {
talkThinkingDiv = null;
if (id && id.startsWith('nct_')) sessionsBtn.classList.remove('talk-badge');
const res = await fetch(`/history/${id}?${_fileParams}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
messagesEl.innerHTML = '';
sessionId = id;
sessionEl.textContent = `session: ${sessionNames.get(id) || id}`;
currentHistory = [];
for (let i = 0; i < data.messages.length; i++) {
const msg = data.messages[i];
const role = msg.role === 'user' ? 'user' : 'assistant';
currentHistory.push({ role, content: msg.content });
const msgDiv = addMessage(role, msg.content);
attachHistoryControls(msgDiv, i);
if (role === 'assistant' && (msg.backend_label || msg.backend)) {
const modelTag = document.createElement('div');
modelTag.className = 'model-tag';
const label = msg.backend_label || msg.backend;
modelTag.textContent = msg.host ? `${label} · ${msg.host}` : label;
msgDiv.appendChild(modelTag);
}
}
if (!silent) addMessage('system', `Resumed session ${id}`);
scrollToBottom();
sessionsPanel.classList.remove('open');
sessionsBackdrop.classList.remove('open');
inputEl.focus();
persist_session();
}
function timeAgo(iso) {
if (!iso) return '?';
const mins = Math.floor((Date.now() - new Date(iso)) / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
// ── Scroll helpers ────────────────────────────────────────────
// Only auto-scroll when the user is already near the bottom (within 80px).
// Explicit user actions (send, resume) call scrollToBottom() directly.
function isNearBottom() {
return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 80;
}
function scrollToBottom() {
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// ── Chat ─────────────────────────────────────────────────────
// Returns the inner .message div. For user/assistant, wraps in .msg-wrapper.
function addMessage(role, text) {
const div = document.createElement('div');
div.className = `message ${role}`;
if (role === 'assistant' && typeof marked !== 'undefined') {
div.dataset.raw = text;
div.innerHTML = marked.parse(text);
highlight_code(div);
div.querySelectorAll('a').forEach(a => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
div.appendChild(makeCopyBtn(div));
} else if (role === 'note-private' || role === 'note-public') {
const label = document.createElement('span');
label.className = 'note-label';
label.textContent = role === 'note-private' ? '◦ private note' : '◦ context note';
const content = document.createElement('span');
content.className = 'note-content';
content.textContent = text;
div.appendChild(label);
div.appendChild(content);
} else {
div.dataset.raw = text;
div.textContent = text;
div.appendChild(makeCopyBtn(div));
}
// Wrap user/assistant messages so action buttons can be attached
const baseRole = role.split(' ')[0]; // 'user' or 'assistant' (strips 'thinking' etc)
if (baseRole === 'user' || baseRole === 'assistant') {
const wrapper = document.createElement('div');
wrapper.className = `msg-wrapper ${baseRole}`;
wrapper.appendChild(div);
const actions = document.createElement('div');
actions.className = 'msg-actions';
wrapper.appendChild(actions);
messagesEl.appendChild(wrapper);
} else {
messagesEl.appendChild(div);
}
if (isNearBottom()) scrollToBottom();
return div;
}
// Wire edit/delete controls onto a message div (must already be in a .msg-wrapper).
// histIdx is the index into currentHistory. Reads wrapper.dataset.histIdx at click time
// so re-indexing after deletions is automatically picked up.
function attachHistoryControls(msgDiv, histIdx) {
const wrapper = msgDiv.parentElement;
if (!wrapper || !wrapper.classList.contains('msg-wrapper')) return;
wrapper.dataset.histIdx = histIdx;
const actionsDiv = wrapper.querySelector('.msg-actions');
if (!actionsDiv) return;
actionsDiv.innerHTML = '';
const editBtn = document.createElement('button');
editBtn.className = 'msg-act-btn';
editBtn.innerHTML = icon_html('pencil', 12) + ' edit';
editBtn.addEventListener('click', () => {
startEdit(msgDiv);
});
const delBtn = document.createElement('button');
delBtn.className = 'msg-act-btn del';
delBtn.innerHTML = icon_html('trash-2', 12) + ' del';
delBtn.addEventListener('click', () => {
deleteMsg(wrapper);
});
actionsDiv.appendChild(editBtn);
actionsDiv.appendChild(delBtn);
render_icons();
}
// After any currentHistory splice, renumber all wrapper data-hist-idx attributes.
function reIndexWrappers() {
messagesEl.querySelectorAll('.msg-wrapper').forEach((w, i) => {
w.dataset.histIdx = i;
});
}
function startEdit(msgDiv) {
const wrapper = msgDiv.parentElement;
const idx = parseInt(wrapper.dataset.histIdx);
const role = msgDiv.classList.contains('user') ? 'user' : 'assistant';
const originalText = currentHistory[idx]?.content
|| msgDiv.dataset.raw
|| msgDiv.textContent;
// Lock the current rendered size so the bubble doesn't collapse when we clear it
const lockedW = msgDiv.offsetWidth;
const lockedH = msgDiv.offsetHeight;
msgDiv.style.minWidth = lockedW + 'px';
msgDiv.style.minHeight = lockedH + 'px';
const actionsDiv = wrapper.querySelector('.msg-actions');
if (actionsDiv) actionsDiv.style.display = 'none';
const ta = document.createElement('textarea');
ta.className = 'edit-textarea';
ta.value = originalText;
ta.rows = Math.min(originalText.split('\n').length + 1, 12);
const saveBtn = document.createElement('button');
saveBtn.innerHTML = icon_html('check', 13) + ' Save';
saveBtn.className = 'edit-save-btn';
const cancelBtn = document.createElement('button');
cancelBtn.innerHTML = icon_html('x', 13) + ' Cancel';
cancelBtn.className = 'edit-cancel-btn';
const btnRow = document.createElement('div');
btnRow.className = 'edit-btns';
btnRow.appendChild(saveBtn);
btnRow.appendChild(cancelBtn);
render_icons();
msgDiv.innerHTML = '';
msgDiv.appendChild(ta);
msgDiv.appendChild(btnRow);
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
function unlock() {
msgDiv.style.minWidth = '';
msgDiv.style.minHeight = '';
if (actionsDiv) actionsDiv.style.display = '';
}
function restore() {
setMessageText(msgDiv, role, originalText);
unlock();
}
function save() {
const newText = ta.value.trim();
if (!newText) return;
const currentIdx = parseInt(wrapper.dataset.histIdx);
currentHistory[currentIdx].content = newText;
setMessageText(msgDiv, role, newText);
unlock();
syncHistory();
}
saveBtn.addEventListener('click', save);
cancelBtn.addEventListener('click', restore);
ta.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); }
if (e.key === 'Escape') { e.preventDefault(); restore(); }
});
}
function deleteMsg(wrapper) {
const idx = parseInt(wrapper.dataset.histIdx);
currentHistory.splice(idx, 1);
wrapper.remove();
reIndexWrappers();
syncHistory();
}
async function syncHistory() {
if (!sessionId) return;
try {
await fetch(`/history/${sessionId}?${_fileParams}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: currentHistory }),
});
} catch (err) {
console.error('syncHistory failed:', err);
}
}
function setMessageText(div, role, text) {
if (role === 'assistant' && typeof marked !== 'undefined') {
div.dataset.raw = text;
div.innerHTML = marked.parse(text);
highlight_code(div);
div.querySelectorAll('a').forEach(a => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
});
div.appendChild(makeCopyBtn(div));
} else {
div.textContent = text;
}
}
// ── Agent tool-call step cards ────────────────────────────────
function renderToolCalls(toolCalls, beforeEl) {
if (!toolCalls || toolCalls.length === 0) return;
const container = document.createElement('div');
container.className = 'tool-calls-container';
for (const tc of toolCalls) {
const details = document.createElement('details');
details.className = 'tool-call';
// Summary: name + first arg value snippet
const args = tc.args || {};
const argKeys = Object.keys(args);
let argSnippet = '';
if (argKeys.length > 0) {
const firstVal = String(args[argKeys[0]]);
argSnippet = firstVal.length > 60 ? firstVal.slice(0, 60) + '…' : firstVal;
}
const summary = document.createElement('summary');
const nameSpan = document.createElement('span');
nameSpan.className = 'tc-name';
nameSpan.textContent = tc.tool;
summary.appendChild(nameSpan);
if (argSnippet) {
const snippetSpan = document.createElement('span');
snippetSpan.className = 'tc-snippet';
snippetSpan.textContent = argSnippet;
summary.appendChild(snippetSpan);
}
details.appendChild(summary);
// Expanded body
const body = document.createElement('div');
body.className = 'tc-body';
if (argKeys.length > 0) {
const sec = document.createElement('div');
sec.className = 'tc-section';
const lbl = document.createElement('span');
lbl.className = 'tc-label';
lbl.textContent = 'args';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(args, null, 2);
sec.appendChild(lbl);
sec.appendChild(pre);
body.appendChild(sec);
}
const resultStr = tc.result || '';
const truncated = resultStr.length > 400;
const sec2 = document.createElement('div');
sec2.className = 'tc-section';
const lbl2 = document.createElement('span');
lbl2.className = 'tc-label';
lbl2.textContent = 'result';
const pre2 = document.createElement('pre');
pre2.textContent = truncated ? resultStr.slice(0, 400) + '\n…[truncated]' : resultStr;
sec2.appendChild(lbl2);
sec2.appendChild(pre2);
body.appendChild(sec2);
details.appendChild(body);
container.appendChild(details);
}
beforeEl.parentElement.insertBefore(container, beforeEl);
}
function makeCopyBtn(div) {
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.innerHTML = icon_html('copy', 12) + ' copy';
render_icons();
btn.addEventListener('click', (e) => {
e.stopPropagation();
const text = div.dataset.raw || '';
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
showToast('Copied to clipboard', 'success', 1800);
btn.innerHTML = icon_html('check', 12) + ' copied';
render_icons();
btn.classList.add('copied');
setTimeout(() => {
btn.innerHTML = icon_html('copy', 12) + ' copy';
btn.classList.remove('copied');
render_icons();
}, 1500);
});
return btn;
}
async function addNote() {
const text = inputEl.value.trim();
if (!text) return;
inputEl.value = '';
syncHeight();
if (!note_public) {
// Private: UI only, never sent to backend
addMessage('note-private', text);
return;
}
// Public: show in UI and persist to session so LLM sees it next turn
if (!sessionId) {
addMessage('system', 'Start a conversation first before adding a public note.');
return;
}
addMessage('note-public', text);
try {
const res = await fetch('/note', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, note: text }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
showToast(`Note save failed: ${err.message}`, 'error');
}
}
stopBtn.addEventListener('click', () => {
if (activeController) activeController.abort();
});
// ── Chat fetch + SSE handler ─────────────────────────────────
// Extracted so the retry button can call it without re-adding the
// user message to the DOM or currentHistory.
async function _doSend(payload, thinkingDiv) {
try {
const res = await fetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: activeController.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = JSON.parse(line.slice(6));
if (data.type === 'keepalive') continue;
if (data.type === 'response') {
sessionId = data.session_id;
sessionEl.textContent = `session: ${sessionId}`;
persist_session();
thinkingDiv.className = 'message assistant';
setMessageText(thinkingDiv, 'assistant', data.response);
const assistHistIdx = currentHistory.length;
currentHistory.push({ role: 'assistant', content: data.response });
attachHistoryControls(thinkingDiv, assistHistIdx);
// Model tag — always shown, amber if fallback was used
const modelTag = document.createElement('div');
modelTag.className = 'model-tag' + (data.fallback_used ? ' fallback' : '');
const label = data.backend_label || data.backend || '';
const hostSuffix = data.host ? ` · ${data.host}` : '';
modelTag.textContent = data.fallback_used
? `⚡ fallback → ${label}${hostSuffix}`
: `${label}${hostSuffix}`;
thinkingDiv.appendChild(modelTag);
} else if (data.type === 'error') {
throw new Error(data.message);
}
}
}
} catch (err) {
if (err.name === 'AbortError') {
thinkingDiv.className = 'message system';
thinkingDiv.textContent = 'Stopped.';
} else {
// Show error + retry button
thinkingDiv.className = 'message error';
thinkingDiv.innerHTML = '';
const errSpan = document.createElement('span');
errSpan.textContent = `Error: ${err.message}`;
thinkingDiv.appendChild(errSpan);
const retryBtn = document.createElement('button');
retryBtn.className = 'retry-btn';
retryBtn.textContent = '↺ Retry';
retryBtn.addEventListener('click', async () => {
// Roll back the failed user push, re-push, and try again
if (currentHistory.at(-1)?.role === 'user') currentHistory.pop();
currentHistory.push({ role: 'user', content: payload.message });
thinkingDiv.className = 'message assistant thinking';
thinkingDiv.textContent = '✨ thinking…';
activeController = new AbortController();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
await _doSend(payload, thinkingDiv);
activeController = null;
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
inputEl.focus();
});
thinkingDiv.appendChild(retryBtn);
}
}
}
async function sendMessage() {
const text = inputEl.value.trim();
if (!text || activeController) return;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
activeController = new AbortController();
const userHistIdx = currentHistory.length;
currentHistory.push({ role: 'user', content: text });
const userMsgDiv = addMessage('user', text);
attachHistoryControls(userMsgDiv, userHistIdx);
scrollToBottom();
const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
const payload = {
message: text,
session_id: sessionId,
tier: currentTier,
include_long: memLong,
include_mid: memMid,
include_short: memShort,
off_record: current_mode === 'otr',
chat_role: activeRole()?.role || 'chat',
user: CORTEX_USER,
persona: CORTEX_PERSONA,
};
await _doSend(payload, thinkingDiv);
activeController = null;
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
inputEl.focus();
}
async function sendOrchestrate() {
const text = inputEl.value.trim();
if (!text || activeController) return;
inputEl.value = '';
syncHeight();
sendBtn.style.display = 'none';
stopBtn.style.display = 'flex';
headerEmoji.classList.add('processing');
activeController = new AbortController();
currentHistory.push({ role: 'user', content: text });
const userMsgDiv = addMessage('user', text);
scrollToBottom();
const thinkingDiv = addMessage('assistant thinking', '⚡ working…');
try {
const res = await fetch('/orchestrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
task: text,
session_id: sessionId,
tier: currentTier,
include_long: memLong,
include_mid: memMid,
include_short: memShort,
chat_role: activeRole()?.role || 'chat',
user: CORTEX_USER,
persona: CORTEX_PERSONA,
}),
signal: activeController.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { job_id } = await res.json();
// Poll until complete or stopped
let job;
while (true) {
if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError');
await new Promise(r => setTimeout(r, 2000));
if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError');
const pollRes = await fetch(`/orchestrate/${job_id}`, {
signal: activeController.signal,
});
if (!pollRes.ok) throw new Error(`Poll failed: HTTP ${pollRes.status}`);
job = await pollRes.json();
const n = job.tool_calls?.length || 0;
if (job.status === 'queued' || job.status === 'running') {
thinkingDiv.textContent = n
? `⚡ working… (${n} tool${n !== 1 ? 's' : ''} used)`
: '⚡ working…';
continue;
}
if (job.status === 'awaiting_confirmation') {
const pc = job.pending_confirmation || {};
const toolNames = (pc.tools || []).map(t => t.name).join(', ');
thinkingDiv.className = 'message assistant';
thinkingDiv.innerHTML = `<div class="confirm-gate">
<p>${escapeHtml(pc.message || 'Confirm this action?')}</p>
<p class="confirm-tools">Tool${(pc.tools||[]).length !== 1 ? 's' : ''}: <code>${escapeHtml(toolNames)}</code></p>
<div class="confirm-actions">
<button class="confirm-btn">Confirm</button>
<button class="deny-btn">Deny</button>
</div>
</div>`;
const confirmed = await new Promise(resolve => {
thinkingDiv.querySelector('.confirm-btn').onclick = () => resolve(true);
thinkingDiv.querySelector('.deny-btn').onclick = () => resolve(false);
});
thinkingDiv.className = 'message assistant thinking';
thinkingDiv.textContent = confirmed ? '⚡ confirmed — continuing…' : '⚡ denied — finishing…';
const action = confirmed ? 'confirm' : 'deny';
const resumeRes = await fetch(`/orchestrate/${job_id}/${action}`, {
method: 'POST',
signal: activeController.signal,
});
if (!resumeRes.ok) throw new Error(`Resume failed: HTTP ${resumeRes.status}`);
continue;
}
break;
}
if (job.status === 'error') throw new Error(job.error || 'Orchestrator failed');
// Update session so this turn is part of the resumable history
if (job.session_id) {
sessionId = job.session_id;
sessionEl.textContent = `session: ${sessionId}`;
persist_session();
}
const userHistIdx = currentHistory.length - 1; // pushed before fetch
attachHistoryControls(userMsgDiv, userHistIdx);
thinkingDiv.className = 'message assistant';
setMessageText(thinkingDiv, 'assistant', job.response || '(no response)');
const assistHistIdx = currentHistory.length;
currentHistory.push({ role: 'assistant', content: job.response || '' });
attachHistoryControls(thinkingDiv, assistHistIdx);
renderToolCalls(job.tool_calls, thinkingDiv.parentElement);
} catch (err) {
if (err.name === 'AbortError') {
thinkingDiv.className = 'message system';
thinkingDiv.textContent = 'Stopped.';
} else {
thinkingDiv.className = 'message error';
thinkingDiv.textContent = `Error: ${err.message}`;
}
}
activeController = null;
headerEmoji.classList.remove('processing');
sendBtn.style.display = 'block';
stopBtn.style.display = 'none';
inputEl.focus();
}
function dispatchSend() {
if (current_mode === 'note') addNote();
else if (toolsEnabled) sendOrchestrate();
else sendMessage();
}
sendBtn.addEventListener('click', dispatchSend);
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const shouldSend = ctrlEnterMode ? (e.ctrlKey || e.metaKey) : !e.shiftKey;
if (shouldSend) {
e.preventDefault();
dispatchSend();
}
}
});
inputEl.addEventListener('input', syncHeight);
// ── File editor ──────────────────────────────────────────────
const fileModal = document.getElementById('file-modal');
const fileSidebar = document.getElementById('file-sidebar');
const fileEditorWrap = document.getElementById('file-editor-wrap');
const filePreview = document.getElementById('file-preview');
const fileRawBtn = document.getElementById('file-raw-btn');
const filePreviewBtn = document.getElementById('file-preview-btn');
const fileSaveBtn = document.getElementById('file-save-btn');
const fileCloseBtn = document.getElementById('file-close-btn');
const filesBtn = document.getElementById('files-btn');
let fileMode = 'preview'; // 'edit' or 'preview'
let activeFileName = null;
let mdEditor = null;
function initMdEditor() {
if (mdEditor) return;
mdEditor = CodeMirror(fileEditorWrap, {
mode: 'markdown',
lineWrapping: true,
lineNumbers: false,
autofocus: false,
tabSize: 2,
indentWithTabs: false,
extraKeys: { 'Ctrl-S': () => { fileSaveBtn.click(); return false; } },
});
}
// File groups — controls sidebar order and section labels
const FILE_GROUPS = [
{ label: 'Identity', files: ['IDENTITY.md', 'SOUL.md', 'PROTOCOLS.md', 'CONTEXT_TIERS.md'] },
{ label: 'Memory', files: ['MEMORY_LONG.md', 'MEMORY_MID.md', 'MEMORY_SHORT.md'] },
{ label: 'Profile', files: ['USER.md', 'HELP.md'] },
{ label: 'Settings', files: ['email_allowlist.json'] },
];
function fmtSize(bytes) {
if (!bytes) return 'empty';
if (bytes < 1024) return bytes + ' B';
return (bytes / 1024).toFixed(1) + ' KB';
}
function fmtModified(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
const now = new Date();
if (d.toDateString() === now.toDateString()) return 'today';
const diff = (now - d) / 86400000;
if (diff < 2) return 'yesterday';
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function renderFileSidebar(files) {
const byName = Object.fromEntries(files.map(f => [f.name, f]));
fileSidebar.innerHTML = '';
for (const group of FILE_GROUPS) {
const groupEl = document.createElement('div');
groupEl.className = 'file-group';
const header = document.createElement('div');
header.className = 'fg-header';
header.textContent = group.label;
header.addEventListener('click', () => header.classList.toggle('collapsed'));
groupEl.appendChild(header);
const items = document.createElement('div');
items.className = 'fg-items';
for (const fname of group.files) {
const f = byName[fname];
if (!f) continue;
const item = document.createElement('div');
item.className = 'file-item' + (f.exists ? '' : ' missing');
item.dataset.name = fname;
if (fname === activeFileName) item.classList.add('active');
const nameEl = document.createElement('div');
nameEl.className = 'fi-name';
nameEl.textContent = fname;
item.appendChild(nameEl);
const metaEl = document.createElement('div');
metaEl.className = 'fi-meta';
metaEl.innerHTML = `<span>${fmtSize(f.size)}</span>`
+ (f.modified ? `<span>${fmtModified(f.modified)}</span>` : '');
item.appendChild(metaEl);
item.addEventListener('click', () => loadFile(fname));
items.appendChild(item);
}
groupEl.appendChild(items);
fileSidebar.appendChild(groupEl);
}
}
function setActiveFile(name) {
activeFileName = name;
fileSidebar.querySelectorAll('.file-item').forEach(el => {
el.classList.toggle('active', el.dataset.name === name);
});
document.getElementById('file-modal-title').textContent = name;
}
function setFileMode(mode) {
fileMode = mode;
if (mode === 'edit') {
fileEditorWrap.classList.remove('hidden');
filePreview.classList.remove('active');
fileRawBtn.classList.add('active');
filePreviewBtn.classList.remove('active');
mdEditor.refresh();
mdEditor.focus();
} else {
fileEditorWrap.classList.add('hidden');
filePreview.classList.add('active');
fileRawBtn.classList.remove('active');
filePreviewBtn.classList.add('active');
if (typeof marked !== 'undefined') {
filePreview.innerHTML = marked.parse(mdEditor.getValue());
filePreview.querySelectorAll('a').forEach(a => {
a.target = '_blank'; a.rel = 'noopener noreferrer';
});
}
}
}
async function loadFile(name) {
setActiveFile(name);
initMdEditor();
const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`);
if (!res.ok) { mdEditor.setValue(`Error loading ${name}`); return; }
const data = await res.json();
mdEditor.setValue(data.content);
mdEditor.clearHistory();
setFileMode(fileMode);
}
async function openFileModal() {
const res = await fetch(`/files?${_fileParams}`);
const data = await res.json();
renderFileSidebar(data.files);
fileModal.classList.add('open');
// Load first existing file
const first = data.files.find(f => f.exists) || data.files[0];
if (first) await loadFile(first.name);
}
filesBtn.addEventListener('click', () => {
settings_dd_el.classList.remove('open');
openFileModal();
});
fileRawBtn.addEventListener('click', () => setFileMode('edit'));
filePreviewBtn.addEventListener('click', () => setFileMode('preview'));
fileSaveBtn.addEventListener('click', async () => {
if (!activeFileName) return;
const res = await fetch(`/files/${encodeURIComponent(activeFileName)}?${_fileParams}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: mdEditor.getValue() }),
});
if (res.ok) {
showToast('File saved', 'success');
// Refresh sidebar to update size/modified
const listRes = await fetch(`/files?${_fileParams}`);
const listData = await listRes.json();
renderFileSidebar(listData.files);
} else {
showToast('Save failed', 'error');
}
});
fileCloseBtn.addEventListener('click', () => fileModal.classList.remove('open'));
fileModal.addEventListener('click', (e) => {
if (e.target === fileModal) fileModal.classList.remove('open');
});
// ── Session search ────────────────────────────────────────────
const sessionSearchInput = document.getElementById('session-search-input');
const sessionSearchBtn = document.getElementById('session-search-btn');
const sessionSearchResults = document.getElementById('session-search-results');
function _showFileView() {
setFileMode(fileMode);
filePreview.style.display = '';
sessionSearchResults.style.display = 'none';
}
function _showSearchResults(html) {
fileEditorWrap.classList.add('hidden');
filePreview.style.display = 'none';
sessionSearchResults.style.display = '';
sessionSearchResults.innerHTML = html;
}
async function runSessionSearch() {
const q = sessionSearchInput.value.trim();
if (q.length < 2) return;
sessionSearchBtn.disabled = true;
sessionSearchBtn.textContent = '…';
try {
const res = await fetch(`/sessions/search?q=${encodeURIComponent(q)}&${_fileParams}&limit=30`);
const data = await res.json();
if (!res.ok) { _showSearchResults(`<p class="sr-error">Error: ${data.detail || res.status}</p>`); return; }
if (!data.matches.length) {
_showSearchResults(`<p class="sr-empty">No results for "<strong>${_esc(q)}</strong>" in ${data.total_files_searched} session file(s).</p>`);
return;
}
let html = `<div class="sr-header">${data.matches.length} result(s) for "<strong>${_esc(q)}</strong>" across ${data.total_files_searched} session(s)</div>`;
let lastDate = null;
for (const m of data.matches) {
if (m.date !== lastDate) {
html += `<div class="sr-date">${m.date}</div>`;
lastDate = m.date;
}
const hi = m.excerpt.replace(new RegExp(_esc(q), 'gi'), s => `<mark>${_esc(s)}</mark>`);
html += `<div class="sr-excerpt">${hi}</div>`;
}
_showSearchResults(html);
} catch (e) {
_showSearchResults(`<p class="sr-error">Search failed: ${e.message}</p>`);
} finally {
sessionSearchBtn.disabled = false;
sessionSearchBtn.textContent = 'Go';
}
}
sessionSearchBtn.addEventListener('click', runSessionSearch);
sessionSearchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') runSessionSearch();
});
// When a file is clicked, switch back from search results to editor
fileSidebar.addEventListener('click', () => {
if (sessionSearchResults.style.display !== 'none') _showFileView();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (fileModal.classList.contains('open')) fileModal.classList.remove('open');
}
// Ctrl+S to save when file modal is open
if ((e.ctrlKey || e.metaKey) && e.key === 's' && fileModal.classList.contains('open')) {
e.preventDefault();
fileSaveBtn.click();
}
});
// ── Real-time Talk updates (SSE) ─────────────────────────────
const evtSource = new EventSource('/events');
// Close cleanly on navigation so the browser doesn't log "connection interrupted"
window.addEventListener('beforeunload', () => evtSource.close());
evtSource.onerror = () => {
// EventSource auto-reconnects — nothing to do; suppress console noise
};
evtSource.onmessage = (e) => {
let data;
try { data = JSON.parse(e.data); } catch { return; }
if (data.type === 'keepalive') return;
if (data.type !== 'nct_message' && data.type !== 'nct_response') return;
if (sessionId === data.session_id) {
// Active session — append live
if (data.type === 'nct_message') {
// Clear any stale thinking div before new user msg
if (talkThinkingDiv) { talkThinkingDiv.remove(); talkThinkingDiv = null; }
addMessage('user', data.content);
talkThinkingDiv = addMessage('assistant thinking', '✨ thinking…');
} else {
if (talkThinkingDiv) {
talkThinkingDiv.className = 'message assistant';
setMessageText(talkThinkingDiv, 'assistant', data.content);
talkThinkingDiv = null;
} else {
addMessage('assistant', data.content);
}
scrollToBottom();
}
} else {
// Different session — light badge on Sessions button
if (data.type === 'nct_message') {
sessionsBtn.classList.add('talk-badge');
}
}
};
// ── Theme toggle ──────────────────────────────────────────────
const themeBtn = document.getElementById('theme-btn');
const _metaThemeColor = document.getElementById('meta-theme-color');
const _themeColors = { dark: '#1a1228', light: '#f2eef9' };
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
themeBtn.textContent = theme === 'dark' ? '☀' : '☾';
themeBtn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
if (_metaThemeColor) _metaThemeColor.content = _themeColors[theme] || _themeColors.dark;
}
{
const saved = localStorage.getItem('theme');
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(saved || (sysDark ? 'dark' : 'light'));
}
themeBtn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', next);
applyTheme(next);
});
// ── Font size cycle ───────────────────────────────────────────
const fontSizeBtn = document.getElementById('font-size-btn');
const fontSizes = ['normal', 'large', 'small'];
const fontSizePx = { normal: '21px', large: '25px', small: '17px' };
const fontSizeLbl = { normal: 'Aa', large: 'A+', small: 'A' };
function applyFontSize(size) {
document.documentElement.style.fontSize = fontSizePx[size];
fontSizeBtn.textContent = fontSizeLbl[size];
fontSizeBtn.title = `Font: ${size} — click to cycle`;
}
{
const saved = localStorage.getItem('font-size') || 'normal';
applyFontSize(saved);
}
fontSizeBtn.addEventListener('click', () => {
const current = localStorage.getItem('font-size') || 'normal';
const next = fontSizes[(fontSizes.indexOf(current) + 1) % fontSizes.length];
localStorage.setItem('font-size', next);
applyFontSize(next);
});
// ── Context panel — tier + memory toggles + distill ───────────
const ctxOpenBtn = document.getElementById('ctx-open-btn');
const ctxPanel = document.getElementById('ctx-panel');
const distillStatus = document.getElementById('ctx-distill-status');
let currentTier = parseInt(localStorage.getItem('ctx-tier') || '2');
let memLong = localStorage.getItem('mem-long') !== 'false';
let memMid = localStorage.getItem('mem-mid') !== 'false';
let memShort = localStorage.getItem('mem-short') !== 'false';
function updateTierUI() {
document.querySelectorAll('.ctx-btn[data-tier]').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.tier) === currentTier);
});
ctxOpenBtn.querySelector('.tier-badge').textContent = currentTier;
}
function updateMemUI() {
document.getElementById('mem-long-btn').classList.toggle('mem-on', memLong);
document.getElementById('mem-mid-btn').classList.toggle('mem-on', memMid);
document.getElementById('mem-short-btn').classList.toggle('mem-on', memShort);
}
function formatNextRun(iso) {
if (!iso) return 'n/a';
const dt = new Date(iso);
const now = new Date();
const diffMs = dt - now;
const diffDays = Math.floor(diffMs / 86400000);
const time = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (diffDays === 0) return `today ${time}`;
if (diffDays === 1) return `tomorrow ${time}`;
return dt.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
}
async function loadSchedule() {
const schedEl = document.getElementById('ctx-schedule');
try {
const res = await fetch('/distill/status');
const d = await res.json();
if (!d.enabled || !d.jobs.length) {
schedEl.textContent = 'auto-distill disabled';
return;
}
schedEl.innerHTML = d.jobs
.map(j => `${j.id.replace('distill_', '').padEnd(6)}${formatNextRun(j.next_run)}`)
.join('<br>');
} catch {
schedEl.textContent = '';
}
}
ctxOpenBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = ctxPanel.classList.contains('open');
closeAllPanels();
if (!isOpen) {
ctxPanel.classList.add('open');
loadSchedule();
}
});
document.addEventListener('click', (e) => {
if (!ctxPanel.contains(e.target) && e.target !== ctxOpenBtn) {
ctxPanel.classList.remove('open');
}
});
document.querySelectorAll('.ctx-btn[data-tier]').forEach(btn => {
btn.addEventListener('click', () => {
currentTier = parseInt(btn.dataset.tier);
localStorage.setItem('ctx-tier', currentTier);
updateTierUI();
});
});
document.getElementById('mem-long-btn').addEventListener('click', () => {
memLong = !memLong; localStorage.setItem('mem-long', memLong); updateMemUI();
});
document.getElementById('mem-mid-btn').addEventListener('click', () => {
memMid = !memMid; localStorage.setItem('mem-mid', memMid); updateMemUI();
});
document.getElementById('mem-short-btn').addEventListener('click', () => {
memShort = !memShort; localStorage.setItem('mem-short', memShort); updateMemUI();
});
function showDistillStatus(msg, isErr) {
distillStatus.textContent = msg;
distillStatus.classList.toggle('err', !!isErr);
distillStatus.classList.add('show');
setTimeout(() => distillStatus.classList.remove('show'), 5000);
}
async function runDistill(endpoint) {
showDistillStatus('distilling…', false);
try {
const res = await fetch(`/distill/${endpoint}`, { method: 'POST' });
const d = await res.json();
if (!res.ok || d.ok === false) {
const err = d.error || d.mid?.error || d.long?.error || `HTTP ${res.status}`;
showDistillStatus(`${err}`, true);
} else {
showDistillStatus(`${endpoint} done`, false);
}
} catch (err) {
showDistillStatus(`${err.message}`, true);
}
}
document.getElementById('distill-short-btn').addEventListener('click', () => runDistill('short'));
document.getElementById('distill-mid-btn').addEventListener('click', () => runDistill('mid'));
document.getElementById('distill-long-btn').addEventListener('click', () => runDistill('long'));
document.getElementById('distill-all-btn').addEventListener('click', () => runDistill('all'));
updateTierUI();
updateMemUI();
updateHeightUI();
updateToolsToggleUI();
update_mode_ui();
// ── Init ─────────────────────────────────────────────────────
updateEnterToggleUI();
syncHeight();
addMessage('system', 'Session started');
// ── Initial render ────────────────────────────────────────────
// Process all static Lucide SVGs in the header + stop button,
// and seed the mode UI (which also calls render_icons internally).
update_mode_ui();
render_icons();
// ── Auto-restore last session ─────────────────────────────────
// Silently resume if within the inactivity TTL; clears stored ID on error.
{
const stored = get_stored_session();
if (stored) resumeSession(stored, true).catch(clear_stored_session);
}
// ── Service worker registration ───────────────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}