Skip to content

Commit 7cee1d5

Browse files
committed
feat(ui): WebUI modernization PR4 — completion keyboard nav, attachments, sidebar
Fourth PR of the WebUI modernization roadmap (Phase 2c + 2d). Stacked on #111 (feat/webui-modernization-3). Input & @-completion (2c): - ArrowUp/ArrowDown move the selection (with wrap-around), Enter/Tab accept, Esc dismisses. Previously mouse-only with Tab-to-accept, and Enter would send the prompt even with the popup open. - role="listbox"/role="option" + aria-selected kept in sync for keyboard, mouse, and hover selection. - Attachments: per-file error chips (oversized/unreadable/over the 10 MB total cap) that dismiss on click or after 6s, replacing toast-only errors; total-size meter ("1.2 MB / 10 MB", amber when above 8 MB) rendered next to the chips. Sessions sidebar (2d): - Session items restructured: the whole body is a real <button> (keyboard-focusable, Enter opens) with separate real rename/delete buttons. Row actions now reveal on hover, focus-within, and always on touch devices (hover: none) - they were hover-only and unreachable by keyboard/touch. - Rename is an inline edit in the task label (Enter commits, Esc cancels, blur commits) - window.prompt() is gone. - Incremental list updates: loadSessions runs after every turn and used to re-render the whole list (stealing scroll position and hover state); it now skips the re-render when the session signature is unchanged and only syncs the active marker. - Skeleton rows while the list loads on cold start. Verified: node --check, go vet, full cmd/odek test suite.
1 parent 556b8de commit 7cee1d5

3 files changed

Lines changed: 263 additions & 59 deletions

File tree

cmd/odek/ui/app.js

Lines changed: 179 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,16 +1268,46 @@ window.switchModel = function(modelId) {
12681268
};
12691269

