@agent-link/server 0.1.144 → 0.1.146

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.144",
3
+ "version": "0.1.146",
4
4
  "description": "AgentLink relay server",
5
5
  "license": "MIT",
6
6
  "repository": {
package/web/app.js CHANGED
@@ -19,6 +19,7 @@ import { createConnection } from './modules/connection.js';
19
19
  import { createFileBrowser } from './modules/fileBrowser.js';
20
20
  import { createFilePreview } from './modules/filePreview.js';
21
21
  import { createTeam } from './modules/team.js';
22
+ import { createScrollManager, createHighlightScheduler, formatUsage } from './modules/appHelpers.js';
22
23
 
23
24
  // ── App ─────────────────────────────────────────────────────────────────────
24
25
  const App = {
@@ -231,38 +232,10 @@ const App = {
231
232
  applyTheme();
232
233
 
233
234
  // ── Scroll management ──
234
- let _scrollTimer = null;
235
- let _userScrolledUp = false;
236
-
237
- function onMessageListScroll(e) {
238
- const el = e.target;
239
- _userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > 80;
240
- }
241
-
242
- function scrollToBottom(force) {
243
- if (_userScrolledUp && !force) return;
244
- if (_scrollTimer) return;
245
- _scrollTimer = setTimeout(() => {
246
- _scrollTimer = null;
247
- const el = document.querySelector('.message-list');
248
- if (el) el.scrollTop = el.scrollHeight;
249
- }, 50);
250
- }
235
+ const { onScroll: onMessageListScroll, scrollToBottom, cleanup: cleanupScroll } = createScrollManager('.message-list');
251
236
 
252
237
  // ── Highlight.js scheduling ──
253
- let _hlTimer = null;
254
- function scheduleHighlight() {
255
- if (_hlTimer) return;
256
- _hlTimer = setTimeout(() => {
257
- _hlTimer = null;
258
- if (typeof hljs !== 'undefined') {
259
- document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
260
- hljs.highlightElement(block);
261
- block.dataset.highlighted = 'true';
262
- });
263
- }
264
- }, 300);
265
- }
238
+ const { scheduleHighlight, cleanup: cleanupHighlight } = createHighlightScheduler();
266
239
 
267
240
  // ── Create module instances ──
268
241
 
@@ -475,25 +448,10 @@ const App = {
475
448
  document.title = name ? `${name} — AgentLink` : 'AgentLink';
476
449
  });
477
450
 
478
- // ── Usage formatting ──
479
- function formatTokens(n) {
480
- if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
481
- return String(n);
482
- }
483
- function formatUsage(u) {
484
- if (!u) return '';
485
- const pct = u.contextWindow ? Math.round(u.inputTokens / u.contextWindow * 100) : 0;
486
- const ctx = formatTokens(u.inputTokens) + ' / ' + formatTokens(u.contextWindow) + ' (' + pct + '%)';
487
- const cost = '$' + u.totalCost.toFixed(2);
488
- const model = u.model.replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/-1m$/, '');
489
- const dur = (u.durationMs / 1000).toFixed(1) + 's';
490
- return 'Context ' + ctx + ' \u00b7 Cost ' + cost + ' \u00b7 ' + model + ' \u00b7 ' + dur;
491
- }
492
-
493
451
  // ── Lifecycle ──
494
452
  onMounted(() => { connect(scheduleHighlight); });
