Skip to content

Commit 47f83aa

Browse files
fix(webui): turn timeline, live metrics, and disconnect awareness (#230)
* fix(webui): sequential turn timeline, live metrics, and disconnect banner Partial assistant replies stay in arrival order with tools instead of concatenating into one parked bubble; reasoning is collapsed behind thinking. Usage frames carry inputTokens so ctx/cost update mid-turn. Composer / matches the palette. A dropped socket shows a sticky banner and one transcript notice instead of a silent lamp. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(webui): do not treat paths as slash commands; drop cards on disconnect Slash complete only matches known command prefixes, so /Users/... is sent as a prompt instead of running the first palette row. A dropped socket clears approval and clarify cards without draining the prompt queue. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(webui): add CSS rules for partial replies and live stream content The CSS-contract test requires every class used in JS to have a rule; without .partial and .stream-content, CI's go test job failed. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a18f4be commit 47f83aa

16 files changed

Lines changed: 885 additions & 204 deletions

‎cmd/odek/serve.go‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,6 +1109,9 @@ func usageFrame(info loop.IterationInfo) (map[string]any, bool) {
11091109
"type": "usage",
11101110
"outputTokens": info.OutputTokens,
11111111
}
1112+
if info.InputTokens > 0 {
1113+
frame["inputTokens"] = info.InputTokens
1114+
}
11121115
if info.WindowTokens > 0 {
11131116
frame["windowTokens"] = info.WindowTokens
11141117
}
@@ -2165,11 +2168,10 @@ func handlePrompt(
21652168
// Tool events (tool_call / tool_result) already fired live during
21662169
// RunWithMessages via ToolEventHandler — skip them here.
21672170
//
2168-
// Assistant messages with ToolCalls are intermediate "thinking + act"
2169-
// turns. Their Content (e.g. "Let me check that file…") was narrated
2170-
// live via the IterationCallback progress bubble; re-sending it here
2171-
// would make it appear *after* all tool blocks in the response bubble,
2172-
// which is confusing. Skip their Content.
2171+
// Assistant messages with ToolCalls are intermediate "think + act"
2172+
// turns. Their Content (e.g. "Let me check that file…") was streamed
2173+
// live as token_delta / token timeline rows; re-sending it here would
2174+
// duplicate those partials after the tools. Skip their Content.
21732175
//
21742176
// The final assistant message (no ToolCalls) carries:
21752177
// • ReasoningContent — the model's private reasoning for this turn.

‎cmd/odek/ui/index.html‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ <h4 class="ws-head">config</h4>
158158
<div id="panels-overlay"></div>
159159

160160
<main id="main">
161+
<div id="conn-banner" hidden role="status" aria-live="polite"></div>
161162
<div id="loading-skeleton">
162163
<div class="skeleton-bar"></div>
163164
<div class="skeleton-bar"></div>
@@ -175,7 +176,7 @@ <h4 class="ws-head">config</h4>
175176
<div class="es-hints">
176177
<button type="button" class="es-tip" data-hint="palette"><kbd>⌘K</kbd> commands, sessions, models</button>
177178
<button type="button" class="es-tip" data-hint="at"><kbd>@</kbd> mention a file or past session</button>
178-
<button type="button" class="es-tip" data-hint="slash"><kbd>/</kbd> new · clear · retry · cancel</button>
179+
<button type="button" class="es-tip" data-hint="slash"><kbd>/</kbd> commands (same as ⌘K)</button>
179180
<button type="button" class="es-tip" data-hint="inspector"><kbd>⌘.</kbd> inspector · <kbd>?</kbd> shortcuts</button>
180181
</div>
181182
</div>
@@ -201,7 +202,7 @@ <h4 class="ws-head">config</h4>
201202
<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>
202203
</button>
203204
<input type="file" id="file-input" multiple style="display:none"/>
204-
<span class="composer-hint">@ mention a file or session · /new /clear /retry · ⌘K commands</span>
205+
<span class="composer-hint">@ mention a file or session · / commands · ⌘K palette</span>
205206
<button id="send-btn" type="button" disabled>↵</button>
206207
</div>
207208
</div>
@@ -224,7 +225,7 @@ <h3 id="shortcuts-title">Keyboard shortcuts</h3>
224225
<div class="shortcut-row"><span class="sc-label">Command palette</span><span class="sc-key">⌘K</span></div>
225226
<div class="shortcut-row"><span class="sc-label">Send / queue while busy</span><span class="sc-key">Enter</span></div>
226227
<div class="shortcut-row"><span class="sc-label">New line</span><span class="sc-key">Shift+Enter</span></div>
227-
<div class="shortcut-row"><span class="sc-label">Slash verbs</span><span class="sc-key">/new /clear /retry /cancel /stop</span></div>
228+
<div class="shortcut-row"><span class="sc-label">Slash commands</span><span class="sc-key">/ (same as ⌘K)</span></div>
228229
<div class="shortcut-row"><span class="sc-label">File/session reference</span><span class="sc-key">@</span></div>
229230
<div class="shortcut-row"><span class="sc-label">Copy last reply</span><span class="sc-key">⌘⇧C</span></div>
230231
<div class="shortcut-row"><span class="sc-label">Retry last prompt</span><span class="sc-key">Alt+R</span></div>

‎cmd/odek/ui/js/approvals.js‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,15 @@ export function removeActiveApprovalCard() {
172172
// clearApprovals drops every pending request and the rendered card — used on
173173
// session switch / new session, where pending approvals belong to the
174174
// previous run. This is the only teardown that resets all three pieces of
175-
// approval state together (queue + card + active id).
176-
export function clearApprovals() {
175+
// approval state together (queue + card + active id). Pass { drain: false }
176+
// when the socket is already dead so a queued prompt is not sent into the
177+
// void; reconnect calls drainQueue once the link is up.
178+
export function clearApprovals(opts) {
177179
S.approvalQueue.length = 0;
178180
removeActiveApprovalCard();
179181
S.activeApprovalId = null;
180182
syncSweep();
183+
if (opts && opts.drain === false) return;
181184
if (S.drainQueue) S.drainQueue();
182185
}
183186

‎cmd/odek/ui/js/commands.js‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,32 @@ const COMMANDS = [
3333
{ id: 'tab-ops', title: 'Inspector · ops', hint: '⌘.', run: () => openTab('ops') },
3434
];
3535

36-
const COMPOSER_SLASH = new Set(['new', 'clear', 'retry', 'cancel', 'stop']);
36+
const SLASH_VERBS = new Set([
37+
'help', 'new', 'clear', 'copy', 'export', 'retry', 'queue', 'theme',
38+
'stats', 'cancel', 'notify', 'shutdown', 'model', 'thinking',
39+
'now', 'memory', 'ops', 'plan', 'jobs', 'agents', 'skills', 'tools',
40+
'runs', 'events', 'config', 'sessions', 'session', 'stop',
41+
]);
42+
43+
// isComposerSlashInput is true for a leading command token (`/` or `/ne`)
44+
// and false for filesystem paths (`/Users/...`, `\Windows\...`). Extra
45+
// slashes or a token that is not a prefix of a known verb are paths, so
46+
// Enter/Tab send the line instead of running the first palette row.
47+
export function isComposerSlashInput(val, cursor) {
48+
if (!val || !val.startsWith('/') || val.includes('\n')) return false;
49+
const end = cursor == null ? val.length : cursor;
50+
const before = val.slice(0, end);
51+
if (!before.startsWith('/')) return false;
52+
const rest = before.slice(1);
53+
if (/\s/.test(rest)) return false;
54+
if (rest.includes('/') || rest.includes('\\')) return false;
55+
if (!rest) return true;
56+
const token = rest.toLowerCase();
57+
for (const v of SLASH_VERBS) {
58+
if (v.startsWith(token)) return true;
59+
}
60+
return false;
61+
}
3762
const TAB_WS = {
3863
sessions: 'sessions', session: 'sessions',
3964
plan: 'now', jobs: 'now', agents: 'now', now: 'now',
@@ -240,10 +265,14 @@ export function dispatchSlash(raw) {
240265
return true;
241266
}
242267

268+
export function paletteItems(q) {
269+
return collectItems(q);
270+
}
271+
243272
export function maybeHandleComposerEnter(text) {
244273
if (!text.startsWith('/') || text.includes('\n')) return false;
245-
const cmd = text.slice(1).split(/\s+/)[0];
246-
if (!COMPOSER_SLASH.has(cmd)) return false;
274+
const cmd = text.slice(1).split(/\s+/)[0].toLowerCase();
275+
if (!cmd || !SLASH_VERBS.has(cmd)) return false;
247276
return dispatchSlash(text);
248277
}
249278

‎cmd/odek/ui/js/input.js‎

Lines changed: 86 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import {
88
} from './dom.js';
99
import {
1010
escapeHtml, escapeAttr, formatFileSize, scrollToBottom,
11-
showCancel, toggleShortcuts, SCROLL_THRESHOLD, teach,
11+
showCancel, toggleShortcuts, SCROLL_THRESHOLD, teach, showToast,
1212
} from './utils.js';
1313
import { addMessage, resetTurnState, showLoading, paintIntent } from './render.js';
14-
import { maybeHandleComposerEnter } from './commands.js';
14+
import { maybeHandleComposerEnter, paletteItems, isComposerSlashInput } from './commands.js';
1515

1616
function queueId() {
1717
return 'q' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
@@ -69,7 +69,9 @@ function moveQueue(i, delta) {
6969

7070
export function drainQueue() {
7171
// Do not auto-send through a live approval — the operator is still deciding.
72+
// A dead socket must not consume the queue either; reconnect drains it.
7273
if (S.busy || S.activeApprovalId || !S.promptQueue.length) return;
74+
if (!S.ws || S.ws.readyState !== WebSocket.OPEN) return;
7375
const next = S.promptQueue.shift();
7476
renderQueueStrip();
7577
sendPayload(next.text, next.attachments, next.display, next.model, next.thinking);
@@ -79,7 +81,10 @@ S.drainQueue = drainQueue;
7981
// ── Send ──
8082
export function send() {
8183
// F-B2: dead socket still rejects BEFORE touching attachments.
82-
if (!S.ws || S.ws.readyState !== WebSocket.OPEN) return;
84+
if (!S.ws || S.ws.readyState !== WebSocket.OPEN) {
85+
showToast('connection lost — reconnecting');
86+
return;
87+
}
8388
const text = promptEl.value.trim();
8489
if (!text && S.attachedFiles.length === 0) return;
8590

@@ -326,13 +331,17 @@ promptEl.addEventListener('keydown', (e) => {
326331
return;
327332
}
328333
if (e.key === 'Enter' && !e.shiftKey) {
329-
e.preventDefault();
330-
selectCompletion();
331-
return;
334+
if (S.compMode === 'slash' && !isComposerSlashInput(promptEl.value, promptEl.selectionStart)) {
335+
hideCompletion();
336+
} else {
337+
e.preventDefault();
338+
selectCompletion();
339+
return;
340+
}
332341
}
333342
if (e.key === 'Escape') {
334343
e.preventDefault();
335-
completionEl.classList.remove('visible');
344+
hideCompletion();
336345
return;
337346
}
338347
}
@@ -392,25 +401,37 @@ promptEl.addEventListener('input', () => {
392401
});
393402

394403
promptEl.addEventListener('keydown', (e) => {
395-
if (e.key === '@') {
404+
if (e.key === '@' || e.key === '/') {
396405
if (completionTimer) clearTimeout(completionTimer);
397406
completionTimer = setTimeout(checkCompletion, 150);
398407
}
399408

400409
// Tab for completion selection
401410
if (e.key === 'Tab' && completionEl.classList.contains('visible')) {
402-
e.preventDefault();
403-
selectCompletion();
404-
return;
411+
if (S.compMode === 'slash' && !isComposerSlashInput(promptEl.value, promptEl.selectionStart)) {
412+
hideCompletion();
413+
} else {
414+
e.preventDefault();
415+
selectCompletion();
416+
return;
417+
}
405418
}
406419
});
407420

408421
// ── @ Completion ──
409422
completionEl.addEventListener('click', (e) => {
410423
const item = e.target.closest('.comp-item');
411424
if (!item) return;
425+
if (S.compMode === 'slash') {
426+
completionEl.querySelectorAll('.comp-item').forEach(el => {
427+
el.classList.toggle('selected', el === item);
428+
el.setAttribute('aria-selected', el === item);
429+
});
430+
selectCompletion();
431+
return;
432+
}
412433
replaceCompletion(item.dataset.id);
413-
completionEl.classList.remove('visible');
434+
hideCompletion();
414435
});
415436

416437
completionEl.addEventListener('mousemove', (e) => {
@@ -422,23 +443,57 @@ completionEl.addEventListener('mousemove', (e) => {
422443
});
423444
});
424445

446+
let slashRows = [];
447+
448+
function hideCompletion() {
449+
completionEl.classList.remove('visible');
450+
S.compMode = '';
451+
slashRows = [];
452+
}
453+
454+
function trySlashCompletion(val, cursor) {
455+
if (!isComposerSlashInput(val, cursor)) return false;
456+
const q = val.slice(1, cursor);
457+
slashRows = paletteItems(q);
458+
S.compMode = 'slash';
459+
S.lastAtIdx = 0;
460+
S.lastCursor = cursor;
461+
S.compQuery = q;
462+
if (!slashRows.length) {
463+
hideCompletion();
464+
return true;
465+
}
466+
completionEl.innerHTML = slashRows.map((r, i) =>
467+
`<div class="comp-item${i === 0 ? ' selected' : ''}" role="option" aria-selected="${i === 0}" data-idx="${i}">
468+
<span class="comp-type">${escapeAttr(r.kind)}</span>
469+
<span class="comp-label">${escapeHtml(r.title)}</span>
470+
<span class="comp-detail">${escapeHtml(r.hint || '')}</span>
471+
</div>`
472+
).join('');
473+
completionEl.classList.add('visible');
474+
return true;
475+
}
476+
425477
async function checkCompletion() {
426478
const val = promptEl.value;
427479
const cursor = promptEl.selectionStart;
480+
if (trySlashCompletion(val, cursor)) return;
481+
428482
const before = val.slice(0, cursor);
429483

430484
const atIdx = before.lastIndexOf('@');
431485
if (atIdx < 0) {
432-
completionEl.classList.remove('visible');
486+
hideCompletion();
433487
return;
434488
}
435489

436490
const query = before.slice(atIdx + 1).split(/\s/)[0];
437491
if (!query) {
438-
completionEl.classList.remove('visible');
492+
hideCompletion();
439493
return;
440494
}
441495

496+
S.compMode = 'at';
442497
S.lastAtIdx = atIdx;
443498
S.lastCursor = cursor;
444499
S.compQuery = query;
@@ -451,12 +506,12 @@ async function checkCompletion() {
451506
headers: apiHeaders()
452507
});
453508
if (!resp.ok) {
454-
completionEl.classList.remove('visible');
509+
hideCompletion();
455510
return;
456511
}
457512
const results = await resp.json();
458513
if (!Array.isArray(results) || results.length === 0) {
459-
completionEl.classList.remove('visible');
514+
hideCompletion();
460515
return;
461516
}
462517
if (promptEl.value + '\u0000' + promptEl.selectionStart !== reqToken) {
@@ -473,7 +528,7 @@ async function checkCompletion() {
473528

474529
completionEl.classList.add('visible');
475530
} catch {
476-
completionEl.classList.remove('visible');
531+
hideCompletion();
477532
}
478533
}
479534

@@ -491,10 +546,23 @@ function moveCompletionSelection(delta) {
491546
}
492547

493548
function selectCompletion() {
549+
if (S.compMode === 'slash') {
550+
const selected = completionEl.querySelector('.selected');
551+
const idx = selected ? parseInt(selected.dataset.idx, 10) : 0;
552+
const row = slashRows[idx];
553+
hideCompletion();
554+
promptEl.value = '';
555+
promptEl.style.height = 'auto';
556+
if (row && row.run) {
557+
try { row.run(); } catch (err) { showToast(err.message || 'command failed'); }
558+
}
559+
promptEl.focus();
560+
return;
561+
}
494562
const selected = completionEl.querySelector('.selected');
495563
if (!selected) return;
496564
replaceCompletion(selected.dataset.id);
497-
completionEl.classList.remove('visible');
565+
hideCompletion();
498566
}
499567

500568
function replaceCompletion(id) {

0 commit comments

Comments
 (0)