Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions cmd/odek/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<body>
<div id="app">
<!-- Top Bar -->
<div id="topbar">
<header id="topbar">
<button id="hamburger-btn" aria-label="Toggle sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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>
Expand All @@ -34,28 +34,28 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
</header>

<!-- Sidebar -->
<div id="sidebar">
<nav id="sidebar" aria-label="Sessions">
<div id="sidebar-header">
<h3>Sessions</h3>
<button class="new-session-btn" title="New session">+</button>
</div>
<input id="sidebar-search" type="text" placeholder="Filter…" />
<div id="session-list"></div>
</div>
</nav>

<div id="sidebar-overlay"></div>

<!-- Main Chat -->
<div id="main">
<main id="main">
<div id="loading-skeleton">
<div class="skeleton-bar"></div>
<div class="skeleton-bar"></div>
<div class="skeleton-bar"></div>
</div>
<div id="messages">
<div id="messages" role="log" aria-live="polite" aria-relevant="additions">
<button id="scroll-bottom-btn" aria-label="Scroll to bottom">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
Expand All @@ -65,8 +65,8 @@ <h3>Sessions</h3>
<div class="es-name">odek</div>
<p class="es-sub">autonomous agent runtime</p>
<div class="es-hints">
<span>shortcuts /?/</span>
<span>refresh sessions</span>
<span role="button" tabindex="0">shortcuts /?/</span>
<span role="button" tabindex="0">refresh sessions</span>
</div>
</div>
</div>
Expand All @@ -91,18 +91,19 @@ <h3>Sessions</h3>
</div>
</div>
</div>
</div>
</main>
</div>

<!-- Keyboard Shortcuts -->
<div id="shortcuts-overlay">
<div id="shortcuts-dialog">
<h3>Keyboard shortcuts</h3>
<div id="shortcuts-dialog" role="dialog" aria-modal="true" aria-labelledby="shortcuts-title" tabindex="-1">
<h3 id="shortcuts-title">Keyboard shortcuts</h3>
<div class="shortcut-row"><span class="sc-label">Send message</span><span class="sc-key">Enter</span></div>
<div class="shortcut-row"><span class="sc-label">New line</span><span class="sc-key">Shift+Enter</span></div>
<div class="shortcut-row"><span class="sc-label">History up</span><span class="sc-key">↑</span></div>
<div class="shortcut-row"><span class="sc-label">History down</span><span class="sc-key">↓</span></div>
<div class="shortcut-row"><span class="sc-label">File/session reference</span><span class="sc-key">@</span></div>
<div class="shortcut-row"><span class="sc-label">Completion navigation</span><span class="sc-key">↑↓ · Enter/Tab · Esc</span></div>
<div class="shortcut-row"><span class="sc-label">Toggle shortcuts</span><span class="sc-key">?</span></div>
<div class="shortcut-row"><span class="sc-label">Refresh sessions</span><span class="sc-key">⌘R</span></div>
<div class="shortcut-row"><span class="sc-label">Toggle thinking</span><span class="sc-key">Alt+T</span></div>
Expand All @@ -114,17 +115,20 @@ <h3>Keyboard shortcuts</h3>

<!-- Confirm Dialog -->
<div id="confirm-overlay">
<div id="confirm-dialog">
<div id="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="confirm-icon">⚠</div>
<h3>Delete session?</h3>
<h3 id="confirm-title">Delete session?</h3>
<p id="confirm-msg">This action cannot be undone.</p>
<div id="confirm-actions">
<button class="cancel">Cancel</button>
<button class="cancel" data-autofocus>Cancel</button>
<button class="delete-confirm" id="confirm-delete-btn">Delete</button>
</div>
</div>
</div>

<!-- Screen-reader-only live status region (announce() in js/utils.js) -->
<div id="sr-status" class="sr-only" role="status" aria-live="polite"></div>

<div class="toast" id="toast"></div>