495
453
  onUnmounted(() => {
496
- closeWs(); streaming.cleanup();
454
+ closeWs(); streaming.cleanup(); cleanupScroll(); cleanupHighlight();
497
455
  window.removeEventListener('resize', _resizeHandler);
498
456
  document.removeEventListener('click', _workdirMenuClickHandler);
499
457
  document.removeEventListener('keydown', _workdirMenuKeyHandler);
@@ -0,0 +1,84 @@
1
+ // ── UI utility functions for the main App component ──────────────────────────
2
+
3
+ /**
4
+ * Create scroll management functions.
5
+ * @param {string} selector - CSS selector for the scrollable element
6
+ * @returns {{ onScroll, scrollToBottom, cleanup }}
7
+ */
8
+ export function createScrollManager(selector) {
9
+ let _scrollTimer = null;
10
+ let _userScrolledUp = false;
11
+
12
+ function onScroll(e) {
13
+ const el = e.target;
14
+ _userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > 80;
15
+ }
16
+
17
+ function scrollToBottom(force) {
18
+ if (_userScrolledUp && !force) return;
19
+ if (_scrollTimer) return;
20
+ _scrollTimer = setTimeout(() => {
21
+ _scrollTimer = null;
22
+ const el = document.querySelector(selector);
23
+ if (el) el.scrollTop = el.scrollHeight;
24
+ }, 50);
25
+ }
26
+
27
+ function cleanup() {
28
+ if (_scrollTimer) { clearTimeout(_scrollTimer); _scrollTimer = null; }
29
+ }
30
+
31
+ return { onScroll, scrollToBottom, cleanup };
32
+ }
33
+
34
+ /**
35
+ * Create a debounced highlight.js scheduler.
36
+ * @returns {{ scheduleHighlight, cleanup }}
37
+ */
38
+ export function createHighlightScheduler() {
39
+ let _hlTimer = null;
40
+
41
+ function scheduleHighlight() {
42
+ if (_hlTimer) return;
43
+ _hlTimer = setTimeout(() => {
44
+ _hlTimer = null;
45
+ if (typeof hljs !== 'undefined') {
46
+ document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
47
+ hljs.highlightElement(block);
48
+ block.dataset.highlighted = 'true';
49
+ });
50
+ }
51
+ }, 300);
52
+ }
53
+
54
+ function cleanup() {
55
+ if (_hlTimer) { clearTimeout(_hlTimer); _hlTimer = null; }
56
+ }
57
+
58
+ return { scheduleHighlight, cleanup };
59
+ }
60
+
61
+ /**
62
+ * Format a token count for display (e.g. 1500 → "1.5k").
63
+ * @param {number} n
64
+ * @returns {string}
65
+ */
66
+ export function formatTokens(n) {
67
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
68
+ return String(n);
69
+ }
70
+
71
+ /**
72
+ * Format a usage stats object into a human-readable summary string.
73
+ * @param {object|null} u - Usage stats from turn_completed
74
+ * @returns {string}
75
+ */
76
+ export function formatUsage(u) {
77
+ if (!u) return '';
78
+ const pct = u.contextWindow ? Math.round(u.inputTokens / u.contextWindow * 100) : 0;
79
+ const ctx = formatTokens(u.inputTokens) + ' / ' + formatTokens(u.contextWindow) + ' (' + pct + '%)';
80
+ const cost = '$' + u.totalCost.toFixed(2);
81
+ const model = u.model.replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/-1m$/, '');
82
+ const dur = (u.durationMs / 1000).toFixed(1) + 's';
83
+ return 'Context ' + ctx + ' \u00b7 Cost ' + cost + ' \u00b7 ' + model + ' \u00b7 ' + dur;
84
+ }
@@ -0,0 +1,258 @@
1
+ // ── History batch building & background conversation routing ──────────────────
2
+ import { isContextSummary } from './messageHelpers.js';
3
+
4
+ /**
5
+ * Convert a history array (from conversation_resumed) into a batch of UI messages.
6
+ * @param {Array} history - Array of {role, content, ...} from the agent
7
+ * @param {() => number} nextId - Function that returns the next message ID
8
+ * @returns {Array} Batch of UI message objects
9
+ */
10
+ export function buildHistoryBatch(history, nextId) {
11
+ const batch = [];
12
+ for (const h of history) {
13
+ if (h.role === 'user') {
14
+ if (isContextSummary(h.content)) {
15
+ batch.push({
16
+ id: nextId(), role: 'context-summary',
17
+ content: h.content, contextExpanded: false,
18
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
19
+ });
20
+ } else if (h.isCommandOutput) {
21
+ batch.push({
22
+ id: nextId(), role: 'system',
23
+ content: h.content, isCommandOutput: true,
24
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
25
+ });
26
+ } else {
27
+ batch.push({
28
+ id: nextId(), role: 'user',
29
+ content: h.content,
30
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
31
+ });
32
+ }
33
+ } else if (h.role === 'assistant') {
34
+ const last = batch[batch.length - 1];
35
+ if (last && last.role === 'assistant' && !last.isStreaming) {
36
+ last.content += '\n\n' + h.content;
37
+ } else {
38
+ batch.push({
39
+ id: nextId(), role: 'assistant',
40
+ content: h.content, isStreaming: false,
41
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
42
+ });
43
+ }
44
+ } else if (h.role === 'tool') {
45
+ batch.push({
46
+ id: nextId(), role: 'tool',
47
+ toolId: h.toolId || '', toolName: h.toolName || 'unknown',
48
+ toolInput: h.toolInput || '', hasResult: true,
49
+ expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite' || h.toolName === 'Agent'),
50
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
51
+ });
52
+ }
53
+ }
54
+ return batch;
55
+ }
56
+
57
+ /**
58
+ * Finalize the last streaming assistant message in a message array.
59
+ * @param {Array} msgs - Array of message objects
60
+ */
61
+ export function finalizeLastStreaming(msgs) {
62
+ const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
63
+ if (last && last.role === 'assistant' && last.isStreaming) {
64
+ last.isStreaming = false;
65
+ if (isContextSummary(last.content)) {
66
+ last.role = 'context-summary';
67
+ last.contextExpanded = false;
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Route a message to a background (non-foreground) conversation's cache.
74
+ * @param {object} deps - Dependencies: conversationCache, processingConversations, sidebar, wsSend
75
+ * @param {string} convId - The conversation ID
76
+ * @param {object} msg - The incoming message
77
+ */
78
+ export function routeToBackgroundConversation(deps, convId, msg) {
79
+ const { conversationCache, processingConversations, sidebar, wsSend } = deps;
80
+ const cache = conversationCache.value[convId];
81
+ if (!cache) return;
82
+
83
+ if (msg.type === 'session_started') {
84
+ cache.claudeSessionId = msg.claudeSessionId;
85
+ sidebar.requestSessionList();
86
+ return;
87
+ }
88
+
89
+ if (msg.type === 'conversation_resumed') {
90
+ cache.claudeSessionId = msg.claudeSessionId;
91
+ if (msg.history && Array.isArray(msg.history)) {
92
+ const nextId = () => ++cache.messageIdCounter;
93
+ cache.messages = buildHistoryBatch(msg.history, nextId);
94
+ if (cache.toolMsgMap) cache.toolMsgMap.clear();
95
+ }
96
+ cache.loadingHistory = false;
97
+ if (msg.isCompacting) {
98
+ cache.isCompacting = true;
99
+ cache.isProcessing = true;
100
+ processingConversations.value[convId] = true;
101
+ cache.messages.push({
102
+ id: ++cache.messageIdCounter, role: 'system',
103
+ content: 'Context compacting...', isCompactStart: true,
104
+ timestamp: new Date(),
105
+ });
106
+ } else if (msg.isProcessing) {
107
+ cache.isProcessing = true;
108
+ processingConversations.value[convId] = true;
109
+ cache.messages.push({
110
+ id: ++cache.messageIdCounter, role: 'system',
111
+ content: 'Agent is processing...',
112
+ timestamp: new Date(),
113
+ });
114
+ } else {
115
+ cache.messages.push({
116
+ id: ++cache.messageIdCounter, role: 'system',
117
+ content: 'Session restored. You can continue the conversation.',
118
+ timestamp: new Date(),
119
+ });
120
+ }
121
+ return;
122
+ }
123
+
124
+ if (msg.type === 'claude_output') {
125
+ if (!cache.isProcessing) {
126
+ cache.isProcessing = true;
127
+ processingConversations.value[convId] = true;
128
+ }
129
+ const data = msg.data;
130
+ if (!data) return;
131
+ if (data.type === 'content_block_delta' && data.delta) {
132
+ const msgs = cache.messages;
133
+ const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
134
+ if (last && last.role === 'assistant' && last.isStreaming) {
135
+ last.content += data.delta;
136
+ } else {
137
+ msgs.push({
138
+ id: ++cache.messageIdCounter, role: 'assistant',
139
+ content: data.delta, isStreaming: true, timestamp: new Date(),
140
+ });
141
+ }
142
+ } else if (data.type === 'tool_use' && data.tools) {
143
+ const msgs = cache.messages;
144
+ finalizeLastStreaming(msgs);
145
+ for (const tool of data.tools) {
146
+ const toolMsg = {
147
+ id: ++cache.messageIdCounter, role: 'tool',
148
+ toolId: tool.id, toolName: tool.name || 'unknown',
149
+ toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
150
+ hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite' || tool.name === 'Agent'),
151
+ timestamp: new Date(),
152
+ };
153
+ msgs.push(toolMsg);
154
+ if (tool.id) {
155
+ if (!cache.toolMsgMap) cache.toolMsgMap = new Map();
156
+ cache.toolMsgMap.set(tool.id, toolMsg);
157
+ }
158
+ }
159
+ } else if (data.type === 'user' && data.tool_use_result) {
160
+ const result = data.tool_use_result;
161
+ const results = Array.isArray(result) ? result : [result];
162
+ const tMap = cache.toolMsgMap || new Map();
163
+ for (const r of results) {
164
+ const toolMsg = tMap.get(r.tool_use_id);
165
+ if (toolMsg) {
166
+ toolMsg.toolOutput = typeof r.content === 'string'
167
+ ? r.content : JSON.stringify(r.content, null, 2);
168
+ toolMsg.hasResult = true;
169
+ }
170
+ }
171
+ }
172
+ } else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
173
+ finalizeLastStreaming(cache.messages);
174
+ cache.isProcessing = false;
175
+ cache.isCompacting = false;
176
+ if (msg.usage) cache.usageStats = msg.usage;
177
+ if (cache.toolMsgMap) cache.toolMsgMap.clear();
178
+ processingConversations.value[convId] = false;
179
+ if (msg.type === 'execution_cancelled') {
180
+ cache.needsResume = true;
181
+ cache.messages.push({
182
+ id: ++cache.messageIdCounter, role: 'system',
183
+ content: 'Generation stopped.', timestamp: new Date(),
184
+ });
185
+ }
186
+ sidebar.requestSessionList();
187
+ if (cache.queuedMessages && cache.queuedMessages.length > 0) {
188
+ const queued = cache.queuedMessages.shift();
189
+ cache.messages.push({
190
+ id: ++cache.messageIdCounter, role: 'user', status: 'sent',
191
+ content: queued.content, attachments: queued.attachments,
192
+ timestamp: new Date(),
193
+ });
194
+ cache.isProcessing = true;
195
+ processingConversations.value[convId] = true;
196
+ wsSend(queued.payload);
197
+ }
198
+ } else if (msg.type === 'context_compaction') {
199
+ if (msg.status === 'started') {
200
+ cache.isCompacting = true;
201
+ cache.messages.push({
202
+ id: ++cache.messageIdCounter, role: 'system',
203
+ content: 'Context compacting...', isCompactStart: true,
204
+ timestamp: new Date(),
205
+ });
206
+ } else if (msg.status === 'completed') {
207
+ cache.isCompacting = false;
208
+ const startMsg = [...cache.messages].reverse().find(m => m.isCompactStart && !m.compactDone);
209
+ if (startMsg) {
210
+ startMsg.content = 'Context compacted';
211
+ startMsg.compactDone = true;
212
+ }
213
+ }
214
+ } else if (msg.type === 'error') {
215
+ finalizeLastStreaming(cache.messages);
216
+ cache.messages.push({
217
+ id: ++cache.messageIdCounter, role: 'system',
218
+ content: msg.message, isError: true, timestamp: new Date(),
219
+ });
220
+ cache.isProcessing = false;
221
+ cache.isCompacting = false;
222
+ processingConversations.value[convId] = false;
223
+ } else if (msg.type === 'command_output') {
224
+ finalizeLastStreaming(cache.messages);
225
+ cache.messages.push({
226
+ id: ++cache.messageIdCounter, role: 'system',
227
+ content: msg.content, isCommandOutput: true, timestamp: new Date(),
228
+ });
229
+ } else if (msg.type === 'ask_user_question') {
230
+ const msgs = cache.messages;
231
+ finalizeLastStreaming(msgs);
232
+ for (let i = msgs.length - 1; i >= 0; i--) {
233
+ const m = msgs[i];
234
+ if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
235
+ msgs.splice(i, 1);
236
+ break;
237
+ }
238
+ if (m.role === 'user') break;
239
+ }
240
+ const questions = msg.questions || [];
241
+ const selectedAnswers = {};
242
+ const customTexts = {};
243
+ for (let i = 0; i < questions.length; i++) {
244
+ selectedAnswers[i] = questions[i].multiSelect ? [] : null;
245
+ customTexts[i] = '';
246
+ }
247
+ msgs.push({
248
+ id: ++cache.messageIdCounter,
249
+ role: 'ask-question',
250
+ requestId: msg.requestId,
251
+ questions,
252
+ answered: false,
253
+ selectedAnswers,
254
+ customTexts,
255
+ timestamp: new Date(),
256
+ });
257
+ }
258
+ }
@@ -1,6 +1,7 @@
1
1
  // ── WebSocket connection, message routing, reconnection ──────────────────────
2
2
  import { encrypt, decrypt, isEncrypted, decodeKey } from '../encryption.js';
3
3
  import { isContextSummary } from './messageHelpers.js';
4
+ import { buildHistoryBatch, finalizeLastStreaming, routeToBackgroundConversation } from './backgroundRouting.js';
4
5
 
5
6
  const MAX_RECONNECT_ATTEMPTS = 50;
6
7
  const RECONNECT_BASE_DELAY = 1000;
@@ -55,265 +56,7 @@ export function createConnection(deps) {
55
56
  function clearToolMsgMap() { toolMsgMap.clear(); }
56
57
 
57
58
  // ── Background conversation routing ──
58
- // When a message arrives for a conversation that is not the current foreground,
59
- // update its cached state directly (no streaming animation).
60
- function routeToBackgroundConversation(convId, msg) {
61
- const cache = conversationCache.value[convId];
62
- if (!cache) return; // no cache entry — discard
63
-
64
- if (msg.type === 'session_started') {
65
- // Claude session ID captured for background conversation
66
- cache.claudeSessionId = msg.claudeSessionId;
67
- sidebar.requestSessionList();
68
- return;
69
- }
70
-
71
- if (msg.type === 'conversation_resumed') {
72
- cache.claudeSessionId = msg.claudeSessionId;
73
- if (msg.history && Array.isArray(msg.history)) {
74
- const batch = [];
75
- for (const h of msg.history) {
76
- if (h.role === 'user') {
77
- if (isContextSummary(h.content)) {
78
- batch.push({
79
- id: ++cache.messageIdCounter, role: 'context-summary',
80
- content: h.content, contextExpanded: false,
81
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
82
- });
83
- } else if (h.isCommandOutput) {
84
- batch.push({
85
- id: ++cache.messageIdCounter, role: 'system',
86
- content: h.content, isCommandOutput: true,
87
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
88
- });
89
- } else {
90
- batch.push({
91
- id: ++cache.messageIdCounter, role: 'user',
92
- content: h.content,
93
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
94
- });
95
- }
96
- } else if (h.role === 'assistant') {
97
- const last = batch[batch.length - 1];
98
- if (last && last.role === 'assistant' && !last.isStreaming) {
99
- last.content += '\n\n' + h.content;
100
- } else {
101
- batch.push({
102
- id: ++cache.messageIdCounter, role: 'assistant',
103
- content: h.content, isStreaming: false,
104
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
105
- });
106
- }
107
- } else if (h.role === 'tool') {
108
- batch.push({
109
- id: ++cache.messageIdCounter, role: 'tool',
110
- toolId: h.toolId || '', toolName: h.toolName || 'unknown',
111
- toolInput: h.toolInput || '', hasResult: true,
112
- expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite' || h.toolName === 'Agent'),
113
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
114
- });
115
- }
116
- }
117
- cache.messages = batch;
118
- if (cache.toolMsgMap) cache.toolMsgMap.clear();
119
- }
120
- cache.loadingHistory = false;
121
- if (msg.isCompacting) {
122
- cache.isCompacting = true;
123
- cache.isProcessing = true;
124
- processingConversations.value[convId] = true;
125
- cache.messages.push({
126
- id: ++cache.messageIdCounter, role: 'system',
127
- content: 'Context compacting...', isCompactStart: true,
128
- timestamp: new Date(),
129
- });
130
- } else if (msg.isProcessing) {
131
- cache.isProcessing = true;
132
- processingConversations.value[convId] = true;
133
- cache.messages.push({
134
- id: ++cache.messageIdCounter, role: 'system',
135
- content: 'Agent is processing...',
136
- timestamp: new Date(),
137
- });
138
- } else {
139
- cache.messages.push({
140
- id: ++cache.messageIdCounter, role: 'system',
141
- content: 'Session restored. You can continue the conversation.',
142
- timestamp: new Date(),
143
- });
144
- }
145
- return;
146
- }
147
-
148
- if (msg.type === 'claude_output') {
149
- // Safety net: restore processing state if output arrives after reconnect
150
- if (!cache.isProcessing) {
151
- cache.isProcessing = true;
152
- processingConversations.value[convId] = true;
153
- }
154
- const data = msg.data;
155
- if (!data) return;
156
- if (data.type === 'content_block_delta' && data.delta) {
157
- // Append text to last assistant message (or create new one)
158
- const msgs = cache.messages;
159
- const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
160
- if (last && last.role === 'assistant' && last.isStreaming) {
161
- last.content += data.delta;
162
- } else {
163
- msgs.push({
164
- id: ++cache.messageIdCounter, role: 'assistant',
165
- content: data.delta, isStreaming: true, timestamp: new Date(),
166
- });
167
- }
168
- } else if (data.type === 'tool_use' && data.tools) {
169
- // Finalize streaming message
170
- const msgs = cache.messages;
171
- const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
172
- if (last && last.role === 'assistant' && last.isStreaming) {
173
- last.isStreaming = false;
174
- if (isContextSummary(last.content)) {
175
- last.role = 'context-summary';
176
- last.contextExpanded = false;
177
- }
178
- }
179
- for (const tool of data.tools) {
180
- const toolMsg = {
181
- id: ++cache.messageIdCounter, role: 'tool',
182
- toolId: tool.id, toolName: tool.name || 'unknown',
183
- toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
184
- hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite' || tool.name === 'Agent'),
185
- timestamp: new Date(),
186
- };
187
- msgs.push(toolMsg);
188
- if (tool.id) {
189
- if (!cache.toolMsgMap) cache.toolMsgMap = new Map();
190
- cache.toolMsgMap.set(tool.id, toolMsg);
191
- }
192
- }
193
- } else if (data.type === 'user' && data.tool_use_result) {
194
- const result = data.tool_use_result;
195
- const results = Array.isArray(result) ? result : [result];
196
- const tMap = cache.toolMsgMap || new Map();
197
- for (const r of results) {
198
- const toolMsg = tMap.get(r.tool_use_id);
199
- if (toolMsg) {
200
- toolMsg.toolOutput = typeof r.content === 'string'
201
- ? r.content : JSON.stringify(r.content, null, 2);
202
- toolMsg.hasResult = true;
203
- }
204
- }
205
- }
206
- } else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
207
- // Finalize streaming message
208
- const msgs = cache.messages;
209
- const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
210
- if (last && last.role === 'assistant' && last.isStreaming) {
211
- last.isStreaming = false;
212
- if (isContextSummary(last.content)) {
213
- last.role = 'context-summary';
214
- last.contextExpanded = false;
215
- }
216
- }
217
- cache.isProcessing = false;
218
- cache.isCompacting = false;
219
- if (msg.usage) cache.usageStats = msg.usage;
220
- if (cache.toolMsgMap) cache.toolMsgMap.clear();
221
- processingConversations.value[convId] = false;
222
- if (msg.type === 'execution_cancelled') {
223
- cache.needsResume = true;
224
- cache.messages.push({
225
- id: ++cache.messageIdCounter, role: 'system',
226
- content: 'Generation stopped.', timestamp: new Date(),
227
- });
228
- }
229
- sidebar.requestSessionList();
230
- // Dequeue next message for this background conversation
231
- if (cache.queuedMessages && cache.queuedMessages.length > 0) {
232
- const queued = cache.queuedMessages.shift();
233
- cache.messages.push({
234
- id: ++cache.messageIdCounter, role: 'user', status: 'sent',
235
- content: queued.content, attachments: queued.attachments,
236
- timestamp: new Date(),
237
- });
238
- cache.isProcessing = true;
239
- processingConversations.value[convId] = true;
240
- wsSend(queued.payload);
241
- }
242
- } else if (msg.type === 'context_compaction') {
243
- if (msg.status === 'started') {
244
- cache.isCompacting = true;
245
- cache.messages.push({
246
- id: ++cache.messageIdCounter, role: 'system',
247
- content: 'Context compacting...', isCompactStart: true,
248
- timestamp: new Date(),
249
- });
250
- } else if (msg.status === 'completed') {
251
- cache.isCompacting = false;
252
- const startMsg = [...cache.messages].reverse().find(m => m.isCompactStart && !m.compactDone);
253
- if (startMsg) {
254
- startMsg.content = 'Context compacted';
255
- startMsg.compactDone = true;
256
- }
257
- }
258
- } else if (msg.type === 'error') {
259
- // Finalize streaming
260
- const msgs = cache.messages;
261
- const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
262
- if (last && last.role === 'assistant' && last.isStreaming) {
263
- last.isStreaming = false;
264
- }
265
- cache.messages.push({
266
- id: ++cache.messageIdCounter, role: 'system',
267
- content: msg.message, isError: true, timestamp: new Date(),
268
- });
269
- cache.isProcessing = false;
270
- cache.isCompacting = false;
271
- processingConversations.value[convId] = false;
272
- } else if (msg.type === 'command_output') {
273
- const msgs = cache.messages;
274
- const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
275
- if (last && last.role === 'assistant' && last.isStreaming) {
276
- last.isStreaming = false;
277
- }
278
- cache.messages.push({
279
- id: ++cache.messageIdCounter, role: 'system',
280
- content: msg.content, isCommandOutput: true, timestamp: new Date(),
281
- });
282
- } else if (msg.type === 'ask_user_question') {
283
- // Finalize streaming
284
- const msgs = cache.messages;
285
- const last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
286
- if (last && last.role === 'assistant' && last.isStreaming) {
287
- last.isStreaming = false;
288
- }
289
- // Remove AskUserQuestion tool msg
290
- for (let i = msgs.length - 1; i >= 0; i--) {
291
- const m = msgs[i];
292
- if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
293
- msgs.splice(i, 1);
294
- break;
295
- }
296
- if (m.role === 'user') break;
297
- }
298
- const questions = msg.questions || [];
299
- const selectedAnswers = {};
300
- const customTexts = {};
301
- for (let i = 0; i < questions.length; i++) {
302
- selectedAnswers[i] = questions[i].multiSelect ? [] : null;
303
- customTexts[i] = '';
304
- }
305
- msgs.push({
306
- id: ++cache.messageIdCounter,
307
- role: 'ask-question',
308
- requestId: msg.requestId,
309
- questions,
310
- answered: false,
311
- selectedAnswers,
312
- customTexts,
313
- timestamp: new Date(),
314
- });
315
- }
316
- }
59
+ // Delegated to backgroundRouting.js module.
317
60
 
