@agent-link/server 0.1.124 → 0.1.126
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/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +54 -54
- package/web/app.js +1192 -1192
- package/web/favicon.svg +10 -10
- package/web/landing.html +1262 -1241
- package/web/landing.zh.html +1261 -0
- package/web/modules/connection.js +880 -880
- package/web/modules/fileBrowser.js +379 -379
- package/web/modules/filePreview.js +187 -187
- package/web/modules/sidebar.js +376 -376
- package/web/modules/streaming.js +110 -110
- package/web/style.css +2941 -2941
|
@@ -1,880 +1,880 @@
|
|
|
1
|
-
// ── WebSocket connection, message routing, reconnection ──────────────────────
|
|
2
|
-
import { encrypt, decrypt, isEncrypted, decodeKey } from '../encryption.js';
|
|
3
|
-
import { isContextSummary } from './messageHelpers.js';
|
|
4
|
-
|
|
5
|
-
const MAX_RECONNECT_ATTEMPTS = 50;
|
|
6
|
-
const RECONNECT_BASE_DELAY = 1000;
|
|
7
|
-
const RECONNECT_MAX_DELAY = 15000;
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Creates the WebSocket connection controller.
|
|
11
|
-
* @param {object} deps - All reactive state and callbacks needed
|
|
12
|
-
*/
|
|
13
|
-
export function createConnection(deps) {
|
|
14
|
-
const {
|
|
15
|
-
status, agentName, hostname, workDir, sessionId, error,
|
|
16
|
-
serverVersion, agentVersion, latency,
|
|
17
|
-
messages, isProcessing, isCompacting, visibleLimit, queuedMessages, usageStats,
|
|
18
|
-
historySessions, currentClaudeSessionId, needsResume, loadingSessions, loadingHistory,
|
|
19
|
-
folderPickerLoading, folderPickerEntries, folderPickerPath,
|
|
20
|
-
authRequired, authPassword, authError, authAttempts, authLocked,
|
|
21
|
-
streaming, sidebar,
|
|
22
|
-
scrollToBottom,
|
|
23
|
-
// Multi-session parallel
|
|
24
|
-
currentConversationId, processingConversations, conversationCache,
|
|
25
|
-
switchConversation,
|
|
26
|
-
} = deps;
|
|
27
|
-
|
|
28
|
-
// Dequeue callback — set after creation to resolve circular dependency
|
|
29
|
-
let _dequeueNext = () => {};
|
|
30
|
-
function setDequeueNext(fn) { _dequeueNext = fn; }
|
|
31
|
-
|
|
32
|
-
// File browser — set after creation to resolve circular dependency
|
|
33
|
-
let fileBrowser = null;
|
|
34
|
-
function setFileBrowser(fb) { fileBrowser = fb; }
|
|
35
|
-
|
|
36
|
-
// File preview — set after creation to resolve circular dependency
|
|
37
|
-
let filePreview = null;
|
|
38
|
-
function setFilePreview(fp) { filePreview = fp; }
|
|
39
|
-
|
|
40
|
-
let ws = null;
|
|
41
|
-
let sessionKey = null;
|
|
42
|
-
let reconnectAttempts = 0;
|
|
43
|
-
let reconnectTimer = null;
|
|
44
|
-
let pingTimer = null;
|
|
45
|
-
const toolMsgMap = new Map(); // toolId -> message (for fast tool_result lookup)
|
|
46
|
-
|
|
47
|
-
// ── toolMsgMap save/restore for conversation switching ──
|
|
48
|
-
function getToolMsgMap() { return new Map(toolMsgMap); }
|
|
49
|
-
function restoreToolMsgMap(map) { toolMsgMap.clear(); for (const [k, v] of map) toolMsgMap.set(k, v); }
|
|
50
|
-
function clearToolMsgMap() { toolMsgMap.clear(); }
|
|
51
|
-
|
|
52
|
-
// ── Background conversation routing ──
|
|
53
|
-
// When a message arrives for a conversation that is not the current foreground,
|
|
54
|
-
// update its cached state directly (no streaming animation).
|
|
55
|
-
function routeToBackgroundConversation(convId, msg) {
|
|
56
|
-
const cache = conversationCache.value[convId];
|
|
57
|
-
if (!cache) return; // no cache entry — discard
|
|
58
|
-
|
|
59
|
-
if (msg.type === 'session_started') {
|
|
60
|
-
// Claude session ID captured for background conversation
|
|
61
|
-
cache.claudeSessionId = msg.claudeSessionId;
|
|
62
|
-
sidebar.requestSessionList();
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (msg.type === 'conversation_resumed') {
|
|
67
|
-
cache.claudeSessionId = msg.claudeSessionId;
|
|
68
|
-
if (msg.history && Array.isArray(msg.history)) {
|
|
69
|
-
const batch = [];
|
|
70
|
-
for (const h of msg.history) {
|
|
71
|
-
if (h.role === 'user') {
|
|
72
|
-
if (isContextSummary(h.content)) {
|
|
73
|
-
batch.push({
|
|
74
|
-
id: ++cache.messageIdCounter, role: 'context-summary',
|
|
75
|
-
content: h.content, contextExpanded: false,
|
|
76
|
-
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
77
|
-
});
|
|
78
|
-
} else if (h.isCommandOutput) {
|
|
79
|
-
batch.push({
|
|
80
|
-
id: ++cache.messageIdCounter, role: 'system',
|
|
81
|
-
content: h.content, isCommandOutput: true,
|
|
82
|
-
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
83
|
-
});
|
|
84
|
-
} else {
|
|
85
|
-
batch.push({
|
|
86
|
-
id: ++cache.messageIdCounter, role: 'user',
|
|
87
|
-
content: h.content,
|
|
88
|
-
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
} else if (h.role === 'assistant') {
|
|
92
|
-
const last = batch[batch.length - 1];
|
|
93
|
-
if (last && last.role === 'assistant' && !last.isStreaming) {
|
|
94
|
-
last.content += '\n\n' + h.content;
|
|
95
|
-
} else {
|
|
96
|
-
batch.push({
|
|
97
|
-
id: ++cache.messageIdCounter, role: 'assistant',
|
|
98
|
-
content: h.content, isStreaming: false,
|
|
99
|
-
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
} else if (h.role === 'tool') {
|
|
103
|
-
batch.push({
|
|
104
|
-
id: ++cache.messageIdCounter, role: 'tool',
|
|
105
|
-
toolId: h.toolId || '', toolName: h.toolName || 'unknown',
|
|
106
|
-
toolInput: h.toolInput || '', hasResult: true,
|
|
107
|
-
expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'),
|
|
108
|
-
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
cache.messages = batch;
|
|
113
|
-
if (cache.toolMsgMap) cache.toolMsgMap.clear();
|
|
114
|
-
}
|
|
115
|
-
cache.loadingHistory = false;
|
|
116
|
-
if (msg.isCompacting) {
|
|
117
|
-
cache.isCompacting = true;
|
|
118
|
-
cache.isProcessing = true;
|
|
119
|
-
processingConversations.value[convId] = true;
|
|
120
|
-
cache.messages.push({
|
|
121
|
-
id: ++cache.messageIdCounter, role: 'system',
|
|
122
|
-
content: 'Context compacting...', isCompactStart: true,
|
|
123
|
-
timestamp: new Date(),
|
|
124
|
-
});
|
|
125
|
-
} else if (msg.isProcessing) {
|
|
126
|
-
cache.isProcessing = true;
|
|
127
|
-
processingConversations.value[convId] = true;
|
|
128
|
-
cache.messages.push({
|
|
129
|
-
id: ++cache.messageIdCounter, role: 'system',
|
|
130
|
-
content: 'Agent is processing...',
|
|
131
|
-
timestamp: new Date(),
|
|
132
|
-
});
|
|
133
|
-
} else {
|
|
134
|
-
cache.messages.push({
|
|
135
|
-
id: ++cache.messageIdCounter, role: 'system',
|
|
136
|
-
content: 'Session restored. You can continue the conversation.',
|
|
137
|
-
timestamp: new Date(),
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (msg.type === 'claude_output') {
|
|
144
|
-
// Safety net: restore processing state if output arrives after reconnect
|
|
145
|
-
if (!cache.isProcessing) {
|
|
146
|
-
cache.isProcessing = true;
|
|
147
|
-
processingConversations.value[convId] = true;
|
|
148
|
-
}
|
|
149
|
-
const data = msg.data;
|
|
150
|
-
if (!data) return;
|
|
151
|
-
if (data.type === 'content_block_delta' && data.delta) {
|
|
152
|
-
// Append text to last assistant message (or create new one)
|
|
153
|
-
const msgs = cache.messages;
|
|
154
|
-
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
155
|
-
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
156
|
-
last.content += data.delta;
|
|
157
|
-
} else {
|
|
158
|
-
msgs.push({
|
|
159
|
-
id: ++cache.messageIdCounter, role: 'assistant',
|
|
160
|
-
content: data.delta, isStreaming: true, timestamp: new Date(),
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
} else if (data.type === 'tool_use' && data.tools) {
|
|
164
|
-
// Finalize streaming message
|
|
165
|
-
const msgs = cache.messages;
|
|
166
|
-
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
167
|
-
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
168
|
-
last.isStreaming = false;
|
|
169
|
-
if (isContextSummary(last.content)) {
|
|
170
|
-
last.role = 'context-summary';
|
|
171
|
-
last.contextExpanded = false;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
for (const tool of data.tools) {
|
|
175
|
-
const toolMsg = {
|
|
176
|
-
id: ++cache.messageIdCounter, role: 'tool',
|
|
177
|
-
toolId: tool.id, toolName: tool.name || 'unknown',
|
|
178
|
-
toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
|
|
179
|
-
hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'),
|
|
180
|
-
timestamp: new Date(),
|
|
181
|
-
};
|
|
182
|
-
msgs.push(toolMsg);
|
|
183
|
-
if (tool.id) {
|
|
184
|
-
if (!cache.toolMsgMap) cache.toolMsgMap = new Map();
|
|
185
|
-
cache.toolMsgMap.set(tool.id, toolMsg);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
} else if (data.type === 'user' && data.tool_use_result) {
|
|
189
|
-
const result = data.tool_use_result;
|
|
190
|
-
const results = Array.isArray(result) ? result : [result];
|
|
191
|
-
const tMap = cache.toolMsgMap || new Map();
|
|
192
|
-
for (const r of results) {
|
|
193
|
-
const toolMsg = tMap.get(r.tool_use_id);
|
|
194
|
-
if (toolMsg) {
|
|
195
|
-
toolMsg.toolOutput = typeof r.content === 'string'
|
|
196
|
-
? r.content : JSON.stringify(r.content, null, 2);
|
|
197
|
-
toolMsg.hasResult = true;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
} else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
|
|
202
|
-
// Finalize streaming message
|
|
203
|
-
const msgs = cache.messages;
|
|
204
|
-
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
205
|
-
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
206
|
-
last.isStreaming = false;
|
|
207
|
-
if (isContextSummary(last.content)) {
|
|
208
|
-
last.role = 'context-summary';
|
|
209
|
-
last.contextExpanded = false;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
cache.isProcessing = false;
|
|
213
|
-
cache.isCompacting = false;
|
|
214
|
-
if (msg.usage) cache.usageStats = msg.usage;
|
|
215
|
-
if (cache.toolMsgMap) cache.toolMsgMap.clear();
|
|
216
|
-
processingConversations.value[convId] = false;
|
|
217
|
-
if (msg.type === 'execution_cancelled') {
|
|
218
|
-
cache.needsResume = true;
|
|
219
|
-
cache.messages.push({
|
|
220
|
-
id: ++cache.messageIdCounter, role: 'system',
|
|
221
|
-
content: 'Generation stopped.', timestamp: new Date(),
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
sidebar.requestSessionList();
|
|
225
|
-
// Dequeue next message for this background conversation
|
|
226
|
-
if (cache.queuedMessages && cache.queuedMessages.length > 0) {
|
|
227
|
-
const queued = cache.queuedMessages.shift();
|
|
228
|
-
cache.messages.push({
|
|
229
|
-
id: ++cache.messageIdCounter, role: 'user', status: 'sent',
|
|
230
|
-
content: queued.content, attachments: queued.attachments,
|
|
231
|
-
timestamp: new Date(),
|
|
232
|
-
});
|
|
233
|
-
cache.isProcessing = true;
|
|
234
|
-
processingConversations.value[convId] = true;
|
|
235
|
-
wsSend(queued.payload);
|
|
236
|
-
}
|
|
237
|
-
} else if (msg.type === 'context_compaction') {
|
|
238
|
-
if (msg.status === 'started') {
|
|
239
|
-
cache.isCompacting = true;
|
|
240
|
-
cache.messages.push({
|
|
241
|
-
id: ++cache.messageIdCounter, role: 'system',
|
|
242
|
-
content: 'Context compacting...', isCompactStart: true,
|
|
243
|
-
timestamp: new Date(),
|
|
244
|
-
});
|
|
245
|
-
} else if (msg.status === 'completed') {
|
|
246
|
-
cache.isCompacting = false;
|
|
247
|
-
const startMsg = [...cache.messages].reverse().find(m => m.isCompactStart && !m.compactDone);
|
|
248
|
-
if (startMsg) {
|
|
249
|
-
startMsg.content = 'Context compacted';
|
|
250
|
-
startMsg.compactDone = true;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
} else if (msg.type === 'error') {
|
|
254
|
-
// Finalize streaming
|
|
255
|
-
const msgs = cache.messages;
|
|
256
|
-
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
257
|
-
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
258
|
-
last.isStreaming = false;
|
|
259
|
-
}
|
|
260
|
-
cache.messages.push({
|
|
261
|
-
id: ++cache.messageIdCounter, role: 'system',
|
|
262
|
-
content: msg.message, isError: true, timestamp: new Date(),
|
|
263
|
-
});
|
|
264
|
-
cache.isProcessing = false;
|
|
265
|
-
cache.isCompacting = false;
|
|
266
|
-
processingConversations.value[convId] = false;
|
|
267
|
-
} else if (msg.type === 'command_output') {
|
|
268
|
-
const msgs = cache.messages;
|
|
269
|
-
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
270
|
-
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
271
|
-
last.isStreaming = false;
|
|
272
|
-
}
|
|
273
|
-
cache.messages.push({
|
|
274
|
-
id: ++cache.messageIdCounter, role: 'system',
|
|
275
|
-
content: msg.content, isCommandOutput: true, timestamp: new Date(),
|
|
276
|
-
});
|
|
277
|
-
} else if (msg.type === 'ask_user_question') {
|
|
278
|
-
// Finalize streaming
|
|
279
|
-
const msgs = cache.messages;
|
|
280
|
-
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
281
|
-
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
282
|
-
last.isStreaming = false;
|
|
283
|
-
}
|
|
284
|
-
// Remove AskUserQuestion tool msg
|
|
285
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
286
|
-
const m = msgs[i];
|
|
287
|
-
if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
|
|
288
|
-
msgs.splice(i, 1);
|
|
289
|
-
break;
|
|
290
|
-
}
|
|
291
|
-
if (m.role === 'user') break;
|
|
292
|
-
}
|
|
293
|
-
const questions = msg.questions || [];
|
|
294
|
-
const selectedAnswers = {};
|
|
295
|
-
const customTexts = {};
|
|
296
|
-
for (let i = 0; i < questions.length; i++) {
|
|
297
|
-
selectedAnswers[i] = questions[i].multiSelect ? [] : null;
|
|
298
|
-
customTexts[i] = '';
|
|
299
|
-
}
|
|
300
|
-
msgs.push({
|
|
301
|
-
id: ++cache.messageIdCounter,
|
|
302
|
-
role: 'ask-question',
|
|
303
|
-
requestId: msg.requestId,
|
|
304
|
-
questions,
|
|
305
|
-
answered: false,
|
|
306
|
-
selectedAnswers,
|
|
307
|
-
customTexts,
|
|
308
|
-
timestamp: new Date(),
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function wsSend(msg) {
|
|
314
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
315
|
-
if (sessionKey) {
|
|
316
|
-
const encrypted = encrypt(msg, sessionKey);
|
|
317
|
-
ws.send(JSON.stringify(encrypted));
|
|
318
|
-
} else {
|
|
319
|
-
ws.send(JSON.stringify(msg));
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function startPing() {
|
|
324
|
-
stopPing();
|
|
325
|
-
// Send first ping immediately, then every 10s
|
|
326
|
-
wsSend({ type: 'ping', ts: Date.now() });
|
|
327
|
-
pingTimer = setInterval(() => {
|
|
328
|
-
wsSend({ type: 'ping', ts: Date.now() });
|
|
329
|
-
}, 10000);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function stopPing() {
|
|
333
|
-
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
|
334
|
-
latency.value = null;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function getSessionId() {
|
|
338
|
-
const match = window.location.pathname.match(/^\/s\/([^/]+)/);
|
|
339
|
-
return match ? match[1] : null;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function finalizeStreamingMsg(scheduleHighlight) {
|
|
343
|
-
const sid = streaming.getStreamingMessageId();
|
|
344
|
-
if (sid === null) return;
|
|
345
|
-
const streamMsg = messages.value.find(m => m.id === sid);
|
|
346
|
-
if (streamMsg) {
|
|
347
|
-
streamMsg.isStreaming = false;
|
|
348
|
-
if (isContextSummary(streamMsg.content)) {
|
|
349
|
-
streamMsg.role = 'context-summary';
|
|
350
|
-
streamMsg.contextExpanded = false;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
streaming.setStreamingMessageId(null);
|
|
354
|
-
if (scheduleHighlight) scheduleHighlight();
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function handleClaudeOutput(msg, scheduleHighlight) {
|
|
358
|
-
const data = msg.data;
|
|
359
|
-
if (!data) return;
|
|
360
|
-
|
|
361
|
-
// Safety net: if streaming output arrives but isProcessing is false
|
|
362
|
-
// (e.g. after reconnect before active_conversations response), self-correct
|
|
363
|
-
if (!isProcessing.value) {
|
|
364
|
-
isProcessing.value = true;
|
|
365
|
-
if (currentConversationId && currentConversationId.value) {
|
|
366
|
-
processingConversations.value[currentConversationId.value] = true;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (data.type === 'content_block_delta' && data.delta) {
|
|
371
|
-
streaming.appendPending(data.delta);
|
|
372
|
-
streaming.startReveal();
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
if (data.type === 'tool_use' && data.tools) {
|
|
377
|
-
streaming.flushReveal();
|
|
378
|
-
finalizeStreamingMsg(scheduleHighlight);
|
|
379
|
-
|
|
380
|
-
for (const tool of data.tools) {
|
|
381
|
-
const toolMsg = {
|
|
382
|
-
id: streaming.nextId(), role: 'tool',
|
|
383
|
-
toolId: tool.id, toolName: tool.name || 'unknown',
|
|
384
|
-
toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
|
|
385
|
-
hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'), timestamp: new Date(),
|
|
386
|
-
};
|
|
387
|
-
messages.value.push(toolMsg);
|
|
388
|
-
if (tool.id) toolMsgMap.set(tool.id, toolMsg);
|
|
389
|
-
}
|
|
390
|
-
scrollToBottom();
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (data.type === 'user' && data.tool_use_result) {
|
|
395
|
-
const result = data.tool_use_result;
|
|
396
|
-
const results = Array.isArray(result) ? result : [result];
|
|
397
|
-
for (const r of results) {
|
|
398
|
-
const toolMsg = toolMsgMap.get(r.tool_use_id);
|
|
399
|
-
if (toolMsg) {
|
|
400
|
-
toolMsg.toolOutput = typeof r.content === 'string'
|
|
401
|
-
? r.content : JSON.stringify(r.content, null, 2);
|
|
402
|
-
toolMsg.hasResult = true;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
scrollToBottom();
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function connect(scheduleHighlight) {
|
|
411
|
-
const sid = getSessionId();
|
|
412
|
-
if (!sid) {
|
|
413
|
-
status.value = 'No Session';
|
|
414
|
-
error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
sessionId.value = sid;
|
|
418
|
-
status.value = 'Connecting...';
|
|
419
|
-
error.value = '';
|
|
420
|
-
|
|
421
|
-
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
422
|
-
let wsUrl = `${protocol}//${window.location.host}/?type=web&sessionId=${sid}`;
|
|
423
|
-
// Include saved auth token for automatic re-authentication
|
|
424
|
-
const savedToken = localStorage.getItem(`agentlink-auth-${sid}`);
|
|
425
|
-
if (savedToken) {
|
|
426
|
-
wsUrl += `&authToken=${encodeURIComponent(savedToken)}`;
|
|
427
|
-
}
|
|
428
|
-
ws = new WebSocket(wsUrl);
|
|
429
|
-
|
|
430
|
-
ws.onopen = () => { error.value = ''; reconnectAttempts = 0; };
|
|
431
|
-
|
|
432
|
-
ws.onmessage = (event) => {
|
|
433
|
-
let msg;
|
|
434
|
-
const parsed = JSON.parse(event.data);
|
|
435
|
-
|
|
436
|
-
// Auth messages are always plaintext (before session key exchange)
|
|
437
|
-
if (parsed.type === 'auth_required') {
|
|
438
|
-
authRequired.value = true;
|
|
439
|
-
authError.value = '';
|
|
440
|
-
authLocked.value = false;
|
|
441
|
-
status.value = 'Authentication Required';
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
if (parsed.type === 'auth_failed') {
|
|
445
|
-
authError.value = parsed.message || 'Incorrect password.';
|
|
446
|
-
authAttempts.value = parsed.attemptsRemaining != null
|
|
447
|
-
? `${parsed.attemptsRemaining} attempt${parsed.attemptsRemaining !== 1 ? 's' : ''} remaining`
|
|
448
|
-
: null;
|
|
449
|
-
authPassword.value = '';
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
if (parsed.type === 'auth_locked') {
|
|
453
|
-
authLocked.value = true;
|
|
454
|
-
authRequired.value = false;
|
|
455
|
-
authError.value = parsed.message || 'Too many failed attempts.';
|
|
456
|
-
status.value = 'Locked';
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (parsed.type === 'connected') {
|
|
461
|
-
msg = parsed;
|
|
462
|
-
if (typeof parsed.sessionKey === 'string') {
|
|
463
|
-
sessionKey = decodeKey(parsed.sessionKey);
|
|
464
|
-
}
|
|
465
|
-
} else if (sessionKey && isEncrypted(parsed)) {
|
|
466
|
-
msg = decrypt(parsed, sessionKey);
|
|
467
|
-
if (!msg) {
|
|
468
|
-
console.error('[WS] Failed to decrypt message');
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
} else {
|
|
472
|
-
msg = parsed;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// ── Multi-session: route messages to background conversations ──
|
|
476
|
-
// Messages with a conversationId that doesn't match the current foreground
|
|
477
|
-
// conversation are routed to their cached background state.
|
|
478
|
-
if (msg.conversationId && currentConversationId
|
|
479
|
-
&& currentConversationId.value
|
|
480
|
-
&& msg.conversationId !== currentConversationId.value) {
|
|
481
|
-
routeToBackgroundConversation(msg.conversationId, msg);
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
if (msg.type === 'connected') {
|
|
486
|
-
// Reset auth state
|
|
487
|
-
authRequired.value = false;
|
|
488
|
-
authPassword.value = '';
|
|
489
|
-
authError.value = '';
|
|
490
|
-
authAttempts.value = null;
|
|
491
|
-
authLocked.value = false;
|
|
492
|
-
// Save auth token for automatic re-authentication
|
|
493
|
-
if (msg.authToken) {
|
|
494
|
-
localStorage.setItem(`agentlink-auth-${sessionId.value}`, msg.authToken);
|
|
495
|
-
}
|
|
496
|
-
if (msg.serverVersion) serverVersion.value = msg.serverVersion;
|
|
497
|
-
if (msg.agent) {
|
|
498
|
-
status.value = 'Connected';
|
|
499
|
-
agentName.value = msg.agent.name;
|
|
500
|
-
hostname.value = msg.agent.hostname || '';
|
|
501
|
-
workDir.value = msg.agent.workDir;
|
|
502
|
-
agentVersion.value = msg.agent.version || '';
|
|
503
|
-
sidebar.loadWorkdirHistory();
|
|
504
|
-
sidebar.addToWorkdirHistory(msg.agent.workDir);
|
|
505
|
-
const savedDir = localStorage.getItem(`agentlink-workdir-${sessionId.value}`);
|
|
506
|
-
if (savedDir && savedDir !== msg.agent.workDir) {
|
|
507
|
-
wsSend({ type: 'change_workdir', workDir: savedDir });
|
|
508
|
-
}
|
|
509
|
-
sidebar.requestSessionList();
|
|
510
|
-
startPing();
|
|
511
|
-
wsSend({ type: 'query_active_conversations' });
|
|
512
|
-
} else {
|
|
513
|
-
status.value = 'Waiting';
|
|
514
|
-
error.value = 'Agent is not connected yet.';
|
|
515
|
-
}
|
|
516
|
-
} else if (msg.type === 'pong') {
|
|
517
|
-
if (typeof msg.ts === 'number') {
|
|
518
|
-
latency.value = Date.now() - msg.ts;
|
|
519
|
-
}
|
|
520
|
-
} else if (msg.type === 'agent_disconnected') {
|
|
521
|
-
stopPing();
|
|
522
|
-
status.value = 'Waiting';
|
|
523
|
-
agentName.value = '';
|
|
524
|
-
hostname.value = '';
|
|
525
|
-
error.value = 'Agent disconnected. Waiting for reconnect...';
|
|
526
|
-
isProcessing.value = false;
|
|
527
|
-
isCompacting.value = false;
|
|
528
|
-
queuedMessages.value = [];
|
|
529
|
-
loadingSessions.value = false;
|
|
530
|
-
// Clear processing state for all background conversations
|
|
531
|
-
if (conversationCache) {
|
|
532
|
-
for (const [convId, cached] of Object.entries(conversationCache.value)) {
|
|
533
|
-
cached.isProcessing = false;
|
|
534
|
-
cached.isCompacting = false;
|
|
535
|
-
processingConversations.value[convId] = false;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
if (currentConversationId && currentConversationId.value) {
|
|
539
|
-
processingConversations.value[currentConversationId.value] = false;
|
|
540
|
-
}
|
|
541
|
-
} else if (msg.type === 'agent_reconnected') {
|
|
542
|
-
status.value = 'Connected';
|
|
543
|
-
error.value = '';
|
|
544
|
-
if (msg.agent) {
|
|
545
|
-
agentName.value = msg.agent.name;
|
|
546
|
-
hostname.value = msg.agent.hostname || '';
|
|
547
|
-
workDir.value = msg.agent.workDir;
|
|
548
|
-
agentVersion.value = msg.agent.version || '';
|
|
549
|
-
workDir.value = msg.agent.workDir;
|
|
550
|
-
sidebar.addToWorkdirHistory(msg.agent.workDir);
|
|
551
|
-
}
|
|
552
|
-
sidebar.requestSessionList();
|
|
553
|
-
startPing();
|
|
554
|
-
wsSend({ type: 'query_active_conversations' });
|
|
555
|
-
} else if (msg.type === 'active_conversations') {
|
|
556
|
-
// Agent's response is authoritative — first clear all processing state,
|
|
557
|
-
// then re-apply only for conversations the agent reports as active.
|
|
558
|
-
// This corrects any stale isProcessing=true left by the safety net or
|
|
559
|
-
// from turns that finished while the socket was down.
|
|
560
|
-
const activeSet = new Set();
|
|
561
|
-
const convs = msg.conversations || [];
|
|
562
|
-
for (const entry of convs) {
|
|
563
|
-
if (entry.conversationId) activeSet.add(entry.conversationId);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// Clear foreground
|
|
567
|
-
if (!activeSet.has(currentConversationId && currentConversationId.value)) {
|
|
568
|
-
isProcessing.value = false;
|
|
569
|
-
isCompacting.value = false;
|
|
570
|
-
}
|
|
571
|
-
// Clear all cached background conversations
|
|
572
|
-
if (conversationCache) {
|
|
573
|
-
for (const [convId, cached] of Object.entries(conversationCache.value)) {
|
|
574
|
-
if (!activeSet.has(convId)) {
|
|
575
|
-
cached.isProcessing = false;
|
|
576
|
-
cached.isCompacting = false;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
// Clear processingConversations map
|
|
581
|
-
if (processingConversations) {
|
|
582
|
-
for (const convId of Object.keys(processingConversations.value)) {
|
|
583
|
-
if (!activeSet.has(convId)) {
|
|
584
|
-
processingConversations.value[convId] = false;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Now set state for actually active conversations
|
|
590
|
-
for (const entry of convs) {
|
|
591
|
-
const convId = entry.conversationId;
|
|
592
|
-
if (!convId) continue;
|
|
593
|
-
if (currentConversationId && currentConversationId.value === convId) {
|
|
594
|
-
// Foreground conversation
|
|
595
|
-
isProcessing.value = true;
|
|
596
|
-
isCompacting.value = !!entry.isCompacting;
|
|
597
|
-
} else if (conversationCache && conversationCache.value[convId]) {
|
|
598
|
-
// Background conversation
|
|
599
|
-
const cached = conversationCache.value[convId];
|
|
600
|
-
cached.isProcessing = true;
|
|
601
|
-
cached.isCompacting = !!entry.isCompacting;
|
|
602
|
-
}
|
|
603
|
-
if (processingConversations) {
|
|
604
|
-
processingConversations.value[convId] = true;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
} else if (msg.type === 'error') {
|
|
608
|
-
streaming.flushReveal();
|
|
609
|
-
finalizeStreamingMsg(scheduleHighlight);
|
|
610
|
-
messages.value.push({
|
|
611
|
-
id: streaming.nextId(), role: 'system',
|
|
612
|
-
content: msg.message, isError: true,
|
|
613
|
-
timestamp: new Date(),
|
|
614
|
-
});
|
|
615
|
-
scrollToBottom();
|
|
616
|
-
isProcessing.value = false;
|
|
617
|
-
isCompacting.value = false;
|
|
618
|
-
loadingSessions.value = false;
|
|
619
|
-
if (currentConversationId && currentConversationId.value) {
|
|
620
|
-
processingConversations.value[currentConversationId.value] = false;
|
|
621
|
-
}
|
|
622
|
-
_dequeueNext();
|
|
623
|
-
} else if (msg.type === 'claude_output') {
|
|
624
|
-
handleClaudeOutput(msg, scheduleHighlight);
|
|
625
|
-
} else if (msg.type === 'session_started') {
|
|
626
|
-
// Claude session ID captured — update and refresh sidebar
|
|
627
|
-
currentClaudeSessionId.value = msg.claudeSessionId;
|
|
628
|
-
sidebar.requestSessionList();
|
|
629
|
-
} else if (msg.type === 'command_output') {
|
|
630
|
-
streaming.flushReveal();
|
|
631
|
-
finalizeStreamingMsg(scheduleHighlight);
|
|
632
|
-
messages.value.push({
|
|
633
|
-
id: streaming.nextId(), role: 'system',
|
|
634
|
-
content: msg.content, isCommandOutput: true,
|
|
635
|
-
timestamp: new Date(),
|
|
636
|
-
});
|
|
637
|
-
scrollToBottom();
|
|
638
|
-
} else if (msg.type === 'context_compaction') {
|
|
639
|
-
if (msg.status === 'started') {
|
|
640
|
-
isCompacting.value = true;
|
|
641
|
-
messages.value.push({
|
|
642
|
-
id: streaming.nextId(), role: 'system',
|
|
643
|
-
content: 'Context compacting...', isCompactStart: true,
|
|
644
|
-
timestamp: new Date(),
|
|
645
|
-
});
|
|
646
|
-
scrollToBottom();
|
|
647
|
-
} else if (msg.status === 'completed') {
|
|
648
|
-
isCompacting.value = false;
|
|
649
|
-
// Update the start message to show completed
|
|
650
|
-
const startMsg = [...messages.value].reverse().find(m => m.isCompactStart && !m.compactDone);
|
|
651
|
-
if (startMsg) {
|
|
652
|
-
startMsg.content = 'Context compacted';
|
|
653
|
-
startMsg.compactDone = true;
|
|
654
|
-
}
|
|
655
|
-
scrollToBottom();
|
|
656
|
-
}
|
|
657
|
-
} else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
|
|
658
|
-
streaming.flushReveal();
|
|
659
|
-
finalizeStreamingMsg(scheduleHighlight);
|
|
660
|
-
isProcessing.value = false;
|
|
661
|
-
isCompacting.value = false;
|
|
662
|
-
toolMsgMap.clear();
|
|
663
|
-
if (msg.usage) usageStats.value = msg.usage;
|
|
664
|
-
if (currentConversationId && currentConversationId.value) {
|
|
665
|
-
processingConversations.value[currentConversationId.value] = false;
|
|
666
|
-
}
|
|
667
|
-
if (msg.type === 'execution_cancelled') {
|
|
668
|
-
needsResume.value = true;
|
|
669
|
-
messages.value.push({
|
|
670
|
-
id: streaming.nextId(), role: 'system',
|
|
671
|
-
content: 'Generation stopped.', timestamp: new Date(),
|
|
672
|
-
});
|
|
673
|
-
scrollToBottom();
|
|
674
|
-
}
|
|
675
|
-
sidebar.requestSessionList();
|
|
676
|
-
_dequeueNext();
|
|
677
|
-
} else if (msg.type === 'ask_user_question') {
|
|
678
|
-
streaming.flushReveal();
|
|
679
|
-
finalizeStreamingMsg(scheduleHighlight);
|
|
680
|
-
for (let i = messages.value.length - 1; i >= 0; i--) {
|
|
681
|
-
const m = messages.value[i];
|
|
682
|
-
if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
|
|
683
|
-
messages.value.splice(i, 1);
|
|
684
|
-
break;
|
|
685
|
-
}
|
|
686
|
-
if (m.role === 'user') break;
|
|
687
|
-
}
|
|
688
|
-
const questions = msg.questions || [];
|
|
689
|
-
const selectedAnswers = {};
|
|
690
|
-
const customTexts = {};
|
|
691
|
-
for (let i = 0; i < questions.length; i++) {
|
|
692
|
-
selectedAnswers[i] = questions[i].multiSelect ? [] : null;
|
|
693
|
-
customTexts[i] = '';
|
|
694
|
-
}
|
|
695
|
-
messages.value.push({
|
|
696
|
-
id: streaming.nextId(),
|
|
697
|
-
role: 'ask-question',
|
|
698
|
-
requestId: msg.requestId,
|
|
699
|
-
questions,
|
|
700
|
-
answered: false,
|
|
701
|
-
selectedAnswers,
|
|
702
|
-
customTexts,
|
|
703
|
-
timestamp: new Date(),
|
|
704
|
-
});
|
|
705
|
-
scrollToBottom();
|
|
706
|
-
} else if (msg.type === 'sessions_list') {
|
|
707
|
-
historySessions.value = msg.sessions || [];
|
|
708
|
-
loadingSessions.value = false;
|
|
709
|
-
} else if (msg.type === 'session_deleted') {
|
|
710
|
-
historySessions.value = historySessions.value.filter(s => s.sessionId !== msg.sessionId);
|
|
711
|
-
} else if (msg.type === 'session_renamed') {
|
|
712
|
-
const session = historySessions.value.find(s => s.sessionId === msg.sessionId);
|
|
713
|
-
if (session) session.title = msg.newTitle;
|
|
714
|
-
} else if (msg.type === 'conversation_resumed') {
|
|
715
|
-
currentClaudeSessionId.value = msg.claudeSessionId;
|
|
716
|
-
if (msg.history && Array.isArray(msg.history)) {
|
|
717
|
-
const batch = [];
|
|
718
|
-
for (const h of msg.history) {
|
|
719
|
-
if (h.role === 'user') {
|
|
720
|
-
if (isContextSummary(h.content)) {
|
|
721
|
-
batch.push({
|
|
722
|
-
id: streaming.nextId(), role: 'context-summary',
|
|
723
|
-
content: h.content, contextExpanded: false,
|
|
724
|
-
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
725
|
-
});
|
|
726
|
-
} else if (h.isCommandOutput) {
|
|
727
|
-
batch.push({
|
|
728
|
-
id: streaming.nextId(), role: 'system',
|
|
729
|
-
content: h.content, isCommandOutput: true,
|
|
730
|
-
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
731
|
-
});
|
|
732
|
-
} else {
|
|
733
|
-
batch.push({
|
|
734
|
-
id: streaming.nextId(), role: 'user',
|
|
735
|
-
content: h.content,
|
|
736
|
-
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
} else if (h.role === 'assistant') {
|
|
740
|
-
const last = batch[batch.length - 1];
|
|
741
|
-
if (last && last.role === 'assistant' && !last.isStreaming) {
|
|
742
|
-
last.content += '\n\n' + h.content;
|
|
743
|
-
} else {
|
|
744
|
-
batch.push({
|
|
745
|
-
id: streaming.nextId(), role: 'assistant',
|
|
746
|
-
content: h.content, isStreaming: false,
|
|
747
|
-
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
} else if (h.role === 'tool') {
|
|
751
|
-
batch.push({
|
|
752
|
-
id: streaming.nextId(), role: 'tool',
|
|
753
|
-
toolId: h.toolId || '', toolName: h.toolName || 'unknown',
|
|
754
|
-
toolInput: h.toolInput || '', hasResult: true,
|
|
755
|
-
expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'), timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
messages.value = batch;
|
|
760
|
-
toolMsgMap.clear();
|
|
761
|
-
}
|
|
762
|
-
loadingHistory.value = false;
|
|
763
|
-
// Restore live status from agent (compacting / processing)
|
|
764
|
-
if (msg.isCompacting) {
|
|
765
|
-
isCompacting.value = true;
|
|
766
|
-
isProcessing.value = true;
|
|
767
|
-
messages.value.push({
|
|
768
|
-
id: streaming.nextId(), role: 'system',
|
|
769
|
-
content: 'Context compacting...', isCompactStart: true,
|
|
770
|
-
timestamp: new Date(),
|
|
771
|
-
});
|
|
772
|
-
} else if (msg.isProcessing) {
|
|
773
|
-
isProcessing.value = true;
|
|
774
|
-
messages.value.push({
|
|
775
|
-
id: streaming.nextId(), role: 'system',
|
|
776
|
-
content: 'Agent is processing...',
|
|
777
|
-
timestamp: new Date(),
|
|
778
|
-
});
|
|
779
|
-
} else {
|
|
780
|
-
messages.value.push({
|
|
781
|
-
id: streaming.nextId(), role: 'system',
|
|
782
|
-
content: 'Session restored. You can continue the conversation.',
|
|
783
|
-
timestamp: new Date(),
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
scrollToBottom();
|
|
787
|
-
} else if (msg.type === 'directory_listing') {
|
|
788
|
-
if (msg.source === 'file_browser' && fileBrowser) {
|
|
789
|
-
fileBrowser.handleDirectoryListing(msg);
|
|
790
|
-
} else {
|
|
791
|
-
folderPickerLoading.value = false;
|
|
792
|
-
folderPickerEntries.value = (msg.entries || [])
|
|
793
|
-
.filter(e => e.type === 'directory')
|
|
794
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
795
|
-
if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
|
|
796
|
-
}
|
|
797
|
-
} else if (msg.type === 'file_content') {
|
|
798
|
-
if (filePreview) filePreview.handleFileContent(msg);
|
|
799
|
-
} else if (msg.type === 'workdir_changed') {
|
|
800
|
-
workDir.value = msg.workDir;
|
|
801
|
-
localStorage.setItem(`agentlink-workdir-${sessionId.value}`, msg.workDir);
|
|
802
|
-
sidebar.addToWorkdirHistory(msg.workDir);
|
|
803
|
-
if (fileBrowser) fileBrowser.onWorkdirChanged();
|
|
804
|
-
if (filePreview) filePreview.onWorkdirChanged();
|
|
805
|
-
|
|
806
|
-
// Multi-session: switch to a new blank conversation for the new workdir.
|
|
807
|
-
// Background conversations keep running and receiving output in their cache.
|
|
808
|
-
if (switchConversation) {
|
|
809
|
-
const newConvId = crypto.randomUUID();
|
|
810
|
-
switchConversation(newConvId);
|
|
811
|
-
} else {
|
|
812
|
-
// Fallback for old code path (no switchConversation)
|
|
813
|
-
messages.value = [];
|
|
814
|
-
queuedMessages.value = [];
|
|
815
|
-
toolMsgMap.clear();
|
|
816
|
-
visibleLimit.value = 50;
|
|
817
|
-
streaming.setMessageIdCounter(0);
|
|
818
|
-
streaming.setStreamingMessageId(null);
|
|
819
|
-
streaming.reset();
|
|
820
|
-
currentClaudeSessionId.value = null;
|
|
821
|
-
isProcessing.value = false;
|
|
822
|
-
}
|
|
823
|
-
messages.value.push({
|
|
824
|
-
id: streaming.nextId(), role: 'system',
|
|
825
|
-
content: 'Working directory changed to: ' + msg.workDir,
|
|
826
|
-
timestamp: new Date(),
|
|
827
|
-
});
|
|
828
|
-
sidebar.requestSessionList();
|
|
829
|
-
}
|
|
830
|
-
};
|
|
831
|
-
|
|
832
|
-
ws.onclose = () => {
|
|
833
|
-
sessionKey = null;
|
|
834
|
-
stopPing();
|
|
835
|
-
const wasConnected = status.value === 'Connected' || status.value === 'Connecting...';
|
|
836
|
-
isProcessing.value = false;
|
|
837
|
-
isCompacting.value = false;
|
|
838
|
-
queuedMessages.value = [];
|
|
839
|
-
loadingSessions.value = false;
|
|
840
|
-
loadingHistory.value = false;
|
|
841
|
-
|
|
842
|
-
// Don't auto-reconnect if auth-locked or still in auth prompt
|
|
843
|
-
if (authLocked.value || authRequired.value) return;
|
|
844
|
-
|
|
845
|
-
if (wasConnected || reconnectAttempts > 0) {
|
|
846
|
-
scheduleReconnect(scheduleHighlight);
|
|
847
|
-
}
|
|
848
|
-
};
|
|
849
|
-
|
|
850
|
-
ws.onerror = () => {};
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
function scheduleReconnect(scheduleHighlight) {
|
|
854
|
-
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
855
|
-
status.value = 'Disconnected';
|
|
856
|
-
error.value = 'Unable to reconnect. Please refresh the page.';
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts), RECONNECT_MAX_DELAY);
|
|
860
|
-
reconnectAttempts++;
|
|
861
|
-
status.value = 'Reconnecting...';
|
|
862
|
-
error.value = 'Connection lost. Reconnecting... (attempt ' + reconnectAttempts + ')';
|
|
863
|
-
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
864
|
-
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(scheduleHighlight); }, delay);
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
function closeWs() {
|
|
868
|
-
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
869
|
-
if (ws) ws.close();
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function submitPassword() {
|
|
873
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
874
|
-
const pwd = authPassword.value.trim();
|
|
875
|
-
if (!pwd) return;
|
|
876
|
-
ws.send(JSON.stringify({ type: 'authenticate', password: pwd }));
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
|
|
880
|
-
}
|
|
1
|
+
// ── WebSocket connection, message routing, reconnection ──────────────────────
|
|
2
|
+
import { encrypt, decrypt, isEncrypted, decodeKey } from '../encryption.js';
|
|
3
|
+
import { isContextSummary } from './messageHelpers.js';
|
|
4
|
+
|
|
5
|
+
const MAX_RECONNECT_ATTEMPTS = 50;
|
|
6
|
+
const RECONNECT_BASE_DELAY = 1000;
|
|
7
|
+
const RECONNECT_MAX_DELAY = 15000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates the WebSocket connection controller.
|
|
11
|
+
* @param {object} deps - All reactive state and callbacks needed
|
|
12
|
+
*/
|
|
13
|
+
export function createConnection(deps) {
|
|
14
|
+
const {
|
|
15
|
+
status, agentName, hostname, workDir, sessionId, error,
|
|
16
|
+
serverVersion, agentVersion, latency,
|
|
17
|
+
messages, isProcessing, isCompacting, visibleLimit, queuedMessages, usageStats,
|
|
18
|
+
historySessions, currentClaudeSessionId, needsResume, loadingSessions, loadingHistory,
|
|
19
|
+
folderPickerLoading, folderPickerEntries, folderPickerPath,
|
|
20
|
+
authRequired, authPassword, authError, authAttempts, authLocked,
|
|
21
|
+
streaming, sidebar,
|
|
22
|
+
scrollToBottom,
|
|
23
|
+
// Multi-session parallel
|
|
24
|
+
currentConversationId, processingConversations, conversationCache,
|
|
25
|
+
switchConversation,
|
|
26
|
+
} = deps;
|
|
27
|
+
|
|
28
|
+
// Dequeue callback — set after creation to resolve circular dependency
|
|
29
|
+
let _dequeueNext = () => {};
|
|
30
|
+
function setDequeueNext(fn) { _dequeueNext = fn; }
|
|
31
|
+
|
|
32
|
+
// File browser — set after creation to resolve circular dependency
|
|
33
|
+
let fileBrowser = null;
|
|
34
|
+
function setFileBrowser(fb) { fileBrowser = fb; }
|
|
35
|
+
|
|
36
|
+
// File preview — set after creation to resolve circular dependency
|
|
37
|
+
let filePreview = null;
|
|
38
|
+
function setFilePreview(fp) { filePreview = fp; }
|
|
39
|
+
|
|
40
|
+
let ws = null;
|
|
41
|
+
let sessionKey = null;
|
|
42
|
+
let reconnectAttempts = 0;
|
|
43
|
+
let reconnectTimer = null;
|
|
44
|
+
let pingTimer = null;
|
|
45
|
+
const toolMsgMap = new Map(); // toolId -> message (for fast tool_result lookup)
|
|
46
|
+
|
|
47
|
+
// ── toolMsgMap save/restore for conversation switching ──
|
|
48
|
+
function getToolMsgMap() { return new Map(toolMsgMap); }
|
|
49
|
+
function restoreToolMsgMap(map) { toolMsgMap.clear(); for (const [k, v] of map) toolMsgMap.set(k, v); }
|
|
50
|
+
function clearToolMsgMap() { toolMsgMap.clear(); }
|
|
51
|
+
|
|
52
|
+
// ── Background conversation routing ──
|
|
53
|
+
// When a message arrives for a conversation that is not the current foreground,
|
|
54
|
+
// update its cached state directly (no streaming animation).
|
|
55
|
+
function routeToBackgroundConversation(convId, msg) {
|
|
56
|
+
const cache = conversationCache.value[convId];
|
|
57
|
+
if (!cache) return; // no cache entry — discard
|
|
58
|
+
|
|
59
|
+
if (msg.type === 'session_started') {
|
|
60
|
+
// Claude session ID captured for background conversation
|
|
61
|
+
cache.claudeSessionId = msg.claudeSessionId;
|
|
62
|
+
sidebar.requestSessionList();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (msg.type === 'conversation_resumed') {
|
|
67
|
+
cache.claudeSessionId = msg.claudeSessionId;
|
|
68
|
+
if (msg.history && Array.isArray(msg.history)) {
|
|
69
|
+
const batch = [];
|
|
70
|
+
for (const h of msg.history) {
|
|
71
|
+
if (h.role === 'user') {
|
|
72
|
+
if (isContextSummary(h.content)) {
|
|
73
|
+
batch.push({
|
|
74
|
+
id: ++cache.messageIdCounter, role: 'context-summary',
|
|
75
|
+
content: h.content, contextExpanded: false,
|
|
76
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
77
|
+
});
|
|
78
|
+
} else if (h.isCommandOutput) {
|
|
79
|
+
batch.push({
|
|
80
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
81
|
+
content: h.content, isCommandOutput: true,
|
|
82
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
batch.push({
|
|
86
|
+
id: ++cache.messageIdCounter, role: 'user',
|
|
87
|
+
content: h.content,
|
|
88
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
} else if (h.role === 'assistant') {
|
|
92
|
+
const last = batch[batch.length - 1];
|
|
93
|
+
if (last && last.role === 'assistant' && !last.isStreaming) {
|
|
94
|
+
last.content += '\n\n' + h.content;
|
|
95
|
+
} else {
|
|
96
|
+
batch.push({
|
|
97
|
+
id: ++cache.messageIdCounter, role: 'assistant',
|
|
98
|
+
content: h.content, isStreaming: false,
|
|
99
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
} else if (h.role === 'tool') {
|
|
103
|
+
batch.push({
|
|
104
|
+
id: ++cache.messageIdCounter, role: 'tool',
|
|
105
|
+
toolId: h.toolId || '', toolName: h.toolName || 'unknown',
|
|
106
|
+
toolInput: h.toolInput || '', hasResult: true,
|
|
107
|
+
expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'),
|
|
108
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
cache.messages = batch;
|
|
113
|
+
if (cache.toolMsgMap) cache.toolMsgMap.clear();
|
|
114
|
+
}
|
|
115
|
+
cache.loadingHistory = false;
|
|
116
|
+
if (msg.isCompacting) {
|
|
117
|
+
cache.isCompacting = true;
|
|
118
|
+
cache.isProcessing = true;
|
|
119
|
+
processingConversations.value[convId] = true;
|
|
120
|
+
cache.messages.push({
|
|
121
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
122
|
+
content: 'Context compacting...', isCompactStart: true,
|
|
123
|
+
timestamp: new Date(),
|
|
124
|
+
});
|
|
125
|
+
} else if (msg.isProcessing) {
|
|
126
|
+
cache.isProcessing = true;
|
|
127
|
+
processingConversations.value[convId] = true;
|
|
128
|
+
cache.messages.push({
|
|
129
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
130
|
+
content: 'Agent is processing...',
|
|
131
|
+
timestamp: new Date(),
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
cache.messages.push({
|
|
135
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
136
|
+
content: 'Session restored. You can continue the conversation.',
|
|
137
|
+
timestamp: new Date(),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (msg.type === 'claude_output') {
|
|
144
|
+
// Safety net: restore processing state if output arrives after reconnect
|
|
145
|
+
if (!cache.isProcessing) {
|
|
146
|
+
cache.isProcessing = true;
|
|
147
|
+
processingConversations.value[convId] = true;
|
|
148
|
+
}
|
|
149
|
+
const data = msg.data;
|
|
150
|
+
if (!data) return;
|
|
151
|
+
if (data.type === 'content_block_delta' && data.delta) {
|
|
152
|
+
// Append text to last assistant message (or create new one)
|
|
153
|
+
const msgs = cache.messages;
|
|
154
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
155
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
156
|
+
last.content += data.delta;
|
|
157
|
+
} else {
|
|
158
|
+
msgs.push({
|
|
159
|
+
id: ++cache.messageIdCounter, role: 'assistant',
|
|
160
|
+
content: data.delta, isStreaming: true, timestamp: new Date(),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
} else if (data.type === 'tool_use' && data.tools) {
|
|
164
|
+
// Finalize streaming message
|
|
165
|
+
const msgs = cache.messages;
|
|
166
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
167
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
168
|
+
last.isStreaming = false;
|
|
169
|
+
if (isContextSummary(last.content)) {
|
|
170
|
+
last.role = 'context-summary';
|
|
171
|
+
last.contextExpanded = false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const tool of data.tools) {
|
|
175
|
+
const toolMsg = {
|
|
176
|
+
id: ++cache.messageIdCounter, role: 'tool',
|
|
177
|
+
toolId: tool.id, toolName: tool.name || 'unknown',
|
|
178
|
+
toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
|
|
179
|
+
hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'),
|
|
180
|
+
timestamp: new Date(),
|
|
181
|
+
};
|
|
182
|
+
msgs.push(toolMsg);
|
|
183
|
+
if (tool.id) {
|
|
184
|
+
if (!cache.toolMsgMap) cache.toolMsgMap = new Map();
|
|
185
|
+
cache.toolMsgMap.set(tool.id, toolMsg);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} else if (data.type === 'user' && data.tool_use_result) {
|
|
189
|
+
const result = data.tool_use_result;
|
|
190
|
+
const results = Array.isArray(result) ? result : [result];
|
|
191
|
+
const tMap = cache.toolMsgMap || new Map();
|
|
192
|
+
for (const r of results) {
|
|
193
|
+
const toolMsg = tMap.get(r.tool_use_id);
|
|
194
|
+
if (toolMsg) {
|
|
195
|
+
toolMsg.toolOutput = typeof r.content === 'string'
|
|
196
|
+
? r.content : JSON.stringify(r.content, null, 2);
|
|
197
|
+
toolMsg.hasResult = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
|
|
202
|
+
// Finalize streaming message
|
|
203
|
+
const msgs = cache.messages;
|
|
204
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
205
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
206
|
+
last.isStreaming = false;
|
|
207
|
+
if (isContextSummary(last.content)) {
|
|
208
|
+
last.role = 'context-summary';
|
|
209
|
+
last.contextExpanded = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
cache.isProcessing = false;
|
|
213
|
+
cache.isCompacting = false;
|
|
214
|
+
if (msg.usage) cache.usageStats = msg.usage;
|
|
215
|
+
if (cache.toolMsgMap) cache.toolMsgMap.clear();
|
|
216
|
+
processingConversations.value[convId] = false;
|
|
217
|
+
if (msg.type === 'execution_cancelled') {
|
|
218
|
+
cache.needsResume = true;
|
|
219
|
+
cache.messages.push({
|
|
220
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
221
|
+
content: 'Generation stopped.', timestamp: new Date(),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
sidebar.requestSessionList();
|
|
225
|
+
// Dequeue next message for this background conversation
|
|
226
|
+
if (cache.queuedMessages && cache.queuedMessages.length > 0) {
|
|
227
|
+
const queued = cache.queuedMessages.shift();
|
|
228
|
+
cache.messages.push({
|
|
229
|
+
id: ++cache.messageIdCounter, role: 'user', status: 'sent',
|
|
230
|
+
content: queued.content, attachments: queued.attachments,
|
|
231
|
+
timestamp: new Date(),
|
|
232
|
+
});
|
|
233
|
+
cache.isProcessing = true;
|
|
234
|
+
processingConversations.value[convId] = true;
|
|
235
|
+
wsSend(queued.payload);
|
|
236
|
+
}
|
|
237
|
+
} else if (msg.type === 'context_compaction') {
|
|
238
|
+
if (msg.status === 'started') {
|
|
239
|
+
cache.isCompacting = true;
|
|
240
|
+
cache.messages.push({
|
|
241
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
242
|
+
content: 'Context compacting...', isCompactStart: true,
|
|
243
|
+
timestamp: new Date(),
|
|
244
|
+
});
|
|
245
|
+
} else if (msg.status === 'completed') {
|
|
246
|
+
cache.isCompacting = false;
|
|
247
|
+
const startMsg = [...cache.messages].reverse().find(m => m.isCompactStart && !m.compactDone);
|
|
248
|
+
if (startMsg) {
|
|
249
|
+
startMsg.content = 'Context compacted';
|
|
250
|
+
startMsg.compactDone = true;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} else if (msg.type === 'error') {
|
|
254
|
+
// Finalize streaming
|
|
255
|
+
const msgs = cache.messages;
|
|
256
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
257
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
258
|
+
last.isStreaming = false;
|
|
259
|
+
}
|
|
260
|
+
cache.messages.push({
|
|
261
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
262
|
+
content: msg.message, isError: true, timestamp: new Date(),
|
|
263
|
+
});
|
|
264
|
+
cache.isProcessing = false;
|
|
265
|
+
cache.isCompacting = false;
|
|
266
|
+
processingConversations.value[convId] = false;
|
|
267
|
+
} else if (msg.type === 'command_output') {
|
|
268
|
+
const msgs = cache.messages;
|
|
269
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
270
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
271
|
+
last.isStreaming = false;
|
|
272
|
+
}
|
|
273
|
+
cache.messages.push({
|
|
274
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
275
|
+
content: msg.content, isCommandOutput: true, timestamp: new Date(),
|
|
276
|
+
});
|
|
277
|
+
} else if (msg.type === 'ask_user_question') {
|
|
278
|
+
// Finalize streaming
|
|
279
|
+
const msgs = cache.messages;
|
|
280
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
281
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
282
|
+
last.isStreaming = false;
|
|
283
|
+
}
|
|
284
|
+
// Remove AskUserQuestion tool msg
|
|
285
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
286
|
+
const m = msgs[i];
|
|
287
|
+
if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
|
|
288
|
+
msgs.splice(i, 1);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
if (m.role === 'user') break;
|
|
292
|
+
}
|
|
293
|
+
const questions = msg.questions || [];
|
|
294
|
+
const selectedAnswers = {};
|
|
295
|
+
const customTexts = {};
|
|
296
|
+
for (let i = 0; i < questions.length; i++) {
|
|
297
|
+
selectedAnswers[i] = questions[i].multiSelect ? [] : null;
|
|
298
|
+
customTexts[i] = '';
|
|
299
|
+
}
|
|
300
|
+
msgs.push({
|
|
301
|
+
id: ++cache.messageIdCounter,
|
|
302
|
+
role: 'ask-question',
|
|
303
|
+
requestId: msg.requestId,
|
|
304
|
+
questions,
|
|
305
|
+
answered: false,
|
|
306
|
+
selectedAnswers,
|
|
307
|
+
customTexts,
|
|
308
|
+
timestamp: new Date(),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function wsSend(msg) {
|
|
314
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
315
|
+
if (sessionKey) {
|
|
316
|
+
const encrypted = encrypt(msg, sessionKey);
|
|
317
|
+
ws.send(JSON.stringify(encrypted));
|
|
318
|
+
} else {
|
|
319
|
+
ws.send(JSON.stringify(msg));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function startPing() {
|
|
324
|
+
stopPing();
|
|
325
|
+
// Send first ping immediately, then every 10s
|
|
326
|
+
wsSend({ type: 'ping', ts: Date.now() });
|
|
327
|
+
pingTimer = setInterval(() => {
|
|
328
|
+
wsSend({ type: 'ping', ts: Date.now() });
|
|
329
|
+
}, 10000);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function stopPing() {
|
|
333
|
+
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
|
334
|
+
latency.value = null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getSessionId() {
|
|
338
|
+
const match = window.location.pathname.match(/^\/s\/([^/]+)/);
|
|
339
|
+
return match ? match[1] : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function finalizeStreamingMsg(scheduleHighlight) {
|
|
343
|
+
const sid = streaming.getStreamingMessageId();
|
|
344
|
+
if (sid === null) return;
|
|
345
|
+
const streamMsg = messages.value.find(m => m.id === sid);
|
|
346
|
+
if (streamMsg) {
|
|
347
|
+
streamMsg.isStreaming = false;
|
|
348
|
+
if (isContextSummary(streamMsg.content)) {
|
|
349
|
+
streamMsg.role = 'context-summary';
|
|
350
|
+
streamMsg.contextExpanded = false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
streaming.setStreamingMessageId(null);
|
|
354
|
+
if (scheduleHighlight) scheduleHighlight();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function handleClaudeOutput(msg, scheduleHighlight) {
|
|
358
|
+
const data = msg.data;
|
|
359
|
+
if (!data) return;
|
|
360
|
+
|
|
361
|
+
// Safety net: if streaming output arrives but isProcessing is false
|
|
362
|
+
// (e.g. after reconnect before active_conversations response), self-correct
|
|
363
|
+
if (!isProcessing.value) {
|
|
364
|
+
isProcessing.value = true;
|
|
365
|
+
if (currentConversationId && currentConversationId.value) {
|
|
366
|
+
processingConversations.value[currentConversationId.value] = true;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (data.type === 'content_block_delta' && data.delta) {
|
|
371
|
+
streaming.appendPending(data.delta);
|
|
372
|
+
streaming.startReveal();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (data.type === 'tool_use' && data.tools) {
|
|
377
|
+
streaming.flushReveal();
|
|
378
|
+
finalizeStreamingMsg(scheduleHighlight);
|
|
379
|
+
|
|
380
|
+
for (const tool of data.tools) {
|
|
381
|
+
const toolMsg = {
|
|
382
|
+
id: streaming.nextId(), role: 'tool',
|
|
383
|
+
toolId: tool.id, toolName: tool.name || 'unknown',
|
|
384
|
+
toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
|
|
385
|
+
hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'), timestamp: new Date(),
|
|
386
|
+
};
|
|
387
|
+
messages.value.push(toolMsg);
|
|
388
|
+
if (tool.id) toolMsgMap.set(tool.id, toolMsg);
|
|
389
|
+
}
|
|
390
|
+
scrollToBottom();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (data.type === 'user' && data.tool_use_result) {
|
|
395
|
+
const result = data.tool_use_result;
|
|
396
|
+
const results = Array.isArray(result) ? result : [result];
|
|
397
|
+
for (const r of results) {
|
|
398
|
+
const toolMsg = toolMsgMap.get(r.tool_use_id);
|
|
399
|
+
if (toolMsg) {
|
|
400
|
+
toolMsg.toolOutput = typeof r.content === 'string'
|
|
401
|
+
? r.content : JSON.stringify(r.content, null, 2);
|
|
402
|
+
toolMsg.hasResult = true;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
scrollToBottom();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function connect(scheduleHighlight) {
|
|
411
|
+
const sid = getSessionId();
|
|
412
|
+
if (!sid) {
|
|
413
|
+
status.value = 'No Session';
|
|
414
|
+
error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
sessionId.value = sid;
|
|
418
|
+
status.value = 'Connecting...';
|
|
419
|
+
error.value = '';
|
|
420
|
+
|
|
421
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
422
|
+
let wsUrl = `${protocol}//${window.location.host}/?type=web&sessionId=${sid}`;
|
|
423
|
+
// Include saved auth token for automatic re-authentication
|
|
424
|
+
const savedToken = localStorage.getItem(`agentlink-auth-${sid}`);
|
|
425
|
+
if (savedToken) {
|
|
426
|
+
wsUrl += `&authToken=${encodeURIComponent(savedToken)}`;
|
|
427
|
+
}
|
|
428
|
+
ws = new WebSocket(wsUrl);
|
|
429
|
+
|
|
430
|
+
ws.onopen = () => { error.value = ''; reconnectAttempts = 0; };
|
|
431
|
+
|
|
432
|
+
ws.onmessage = (event) => {
|
|
433
|
+
let msg;
|
|
434
|
+
const parsed = JSON.parse(event.data);
|
|
435
|
+
|
|
436
|
+
// Auth messages are always plaintext (before session key exchange)
|
|
437
|
+
if (parsed.type === 'auth_required') {
|
|
438
|
+
authRequired.value = true;
|
|
439
|
+
authError.value = '';
|
|
440
|
+
authLocked.value = false;
|
|
441
|
+
status.value = 'Authentication Required';
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (parsed.type === 'auth_failed') {
|
|
445
|
+
authError.value = parsed.message || 'Incorrect password.';
|
|
446
|
+
authAttempts.value = parsed.attemptsRemaining != null
|
|
447
|
+
? `${parsed.attemptsRemaining} attempt${parsed.attemptsRemaining !== 1 ? 's' : ''} remaining`
|
|
448
|
+
: null;
|
|
449
|
+
authPassword.value = '';
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (parsed.type === 'auth_locked') {
|
|
453
|
+
authLocked.value = true;
|
|
454
|
+
authRequired.value = false;
|
|
455
|
+
authError.value = parsed.message || 'Too many failed attempts.';
|
|
456
|
+
status.value = 'Locked';
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (parsed.type === 'connected') {
|
|
461
|
+
msg = parsed;
|
|
462
|
+
if (typeof parsed.sessionKey === 'string') {
|
|
463
|
+
sessionKey = decodeKey(parsed.sessionKey);
|
|
464
|
+
}
|
|
465
|
+
} else if (sessionKey && isEncrypted(parsed)) {
|
|
466
|
+
msg = decrypt(parsed, sessionKey);
|
|
467
|
+
if (!msg) {
|
|
468
|
+
console.error('[WS] Failed to decrypt message');
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
} else {
|
|
472
|
+
msg = parsed;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── Multi-session: route messages to background conversations ──
|
|
476
|
+
// Messages with a conversationId that doesn't match the current foreground
|
|
477
|
+
// conversation are routed to their cached background state.
|
|
478
|
+
if (msg.conversationId && currentConversationId
|
|
479
|
+
&& currentConversationId.value
|
|
480
|
+
&& msg.conversationId !== currentConversationId.value) {
|
|
481
|
+
routeToBackgroundConversation(msg.conversationId, msg);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (msg.type === 'connected') {
|
|
486
|
+
// Reset auth state
|
|
487
|
+
authRequired.value = false;
|
|
488
|
+
authPassword.value = '';
|
|
489
|
+
authError.value = '';
|
|
490
|
+
authAttempts.value = null;
|
|
491
|
+
authLocked.value = false;
|
|
492
|
+
// Save auth token for automatic re-authentication
|
|
493
|
+
if (msg.authToken) {
|
|
494
|
+
localStorage.setItem(`agentlink-auth-${sessionId.value}`, msg.authToken);
|
|
495
|
+
}
|
|
496
|
+
if (msg.serverVersion) serverVersion.value = msg.serverVersion;
|
|
497
|
+
if (msg.agent) {
|
|
498
|
+
status.value = 'Connected';
|
|
499
|
+
agentName.value = msg.agent.name;
|
|
500
|
+
hostname.value = msg.agent.hostname || '';
|
|
501
|
+
workDir.value = msg.agent.workDir;
|
|
502
|
+
agentVersion.value = msg.agent.version || '';
|
|
503
|
+
sidebar.loadWorkdirHistory();
|
|
504
|
+
sidebar.addToWorkdirHistory(msg.agent.workDir);
|
|
505
|
+
const savedDir = localStorage.getItem(`agentlink-workdir-${sessionId.value}`);
|
|
506
|
+
if (savedDir && savedDir !== msg.agent.workDir) {
|
|
507
|
+
wsSend({ type: 'change_workdir', workDir: savedDir });
|
|
508
|
+
}
|
|
509
|
+
sidebar.requestSessionList();
|
|
510
|
+
startPing();
|
|
511
|
+
wsSend({ type: 'query_active_conversations' });
|
|
512
|
+
} else {
|
|
513
|
+
status.value = 'Waiting';
|
|
514
|
+
error.value = 'Agent is not connected yet.';
|
|
515
|
+
}
|
|
516
|
+
} else if (msg.type === 'pong') {
|
|
517
|
+
if (typeof msg.ts === 'number') {
|
|
518
|
+
latency.value = Date.now() - msg.ts;
|
|
519
|
+
}
|
|
520
|
+
} else if (msg.type === 'agent_disconnected') {
|
|
521
|
+
stopPing();
|
|
522
|
+
status.value = 'Waiting';
|
|
523
|
+
agentName.value = '';
|
|
524
|
+
hostname.value = '';
|
|
525
|
+
error.value = 'Agent disconnected. Waiting for reconnect...';
|
|
526
|
+
isProcessing.value = false;
|
|
527
|
+
isCompacting.value = false;
|
|
528
|
+
queuedMessages.value = [];
|
|
529
|
+
loadingSessions.value = false;
|
|
530
|
+
// Clear processing state for all background conversations
|
|
531
|
+
if (conversationCache) {
|
|
532
|
+
for (const [convId, cached] of Object.entries(conversationCache.value)) {
|
|
533
|
+
cached.isProcessing = false;
|
|
534
|
+
cached.isCompacting = false;
|
|
535
|
+
processingConversations.value[convId] = false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (currentConversationId && currentConversationId.value) {
|
|
539
|
+
processingConversations.value[currentConversationId.value] = false;
|
|
540
|
+
}
|
|
541
|
+
} else if (msg.type === 'agent_reconnected') {
|
|
542
|
+
status.value = 'Connected';
|
|
543
|
+
error.value = '';
|
|
544
|
+
if (msg.agent) {
|
|
545
|
+
agentName.value = msg.agent.name;
|
|
546
|
+
hostname.value = msg.agent.hostname || '';
|
|
547
|
+
workDir.value = msg.agent.workDir;
|
|
548
|
+
agentVersion.value = msg.agent.version || '';
|
|
549
|
+
workDir.value = msg.agent.workDir;
|
|
550
|
+
sidebar.addToWorkdirHistory(msg.agent.workDir);
|
|
551
|
+
}
|
|
552
|
+
sidebar.requestSessionList();
|
|
553
|
+
startPing();
|
|
554
|
+
wsSend({ type: 'query_active_conversations' });
|
|
555
|
+
} else if (msg.type === 'active_conversations') {
|
|
556
|
+
// Agent's response is authoritative — first clear all processing state,
|
|
557
|
+
// then re-apply only for conversations the agent reports as active.
|
|
558
|
+
// This corrects any stale isProcessing=true left by the safety net or
|
|
559
|
+
// from turns that finished while the socket was down.
|
|
560
|
+
const activeSet = new Set();
|
|
561
|
+
const convs = msg.conversations || [];
|
|
562
|
+
for (const entry of convs) {
|
|
563
|
+
if (entry.conversationId) activeSet.add(entry.conversationId);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Clear foreground
|
|
567
|
+
if (!activeSet.has(currentConversationId && currentConversationId.value)) {
|
|
568
|
+
isProcessing.value = false;
|
|
569
|
+
isCompacting.value = false;
|
|
570
|
+
}
|
|
571
|
+
// Clear all cached background conversations
|
|
572
|
+
if (conversationCache) {
|
|
573
|
+
for (const [convId, cached] of Object.entries(conversationCache.value)) {
|
|
574
|
+
if (!activeSet.has(convId)) {
|
|
575
|
+
cached.isProcessing = false;
|
|
576
|
+
cached.isCompacting = false;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Clear processingConversations map
|
|
581
|
+
if (processingConversations) {
|
|
582
|
+
for (const convId of Object.keys(processingConversations.value)) {
|
|
583
|
+
if (!activeSet.has(convId)) {
|
|
584
|
+
processingConversations.value[convId] = false;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Now set state for actually active conversations
|
|
590
|
+
for (const entry of convs) {
|
|
591
|
+
const convId = entry.conversationId;
|
|
592
|
+
if (!convId) continue;
|
|
593
|
+
if (currentConversationId && currentConversationId.value === convId) {
|
|
594
|
+
// Foreground conversation
|
|
595
|
+
isProcessing.value = true;
|
|
596
|
+
isCompacting.value = !!entry.isCompacting;
|
|
597
|
+
} else if (conversationCache && conversationCache.value[convId]) {
|
|
598
|
+
// Background conversation
|
|
599
|
+
const cached = conversationCache.value[convId];
|
|
600
|
+
cached.isProcessing = true;
|
|
601
|
+
cached.isCompacting = !!entry.isCompacting;
|
|
602
|
+
}
|
|
603
|
+
if (processingConversations) {
|
|
604
|
+
processingConversations.value[convId] = true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} else if (msg.type === 'error') {
|
|
608
|
+
streaming.flushReveal();
|
|
609
|
+
finalizeStreamingMsg(scheduleHighlight);
|
|
610
|
+
messages.value.push({
|
|
611
|
+
id: streaming.nextId(), role: 'system',
|
|
612
|
+
content: msg.message, isError: true,
|
|
613
|
+
timestamp: new Date(),
|
|
614
|
+
});
|
|
615
|
+
scrollToBottom();
|
|
616
|
+
isProcessing.value = false;
|
|
617
|
+
isCompacting.value = false;
|
|
618
|
+
loadingSessions.value = false;
|
|
619
|
+
if (currentConversationId && currentConversationId.value) {
|
|
620
|
+
processingConversations.value[currentConversationId.value] = false;
|
|
621
|
+
}
|
|
622
|
+
_dequeueNext();
|
|
623
|
+
} else if (msg.type === 'claude_output') {
|
|
624
|
+
handleClaudeOutput(msg, scheduleHighlight);
|
|
625
|
+
} else if (msg.type === 'session_started') {
|
|
626
|
+
// Claude session ID captured — update and refresh sidebar
|
|
627
|
+
currentClaudeSessionId.value = msg.claudeSessionId;
|
|
628
|
+
sidebar.requestSessionList();
|
|
629
|
+
} else if (msg.type === 'command_output') {
|
|
630
|
+
streaming.flushReveal();
|
|
631
|
+
finalizeStreamingMsg(scheduleHighlight);
|
|
632
|
+
messages.value.push({
|
|
633
|
+
id: streaming.nextId(), role: 'system',
|
|
634
|
+
content: msg.content, isCommandOutput: true,
|
|
635
|
+
timestamp: new Date(),
|
|
636
|
+
});
|
|
637
|
+
scrollToBottom();
|
|
638
|
+
} else if (msg.type === 'context_compaction') {
|
|
639
|
+
if (msg.status === 'started') {
|
|
640
|
+
isCompacting.value = true;
|
|
641
|
+
messages.value.push({
|
|
642
|
+
id: streaming.nextId(), role: 'system',
|
|
643
|
+
content: 'Context compacting...', isCompactStart: true,
|
|
644
|
+
timestamp: new Date(),
|
|
645
|
+
});
|
|
646
|
+
scrollToBottom();
|
|
647
|
+
} else if (msg.status === 'completed') {
|
|
648
|
+
isCompacting.value = false;
|
|
649
|
+
// Update the start message to show completed
|
|
650
|
+
const startMsg = [...messages.value].reverse().find(m => m.isCompactStart && !m.compactDone);
|
|
651
|
+
if (startMsg) {
|
|
652
|
+
startMsg.content = 'Context compacted';
|
|
653
|
+
startMsg.compactDone = true;
|
|
654
|
+
}
|
|
655
|
+
scrollToBottom();
|
|
656
|
+
}
|
|
657
|
+
} else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
|
|
658
|
+
streaming.flushReveal();
|
|
659
|
+
finalizeStreamingMsg(scheduleHighlight);
|
|
660
|
+
isProcessing.value = false;
|
|
661
|
+
isCompacting.value = false;
|
|
662
|
+
toolMsgMap.clear();
|
|
663
|
+
if (msg.usage) usageStats.value = msg.usage;
|
|
664
|
+
if (currentConversationId && currentConversationId.value) {
|
|
665
|
+
processingConversations.value[currentConversationId.value] = false;
|
|
666
|
+
}
|
|
667
|
+
if (msg.type === 'execution_cancelled') {
|
|
668
|
+
needsResume.value = true;
|
|
669
|
+
messages.value.push({
|
|
670
|
+
id: streaming.nextId(), role: 'system',
|
|
671
|
+
content: 'Generation stopped.', timestamp: new Date(),
|
|
672
|
+
});
|
|
673
|
+
scrollToBottom();
|
|
674
|
+
}
|
|
675
|
+
sidebar.requestSessionList();
|
|
676
|
+
_dequeueNext();
|
|
677
|
+
} else if (msg.type === 'ask_user_question') {
|
|
678
|
+
streaming.flushReveal();
|
|
679
|
+
finalizeStreamingMsg(scheduleHighlight);
|
|
680
|
+
for (let i = messages.value.length - 1; i >= 0; i--) {
|
|
681
|
+
const m = messages.value[i];
|
|
682
|
+
if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
|
|
683
|
+
messages.value.splice(i, 1);
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
if (m.role === 'user') break;
|
|
687
|
+
}
|
|
688
|
+
const questions = msg.questions || [];
|
|
689
|
+
const selectedAnswers = {};
|
|
690
|
+
const customTexts = {};
|
|
691
|
+
for (let i = 0; i < questions.length; i++) {
|
|
692
|
+
selectedAnswers[i] = questions[i].multiSelect ? [] : null;
|
|
693
|
+
customTexts[i] = '';
|
|
694
|
+
}
|
|
695
|
+
messages.value.push({
|
|
696
|
+
id: streaming.nextId(),
|
|
697
|
+
role: 'ask-question',
|
|
698
|
+
requestId: msg.requestId,
|
|
699
|
+
questions,
|
|
700
|
+
answered: false,
|
|
701
|
+
selectedAnswers,
|
|
702
|
+
customTexts,
|
|
703
|
+
timestamp: new Date(),
|
|
704
|
+
});
|
|
705
|
+
scrollToBottom();
|
|
706
|
+
} else if (msg.type === 'sessions_list') {
|
|
707
|
+
historySessions.value = msg.sessions || [];
|
|
708
|
+
loadingSessions.value = false;
|
|
709
|
+
} else if (msg.type === 'session_deleted') {
|
|
710
|
+
historySessions.value = historySessions.value.filter(s => s.sessionId !== msg.sessionId);
|
|
711
|
+
} else if (msg.type === 'session_renamed') {
|
|
712
|
+
const session = historySessions.value.find(s => s.sessionId === msg.sessionId);
|
|
713
|
+
if (session) session.title = msg.newTitle;
|
|
714
|
+
} else if (msg.type === 'conversation_resumed') {
|
|
715
|
+
currentClaudeSessionId.value = msg.claudeSessionId;
|
|
716
|
+
if (msg.history && Array.isArray(msg.history)) {
|
|
717
|
+
const batch = [];
|
|
718
|
+
for (const h of msg.history) {
|
|
719
|
+
if (h.role === 'user') {
|
|
720
|
+
if (isContextSummary(h.content)) {
|
|
721
|
+
batch.push({
|
|
722
|
+
id: streaming.nextId(), role: 'context-summary',
|
|
723
|
+
content: h.content, contextExpanded: false,
|
|
724
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
725
|
+
});
|
|
726
|
+
} else if (h.isCommandOutput) {
|
|
727
|
+
batch.push({
|
|
728
|
+
id: streaming.nextId(), role: 'system',
|
|
729
|
+
content: h.content, isCommandOutput: true,
|
|
730
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
731
|
+
});
|
|
732
|
+
} else {
|
|
733
|
+
batch.push({
|
|
734
|
+
id: streaming.nextId(), role: 'user',
|
|
735
|
+
content: h.content,
|
|
736
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
} else if (h.role === 'assistant') {
|
|
740
|
+
const last = batch[batch.length - 1];
|
|
741
|
+
if (last && last.role === 'assistant' && !last.isStreaming) {
|
|
742
|
+
last.content += '\n\n' + h.content;
|
|
743
|
+
} else {
|
|
744
|
+
batch.push({
|
|
745
|
+
id: streaming.nextId(), role: 'assistant',
|
|
746
|
+
content: h.content, isStreaming: false,
|
|
747
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
} else if (h.role === 'tool') {
|
|
751
|
+
batch.push({
|
|
752
|
+
id: streaming.nextId(), role: 'tool',
|
|
753
|
+
toolId: h.toolId || '', toolName: h.toolName || 'unknown',
|
|
754
|
+
toolInput: h.toolInput || '', hasResult: true,
|
|
755
|
+
expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'), timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
messages.value = batch;
|
|
760
|
+
toolMsgMap.clear();
|
|
761
|
+
}
|
|
762
|
+
loadingHistory.value = false;
|
|
763
|
+
// Restore live status from agent (compacting / processing)
|
|
764
|
+
if (msg.isCompacting) {
|
|
765
|
+
isCompacting.value = true;
|
|
766
|
+
isProcessing.value = true;
|
|
767
|
+
messages.value.push({
|
|
768
|
+
id: streaming.nextId(), role: 'system',
|
|
769
|
+
content: 'Context compacting...', isCompactStart: true,
|
|
770
|
+
timestamp: new Date(),
|
|
771
|
+
});
|
|
772
|
+
} else if (msg.isProcessing) {
|
|
773
|
+
isProcessing.value = true;
|
|
774
|
+
messages.value.push({
|
|
775
|
+
id: streaming.nextId(), role: 'system',
|
|
776
|
+
content: 'Agent is processing...',
|
|
777
|
+
timestamp: new Date(),
|
|
778
|
+
});
|
|
779
|
+
} else {
|
|
780
|
+
messages.value.push({
|
|
781
|
+
id: streaming.nextId(), role: 'system',
|
|
782
|
+
content: 'Session restored. You can continue the conversation.',
|
|
783
|
+
timestamp: new Date(),
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
scrollToBottom();
|
|
787
|
+
} else if (msg.type === 'directory_listing') {
|
|
788
|
+
if (msg.source === 'file_browser' && fileBrowser) {
|
|
789
|
+
fileBrowser.handleDirectoryListing(msg);
|
|
790
|
+
} else {
|
|
791
|
+
folderPickerLoading.value = false;
|
|
792
|
+
folderPickerEntries.value = (msg.entries || [])
|
|
793
|
+
.filter(e => e.type === 'directory')
|
|
794
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
795
|
+
if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
|
|
796
|
+
}
|
|
797
|
+
} else if (msg.type === 'file_content') {
|
|
798
|
+
if (filePreview) filePreview.handleFileContent(msg);
|
|
799
|
+
} else if (msg.type === 'workdir_changed') {
|
|
800
|
+
workDir.value = msg.workDir;
|
|
801
|
+
localStorage.setItem(`agentlink-workdir-${sessionId.value}`, msg.workDir);
|
|
802
|
+
sidebar.addToWorkdirHistory(msg.workDir);
|
|
803
|
+
if (fileBrowser) fileBrowser.onWorkdirChanged();
|
|
804
|
+
if (filePreview) filePreview.onWorkdirChanged();
|
|
805
|
+
|
|
806
|
+
// Multi-session: switch to a new blank conversation for the new workdir.
|
|
807
|
+
// Background conversations keep running and receiving output in their cache.
|
|
808
|
+
if (switchConversation) {
|
|
809
|
+
const newConvId = crypto.randomUUID();
|
|
810
|
+
switchConversation(newConvId);
|
|
811
|
+
} else {
|
|
812
|
+
// Fallback for old code path (no switchConversation)
|
|
813
|
+
messages.value = [];
|
|
814
|
+
queuedMessages.value = [];
|
|
815
|
+
toolMsgMap.clear();
|
|
816
|
+
visibleLimit.value = 50;
|
|
817
|
+
streaming.setMessageIdCounter(0);
|
|
818
|
+
streaming.setStreamingMessageId(null);
|
|
819
|
+
streaming.reset();
|
|
820
|
+
currentClaudeSessionId.value = null;
|
|
821
|
+
isProcessing.value = false;
|
|
822
|
+
}
|
|
823
|
+
messages.value.push({
|
|
824
|
+
id: streaming.nextId(), role: 'system',
|
|
825
|
+
content: 'Working directory changed to: ' + msg.workDir,
|
|
826
|
+
timestamp: new Date(),
|
|
827
|
+
});
|
|
828
|
+
sidebar.requestSessionList();
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
ws.onclose = () => {
|
|
833
|
+
sessionKey = null;
|
|
834
|
+
stopPing();
|
|
835
|
+
const wasConnected = status.value === 'Connected' || status.value === 'Connecting...';
|
|
836
|
+
isProcessing.value = false;
|
|
837
|
+
isCompacting.value = false;
|
|
838
|
+
queuedMessages.value = [];
|
|
839
|
+
loadingSessions.value = false;
|
|
840
|
+
loadingHistory.value = false;
|
|
841
|
+
|
|
842
|
+
// Don't auto-reconnect if auth-locked or still in auth prompt
|
|
843
|
+
if (authLocked.value || authRequired.value) return;
|
|
844
|
+
|
|
845
|
+
if (wasConnected || reconnectAttempts > 0) {
|
|
846
|
+
scheduleReconnect(scheduleHighlight);
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
ws.onerror = () => {};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function scheduleReconnect(scheduleHighlight) {
|
|
854
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
855
|
+
status.value = 'Disconnected';
|
|
856
|
+
error.value = 'Unable to reconnect. Please refresh the page.';
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts), RECONNECT_MAX_DELAY);
|
|
860
|
+
reconnectAttempts++;
|
|
861
|
+
status.value = 'Reconnecting...';
|
|
862
|
+
error.value = 'Connection lost. Reconnecting... (attempt ' + reconnectAttempts + ')';
|
|
863
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
864
|
+
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(scheduleHighlight); }, delay);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function closeWs() {
|
|
868
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
869
|
+
if (ws) ws.close();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function submitPassword() {
|
|
873
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
874
|
+
const pwd = authPassword.value.trim();
|
|
875
|
+
if (!pwd) return;
|
|
876
|
+
ws.send(JSON.stringify({ type: 'authenticate', password: pwd }));
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
|
|
880
|
+
}
|