<script type="module" src="/app.js"></script>
Expand Down
5 changes: 4 additions & 1 deletion cmd/odek/ui/js/approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// inline decision card pinned at the bottom of the message stream.
import { S } from './state.js';
import { messagesEl } from './dom.js';
import { forceScrollBottom } from './utils.js';
import { forceScrollBottom, announce } from './utils.js';
import { hideEmptyState } from './render.js';

// Approval requests are queued and rendered one at a time as an inline
Expand Down Expand Up @@ -193,6 +193,7 @@ function renderApprovalCard(event) {
messagesEl.appendChild(card);
forceScrollBottom();
card.focus({ preventScroll: true });
announce('Approval required: ' + (event.risk || 'unknown') + ' risk operation');
}

export function sendApproval(action) {
Expand All @@ -211,6 +212,8 @@ export function sendApproval(action) {
S.approvalQueue.shift();
removeActiveApprovalCard();
showNextApproval();
announce(action === 'trust' ? 'Risk class trusted for this session' :
action === 'approve' ? 'Approved' : 'Denied');
}

// Keyboard operation while an approval card is active. Ignored when the
Expand Down
12 changes: 12 additions & 0 deletions cmd/odek/ui/js/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,13 @@ function renderFileChips() {
const remove = document.createElement('span');
remove.className = 'chip-remove';
remove.textContent = '✕';
remove.setAttribute('role', 'button');
remove.setAttribute('tabindex', '0');
remove.setAttribute('aria-label', 'Remove ' + f.name);
remove.addEventListener('click', () => removeAttachedFile(i));
remove.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); removeAttachedFile(i); }
});

chip.append(icon, name, size, remove);
fileChips.appendChild(chip);
Expand Down Expand Up @@ -139,10 +145,16 @@ function addErrorChip(name, reason) {
const remove = document.createElement('span');
remove.className = 'chip-remove';
remove.textContent = '✕';
remove.setAttribute('role', 'button');
remove.setAttribute('tabindex', '0');
remove.setAttribute('aria-label', 'Dismiss');
chip.append(icon, label, remove);
fileChips.appendChild(chip);
const dismiss = () => chip.remove();
remove.addEventListener('click', dismiss);
remove.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); dismiss(); }
});
setTimeout(dismiss, 6000);
}

Expand Down
18 changes: 12 additions & 6 deletions cmd/odek/ui/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { S, getSessionToken } from './state.js';
import { apiHeaders } from './net.js';
import { promptEl, skeletonEl, thinkBtn } from './dom.js';
import { escapeHtml, escapeAttr, showToast, toggleShortcuts, hideCancel } from './utils.js';
import { escapeHtml, escapeAttr, showToast, toggleShortcuts, hideCancel, closeDialog } from './utils.js';
import { addSystemMessage } from './render.js';
import { loadSessions } from './sessions.js';
import { connect } from './ws.js';
Expand All @@ -16,11 +16,18 @@ S.savedEmptyStateNode = document.getElementById('empty-state');
S.savedScrollBtnNode = document.getElementById('scroll-bottom-btn');

// Empty-state hint actions (the saved node is re-appended on session
// switches, so direct listeners persist).
// switches, so direct listeners persist). The hints are role="button"
// spans, so Enter/Space must activate them like a click.
function activateOnKey(el, fn) {
el.addEventListener('click', fn);
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fn(); }
});
}
if (S.savedEmptyStateNode) {
const hints = S.savedEmptyStateNode.querySelectorAll('.es-hints span');
if (hints[0]) hints[0].addEventListener('click', toggleShortcuts);
if (hints[1]) hints[1].addEventListener('click', loadSessions);
if (hints[0]) activateOnKey(hints[0], toggleShortcuts);
if (hints[1]) activateOnKey(hints[1], loadSessions);
}

