@lelouchhe/webagent 0.1.7 → 0.1.10
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/README.md +21 -18
- package/config.toml +1 -1
- package/dist/index.html +2 -2
- package/dist/js/app.IXP5KGP6.js +8 -0
- package/dist/{styles.mmmmfxhu.css → styles.01a9ju9l.css} +8 -0
- package/dist/sw.js +1 -1
- package/package.json +7 -8
- package/dist/js/app.mmmmfxhu.js +0 -34
- package/dist/js/commands.mmmmfxhu.js +0 -647
- package/dist/js/connection.mmmmfxhu.js +0 -87
- package/dist/js/events.mmmmfxhu.js +0 -694
- package/dist/js/images.mmmmfxhu.js +0 -58
- package/dist/js/input.mmmmfxhu.js +0 -215
- package/dist/js/render.mmmmfxhu.js +0 -200
- package/dist/js/state.mmmmfxhu.js +0 -203
- package/lib/bridge.js +0 -284
- package/lib/config.js +0 -62
- package/lib/daemon.js +0 -278
- package/lib/event-handler.js +0 -95
- package/lib/push-service.js +0 -112
- package/lib/routes.js +0 -202
- package/lib/server.js +0 -70
- package/lib/session-manager.js +0 -199
- package/lib/store.js +0 -120
- package/lib/title-service.js +0 -71
- package/lib/types.js +0 -48
- package/lib/ws-handler.js +0 -280
|
@@ -1,694 +0,0 @@
|
|
|
1
|
-
// WebSocket event handling and history replay
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
state, dom, setBusy, setConfigValue, getConfigOption, updateConfigOptions,
|
|
5
|
-
updateModeUI, updateStatusBar, resetSessionUI, requestNewSession, setHashSessionId, updateSessionInfo,
|
|
6
|
-
setConnectionStatus, clearCancelTimer,
|
|
7
|
-
} from './state.mmmmfxhu.js';
|
|
8
|
-
import {
|
|
9
|
-
addMessage, addSystem, finishAssistant, finishThinking, hideWaiting,
|
|
10
|
-
scrollToBottom, renderMd, escHtml, renderPatchDiff, addBashBlock, finishBash, appendMessageElement,
|
|
11
|
-
formatLocalTime,
|
|
12
|
-
} from './render.mmmmfxhu.js';
|
|
13
|
-
|
|
14
|
-
const NOTIFY_TIP_KEY = 'webagent_notify_tip_shown';
|
|
15
|
-
const NOTIFY_TIP_DENIED_KEY = 'webagent_notify_tip_denied_shown';
|
|
16
|
-
|
|
17
|
-
function showNotifyTip() {
|
|
18
|
-
if (typeof Notification === 'undefined') return;
|
|
19
|
-
if (state.replayInProgress) return;
|
|
20
|
-
|
|
21
|
-
const perm = Notification.permission;
|
|
22
|
-
if (perm === 'granted') return; // already enabled
|
|
23
|
-
|
|
24
|
-
if (perm === 'denied') {
|
|
25
|
-
if (localStorage.getItem(NOTIFY_TIP_DENIED_KEY)) return;
|
|
26
|
-
localStorage.setItem(NOTIFY_TIP_DENIED_KEY, '1');
|
|
27
|
-
addSystem('tip: notifications are blocked — allow in browser site settings to enable');
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// permission === 'default'
|
|
32
|
-
if (localStorage.getItem(NOTIFY_TIP_KEY)) return;
|
|
33
|
-
localStorage.setItem(NOTIFY_TIP_KEY, '1');
|
|
34
|
-
addSystem('tip: use /notify to enable background notifications');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function finishPromptIfIdle() {
|
|
38
|
-
if (!state.pendingPromptDone) return;
|
|
39
|
-
if (state.pendingToolCallIds.size > 0 || state.pendingPermissionRequestIds.size > 0) return;
|
|
40
|
-
hideWaiting();
|
|
41
|
-
finishThinking();
|
|
42
|
-
finishAssistant();
|
|
43
|
-
setBusy(false);
|
|
44
|
-
state.pendingPromptDone = false;
|
|
45
|
-
showNotifyTip();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function cancelPendingTurnUI() {
|
|
49
|
-
for (const id of state.pendingToolCallIds) {
|
|
50
|
-
const el = document.getElementById(`tc-${id}`);
|
|
51
|
-
if (!el) continue;
|
|
52
|
-
el.className = 'tool-call failed';
|
|
53
|
-
const iconSpan = el.querySelector('.icon');
|
|
54
|
-
if (iconSpan) iconSpan.textContent = '✗';
|
|
55
|
-
}
|
|
56
|
-
for (const requestId of state.pendingPermissionRequestIds) {
|
|
57
|
-
const permEl = document.querySelector(`.permission[data-request-id="${requestId}"]`);
|
|
58
|
-
if (!permEl || !permEl.querySelector('button')) continue;
|
|
59
|
-
const titleEl = permEl.querySelector('.title');
|
|
60
|
-
const title = titleEl?.textContent || '⚿';
|
|
61
|
-
permEl.innerHTML = `<span style="opacity:0.5">${escHtml(title)} — cancelled</span>`;
|
|
62
|
-
}
|
|
63
|
-
state.pendingToolCallIds.clear();
|
|
64
|
-
state.pendingPermissionRequestIds.clear();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function loadHistory(sid) {
|
|
68
|
-
state.replayInProgress = true;
|
|
69
|
-
state.replayQueue = [];
|
|
70
|
-
try {
|
|
71
|
-
const res = await fetch(`/api/sessions/${sid}/events`);
|
|
72
|
-
if (!res.ok) return false;
|
|
73
|
-
const events = await res.json();
|
|
74
|
-
for (let i = 0; i < events.length; i++) {
|
|
75
|
-
const data = JSON.parse(events[i].data);
|
|
76
|
-
replayEvent(events[i].type, data, events, i);
|
|
77
|
-
}
|
|
78
|
-
if (events.length) {
|
|
79
|
-
state.lastEventSeq = events[events.length - 1].seq;
|
|
80
|
-
}
|
|
81
|
-
setSyncBoundary();
|
|
82
|
-
return true;
|
|
83
|
-
} catch {
|
|
84
|
-
return false;
|
|
85
|
-
} finally {
|
|
86
|
-
state.replayInProgress = false;
|
|
87
|
-
drainReplayQueue();
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Mark the last DOM child as the sync boundary for incremental reconnect. */
|
|
92
|
-
function setSyncBoundary() {
|
|
93
|
-
const prev = dom.messages.querySelector('[data-sync-boundary]');
|
|
94
|
-
if (prev) prev.removeAttribute('data-sync-boundary');
|
|
95
|
-
const last = dom.messages.lastElementChild;
|
|
96
|
-
if (last) last.setAttribute('data-sync-boundary', '');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Fetch only events added since the last sync point and replay them.
|
|
101
|
-
* Returns true if new events were applied (or none needed), false on error.
|
|
102
|
-
*/
|
|
103
|
-
export async function loadNewEvents(sid) {
|
|
104
|
-
state.replayInProgress = true;
|
|
105
|
-
state.replayQueue = [];
|
|
106
|
-
try {
|
|
107
|
-
const url = `/api/sessions/${sid}/events?after_seq=${state.lastEventSeq}`;
|
|
108
|
-
const res = await fetch(url);
|
|
109
|
-
if (!res.ok) return false;
|
|
110
|
-
const events = await res.json();
|
|
111
|
-
|
|
112
|
-
// Always remove DOM elements added after the sync boundary (live-rendered
|
|
113
|
-
// content that may be orphaned or overlap with new DB events), and reset
|
|
114
|
-
// in-progress streaming state. This must run even when the event list is
|
|
115
|
-
// empty so that partially-streamed elements left over from a disconnect
|
|
116
|
-
// don't stay in the DOM.
|
|
117
|
-
const boundary = dom.messages.querySelector('[data-sync-boundary]');
|
|
118
|
-
if (boundary) {
|
|
119
|
-
while (boundary.nextElementSibling) boundary.nextElementSibling.remove();
|
|
120
|
-
}
|
|
121
|
-
state.currentAssistantEl = null;
|
|
122
|
-
state.currentAssistantText = '';
|
|
123
|
-
state.currentThinkingEl = null;
|
|
124
|
-
state.currentThinkingText = '';
|
|
125
|
-
state.currentBashEl = null;
|
|
126
|
-
|
|
127
|
-
if (events.length === 0) return true;
|
|
128
|
-
|
|
129
|
-
for (let i = 0; i < events.length; i++) {
|
|
130
|
-
const data = JSON.parse(events[i].data);
|
|
131
|
-
replayEvent(events[i].type, data, events, i);
|
|
132
|
-
}
|
|
133
|
-
state.lastEventSeq = events[events.length - 1].seq;
|
|
134
|
-
setSyncBoundary();
|
|
135
|
-
return true;
|
|
136
|
-
} catch {
|
|
137
|
-
return false;
|
|
138
|
-
} finally {
|
|
139
|
-
state.replayInProgress = false;
|
|
140
|
-
drainReplayQueue();
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Resend permission responses that were sent optimistically but never confirmed
|
|
146
|
-
* by the server (e.g. WS dropped before delivery). Call after loadNewEvents/loadHistory
|
|
147
|
-
* on reconnect.
|
|
148
|
-
*/
|
|
149
|
-
export function retryUnconfirmedPermissions() {
|
|
150
|
-
for (const [requestId, response] of state.unconfirmedPermissions) {
|
|
151
|
-
const el = document.querySelector(`.permission[data-request-id="${requestId}"]`);
|
|
152
|
-
if (!el || !el.querySelector('button')) {
|
|
153
|
-
// Element gone or already resolved — clean up
|
|
154
|
-
state.unconfirmedPermissions.delete(requestId);
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
// Still pending in DOM — resend and optimistically resolve
|
|
158
|
-
if (state.ws && state.ws.readyState === 1) {
|
|
159
|
-
state.ws.send(JSON.stringify({
|
|
160
|
-
type: 'permission_response',
|
|
161
|
-
sessionId: response.sessionId,
|
|
162
|
-
requestId,
|
|
163
|
-
optionId: response.optionId,
|
|
164
|
-
optionName: response.optionName,
|
|
165
|
-
denied: response.denied,
|
|
166
|
-
}));
|
|
167
|
-
}
|
|
168
|
-
const title = el.dataset.title ? `⚿ ${escHtml(el.dataset.title)}` : '⚿';
|
|
169
|
-
el.innerHTML = `<span style="opacity:0.5">${title} — ${escHtml(response.optionName)}</span>`;
|
|
170
|
-
state.unconfirmedPermissions.delete(requestId);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function replayEvent(type, data, events, idx) {
|
|
175
|
-
switch (type) {
|
|
176
|
-
case 'user_message': {
|
|
177
|
-
const el = addMessage('user', data.text);
|
|
178
|
-
if (data.images) {
|
|
179
|
-
for (const img of data.images) {
|
|
180
|
-
const imgEl = document.createElement('img');
|
|
181
|
-
imgEl.className = 'user-image';
|
|
182
|
-
imgEl.src = `/data/${img.path}`;
|
|
183
|
-
el.appendChild(imgEl);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
break;
|
|
187
|
-
}
|
|
188
|
-
case 'assistant_message':
|
|
189
|
-
addMessage('assistant', data.text);
|
|
190
|
-
break;
|
|
191
|
-
case 'thinking': {
|
|
192
|
-
const el = document.createElement('details');
|
|
193
|
-
el.className = 'thinking';
|
|
194
|
-
el.innerHTML = `<summary>⠿ thought</summary><div class="thinking-content">${escHtml(data.text)}</div>`;
|
|
195
|
-
appendMessageElement(el);
|
|
196
|
-
break;
|
|
197
|
-
}
|
|
198
|
-
case 'tool_call': {
|
|
199
|
-
const icons = { read: 'cat', edit: 'edit', execute: 'exec', search: 'find', delete: 'rm' };
|
|
200
|
-
const icon = icons[data.kind] || 'run';
|
|
201
|
-
const el = document.createElement('div');
|
|
202
|
-
el.className = 'tool-call';
|
|
203
|
-
el.id = `tc-${data.id}`;
|
|
204
|
-
let label = `<span class="icon">${icon}</span> ${escHtml(data.title)}`;
|
|
205
|
-
const ri = data.rawInput;
|
|
206
|
-
if (ri && ri.command) {
|
|
207
|
-
label += `<span class="tc-detail">$ ${escHtml(ri.command)}</span>`;
|
|
208
|
-
} else if (ri && ri.path) {
|
|
209
|
-
label += `<span class="tc-detail">${escHtml(ri.path)}</span>`;
|
|
210
|
-
}
|
|
211
|
-
el.innerHTML = label;
|
|
212
|
-
const diffHtml = data.kind === 'edit' ? renderPatchDiff(ri) : null;
|
|
213
|
-
if (diffHtml) {
|
|
214
|
-
const details = document.createElement('details');
|
|
215
|
-
details.innerHTML = `<summary>diff</summary><div class="diff-view">${diffHtml}</div>`;
|
|
216
|
-
el.appendChild(details);
|
|
217
|
-
}
|
|
218
|
-
const detail = el.querySelector('.tc-detail');
|
|
219
|
-
if (detail) {
|
|
220
|
-
el.addEventListener('click', (e) => {
|
|
221
|
-
if (e.target.closest('details')) return;
|
|
222
|
-
detail.classList.toggle('expanded');
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
appendMessageElement(el);
|
|
226
|
-
break;
|
|
227
|
-
}
|
|
228
|
-
case 'tool_call_update': {
|
|
229
|
-
const el = document.getElementById(`tc-${data.id}`);
|
|
230
|
-
if (el) {
|
|
231
|
-
const statusIcon = data.status === 'completed' ? '✓' : data.status === 'failed' ? '✗' : '…';
|
|
232
|
-
el.className = `tool-call ${data.status}`;
|
|
233
|
-
const iconSpan = el.querySelector('.icon');
|
|
234
|
-
if (iconSpan) iconSpan.textContent = statusIcon;
|
|
235
|
-
}
|
|
236
|
-
// Clear pending state so prompt_done can finish after reconnect replay
|
|
237
|
-
if (data.status === 'completed' || data.status === 'failed') {
|
|
238
|
-
state.pendingToolCallIds.delete(data.id);
|
|
239
|
-
}
|
|
240
|
-
break;
|
|
241
|
-
}
|
|
242
|
-
case 'plan': {
|
|
243
|
-
const el = document.createElement('div');
|
|
244
|
-
el.className = 'plan';
|
|
245
|
-
el.innerHTML = '<div class="plan-title">― plan</div>' +
|
|
246
|
-
(data.entries || []).map(e => {
|
|
247
|
-
const s = { pending: '○', in_progress: '◉', completed: '●' }[e.status] || '?';
|
|
248
|
-
return `<div class="plan-entry">${s} ${escHtml(e.content)}</div>`;
|
|
249
|
-
}).join('');
|
|
250
|
-
appendMessageElement(el);
|
|
251
|
-
break;
|
|
252
|
-
}
|
|
253
|
-
case 'permission_request': {
|
|
254
|
-
const el = document.createElement('div');
|
|
255
|
-
el.className = 'permission';
|
|
256
|
-
el.dataset.requestId = data.requestId;
|
|
257
|
-
el.dataset.title = data.title || '';
|
|
258
|
-
el.innerHTML = `<span class="title" style="opacity:0.5">⚿ ${escHtml(data.title)}</span> `;
|
|
259
|
-
// Check if this permission was already resolved later in history
|
|
260
|
-
const wasResolved = events && events.slice(idx + 1).some(e =>
|
|
261
|
-
e.type === 'permission_response' && JSON.parse(e.data).requestId === data.requestId
|
|
262
|
-
);
|
|
263
|
-
if (!wasResolved && data.options) {
|
|
264
|
-
el.querySelector('.title').style.opacity = '1';
|
|
265
|
-
data.options.forEach(opt => {
|
|
266
|
-
const btn = document.createElement('button');
|
|
267
|
-
const isAllow = (opt.kind || '').includes('allow');
|
|
268
|
-
btn.className = isAllow ? 'allow' : 'deny';
|
|
269
|
-
btn.textContent = opt.name;
|
|
270
|
-
btn.onclick = () => {
|
|
271
|
-
const isDeny = (opt.kind || '').includes('reject') || (opt.kind || '').includes('deny');
|
|
272
|
-
state.ws.send(JSON.stringify({
|
|
273
|
-
type: 'permission_response',
|
|
274
|
-
sessionId: state.sessionId,
|
|
275
|
-
requestId: data.requestId,
|
|
276
|
-
optionId: opt.optionId,
|
|
277
|
-
optionName: opt.name,
|
|
278
|
-
denied: isDeny,
|
|
279
|
-
}));
|
|
280
|
-
el.innerHTML = `<span style="opacity:0.5">⚿ ${escHtml(data.title)} — ${escHtml(opt.name)}</span>`;
|
|
281
|
-
};
|
|
282
|
-
el.appendChild(btn);
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
appendMessageElement(el);
|
|
286
|
-
break;
|
|
287
|
-
}
|
|
288
|
-
case 'permission_response': {
|
|
289
|
-
const el = document.querySelector(`.permission[data-request-id="${data.requestId}"]`);
|
|
290
|
-
if (el) {
|
|
291
|
-
const title = el.dataset.title ? `⚿ ${el.dataset.title}` : '⚿';
|
|
292
|
-
const action = data.optionName || (data.denied ? 'denied' : 'allowed');
|
|
293
|
-
el.innerHTML = `<span style="opacity:0.5">${escHtml(title)} — ${escHtml(action)}</span>`;
|
|
294
|
-
}
|
|
295
|
-
break;
|
|
296
|
-
}
|
|
297
|
-
case 'bash_command': {
|
|
298
|
-
const el = addBashBlock(data.command, false);
|
|
299
|
-
el.id = 'bash-replay-pending';
|
|
300
|
-
break;
|
|
301
|
-
}
|
|
302
|
-
case 'bash_result': {
|
|
303
|
-
const el = document.getElementById('bash-replay-pending');
|
|
304
|
-
if (el) {
|
|
305
|
-
el.removeAttribute('id');
|
|
306
|
-
if (data.output) {
|
|
307
|
-
const out = el.querySelector('.bash-output');
|
|
308
|
-
out.textContent = data.output;
|
|
309
|
-
out.classList.add('has-content');
|
|
310
|
-
}
|
|
311
|
-
finishBash(el, data.code, data.signal);
|
|
312
|
-
}
|
|
313
|
-
break;
|
|
314
|
-
}
|
|
315
|
-
case 'prompt_done':
|
|
316
|
-
state.pendingToolCallIds.clear();
|
|
317
|
-
state.pendingPermissionRequestIds.clear();
|
|
318
|
-
state.pendingPromptDone = false;
|
|
319
|
-
setBusy(false);
|
|
320
|
-
break;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** Process queued WS events, skipping any that duplicate content already in the DOM. */
|
|
325
|
-
function drainReplayQueue() {
|
|
326
|
-
const queue = state.replayQueue;
|
|
327
|
-
state.replayQueue = [];
|
|
328
|
-
for (const msg of queue) {
|
|
329
|
-
if (isDuplicateOfReplay(msg)) continue;
|
|
330
|
-
handleEvent(msg);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/** Check whether a queued WS event duplicates an element already rendered by replay. */
|
|
335
|
-
function isDuplicateOfReplay(msg) {
|
|
336
|
-
switch (msg.type) {
|
|
337
|
-
case 'tool_call':
|
|
338
|
-
return !!document.getElementById(`tc-${msg.id}`);
|
|
339
|
-
case 'permission_request':
|
|
340
|
-
return !!document.querySelector(`.permission[data-request-id="${msg.requestId}"]`);
|
|
341
|
-
default:
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
export function handleEvent(msg) {
|
|
347
|
-
// Queue events that arrive while history replay is in progress to avoid duplicates
|
|
348
|
-
if (state.replayInProgress) {
|
|
349
|
-
state.replayQueue.push(msg);
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Ignore events from other sessions (multi-client broadcast)
|
|
354
|
-
if (msg.sessionId && state.sessionId && msg.sessionId !== state.sessionId
|
|
355
|
-
&& msg.type !== 'session_created' && msg.type !== 'session_deleted') {
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
switch (msg.type) {
|
|
359
|
-
case 'connected':
|
|
360
|
-
if (msg.cancelTimeout != null) state.cancelTimeout = msg.cancelTimeout;
|
|
361
|
-
break;
|
|
362
|
-
|
|
363
|
-
case 'session_created':
|
|
364
|
-
// Only switch to the new session if this client requested it
|
|
365
|
-
if (!state.awaitingNewSession && state.sessionId && msg.sessionId !== state.sessionId) {
|
|
366
|
-
break;
|
|
367
|
-
}
|
|
368
|
-
state.awaitingNewSession = false;
|
|
369
|
-
state.sessionId = msg.sessionId;
|
|
370
|
-
state.sessionCwd = msg.cwd || state.sessionCwd;
|
|
371
|
-
state.sessionTitle = msg.title || null;
|
|
372
|
-
if (msg.configOptions?.length) updateConfigOptions(msg.configOptions);
|
|
373
|
-
setHashSessionId(state.sessionId);
|
|
374
|
-
updateSessionInfo(state.sessionId, state.sessionTitle);
|
|
375
|
-
setConnectionStatus('connected', 'connected');
|
|
376
|
-
dom.input.disabled = false;
|
|
377
|
-
dom.sendBtn.disabled = false;
|
|
378
|
-
dom.input.placeholder = '';
|
|
379
|
-
setBusy(Boolean(msg.busyKind));
|
|
380
|
-
state.newTurnStarted = false;
|
|
381
|
-
if (msg.busyKind === 'bash') {
|
|
382
|
-
const pendingBashEl = document.getElementById('bash-replay-pending');
|
|
383
|
-
if (pendingBashEl) {
|
|
384
|
-
pendingBashEl.removeAttribute('id');
|
|
385
|
-
pendingBashEl.querySelector('.bash-cmd')?.classList.add('running');
|
|
386
|
-
state.currentBashEl = pendingBashEl;
|
|
387
|
-
}
|
|
388
|
-
} else {
|
|
389
|
-
state.currentBashEl = null;
|
|
390
|
-
}
|
|
391
|
-
if (dom.messages.children.length === 0) {
|
|
392
|
-
addSystem(`Session created: ${state.sessionTitle || msg.sessionId.slice(0, 8) + '…'}`);
|
|
393
|
-
}
|
|
394
|
-
updateStatusBar();
|
|
395
|
-
break;
|
|
396
|
-
|
|
397
|
-
case 'user_message': {
|
|
398
|
-
// A new turn is starting (from another client's broadcast).
|
|
399
|
-
// Finalise any in-progress streaming from the previous turn so
|
|
400
|
-
// subsequent message_chunks create a fresh element BELOW this bubble.
|
|
401
|
-
finishThinking();
|
|
402
|
-
finishAssistant();
|
|
403
|
-
state.newTurnStarted = true;
|
|
404
|
-
state.turnEnded = false;
|
|
405
|
-
if (msg.sessionId === state.sessionId) {
|
|
406
|
-
const el = addMessage('user', msg.text);
|
|
407
|
-
if (msg.images) {
|
|
408
|
-
for (const img of msg.images) {
|
|
409
|
-
const imgEl = document.createElement('img');
|
|
410
|
-
imgEl.className = 'user-image';
|
|
411
|
-
imgEl.src = `/data/${img.path}`;
|
|
412
|
-
el.appendChild(imgEl);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
break;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
case 'message_chunk':
|
|
420
|
-
state.turnEnded = false;
|
|
421
|
-
hideWaiting();
|
|
422
|
-
finishThinking();
|
|
423
|
-
if (!state.currentAssistantEl) {
|
|
424
|
-
state.currentAssistantEl = addMessage('assistant', '');
|
|
425
|
-
state.currentAssistantText = '';
|
|
426
|
-
}
|
|
427
|
-
state.currentAssistantText += msg.text;
|
|
428
|
-
state.currentAssistantEl.innerHTML = renderMd(state.currentAssistantText);
|
|
429
|
-
scrollToBottom();
|
|
430
|
-
break;
|
|
431
|
-
|
|
432
|
-
case 'thought_chunk':
|
|
433
|
-
state.turnEnded = false;
|
|
434
|
-
hideWaiting();
|
|
435
|
-
if (!state.currentThinkingEl) {
|
|
436
|
-
state.currentThinkingEl = document.createElement('details');
|
|
437
|
-
state.currentThinkingEl.className = 'thinking';
|
|
438
|
-
state.currentThinkingEl.innerHTML = '<summary class="active">⠿ thinking...</summary><div class="thinking-content"></div>';
|
|
439
|
-
state.currentThinkingText = '';
|
|
440
|
-
appendMessageElement(state.currentThinkingEl);
|
|
441
|
-
}
|
|
442
|
-
state.currentThinkingText += msg.text;
|
|
443
|
-
state.currentThinkingEl.querySelector('.thinking-content').textContent = state.currentThinkingText;
|
|
444
|
-
scrollToBottom();
|
|
445
|
-
break;
|
|
446
|
-
|
|
447
|
-
case 'tool_call': {
|
|
448
|
-
if (state.turnEnded) break;
|
|
449
|
-
state.pendingToolCallIds.add(msg.id);
|
|
450
|
-
setBusy(true);
|
|
451
|
-
hideWaiting();
|
|
452
|
-
finishThinking();
|
|
453
|
-
finishAssistant();
|
|
454
|
-
const icons = { read: 'cat', edit: 'edit', execute: 'exec', search: 'find', delete: 'rm' };
|
|
455
|
-
const icon = icons[msg.kind] || 'run';
|
|
456
|
-
const el = document.createElement('div');
|
|
457
|
-
el.className = 'tool-call';
|
|
458
|
-
el.id = `tc-${msg.id}`;
|
|
459
|
-
let label = `<span class="icon">${icon}</span> ${escHtml(msg.title)}`;
|
|
460
|
-
const ri = msg.rawInput;
|
|
461
|
-
if (ri && ri.command) {
|
|
462
|
-
label += `<span class="tc-detail">$ ${escHtml(ri.command)}</span>`;
|
|
463
|
-
} else if (ri && ri.path) {
|
|
464
|
-
label += `<span class="tc-detail">${escHtml(ri.path)}</span>`;
|
|
465
|
-
}
|
|
466
|
-
el.innerHTML = label;
|
|
467
|
-
const diffHtml = msg.kind === 'edit' ? renderPatchDiff(ri) : null;
|
|
468
|
-
if (diffHtml) {
|
|
469
|
-
const details = document.createElement('details');
|
|
470
|
-
details.innerHTML = `<summary>diff</summary><div class="diff-view">${diffHtml}</div>`;
|
|
471
|
-
el.appendChild(details);
|
|
472
|
-
}
|
|
473
|
-
const detail = el.querySelector('.tc-detail');
|
|
474
|
-
if (detail) {
|
|
475
|
-
el.addEventListener('click', (e) => {
|
|
476
|
-
// Don't toggle tc-detail when clicking inside a <details> element
|
|
477
|
-
if (e.target.closest('details')) return;
|
|
478
|
-
detail.classList.toggle('expanded');
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
appendMessageElement(el);
|
|
482
|
-
break;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
case 'tool_call_update': {
|
|
486
|
-
const el = document.getElementById(`tc-${msg.id}`);
|
|
487
|
-
if (msg.status === 'completed' || msg.status === 'failed') {
|
|
488
|
-
state.pendingToolCallIds.delete(msg.id);
|
|
489
|
-
}
|
|
490
|
-
if (el) {
|
|
491
|
-
const statusIcon = msg.status === 'completed' ? '✓' : msg.status === 'failed' ? '✗' : '…';
|
|
492
|
-
el.className = `tool-call ${msg.status}`;
|
|
493
|
-
const iconSpan = el.querySelector('.icon');
|
|
494
|
-
if (iconSpan) iconSpan.textContent = statusIcon;
|
|
495
|
-
if (msg.content && msg.content.length && !el.querySelector('details')) {
|
|
496
|
-
const text = msg.content
|
|
497
|
-
.map(c => {
|
|
498
|
-
if (c.type === 'terminal') return `[terminal ${c.terminalId}]`;
|
|
499
|
-
if (c.content?.text) return c.content.text;
|
|
500
|
-
if (Array.isArray(c.content)) return c.content.map(cc => cc.text || '').join('');
|
|
501
|
-
return '';
|
|
502
|
-
})
|
|
503
|
-
.filter(Boolean).join('\n');
|
|
504
|
-
if (text) {
|
|
505
|
-
const details = document.createElement('details');
|
|
506
|
-
details.innerHTML = `<summary>output</summary><div class="tc-content">${escHtml(text)}</div>`;
|
|
507
|
-
el.appendChild(details);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
finishPromptIfIdle();
|
|
512
|
-
scrollToBottom();
|
|
513
|
-
break;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
case 'plan': {
|
|
517
|
-
finishThinking();
|
|
518
|
-
finishAssistant();
|
|
519
|
-
const el = document.createElement('div');
|
|
520
|
-
el.className = 'plan';
|
|
521
|
-
el.innerHTML = '<div class="plan-title">― plan</div>' +
|
|
522
|
-
msg.entries.map(e => {
|
|
523
|
-
const s = { pending: '○', in_progress: '◉', completed: '●' }[e.status] || '?';
|
|
524
|
-
return `<div class="plan-entry">${s} ${escHtml(e.content)}</div>`;
|
|
525
|
-
}).join('');
|
|
526
|
-
appendMessageElement(el);
|
|
527
|
-
break;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
case 'permission_request': {
|
|
531
|
-
if (state.turnEnded) break;
|
|
532
|
-
// Dedup: skip if a permission element with this requestId already exists (e.g. bridge restore)
|
|
533
|
-
if (document.querySelector(`.permission[data-request-id="${msg.requestId}"]`)) break;
|
|
534
|
-
state.pendingPermissionRequestIds.add(msg.requestId);
|
|
535
|
-
setBusy(true);
|
|
536
|
-
finishThinking();
|
|
537
|
-
const permEl = document.createElement('div');
|
|
538
|
-
permEl.className = 'permission';
|
|
539
|
-
permEl.dataset.requestId = msg.requestId;
|
|
540
|
-
permEl.dataset.title = msg.title || '';
|
|
541
|
-
permEl.innerHTML = `<span class="title">⚿ ${escHtml(msg.title)}</span> `;
|
|
542
|
-
msg.options.forEach(opt => {
|
|
543
|
-
const btn = document.createElement('button');
|
|
544
|
-
const isAllow = (opt.kind || '').includes('allow');
|
|
545
|
-
btn.className = isAllow ? 'allow' : 'deny';
|
|
546
|
-
btn.textContent = opt.name;
|
|
547
|
-
btn.onclick = () => {
|
|
548
|
-
const isDeny = (opt.kind || '').includes('reject') || (opt.kind || '').includes('deny');
|
|
549
|
-
try {
|
|
550
|
-
state.ws.send(JSON.stringify({
|
|
551
|
-
type: 'permission_response',
|
|
552
|
-
sessionId: state.sessionId,
|
|
553
|
-
requestId: msg.requestId,
|
|
554
|
-
optionId: opt.optionId,
|
|
555
|
-
optionName: opt.name,
|
|
556
|
-
denied: isDeny,
|
|
557
|
-
}));
|
|
558
|
-
} catch { /* connection may be broken; cleanup still runs */ }
|
|
559
|
-
state.pendingPermissionRequestIds.delete(msg.requestId);
|
|
560
|
-
// Track for retry on reconnect (cleared when server confirms via permission_resolved)
|
|
561
|
-
state.unconfirmedPermissions.set(msg.requestId, {
|
|
562
|
-
sessionId: state.sessionId,
|
|
563
|
-
optionId: opt.optionId,
|
|
564
|
-
optionName: opt.name,
|
|
565
|
-
denied: isDeny,
|
|
566
|
-
});
|
|
567
|
-
permEl.innerHTML = `<span style="opacity:0.5">⚿ ${escHtml(msg.title)} — ${escHtml(opt.name)}</span>`;
|
|
568
|
-
finishPromptIfIdle();
|
|
569
|
-
};
|
|
570
|
-
permEl.appendChild(btn);
|
|
571
|
-
});
|
|
572
|
-
appendMessageElement(permEl);
|
|
573
|
-
break;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
case 'permission_resolved': {
|
|
577
|
-
state.pendingPermissionRequestIds.delete(msg.requestId);
|
|
578
|
-
state.unconfirmedPermissions.delete(msg.requestId);
|
|
579
|
-
const permTarget = document.querySelector(`.permission[data-request-id="${msg.requestId}"]`);
|
|
580
|
-
if (msg.sessionId === state.sessionId && permTarget) {
|
|
581
|
-
const title = permTarget.dataset.title ? `⚿ ${permTarget.dataset.title}` : '⚿';
|
|
582
|
-
const action = msg.optionName || (msg.denied ? 'denied' : 'allowed');
|
|
583
|
-
permTarget.innerHTML = `<span style="opacity:0.5">${escHtml(title)} — ${escHtml(action)}</span>`;
|
|
584
|
-
}
|
|
585
|
-
finishPromptIfIdle();
|
|
586
|
-
break;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
case 'bash_command': {
|
|
590
|
-
if (msg.sessionId === state.sessionId) {
|
|
591
|
-
addBashBlock(msg.command, true);
|
|
592
|
-
setBusy(true);
|
|
593
|
-
}
|
|
594
|
-
break;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
case 'bash_output': {
|
|
598
|
-
if (msg.sessionId !== state.sessionId) break;
|
|
599
|
-
if (state.currentBashEl) {
|
|
600
|
-
const out = state.currentBashEl.querySelector('.bash-output');
|
|
601
|
-
if (msg.stream === 'stderr') {
|
|
602
|
-
const span = document.createElement('span');
|
|
603
|
-
span.className = 'stderr';
|
|
604
|
-
span.textContent = msg.text;
|
|
605
|
-
out.appendChild(span);
|
|
606
|
-
} else {
|
|
607
|
-
out.appendChild(document.createTextNode(msg.text));
|
|
608
|
-
}
|
|
609
|
-
out.classList.add('has-content');
|
|
610
|
-
out.scrollTop = out.scrollHeight;
|
|
611
|
-
scrollToBottom();
|
|
612
|
-
}
|
|
613
|
-
break;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
case 'bash_done': {
|
|
617
|
-
if (msg.sessionId !== state.sessionId) break;
|
|
618
|
-
finishBash(state.currentBashEl, msg.code, msg.signal);
|
|
619
|
-
if (msg.error) addSystem(`err: ${msg.error}`);
|
|
620
|
-
setBusy(false);
|
|
621
|
-
break;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
case 'prompt_done': {
|
|
625
|
-
clearCancelTimer();
|
|
626
|
-
if (msg.stopReason === 'cancelled' && state.newTurnStarted) {
|
|
627
|
-
// This prompt_done belongs to a previous turn — a new turn has already
|
|
628
|
-
// started (signaled by user_message from another client). Don't clobber
|
|
629
|
-
// the current turn's pending state; just tidy up leftover streaming elements.
|
|
630
|
-
state.newTurnStarted = false;
|
|
631
|
-
finishThinking();
|
|
632
|
-
finishAssistant();
|
|
633
|
-
break;
|
|
634
|
-
}
|
|
635
|
-
state.newTurnStarted = false;
|
|
636
|
-
if (msg.stopReason === 'cancelled') {
|
|
637
|
-
cancelPendingTurnUI();
|
|
638
|
-
}
|
|
639
|
-
state.turnEnded = true;
|
|
640
|
-
state.pendingPromptDone = true;
|
|
641
|
-
finishPromptIfIdle();
|
|
642
|
-
break;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
case 'session_deleted':
|
|
646
|
-
if (msg.sessionId === state.sessionId) {
|
|
647
|
-
addSystem('warn: This session has been deleted.');
|
|
648
|
-
dom.input.disabled = true;
|
|
649
|
-
dom.sendBtn.disabled = true;
|
|
650
|
-
dom.input.placeholder = 'Session deleted';
|
|
651
|
-
}
|
|
652
|
-
break;
|
|
653
|
-
|
|
654
|
-
case 'session_expired':
|
|
655
|
-
resetSessionUI();
|
|
656
|
-
addSystem('warn: Previous session expired, created new one.');
|
|
657
|
-
requestNewSession();
|
|
658
|
-
break;
|
|
659
|
-
|
|
660
|
-
case 'config_set': {
|
|
661
|
-
setConfigValue(msg.configId, msg.value);
|
|
662
|
-
const opt = getConfigOption(msg.configId);
|
|
663
|
-
const label = opt?.name || msg.configId;
|
|
664
|
-
const valueName = opt?.options.find(o => o.value === msg.value)?.name || msg.value;
|
|
665
|
-
addSystem(`ok: ${label}: ${valueName}`);
|
|
666
|
-
if (msg.configId === 'mode') updateModeUI();
|
|
667
|
-
updateStatusBar();
|
|
668
|
-
break;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
case 'config_option_update':
|
|
672
|
-
if (msg.configOptions?.length) updateConfigOptions(msg.configOptions);
|
|
673
|
-
break;
|
|
674
|
-
|
|
675
|
-
case 'session_title_updated':
|
|
676
|
-
if (msg.sessionId === state.sessionId) {
|
|
677
|
-
state.sessionTitle = msg.title;
|
|
678
|
-
updateSessionInfo(state.sessionId, state.sessionTitle);
|
|
679
|
-
}
|
|
680
|
-
break;
|
|
681
|
-
|
|
682
|
-
case 'error':
|
|
683
|
-
state.awaitingNewSession = false;
|
|
684
|
-
state.pendingToolCallIds.clear();
|
|
685
|
-
state.pendingPermissionRequestIds.clear();
|
|
686
|
-
state.pendingPromptDone = false;
|
|
687
|
-
hideWaiting();
|
|
688
|
-
finishThinking();
|
|
689
|
-
finishAssistant();
|
|
690
|
-
addSystem(`err: ${msg.message}`);
|
|
691
|
-
setBusy(false);
|
|
692
|
-
break;
|
|
693
|
-
}
|
|
694
|
-
}
|