@mjasnikovs/pi-task 0.13.5 → 0.13.7
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.js +5 -4
- package/dist/remote/broadcast.d.ts +0 -1
- package/dist/remote/broadcast.js +0 -7
- package/dist/remote/events.js +5 -5
- package/dist/remote/push.d.ts +12 -3
- package/dist/remote/push.js +63 -9
- package/dist/remote/server.js +0 -16
- package/dist/remote/ui-script.d.ts +3 -0
- package/dist/remote/ui-script.js +804 -0
- package/dist/remote/ui-styles.d.ts +1 -0
- package/dist/remote/ui-styles.js +202 -0
- package/dist/remote/ui.js +4 -1000
- package/dist/shared/child-process.d.ts +27 -0
- package/dist/shared/child-process.js +151 -139
- package/dist/task/auto-orchestrator.js +3 -6
- package/dist/task/child-runner.js +1 -1
- package/dist/task/context-usage.d.ts +16 -0
- package/dist/task/context-usage.js +22 -0
- package/dist/task/external-context.d.ts +27 -0
- package/dist/task/external-context.js +93 -0
- package/dist/task/failure-classifier.js +1 -1
- package/dist/task/orchestrator.js +7 -13
- package/dist/task/parsers.d.ts +1 -15
- package/dist/task/parsers.js +17 -84
- package/dist/task/phases.d.ts +5 -7
- package/dist/task/phases.js +40 -84
- package/dist/task/prompts.d.ts +1 -0
- package/dist/task/prompts.js +9 -0
- package/dist/task/spec-validation.d.ts +23 -0
- package/dist/task/spec-validation.js +90 -0
- package/dist/task/widget.d.ts +1 -1
- package/dist/task/widget.js +1 -1
- package/dist/workers/pi-worker-docs.js +69 -58
- package/dist/workers/pi-worker-fetch.js +25 -21
- package/dist/workers/pi-worker-search.js +7 -13
- package/dist/workers/pi-worker.js +8 -14
- package/dist/workers/shared.d.ts +40 -0
- package/dist/workers/shared.js +31 -0
- package/package.json +1 -1
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
/** The remote web client script (vanilla JS shipped as a string).
|
|
2
|
+
* `wsUrl` is the LAN/Tailscale fallback baked in as FALLBACK_WS_URL. */
|
|
3
|
+
export function clientScript(wsUrl) {
|
|
4
|
+
return ` // Connect the WebSocket back to whatever host served this page (LAN or
|
|
5
|
+
// Tailscale), not a server-baked IP — otherwise opening the LAN URL on a
|
|
6
|
+
// non-Tailscale device tries to reach the Tailscale IP and hangs. Fall back
|
|
7
|
+
// to the server-provided URL only if location is somehow unavailable.
|
|
8
|
+
const FALLBACK_WS_URL = ${JSON.stringify(wsUrl)};
|
|
9
|
+
const WS_URL = (location && location.host)
|
|
10
|
+
? (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'
|
|
11
|
+
: FALLBACK_WS_URL;
|
|
12
|
+
const chatLog = document.getElementById('chat-log');
|
|
13
|
+
const inputEl = document.getElementById('input');
|
|
14
|
+
const sendBtn = document.getElementById('send');
|
|
15
|
+
const contextFill = document.getElementById('context-bar-fill');
|
|
16
|
+
function setContextBar(usage) {
|
|
17
|
+
if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
|
|
18
|
+
}
|
|
19
|
+
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
20
|
+
const reconnectMsg = document.getElementById('reconnect-msg');
|
|
21
|
+
const cmdSuggestions = document.getElementById('cmd-suggestions');
|
|
22
|
+
const statusPanel = document.getElementById('status-panel');
|
|
23
|
+
// Widgets are keyed (e.g. 'pi-tasks', 'pi-task-auto'); track them per key so a
|
|
24
|
+
// clear for one key can't be masked by a stale message from another.
|
|
25
|
+
// Single authoritative task-widget slot. The snapshot and the live 'widget'
|
|
26
|
+
// delta both set this; null hides the panel. (No more per-key map that could
|
|
27
|
+
// strand an orphaned widget on screen.)
|
|
28
|
+
let taskWidgetLines = null;
|
|
29
|
+
function renderWidgets() {
|
|
30
|
+
if (taskWidgetLines && taskWidgetLines.length) {
|
|
31
|
+
statusPanel.textContent = taskWidgetLines.join('\\n');
|
|
32
|
+
statusPanel.style.display = 'block';
|
|
33
|
+
} else {
|
|
34
|
+
statusPanel.style.display = 'none';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const promptCard = document.getElementById('prompt-card');
|
|
38
|
+
const promptQ = document.getElementById('prompt-q');
|
|
39
|
+
const promptRec = document.getElementById('prompt-rec');
|
|
40
|
+
const promptRecText = document.getElementById('prompt-rec-text');
|
|
41
|
+
const promptInput = document.getElementById('prompt-input');
|
|
42
|
+
const promptButtons = document.getElementById('prompt-buttons');
|
|
43
|
+
const viewer = document.getElementById('viewer');
|
|
44
|
+
const viewerBody = document.getElementById('viewer-body');
|
|
45
|
+
document.getElementById('viewer-close').onclick = function () { viewer.style.display = 'none'; };
|
|
46
|
+
let activePromptId = null;
|
|
47
|
+
let activeRecommended = '';
|
|
48
|
+
let activeRecommended2 = '';
|
|
49
|
+
let cancelArmTimer = null;
|
|
50
|
+
const toolCallMap = {};
|
|
51
|
+
let currentBubble = null;
|
|
52
|
+
let streamText = '';
|
|
53
|
+
let autoScroll = true;
|
|
54
|
+
let reconnectDelay = 1000;
|
|
55
|
+
let reconnectAnim = null;
|
|
56
|
+
let ws = null;
|
|
57
|
+
|
|
58
|
+
const BT = String.fromCharCode(96);
|
|
59
|
+
const JS_LANGS = new Set(['js','jsx','mjs','cjs','javascript','ts','tsx','typescript']);
|
|
60
|
+
const JS_KW = new Set(['break','case','catch','class','const','continue','debugger',
|
|
61
|
+
'default','delete','do','else','export','extends','finally','for','from','function',
|
|
62
|
+
'if','import','in','instanceof','let','new','of','return','static','super','switch',
|
|
63
|
+
'this','throw','try','typeof','var','void','while','with','yield','async','await',
|
|
64
|
+
'type','interface','enum','implements','abstract','as','declare','namespace',
|
|
65
|
+
'readonly','undefined','null','true','false','override','satisfies']);
|
|
66
|
+
|
|
67
|
+
function escHtml(s) {
|
|
68
|
+
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function syntaxHighlight(code, lang) {
|
|
72
|
+
if (!JS_LANGS.has((lang || '').toLowerCase())) return escHtml(code);
|
|
73
|
+
let r = '', i = 0;
|
|
74
|
+
while (i < code.length) {
|
|
75
|
+
const ch = code[i];
|
|
76
|
+
// Template literal
|
|
77
|
+
if (ch === BT) {
|
|
78
|
+
let j = i + 1;
|
|
79
|
+
while (j < code.length) {
|
|
80
|
+
if (code[j] === '\\\\') { j += 2; continue; }
|
|
81
|
+
if (code[j] === BT) { j++; break; }
|
|
82
|
+
j++;
|
|
83
|
+
}
|
|
84
|
+
r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
|
|
85
|
+
i = j; continue;
|
|
86
|
+
}
|
|
87
|
+
// Single / double quoted string
|
|
88
|
+
if (ch === '"' || ch === "'") {
|
|
89
|
+
let j = i + 1;
|
|
90
|
+
while (j < code.length) {
|
|
91
|
+
if (code[j] === '\\\\') { j += 2; continue; }
|
|
92
|
+
if (code[j] === ch || code[j] === '\\n') break;
|
|
93
|
+
j++;
|
|
94
|
+
}
|
|
95
|
+
if (code[j] === ch) j++;
|
|
96
|
+
r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
|
|
97
|
+
i = j; continue;
|
|
98
|
+
}
|
|
99
|
+
// Line comment
|
|
100
|
+
if (ch === '/' && code[i + 1] === '/') {
|
|
101
|
+
let j = i + 2;
|
|
102
|
+
while (j < code.length && code[j] !== '\\n') j++;
|
|
103
|
+
r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
|
|
104
|
+
i = j; continue;
|
|
105
|
+
}
|
|
106
|
+
// Block comment
|
|
107
|
+
if (ch === '/' && code[i + 1] === '*') {
|
|
108
|
+
let j = i + 2;
|
|
109
|
+
while (j < code.length && !(code[j] === '*' && code[j + 1] === '/')) j++;
|
|
110
|
+
j += 2;
|
|
111
|
+
r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
|
|
112
|
+
i = j; continue;
|
|
113
|
+
}
|
|
114
|
+
// Number
|
|
115
|
+
if (ch >= '0' && ch <= '9') {
|
|
116
|
+
let j = i;
|
|
117
|
+
if (code[i] === '0' && /[xXoObB]/.test(code[i + 1] || '')) {
|
|
118
|
+
j += 2; while (j < code.length && /[0-9a-fA-F_]/.test(code[j])) j++;
|
|
119
|
+
} else {
|
|
120
|
+
while (j < code.length && (code[j] >= '0' && code[j] <= '9' || code[j] === '_')) j++;
|
|
121
|
+
if (code[j] === '.') { j++; while (j < code.length && code[j] >= '0' && code[j] <= '9') j++; }
|
|
122
|
+
if (code[j] === 'e' || code[j] === 'E') {
|
|
123
|
+
j++; if (code[j] === '+' || code[j] === '-') j++;
|
|
124
|
+
while (j < code.length && code[j] >= '0' && code[j] <= '9') j++;
|
|
125
|
+
}
|
|
126
|
+
if (code[j] === 'n') j++;
|
|
127
|
+
}
|
|
128
|
+
r += '<span class="hl-num">' + escHtml(code.slice(i, j)) + '</span>';
|
|
129
|
+
i = j; continue;
|
|
130
|
+
}
|
|
131
|
+
// Identifier / keyword / function call
|
|
132
|
+
if (/[a-zA-Z_$]/.test(ch)) {
|
|
133
|
+
let j = i;
|
|
134
|
+
while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
|
|
135
|
+
const word = code.slice(i, j);
|
|
136
|
+
if (JS_KW.has(word)) {
|
|
137
|
+
r += '<span class="hl-kw">' + word + '</span>';
|
|
138
|
+
} else if (code[j] === '(') {
|
|
139
|
+
r += '<span class="hl-fn">' + escHtml(word) + '</span>';
|
|
140
|
+
} else {
|
|
141
|
+
r += escHtml(word);
|
|
142
|
+
}
|
|
143
|
+
i = j; continue;
|
|
144
|
+
}
|
|
145
|
+
r += escHtml(ch); i++;
|
|
146
|
+
}
|
|
147
|
+
return r;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function setContent(el, text) {
|
|
151
|
+
el.innerHTML = '';
|
|
152
|
+
const BT3 = BT + BT + BT;
|
|
153
|
+
const re = new RegExp(BT3 + '([^\\n' + BT + ']*)\\n([\\s\\S]*?)' + BT3, 'g');
|
|
154
|
+
let last = 0, m;
|
|
155
|
+
while ((m = re.exec(text)) !== null) {
|
|
156
|
+
if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
|
|
157
|
+
const lang = m[1].trim();
|
|
158
|
+
const code = m[2];
|
|
159
|
+
const wrap = document.createElement('div');
|
|
160
|
+
wrap.className = 'code-block';
|
|
161
|
+
if (lang) { const lb = document.createElement('div'); lb.className = 'code-lang'; lb.textContent = lang; wrap.appendChild(lb); }
|
|
162
|
+
const pre = document.createElement('pre');
|
|
163
|
+
const codeEl = document.createElement('code');
|
|
164
|
+
codeEl.innerHTML = syntaxHighlight(code, lang);
|
|
165
|
+
pre.appendChild(codeEl);
|
|
166
|
+
wrap.appendChild(pre);
|
|
167
|
+
el.appendChild(wrap);
|
|
168
|
+
last = m.index + m[0].length;
|
|
169
|
+
}
|
|
170
|
+
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const COMMANDS = [
|
|
174
|
+
{ name: '/task', desc: 'Start a new task' },
|
|
175
|
+
{ name: '/task-list', desc: 'List tasks in this project' },
|
|
176
|
+
{ name: '/task-resume', desc: 'Resume a task' },
|
|
177
|
+
{ name: '/task-cancel', desc: 'Cancel the currently running task' },
|
|
178
|
+
{ name: '/task-auto', desc: 'Plan a feature into tasks and run them' },
|
|
179
|
+
{ name: '/task-auto-resume', desc: 'Resume the active /task-auto run' },
|
|
180
|
+
{ name: '/task-auto-cancel', desc: 'Stop the running /task-auto loop after the current task' },
|
|
181
|
+
{ name: '/new', desc: 'Start a new session' },
|
|
182
|
+
{ name: '/clear', desc: 'Clear the conversation' },
|
|
183
|
+
{ name: '/compact', desc: 'Compact context to save tokens' },
|
|
184
|
+
{ name: '/help', desc: 'Show available commands' },
|
|
185
|
+
{ name: '/fast', desc: 'Toggle fast mode' },
|
|
186
|
+
{ name: '/remote stop', desc: 'Stop the remote server' },
|
|
187
|
+
];
|
|
188
|
+
let cmdActive = [];
|
|
189
|
+
let cmdIndex = -1;
|
|
190
|
+
|
|
191
|
+
function renderSuggestions() {
|
|
192
|
+
if (cmdActive.length === 0) { cmdSuggestions.style.display = 'none'; return; }
|
|
193
|
+
cmdSuggestions.style.display = 'block';
|
|
194
|
+
cmdSuggestions.innerHTML = '';
|
|
195
|
+
cmdActive.forEach((cmd, i) => {
|
|
196
|
+
const el = document.createElement('div');
|
|
197
|
+
el.className = 'cmd-item' + (i === cmdIndex ? ' active' : '');
|
|
198
|
+
el.innerHTML = '<span class="cmd-name">' + cmd.name + '</span><span class="cmd-desc">' + cmd.desc + '</span>';
|
|
199
|
+
el.addEventListener('mousedown', (e) => { e.preventDefault(); pickCmd(i); });
|
|
200
|
+
cmdSuggestions.appendChild(el);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function updateSuggestions() {
|
|
205
|
+
const val = inputEl.value;
|
|
206
|
+
if (!val.startsWith('/')) { cmdActive = []; cmdIndex = -1; renderSuggestions(); return; }
|
|
207
|
+
cmdActive = COMMANDS.filter(c => c.name.startsWith(val));
|
|
208
|
+
cmdIndex = cmdActive.length === 1 ? 0 : -1;
|
|
209
|
+
renderSuggestions();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function pickCmd(i) {
|
|
213
|
+
if (!cmdActive[i]) return;
|
|
214
|
+
inputEl.value = cmdActive[i].name + ' ';
|
|
215
|
+
cmdActive = []; cmdIndex = -1; renderSuggestions();
|
|
216
|
+
inputEl.focus();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
chatLog.addEventListener('scroll', () => {
|
|
220
|
+
const { scrollTop, scrollHeight, clientHeight } = chatLog;
|
|
221
|
+
autoScroll = scrollTop + clientHeight >= scrollHeight - 24;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function scrollBottom() { if (autoScroll) chatLog.scrollTop = chatLog.scrollHeight; }
|
|
225
|
+
|
|
226
|
+
function addBubble(role, text) {
|
|
227
|
+
const el = document.createElement('div');
|
|
228
|
+
el.className = 'bubble ' + role;
|
|
229
|
+
setContent(el, text);
|
|
230
|
+
chatLog.appendChild(el);
|
|
231
|
+
scrollBottom();
|
|
232
|
+
return el;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let thinkingEl = null;
|
|
236
|
+
let spinTimer = null;
|
|
237
|
+
let spinIdx = 0;
|
|
238
|
+
const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
|
|
239
|
+
// One braille ticker drives every '.spin' element — the thinking bubble AND the
|
|
240
|
+
// trailing stream cursor — so they share the same frame and look identical.
|
|
241
|
+
function spinPaint() {
|
|
242
|
+
const g = SPIN[spinIdx % SPIN.length];
|
|
243
|
+
const els = document.getElementsByClassName('spin');
|
|
244
|
+
for (let i = 0; i < els.length; i++) els[i].textContent = g;
|
|
245
|
+
}
|
|
246
|
+
function startSpin() {
|
|
247
|
+
spinPaint();
|
|
248
|
+
if (spinTimer) return;
|
|
249
|
+
spinTimer = setInterval(function () {
|
|
250
|
+
spinIdx = (spinIdx + 1) % SPIN.length;
|
|
251
|
+
spinPaint();
|
|
252
|
+
}, 90);
|
|
253
|
+
}
|
|
254
|
+
// Stop the ticker once nothing on screen needs spinning.
|
|
255
|
+
function stopSpinIfIdle() {
|
|
256
|
+
if (spinTimer && !document.querySelector('.spin')) {
|
|
257
|
+
clearInterval(spinTimer); spinTimer = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function showThinking() {
|
|
261
|
+
if (!thinkingEl) {
|
|
262
|
+
thinkingEl = document.createElement('div');
|
|
263
|
+
thinkingEl.className = 'bubble assistant thinking';
|
|
264
|
+
thinkingEl.innerHTML = '<span class="spinner spin"></span>';
|
|
265
|
+
}
|
|
266
|
+
chatLog.appendChild(thinkingEl); // append (or move) to bottom
|
|
267
|
+
startSpin();
|
|
268
|
+
scrollBottom();
|
|
269
|
+
}
|
|
270
|
+
function hideThinking() {
|
|
271
|
+
if (thinkingEl) thinkingEl.remove();
|
|
272
|
+
stopSpinIfIdle();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function addToolCall(toolName, argsStr, isError) {
|
|
276
|
+
// argsStr can be undefined (no args / JSON.stringify(undefined)); don't let
|
|
277
|
+
// that render as the literal "name: undefined" in the collapsed summary.
|
|
278
|
+
const label = (toolName + (argsStr ? ': ' + argsStr : '')).slice(0, 64);
|
|
279
|
+
const d = document.createElement('details');
|
|
280
|
+
d.className = 'tool-call' + (isError ? ' error' : '');
|
|
281
|
+
const s = document.createElement('summary');
|
|
282
|
+
s.textContent = label;
|
|
283
|
+
d.appendChild(s);
|
|
284
|
+
chatLog.appendChild(d);
|
|
285
|
+
scrollBottom();
|
|
286
|
+
return d;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function setEnabled(on) {
|
|
290
|
+
const allow = on && activePromptId === null;
|
|
291
|
+
inputEl.disabled = !allow;
|
|
292
|
+
sendBtn.disabled = !allow;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Stringify a tool result safely. A null/undefined result (e.g. a tool that
|
|
296
|
+
// hasn't produced output) must NOT become the JS value undefined, whose
|
|
297
|
+
// .slice() throws — a throw here aborts the whole snapshot rebuild after the
|
|
298
|
+
// log was already cleared, blanking the transcript on reconnect.
|
|
299
|
+
function toolResultText(result) {
|
|
300
|
+
if (result == null) return '';
|
|
301
|
+
const r = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
302
|
+
return (r == null ? '' : r).slice(0, 8000);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Render one tool part from the ordered parts list (running or finished).
|
|
306
|
+
function renderToolPart(p) {
|
|
307
|
+
const argsStr = typeof p.args === 'string' ? p.args : JSON.stringify(p.args);
|
|
308
|
+
const d = addToolCall(p.toolName, argsStr, p.isError);
|
|
309
|
+
if (p.done) {
|
|
310
|
+
const pre = document.createElement('pre');
|
|
311
|
+
pre.textContent = toolResultText(p.result);
|
|
312
|
+
d.appendChild(pre);
|
|
313
|
+
} else {
|
|
314
|
+
const sp = document.createElement('span');
|
|
315
|
+
sp.className = 'tool-spin spin';
|
|
316
|
+
d.querySelector('summary').appendChild(sp);
|
|
317
|
+
startSpin();
|
|
318
|
+
toolCallMap[p.toolCallId] = d;
|
|
319
|
+
}
|
|
320
|
+
return d;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// A muted, centered system note (e.g. "Context compacted").
|
|
324
|
+
function addSystemLine(text) {
|
|
325
|
+
const el = document.createElement('div');
|
|
326
|
+
el.className = 'sysnote';
|
|
327
|
+
el.textContent = text;
|
|
328
|
+
chatLog.appendChild(el);
|
|
329
|
+
scrollBottom();
|
|
330
|
+
return el;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Render one committed transcript turn. Assistant turns are an ordered list of
|
|
334
|
+
// parts (text segments + tool calls), so the layout matches the terminal's
|
|
335
|
+
// interleaving instead of one merged blob with tools dumped at the end.
|
|
336
|
+
function renderTurn(t) {
|
|
337
|
+
if (t.error) { addBubble('error', t.text); return; }
|
|
338
|
+
if (t.role === 'system') { addSystemLine(t.text); return; }
|
|
339
|
+
if (t.role === 'user') { addBubble('user', t.text); return; }
|
|
340
|
+
for (const p of (t.parts || [])) {
|
|
341
|
+
if (p.kind === 'text') { if (p.text) addBubble('assistant', p.text); }
|
|
342
|
+
else renderToolPart(p);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Render the in-progress assistant turn from a snapshot, preserving order. The
|
|
347
|
+
// trailing OPEN text segment becomes the live streaming bubble (cursor + spin)
|
|
348
|
+
// so subsequent text_delta frames keep flowing into it.
|
|
349
|
+
function renderLiveTurn(live) {
|
|
350
|
+
const parts = live.parts || [];
|
|
351
|
+
for (let i = 0; i < parts.length; i++) {
|
|
352
|
+
const p = parts[i];
|
|
353
|
+
const last = i === parts.length - 1;
|
|
354
|
+
if (p.kind === 'text') {
|
|
355
|
+
if (last && live.textOpen) {
|
|
356
|
+
currentBubble = document.createElement('div');
|
|
357
|
+
currentBubble.className = 'bubble assistant';
|
|
358
|
+
const cursor = document.createElement('span');
|
|
359
|
+
cursor.className = 'cursor spin';
|
|
360
|
+
currentBubble.appendChild(cursor);
|
|
361
|
+
if (p.text) currentBubble.insertBefore(document.createTextNode(p.text), cursor);
|
|
362
|
+
chatLog.appendChild(currentBubble);
|
|
363
|
+
streamText = p.text || '';
|
|
364
|
+
startSpin();
|
|
365
|
+
scrollBottom();
|
|
366
|
+
} else if (p.text) {
|
|
367
|
+
addBubble('assistant', p.text);
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
renderToolPart(p);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function showToast(message, level) {
|
|
376
|
+
const t = document.createElement('div');
|
|
377
|
+
t.className = 'toast ' + (level || 'info');
|
|
378
|
+
t.textContent = message;
|
|
379
|
+
document.body.appendChild(t);
|
|
380
|
+
setTimeout(function () { t.remove(); }, 4000);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const bell = document.getElementById('bell');
|
|
384
|
+
const NOTIFY_KEY = 'piRemoteNotify';
|
|
385
|
+
|
|
386
|
+
function notifyEnabled() {
|
|
387
|
+
return localStorage.getItem(NOTIFY_KEY) === '1'
|
|
388
|
+
&& typeof Notification !== 'undefined'
|
|
389
|
+
&& Notification.permission === 'granted';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function updateBell() {
|
|
393
|
+
// ◉ (mauve) when armed, ◯ (dim) when off/unavailable.
|
|
394
|
+
var on = notifyEnabled();
|
|
395
|
+
bell.textContent = on ? '\\u25C9' : '\\u25EF';
|
|
396
|
+
bell.classList.toggle('on', on);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Why notifications can't be enabled here, or null if they can.
|
|
400
|
+
function notifyEnvIssue() {
|
|
401
|
+
if (typeof Notification === 'undefined') return "This browser doesn't support notifications.";
|
|
402
|
+
if (!window.isSecureContext) return 'Notifications need HTTPS. Open the Tailscale https:// URL, or open via localhost.';
|
|
403
|
+
const isIOS = /iP(hone|ad|od)/i.test(navigator.userAgent);
|
|
404
|
+
const standalone = navigator.standalone === true
|
|
405
|
+
|| (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
|
|
406
|
+
if (isIOS && !standalone) return 'On iOS: Share \\u2192 Add to Home Screen first, then enable notifications.';
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
bell.addEventListener('click', function () {
|
|
411
|
+
// Turning OFF always works regardless of environment.
|
|
412
|
+
if (localStorage.getItem(NOTIFY_KEY) === '1') {
|
|
413
|
+
localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
|
|
414
|
+
}
|
|
415
|
+
const issue = notifyEnvIssue();
|
|
416
|
+
if (issue) { showToast(issue, 'warning'); return; }
|
|
417
|
+
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
|
418
|
+
showToast('This browser doesn\\u2019t support push notifications.', 'warning'); return;
|
|
419
|
+
}
|
|
420
|
+
Notification.requestPermission().then(function (perm) {
|
|
421
|
+
if (perm !== 'granted') {
|
|
422
|
+
showToast('Notifications blocked in browser settings.', 'warning');
|
|
423
|
+
updateBell(); return;
|
|
424
|
+
}
|
|
425
|
+
subscribePush().then(function (ok) {
|
|
426
|
+
if (ok) { localStorage.setItem(NOTIFY_KEY, '1'); showToast('Notifications on.', 'info'); }
|
|
427
|
+
else { showToast('Could not register for notifications.', 'warning'); }
|
|
428
|
+
updateBell();
|
|
429
|
+
}).catch(function (e) {
|
|
430
|
+
showToast('Notification setup failed: ' + (e && e.message ? e.message : e), 'warning');
|
|
431
|
+
updateBell();
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// VAPID public key (base64url) -> Uint8Array for applicationServerKey.
|
|
437
|
+
function urlB64ToUint8Array(base64) {
|
|
438
|
+
const pad = '='.repeat((4 - base64.length % 4) % 4);
|
|
439
|
+
const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
|
|
440
|
+
const raw = atob(b64);
|
|
441
|
+
const arr = new Uint8Array(raw.length);
|
|
442
|
+
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
|
443
|
+
return arr;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Register the service worker, subscribe via the Push API, and hand the
|
|
447
|
+
// subscription to the server. The server (not the page) sends notifications,
|
|
448
|
+
// so they arrive even when this PWA is backgrounded/suspended on iOS.
|
|
449
|
+
function subscribePush() {
|
|
450
|
+
return navigator.serviceWorker.register('/sw.js')
|
|
451
|
+
.then(function () { return navigator.serviceWorker.ready; })
|
|
452
|
+
.then(function (reg) {
|
|
453
|
+
return fetch('/push-key').then(function (r) { return r.text(); }).then(function (key) {
|
|
454
|
+
return reg.pushManager.getSubscription().then(function (existing) {
|
|
455
|
+
return existing || reg.pushManager.subscribe({
|
|
456
|
+
userVisibleOnly: true,
|
|
457
|
+
applicationServerKey: urlB64ToUint8Array(key.trim())
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
})
|
|
462
|
+
.then(function (subscription) {
|
|
463
|
+
return fetch('/subscribe', {
|
|
464
|
+
method: 'POST',
|
|
465
|
+
headers: { 'Content-Type': 'application/json' },
|
|
466
|
+
body: JSON.stringify(subscription)
|
|
467
|
+
}).then(function (res) { return res.ok; });
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
updateBell();
|
|
472
|
+
|
|
473
|
+
function answer(value) {
|
|
474
|
+
if (activePromptId === null) return;
|
|
475
|
+
ws.send(JSON.stringify({ type: 'prompt_answer', id: activePromptId, value: value }));
|
|
476
|
+
closePrompt();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function closePrompt() {
|
|
480
|
+
activePromptId = null;
|
|
481
|
+
promptCard.style.display = 'none';
|
|
482
|
+
promptInput.value = '';
|
|
483
|
+
promptInput.style.display = 'none';
|
|
484
|
+
promptRec.style.display = 'none';
|
|
485
|
+
activeRecommended2 = '';
|
|
486
|
+
if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
|
|
487
|
+
setEnabled(true);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function makeBtn(label, cls, onClick) {
|
|
491
|
+
const btn = document.createElement('button');
|
|
492
|
+
btn.textContent = label;
|
|
493
|
+
if (cls) btn.className = cls;
|
|
494
|
+
btn.onclick = onClick;
|
|
495
|
+
return btn;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// "Cancel task" aborts the whole run, so it's deliberately small and needs a
|
|
499
|
+
// two-step confirm: first tap arms it, second tap (within 3s) confirms.
|
|
500
|
+
function makeCancelBtn() {
|
|
501
|
+
const btn = makeBtn('Cancel task', 'cancel', null);
|
|
502
|
+
let armed = false;
|
|
503
|
+
btn.onclick = function () {
|
|
504
|
+
if (armed) { answer(undefined); return; }
|
|
505
|
+
armed = true;
|
|
506
|
+
btn.classList.add('armed');
|
|
507
|
+
btn.textContent = 'Tap again to cancel';
|
|
508
|
+
if (cancelArmTimer) clearTimeout(cancelArmTimer);
|
|
509
|
+
cancelArmTimer = setTimeout(function () {
|
|
510
|
+
armed = false;
|
|
511
|
+
btn.classList.remove('armed');
|
|
512
|
+
btn.textContent = 'Cancel task';
|
|
513
|
+
cancelArmTimer = null;
|
|
514
|
+
}, 3000);
|
|
515
|
+
};
|
|
516
|
+
return btn;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function renderButtons(buttons, stacked) {
|
|
520
|
+
promptButtons.className = stacked ? 'row stacked' : 'row';
|
|
521
|
+
promptButtons.innerHTML = '';
|
|
522
|
+
for (let i = 0; i < buttons.length; i++) promptButtons.appendChild(buttons[i]);
|
|
523
|
+
promptButtons.appendChild(makeCancelBtn());
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Manual-entry view: empty textarea + Submit, reachable from the
|
|
527
|
+
// recommendation view via "Manual answer".
|
|
528
|
+
function showManualEntry() {
|
|
529
|
+
promptRec.style.display = 'none';
|
|
530
|
+
promptInput.style.display = 'block';
|
|
531
|
+
promptInput.value = '';
|
|
532
|
+
renderButtons([
|
|
533
|
+
makeBtn('Submit', 'primary', function () { answer(promptInput.value); }),
|
|
534
|
+
makeBtn('← Back', 'secondary', function () { showRecommendation(); })
|
|
535
|
+
]);
|
|
536
|
+
promptInput.focus();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Recommendation view: 2-button mode when both options present, panel mode for one.
|
|
540
|
+
function showRecommendation() {
|
|
541
|
+
promptInput.style.display = 'none';
|
|
542
|
+
const buttons = [];
|
|
543
|
+
if (activeRecommended2) {
|
|
544
|
+
// Two-option mode: each recommendation is a direct-accept button.
|
|
545
|
+
promptRec.style.display = 'none';
|
|
546
|
+
buttons.push(makeBtn(activeRecommended, 'primary', function () { answer(activeRecommended); }));
|
|
547
|
+
buttons.push(makeBtn(activeRecommended2, 'secondary', function () { answer(activeRecommended2); }));
|
|
548
|
+
buttons.push(makeBtn('✎ Manual answer', 'secondary', function () { showManualEntry(); }));
|
|
549
|
+
// Answer buttons hold full sentences — stack them so long text stays readable.
|
|
550
|
+
renderButtons(buttons, true);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// Single recommendation: show it in the green panel.
|
|
554
|
+
promptRec.style.display = 'block';
|
|
555
|
+
buttons.push(makeBtn('✓ Accept', 'primary', function () { answer(activeRecommended); }));
|
|
556
|
+
buttons.push(makeBtn('✎ Manual answer', 'secondary', function () { showManualEntry(); }));
|
|
557
|
+
renderButtons(buttons);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function showPrompt(msg) {
|
|
561
|
+
activePromptId = msg.id;
|
|
562
|
+
promptQ.textContent = msg.question;
|
|
563
|
+
activeRecommended = msg.recommended || '';
|
|
564
|
+
activeRecommended2 = msg.recommended2 || '';
|
|
565
|
+
if (msg.recommended) {
|
|
566
|
+
// Mode A: recommendation(s) present.
|
|
567
|
+
promptRecText.textContent = msg.recommended;
|
|
568
|
+
showRecommendation();
|
|
569
|
+
} else {
|
|
570
|
+
// Mode B: no recommendation — the user must type an answer (or skip).
|
|
571
|
+
promptRec.style.display = 'none';
|
|
572
|
+
promptInput.style.display = 'block';
|
|
573
|
+
promptInput.value = '';
|
|
574
|
+
const buttons = [makeBtn('Submit', 'primary', function () { answer(promptInput.value); })];
|
|
575
|
+
if (msg.allowSkip) {
|
|
576
|
+
buttons.push(makeBtn('Skip', 'secondary', function () { answer(''); }));
|
|
577
|
+
}
|
|
578
|
+
renderButtons(buttons);
|
|
579
|
+
promptInput.focus();
|
|
580
|
+
}
|
|
581
|
+
promptCard.style.display = 'block';
|
|
582
|
+
setEnabled(false);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function handleMsg(msg) {
|
|
586
|
+
switch (msg.type) {
|
|
587
|
+
case 'snapshot': {
|
|
588
|
+
// Authoritative full state on every (re)connect: replace the WHOLE view.
|
|
589
|
+
// This is what kills duplicated transcript / stale-orphaned widgets —
|
|
590
|
+
// whatever was on screen is discarded and rebuilt from server truth.
|
|
591
|
+
chatLog.innerHTML = '';
|
|
592
|
+
closePrompt();
|
|
593
|
+
hideThinking();
|
|
594
|
+
currentBubble = null; streamText = '';
|
|
595
|
+
for (const k in toolCallMap) delete toolCallMap[k];
|
|
596
|
+
// Per-turn try/catch: one malformed turn must never abort the rebuild
|
|
597
|
+
// and leave the (already-cleared) transcript blank.
|
|
598
|
+
for (const t of (msg.turns || [])) { try { renderTurn(t); } catch (e) {} }
|
|
599
|
+
if (msg.live) { try { renderLiveTurn(msg.live); } catch (e) {} }
|
|
600
|
+
taskWidgetLines = (msg.taskWidget && msg.taskWidget.length) ? msg.taskWidget : null;
|
|
601
|
+
renderWidgets();
|
|
602
|
+
if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
|
|
603
|
+
if (msg.prompt) showPrompt(msg.prompt);
|
|
604
|
+
setEnabled(!msg.agentRunning && !msg.prompt);
|
|
605
|
+
if (msg.agentRunning && !msg.live) showThinking();
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
case 'agent_start':
|
|
609
|
+
autoScroll = true;
|
|
610
|
+
streamText = '';
|
|
611
|
+
currentBubble = null;
|
|
612
|
+
setEnabled(false);
|
|
613
|
+
showThinking();
|
|
614
|
+
break;
|
|
615
|
+
case 'text_delta':
|
|
616
|
+
if (!currentBubble) {
|
|
617
|
+
hideThinking();
|
|
618
|
+
currentBubble = document.createElement('div');
|
|
619
|
+
currentBubble.className = 'bubble assistant';
|
|
620
|
+
const cursor = document.createElement('span');
|
|
621
|
+
cursor.className = 'cursor spin';
|
|
622
|
+
currentBubble.appendChild(cursor);
|
|
623
|
+
chatLog.appendChild(currentBubble);
|
|
624
|
+
startSpin();
|
|
625
|
+
}
|
|
626
|
+
streamText += msg.delta;
|
|
627
|
+
{
|
|
628
|
+
const c = currentBubble.querySelector('.cursor');
|
|
629
|
+
currentBubble.insertBefore(document.createTextNode(msg.delta), c);
|
|
630
|
+
}
|
|
631
|
+
scrollBottom();
|
|
632
|
+
break;
|
|
633
|
+
case 'text_end':
|
|
634
|
+
if (currentBubble) {
|
|
635
|
+
const c = currentBubble.querySelector('.cursor');
|
|
636
|
+
if (c) c.remove();
|
|
637
|
+
if (streamText) setContent(currentBubble, streamText);
|
|
638
|
+
// Close this message's bubble so the next text segment (after a tool or
|
|
639
|
+
// the next message) starts a fresh bubble — matching the terminal.
|
|
640
|
+
currentBubble = null;
|
|
641
|
+
streamText = '';
|
|
642
|
+
stopSpinIfIdle();
|
|
643
|
+
}
|
|
644
|
+
break;
|
|
645
|
+
case 'tool_start': {
|
|
646
|
+
hideThinking();
|
|
647
|
+
const argsStr = typeof msg.args === 'string' ? msg.args : JSON.stringify(msg.args);
|
|
648
|
+
const d = addToolCall(msg.toolName, argsStr, false);
|
|
649
|
+
const sp = document.createElement('span');
|
|
650
|
+
sp.className = 'tool-spin spin';
|
|
651
|
+
d.querySelector('summary').appendChild(sp);
|
|
652
|
+
startSpin();
|
|
653
|
+
toolCallMap[msg.toolCallId] = d;
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
case 'tool_end': {
|
|
657
|
+
const d = toolCallMap[msg.toolCallId];
|
|
658
|
+
if (d) {
|
|
659
|
+
const sp = d.querySelector('.tool-spin');
|
|
660
|
+
if (sp) { sp.remove(); stopSpinIfIdle(); }
|
|
661
|
+
if (msg.isError) d.classList.add('error');
|
|
662
|
+
const pre = document.createElement('pre');
|
|
663
|
+
pre.textContent = toolResultText(msg.result);
|
|
664
|
+
d.appendChild(pre);
|
|
665
|
+
delete toolCallMap[msg.toolCallId];
|
|
666
|
+
}
|
|
667
|
+
// Tool finished: model is thinking about the result next.
|
|
668
|
+
currentBubble = null;
|
|
669
|
+
streamText = '';
|
|
670
|
+
showThinking();
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
case 'user_message':
|
|
674
|
+
addBubble('user', msg.text);
|
|
675
|
+
break;
|
|
676
|
+
case 'system_note':
|
|
677
|
+
addSystemLine(msg.text);
|
|
678
|
+
break;
|
|
679
|
+
case 'agent_error':
|
|
680
|
+
hideThinking();
|
|
681
|
+
if (currentBubble) {
|
|
682
|
+
const c = currentBubble.querySelector('.cursor');
|
|
683
|
+
if (c) c.remove();
|
|
684
|
+
if (streamText) setContent(currentBubble, streamText);
|
|
685
|
+
currentBubble = null;
|
|
686
|
+
streamText = '';
|
|
687
|
+
stopSpinIfIdle();
|
|
688
|
+
}
|
|
689
|
+
addBubble('error', msg.message || 'Error');
|
|
690
|
+
setEnabled(true);
|
|
691
|
+
break;
|
|
692
|
+
case 'agent_end':
|
|
693
|
+
hideThinking();
|
|
694
|
+
currentBubble = null;
|
|
695
|
+
streamText = '';
|
|
696
|
+
setEnabled(true);
|
|
697
|
+
setContextBar(msg.contextUsage);
|
|
698
|
+
break;
|
|
699
|
+
case 'context':
|
|
700
|
+
// Seeds the bar for a client that joined mid-session.
|
|
701
|
+
setContextBar(msg.contextUsage);
|
|
702
|
+
break;
|
|
703
|
+
case 'prompt':
|
|
704
|
+
showPrompt(msg);
|
|
705
|
+
break;
|
|
706
|
+
case 'prompt_resolved':
|
|
707
|
+
if (activePromptId === msg.id) closePrompt();
|
|
708
|
+
break;
|
|
709
|
+
case 'widget':
|
|
710
|
+
taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
|
|
711
|
+
renderWidgets();
|
|
712
|
+
break;
|
|
713
|
+
case 'notify':
|
|
714
|
+
showToast(msg.message, msg.level);
|
|
715
|
+
break;
|
|
716
|
+
case 'viewer':
|
|
717
|
+
viewerBody.textContent = msg.text;
|
|
718
|
+
viewer.style.display = 'block';
|
|
719
|
+
break;
|
|
720
|
+
case 'reset':
|
|
721
|
+
// A new session started — wipe the previous session's transcript.
|
|
722
|
+
chatLog.innerHTML = '';
|
|
723
|
+
hideThinking();
|
|
724
|
+
currentBubble = null; streamText = '';
|
|
725
|
+
closePrompt();
|
|
726
|
+
taskWidgetLines = null;
|
|
727
|
+
renderWidgets();
|
|
728
|
+
contextFill.style.width = '0%';
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function sendMessage() {
|
|
734
|
+
const text = inputEl.value.trim();
|
|
735
|
+
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
|
|
736
|
+
ws.send(JSON.stringify({ type: 'message', text }));
|
|
737
|
+
inputEl.value = '';
|
|
738
|
+
inputEl.style.height = 'auto';
|
|
739
|
+
cmdActive = []; cmdIndex = -1; renderSuggestions();
|
|
740
|
+
// Slash commands are handled server-side and produce no chat turn.
|
|
741
|
+
if (text.startsWith('/')) return;
|
|
742
|
+
// The server records the message via addUserTurn and broadcasts a
|
|
743
|
+
// user_message back to every client (us included), which renders the
|
|
744
|
+
// bubble. Don't render it here too, or the sender sees it twice.
|
|
745
|
+
setEnabled(false);
|
|
746
|
+
showThinking();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
sendBtn.addEventListener('click', sendMessage);
|
|
750
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
751
|
+
if (cmdActive.length > 0) {
|
|
752
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); cmdIndex = Math.min(cmdIndex + 1, cmdActive.length - 1); renderSuggestions(); return; }
|
|
753
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); cmdIndex = Math.max(cmdIndex - 1, 0); renderSuggestions(); return; }
|
|
754
|
+
if (e.key === 'Tab') { e.preventDefault(); pickCmd(cmdIndex >= 0 ? cmdIndex : 0); return; }
|
|
755
|
+
if (e.key === 'Escape') { cmdActive = []; cmdIndex = -1; renderSuggestions(); return; }
|
|
756
|
+
if (e.key === 'Enter' && !e.shiftKey && cmdIndex >= 0) { e.preventDefault(); pickCmd(cmdIndex); return; }
|
|
757
|
+
}
|
|
758
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
|
759
|
+
});
|
|
760
|
+
inputEl.addEventListener('input', () => {
|
|
761
|
+
inputEl.style.height = 'auto';
|
|
762
|
+
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
|
763
|
+
updateSuggestions();
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
function connect() {
|
|
767
|
+
ws = new WebSocket(WS_URL);
|
|
768
|
+
ws.addEventListener('open', () => {
|
|
769
|
+
if (reconnectAnim) { clearInterval(reconnectAnim); reconnectAnim = null; }
|
|
770
|
+
reconnectOverlay.classList.remove('visible');
|
|
771
|
+
reconnectDelay = 1000;
|
|
772
|
+
setEnabled(true);
|
|
773
|
+
// Self-heal on every (re)connect, not just page load: if the server
|
|
774
|
+
// restarted it may have lost (or be rehydrating) our subscription, and
|
|
775
|
+
// browsers can rotate it. Re-registering here covers reconnects the
|
|
776
|
+
// disk-persisted server store can't see yet. The server dedupes by
|
|
777
|
+
// endpoint, so a redundant re-POST is harmless.
|
|
778
|
+
if (notifyEnabled()) { subscribePush().catch(function () {}); }
|
|
779
|
+
});
|
|
780
|
+
ws.addEventListener('message', (e) => {
|
|
781
|
+
try { handleMsg(JSON.parse(e.data)); } catch {}
|
|
782
|
+
});
|
|
783
|
+
ws.addEventListener('close', () => {
|
|
784
|
+
setEnabled(false);
|
|
785
|
+
reconnectOverlay.classList.add('visible');
|
|
786
|
+
// Animate the same braille spinner used elsewhere, with a live countdown.
|
|
787
|
+
const until = Date.now() + reconnectDelay;
|
|
788
|
+
let frame = 0;
|
|
789
|
+
const paint = () => {
|
|
790
|
+
const left = Math.max(0, Math.ceil((until - Date.now()) / 1000));
|
|
791
|
+
const glyph = SPIN[frame++ % SPIN.length];
|
|
792
|
+
reconnectMsg.textContent = left > 0
|
|
793
|
+
? glyph + ' connection lost — retrying in ' + left + 's'
|
|
794
|
+
: glyph + ' reconnecting…';
|
|
795
|
+
};
|
|
796
|
+
if (reconnectAnim) clearInterval(reconnectAnim);
|
|
797
|
+
paint();
|
|
798
|
+
reconnectAnim = setInterval(paint, 90);
|
|
799
|
+
setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); connect(); }, reconnectDelay);
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
connect();`;
|
|
804
|
+
}
|