@agent-link/server 0.1.113 → 0.1.114
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/package.json +1 -1
- package/web/app.js +101 -6
- package/web/modules/connection.js +313 -10
- package/web/modules/sidebar.js +88 -7
- package/web/modules/streaming.js +18 -1
- package/web/style.css +18 -0
package/package.json
CHANGED
package/web/app.js
CHANGED
|
@@ -89,6 +89,75 @@ const App = {
|
|
|
89
89
|
const fileInputRef = ref(null);
|
|
90
90
|
const dragOver = ref(false);
|
|
91
91
|
|
|
92
|
+
// Multi-session parallel state
|
|
93
|
+
const conversationCache = ref({}); // conversationId → saved state snapshot
|
|
94
|
+
const currentConversationId = ref(crypto.randomUUID()); // currently visible conversation
|
|
95
|
+
const processingConversations = ref({}); // conversationId → boolean
|
|
96
|
+
|
|
97
|
+
// ── switchConversation: save current → load target ──
|
|
98
|
+
// Defined here and used by sidebar.newConversation, sidebar.resumeSession, workdir_changed
|
|
99
|
+
// Needs access to streaming / connection which are created later, so we use late-binding refs.
|
|
100
|
+
let _getToolMsgMap = () => new Map();
|
|
101
|
+
let _restoreToolMsgMap = () => {};
|
|
102
|
+
let _clearToolMsgMap = () => {};
|
|
103
|
+
|
|
104
|
+
function switchConversation(newConvId) {
|
|
105
|
+
const oldConvId = currentConversationId.value;
|
|
106
|
+
|
|
107
|
+
// Save current state (if there is one)
|
|
108
|
+
if (oldConvId) {
|
|
109
|
+
const streamState = streaming.saveState();
|
|
110
|
+
conversationCache.value[oldConvId] = {
|
|
111
|
+
messages: messages.value,
|
|
112
|
+
isProcessing: isProcessing.value,
|
|
113
|
+
isCompacting: isCompacting.value,
|
|
114
|
+
loadingHistory: loadingHistory.value,
|
|
115
|
+
claudeSessionId: currentClaudeSessionId.value,
|
|
116
|
+
visibleLimit: visibleLimit.value,
|
|
117
|
+
needsResume: needsResume.value,
|
|
118
|
+
streamingState: streamState,
|
|
119
|
+
toolMsgMap: _getToolMsgMap(),
|
|
120
|
+
messageIdCounter: streaming.getMessageIdCounter(),
|
|
121
|
+
queuedMessages: queuedMessages.value,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Load target state
|
|
126
|
+
const cached = conversationCache.value[newConvId];
|
|
127
|
+
if (cached) {
|
|
128
|
+
messages.value = cached.messages;
|
|
129
|
+
isProcessing.value = cached.isProcessing;
|
|
130
|
+
isCompacting.value = cached.isCompacting;
|
|
131
|
+
loadingHistory.value = cached.loadingHistory || false;
|
|
132
|
+
currentClaudeSessionId.value = cached.claudeSessionId;
|
|
133
|
+
visibleLimit.value = cached.visibleLimit;
|
|
134
|
+
needsResume.value = cached.needsResume;
|
|
135
|
+
streaming.restoreState(cached.streamingState || { pendingText: '', streamingMessageId: null, messageIdCounter: cached.messageIdCounter || 0 });
|
|
136
|
+
// Background routing may have incremented messageIdCounter beyond what
|
|
137
|
+
// streamingState recorded at save time — use the authoritative value.
|
|
138
|
+
streaming.setMessageIdCounter(cached.messageIdCounter || 0);
|
|
139
|
+
_restoreToolMsgMap(cached.toolMsgMap || new Map());
|
|
140
|
+
queuedMessages.value = cached.queuedMessages || [];
|
|
141
|
+
} else {
|
|
142
|
+
// New blank conversation
|
|
143
|
+
messages.value = [];
|
|
144
|
+
isProcessing.value = false;
|
|
145
|
+
isCompacting.value = false;
|
|
146
|
+
loadingHistory.value = false;
|
|
147
|
+
currentClaudeSessionId.value = null;
|
|
148
|
+
visibleLimit.value = 50;
|
|
149
|
+
needsResume.value = false;
|
|
150
|
+
streaming.setMessageIdCounter(0);
|
|
151
|
+
streaming.setStreamingMessageId(null);
|
|
152
|
+
streaming.reset();
|
|
153
|
+
_clearToolMsgMap();
|
|
154
|
+
queuedMessages.value = [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
currentConversationId.value = newConvId;
|
|
158
|
+
scrollToBottom(true);
|
|
159
|
+
}
|
|
160
|
+
|
|
92
161
|
// Theme
|
|
93
162
|
const theme = ref(localStorage.getItem('agentlink-theme') || 'light');
|
|
94
163
|
function applyTheme() {
|
|
@@ -159,9 +228,12 @@ const App = {
|
|
|
159
228
|
deleteConfirmOpen, deleteConfirmTitle,
|
|
160
229
|
renamingSessionId, renameText,
|
|
161
230
|
hostname, workdirHistory,
|
|
231
|
+
// Multi-session parallel
|
|
232
|
+
currentConversationId, conversationCache, processingConversations,
|
|
233
|
+
switchConversation,
|
|
162
234
|
});
|
|
163
235
|
|
|
164
|
-
const { connect, wsSend, closeWs, submitPassword, setDequeueNext } = createConnection({
|
|
236
|
+
const { connect, wsSend, closeWs, submitPassword, setDequeueNext, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap } = createConnection({
|
|
165
237
|
status, agentName, hostname, workDir, sessionId, error,
|
|
166
238
|
serverVersion, agentVersion, latency,
|
|
167
239
|
messages, isProcessing, isCompacting, visibleLimit, queuedMessages,
|
|
@@ -169,11 +241,18 @@ const App = {
|
|
|
169
241
|
folderPickerLoading, folderPickerEntries, folderPickerPath,
|
|
170
242
|
authRequired, authPassword, authError, authAttempts, authLocked,
|
|
171
243
|
streaming, sidebar, scrollToBottom,
|
|
244
|
+
// Multi-session parallel
|
|
245
|
+
currentConversationId, processingConversations, conversationCache,
|
|
246
|
+
switchConversation,
|
|
172
247
|
});
|
|
173
248
|
|
|
174
249
|
// Now wire up the forwarding function
|
|
175
250
|
_wsSend = wsSend;
|
|
176
251
|
setDequeueNext(dequeueNext);
|
|
252
|
+
// Wire up late-binding toolMsgMap functions for switchConversation
|
|
253
|
+
_getToolMsgMap = getToolMsgMap;
|
|
254
|
+
_restoreToolMsgMap = restoreToolMsgMap;
|
|
255
|
+
_clearToolMsgMap = clearToolMsgMap;
|
|
177
256
|
|
|
178
257
|
// ── Computed ──
|
|
179
258
|
const hasInput = computed(() => !!(inputText.value.trim() || attachments.value.length > 0));
|
|
@@ -205,6 +284,9 @@ const App = {
|
|
|
205
284
|
}));
|
|
206
285
|
|
|
207
286
|
const payload = { type: 'chat', prompt: text || '(see attached files)' };
|
|
287
|
+
if (currentConversationId.value) {
|
|
288
|
+
payload.conversationId = currentConversationId.value;
|
|
289
|
+
}
|
|
208
290
|
if (needsResume.value && currentClaudeSessionId.value) {
|
|
209
291
|
payload.resumeSessionId = currentClaudeSessionId.value;
|
|
210
292
|
needsResume.value = false;
|
|
@@ -228,6 +310,9 @@ const App = {
|
|
|
228
310
|
userMsg.status = 'sent';
|
|
229
311
|
messages.value.push(userMsg);
|
|
230
312
|
isProcessing.value = true;
|
|
313
|
+
if (currentConversationId.value) {
|
|
314
|
+
processingConversations.value[currentConversationId.value] = true;
|
|
315
|
+
}
|
|
231
316
|
wsSend(payload);
|
|
232
317
|
}
|
|
233
318
|
scrollToBottom(true);
|
|
@@ -236,7 +321,11 @@ const App = {
|
|
|
236
321
|
|
|
237
322
|
function cancelExecution() {
|
|
238
323
|
if (!isProcessing.value) return;
|
|
239
|
-
|
|
324
|
+
const cancelPayload = { type: 'cancel_execution' };
|
|
325
|
+
if (currentConversationId.value) {
|
|
326
|
+
cancelPayload.conversationId = currentConversationId.value;
|
|
327
|
+
}
|
|
328
|
+
wsSend(cancelPayload);
|
|
240
329
|
}
|
|
241
330
|
|
|
242
331
|
function dequeueNext() {
|
|
@@ -249,6 +338,9 @@ const App = {
|
|
|
249
338
|
};
|
|
250
339
|
messages.value.push(userMsg);
|
|
251
340
|
isProcessing.value = true;
|
|
341
|
+
if (currentConversationId.value) {
|
|
342
|
+
processingConversations.value[currentConversationId.value] = true;
|
|
343
|
+
}
|
|
252
344
|
wsSend(queued.payload);
|
|
253
345
|
scrollToBottom(true);
|
|
254
346
|
}
|
|
@@ -311,6 +403,8 @@ const App = {
|
|
|
311
403
|
requestSessionList: sidebar.requestSessionList,
|
|
312
404
|
formatRelativeTime,
|
|
313
405
|
groupedSessions: sidebar.groupedSessions,
|
|
406
|
+
isSessionProcessing: sidebar.isSessionProcessing,
|
|
407
|
+
processingConversations,
|
|
314
408
|
// Folder picker
|
|
315
409
|
folderPickerOpen, folderPickerPath, folderPickerEntries,
|
|
316
410
|
folderPickerLoading, folderPickerSelected,
|
|
@@ -395,7 +489,7 @@ const App = {
|
|
|
395
489
|
</div>
|
|
396
490
|
<div class="sidebar-workdir-header">
|
|
397
491
|
<div class="sidebar-workdir-label">Working Directory</div>
|
|
398
|
-
<button class="sidebar-change-dir-btn" @click="openFolderPicker" title="Change working directory"
|
|
492
|
+
<button class="sidebar-change-dir-btn" @click="openFolderPicker" title="Change working directory">
|
|
399
493
|
<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
|
|
400
494
|
</button>
|
|
401
495
|
</div>
|
|
@@ -427,7 +521,7 @@ const App = {
|
|
|
427
521
|
</button>
|
|
428
522
|
</div>
|
|
429
523
|
|
|
430
|
-
<button class="new-conversation-btn" @click="newConversation"
|
|
524
|
+
<button class="new-conversation-btn" @click="newConversation">
|
|
431
525
|
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
|
432
526
|
New conversation
|
|
433
527
|
</button>
|
|
@@ -443,9 +537,10 @@ const App = {
|
|
|
443
537
|
<div class="session-group-label">{{ group.label }}</div>
|
|
444
538
|
<div
|
|
445
539
|
v-for="s in group.sessions" :key="s.sessionId"
|
|
446
|
-
:class="['session-item', { active: currentClaudeSessionId === s.sessionId }]"
|
|
540
|
+
:class="['session-item', { active: currentClaudeSessionId === s.sessionId, processing: isSessionProcessing(s.sessionId) }]"
|
|
447
541
|
@click="renamingSessionId !== s.sessionId && resumeSession(s)"
|
|
448
542
|
:title="s.preview"
|
|
543
|
+
:aria-label="(s.title || s.sessionId.slice(0, 8)) + (isSessionProcessing(s.sessionId) ? ' (processing)' : '')"
|
|
449
544
|
>
|
|
450
545
|
<div v-if="renamingSessionId === s.sessionId" class="session-rename-row">
|
|
451
546
|
<input
|
|
@@ -462,7 +557,7 @@ const App = {
|
|
|
462
557
|
<div v-else class="session-title">{{ s.title }}</div>
|
|
463
558
|
<div class="session-meta">
|
|
464
559
|
<span>{{ formatRelativeTime(s.lastModified) }}</span>
|
|
465
|
-
<span v-if="renamingSessionId !== s.sessionId
|
|
560
|
+
<span v-if="renamingSessionId !== s.sessionId" class="session-actions">
|
|
466
561
|
<button
|
|
467
562
|
class="session-rename-btn"
|
|
468
563
|
@click.stop="startRename(s)"
|
|
@@ -20,6 +20,9 @@ export function createConnection(deps) {
|
|
|
20
20
|
authRequired, authPassword, authError, authAttempts, authLocked,
|
|
21
21
|
streaming, sidebar,
|
|
22
22
|
scrollToBottom,
|
|
23
|
+
// Multi-session parallel
|
|
24
|
+
currentConversationId, processingConversations, conversationCache,
|
|
25
|
+
switchConversation,
|
|
23
26
|
} = deps;
|
|
24
27
|
|
|
25
28
|
// Dequeue callback — set after creation to resolve circular dependency
|
|
@@ -33,6 +36,265 @@ export function createConnection(deps) {
|
|
|
33
36
|
let pingTimer = null;
|
|
34
37
|
const toolMsgMap = new Map(); // toolId -> message (for fast tool_result lookup)
|
|
35
38
|
|
|
39
|
+
// ── toolMsgMap save/restore for conversation switching ──
|
|
40
|
+
function getToolMsgMap() { return new Map(toolMsgMap); }
|
|
41
|
+
function restoreToolMsgMap(map) { toolMsgMap.clear(); for (const [k, v] of map) toolMsgMap.set(k, v); }
|
|
42
|
+
function clearToolMsgMap() { toolMsgMap.clear(); }
|
|
43
|
+
|
|
44
|
+
// ── Background conversation routing ──
|
|
45
|
+
// When a message arrives for a conversation that is not the current foreground,
|
|
46
|
+
// update its cached state directly (no streaming animation).
|
|
47
|
+
function routeToBackgroundConversation(convId, msg) {
|
|
48
|
+
const cache = conversationCache.value[convId];
|
|
49
|
+
if (!cache) return; // no cache entry — discard
|
|
50
|
+
|
|
51
|
+
if (msg.type === 'session_started') {
|
|
52
|
+
// Claude session ID captured for background conversation
|
|
53
|
+
cache.claudeSessionId = msg.claudeSessionId;
|
|
54
|
+
sidebar.requestSessionList();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (msg.type === 'conversation_resumed') {
|
|
59
|
+
cache.claudeSessionId = msg.claudeSessionId;
|
|
60
|
+
if (msg.history && Array.isArray(msg.history)) {
|
|
61
|
+
const batch = [];
|
|
62
|
+
for (const h of msg.history) {
|
|
63
|
+
if (h.role === 'user') {
|
|
64
|
+
if (isContextSummary(h.content)) {
|
|
65
|
+
batch.push({
|
|
66
|
+
id: ++cache.messageIdCounter, role: 'context-summary',
|
|
67
|
+
content: h.content, contextExpanded: false,
|
|
68
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
69
|
+
});
|
|
70
|
+
} else if (h.isCommandOutput) {
|
|
71
|
+
batch.push({
|
|
72
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
73
|
+
content: h.content, isCommandOutput: true,
|
|
74
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
75
|
+
});
|
|
76
|
+
} else {
|
|
77
|
+
batch.push({
|
|
78
|
+
id: ++cache.messageIdCounter, role: 'user',
|
|
79
|
+
content: h.content,
|
|
80
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
} else if (h.role === 'assistant') {
|
|
84
|
+
const last = batch[batch.length - 1];
|
|
85
|
+
if (last && last.role === 'assistant' && !last.isStreaming) {
|
|
86
|
+
last.content += '\n\n' + h.content;
|
|
87
|
+
} else {
|
|
88
|
+
batch.push({
|
|
89
|
+
id: ++cache.messageIdCounter, role: 'assistant',
|
|
90
|
+
content: h.content, isStreaming: false,
|
|
91
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
} else if (h.role === 'tool') {
|
|
95
|
+
batch.push({
|
|
96
|
+
id: ++cache.messageIdCounter, role: 'tool',
|
|
97
|
+
toolId: h.toolId || '', toolName: h.toolName || 'unknown',
|
|
98
|
+
toolInput: h.toolInput || '', hasResult: true,
|
|
99
|
+
expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'),
|
|
100
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
cache.messages = batch;
|
|
105
|
+
if (cache.toolMsgMap) cache.toolMsgMap.clear();
|
|
106
|
+
}
|
|
107
|
+
cache.loadingHistory = false;
|
|
108
|
+
if (msg.isCompacting) {
|
|
109
|
+
cache.isCompacting = true;
|
|
110
|
+
cache.isProcessing = true;
|
|
111
|
+
processingConversations.value[convId] = true;
|
|
112
|
+
cache.messages.push({
|
|
113
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
114
|
+
content: 'Context compacting...', isCompactStart: true,
|
|
115
|
+
timestamp: new Date(),
|
|
116
|
+
});
|
|
117
|
+
} else if (msg.isProcessing) {
|
|
118
|
+
cache.isProcessing = true;
|
|
119
|
+
processingConversations.value[convId] = true;
|
|
120
|
+
cache.messages.push({
|
|
121
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
122
|
+
content: 'Agent is processing...',
|
|
123
|
+
timestamp: new Date(),
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
cache.messages.push({
|
|
127
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
128
|
+
content: 'Session restored. You can continue the conversation.',
|
|
129
|
+
timestamp: new Date(),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (msg.type === 'claude_output') {
|
|
136
|
+
const data = msg.data;
|
|
137
|
+
if (!data) return;
|
|
138
|
+
if (data.type === 'content_block_delta' && data.delta) {
|
|
139
|
+
// Append text to last assistant message (or create new one)
|
|
140
|
+
const msgs = cache.messages;
|
|
141
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
142
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
143
|
+
last.content += data.delta;
|
|
144
|
+
} else {
|
|
145
|
+
msgs.push({
|
|
146
|
+
id: ++cache.messageIdCounter, role: 'assistant',
|
|
147
|
+
content: data.delta, isStreaming: true, timestamp: new Date(),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
} else if (data.type === 'tool_use' && data.tools) {
|
|
151
|
+
// Finalize streaming message
|
|
152
|
+
const msgs = cache.messages;
|
|
153
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
154
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
155
|
+
last.isStreaming = false;
|
|
156
|
+
if (isContextSummary(last.content)) {
|
|
157
|
+
last.role = 'context-summary';
|
|
158
|
+
last.contextExpanded = false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const tool of data.tools) {
|
|
162
|
+
const toolMsg = {
|
|
163
|
+
id: ++cache.messageIdCounter, role: 'tool',
|
|
164
|
+
toolId: tool.id, toolName: tool.name || 'unknown',
|
|
165
|
+
toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
|
|
166
|
+
hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'),
|
|
167
|
+
timestamp: new Date(),
|
|
168
|
+
};
|
|
169
|
+
msgs.push(toolMsg);
|
|
170
|
+
if (tool.id) {
|
|
171
|
+
if (!cache.toolMsgMap) cache.toolMsgMap = new Map();
|
|
172
|
+
cache.toolMsgMap.set(tool.id, toolMsg);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} else if (data.type === 'user' && data.tool_use_result) {
|
|
176
|
+
const result = data.tool_use_result;
|
|
177
|
+
const results = Array.isArray(result) ? result : [result];
|
|
178
|
+
const tMap = cache.toolMsgMap || new Map();
|
|
179
|
+
for (const r of results) {
|
|
180
|
+
const toolMsg = tMap.get(r.tool_use_id);
|
|
181
|
+
if (toolMsg) {
|
|
182
|
+
toolMsg.toolOutput = typeof r.content === 'string'
|
|
183
|
+
? r.content : JSON.stringify(r.content, null, 2);
|
|
184
|
+
toolMsg.hasResult = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
|
|
189
|
+
// Finalize streaming message
|
|
190
|
+
const msgs = cache.messages;
|
|
191
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
192
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
193
|
+
last.isStreaming = false;
|
|
194
|
+
if (isContextSummary(last.content)) {
|
|
195
|
+
last.role = 'context-summary';
|
|
196
|
+
last.contextExpanded = false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
cache.isProcessing = false;
|
|
200
|
+
cache.isCompacting = false;
|
|
201
|
+
if (cache.toolMsgMap) cache.toolMsgMap.clear();
|
|
202
|
+
processingConversations.value[convId] = false;
|
|
203
|
+
if (msg.type === 'execution_cancelled') {
|
|
204
|
+
cache.messages.push({
|
|
205
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
206
|
+
content: 'Generation stopped.', timestamp: new Date(),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
sidebar.requestSessionList();
|
|
210
|
+
// Dequeue next message for this background conversation
|
|
211
|
+
if (cache.queuedMessages && cache.queuedMessages.length > 0) {
|
|
212
|
+
const queued = cache.queuedMessages.shift();
|
|
213
|
+
cache.messages.push({
|
|
214
|
+
id: ++cache.messageIdCounter, role: 'user', status: 'sent',
|
|
215
|
+
content: queued.content, attachments: queued.attachments,
|
|
216
|
+
timestamp: new Date(),
|
|
217
|
+
});
|
|
218
|
+
cache.isProcessing = true;
|
|
219
|
+
processingConversations.value[convId] = true;
|
|
220
|
+
wsSend(queued.payload);
|
|
221
|
+
}
|
|
222
|
+
} else if (msg.type === 'context_compaction') {
|
|
223
|
+
if (msg.status === 'started') {
|
|
224
|
+
cache.isCompacting = true;
|
|
225
|
+
cache.messages.push({
|
|
226
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
227
|
+
content: 'Context compacting...', isCompactStart: true,
|
|
228
|
+
timestamp: new Date(),
|
|
229
|
+
});
|
|
230
|
+
} else if (msg.status === 'completed') {
|
|
231
|
+
cache.isCompacting = false;
|
|
232
|
+
const startMsg = [...cache.messages].reverse().find(m => m.isCompactStart && !m.compactDone);
|
|
233
|
+
if (startMsg) {
|
|
234
|
+
startMsg.content = 'Context compacted';
|
|
235
|
+
startMsg.compactDone = true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} else if (msg.type === 'error') {
|
|
239
|
+
// Finalize streaming
|
|
240
|
+
const msgs = cache.messages;
|
|
241
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
242
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
243
|
+
last.isStreaming = false;
|
|
244
|
+
}
|
|
245
|
+
cache.messages.push({
|
|
246
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
247
|
+
content: msg.message, isError: true, timestamp: new Date(),
|
|
248
|
+
});
|
|
249
|
+
cache.isProcessing = false;
|
|
250
|
+
cache.isCompacting = false;
|
|
251
|
+
processingConversations.value[convId] = false;
|
|
252
|
+
} else if (msg.type === 'command_output') {
|
|
253
|
+
const msgs = cache.messages;
|
|
254
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
255
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
256
|
+
last.isStreaming = false;
|
|
257
|
+
}
|
|
258
|
+
cache.messages.push({
|
|
259
|
+
id: ++cache.messageIdCounter, role: 'system',
|
|
260
|
+
content: msg.content, isCommandOutput: true, timestamp: new Date(),
|
|
261
|
+
});
|
|
262
|
+
} else if (msg.type === 'ask_user_question') {
|
|
263
|
+
// Finalize streaming
|
|
264
|
+
const msgs = cache.messages;
|
|
265
|
+
const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
266
|
+
if (last && last.role === 'assistant' && last.isStreaming) {
|
|
267
|
+
last.isStreaming = false;
|
|
268
|
+
}
|
|
269
|
+
// Remove AskUserQuestion tool msg
|
|
270
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
271
|
+
const m = msgs[i];
|
|
272
|
+
if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
|
|
273
|
+
msgs.splice(i, 1);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
if (m.role === 'user') break;
|
|
277
|
+
}
|
|
278
|
+
const questions = msg.questions || [];
|
|
279
|
+
const selectedAnswers = {};
|
|
280
|
+
const customTexts = {};
|
|
281
|
+
for (let i = 0; i < questions.length; i++) {
|
|
282
|
+
selectedAnswers[i] = questions[i].multiSelect ? [] : null;
|
|
283
|
+
customTexts[i] = '';
|
|
284
|
+
}
|
|
285
|
+
msgs.push({
|
|
286
|
+
id: ++cache.messageIdCounter,
|
|
287
|
+
role: 'ask-question',
|
|
288
|
+
requestId: msg.requestId,
|
|
289
|
+
questions,
|
|
290
|
+
answered: false,
|
|
291
|
+
selectedAnswers,
|
|
292
|
+
customTexts,
|
|
293
|
+
timestamp: new Date(),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
36
298
|
function wsSend(msg) {
|
|
37
299
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
38
300
|
if (sessionKey) {
|
|
@@ -186,6 +448,16 @@ export function createConnection(deps) {
|
|
|
186
448
|
msg = parsed;
|
|
187
449
|
}
|
|
188
450
|
|
|
451
|
+
// ── Multi-session: route messages to background conversations ──
|
|
452
|
+
// Messages with a conversationId that doesn't match the current foreground
|
|
453
|
+
// conversation are routed to their cached background state.
|
|
454
|
+
if (msg.conversationId && currentConversationId
|
|
455
|
+
&& currentConversationId.value
|
|
456
|
+
&& msg.conversationId !== currentConversationId.value) {
|
|
457
|
+
routeToBackgroundConversation(msg.conversationId, msg);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
189
461
|
if (msg.type === 'connected') {
|
|
190
462
|
// Reset auth state
|
|
191
463
|
authRequired.value = false;
|
|
@@ -230,6 +502,17 @@ export function createConnection(deps) {
|
|
|
230
502
|
isCompacting.value = false;
|
|
231
503
|
queuedMessages.value = [];
|
|
232
504
|
loadingSessions.value = false;
|
|
505
|
+
// Clear processing state for all background conversations
|
|
506
|
+
if (conversationCache) {
|
|
507
|
+
for (const [convId, cached] of Object.entries(conversationCache.value)) {
|
|
508
|
+
cached.isProcessing = false;
|
|
509
|
+
cached.isCompacting = false;
|
|
510
|
+
processingConversations.value[convId] = false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (currentConversationId && currentConversationId.value) {
|
|
514
|
+
processingConversations.value[currentConversationId.value] = false;
|
|
515
|
+
}
|
|
233
516
|
} else if (msg.type === 'agent_reconnected') {
|
|
234
517
|
status.value = 'Connected';
|
|
235
518
|
error.value = '';
|
|
@@ -255,9 +538,16 @@ export function createConnection(deps) {
|
|
|
255
538
|
isProcessing.value = false;
|
|
256
539
|
isCompacting.value = false;
|
|
257
540
|
loadingSessions.value = false;
|
|
541
|
+
if (currentConversationId && currentConversationId.value) {
|
|
542
|
+
processingConversations.value[currentConversationId.value] = false;
|
|
543
|
+
}
|
|
258
544
|
_dequeueNext();
|
|
259
545
|
} else if (msg.type === 'claude_output') {
|
|
260
546
|
handleClaudeOutput(msg, scheduleHighlight);
|
|
547
|
+
} else if (msg.type === 'session_started') {
|
|
548
|
+
// Claude session ID captured — update and refresh sidebar
|
|
549
|
+
currentClaudeSessionId.value = msg.claudeSessionId;
|
|
550
|
+
sidebar.requestSessionList();
|
|
261
551
|
} else if (msg.type === 'command_output') {
|
|
262
552
|
streaming.flushReveal();
|
|
263
553
|
finalizeStreamingMsg(scheduleHighlight);
|
|
@@ -292,6 +582,9 @@ export function createConnection(deps) {
|
|
|
292
582
|
isProcessing.value = false;
|
|
293
583
|
isCompacting.value = false;
|
|
294
584
|
toolMsgMap.clear();
|
|
585
|
+
if (currentConversationId && currentConversationId.value) {
|
|
586
|
+
processingConversations.value[currentConversationId.value] = false;
|
|
587
|
+
}
|
|
295
588
|
if (msg.type === 'execution_cancelled') {
|
|
296
589
|
messages.value.push({
|
|
297
590
|
id: streaming.nextId(), role: 'system',
|
|
@@ -299,6 +592,7 @@ export function createConnection(deps) {
|
|
|
299
592
|
});
|
|
300
593
|
scrollToBottom();
|
|
301
594
|
}
|
|
595
|
+
sidebar.requestSessionList();
|
|
302
596
|
_dequeueNext();
|
|
303
597
|
} else if (msg.type === 'ask_user_question') {
|
|
304
598
|
streaming.flushReveal();
|
|
@@ -420,15 +714,24 @@ export function createConnection(deps) {
|
|
|
420
714
|
workDir.value = msg.workDir;
|
|
421
715
|
localStorage.setItem(`agentlink-workdir-${sessionId.value}`, msg.workDir);
|
|
422
716
|
sidebar.addToWorkdirHistory(msg.workDir);
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
717
|
+
|
|
718
|
+
// Multi-session: switch to a new blank conversation for the new workdir.
|
|
719
|
+
// Background conversations keep running and receiving output in their cache.
|
|
720
|
+
if (switchConversation) {
|
|
721
|
+
const newConvId = crypto.randomUUID();
|
|
722
|
+
switchConversation(newConvId);
|
|
723
|
+
} else {
|
|
724
|
+
// Fallback for old code path (no switchConversation)
|
|
725
|
+
messages.value = [];
|
|
726
|
+
queuedMessages.value = [];
|
|
727
|
+
toolMsgMap.clear();
|
|
728
|
+
visibleLimit.value = 50;
|
|
729
|
+
streaming.setMessageIdCounter(0);
|
|
730
|
+
streaming.setStreamingMessageId(null);
|
|
731
|
+
streaming.reset();
|
|
732
|
+
currentClaudeSessionId.value = null;
|
|
733
|
+
isProcessing.value = false;
|
|
734
|
+
}
|
|
432
735
|
messages.value.push({
|
|
433
736
|
id: streaming.nextId(), role: 'system',
|
|
434
737
|
content: 'Working directory changed to: ' + msg.workDir,
|
|
@@ -485,5 +788,5 @@ export function createConnection(deps) {
|
|
|
485
788
|
ws.send(JSON.stringify({ type: 'authenticate', password: pwd }));
|
|
486
789
|
}
|
|
487
790
|
|
|
488
|
-
return { connect, wsSend, closeWs, submitPassword, setDequeueNext };
|
|
791
|
+
return { connect, wsSend, closeWs, submitPassword, setDequeueNext, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
|
|
489
792
|
}
|
package/web/modules/sidebar.js
CHANGED
|
@@ -32,18 +32,65 @@ export function createSidebar(deps) {
|
|
|
32
32
|
folderPickerOpen, folderPickerPath, folderPickerEntries,
|
|
33
33
|
folderPickerLoading, folderPickerSelected, streaming,
|
|
34
34
|
hostname, workdirHistory,
|
|
35
|
+
// Multi-session parallel
|
|
36
|
+
currentConversationId, conversationCache, processingConversations,
|
|
37
|
+
switchConversation,
|
|
35
38
|
} = deps;
|
|
36
39
|
|
|
37
40
|
// ── Session management ──
|
|
38
41
|
|
|
42
|
+
let _sessionListTimer = null;
|
|
43
|
+
|
|
39
44
|
function requestSessionList() {
|
|
45
|
+
// Debounce: coalesce rapid calls (e.g. session_started + turn_completed)
|
|
46
|
+
// into a single request. First call fires immediately, subsequent calls
|
|
47
|
+
// within 2s are deferred.
|
|
48
|
+
if (_sessionListTimer) {
|
|
49
|
+
clearTimeout(_sessionListTimer);
|
|
50
|
+
_sessionListTimer = setTimeout(() => {
|
|
51
|
+
_sessionListTimer = null;
|
|
52
|
+
loadingSessions.value = true;
|
|
53
|
+
wsSend({ type: 'list_sessions' });
|
|
54
|
+
}, 2000);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
40
57
|
loadingSessions.value = true;
|
|
41
58
|
wsSend({ type: 'list_sessions' });
|
|
59
|
+
_sessionListTimer = setTimeout(() => { _sessionListTimer = null; }, 2000);
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
function resumeSession(session) {
|
|
45
|
-
if (isProcessing.value) return;
|
|
46
63
|
if (window.innerWidth <= 768) sidebarOpen.value = false;
|
|
64
|
+
|
|
65
|
+
// Multi-session: check if we already have a conversation loaded for this claudeSessionId
|
|
66
|
+
if (switchConversation && conversationCache) {
|
|
67
|
+
// Check cache for existing conversation with this claudeSessionId
|
|
68
|
+
for (const [convId, cached] of Object.entries(conversationCache.value)) {
|
|
69
|
+
if (cached.claudeSessionId === session.sessionId) {
|
|
70
|
+
switchConversation(convId);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Check if current foreground already shows this session
|
|
75
|
+
if (currentClaudeSessionId.value === session.sessionId) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Create new conversationId, switch to it, then send resume
|
|
79
|
+
const newConvId = crypto.randomUUID();
|
|
80
|
+
switchConversation(newConvId);
|
|
81
|
+
currentClaudeSessionId.value = session.sessionId;
|
|
82
|
+
needsResume.value = true;
|
|
83
|
+
loadingHistory.value = true;
|
|
84
|
+
wsSend({
|
|
85
|
+
type: 'resume_conversation',
|
|
86
|
+
conversationId: newConvId,
|
|
87
|
+
claudeSessionId: session.sessionId,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Legacy fallback (no multi-session)
|
|
93
|
+
if (isProcessing.value) return;
|
|
47
94
|
messages.value = [];
|
|
48
95
|
visibleLimit.value = 50;
|
|
49
96
|
streaming.setMessageIdCounter(0);
|
|
@@ -61,8 +108,22 @@ export function createSidebar(deps) {
|
|
|
61
108
|
}
|
|
62
109
|
|
|
63
110
|
function newConversation() {
|
|
64
|
-
if (isProcessing.value) return;
|
|
65
111
|
if (window.innerWidth <= 768) sidebarOpen.value = false;
|
|
112
|
+
|
|
113
|
+
// Multi-session: just switch to a new blank conversation
|
|
114
|
+
if (switchConversation) {
|
|
115
|
+
const newConvId = crypto.randomUUID();
|
|
116
|
+
switchConversation(newConvId);
|
|
117
|
+
messages.value.push({
|
|
118
|
+
id: streaming.nextId(), role: 'system',
|
|
119
|
+
content: 'New conversation started.',
|
|
120
|
+
timestamp: new Date(),
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Legacy fallback (no multi-session)
|
|
126
|
+
if (isProcessing.value) return;
|
|
66
127
|
messages.value = [];
|
|
67
128
|
visibleLimit.value = 50;
|
|
68
129
|
streaming.setMessageIdCounter(0);
|
|
@@ -94,8 +155,13 @@ export function createSidebar(deps) {
|
|
|
94
155
|
const deleteConfirmTitle = deps.deleteConfirmTitle;
|
|
95
156
|
|
|
96
157
|
function deleteSession(session) {
|
|
97
|
-
if (
|
|
98
|
-
|
|
158
|
+
if (currentClaudeSessionId.value === session.sessionId) return; // guard: foreground
|
|
159
|
+
// Guard: check background conversations that are actively processing
|
|
160
|
+
if (conversationCache) {
|
|
161
|
+
for (const [, cached] of Object.entries(conversationCache.value)) {
|
|
162
|
+
if (cached.claudeSessionId === session.sessionId && cached.isProcessing) return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
99
165
|
pendingDeleteSession = session;
|
|
100
166
|
deleteConfirmTitle.value = session.title || session.sessionId.slice(0, 8);
|
|
101
167
|
deleteConfirmOpen.value = true;
|
|
@@ -119,7 +185,6 @@ export function createSidebar(deps) {
|
|
|
119
185
|
const renameText = deps.renameText;
|
|
120
186
|
|
|
121
187
|
function startRename(session) {
|
|
122
|
-
if (isProcessing.value) return;
|
|
123
188
|
renamingSessionId.value = session.sessionId;
|
|
124
189
|
renameText.value = session.title || '';
|
|
125
190
|
}
|
|
@@ -251,7 +316,6 @@ export function createSidebar(deps) {
|
|
|
251
316
|
}
|
|
252
317
|
|
|
253
318
|
function switchToWorkdir(path) {
|
|
254
|
-
if (isProcessing.value) return;
|
|
255
319
|
wsSend({ type: 'change_workdir', workDir: path });
|
|
256
320
|
}
|
|
257
321
|
|
|
@@ -259,6 +323,23 @@ export function createSidebar(deps) {
|
|
|
259
323
|
return workdirHistory.value.filter(p => p !== workDir.value);
|
|
260
324
|
});
|
|
261
325
|
|
|
326
|
+
// ── isSessionProcessing ──
|
|
327
|
+
// Used by sidebar template to show processing indicator on session items
|
|
328
|
+
function isSessionProcessing(claudeSessionId) {
|
|
329
|
+
if (!conversationCache || !processingConversations) return false;
|
|
330
|
+
// Check cached background conversations
|
|
331
|
+
for (const [convId, cached] of Object.entries(conversationCache.value)) {
|
|
332
|
+
if (cached.claudeSessionId === claudeSessionId && cached.isProcessing) {
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Check current foreground conversation
|
|
337
|
+
if (currentClaudeSessionId.value === claudeSessionId && isProcessing.value) {
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
|
|
262
343
|
// ── Grouped sessions ──
|
|
263
344
|
|
|
264
345
|
const groupedSessions = computed(() => {
|
|
@@ -288,7 +369,7 @@ export function createSidebar(deps) {
|
|
|
288
369
|
startRename, confirmRename, cancelRename,
|
|
289
370
|
openFolderPicker, folderPickerNavigateUp, folderPickerSelectItem,
|
|
290
371
|
folderPickerEnter, folderPickerGoToPath, confirmFolderPicker,
|
|
291
|
-
groupedSessions,
|
|
372
|
+
groupedSessions, isSessionProcessing,
|
|
292
373
|
loadWorkdirHistory, addToWorkdirHistory, removeFromWorkdirHistory,
|
|
293
374
|
switchToWorkdir, filteredWorkdirHistory,
|
|
294
375
|
};
|
package/web/modules/streaming.js
CHANGED
|
@@ -84,10 +84,27 @@ export function createStreaming({ messages, scrollToBottom }) {
|
|
|
84
84
|
if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function saveState() {
|
|
88
|
+
flushReveal(); // flush pending text into the message before saving
|
|
89
|
+
return {
|
|
90
|
+
pendingText: '',
|
|
91
|
+
streamingMessageId,
|
|
92
|
+
messageIdCounter,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function restoreState(saved) {
|
|
97
|
+
flushReveal(); // clear any current pending
|
|
98
|
+
pendingText = saved.pendingText || '';
|
|
99
|
+
streamingMessageId = saved.streamingMessageId ?? null;
|
|
100
|
+
messageIdCounter = saved.messageIdCounter || 0;
|
|
101
|
+
if (pendingText) startReveal();
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
return {
|
|
88
105
|
startReveal, flushReveal, appendPending, reset, cleanup,
|
|
89
106
|
getMessageIdCounter, setMessageIdCounter,
|
|
90
107
|
getStreamingMessageId, setStreamingMessageId,
|
|
91
|
-
nextId,
|
|
108
|
+
nextId, saveState, restoreState,
|
|
92
109
|
};
|
|
93
110
|
}
|
package/web/style.css
CHANGED
|
@@ -518,6 +518,24 @@ body {
|
|
|
518
518
|
line-height: 1.3;
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
+
/* Processing indicator: pulsing dot before session title */
|
|
522
|
+
.session-item.processing .session-title::before {
|
|
523
|
+
content: '';
|
|
524
|
+
display: inline-block;
|
|
525
|
+
width: 6px;
|
|
526
|
+
height: 6px;
|
|
527
|
+
border-radius: 50%;
|
|
528
|
+
background: var(--accent);
|
|
529
|
+
margin-right: 6px;
|
|
530
|
+
vertical-align: middle;
|
|
531
|
+
animation: pulse-dot 1.5s ease-in-out infinite;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
@keyframes pulse-dot {
|
|
535
|
+
0%, 100% { opacity: 1; }
|
|
536
|
+
50% { opacity: 0.3; }
|
|
537
|
+
}
|
|
538
|
+
|
|
521
539
|
.session-meta {
|
|
522
540
|
font-size: 0.7rem;
|
|
523
541
|
color: var(--text-secondary);
|