@mjasnikovs/pi-task 0.17.21 → 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 +11 -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 +19 -5
- package/dist/remote/session-state.js +55 -9
- package/dist/remote/ui-highlight.d.ts +2 -0
- package/dist/remote/ui-highlight.js +119 -0
- package/dist/remote/ui-render.d.ts +2 -0
- package/dist/remote/ui-render.js +173 -0
- package/dist/remote/ui-script.js +425 -146
- package/dist/remote/ui-styles.d.ts +1 -1
- package/dist/remote/ui-styles.js +175 -8
- package/dist/remote/ui-tools.d.ts +2 -0
- package/dist/remote/ui-tools.js +113 -0
- package/dist/remote/ui.js +19 -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,125 +104,33 @@ 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;
|
|
56
123
|
let reconnectTimer = null;
|
|
57
124
|
let ws = null;
|
|
58
125
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
'this','throw','try','typeof','var','void','while','with','yield','async','await',
|
|
65
|
-
'type','interface','enum','implements','abstract','as','declare','namespace',
|
|
66
|
-
'readonly','undefined','null','true','false','override','satisfies']);
|
|
67
|
-
|
|
68
|
-
function escHtml(s) {
|
|
69
|
-
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function syntaxHighlight(code, lang) {
|
|
73
|
-
if (!JS_LANGS.has((lang || '').toLowerCase())) return escHtml(code);
|
|
74
|
-
let r = '', i = 0;
|
|
75
|
-
while (i < code.length) {
|
|
76
|
-
const ch = code[i];
|
|
77
|
-
// Template literal
|
|
78
|
-
if (ch === BT) {
|
|
79
|
-
let j = i + 1;
|
|
80
|
-
while (j < code.length) {
|
|
81
|
-
if (code[j] === '\\\\') { j += 2; continue; }
|
|
82
|
-
if (code[j] === BT) { j++; break; }
|
|
83
|
-
j++;
|
|
84
|
-
}
|
|
85
|
-
r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
|
|
86
|
-
i = j; continue;
|
|
87
|
-
}
|
|
88
|
-
// Single / double quoted string
|
|
89
|
-
if (ch === '"' || ch === "'") {
|
|
90
|
-
let j = i + 1;
|
|
91
|
-
while (j < code.length) {
|
|
92
|
-
if (code[j] === '\\\\') { j += 2; continue; }
|
|
93
|
-
if (code[j] === ch || code[j] === '\\n') break;
|
|
94
|
-
j++;
|
|
95
|
-
}
|
|
96
|
-
if (code[j] === ch) j++;
|
|
97
|
-
r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
|
|
98
|
-
i = j; continue;
|
|
99
|
-
}
|
|
100
|
-
// Line comment
|
|
101
|
-
if (ch === '/' && code[i + 1] === '/') {
|
|
102
|
-
let j = i + 2;
|
|
103
|
-
while (j < code.length && code[j] !== '\\n') j++;
|
|
104
|
-
r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
|
|
105
|
-
i = j; continue;
|
|
106
|
-
}
|
|
107
|
-
// Block comment
|
|
108
|
-
if (ch === '/' && code[i + 1] === '*') {
|
|
109
|
-
let j = i + 2;
|
|
110
|
-
while (j < code.length && !(code[j] === '*' && code[j + 1] === '/')) j++;
|
|
111
|
-
j += 2;
|
|
112
|
-
r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
|
|
113
|
-
i = j; continue;
|
|
114
|
-
}
|
|
115
|
-
// Number
|
|
116
|
-
if (ch >= '0' && ch <= '9') {
|
|
117
|
-
let j = i;
|
|
118
|
-
if (code[i] === '0' && /[xXoObB]/.test(code[i + 1] || '')) {
|
|
119
|
-
j += 2; while (j < code.length && /[0-9a-fA-F_]/.test(code[j])) j++;
|
|
120
|
-
} else {
|
|
121
|
-
while (j < code.length && (code[j] >= '0' && code[j] <= '9' || code[j] === '_')) j++;
|
|
122
|
-
if (code[j] === '.') { j++; while (j < code.length && code[j] >= '0' && code[j] <= '9') j++; }
|
|
123
|
-
if (code[j] === 'e' || code[j] === 'E') {
|
|
124
|
-
j++; if (code[j] === '+' || code[j] === '-') j++;
|
|
125
|
-
while (j < code.length && code[j] >= '0' && code[j] <= '9') j++;
|
|
126
|
-
}
|
|
127
|
-
if (code[j] === 'n') j++;
|
|
128
|
-
}
|
|
129
|
-
r += '<span class="hl-num">' + escHtml(code.slice(i, j)) + '</span>';
|
|
130
|
-
i = j; continue;
|
|
131
|
-
}
|
|
132
|
-
// Identifier / keyword / function call
|
|
133
|
-
if (/[a-zA-Z_$]/.test(ch)) {
|
|
134
|
-
let j = i;
|
|
135
|
-
while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
|
|
136
|
-
const word = code.slice(i, j);
|
|
137
|
-
if (JS_KW.has(word)) {
|
|
138
|
-
r += '<span class="hl-kw">' + word + '</span>';
|
|
139
|
-
} else if (code[j] === '(') {
|
|
140
|
-
r += '<span class="hl-fn">' + escHtml(word) + '</span>';
|
|
141
|
-
} else {
|
|
142
|
-
r += escHtml(word);
|
|
143
|
-
}
|
|
144
|
-
i = j; continue;
|
|
145
|
-
}
|
|
146
|
-
r += escHtml(ch); i++;
|
|
147
|
-
}
|
|
148
|
-
return r;
|
|
149
|
-
}
|
|
150
|
-
|
|
126
|
+
// Render assistant text as markdown (headers, lists, tables, emphasis, links)
|
|
127
|
+
// with fenced code blocks syntax-highlighted. renderMarkdown/syntaxHighlight are
|
|
128
|
+
// the pure functions from ui-render.ts / ui-highlight.ts, concatenated into this
|
|
129
|
+
// same <script> ahead of clientScript. innerHTML is safe because renderMarkdown
|
|
130
|
+
// escapes every span of literal text and only emits a fixed set of known tags.
|
|
151
131
|
function setContent(el, text) {
|
|
152
|
-
el.
|
|
153
|
-
|
|
154
|
-
const re = new RegExp(BT3 + '([^\\n' + BT + ']*)\\n([\\s\\S]*?)' + BT3, 'g');
|
|
155
|
-
let last = 0, m;
|
|
156
|
-
while ((m = re.exec(text)) !== null) {
|
|
157
|
-
if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
|
|
158
|
-
const lang = m[1].trim();
|
|
159
|
-
const code = m[2];
|
|
160
|
-
const wrap = document.createElement('div');
|
|
161
|
-
wrap.className = 'code-block';
|
|
162
|
-
if (lang) { const lb = document.createElement('div'); lb.className = 'code-lang'; lb.textContent = lang; wrap.appendChild(lb); }
|
|
163
|
-
const pre = document.createElement('pre');
|
|
164
|
-
const codeEl = document.createElement('code');
|
|
165
|
-
codeEl.innerHTML = syntaxHighlight(code, lang);
|
|
166
|
-
pre.appendChild(codeEl);
|
|
167
|
-
wrap.appendChild(pre);
|
|
168
|
-
el.appendChild(wrap);
|
|
169
|
-
last = m.index + m[0].length;
|
|
170
|
-
}
|
|
171
|
-
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
|
|
132
|
+
el.classList.add('md');
|
|
133
|
+
el.innerHTML = renderMarkdown(text);
|
|
172
134
|
}
|
|
173
135
|
|
|
174
136
|
const COMMANDS = [
|
|
@@ -247,7 +209,10 @@ export function clientScript(wsUrl) {
|
|
|
247
209
|
function addBubble(role, text) {
|
|
248
210
|
const el = document.createElement('div');
|
|
249
211
|
el.className = 'bubble ' + role;
|
|
250
|
-
|
|
212
|
+
// Only assistant text is markdown-rendered; user/error bubbles stay plain so a
|
|
213
|
+
// user's literal *asterisks* or backticks show verbatim.
|
|
214
|
+
if (role === 'assistant') { setContent(el, text); attachBubbleCopy(el, text); }
|
|
215
|
+
else el.textContent = text;
|
|
251
216
|
chatLog.appendChild(el);
|
|
252
217
|
scrollBottom();
|
|
253
218
|
return el;
|
|
@@ -293,24 +258,204 @@ export function clientScript(wsUrl) {
|
|
|
293
258
|
stopSpinIfIdle();
|
|
294
259
|
}
|
|
295
260
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
261
|
+
// A finished/running tool card. args is the RAW args (object or JSON string) —
|
|
262
|
+
// toolSummary/toolBadge/toolDiffHtml (ui-tools.ts) turn it into a readable
|
|
263
|
+
// one-line summary, a +N −M badge, and (for edit/write) an expandable line diff.
|
|
264
|
+
function addToolCall(toolName, args, isError) {
|
|
300
265
|
const d = document.createElement('details');
|
|
301
266
|
d.className = 'tool-call' + (isError ? ' error' : '');
|
|
302
267
|
const s = document.createElement('summary');
|
|
303
|
-
|
|
268
|
+
const label = document.createElement('span');
|
|
269
|
+
label.className = 'tool-label';
|
|
270
|
+
label.textContent = toolSummary(toolName, args);
|
|
271
|
+
s.title = label.textContent;
|
|
272
|
+
s.appendChild(label);
|
|
273
|
+
const badge = toolBadge(toolName, args);
|
|
274
|
+
if (badge && (badge.added || badge.removed)) {
|
|
275
|
+
const b = document.createElement('span');
|
|
276
|
+
b.className = 'tool-badge';
|
|
277
|
+
b.textContent = '+' + badge.added + ' \\u2212' + badge.removed;
|
|
278
|
+
s.appendChild(b);
|
|
279
|
+
}
|
|
304
280
|
d.appendChild(s);
|
|
281
|
+
const diffHtml = toolDiffHtml(toolName, args);
|
|
282
|
+
if (diffHtml) {
|
|
283
|
+
const dv = document.createElement('div');
|
|
284
|
+
dv.className = 'tool-diff';
|
|
285
|
+
dv.innerHTML = diffHtml;
|
|
286
|
+
d.appendChild(dv);
|
|
287
|
+
}
|
|
305
288
|
chatLog.appendChild(d);
|
|
306
289
|
scrollBottom();
|
|
307
290
|
return d;
|
|
308
291
|
}
|
|
309
292
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
293
|
+
// Dim "· 1.2s" appended to a finished tool summary.
|
|
294
|
+
function appendElapsed(d, elapsedMs) {
|
|
295
|
+
const txt = fmtElapsed(elapsedMs);
|
|
296
|
+
if (!txt) return;
|
|
297
|
+
const s = d.querySelector('summary');
|
|
298
|
+
if (!s || s.querySelector('.tool-elapsed')) return;
|
|
299
|
+
const e = document.createElement('span');
|
|
300
|
+
e.className = 'tool-elapsed';
|
|
301
|
+
e.textContent = '\\u00B7 ' + txt;
|
|
302
|
+
s.appendChild(e);
|
|
303
|
+
}
|
|
304
|
+
|
|
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 = '';
|
|
314
459
|
}
|
|
315
460
|
|
|
316
461
|
// Pull the human-readable text out of a tool result. Many tools (Read, Bash,
|
|
@@ -345,9 +490,9 @@ export function clientScript(wsUrl) {
|
|
|
345
490
|
|
|
346
491
|
// Render one tool part from the ordered parts list (running or finished).
|
|
347
492
|
function renderToolPart(p) {
|
|
348
|
-
const
|
|
349
|
-
const d = addToolCall(p.toolName, argsStr, p.isError);
|
|
493
|
+
const d = addToolCall(p.toolName, p.args, p.isError);
|
|
350
494
|
if (p.done) {
|
|
495
|
+
appendElapsed(d, p.elapsedMs);
|
|
351
496
|
const pre = document.createElement('pre');
|
|
352
497
|
pre.textContent = toolResultText(p.result);
|
|
353
498
|
d.appendChild(pre);
|
|
@@ -361,6 +506,23 @@ export function clientScript(wsUrl) {
|
|
|
361
506
|
return d;
|
|
362
507
|
}
|
|
363
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
|
+
|
|
364
526
|
// A muted, centered system note (e.g. "Context compacted").
|
|
365
527
|
function addSystemLine(text) {
|
|
366
528
|
const el = document.createElement('div');
|
|
@@ -375,13 +537,17 @@ export function clientScript(wsUrl) {
|
|
|
375
537
|
// parts (text segments + tool calls), so the layout matches the terminal's
|
|
376
538
|
// interleaving instead of one merged blob with tools dumped at the end.
|
|
377
539
|
function renderTurn(t) {
|
|
378
|
-
if (t.error) { addBubble('error', t.text); return; }
|
|
379
|
-
if (t.role === 'system') { addSystemLine(t.text); return; }
|
|
380
|
-
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; }
|
|
381
543
|
for (const p of (t.parts || [])) {
|
|
382
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
|
+
}
|
|
383
548
|
else renderToolPart(p);
|
|
384
549
|
}
|
|
550
|
+
addTurnTime(t.ts, 'assistant');
|
|
385
551
|
}
|
|
386
552
|
|
|
387
553
|
// Render the in-progress assistant turn from a snapshot, preserving order. The
|
|
@@ -407,6 +573,18 @@ export function clientScript(wsUrl) {
|
|
|
407
573
|
} else if (p.text) {
|
|
408
574
|
addBubble('assistant', p.text);
|
|
409
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
|
+
}
|
|
410
588
|
} else {
|
|
411
589
|
renderToolPart(p);
|
|
412
590
|
}
|
|
@@ -419,6 +597,37 @@ export function clientScript(wsUrl) {
|
|
|
419
597
|
t.textContent = message;
|
|
420
598
|
document.body.appendChild(t);
|
|
421
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(); }
|
|
422
631
|
}
|
|
423
632
|
|
|
424
633
|
const bell = document.getElementById('bell');
|
|
@@ -435,6 +644,12 @@ export function clientScript(wsUrl) {
|
|
|
435
644
|
var on = notifyEnabled();
|
|
436
645
|
bell.textContent = on ? '\\u25C9' : '\\u25EF';
|
|
437
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);
|
|
438
653
|
}
|
|
439
654
|
|
|
440
655
|
// Why notifications can't be enabled here, or null if they can.
|
|
@@ -448,7 +663,17 @@ export function clientScript(wsUrl) {
|
|
|
448
663
|
return null;
|
|
449
664
|
}
|
|
450
665
|
|
|
451
|
-
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() {
|
|
452
677
|
// Turning OFF always works regardless of environment.
|
|
453
678
|
if (localStorage.getItem(NOTIFY_KEY) === '1') {
|
|
454
679
|
localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
|
|
@@ -472,7 +697,8 @@ export function clientScript(wsUrl) {
|
|
|
472
697
|
updateBell();
|
|
473
698
|
});
|
|
474
699
|
});
|
|
475
|
-
}
|
|
700
|
+
}
|
|
701
|
+
notifToggle.addEventListener('click', togglePush);
|
|
476
702
|
|
|
477
703
|
// VAPID public key (base64url) -> Uint8Array for applicationServerKey.
|
|
478
704
|
function urlB64ToUint8Array(base64) {
|
|
@@ -525,7 +751,7 @@ export function clientScript(wsUrl) {
|
|
|
525
751
|
promptRec.style.display = 'none';
|
|
526
752
|
activeRecommended2 = '';
|
|
527
753
|
if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
|
|
528
|
-
|
|
754
|
+
refreshComposer();
|
|
529
755
|
}
|
|
530
756
|
|
|
531
757
|
function makeBtn(label, cls, onClick) {
|
|
@@ -604,8 +830,9 @@ export function clientScript(wsUrl) {
|
|
|
604
830
|
activeRecommended = msg.recommended || '';
|
|
605
831
|
activeRecommended2 = msg.recommended2 || '';
|
|
606
832
|
if (msg.recommended) {
|
|
607
|
-
// Mode A: recommendation(s) present.
|
|
608
|
-
|
|
833
|
+
// Mode A: recommendation(s) present. Render markdown in the panel so a
|
|
834
|
+
// recommendation with code/emphasis reads the same as an assistant bubble.
|
|
835
|
+
setContent(promptRecText, msg.recommended);
|
|
609
836
|
showRecommendation();
|
|
610
837
|
} else {
|
|
611
838
|
// Mode B: no recommendation — the user must type an answer (or skip).
|
|
@@ -620,7 +847,7 @@ export function clientScript(wsUrl) {
|
|
|
620
847
|
promptInput.focus();
|
|
621
848
|
}
|
|
622
849
|
promptCard.style.display = 'block';
|
|
623
|
-
|
|
850
|
+
refreshComposer();
|
|
624
851
|
}
|
|
625
852
|
|
|
626
853
|
function handleMsg(msg) {
|
|
@@ -632,6 +859,7 @@ export function clientScript(wsUrl) {
|
|
|
632
859
|
chatLog.innerHTML = '';
|
|
633
860
|
closePrompt();
|
|
634
861
|
hideThinking();
|
|
862
|
+
currentThinking = null; thinkingText = '';
|
|
635
863
|
currentBubble = null; streamText = '';
|
|
636
864
|
for (const k in toolCallMap) delete toolCallMap[k];
|
|
637
865
|
// Per-turn try/catch: one malformed turn must never abort the rebuild
|
|
@@ -639,10 +867,14 @@ export function clientScript(wsUrl) {
|
|
|
639
867
|
for (const t of (msg.turns || [])) { try { renderTurn(t); } catch (e) {} }
|
|
640
868
|
if (msg.live) { try { renderLiveTurn(msg.live); } catch (e) {} }
|
|
641
869
|
taskWidgetLines = (msg.taskWidget && msg.taskWidget.length) ? msg.taskWidget : null;
|
|
870
|
+
taskWidgetData = msg.taskWidgetData || null;
|
|
642
871
|
renderWidgets();
|
|
872
|
+
setModelName(msg.model);
|
|
643
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);
|
|
644
876
|
if (msg.prompt) showPrompt(msg.prompt);
|
|
645
|
-
|
|
877
|
+
refreshComposer();
|
|
646
878
|
if (msg.agentRunning && !msg.live) showThinking();
|
|
647
879
|
break;
|
|
648
880
|
}
|
|
@@ -650,12 +882,37 @@ export function clientScript(wsUrl) {
|
|
|
650
882
|
autoScroll = true;
|
|
651
883
|
streamText = '';
|
|
652
884
|
currentBubble = null;
|
|
653
|
-
|
|
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.
|
|
654
909
|
showThinking();
|
|
655
910
|
break;
|
|
656
911
|
case 'text_delta':
|
|
912
|
+
turnHadContent = true;
|
|
657
913
|
if (!currentBubble) {
|
|
658
914
|
hideThinking();
|
|
915
|
+
finalizeThinking();
|
|
659
916
|
currentBubble = document.createElement('div');
|
|
660
917
|
currentBubble.className = 'bubble assistant';
|
|
661
918
|
const cursor = document.createElement('span');
|
|
@@ -675,7 +932,7 @@ export function clientScript(wsUrl) {
|
|
|
675
932
|
if (currentBubble) {
|
|
676
933
|
const c = currentBubble.querySelector('.cursor');
|
|
677
934
|
if (c) c.remove();
|
|
678
|
-
if (streamText) setContent(currentBubble, streamText);
|
|
935
|
+
if (streamText) { setContent(currentBubble, streamText); attachBubbleCopy(currentBubble, streamText); }
|
|
679
936
|
// Close this message's bubble so the next text segment (after a tool or
|
|
680
937
|
// the next message) starts a fresh bubble — matching the terminal.
|
|
681
938
|
currentBubble = null;
|
|
@@ -684,9 +941,10 @@ export function clientScript(wsUrl) {
|
|
|
684
941
|
}
|
|
685
942
|
break;
|
|
686
943
|
case 'tool_start': {
|
|
944
|
+
turnHadContent = true;
|
|
687
945
|
hideThinking();
|
|
688
|
-
|
|
689
|
-
const d = addToolCall(msg.toolName,
|
|
946
|
+
finalizeThinking();
|
|
947
|
+
const d = addToolCall(msg.toolName, msg.args, false);
|
|
690
948
|
const sp = document.createElement('span');
|
|
691
949
|
sp.className = 'tool-spin spin';
|
|
692
950
|
d.querySelector('summary').appendChild(sp);
|
|
@@ -700,6 +958,7 @@ export function clientScript(wsUrl) {
|
|
|
700
958
|
const sp = d.querySelector('.tool-spin');
|
|
701
959
|
if (sp) { sp.remove(); stopSpinIfIdle(); }
|
|
702
960
|
if (msg.isError) d.classList.add('error');
|
|
961
|
+
appendElapsed(d, msg.elapsedMs);
|
|
703
962
|
const pre = document.createElement('pre');
|
|
704
963
|
pre.textContent = toolResultText(msg.result);
|
|
705
964
|
d.appendChild(pre);
|
|
@@ -713,28 +972,39 @@ export function clientScript(wsUrl) {
|
|
|
713
972
|
}
|
|
714
973
|
case 'user_message':
|
|
715
974
|
addBubble('user', msg.text);
|
|
975
|
+
addTurnTime(Date.now(), 'user');
|
|
716
976
|
break;
|
|
717
977
|
case 'system_note':
|
|
718
978
|
addSystemLine(msg.text);
|
|
979
|
+
addTurnTime(Date.now(), 'system');
|
|
719
980
|
break;
|
|
720
981
|
case 'agent_error':
|
|
721
982
|
hideThinking();
|
|
983
|
+
finalizeThinking();
|
|
722
984
|
if (currentBubble) {
|
|
723
985
|
const c = currentBubble.querySelector('.cursor');
|
|
724
986
|
if (c) c.remove();
|
|
725
|
-
if (streamText) setContent(currentBubble, streamText);
|
|
987
|
+
if (streamText) { setContent(currentBubble, streamText); attachBubbleCopy(currentBubble, streamText); }
|
|
726
988
|
currentBubble = null;
|
|
727
989
|
streamText = '';
|
|
728
990
|
stopSpinIfIdle();
|
|
729
991
|
}
|
|
730
992
|
addBubble('error', msg.message || 'Error');
|
|
731
|
-
|
|
993
|
+
if (turnHadContent) addTurnTime(Date.now(), 'assistant');
|
|
994
|
+
turnHadContent = false;
|
|
995
|
+
agentRunning = false;
|
|
996
|
+
refreshComposer();
|
|
732
997
|
break;
|
|
733
998
|
case 'agent_end':
|
|
734
999
|
hideThinking();
|
|
1000
|
+
finalizeThinking();
|
|
735
1001
|
currentBubble = null;
|
|
736
1002
|
streamText = '';
|
|
737
|
-
|
|
1003
|
+
if (turnHadContent) addTurnTime(Date.now(), 'assistant');
|
|
1004
|
+
turnHadContent = false;
|
|
1005
|
+
agentRunning = false;
|
|
1006
|
+
setModelName(msg.model);
|
|
1007
|
+
refreshComposer();
|
|
738
1008
|
setContextBar(msg.contextUsage);
|
|
739
1009
|
break;
|
|
740
1010
|
case 'context':
|
|
@@ -749,6 +1019,7 @@ export function clientScript(wsUrl) {
|
|
|
749
1019
|
break;
|
|
750
1020
|
case 'widget':
|
|
751
1021
|
taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
|
|
1022
|
+
taskWidgetData = msg.data || null;
|
|
752
1023
|
renderWidgets();
|
|
753
1024
|
break;
|
|
754
1025
|
case 'notify':
|
|
@@ -762,9 +1033,14 @@ export function clientScript(wsUrl) {
|
|
|
762
1033
|
// A new session started — wipe the previous session's transcript.
|
|
763
1034
|
chatLog.innerHTML = '';
|
|
764
1035
|
hideThinking();
|
|
1036
|
+
finalizeThinking();
|
|
765
1037
|
currentBubble = null; streamText = '';
|
|
1038
|
+
turnHadContent = false;
|
|
766
1039
|
closePrompt();
|
|
1040
|
+
agentRunning = false;
|
|
1041
|
+
refreshComposer();
|
|
767
1042
|
taskWidgetLines = null;
|
|
1043
|
+
taskWidgetData = null;
|
|
768
1044
|
renderWidgets();
|
|
769
1045
|
contextFill.style.width = '0%';
|
|
770
1046
|
break;
|
|
@@ -783,11 +1059,12 @@ export function clientScript(wsUrl) {
|
|
|
783
1059
|
// The server records the message via addUserTurn and broadcasts a
|
|
784
1060
|
// user_message back to every client (us included), which renders the
|
|
785
1061
|
// bubble. Don't render it here too, or the sender sees it twice.
|
|
786
|
-
|
|
787
|
-
|
|
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();
|
|
788
1065
|
}
|
|
789
1066
|
|
|
790
|
-
sendBtn.addEventListener('click',
|
|
1067
|
+
sendBtn.addEventListener('click', onSendClick);
|
|
791
1068
|
inputEl.addEventListener('keydown', (e) => {
|
|
792
1069
|
if (cmdActive.length > 0) {
|
|
793
1070
|
if (e.key === 'ArrowDown') { e.preventDefault(); cmdIndex = Math.min(cmdIndex + 1, cmdActive.length - 1); renderSuggestions(); return; }
|
|
@@ -817,7 +1094,8 @@ export function clientScript(wsUrl) {
|
|
|
817
1094
|
if (reconnectAnim) { clearInterval(reconnectAnim); reconnectAnim = null; }
|
|
818
1095
|
reconnectOverlay.classList.remove('visible');
|
|
819
1096
|
reconnectDelay = 1000;
|
|
820
|
-
|
|
1097
|
+
connected = true;
|
|
1098
|
+
refreshComposer();
|
|
821
1099
|
// Self-heal on every (re)connect, not just page load: if the server
|
|
822
1100
|
// restarted it may have lost (or be rehydrating) our subscription, and
|
|
823
1101
|
// browsers can rotate it. Re-registering here covers reconnects the
|
|
@@ -831,7 +1109,8 @@ export function clientScript(wsUrl) {
|
|
|
831
1109
|
});
|
|
832
1110
|
sock.addEventListener('close', () => {
|
|
833
1111
|
if (ws !== sock) return;
|
|
834
|
-
|
|
1112
|
+
connected = false;
|
|
1113
|
+
refreshComposer();
|
|
835
1114
|
reconnectOverlay.classList.add('visible');
|
|
836
1115
|
// Animate the same braille spinner used elsewhere, with a live countdown.
|
|
837
1116
|
const until = Date.now() + reconnectDelay;
|