12701270
// ── Session Rename ──
1271-
window.renameSession = async function(sid, el) {
1271+
// Inline edit: swaps the task label for an input; Enter commits, Esc
1272+
// cancels, blur commits. No window.prompt.
1273+
window.renameSession = function(sid, el) {
12721274
const item = el.closest('.session-item');
12731275
if (!item) return;
12741276
const taskEl = item.querySelector('.task');
1275-
const currentName = taskEl ? taskEl.textContent : '';
1276-
const newName = prompt('Rename session:', currentName);
1277-
if (!newName || newName === currentName) return;
1277+
if (!taskEl || taskEl.querySelector('.si-rename-input')) return;
1278+
const currentName = taskEl.textContent;
1279+
1280+
const input = document.createElement('input');
1281+
input.className = 'si-rename-input';
1282+
input.type = 'text';
1283+
input.value = currentName === 'untitled' ? '' : currentName;
1284+
input.placeholder = 'session name…';
1285+
taskEl.textContent = '';
1286+
taskEl.appendChild(input);
1287+
input.focus();
1288+
input.select();
1289+
1290+
let done = false;
1291+
const finish = (commit) => {
1292+
if (done) return;
1293+
done = true;
1294+
const newName = input.value.trim();
1295+
taskEl.textContent = currentName;
1296+
if (commit && newName && newName !== currentName) {
1297+
doRenameSession(sid, newName);
1298+
}
1299+
};
1300+
input.addEventListener('keydown', (e) => {
1301+
e.stopPropagation();
1302+
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
1303+
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
1304+
});
1305+
input.addEventListener('click', (e) => e.stopPropagation());
1306+
input.addEventListener('blur', () => finish(true));
1307+
};
12781308

1309+
async function doRenameSession(sid, newName) {
12791310
const token = await ensureSessionToken(sid);
1280-
12811311
fetch('/api/sessions/' + encodeURIComponent(sid), {
12821312
method: 'POST',
12831313
headers: apiHeaders({
@@ -1292,7 +1322,7 @@ window.renameSession = async function(sid, el) {
12921322
showToast('Session renamed');
12931323
})
12941324
.catch(() => showToast('Failed to rename session'));
1295-
};
1325+
}
12961326

12971327
// ── Skill Events ──
12981328
function handleSkillEvent(event) {
@@ -1596,7 +1626,7 @@ function addAttachedFile(file) {
15961626
// Check total size (max 10MB total)
15971627
const totalSize = attachedFiles.reduce((s, f) => s + f.size, 0) + file.size;
15981628
if (totalSize > 10 * 1024 * 1024) {
1599-
showToast('Total attachment size exceeds 10 MB');
1629+
addErrorChip(file.name, 'total attachments exceed 10 MB');
16001630
return;
16011631
}
16021632
attachedFiles.push(file);
@@ -1641,6 +1671,38 @@ function renderFileChips() {
16411671
chip.append(icon, name, size, remove);
16421672
fileChips.appendChild(chip);
16431673
});
1674+
1675+
// Total-size meter against the 10 MB cap.
1676+
if (attachedFiles.length > 0) {
1677+
const total = attachedFiles.reduce((s, f) => s + f.size, 0);
1678+
const meter = document.createElement('span');
1679+
meter.className = 'chips-total';
1680+
meter.textContent = formatFileSize(total) + ' / 10 MB';
1681+
if (total > 8 * 1024 * 1024) meter.classList.add('warn');
1682+
fileChips.appendChild(meter);
1683+
}
1684+
}
1685+
1686+
// addErrorChip shows a transient error chip for a file that could not be
1687+
// attached (too large, unreadable). Dismisses on click or after 6s.
1688+
function addErrorChip(name, reason) {
1689+
const chip = document.createElement('span');
1690+
chip.className = 'file-chip error';
1691+
chip.title = reason;
1692+
const icon = document.createElement('span');
1693+
icon.className = 'chip-icon';
1694+
icon.textContent = '⚠️';
1695+
const label = document.createElement('span');
1696+
label.className = 'chip-name';
1697+
label.textContent = name + ' — ' + reason;
1698+
const remove = document.createElement('span');
1699+
remove.className = 'chip-remove';
1700+
remove.textContent = '✕';
1701+
chip.append(icon, label, remove);
1702+
fileChips.appendChild(chip);
1703+
const dismiss = () => chip.remove();
1704+
remove.addEventListener('click', dismiss);
1705+
setTimeout(dismiss, 6000);
16441706
}
16451707

16461708
function readFileAsText(file) {
@@ -1665,7 +1727,7 @@ function handleFiles(fileList) {
16651727
readFileAsText(file).then(content => {
16661728
addAttachedFile({name: file.name, size: file.size, content});
16671729
}).catch(err => {
1668-
showToast(err.message);
1730+
addErrorChip(file.name, err.message || 'could not read file');
16691731
})
16701732
);
16711733
}
@@ -1705,6 +1767,26 @@ messagesEl.addEventListener('drop', (e) => {
17051767

17061768
// ── Input handlers ──
17071769
promptEl.addEventListener('keydown', (e) => {
1770+
// @-completion keyboard navigation takes precedence while visible:
1771+
// ↑/↓ move, Enter/Tab accept, Esc dismisses.
1772+
if (completionEl.classList.contains('visible')) {
1773+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1774+
e.preventDefault();
1775+
moveCompletionSelection(e.key === 'ArrowDown' ? 1 : -1);
1776+
return;
1777+
}
1778+
if (e.key === 'Enter' && !e.shiftKey) {
1779+
e.preventDefault();
1780+
selectCompletion();
1781+
return;
1782+
}
1783+
if (e.key === 'Escape') {
1784+
e.preventDefault();
1785+
completionEl.classList.remove('visible');
1786+
return;
1787+
}
1788+
}
1789+
17081790
if (e.key === 'Enter' && !e.shiftKey) {
17091791
e.preventDefault();
17101792
send();
@@ -1784,8 +1866,10 @@ completionEl.addEventListener('click', (e) => {
17841866
completionEl.addEventListener('mousemove', (e) => {
17851867
const item = e.target.closest('.comp-item');
17861868
if (!item) return;
1787-
completionEl.querySelectorAll('.comp-item').forEach(el => el.classList.remove('selected'));
1788-
item.classList.add('selected');
1869+
completionEl.querySelectorAll('.comp-item').forEach(el => {
1870+
el.classList.toggle('selected', el === item);
1871+
el.setAttribute('aria-selected', el === item);
1872+
});
17891873
});
17901874

17911875
let lastAtIdx = -1;
@@ -1824,7 +1908,7 @@ async function checkCompletion() {
18241908
}
18251909

18261910
completionEl.innerHTML = results.map((r, i) =>
1827-
`<div class="comp-item${i === 0 ? ' selected' : ''}" data-id="${escapeAttr(r.id)}">
1911+
`<div class="comp-item${i === 0 ? ' selected' : ''}" role="option" aria-selected="${i === 0}" data-id="${escapeAttr(r.id)}">
18281912
<span class="comp-type">${escapeAttr(r.type)}</span>
18291913
<span class="comp-label">${escapeHtml(r.label)}</span>
18301914
<span class="comp-detail">${escapeHtml(r.detail || '')}</span>
@@ -1837,6 +1921,19 @@ async function checkCompletion() {
18371921
}
18381922
}
18391923

1924+
// moveCompletionSelection moves the .selected marker by delta (+1/-1),
1925+
// keeping aria-selected in sync for assistive technology.
1926+
function moveCompletionSelection(delta) {
1927+
const items = Array.from(completionEl.querySelectorAll('.comp-item'));
1928+
if (items.length === 0) return;
1929+
let idx = items.findIndex(el => el.classList.contains('selected'));
1930+
idx = (idx + delta + items.length) % items.length;
1931+
items.forEach((el, i) => {
1932+
el.classList.toggle('selected', i === idx);
1933+
el.setAttribute('aria-selected', i === idx);
1934+
});
1935+
}
1936+
18401937
function selectCompletion() {
18411938
const selected = completionEl.querySelector('.selected');
18421939
if (!selected) return;
@@ -1867,14 +1964,16 @@ function relativeTime(dateStr) {
18671964
// ── Sessions ──
18681965
let allSessions = [];
18691966
let pendingDeleteId = null;
1967+
let sessionsSig = '';
18701968

18711969
sessionListEl.addEventListener('click', (e) => {
1970+
const item = e.target.closest('.session-item');
1971+
if (!item) return;
1972+
const sid = item.dataset.id;
1973+
if (!sid) return;
1974+
18721975
// Delete button
1873-
if (e.target.classList.contains('del-btn')) {
1874-
const item = e.target.closest('.session-item');
1875-
if (!item) return;
1876-
const sid = item.dataset.id;
1877-
if (!sid) return;
1976+
if (e.target.closest('.del-btn')) {
18781977
e.stopPropagation();
18791978
pendingDeleteId = sid;
18801979
document.getElementById('confirm-msg').textContent = 'Delete session ' + sid.slice(0, 8) + '...?';
@@ -1883,19 +1982,73 @@ sessionListEl.addEventListener('click', (e) => {
18831982
}
18841983

18851984
// Rename button
1886-
if (e.target.classList.contains('rename-btn')) return; // handled by inline onclick
1985+
if (e.target.closest('.rename-btn')) {
1986+
e.stopPropagation();
1987+
renameSession(sid, e.target);
1988+
return;
1989+
}
18871990

1888-
// Load and render session
1889-
const item = e.target.closest('.session-item');
1890-
if (!item) return;
1891-
const sid = item.dataset.id;
1892-
if (!sid || sid === sessionId) return;
1991+
// Load and render session (click on the item body)
1992+
if (e.target.closest('.si-body')) {
1993+
if (sid === sessionId) return;
1994+
sessionListEl.querySelectorAll('.session-item').forEach(s => s.classList.remove('active'));
1995+
item.classList.add('active');
1996+
loadAndRenderSession(sid);
1997+
}
1998+
});
18931999

1894-
sessionListEl.querySelectorAll('.session-item').forEach(s => s.classList.remove('active'));
1895-
item.classList.add('active');
2000+
// updateActiveSessionItem syncs the .active marker with the current
2001+
// sessionId without re-rendering the list.
2002+
function updateActiveSessionItem() {
2003+
sessionListEl.querySelectorAll('.session-item').forEach(el => {
2004+
el.classList.toggle('active', el.dataset.id === sessionId);
2005+
});
2006+
}
18962007

1897-
loadAndRenderSession(sid);
1898-
});
2008+
async function loadSessions() {
2009+
// Skeleton rows only on cold start (empty list); refreshes keep the
2010+
// existing items so scroll position and hover state survive.
2011+
if (!sessionListEl.querySelector('.session-item')) {
2012+
sessionListEl.innerHTML = '<div class="session-skel"></div>'.repeat(3);
2013+
}
2014+
try {
2015+
const resp = await fetch('/api/sessions', { headers: apiHeaders() });
2016+
const sessions = await resp.json();
2017+
if (!sessions || !Array.isArray(sessions)) {
2018+
sessionListEl.querySelectorAll('.session-skel').forEach(el => el.remove());
2019+
return;
2020+
}
2021+
allSessions = sessions;
2022+
2023+
// Skip the full re-render when nothing changed — this runs after every
2024+
// turn, and re-rendering would steal scroll position and hover state.
2025+
const sig = sessions.map(s => [s.id, s.task, s.turns, s.updated_at, s.model].join('|')).join('\n');
2026+
if (sig === sessionsSig) {
2027+
updateActiveSessionItem();
2028+
return;
2029+
}
2030+
sessionsSig = sig;
2031+
2032+
sessionListEl.innerHTML = sessions.map(s =>
2033+
`<div class="session-item${s.id === sessionId ? ' active' : ''}" data-id="${escapeAttr(s.id)}">
2034+
<button class="si-body" type="button" title="Open session">
2035+
<span class="id">${escapeHtml(s.id.slice(0, 8))}</span>
2036+
<span class="task${!s.task ? ' untitled' : ''}">${escapeHtml(s.task || 'untitled')}</span>
2037+
<span class="meta">
2038+
<span>${s.turns || 0} turn${s.turns !== 1 ? 's' : ''}</span><span>${relativeTime(s.updated_at)}</span>
2039+
${s.model ? `<span class="model-chip">${escapeHtml(s.model)}</span>` : ''}
2040+
</span>
2041+
</button>
2042+
<span class="si-actions">
2043+
<button class="rename-btn" type="button" title="Rename">✎</button>
2044+
<button class="del-btn" type="button" title="Delete">✕</button>
2045+
</span>
2046+
</div>`
2047+
).join('');
2048+
} catch {
2049+
sessionListEl.querySelectorAll('.session-skel').forEach(el => el.remove());
2050+
}
2051+
}
18992052

19002053
// ── Session history rendering ──
19012054
// Renders the full persisted transcript on session load: user/assistant
@@ -2092,30 +2245,6 @@ sidebarSearch.addEventListener('input', () => {
20922245
});
20932246
});
20942247

2095-
async function loadSessions() {
2096-
try {
2097-
const resp = await fetch('/api/sessions', { headers: apiHeaders() });
2098-
const sessions = await resp.json();
2099-
if (!sessions || !Array.isArray(sessions)) return;
2100-
allSessions = sessions;
2101-
2102-
sessionListEl.innerHTML = sessions.map(s =>
2103-
`<div class="session-item${s.id === sessionId ? ' active' : ''}" data-id="${escapeAttr(s.id)}">
2104-
<div style="display:flex;align-items:center;gap:4px">
2105-
<div class="id">${escapeHtml(s.id.slice(0, 8))}</div>
2106-
<span class="rename-btn" title="Rename" onclick="event.stopPropagation();renameSession('${escapeAttr(s.id)}', this)">✎</span>
2107-
<span class="del-btn" title="Delete">✕</span>
2108-
</div>
2109-
<div class="task${!s.task ? ' untitled' : ''}">${escapeHtml(s.task || 'untitled')}</div>
2110-
<div class="meta">
2111-
<span>${s.turns || 0} turn${s.turns !== 1 ? 's' : ''}</span><span>${relativeTime(s.updated_at)}</span>
2112-
${s.model ? `<span class="model-chip">${escapeHtml(s.model)}</span>` : ''}
2113-
</div>
2114-
</div>`
2115-
).join('');
2116-
} catch { /* ignore */ }
2117-
}
2118-
21192248
// ── Init ──
21202249
// Save references so newSession() can restore the empty state after clearing.
21212250
savedEmptyStateNode = document.getElementById('empty-state');

cmd/odek/ui/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ <h3>Sessions</h3>
7676
<div id="input-area">
7777
<div class="input-inner">
7878
<div id="file-chips"></div>
79-
<div id="completion"></div>
79+
<div id="completion" role="listbox" aria-label="Resource completion"></div>
8080
<div id="input-row">
8181
<button id="attach-btn" title="Attach files">
8282
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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>

0 commit comments

Comments
 (0)