Skip to content

Commit 60c2ee1

Browse files
committed
feat(ui): WebUI modernization PR1 — hygiene, design tokens, type floor, self-hosted font
First PR of the WebUI modernization roadmap (Phase 0 + Phase 1 foundation). Phase 0 — hygiene: - Remove dead code: initParticles (targeted nonexistent #particles), addTypingIndicator (never called), #stats-bar element + writes, .li-spinner legacy CSS, dead .approval-dialog fallback branch. - Deduplicate: extract resetTurnState() from the three identical streaming-state reset blocks (send / newSession / loadAndRenderSession); extract ensureSessionToken() from the two identical session-token bootstrap blocks (delete / rename); unify clipboard writes behind copyTextToClipboard() with execCommand fallback. - Micro-bugs: collapse threshold mismatch (JS measured 500px, CSS capped 460px — content could clip with no expander; now shared constant 460), escapeAttr now also escapes < and >, tb- class-prefix collision between topbar and tool blocks resolved (topbar renamed to top-*), Mesage typo. Phase 1 — design system foundation: - Token scales in :root: type (--fs-xs..xl, 11px floor), spacing (--space-1..8, 4px grid), radius (--r-sm/md/lg). - 11px type floor: every font-size below 11px (42 declarations down to 8px) raised to 11px for WCAG AA readability. - Contrast: --text-3 lifted from #706e68 to #8a8880 (~5.5:1 on the carbon background) so tertiary text passes AA at the new floor size. - Self-hosted font: Azeret Mono variable woff2 (26 KB, weights 100-700) embedded at ui/fonts/ and served at /fonts/azeret-mono.woff2 via the static file map; Google Fonts CDN links removed - the UI now works fully offline/air-gapped. Verified: node --check, go vet, full cmd/odek test suite, and a live smoke test of odek serve (/, /style.css, /app.js, /fonts/azeret-mono.woff2 all 200). No behavioral changes beyond readability improvements.
1 parent 3218d72 commit 60c2ee1

5 files changed

Lines changed: 168 additions & 168 deletions

File tree

cmd/odek/serve.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1748,6 +1748,9 @@ var staticFiles = map[string][2]string{
17481748
"/": {"ui/index.html", "text/html; charset=utf-8"},
17491749
"/style.css": {"ui/style.css", "text/css; charset=utf-8"},
17501750
"/app.js": {"ui/app.js", "application/javascript; charset=utf-8"},
1751+
// Self-hosted font (variable weight 100–700) so the UI works offline and
1752+
// does not depend on the Google Fonts CDN.
1753+
"/fonts/azeret-mono.woff2": {"ui/fonts/azeret-mono.woff2", "font/woff2"},
17511754
}
17521755

17531756
func handleStatic(wsToken string) http.HandlerFunc {

cmd/odek/ui/app.js

Lines changed: 74 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,26 @@ function clearSessionToken(sid) {
3939
localStorage.removeItem('odek_session_token_' + sid);
4040
}
4141

42+
// ensureSessionToken returns a stored token for sid, bootstrapping one from
43+
// the server (GET /api/sessions/<id>) when missing. Used by session REST
44+
// mutations (rename/delete) whose tokens may predate this browser. Returns
45+
// '' on failure — the server will answer 401 if a token was required.
46+
async function ensureSessionToken(sid) {
47+
let token = getSessionToken(sid);
48+
if (token) return token;
49+
try {
50+
const bootstrap = await fetch('/api/sessions/' + encodeURIComponent(sid), {
51+
headers: apiHeaders()
52+
});
53+
if (bootstrap.ok) {
54+
const bs = await bootstrap.json();
55+
token = bootstrap.headers.get('X-Session-Token') || bs.auth_token;
56+
if (token) setSessionToken(sid, token);
57+
}
58+
} catch { /* continue — server will return 401 if token required */ }
59+
return token || '';
60+
}
61+
4262
// ── DOM ──
4363
const messagesEl = document.getElementById('messages');
4464
const promptEl = document.getElementById('prompt');
@@ -47,7 +67,6 @@ const completionEl = document.getElementById('completion');
4767
const statusEl = document.getElementById('ws-status');
4868
const dotEl = document.getElementById('ws-dot');
4969
const modelLabel = document.getElementById('model-label');
50-
const statsBar = document.getElementById('stats-bar');
5170
const sessionListEl = document.getElementById('session-list');
5271
const sidebarSearch = document.getElementById('sidebar-search');
5372
const emptyState = document.getElementById('empty-state');
@@ -73,6 +92,24 @@ let inToolGroup = false;
7392
// Timestamps for tool latency (name → start ms, queue-based like above).
7493
const toolStartQueues = new Map();
7594

95+
// resetTurnState clears all per-turn streaming/tool/sub-agent state. Called
96+
// before a new turn (send), on new session, and when loading a session.
97+
function resetTurnState() {
98+
streamBuffer = '';
99+
if (streamRAF) {
100+
cancelAnimationFrame(streamRAF);
101+
streamRAF = null;
102+
}
103+
streamBubbleEl = null;
104+
streamContentEl = null;
105+
currentToolBlock = null;
106+
subagentGroup = null;
107+
thinkingContentEl = null;
108+
toolBlockQueues.clear();
109+
toolStartQueues.clear();
110+
inToolGroup = false;
111+
}
112+
76113
// ── Sub-agent state ──
77114
let subagentGroup = null;
78115

@@ -173,10 +210,10 @@ function formatNum(n) {
173210
return String(n);
174211
}
175212

176-
// ── Mesage cap ──
213+
// ── Message cap ──
177214
const MAX_MESSAGES = 80;
178215
function pruneMessages() {
179-
const items = messagesEl.querySelectorAll(':scope > .msg, :scope > .tool-block, :scope > .subagent-group, :scope > .thinking-block, :scope > .typing-indicator');
216+
const items = messagesEl.querySelectorAll(':scope > .msg, :scope > .tool-block, :scope > .subagent-group, :scope > .thinking-block');
180217
if (items.length > MAX_MESSAGES) {
181218
for (let i = 0, n = items.length - MAX_MESSAGES; i < n; i++) {
182219
items[i].remove();
@@ -190,22 +227,6 @@ promptEl.addEventListener('input', () => {
190227
promptEl.style.height = Math.min(promptEl.scrollHeight, 200) + 'px';
191228
});
192229

193-
// ── Particles ──
194-
function initParticles() {
195-
const el = document.getElementById('particles');
196-
if (!el) return;
197-
for (let i = 0; i < 15; i++) {
198-
const p = document.createElement('div');
199-
p.className = 'particle';
200-
p.style.left = Math.random() * 100 + '%';
201-
p.style.animationDelay = Math.random() * 6 + 's';
202-
p.style.animationDuration = (4 + Math.random() * 4) + 's';
203-
p.style.width = p.style.height = (1 + Math.random() * 3) + 'px';
204-
el.appendChild(p);
205-
}
206-
}
207-
initParticles();
208-
209230
// ── WebSocket ──
210231
function getWsToken() {
211232
const meta = document.querySelector('meta[name="odek-ws-token"]');
@@ -233,7 +254,6 @@ function connect() {
233254
dotEl.className = 'dot connected';
234255
statusEl.textContent = 'connected';
235256
sendBtn.disabled = false;
236-
statsBar.textContent = '';
237257
// Hide loading skeleton when connected
238258
if (skeletonEl) skeletonEl.classList.remove('visible');
239259
};
@@ -538,15 +558,6 @@ function addSystemMessage(content) {
538558
scrollBottom();
539559
}
540560

541-
function addTypingIndicator() {
542-
const el = document.createElement('div');
543-
el.className = 'typing-indicator';
544-
el.innerHTML = '<div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div>';
545-
messagesEl.appendChild(el);
546-
scrollBottom();
547-
return el;
548-
}
549-
550561
function addDivider(text) {
551562
const el = document.createElement('div');
552563
el.className = 'msg-divider';
@@ -906,8 +917,7 @@ window.showApprovalDialog = function(event) {
906917
frictionMsg = document.createElement('div');
907918
frictionMsg.id = 'approval-friction-msg';
908919
frictionMsg.style.cssText = 'color: var(--accent); font-size: 13px; margin-top: 8px;';
909-
overlay.querySelector('.approval-dialog')?.appendChild(frictionMsg)
910-
|| document.getElementById('approval-actions').parentNode.insertBefore(frictionMsg, document.getElementById('approval-actions'));
920+
document.getElementById('approval-actions').parentNode.insertBefore(frictionMsg, document.getElementById('approval-actions'));
911921
}
912922
frictionMsg.textContent =
913923
`⚠️ You have approved ${event.friction_approvals || 0} ${event.risk} operations in the last minute. ` +
@@ -968,19 +978,7 @@ window.executeDeleteSession = async function() {
968978
pendingDeleteId = null;
969979
document.getElementById('confirm-overlay').classList.remove('active');
970980

971-
let token = getSessionToken(sid);
972-
if (!token) {
973-
try {
974-
const bootstrap = await fetch('/api/sessions/' + encodeURIComponent(sid), {
975-
headers: apiHeaders()
976-
});
977-
if (bootstrap.ok) {
978-
const bs = await bootstrap.json();
979-
token = bootstrap.headers.get('X-Session-Token') || bs.auth_token;
980-
if (token) setSessionToken(sid, token);
981-
}
982-
} catch { /* continue — server will return 401 if token required */ }
983-
}
981+
const token = await ensureSessionToken(sid);
984982

985983
fetch('/api/sessions/' + encodeURIComponent(sid), {
986984
method: 'DELETE',
@@ -1114,19 +1112,7 @@ window.renameSession = async function(sid, el) {
11141112
const newName = prompt('Rename session:', currentName);
11151113
if (!newName || newName === currentName) return;
11161114

1117-
let token = getSessionToken(sid);
1118-
if (!token) {
1119-
try {
1120-
const bootstrap = await fetch('/api/sessions/' + encodeURIComponent(sid), {
1121-
headers: apiHeaders()
1122-
});
1123-
if (bootstrap.ok) {
1124-
const bs = await bootstrap.json();
1125-
token = bootstrap.headers.get('X-Session-Token') || bs.auth_token;
1126-
if (token) setSessionToken(sid, token);
1127-
}
1128-
} catch { /* continue — server will return 401 if token required */ }
1129-
}
1115+
const token = await ensureSessionToken(sid);
11301116

11311117
fetch('/api/sessions/' + encodeURIComponent(sid), {
11321118
method: 'POST',
@@ -1229,11 +1215,7 @@ window.newSession = function() {
12291215
sessionId = null;
12301216

12311217
// Reset all streaming + tool state.
1232-
streamBuffer = '';
1233-
if (streamRAF) { cancelAnimationFrame(streamRAF); streamRAF = null; }
1234-
streamBubbleEl = null; streamContentEl = null;
1235-
currentToolBlock = null; subagentGroup = null; thinkingContentEl = null;
1236-
toolBlockQueues.clear(); toolStartQueues.clear(); inToolGroup = false;
1218+
resetTurnState();
12371219
busy = false;
12381220
hideLoading(); hideCancel();
12391221
sendBtn.disabled = !ws || ws.readyState !== WebSocket.OPEN;
@@ -1276,7 +1258,8 @@ function escapeAttr(s) {
12761258
if (!s) return '';
12771259
// & must be replaced first — doing it last double-escapes the entities
12781260
// introduced by the quote replacements (&quot; → &amp;quot;).
1279-
return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
1261+
return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')
1262+
.replace(/</g,'&lt;').replace(/>/g,'&gt;');
12801263
}
12811264

12821265
// ── Markdown to HTML (safe, no DOMPurify needed since we control input) ──
@@ -1346,30 +1329,38 @@ function markdownToHtml(text) {
13461329
return html;
13471330
}
13481331

1349-
// ── Copy code ──
1332+
// ── Copy helpers ──
1333+
// copyTextToClipboard writes text to the clipboard, falling back to the
1334+
// legacy execCommand path when the async Clipboard API is unavailable or
1335+
// denied (non-secure contexts). Returns a Promise.
1336+
function copyTextToClipboard(text) {
1337+
const fallback = () => {
1338+
const ta = document.createElement('textarea');
1339+
ta.value = text;
1340+
document.body.appendChild(ta);
1341+
ta.select();
1342+
document.execCommand('copy');
1343+
document.body.removeChild(ta);
1344+
};
1345+
if (!navigator.clipboard || !navigator.clipboard.writeText) {
1346+
fallback();
1347+
return Promise.resolve();
1348+
}
1349+
return navigator.clipboard.writeText(text).catch(fallback);
1350+
}
1351+
13501352
window.copyCode = function(el) {
13511353
const block = el.closest('.code-block');
13521354
if (!block) return;
13531355
const code = block.querySelector('pre code');
13541356
if (!code) return;
1355-
const text = code.textContent;
1356-
navigator.clipboard.writeText(text).then(() => {
1357+
copyTextToClipboard(code.textContent).then(() => {
13571358
el.textContent = '✓ copied';
13581359
el.classList.add('copied');
13591360
setTimeout(() => {
13601361
el.textContent = '📋 copy';
13611362
el.classList.remove('copied');
13621363
}, 2000);
1363-
}).catch(() => {
1364-
// Fallback
1365-
const ta = document.createElement('textarea');
1366-
ta.value = text;
1367-
document.body.appendChild(ta);
1368-
ta.select();
1369-
document.execCommand('copy');
1370-
document.body.removeChild(ta);
1371-
el.textContent = '✓ copied';
1372-
setTimeout(() => { el.textContent = '📋 copy'; }, 2000);
13731364
});
13741365
};
13751366

@@ -1403,19 +1394,7 @@ function send() {
14031394
addMessage('user', display);
14041395

14051396
// Reset streaming + tool state for the new turn.
1406-
streamBuffer = '';
1407-
if (streamRAF) {
1408-
cancelAnimationFrame(streamRAF);
1409-
streamRAF = null;
1410-
}
1411-
streamBubbleEl = null;
1412-
streamContentEl = null;
1413-
currentToolBlock = null;
1414-
subagentGroup = null;
1415-
thinkingContentEl = null;
1416-
toolBlockQueues.clear();
1417-
toolStartQueues.clear();
1418-
inToolGroup = false;
1397+
resetTurnState();
14191398

14201399
busy = true;
14211400
sendBtn.disabled = true;
@@ -1769,11 +1748,7 @@ async function loadAndRenderSession(sid) {
17691748
sessionId = sid;
17701749

17711750
// Clear current messages and reset all streaming state.
1772-
streamBuffer = '';
1773-
if (streamRAF) { cancelAnimationFrame(streamRAF); streamRAF = null; }
1774-
streamBubbleEl = null; streamContentEl = null;
1775-
currentToolBlock = null; subagentGroup = null; thinkingContentEl = null;
1776-
toolBlockQueues.clear(); toolStartQueues.clear(); inToolGroup = false;
1751+
resetTurnState();
17771752
busy = false; hideLoading(); hideCancel();
17781753
sendBtn.disabled = !ws || ws.readyState !== WebSocket.OPEN;
17791754
promptEl.disabled = false;
@@ -1972,14 +1947,14 @@ window.copyMessage = function(btn, content) {
19721947
}
19731948
}
19741949
if (!content) return;
1975-
navigator.clipboard.writeText(content).then(function() {
1950+
copyTextToClipboard(content).then(function() {
19761951
btn.classList.add('copied');
19771952
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Copied';
19781953
setTimeout(function() {
19791954
btn.classList.remove('copied');
19801955
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
19811956
}, 2000);
1982-
}).catch(function(){});
1957+
});
19831958
};
19841959

19851960
// ── Phase 1: Add copy buttons to rendered messages ──
@@ -1994,9 +1969,11 @@ function addCopyButton(bubble) {
19941969
}
19951970

19961971
// ── Phase 1: Collapse long messages after rendering ──
1972+
// Must match the max-height of .bubble.collapsible in style.css.
1973+
const COLLAPSE_MAX_HEIGHT_PX = 460;
19971974
function checkCollapse(bubble) {
19981975
var content = bubble.querySelector('.content');
1999-
if (!content || content.scrollHeight <= 500) return;
1976+
if (!content || content.scrollHeight <= COLLAPSE_MAX_HEIGHT_PX) return;
20001977
bubble.classList.add('collapsible');
20011978
var toggle = document.createElement('div');
20021979
toggle.className = 'collapse-toggle';
25.6 KB
Binary file not shown.

cmd/odek/ui/index.html

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1"/>
66
<meta name="odek-ws-token" content="{{ODEK_WS_TOKEN}}"/>
77
<title>odek ⚡</title>
8-
<link rel="preconnect" href="https://fonts.googleapis.com">
9-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10-
<link href="https://fonts.googleapis.com/css2?family=Azeret+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
118
<link rel="stylesheet" href="/style.css"/>
9+
<link rel="preload" href="/fonts/azeret-mono.woff2" as="font" type="font/woff2" crossorigin>
1210
</head>
1311
<body>
1412
<div id="app">
@@ -17,17 +15,16 @@
1715
<button id="hamburger-btn" onclick="toggleSidebar()" aria-label="Toggle sidebar">
1816
<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>
1917
</button>
20-
<span class="tb-brand"><span class="tb-brand-text">odek</span></span>
21-
<span class="tb-status-group">
18+
<span class="top-brand"><span class="top-brand-text">odek</span></span>
19+
<span class="top-status-group">
2220
<span class="dot" id="ws-dot"></span>
2321
<span class="status" id="ws-status">connecting</span>
2422
<span id="sandbox-badge" style="display:none" title="Sandbox mode — shell commands run in an isolated Docker container">🔒</span>
2523
</span>
26-
<div class="tb-spacer"></div>
27-
<div class="stats" id="stats-bar"></div>
24+
<div class="top-spacer"></div>
2825
<span class="session-stats" id="session-stats"></span>
2926
<span class="model" id="model-label"></span>
30-
<div class="tb-controls">
27+
<div class="top-controls">
3128
<select id="model-picker" onchange="onPickerChange(this.value)" title="Switch model">
3229
<option value="">Loading...</option>
3330
</select>

0 commit comments

Comments
 (0)