@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.
@@ -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
- function setEnabled(on) {
239
- const allow = on && activePromptId === null;
240
- inputEl.disabled = !allow;
241
- sendBtn.disabled = !allow;
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.addEventListener('click', function () {
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
- setEnabled(true);
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
- setEnabled(false);
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
- setEnabled(!msg.agentRunning && !msg.prompt);
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
- setEnabled(false);
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
- setEnabled(true);
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
- setEnabled(true);
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
- setEnabled(false);
716
- showThinking();
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', sendMessage);
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
- setEnabled(true);
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
- setEnabled(false);
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;