318
61
  function wsSend(msg) {
319
62
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
@@ -493,7 +236,7 @@ export function createConnection(deps) {
493
236
  if (msg.conversationId && currentConversationId
494
237
  && currentConversationId.value
495
238
  && msg.conversationId !== currentConversationId.value) {
496
- routeToBackgroundConversation(msg.conversationId, msg);
239
+ routeToBackgroundConversation({ conversationCache, processingConversations, sidebar, wsSend }, msg.conversationId, msg);
497
240
  return;
498
241
  }
499
242
 
@@ -738,49 +481,7 @@ export function createConnection(deps) {
738
481
  } else if (msg.type === 'conversation_resumed') {
739
482
  currentClaudeSessionId.value = msg.claudeSessionId;
740
483
  if (msg.history && Array.isArray(msg.history)) {
741
- const batch = [];
742
- for (const h of msg.history) {
743
- if (h.role === 'user') {
744
- if (isContextSummary(h.content)) {
745
- batch.push({
746
- id: streaming.nextId(), role: 'context-summary',
747
- content: h.content, contextExpanded: false,
748
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
749
- });
750
- } else if (h.isCommandOutput) {
751
- batch.push({
752
- id: streaming.nextId(), role: 'system',
753
- content: h.content, isCommandOutput: true,
754
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
755
- });
756
- } else {
757
- batch.push({
758
- id: streaming.nextId(), role: 'user',
759
- content: h.content,
760
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
761
- });
762
- }
763
- } else if (h.role === 'assistant') {
764
- const last = batch[batch.length - 1];
765
- if (last && last.role === 'assistant' && !last.isStreaming) {
766
- last.content += '\n\n' + h.content;
767
- } else {
768
- batch.push({
769
- id: streaming.nextId(), role: 'assistant',
770
- content: h.content, isStreaming: false,
771
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
772
- });
773
- }
774
- } else if (h.role === 'tool') {
775
- batch.push({
776
- id: streaming.nextId(), role: 'tool',
777
- toolId: h.toolId || '', toolName: h.toolName || 'unknown',
778
- toolInput: h.toolInput || '', hasResult: true,
779
- expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite' || h.toolName === 'Agent'), timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
780
- });
781
- }
782
- }
783
- messages.value = batch;
484
+ messages.value = buildHistoryBatch(msg.history, () => streaming.nextId());
784
485
  toolMsgMap.clear();
785
486
  }
786
487
  loadingHistory.value = false;