diff --git a/cmd/odek/ui/app.js b/cmd/odek/ui/app.js index e5e9c75..9a5a43b 100644 --- a/cmd/odek/ui/app.js +++ b/cmd/odek/ui/app.js @@ -1268,16 +1268,46 @@ window.switchModel = function(modelId) { }; // ── Session Rename ── -window.renameSession = async function(sid, el) { +// Inline edit: swaps the task label for an input; Enter commits, Esc +// cancels, blur commits. No window.prompt. +window.renameSession = function(sid, el) { const item = el.closest('.session-item'); if (!item) return; const taskEl = item.querySelector('.task'); - const currentName = taskEl ? taskEl.textContent : ''; - const newName = prompt('Rename session:', currentName); - if (!newName || newName === currentName) return; + if (!taskEl || taskEl.querySelector('.si-rename-input')) return; + const currentName = taskEl.textContent; + + const input = document.createElement('input'); + input.className = 'si-rename-input'; + input.type = 'text'; + input.value = currentName === 'untitled' ? '' : currentName; + input.placeholder = 'session name…'; + taskEl.textContent = ''; + taskEl.appendChild(input); + input.focus(); + input.select(); + + let done = false; + const finish = (commit) => { + if (done) return; + done = true; + const newName = input.value.trim(); + taskEl.textContent = currentName; + if (commit && newName && newName !== currentName) { + doRenameSession(sid, newName); + } + }; + input.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); + input.addEventListener('click', (e) => e.stopPropagation()); + input.addEventListener('blur', () => finish(true)); +}; +async function doRenameSession(sid, newName) { const token = await ensureSessionToken(sid); - fetch('/api/sessions/' + encodeURIComponent(sid), { method: 'POST', headers: apiHeaders({ @@ -1292,7 +1322,7 @@ window.renameSession = async function(sid, el) { showToast('Session renamed'); }) .catch(() => showToast('Failed to rename session')); -}; +} // ── Skill Events ── function handleSkillEvent(event) { @@ -1596,7 +1626,7 @@ function addAttachedFile(file) { // Check total size (max 10MB total) const totalSize = attachedFiles.reduce((s, f) => s + f.size, 0) + file.size; if (totalSize > 10 * 1024 * 1024) { - showToast('Total attachment size exceeds 10 MB'); + addErrorChip(file.name, 'total attachments exceed 10 MB'); return; } attachedFiles.push(file); @@ -1641,6 +1671,38 @@ function renderFileChips() { chip.append(icon, name, size, remove); fileChips.appendChild(chip); }); + + // Total-size meter against the 10 MB cap. + if (attachedFiles.length > 0) { + const total = attachedFiles.reduce((s, f) => s + f.size, 0); + const meter = document.createElement('span'); + meter.className = 'chips-total'; + meter.textContent = formatFileSize(total) + ' / 10 MB'; + if (total > 8 * 1024 * 1024) meter.classList.add('warn'); + fileChips.appendChild(meter); + } +} + +// addErrorChip shows a transient error chip for a file that could not be +// attached (too large, unreadable). Dismisses on click or after 6s. +function addErrorChip(name, reason) { + const chip = document.createElement('span'); + chip.className = 'file-chip error'; + chip.title = reason; + const icon = document.createElement('span'); + icon.className = 'chip-icon'; + icon.textContent = '⚠️'; + const label = document.createElement('span'); + label.className = 'chip-name'; + label.textContent = name + ' — ' + reason; + const remove = document.createElement('span'); + remove.className = 'chip-remove'; + remove.textContent = '✕'; + chip.append(icon, label, remove); + fileChips.appendChild(chip); + const dismiss = () => chip.remove(); + remove.addEventListener('click', dismiss); + setTimeout(dismiss, 6000); } function readFileAsText(file) { @@ -1665,7 +1727,7 @@ function handleFiles(fileList) { readFileAsText(file).then(content => { addAttachedFile({name: file.name, size: file.size, content}); }).catch(err => { - showToast(err.message); + addErrorChip(file.name, err.message || 'could not read file'); }) ); } @@ -1705,6 +1767,26 @@ messagesEl.addEventListener('drop', (e) => { // ── Input handlers ── promptEl.addEventListener('keydown', (e) => { + // @-completion keyboard navigation takes precedence while visible: + // ↑/↓ move, Enter/Tab accept, Esc dismisses. + if (completionEl.classList.contains('visible')) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + moveCompletionSelection(e.key === 'ArrowDown' ? 1 : -1); + return; + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + selectCompletion(); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + completionEl.classList.remove('visible'); + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); @@ -1784,8 +1866,10 @@ completionEl.addEventListener('click', (e) => { completionEl.addEventListener('mousemove', (e) => { const item = e.target.closest('.comp-item'); if (!item) return; - completionEl.querySelectorAll('.comp-item').forEach(el => el.classList.remove('selected')); - item.classList.add('selected'); + completionEl.querySelectorAll('.comp-item').forEach(el => { + el.classList.toggle('selected', el === item); + el.setAttribute('aria-selected', el === item); + }); }); let lastAtIdx = -1; @@ -1824,7 +1908,7 @@ async function checkCompletion() { } completionEl.innerHTML = results.map((r, i) => - `