@agent-link/server 0.1.145 → 0.1.147

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.145",
3
+ "version": "0.1.147",
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 = {
@@ -117,6 +118,8 @@ const App = {
117
118
  const sidebarView = ref('sessions'); // 'sessions' | 'files' | 'preview' (mobile only)
118
119
  const isMobile = ref(window.innerWidth <= 768);
119
120
  const workdirMenuOpen = ref(false);
121
+ const teamsCollapsed = ref(false);
122
+ const chatsCollapsed = ref(false);
120
123
 
121
124
  // Team creation state
122
125
  const teamInstruction = ref('');
@@ -231,38 +234,10 @@ const App = {
231
234
  applyTheme();
232
235
 
233
236
  // ── 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
- }
237
+ const { onScroll: onMessageListScroll, scrollToBottom, cleanup: cleanupScroll } = createScrollManager('.message-list');
251
238
 
252
239
  // ── 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
- }
240
+ const { scheduleHighlight, cleanup: cleanupHighlight } = createHighlightScheduler();
266
241
 
267
242
  // ── Create module instances ──
268
243
 
@@ -475,25 +450,10 @@ const App = {
475
450
  document.title = name ? `${name} — AgentLink` : 'AgentLink';
476
451
  });
477
452
 
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
453
  // ── Lifecycle ──
494
454
  onMounted(() => { connect(scheduleHighlight); });
495
455
  onUnmounted(() => {
496
- closeWs(); streaming.cleanup();
456
+ closeWs(); streaming.cleanup(); cleanupScroll(); cleanupHighlight();
497
457
  window.removeEventListener('resize', _resizeHandler);
498
458
  document.removeEventListener('click', _workdirMenuClickHandler);
499
459
  document.removeEventListener('keydown', _workdirMenuKeyHandler);
@@ -603,6 +563,7 @@ const App = {
603
563
  // File preview
604
564
  previewPanelOpen, previewPanelWidth, previewFile, previewLoading, previewMarkdownRendered, filePreview,
605
565
  workdirMenuOpen,
566
+ teamsCollapsed, chatsCollapsed,
606
567
  toggleWorkdirMenu() { workdirMenuOpen.value = !workdirMenuOpen.value; },
607
568
  workdirMenuBrowse() {
608
569
  workdirMenuOpen.value = false;
@@ -885,10 +846,14 @@ const App = {
885
846
 
886
847
  <!-- Teams section -->
887
848
  <div class="sidebar-section sidebar-teams">
888
- <div class="sidebar-section-header">
849
+ <div class="sidebar-section-header" @click="teamsCollapsed = !teamsCollapsed" style="cursor: pointer;">
889
850
  <span>Teams History</span>
851
+ <button class="sidebar-collapse-btn" :title="teamsCollapsed ? 'Expand' : 'Collapse'">
852
+ <svg :class="{ collapsed: teamsCollapsed }" viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>
853
+ </button>
890
854
  </div>
891
855
 
856
+ <div v-show="!teamsCollapsed">
892
857
  <button class="new-conversation-btn" @click="newTeam" :disabled="isTeamActive">
893
858
  <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
894
859
  New team
@@ -932,16 +897,23 @@ const App = {
932
897
  </div>
933
898
  </div>
934
899
  </div>
900
+ </div>
935
901
  </div>
936
902
 
937
903
  <div class="sidebar-section sidebar-sessions">
938
- <div class="sidebar-section-header">
904
+ <div class="sidebar-section-header" @click="chatsCollapsed = !chatsCollapsed" style="cursor: pointer;">
939
905
  <span>Chat History</span>
940
- <button class="sidebar-refresh-btn" @click="requestSessionList" title="Refresh" :disabled="loadingSessions">
941
- <svg :class="{ spinning: loadingSessions }" viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
942
- </button>
906
+ <span class="sidebar-section-header-actions">
907
+ <button class="sidebar-refresh-btn" @click.stop="requestSessionList" title="Refresh" :disabled="loadingSessions">
908
+ <svg :class="{ spinning: loadingSessions }" viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
909
+ </button>
910
+ <button class="sidebar-collapse-btn" :title="chatsCollapsed ? 'Expand' : 'Collapse'">
911
+ <svg :class="{ collapsed: chatsCollapsed }" viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>
912
+ </button>
913
+ </span>
943
914
  </div>
944
915
 
916
+ <div v-show="!chatsCollapsed">
945
917
  <button class="new-conversation-btn" @click="newConversation">
946
918
  <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
947
919
  New conversation
@@ -1002,6 +974,7 @@ const App = {
1002
974
  </div>
1003
975
  </div>
1004
976
  </div>
977
+ </div>
1005
978
  </div>
1006
979
 
1007
980
  <div v-if="serverVersion || agentVersion" class="sidebar-version-footer">
@@ -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;
package/web/style.css CHANGED
@@ -419,6 +419,38 @@ body {
419
419
  cursor: not-allowed;
420
420
  }
421
421
 
422
+ .sidebar-section-header-actions {
423
+ display: flex;
424
+ align-items: center;
425
+ gap: 2px;
426
+ }
427
+
428
+ .sidebar-collapse-btn {
429
+ display: flex;
430
+ align-items: center;
431
+ justify-content: center;
432
+ width: 24px;
433
+ height: 24px;
434
+ background: none;
435
+ border: none;
436
+ border-radius: 4px;
437
+ color: var(--text-secondary);
438
+ cursor: pointer;
439
+ transition: color 0.15s;
440
+ }
441
+
442
+ .sidebar-collapse-btn:hover {
443
+ color: var(--text-primary);
444
+ }
445
+
446
+ .sidebar-collapse-btn svg {
447
+ transition: transform 0.2s ease;
448
+ }
449
+
450
+ .sidebar-collapse-btn svg.collapsed {
451
+ transform: rotate(-90deg);
452
+ }
453
+
422
454
  @keyframes spin {
423
455
  from { transform: rotate(0deg); }
424
456
  to { transform: rotate(360deg); }