// ── Theme Toggle ──
Expand Down Expand Up @@ -189,8 +196,7 @@ document.addEventListener('keydown', (e) => {
// Escape closes overlays. Approval cards are deliberately NOT dismissed —
// a decision must be made explicitly.
if (e.key === 'Escape') {
document.getElementById('shortcuts-overlay').classList.remove('active');
document.getElementById('confirm-overlay').classList.remove('active');
closeDialog(); // whichever overlay dialog is open (shortcuts / confirm)
S.pendingDeleteId = null;
}
// Ctrl+R refreshes sessions
Expand Down
27 changes: 21 additions & 6 deletions cmd/odek/ui/js/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { messagesEl, promptEl, sendBtn, emptyState } from './dom.js';
import {
escapeHtml, escapeAttr, truncateStr, copyTextToClipboard,
pruneMessages, scrollBottom, forceScrollBottom, stripAttachmentBodies,
showCancel, hideCancel,
showCancel, hideCancel, announce,
} from './utils.js';
import { markdownToHtml } from './markdown.js';

Expand Down Expand Up @@ -88,7 +88,7 @@ export function streamThinking(content) {
const block = document.createElement('div');
block.className = 'thinking-block';
block.innerHTML =
'<div class="thinking-toggle">' +
'<div class="thinking-toggle" role="button" tabindex="0" aria-expanded="false">' +
'<span class="arrow">▶</span> reasoning' +
'</div>' +
'<div class="thinking-content">' + escapeHtml(content) + '</div>';
Expand All @@ -110,6 +110,7 @@ function toggleThinking(el) {
if (content) {
content.classList.toggle('open');
arrow.classList.toggle('open');
el.setAttribute('aria-expanded', content.classList.contains('open'));
// Auto-open on first click
if (content.classList.contains('open')) {
scrollBottom();
Expand Down Expand Up @@ -248,6 +249,7 @@ export function addSystemMessage(content) {
el.className = 'msg system';
el.innerHTML = '<div class="bubble"><div class="content">' + escapeHtml(content) + '</div></div>';
messagesEl.appendChild(el);
announce(content);
pruneMessages();
scrollBottom();
}
Expand Down Expand Up @@ -346,7 +348,7 @@ export function addToolCall(name, data) {
const el = document.createElement('div');
el.className = 'tool-block';
el.innerHTML =
'<div class="tb-header">' +
'<div class="tb-header" role="button" tabindex="0" aria-expanded="false">' +
'<span class="arrow">▶</span>' +
' <span class="tb-emoji">' + emoji + '</span>' +
' <span class="tb-name">' + escapeHtml(name) + '</span>' +
Expand Down Expand Up @@ -381,7 +383,7 @@ function appendToolResultContent(block, output) {
if (truncated) {
resultEl.innerHTML =
escapeHtml(output.slice(0, MAX_RESULT)) +
'<span class="tb-result-more" data-full="' +
'<span class="tb-result-more" role="button" tabindex="0" data-full="' +
escapeAttr(output) + '"> …show all (' + output.length + ' chars)</span>';
} else {
resultEl.textContent = output || '';
Expand Down Expand Up @@ -421,6 +423,7 @@ function toggleToolBody(header) {
if (body) {
body.classList.toggle('open');
arrow.classList.toggle('open');
header.setAttribute('aria-expanded', body.classList.contains('open'));
}
}

Expand Down Expand Up @@ -645,7 +648,7 @@ function renderHistoricalThinking(content) {
const block = document.createElement('div');
block.className = 'thinking-block';
block.innerHTML =
'<div class="thinking-toggle">' +
'<div class="thinking-toggle" role="button" tabindex="0" aria-expanded="false">' +
'<span class="arrow">▶</span> reasoning' +
'</div>' +
'<div class="thinking-content">' + escapeHtml(content) + '</div>';
Expand All @@ -659,7 +662,7 @@ function renderHistoricalToolBlock(name, args, result) {
const el = document.createElement('div');
el.className = 'tool-block';
el.innerHTML =
'<div class="tb-header">' +
'<div class="tb-header" role="button" tabindex="0" aria-expanded="false">' +
'<span class="arrow">▶</span>' +
' <span class="tb-emoji">' + toolEmoji(name) + '</span>' +
' <span class="tb-name">' + escapeHtml(name) + '</span>' +
Expand Down Expand Up @@ -722,6 +725,8 @@ function checkCollapse(bubble) {
bubble.classList.add('collapsible');
const toggle = document.createElement('div');
toggle.className = 'collapse-toggle';
toggle.setAttribute('role', 'button');
toggle.setAttribute('tabindex', '0');
toggle.textContent = 'Show more ▼';
bubble.appendChild(toggle);
}
Expand Down Expand Up @@ -797,3 +802,13 @@ messagesEl.addEventListener('click', (e) => {
const saDetails = t.closest('.sa-details');
if (saDetails) { toggleSaDetails(saDetails); return; }
});

// Keyboard activation for the role="button" elements above: Enter/Space
// re-dispatches as a click so the same delegation handles it.
messagesEl.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const btn = e.target.closest('[role="button"]');
if (!btn || !messagesEl.contains(btn)) return;
e.preventDefault();
btn.click();
});
15 changes: 11 additions & 4 deletions cmd/odek/ui/js/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { S, getSessionToken, setSessionToken, clearSessionToken, ensureSessionToken } from './state.js';
import { apiHeaders } from './net.js';
import { messagesEl, promptEl, sendBtn, sessionListEl, sidebarSearch, sidebarOverlay } from './dom.js';
import { escapeHtml, escapeAttr, relativeTime, showToast, forceScrollBottom, hideCancel } from './utils.js';
import { escapeHtml, escapeAttr, relativeTime, showToast, forceScrollBottom, hideCancel, announce, openDialog, closeDialog, isDialogOpen } from './utils.js';
import { resetTurnState, hideLoading, renderSessionHistory } from './render.js';
import { removeActiveApprovalCard } from './approvals.js';

Expand Down Expand Up @@ -131,6 +131,7 @@ export async function loadAndRenderSession(sid) {

forceScrollBottom();
showToast('Session loaded');
announce('Session loaded');
} catch (err) {
showToast('Error loading session');
}
Expand Down Expand Up @@ -189,21 +190,24 @@ async function doRenameSession(sid, newName) {
if (!resp.ok) throw new Error('rename failed');
loadSessions();
showToast('Session renamed');
announce('Session renamed');
})
.catch(() => showToast('Failed to rename session'));
}

// ── Confirm Dialog ──
export function hideConfirmDialog() {
document.getElementById('confirm-overlay').classList.remove('active');
const overlay = document.getElementById('confirm-overlay');
if (isDialogOpen(overlay)) closeDialog();
else overlay.classList.remove('active');
S.pendingDeleteId = null;
}

export async function executeDeleteSession() {
if (!S.pendingDeleteId) return;
const sid = S.pendingDeleteId;
S.pendingDeleteId = null;
document.getElementById('confirm-overlay').classList.remove('active');
hideConfirmDialog();

const token = await ensureSessionToken(sid);

Expand All @@ -215,6 +219,7 @@ export async function executeDeleteSession() {
clearSessionToken(sid);
if (S.sessionId === sid) newSession();
loadSessions();
announce('Session deleted');
})
.catch(() => showToast('Failed to delete session'));
}
Expand All @@ -225,6 +230,8 @@ export function toggleSidebar() {
if (!sidebar) return;
sidebar.classList.toggle('active');
if (sidebarOverlay) sidebarOverlay.classList.toggle('active');
const hamburger = document.getElementById('hamburger-btn');
if (hamburger) hamburger.setAttribute('aria-expanded', sidebar.classList.contains('active'));
}

// ── Session list click delegation ──
Expand All @@ -239,7 +246,7 @@ sessionListEl.addEventListener('click', (e) => {
e.stopPropagation();
S.pendingDeleteId = sid;
document.getElementById('confirm-msg').textContent = 'Delete session ' + sid.slice(0, 8) + '...?';
document.getElementById('confirm-overlay').classList.add('active');
openDialog(document.getElementById('confirm-overlay'));
return;
}

Expand Down
Loading