@agent-link/server 0.1.112 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-link/server",
3
- "version": "0.1.112",
3
+ "version": "0.1.114",
4
4
  "description": "AgentLink relay server",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- wsSend({ type: 'cancel_execution' });
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" :disabled="isProcessing">
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" :disabled="isProcessing">
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 && !isProcessing" class="session-actions">
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
- messages.value = [];
424
- queuedMessages.value = [];
425
- toolMsgMap.clear();
426
- visibleLimit.value = 50;
427
- streaming.setMessageIdCounter(0);
428
- streaming.setStreamingMessageId(null);
429
- streaming.reset();
430
- currentClaudeSessionId.value = null;
431
- isProcessing.value = false;
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
  }
@@ -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);
@@ -71,6 +132,10 @@ export function createSidebar(deps) {
71
132
  currentClaudeSessionId.value = null;
72
133
  needsResume.value = false;
73
134
 
135
+ // Tell the agent to clear its lastClaudeSessionId so the next message
136
+ // starts a fresh session instead of auto-resuming the previous one.
137
+ wsSend({ type: 'new_conversation' });
138
+
74
139
  messages.value.push({
75
140
  id: streaming.nextId(), role: 'system',
76
141
  content: 'New conversation started.',
@@ -90,8 +155,13 @@ export function createSidebar(deps) {
90
155
  const deleteConfirmTitle = deps.deleteConfirmTitle;
91
156
 
92
157
  function deleteSession(session) {
93
- if (isProcessing.value) return;
94
- if (currentClaudeSessionId.value === session.sessionId) return; // guard
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
+ }
95
165
  pendingDeleteSession = session;
96
166
  deleteConfirmTitle.value = session.title || session.sessionId.slice(0, 8);
97
167
  deleteConfirmOpen.value = true;
@@ -115,7 +185,6 @@ export function createSidebar(deps) {
115
185
  const renameText = deps.renameText;
116
186
 
117
187
  function startRename(session) {
118
- if (isProcessing.value) return;
119
188
  renamingSessionId.value = session.sessionId;
120
189
  renameText.value = session.title || '';
121
190
  }
@@ -247,7 +316,6 @@ export function createSidebar(deps) {
247
316
  }
248
317
 
249
318
  function switchToWorkdir(path) {
250
- if (isProcessing.value) return;
251
319
  wsSend({ type: 'change_workdir', workDir: path });
252
320
  }
253
321
 
@@ -255,6 +323,23 @@ export function createSidebar(deps) {
255
323
  return workdirHistory.value.filter(p => p !== workDir.value);
256
324
  });
257
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
+
258
343
  // ── Grouped sessions ──
259
344
 
260
345
  const groupedSessions = computed(() => {
@@ -284,7 +369,7 @@ export function createSidebar(deps) {
284
369
  startRename, confirmRename, cancelRename,
285
370
  openFolderPicker, folderPickerNavigateUp, folderPickerSelectItem,
286
371
  folderPickerEnter, folderPickerGoToPath, confirmFolderPicker,
287
- groupedSessions,
372
+ groupedSessions, isSessionProcessing,
288
373
  loadWorkdirHistory, addToWorkdirHistory, removeFromWorkdirHistory,
289
374
  switchToWorkdir, filteredWorkdirHistory,
290
375
  };
@@ -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);