@mjasnikovs/pi-task 0.17.22 → 0.17.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/remote/bridge.d.ts +8 -0
- package/dist/remote/bridge.js +10 -0
- package/dist/remote/events.js +10 -4
- package/dist/remote/history.d.ts +9 -1
- package/dist/remote/history.js +1 -1
- package/dist/remote/protocol.d.ts +23 -2
- package/dist/remote/protocol.js +2 -0
- package/dist/remote/register.js +7 -3
- package/dist/remote/server.d.ts +1 -1
- package/dist/remote/server.js +5 -1
- package/dist/remote/session-state.d.ts +16 -5
- package/dist/remote/session-state.js +44 -8
- package/dist/remote/ui-render.js +6 -2
- package/dist/remote/ui-script.js +373 -23
- package/dist/remote/ui-styles.d.ts +1 -1
- package/dist/remote/ui-styles.js +116 -3
- package/dist/remote/ui.js +13 -1
- package/dist/task/impl-widget.js +2 -2
- package/dist/task/widget.d.ts +9 -0
- package/dist/task/widget.js +42 -2
- package/package.json +1 -1
package/dist/remote/ui-script.js
CHANGED
|
@@ -15,18 +15,57 @@ export function clientScript(wsUrl) {
|
|
|
15
15
|
const contextFill = document.getElementById('context-bar-fill');
|
|
16
16
|
function setContextBar(usage) {
|
|
17
17
|
if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
|
|
18
|
+
setStatusChip(usage);
|
|
19
|
+
}
|
|
20
|
+
// Compact token count for the header chip (mirrors formatContextTokens).
|
|
21
|
+
function fmtTokens(n) {
|
|
22
|
+
if (n == null) return '';
|
|
23
|
+
if (n < 1000) return String(n);
|
|
24
|
+
if (n < 10000) return (n / 1000).toFixed(1) + 'k';
|
|
25
|
+
if (n < 1000000) return Math.round(n / 1000) + 'k';
|
|
26
|
+
return (n / 1000000).toFixed(1) + 'M';
|
|
27
|
+
}
|
|
28
|
+
function setStatusChip(usage) {
|
|
29
|
+
if (!usage) return;
|
|
30
|
+
var parts = [];
|
|
31
|
+
if (usage.percent != null) parts.push(Math.round(usage.percent) + '%');
|
|
32
|
+
if (usage.tokens != null && usage.contextWindow) {
|
|
33
|
+
parts.push(fmtTokens(usage.tokens) + '/' + fmtTokens(usage.contextWindow));
|
|
34
|
+
}
|
|
35
|
+
statusCtx.textContent = parts.join(' \\u00B7 ');
|
|
36
|
+
}
|
|
37
|
+
function setModelName(name) {
|
|
38
|
+
if (name && name !== modelName) { modelName = name; statusModel.textContent = name; }
|
|
39
|
+
}
|
|
40
|
+
// Connection dot: red disconnected, mauve pulsing while running, green idle.
|
|
41
|
+
function updateStatusDot() {
|
|
42
|
+
statusDot.className = !connected ? 'disconnected' : (agentRunning ? 'running' : 'idle');
|
|
18
43
|
}
|
|
19
44
|
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
20
45
|
const reconnectMsg = document.getElementById('reconnect-msg');
|
|
21
46
|
const cmdSuggestions = document.getElementById('cmd-suggestions');
|
|
22
47
|
const statusPanel = document.getElementById('status-panel');
|
|
48
|
+
const statusDot = document.getElementById('status-dot');
|
|
49
|
+
const statusModel = document.getElementById('status-model');
|
|
50
|
+
const statusCtx = document.getElementById('status-ctx');
|
|
51
|
+
const notifPanel = document.getElementById('notif-panel');
|
|
52
|
+
const notifList = document.getElementById('notif-list');
|
|
53
|
+
const notifToggle = document.getElementById('notif-toggle');
|
|
54
|
+
// Last ~20 toasts, newest first, for the bell dropdown history.
|
|
55
|
+
let notifHistory = [];
|
|
56
|
+
let modelName = '';
|
|
23
57
|
// Widgets are keyed (e.g. 'pi-tasks', 'pi-task-auto'); track them per key so a
|
|
24
58
|
// clear for one key can't be masked by a stale message from another.
|
|
25
59
|
// Single authoritative task-widget slot. The snapshot and the live 'widget'
|
|
26
60
|
// delta both set this; null hides the panel. (No more per-key map that could
|
|
27
61
|
// strand an orphaned widget on screen.)
|
|
28
62
|
let taskWidgetLines = null;
|
|
63
|
+
let taskWidgetData = null;
|
|
29
64
|
function renderWidgets() {
|
|
65
|
+
// Structured payload wins (progress bar + phase badge + elapsed); the plain
|
|
66
|
+
// text lines are the fallback for older/unstructured producers.
|
|
67
|
+
if (taskWidgetData) { renderStructuredWidget(taskWidgetData); return; }
|
|
68
|
+
statusPanel.classList.remove('structured');
|
|
30
69
|
if (taskWidgetLines && taskWidgetLines.length) {
|
|
31
70
|
statusPanel.textContent = taskWidgetLines.join('\\n');
|
|
32
71
|
statusPanel.style.display = 'block';
|
|
@@ -34,6 +73,21 @@ export function clientScript(wsUrl) {
|
|
|
34
73
|
statusPanel.style.display = 'none';
|
|
35
74
|
}
|
|
36
75
|
}
|
|
76
|
+
function renderStructuredWidget(d) {
|
|
77
|
+
statusPanel.classList.add('structured');
|
|
78
|
+
statusPanel.style.display = 'block';
|
|
79
|
+
var top = '<div class="widget-top">'
|
|
80
|
+
+ '<span class="widget-title">' + escHtml(d.title || '') + '</span>'
|
|
81
|
+
+ (d.phase ? '<span class="widget-phase">' + escHtml(d.phase) + '</span>' : '')
|
|
82
|
+
+ (d.elapsed ? '<span class="widget-elapsed">' + escHtml(d.elapsed) + '</span>' : '')
|
|
83
|
+
+ '</div>';
|
|
84
|
+
var bar = '';
|
|
85
|
+
if (d.total > 0 && d.done != null) {
|
|
86
|
+
var pct = Math.max(0, Math.min(100, Math.round((d.done / d.total) * 100)));
|
|
87
|
+
bar = '<div class="widget-bar"><div class="widget-bar-fill" style="width:' + pct + '%"></div></div>';
|
|
88
|
+
}
|
|
89
|
+
statusPanel.innerHTML = top + bar;
|
|
90
|
+
}
|
|
37
91
|
const promptCard = document.getElementById('prompt-card');
|
|
38
92
|
const promptQ = document.getElementById('prompt-q');
|
|
39
93
|
const promptRec = document.getElementById('prompt-rec');
|
|
@@ -50,6 +104,19 @@ export function clientScript(wsUrl) {
|
|
|
50
104
|
const toolCallMap = {};
|
|
51
105
|
let currentBubble = null;
|
|
52
106
|
let streamText = '';
|
|
107
|
+
// Composer state: input is enabled whenever connected AND no prompt card is
|
|
108
|
+
// open (messages typed mid-run steer the live turn); the Send button morphs
|
|
109
|
+
// into a red Stop while the agent is running.
|
|
110
|
+
let agentRunning = false;
|
|
111
|
+
let connected = false;
|
|
112
|
+
// Whether the current live turn produced any content — gates the trailing
|
|
113
|
+
// turn-timestamp so an empty/aborted turn doesn't leave a stray clock.
|
|
114
|
+
let turnHadContent = false;
|
|
115
|
+
let stopArmed = false;
|
|
116
|
+
let stopArmTimer = null;
|
|
117
|
+
// The live thinking (<details>) block being streamed, and its accumulated text.
|
|
118
|
+
let currentThinking = null;
|
|
119
|
+
let thinkingText = '';
|
|
53
120
|
let autoScroll = true;
|
|
54
121
|
let reconnectDelay = 1000;
|
|
55
122
|
let reconnectAnim = null;
|
|
@@ -144,7 +211,7 @@ export function clientScript(wsUrl) {
|
|
|
144
211
|
el.className = 'bubble ' + role;
|
|
145
212
|
// Only assistant text is markdown-rendered; user/error bubbles stay plain so a
|
|
146
213
|
// user's literal *asterisks* or backticks show verbatim.
|
|
147
|
-
if (role === 'assistant') setContent(el, text);
|
|
214
|
+
if (role === 'assistant') { setContent(el, text); attachBubbleCopy(el, text); }
|
|
148
215
|
else el.textContent = text;
|
|
149
216
|
chatLog.appendChild(el);
|
|
150
217
|
scrollBottom();
|
|
@@ -235,10 +302,160 @@ export function clientScript(wsUrl) {
|
|
|
235
302
|
s.appendChild(e);
|
|
236
303
|
}
|
|
237
304
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
305
|
+
// Reconcile the composer (input + Send/Stop button) with the current state.
|
|
306
|
+
// The input is disabled ONLY while disconnected or a prompt card is open — a
|
|
307
|
+
// running agent no longer locks it, so messages can steer the live turn.
|
|
308
|
+
function refreshComposer() {
|
|
309
|
+
updateStatusDot();
|
|
310
|
+
const promptOpen = activePromptId !== null;
|
|
311
|
+
inputEl.disabled = !connected || promptOpen;
|
|
312
|
+
inputEl.placeholder = agentRunning
|
|
313
|
+
? 'message the agent — delivered mid-run'
|
|
314
|
+
: 'type a message\\u2026 (/ for commands)';
|
|
315
|
+
if (agentRunning && !promptOpen) {
|
|
316
|
+
// Send morphs into a red Stop that interrupts the running turn.
|
|
317
|
+
sendBtn.classList.add('stop');
|
|
318
|
+
sendBtn.disabled = !connected;
|
|
319
|
+
if (!stopArmed) sendBtn.textContent = 'Stop';
|
|
320
|
+
} else {
|
|
321
|
+
disarmStop();
|
|
322
|
+
sendBtn.classList.remove('stop');
|
|
323
|
+
sendBtn.textContent = 'Send';
|
|
324
|
+
sendBtn.disabled = !connected || promptOpen;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function disarmStop() {
|
|
329
|
+
stopArmed = false;
|
|
330
|
+
if (stopArmTimer) { clearTimeout(stopArmTimer); stopArmTimer = null; }
|
|
331
|
+
sendBtn.classList.remove('armed');
|
|
332
|
+
if (agentRunning) sendBtn.textContent = 'Stop';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function sendInterrupt() {
|
|
336
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
337
|
+
ws.send(JSON.stringify({ type: 'interrupt' }));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Stop needs a two-tap confirm (same pattern as the prompt card's cancel):
|
|
342
|
+
// first tap arms it, a second tap within 3s actually interrupts.
|
|
343
|
+
function onSendClick() {
|
|
344
|
+
if (agentRunning && activePromptId === null) {
|
|
345
|
+
if (stopArmed) { disarmStop(); sendInterrupt(); return; }
|
|
346
|
+
stopArmed = true;
|
|
347
|
+
sendBtn.classList.add('armed');
|
|
348
|
+
sendBtn.textContent = 'Tap to stop';
|
|
349
|
+
if (stopArmTimer) clearTimeout(stopArmTimer);
|
|
350
|
+
stopArmTimer = setTimeout(function () {
|
|
351
|
+
stopArmed = false;
|
|
352
|
+
sendBtn.classList.remove('armed');
|
|
353
|
+
if (agentRunning) sendBtn.textContent = 'Stop';
|
|
354
|
+
stopArmTimer = null;
|
|
355
|
+
}, 3000);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
sendMessage();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Copy source text to the clipboard, flashing the button. Falls back to a
|
|
362
|
+
// hidden textarea + execCommand for non-secure (plain-http LAN) contexts where
|
|
363
|
+
// navigator.clipboard is unavailable.
|
|
364
|
+
function flashCopied(btn) {
|
|
365
|
+
if (!btn) return;
|
|
366
|
+
const prev = btn.textContent;
|
|
367
|
+
btn.textContent = 'Copied';
|
|
368
|
+
btn.classList.add('copied');
|
|
369
|
+
setTimeout(function () { btn.textContent = prev; btn.classList.remove('copied'); }, 1200);
|
|
370
|
+
}
|
|
371
|
+
function fallbackCopy(text, btn) {
|
|
372
|
+
try {
|
|
373
|
+
const ta = document.createElement('textarea');
|
|
374
|
+
ta.value = text;
|
|
375
|
+
ta.style.position = 'fixed';
|
|
376
|
+
ta.style.top = '0';
|
|
377
|
+
ta.style.opacity = '0';
|
|
378
|
+
document.body.appendChild(ta);
|
|
379
|
+
ta.focus(); ta.select();
|
|
380
|
+
document.execCommand('copy');
|
|
381
|
+
document.body.removeChild(ta);
|
|
382
|
+
flashCopied(btn);
|
|
383
|
+
} catch (e) {}
|
|
384
|
+
}
|
|
385
|
+
function copyText(text, btn) {
|
|
386
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
387
|
+
navigator.clipboard.writeText(text).then(function () { flashCopied(btn); },
|
|
388
|
+
function () { fallbackCopy(text, btn); });
|
|
389
|
+
} else {
|
|
390
|
+
fallbackCopy(text, btn);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// One delegated handler for every copy button (code-block headers live inside
|
|
394
|
+
// markdown HTML; bubble buttons are appended in JS). Resolve the source text
|
|
395
|
+
// from whichever container the button sits in.
|
|
396
|
+
chatLog.addEventListener('click', function (e) {
|
|
397
|
+
const btn = e.target && e.target.closest ? e.target.closest('.copy-btn') : null;
|
|
398
|
+
if (!btn) return;
|
|
399
|
+
const block = btn.closest('.code-block');
|
|
400
|
+
if (block) {
|
|
401
|
+
const code = block.querySelector('code');
|
|
402
|
+
copyText(code ? code.textContent : '', btn);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const bub = btn.closest('.bubble');
|
|
406
|
+
if (bub) copyText(bub.__copyText != null ? bub.__copyText : bub.textContent, btn);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Attach a copy button to a finished assistant bubble and stash its raw text
|
|
410
|
+
// (the pre-markdown source) so the delegated handler copies the original.
|
|
411
|
+
function attachBubbleCopy(el, text) {
|
|
412
|
+
el.__copyText = text;
|
|
413
|
+
const b = document.createElement('button');
|
|
414
|
+
b.className = 'copy-btn bubble-copy';
|
|
415
|
+
b.type = 'button';
|
|
416
|
+
b.setAttribute('aria-label', 'Copy message');
|
|
417
|
+
b.textContent = 'Copy';
|
|
418
|
+
el.appendChild(b);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// "✻ Thinking… (n lines)" collapsed-block summary.
|
|
422
|
+
function thinkingSummary(n) {
|
|
423
|
+
return '\\u273B Thinking\\u2026 (' + n + (n === 1 ? ' line' : ' lines') + ')';
|
|
424
|
+
}
|
|
425
|
+
function thinkingLineCount(text) {
|
|
426
|
+
return text ? text.split('\\n').length : 0;
|
|
427
|
+
}
|
|
428
|
+
// Build a collapsed thinking <details>. A live block keeps a spinner in the
|
|
429
|
+
// summary and stays open to further deltas; otherwise it's a finished, static one.
|
|
430
|
+
function makeThinkingEl(text, live) {
|
|
431
|
+
const d = document.createElement('details');
|
|
432
|
+
d.className = 'thinking-block';
|
|
433
|
+
const s = document.createElement('summary');
|
|
434
|
+
const lbl = document.createElement('span');
|
|
435
|
+
lbl.className = 'thinking-label';
|
|
436
|
+
lbl.textContent = thinkingSummary(thinkingLineCount(text));
|
|
437
|
+
s.appendChild(lbl);
|
|
438
|
+
if (live) {
|
|
439
|
+
const sp = document.createElement('span');
|
|
440
|
+
sp.className = 'thinking-spin spin';
|
|
441
|
+
s.appendChild(sp);
|
|
442
|
+
}
|
|
443
|
+
d.appendChild(s);
|
|
444
|
+
const body = document.createElement('div');
|
|
445
|
+
body.className = 'thinking-body';
|
|
446
|
+
body.textContent = text || '';
|
|
447
|
+
d.appendChild(body);
|
|
448
|
+
return d;
|
|
449
|
+
}
|
|
450
|
+
// Close out the live thinking block (drop its spinner) — called when the model
|
|
451
|
+
// moves on to text/tools, or the turn ends.
|
|
452
|
+
function finalizeThinking() {
|
|
453
|
+
if (currentThinking) {
|
|
454
|
+
const sp = currentThinking.querySelector('.thinking-spin');
|
|
455
|
+
if (sp) { sp.remove(); stopSpinIfIdle(); }
|
|
456
|
+
currentThinking = null;
|
|
457
|
+
}
|
|
458
|
+
thinkingText = '';
|
|
242
459
|
}
|
|
243
460
|
|
|
244
461
|
// Pull the human-readable text out of a tool result. Many tools (Read, Bash,
|
|
@@ -289,6 +506,23 @@ export function clientScript(wsUrl) {
|
|
|
289
506
|
return d;
|
|
290
507
|
}
|
|
291
508
|
|
|
509
|
+
// Dim HH:MM shown under a turn's last bubble (local time).
|
|
510
|
+
function fmtClock(ts) {
|
|
511
|
+
if (!ts) return '';
|
|
512
|
+
var d = new Date(ts);
|
|
513
|
+
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
|
|
514
|
+
return pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
515
|
+
}
|
|
516
|
+
function addTurnTime(ts, roleClass) {
|
|
517
|
+
var clock = fmtClock(ts || Date.now());
|
|
518
|
+
if (!clock) return;
|
|
519
|
+
var el = document.createElement('div');
|
|
520
|
+
el.className = 'turn-time ' + roleClass;
|
|
521
|
+
el.textContent = clock;
|
|
522
|
+
chatLog.appendChild(el);
|
|
523
|
+
scrollBottom();
|
|
524
|
+
}
|
|
525
|
+
|
|
292
526
|
// A muted, centered system note (e.g. "Context compacted").
|
|
293
527
|
function addSystemLine(text) {
|
|
294
528
|
const el = document.createElement('div');
|
|
@@ -303,13 +537,17 @@ export function clientScript(wsUrl) {
|
|
|
303
537
|
// parts (text segments + tool calls), so the layout matches the terminal's
|
|
304
538
|
// interleaving instead of one merged blob with tools dumped at the end.
|
|
305
539
|
function renderTurn(t) {
|
|
306
|
-
if (t.error) { addBubble('error', t.text); return; }
|
|
307
|
-
if (t.role === 'system') { addSystemLine(t.text); return; }
|
|
308
|
-
if (t.role === 'user') { addBubble('user', t.text); return; }
|
|
540
|
+
if (t.error) { addBubble('error', t.text); addTurnTime(t.ts, 'assistant'); return; }
|
|
541
|
+
if (t.role === 'system') { addSystemLine(t.text); addTurnTime(t.ts, 'system'); return; }
|
|
542
|
+
if (t.role === 'user') { addBubble('user', t.text); addTurnTime(t.ts, 'user'); return; }
|
|
309
543
|
for (const p of (t.parts || [])) {
|
|
310
544
|
if (p.kind === 'text') { if (p.text) addBubble('assistant', p.text); }
|
|
545
|
+
else if (p.kind === 'thinking') {
|
|
546
|
+
if (p.text) { chatLog.appendChild(makeThinkingEl(p.text, false)); scrollBottom(); }
|
|
547
|
+
}
|
|
311
548
|
else renderToolPart(p);
|
|
312
549
|
}
|
|
550
|
+
addTurnTime(t.ts, 'assistant');
|
|
313
551
|
}
|
|
314
552
|
|
|
315
553
|
// Render the in-progress assistant turn from a snapshot, preserving order. The
|
|
@@ -335,6 +573,18 @@ export function clientScript(wsUrl) {
|
|
|
335
573
|
} else if (p.text) {
|
|
336
574
|
addBubble('assistant', p.text);
|
|
337
575
|
}
|
|
576
|
+
} else if (p.kind === 'thinking') {
|
|
577
|
+
if (last && !p.done) {
|
|
578
|
+
// Reconnected mid-thinking: keep the block live so further deltas flow in.
|
|
579
|
+
currentThinking = makeThinkingEl(p.text || '', true);
|
|
580
|
+
thinkingText = p.text || '';
|
|
581
|
+
chatLog.appendChild(currentThinking);
|
|
582
|
+
startSpin();
|
|
583
|
+
scrollBottom();
|
|
584
|
+
} else if (p.text) {
|
|
585
|
+
chatLog.appendChild(makeThinkingEl(p.text, false));
|
|
586
|
+
scrollBottom();
|
|
587
|
+
}
|
|
338
588
|
} else {
|
|
339
589
|
renderToolPart(p);
|
|
340
590
|
}
|
|
@@ -347,6 +597,37 @@ export function clientScript(wsUrl) {
|
|
|
347
597
|
t.textContent = message;
|
|
348
598
|
document.body.appendChild(t);
|
|
349
599
|
setTimeout(function () { t.remove(); }, 4000);
|
|
600
|
+
recordNotif(message, level);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Keep the last ~20 toasts (newest first) for the bell's history dropdown.
|
|
604
|
+
function recordNotif(message, level) {
|
|
605
|
+
notifHistory.unshift({message: message, level: level || 'info', ts: Date.now()});
|
|
606
|
+
if (notifHistory.length > 20) notifHistory.pop();
|
|
607
|
+
renderNotifList();
|
|
608
|
+
}
|
|
609
|
+
function renderNotifList() {
|
|
610
|
+
if (!notifHistory.length) {
|
|
611
|
+
notifList.innerHTML = '<div id="notif-empty">No notifications yet.</div>';
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
var html = '';
|
|
615
|
+
for (var i = 0; i < notifHistory.length; i++) {
|
|
616
|
+
var n = notifHistory[i];
|
|
617
|
+
html += '<div class="notif-item ' + escHtml(n.level) + '">'
|
|
618
|
+
+ '<span class="notif-dot"></span>'
|
|
619
|
+
+ '<span class="notif-msg">' + escHtml(n.message) + '</span>'
|
|
620
|
+
+ '<span class="notif-time">' + fmtClock(n.ts) + '</span></div>';
|
|
621
|
+
}
|
|
622
|
+
notifList.innerHTML = html;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
let notifOpen = false;
|
|
626
|
+
function setNotifOpen(open) {
|
|
627
|
+
notifOpen = open;
|
|
628
|
+
notifPanel.classList.toggle('open', open);
|
|
629
|
+
notifPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
|
|
630
|
+
if (open) { renderNotifList(); updateNotifToggle(); }
|
|
350
631
|
}
|
|
351
632
|
|
|
352
633
|
const bell = document.getElementById('bell');
|
|
@@ -363,6 +644,12 @@ export function clientScript(wsUrl) {
|
|
|
363
644
|
var on = notifyEnabled();
|
|
364
645
|
bell.textContent = on ? '\\u25C9' : '\\u25EF';
|
|
365
646
|
bell.classList.toggle('on', on);
|
|
647
|
+
updateNotifToggle();
|
|
648
|
+
}
|
|
649
|
+
function updateNotifToggle() {
|
|
650
|
+
var on = notifyEnabled();
|
|
651
|
+
notifToggle.textContent = on ? 'On' : 'Enable';
|
|
652
|
+
notifToggle.classList.toggle('on', on);
|
|
366
653
|
}
|
|
367
654
|
|
|
368
655
|
// Why notifications can't be enabled here, or null if they can.
|
|
@@ -376,7 +663,17 @@ export function clientScript(wsUrl) {
|
|
|
376
663
|
return null;
|
|
377
664
|
}
|
|
378
665
|
|
|
379
|
-
bell
|
|
666
|
+
// The bell opens the notification dropdown (history + push toggle); the push
|
|
667
|
+
// enable/disable lives on the toggle inside it.
|
|
668
|
+
bell.addEventListener('click', function (e) {
|
|
669
|
+
e.stopPropagation();
|
|
670
|
+
setNotifOpen(!notifOpen);
|
|
671
|
+
});
|
|
672
|
+
document.addEventListener('click', function (e) {
|
|
673
|
+
if (notifOpen && !notifPanel.contains(e.target) && e.target !== bell) setNotifOpen(false);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
function togglePush() {
|
|
380
677
|
// Turning OFF always works regardless of environment.
|
|
381
678
|
if (localStorage.getItem(NOTIFY_KEY) === '1') {
|
|
382
679
|
localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
|
|
@@ -400,7 +697,8 @@ export function clientScript(wsUrl) {
|
|
|
400
697
|
updateBell();
|
|
401
698
|
});
|
|
402
699
|
});
|
|
403
|
-
}
|
|
700
|
+
}
|
|
701
|
+
notifToggle.addEventListener('click', togglePush);
|
|
404
702
|
|
|
405
703
|
// VAPID public key (base64url) -> Uint8Array for applicationServerKey.
|
|
406
704
|
function urlB64ToUint8Array(base64) {
|
|
@@ -453,7 +751,7 @@ export function clientScript(wsUrl) {
|
|
|
453
751
|
promptRec.style.display = 'none';
|
|
454
752
|
activeRecommended2 = '';
|
|
455
753
|
if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
|
|
456
|
-
|
|
754
|
+
refreshComposer();
|
|
457
755
|
}
|
|
458
756
|
|
|
459
757
|
function makeBtn(label, cls, onClick) {
|
|
@@ -549,7 +847,7 @@ export function clientScript(wsUrl) {
|
|
|
549
847
|
promptInput.focus();
|
|
550
848
|
}
|
|
551
849
|
promptCard.style.display = 'block';
|
|
552
|
-
|
|
850
|
+
refreshComposer();
|
|
553
851
|
}
|
|
554
852
|
|
|
555
853
|
function handleMsg(msg) {
|
|
@@ -561,6 +859,7 @@ export function clientScript(wsUrl) {
|
|
|
561
859
|
chatLog.innerHTML = '';
|
|
562
860
|
closePrompt();
|
|
563
861
|
hideThinking();
|
|
862
|
+
currentThinking = null; thinkingText = '';
|
|
564
863
|
currentBubble = null; streamText = '';
|
|
565
864
|
for (const k in toolCallMap) delete toolCallMap[k];
|
|
566
865
|
// Per-turn try/catch: one malformed turn must never abort the rebuild
|
|
@@ -568,10 +867,14 @@ export function clientScript(wsUrl) {
|
|
|
568
867
|
for (const t of (msg.turns || [])) { try { renderTurn(t); } catch (e) {} }
|
|
569
868
|
if (msg.live) { try { renderLiveTurn(msg.live); } catch (e) {} }
|
|
570
869
|
taskWidgetLines = (msg.taskWidget && msg.taskWidget.length) ? msg.taskWidget : null;
|
|
870
|
+
taskWidgetData = msg.taskWidgetData || null;
|
|
571
871
|
renderWidgets();
|
|
872
|
+
setModelName(msg.model);
|
|
572
873
|
if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
|
|
874
|
+
agentRunning = !!msg.agentRunning;
|
|
875
|
+
turnHadContent = !!(msg.live && msg.live.parts && msg.live.parts.length);
|
|
573
876
|
if (msg.prompt) showPrompt(msg.prompt);
|
|
574
|
-
|
|
877
|
+
refreshComposer();
|
|
575
878
|
if (msg.agentRunning && !msg.live) showThinking();
|
|
576
879
|
break;
|
|
577
880
|
}
|
|
@@ -579,12 +882,37 @@ export function clientScript(wsUrl) {
|
|
|
579
882
|
autoScroll = true;
|
|
580
883
|
streamText = '';
|
|
581
884
|
currentBubble = null;
|
|
582
|
-
|
|
885
|
+
turnHadContent = false;
|
|
886
|
+
agentRunning = true;
|
|
887
|
+
setModelName(msg.model);
|
|
888
|
+
refreshComposer();
|
|
889
|
+
showThinking();
|
|
890
|
+
break;
|
|
891
|
+
case 'thinking_delta':
|
|
892
|
+
turnHadContent = true;
|
|
893
|
+
hideThinking(); // drop the spinner-only bubble
|
|
894
|
+
if (!currentThinking) {
|
|
895
|
+
currentThinking = makeThinkingEl('', true);
|
|
896
|
+
chatLog.appendChild(currentThinking);
|
|
897
|
+
thinkingText = '';
|
|
898
|
+
startSpin();
|
|
899
|
+
}
|
|
900
|
+
thinkingText += msg.delta;
|
|
901
|
+
currentThinking.querySelector('.thinking-body').textContent = thinkingText;
|
|
902
|
+
currentThinking.querySelector('.thinking-label').textContent =
|
|
903
|
+
thinkingSummary(thinkingLineCount(thinkingText));
|
|
904
|
+
scrollBottom();
|
|
905
|
+
break;
|
|
906
|
+
case 'thinking_end':
|
|
907
|
+
finalizeThinking();
|
|
908
|
+
// Model moves to text/tool next — show the spinner meanwhile.
|
|
583
909
|
showThinking();
|
|
584
910
|
break;
|
|
585
911
|
case 'text_delta':
|
|
912
|
+
turnHadContent = true;
|
|
586
913
|
if (!currentBubble) {
|
|
587
914
|
hideThinking();
|
|
915
|
+
finalizeThinking();
|
|
588
916
|
currentBubble = document.createElement('div');
|
|
589
917
|
currentBubble.className = 'bubble assistant';
|
|
590
918
|
const cursor = document.createElement('span');
|
|
@@ -604,7 +932,7 @@ export function clientScript(wsUrl) {
|
|
|
604
932
|
if (currentBubble) {
|
|
605
933
|
const c = currentBubble.querySelector('.cursor');
|
|
606
934
|
if (c) c.remove();
|
|
607
|
-
if (streamText) setContent(currentBubble, streamText);
|
|
935
|
+
if (streamText) { setContent(currentBubble, streamText); attachBubbleCopy(currentBubble, streamText); }
|
|
608
936
|
// Close this message's bubble so the next text segment (after a tool or
|
|
609
937
|
// the next message) starts a fresh bubble — matching the terminal.
|
|
610
938
|
currentBubble = null;
|
|
@@ -613,7 +941,9 @@ export function clientScript(wsUrl) {
|
|
|
613
941
|
}
|
|
614
942
|
break;
|
|
615
943
|
case 'tool_start': {
|
|
944
|
+
turnHadContent = true;
|
|
616
945
|
hideThinking();
|
|
946
|
+
finalizeThinking();
|
|
617
947
|
const d = addToolCall(msg.toolName, msg.args, false);
|
|
618
948
|
const sp = document.createElement('span');
|
|
619
949
|
sp.className = 'tool-spin spin';
|
|
@@ -642,28 +972,39 @@ export function clientScript(wsUrl) {
|
|
|
642
972
|
}
|
|
643
973
|
case 'user_message':
|
|
644
974
|
addBubble('user', msg.text);
|
|
975
|
+
addTurnTime(Date.now(), 'user');
|
|
645
976
|
break;
|
|
646
977
|
case 'system_note':
|
|
647
978
|
addSystemLine(msg.text);
|
|
979
|
+
addTurnTime(Date.now(), 'system');
|
|
648
980
|
break;
|
|
649
981
|
case 'agent_error':
|
|
650
982
|
hideThinking();
|
|
983
|
+
finalizeThinking();
|
|
651
984
|
if (currentBubble) {
|
|
652
985
|
const c = currentBubble.querySelector('.cursor');
|
|
653
986
|
if (c) c.remove();
|
|
654
|
-
if (streamText) setContent(currentBubble, streamText);
|
|
987
|
+
if (streamText) { setContent(currentBubble, streamText); attachBubbleCopy(currentBubble, streamText); }
|
|
655
988
|
currentBubble = null;
|
|
656
989
|
streamText = '';
|
|
657
990
|
stopSpinIfIdle();
|
|
658
991
|
}
|
|
659
992
|
addBubble('error', msg.message || 'Error');
|
|
660
|
-
|
|
993
|
+
if (turnHadContent) addTurnTime(Date.now(), 'assistant');
|
|
994
|
+
turnHadContent = false;
|
|
995
|
+
agentRunning = false;
|
|
996
|
+
refreshComposer();
|
|
661
997
|
break;
|
|
662
998
|
case 'agent_end':
|
|
663
999
|
hideThinking();
|
|
1000
|
+
finalizeThinking();
|
|
664
1001
|
currentBubble = null;
|
|
665
1002
|
streamText = '';
|
|
666
|
-
|
|
1003
|
+
if (turnHadContent) addTurnTime(Date.now(), 'assistant');
|
|
1004
|
+
turnHadContent = false;
|
|
1005
|
+
agentRunning = false;
|
|
1006
|
+
setModelName(msg.model);
|
|
1007
|
+
refreshComposer();
|
|
667
1008
|
setContextBar(msg.contextUsage);
|
|
668
1009
|
break;
|
|
669
1010
|
case 'context':
|
|
@@ -678,6 +1019,7 @@ export function clientScript(wsUrl) {
|
|
|
678
1019
|
break;
|
|
679
1020
|
case 'widget':
|
|
680
1021
|
taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
|
|
1022
|
+
taskWidgetData = msg.data || null;
|
|
681
1023
|
renderWidgets();
|
|
682
1024
|
break;
|
|
683
1025
|
case 'notify':
|
|
@@ -691,9 +1033,14 @@ export function clientScript(wsUrl) {
|
|
|
691
1033
|
// A new session started — wipe the previous session's transcript.
|
|
692
1034
|
chatLog.innerHTML = '';
|
|
693
1035
|
hideThinking();
|
|
1036
|
+
finalizeThinking();
|
|
694
1037
|
currentBubble = null; streamText = '';
|
|
1038
|
+
turnHadContent = false;
|
|
695
1039
|
closePrompt();
|
|
1040
|
+
agentRunning = false;
|
|
1041
|
+
refreshComposer();
|
|
696
1042
|
taskWidgetLines = null;
|
|
1043
|
+
taskWidgetData = null;
|
|
697
1044
|
renderWidgets();
|
|
698
1045
|
contextFill.style.width = '0%';
|
|
699
1046
|
break;
|
|
@@ -712,11 +1059,12 @@ export function clientScript(wsUrl) {
|
|
|
712
1059
|
// The server records the message via addUserTurn and broadcasts a
|
|
713
1060
|
// user_message back to every client (us included), which renders the
|
|
714
1061
|
// bubble. Don't render it here too, or the sender sees it twice.
|
|
715
|
-
|
|
716
|
-
|
|
1062
|
+
// Mid-run the message steers the live turn (no state change here); when
|
|
1063
|
+
// idle, optimistically show the spinner until agent_start lands.
|
|
1064
|
+
if (!agentRunning) showThinking();
|
|
717
1065
|
}
|
|
718
1066
|
|
|
719
|
-
sendBtn.addEventListener('click',
|
|
1067
|
+
sendBtn.addEventListener('click', onSendClick);
|
|
720
1068
|
inputEl.addEventListener('keydown', (e) => {
|
|
721
1069
|
if (cmdActive.length > 0) {
|
|
722
1070
|
if (e.key === 'ArrowDown') { e.preventDefault(); cmdIndex = Math.min(cmdIndex + 1, cmdActive.length - 1); renderSuggestions(); return; }
|
|
@@ -746,7 +1094,8 @@ export function clientScript(wsUrl) {
|
|
|
746
1094
|
if (reconnectAnim) { clearInterval(reconnectAnim); reconnectAnim = null; }
|
|
747
1095
|
reconnectOverlay.classList.remove('visible');
|
|
748
1096
|
reconnectDelay = 1000;
|
|
749
|
-
|
|
1097
|
+
connected = true;
|
|
1098
|
+
refreshComposer();
|
|
750
1099
|
// Self-heal on every (re)connect, not just page load: if the server
|
|
751
1100
|
// restarted it may have lost (or be rehydrating) our subscription, and
|
|
752
1101
|
// browsers can rotate it. Re-registering here covers reconnects the
|
|
@@ -760,7 +1109,8 @@ export function clientScript(wsUrl) {
|
|
|
760
1109
|
});
|
|
761
1110
|
sock.addEventListener('close', () => {
|
|
762
1111
|
if (ws !== sock) return;
|
|
763
|
-
|
|
1112
|
+
connected = false;
|
|
1113
|
+
refreshComposer();
|
|
764
1114
|
reconnectOverlay.classList.add('visible');
|
|
765
1115
|
// Animate the same braille spinner used elsewhere, with a live countdown.
|
|
766
1116
|
const until = Date.now() + reconnectDelay;
|