@agent-link/server 0.1.28 → 0.1.29

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/web/app.js CHANGED
@@ -1,110 +1,26 @@
1
+ // ── AgentLink Web UI — Main coordinator ──────────────────────────────────────
1
2
  const { createApp, ref, nextTick, onMounted, onUnmounted, computed, watch } = Vue;
2
- import { encrypt, decrypt, isEncrypted, decodeKey } from './encryption.js';
3
3
 
4
- // ── Markdown setup ──────────────────────────────────────────────────────────
5
- if (typeof marked !== 'undefined') {
6
- marked.setOptions({
7
- breaks: true,
8
- gfm: true,
9
- highlight: function(code, lang) {
10
- if (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) {
11
- try { return hljs.highlight(code, { language: lang }).value; } catch {}
12
- }
13
- return code;
14
- },
15
- });
16
- }
17
-
18
- const _mdCache = new Map();
19
-
20
- function renderMarkdown(text) {
21
- if (!text) return '';
22
- const cached = _mdCache.get(text);
23
- if (cached) return cached;
24
- let html;
25
- try {
26
- if (typeof marked !== 'undefined') {
27
- html = marked.parse(text);
28
- // Add copy buttons to code blocks
29
- html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
30
- (match, attrs, code) => {
31
- const langMatch = attrs.match(/class="language-(\w+)"/);
32
- const lang = langMatch ? langMatch[1] : '';
33
- return `<div class="code-block-wrapper">
34
- <div class="code-block-header">
35
- <span class="code-lang">${lang}</span>
36
- <button class="code-copy-btn" onclick="window.__copyCodeBlock(this)" title="Copy">
37
- <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
38
- </button>
39
- </div>
40
- <pre><code${attrs}>${code}</code></pre>
41
- </div>`;
42
- }
43
- );
44
- } else {
45
- html = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
46
- }
47
- } catch {
48
- html = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
49
- }
50
- // Only cache completed (non-streaming) messages; streaming text changes every tick
51
- if (_mdCache.size > 500) _mdCache.clear();
52
- _mdCache.set(text, html);
53
- return html;
54
- }
55
-
56
- // Global code copy handler
57
- window.__copyCodeBlock = async function(btn) {
58
- const wrapper = btn.closest('.code-block-wrapper');
59
- const code = wrapper?.querySelector('code');
60
- if (!code) return;
61
- try {
62
- await navigator.clipboard.writeText(code.textContent);
63
- btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';
64
- setTimeout(() => {
65
- btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>';
66
- }, 2000);
67
- } catch {}
68
- };
69
-
70
- // Tool icons (monochrome SVG)
71
- const TOOL_SVG = {
72
- Read: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M1 2.5A2.5 2.5 0 0 1 3.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75H3.5a1 1 0 0 0-1 1h9.25a.75.75 0 0 1 0 1.5H3.5A2.5 2.5 0 0 1 1 14V2.5zm3 0v7l1.5-1.25L7 9.5v-7H4z"/></svg>',
73
- Edit: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25a1.75 1.75 0 0 1 .445-.758l8.61-8.61zM11.524 2.2l-8.61 8.61a.25.25 0 0 0-.064.108l-.57 1.996 1.996-.57a.25.25 0 0 0 .108-.064l8.61-8.61a.25.25 0 0 0 0-.354l-1.086-1.086a.25.25 0 0 0-.354 0z"/></svg>',
74
- Write: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M8.75 1.75a.75.75 0 0 0-1.5 0V6H2.75a.75.75 0 0 0 0 1.5H7.25v4.25a.75.75 0 0 0 1.5 0V7.5h4.25a.75.75 0 0 0 0-1.5H8.75V1.75z"/></svg>',
75
- Bash: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25H1.75zM7 11a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5A.75.75 0 0 1 7 11zm-3.22-4.53a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.75.75 0 0 1-1.06-1.06L5.25 9 3.78 7.53a.75.75 0 0 1 0-1.06z"/></svg>',
76
- Glob: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 1 1-1.06 1.06l-3.04-3.04zM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7z"/></svg>',
77
- Grep: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 1 1-1.06 1.06l-3.04-3.04zM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7z"/></svg>',
78
- Task: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75C0 1.784.784 1 1.75 1zm0 1.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25H1.75zM3.5 5h9v1.5h-9V5zm0 3h9v1.5h-9V8zm0 3h5v1.5h-5V11z"/></svg>',
79
- WebFetch: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm3.7 5.3a.75.75 0 0 0-1.06-1.06l-5.5 5.5a.75.75 0 1 0 1.06 1.06l5.5-5.5zM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z"/></svg>',
80
- WebSearch: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm3.7 5.3a.75.75 0 0 0-1.06-1.06l-5.5 5.5a.75.75 0 1 0 1.06 1.06l5.5-5.5zM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z"/></svg>',
81
- TodoWrite: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 1.042-1.08L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg>',
82
- };
83
- const TOOL_SVG_DEFAULT = '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M7.429 1.525a3.751 3.751 0 0 1 4.41.899l.04.045a.75.75 0 0 1-.17 1.143l-2.2 1.378a1.25 1.25 0 0 0-.473 1.58l.614 1.341a1.25 1.25 0 0 0 1.412.663l2.476-.542a.75.75 0 0 1 .848.496 3.75 3.75 0 0 1-1.468 4.155 3.751 3.751 0 0 1-4.41-.898l-.04-.046a.75.75 0 0 1 .17-1.142l2.2-1.378a1.25 1.25 0 0 0 .473-1.58l-.614-1.342a1.25 1.25 0 0 0-1.412-.662l-2.476.541a.75.75 0 0 1-.848-.496 3.75 3.75 0 0 1 1.468-4.155z"/></svg>';
84
- function getToolIcon(name) { return TOOL_SVG[name] || TOOL_SVG_DEFAULT; }
85
-
86
- // ── Helpers ─────────────────────────────────────────────────────────────────
87
- const CONTEXT_SUMMARY_PREFIX = 'This session is being continued from a previous conversation';
88
-
89
- function isContextSummary(text) {
90
- return typeof text === 'string' && text.trimStart().startsWith(CONTEXT_SUMMARY_PREFIX);
91
- }
92
-
93
- function formatRelativeTime(ts) {
94
- const diff = Date.now() - ts;
95
- const mins = Math.floor(diff / 60000);
96
- if (mins < 1) return 'just now';
97
- if (mins < 60) return `${mins}m ago`;
98
- const hours = Math.floor(mins / 60);
99
- if (hours < 24) return `${hours}h ago`;
100
- const days = Math.floor(hours / 24);
101
- if (days < 30) return `${days}d ago`;
102
- return new Date(ts).toLocaleDateString();
103
- }
4
+ // Module imports
5
+ import { renderMarkdown, getToolIcon } from './modules/markdown.js';
6
+ import {
7
+ isContextSummary, formatRelativeTime, formatTimestamp,
8
+ getRenderedContent, copyMessage, isPrevAssistant, toggleContextSummary,
9
+ toggleTool, getToolSummary, isEditTool, getFormattedToolInput, getEditDiffHtml,
10
+ } from './modules/messageHelpers.js';
11
+ import { formatFileSize, createFileAttachments } from './modules/fileAttachments.js';
12
+ import {
13
+ selectQuestionOption, submitQuestionAnswer,
14
+ hasQuestionAnswer, getQuestionResponseSummary,
15
+ } from './modules/askQuestion.js';
16
+ import { createStreaming } from './modules/streaming.js';
17
+ import { createSidebar } from './modules/sidebar.js';
18
+ import { createConnection } from './modules/connection.js';
104
19
 
105
20
  // ── App ─────────────────────────────────────────────────────────────────────
106
21
  const App = {
107
22
  setup() {
23
+ // ── Reactive state ──
108
24
  const status = ref('Connecting...');
109
25
  const agentName = ref('');
110
26
  const hostname = ref('');
@@ -146,119 +62,7 @@ const App = {
146
62
  const fileInputRef = ref(null);
147
63
  const dragOver = ref(false);
148
64
 
149
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
150
- const MAX_FILES = 5;
151
- const ACCEPTED_EXTENSIONS = [
152
- '.pdf', '.json', '.md', '.py', '.js', '.ts', '.tsx', '.jsx', '.css',
153
- '.html', '.xml', '.yaml', '.yml', '.toml', '.sh', '.sql', '.csv',
154
- '.c', '.cpp', '.h', '.hpp', '.java', '.go', '.rs', '.rb', '.php',
155
- '.swift', '.kt', '.scala', '.r', '.m', '.vue', '.svelte', '.txt',
156
- '.log', '.cfg', '.ini', '.env', '.gitignore', '.dockerfile',
157
- ];
158
-
159
- function isAcceptedFile(file) {
160
- if (file.type.startsWith('image/')) return true;
161
- if (file.type.startsWith('text/')) return true;
162
- const ext = '.' + file.name.split('.').pop().toLowerCase();
163
- return ACCEPTED_EXTENSIONS.includes(ext);
164
- }
165
-
166
- function formatFileSize(bytes) {
167
- if (bytes < 1024) return bytes + ' B';
168
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
169
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
170
- }
171
-
172
- function readFileAsBase64(file) {
173
- return new Promise((resolve, reject) => {
174
- const reader = new FileReader();
175
- reader.onload = () => {
176
- // result is "data:<mime>;base64,<data>" — extract just the base64 part
177
- const base64 = reader.result.split(',')[1];
178
- resolve(base64);
179
- };
180
- reader.onerror = reject;
181
- reader.readAsDataURL(file);
182
- });
183
- }
184
-
185
- async function addFiles(fileList) {
186
- const currentCount = attachments.value.length;
187
- const remaining = MAX_FILES - currentCount;
188
- if (remaining <= 0) return;
189
-
190
- const files = Array.from(fileList).slice(0, remaining);
191
- for (const file of files) {
192
- if (!isAcceptedFile(file)) continue;
193
- if (file.size > MAX_FILE_SIZE) continue;
194
- // Skip duplicates
195
- if (attachments.value.some(a => a.name === file.name && a.size === file.size)) continue;
196
-
197
- const data = await readFileAsBase64(file);
198
- const isImage = file.type.startsWith('image/');
199
- let thumbUrl = null;
200
- if (isImage) {
201
- thumbUrl = URL.createObjectURL(file);
202
- }
203
- attachments.value.push({
204
- name: file.name,
205
- mimeType: file.type || 'application/octet-stream',
206
- size: file.size,
207
- data,
208
- isImage,
209
- thumbUrl,
210
- });
211
- }
212
- }
213
-
214
- function removeAttachment(index) {
215
- const att = attachments.value[index];
216
- if (att.thumbUrl) URL.revokeObjectURL(att.thumbUrl);
217
- attachments.value.splice(index, 1);
218
- }
219
-
220
- function triggerFileInput() {
221
- if (fileInputRef.value) fileInputRef.value.click();
222
- }
223
-
224
- function handleFileSelect(e) {
225
- if (e.target.files) addFiles(e.target.files);
226
- e.target.value = ''; // reset so same file can be selected again
227
- }
228
-
229
- function handleDragOver(e) {
230
- e.preventDefault();
231
- dragOver.value = true;
232
- }
233
-
234
- function handleDragLeave(e) {
235
- e.preventDefault();
236
- dragOver.value = false;
237
- }
238
-
239
- function handleDrop(e) {
240
- e.preventDefault();
241
- dragOver.value = false;
242
- if (e.dataTransfer?.files) addFiles(e.dataTransfer.files);
243
- }
244
-
245
- function handlePaste(e) {
246
- const items = e.clipboardData?.items;
247
- if (!items) return;
248
- const files = [];
249
- for (const item of items) {
250
- if (item.kind === 'file') {
251
- const file = item.getAsFile();
252
- if (file) files.push(file);
253
- }
254
- }
255
- if (files.length > 0) {
256
- e.preventDefault();
257
- addFiles(files);
258
- }
259
- }
260
-
261
- // Theme state
65
+ // Theme
262
66
  const theme = ref(localStorage.getItem('agentlink-theme') || 'dark');
263
67
  function applyTheme() {
264
68
  document.documentElement.setAttribute('data-theme', theme.value);
@@ -274,93 +78,12 @@ const App = {
274
78
  }
275
79
  applyTheme();
276
80
 
277
- let ws = null;
278
- let sessionKey = null;
279
- let messageIdCounter = 0;
280
- let streamingMessageId = null;
281
-
282
- function wsSend(msg) {
283
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
284
- if (sessionKey) {
285
- const encrypted = encrypt(msg, sessionKey);
286
- ws.send(JSON.stringify(encrypted));
287
- } else {
288
- ws.send(JSON.stringify(msg));
289
- }
290
- }
291
-
292
- // Progressive text reveal state
293
- let pendingText = '';
294
- let revealTimer = null;
295
- const CHARS_PER_TICK = 5;
296
- const TICK_MS = 16;
297
-
298
- function startReveal() {
299
- if (revealTimer !== null) return;
300
- revealTimer = setTimeout(revealTick, TICK_MS);
301
- }
302
-
303
- function revealTick() {
304
- revealTimer = null;
305
- if (!pendingText) return;
306
-
307
- const streamMsg = streamingMessageId !== null
308
- ? messages.value.find(m => m.id === streamingMessageId)
309
- : null;
310
-
311
- if (!streamMsg) {
312
- const id = ++messageIdCounter;
313
- const chunk = pendingText.slice(0, CHARS_PER_TICK);
314
- pendingText = pendingText.slice(CHARS_PER_TICK);
315
- messages.value.push({
316
- id, role: 'assistant', content: chunk,
317
- isStreaming: true, timestamp: new Date(),
318
- });
319
- streamingMessageId = id;
320
- } else {
321
- const chunk = pendingText.slice(0, CHARS_PER_TICK);
322
- pendingText = pendingText.slice(CHARS_PER_TICK);
323
- streamMsg.content += chunk;
324
- }
325
- scrollToBottom();
326
- if (pendingText) revealTimer = setTimeout(revealTick, TICK_MS);
327
- }
328
-
329
- function flushReveal() {
330
- if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
331
- if (!pendingText) return;
332
- const streamMsg = streamingMessageId !== null
333
- ? messages.value.find(m => m.id === streamingMessageId) : null;
334
- if (streamMsg) {
335
- streamMsg.content += pendingText;
336
- } else {
337
- const id = ++messageIdCounter;
338
- messages.value.push({
339
- id, role: 'assistant', content: pendingText,
340
- isStreaming: true, timestamp: new Date(),
341
- });
342
- streamingMessageId = id;
343
- }
344
- pendingText = '';
345
- scrollToBottom();
346
- }
347
-
348
- const canSend = computed(() =>
349
- status.value === 'Connected' && (inputText.value.trim() || attachments.value.length > 0) && !isProcessing.value && !isCompacting.value
350
- && !messages.value.some(m => m.role === 'ask-question' && !m.answered)
351
- );
352
-
353
- function getSessionId() {
354
- const match = window.location.pathname.match(/^\/s\/([^/]+)/);
355
- return match ? match[1] : null;
356
- }
357
-
81
+ // ── Scroll management ──
358
82
  let _scrollTimer = null;
359
83
  let _userScrolledUp = false;
360
84
 
361
85
  function onMessageListScroll(e) {
362
86
  const el = e.target;
363
- // Consider "at bottom" if within 80px of the bottom
364
87
  _userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > 80;
365
88
  }
366
89
 
@@ -374,6 +97,57 @@ const App = {
374
97
  }, 50);
375
98
  }
376
99
 
100
+ // ── Highlight.js scheduling ──
101
+ let _hlTimer = null;
102
+ function scheduleHighlight() {
103
+ if (_hlTimer) return;
104
+ _hlTimer = setTimeout(() => {
105
+ _hlTimer = null;
106
+ if (typeof hljs !== 'undefined') {
107
+ document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
108
+ hljs.highlightElement(block);
109
+ block.dataset.highlighted = 'true';
110
+ });
111
+ }
112
+ }, 300);
113
+ }
114
+
115
+ // ── Create module instances ──
116
+
117
+ const streaming = createStreaming({ messages, scrollToBottom });
118
+
119
+ const fileAttach = createFileAttachments(attachments, fileInputRef, dragOver);
120
+
121
+ // Sidebar needs wsSend, but connection creates wsSend.
122
+ // Resolve circular dependency with a forwarding function.
123
+ let _wsSend = () => {};
124
+
125
+ const sidebar = createSidebar({
126
+ wsSend: (msg) => _wsSend(msg),
127
+ messages, isProcessing, sidebarOpen,
128
+ historySessions, currentClaudeSessionId, needsResume,
129
+ loadingSessions, loadingHistory, workDir, visibleLimit,
130
+ folderPickerOpen, folderPickerPath, folderPickerEntries,
131
+ folderPickerLoading, folderPickerSelected, streaming,
132
+ });
133
+
134
+ const { connect, wsSend, closeWs } = createConnection({
135
+ status, agentName, hostname, workDir, sessionId, error,
136
+ messages, isProcessing, isCompacting, visibleLimit,
137
+ historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
138
+ folderPickerLoading, folderPickerEntries, folderPickerPath,
139
+ streaming, sidebar, scrollToBottom,
140
+ });
141
+
142
+ // Now wire up the forwarding function
143
+ _wsSend = wsSend;
144
+
145
+ // ── Computed ──
146
+ const canSend = computed(() =>
147
+ status.value === 'Connected' && (inputText.value.trim() || attachments.value.length > 0) && !isProcessing.value && !isCompacting.value
148
+ && !messages.value.some(m => m.role === 'ask-question' && !m.answered)
149
+ );
150
+
377
151
  // ── Auto-resize textarea ──
378
152
  function autoResize() {
379
153
  const ta = inputRef.value;
@@ -383,6 +157,7 @@ const App = {
383
157
  }
384
158
  }
385
159
 
160
+ // ── Send message ──
386
161
  function sendMessage() {
387
162
  if (!canSend.value) return;
388
163
 
@@ -391,13 +166,12 @@ const App = {
391
166
  inputText.value = '';
392
167
  if (inputRef.value) inputRef.value.style.height = 'auto';
393
168
 
394
- // Build message display with attachment info
395
169
  const msgAttachments = files.map(f => ({
396
170
  name: f.name, size: f.size, isImage: f.isImage, thumbUrl: f.thumbUrl,
397
171
  }));
398
172
 
399
173
  messages.value.push({
400
- id: ++messageIdCounter, role: 'user',
174
+ id: streaming.nextId(), role: 'user',
401
175
  content: text || (files.length > 0 ? `[${files.length} file${files.length > 1 ? 's' : ''} attached]` : ''),
402
176
  attachments: msgAttachments.length > 0 ? msgAttachments : undefined,
403
177
  timestamp: new Date(),
@@ -405,7 +179,6 @@ const App = {
405
179
  isProcessing.value = true;
406
180
  scrollToBottom(true);
407
181
 
408
- // Build payload
409
182
  const payload = { type: 'chat', prompt: text || '(see attached files)' };
410
183
  if (needsResume.value && currentClaudeSessionId.value) {
411
184
  payload.resumeSessionId = currentClaudeSessionId.value;
@@ -413,19 +186,15 @@ const App = {
413
186
  }
414
187
  if (files.length > 0) {
415
188
  payload.files = files.map(f => ({
416
- name: f.name,
417
- mimeType: f.mimeType,
418
- data: f.data,
189
+ name: f.name, mimeType: f.mimeType, data: f.data,
419
190
  }));
420
191
  }
421
192
  wsSend(payload);
422
-
423
- // Clear attachments (don't revoke thumbUrls — they're referenced by the message now)
424
193
  attachments.value = [];
425
194
  }
426
195
 
427
196
  function cancelExecution() {
428
- if (!ws || !isProcessing.value) return;
197
+ if (!isProcessing.value) return;
429
198
  wsSend({ type: 'cancel_execution' });
430
199
  }
431
200
 
@@ -436,758 +205,70 @@ const App = {
436
205
  }
437
206
  }
438
207
 
439
- // ── Rendered markdown for assistant messages ──
440
- function formatTimestamp(ts) {
441
- if (!ts) return '';
442
- const d = ts instanceof Date ? ts : new Date(ts);
443
- return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' · ' + d.toLocaleDateString();
444
- }
445
-
446
- function getRenderedContent(msg) {
447
- if (msg.role !== 'assistant' && !msg.isCommandOutput) return msg.content;
448
- return renderMarkdown(msg.content);
449
- }
450
-
451
- // ── Copy full message ──
452
- async function copyMessage(msg) {
453
- try {
454
- await navigator.clipboard.writeText(msg.content);
455
- msg.copied = true;
456
- setTimeout(() => { msg.copied = false; }, 2000);
457
- } catch {}
458
- }
459
-
460
- // ── Check if previous message is also assistant (to suppress repeated label) ──
461
- function isPrevAssistant(idx) {
462
- if (idx <= 0) return false;
463
- const prev = visibleMessages.value[idx - 1];
464
- return prev && (prev.role === 'assistant' || prev.role === 'tool');
465
- }
466
-
467
- // ── Context summary toggle ──
468
- function toggleContextSummary(msg) {
469
- msg.contextExpanded = !msg.contextExpanded;
470
- }
471
-
472
- // ── Finalize a streaming message (mark done, detect context summary) ──
473
- function finalizeStreamingMsg() {
474
- if (streamingMessageId === null) return;
475
- const streamMsg = messages.value.find(m => m.id === streamingMessageId);
476
- if (streamMsg) {
477
- streamMsg.isStreaming = false;
478
- if (isContextSummary(streamMsg.content)) {
479
- streamMsg.role = 'context-summary';
480
- streamMsg.contextExpanded = false;
481
- }
482
- }
483
- streamingMessageId = null;
484
- // Trigger syntax highlighting for the finalized message content
485
- nextTick(scheduleHighlight);
486
- }
487
-
488
- // ── Tool expand/collapse ──
489
- function toggleTool(msg) {
490
- msg.expanded = !msg.expanded;
208
+ // ── Template adapter wrappers ──
209
+ // These adapt the module function signatures to the template's call conventions.
210
+ function _isPrevAssistant(idx) {
211
+ return isPrevAssistant(visibleMessages.value, idx);
491
212
  }
492
213
 
493
- function getToolSummary(msg) {
494
- const name = msg.toolName;
495
- const input = msg.toolInput;
496
- try {
497
- const obj = JSON.parse(input);
498
- if (name === 'Read' && obj.file_path) return obj.file_path;
499
- if (name === 'Edit' && obj.file_path) return obj.file_path;
500
- if (name === 'Write' && obj.file_path) return obj.file_path;
501
- if (name === 'Bash' && obj.command) return obj.command.length > 60 ? obj.command.slice(0, 60) + '...' : obj.command;
502
- if (name === 'Glob' && obj.pattern) return obj.pattern;
503
- if (name === 'Grep' && obj.pattern) return obj.pattern;
504
- if (name === 'TodoWrite' && obj.todos) {
505
- const done = obj.todos.filter(t => t.status === 'completed').length;
506
- return `${done}/${obj.todos.length} done`;
507
- }
508
- if (name === 'Task' && obj.description) return obj.description;
509
- if (name === 'WebSearch' && obj.query) return obj.query;
510
- if (name === 'WebFetch' && obj.url) return obj.url.length > 60 ? obj.url.slice(0, 60) + '...' : obj.url;
511
- } catch {}
512
- return '';
214
+ function _submitQuestionAnswer(msg) {
215
+ submitQuestionAnswer(msg, wsSend);
513
216
  }
514
217
 
515
- function isEditTool(msg) {
516
- return msg.role === 'tool' && msg.toolName === 'Edit' && msg.toolInput;
517
- }
518
-
519
- function getFormattedToolInput(msg) {
520
- if (!msg.toolInput) return null;
521
- try {
522
- const obj = JSON.parse(msg.toolInput);
523
- const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
524
- const name = msg.toolName;
525
-
526
- if (name === 'Read' && obj.file_path) {
527
- let detail = esc(obj.file_path);
528
- if (obj.offset && obj.limit) {
529
- detail += ` <span class="tool-input-meta">lines ${obj.offset}\u2013${obj.offset + obj.limit - 1}</span>`;
530
- } else if (obj.offset) {
531
- detail += ` <span class="tool-input-meta">from line ${obj.offset}</span>`;
532
- } else if (obj.limit) {
533
- detail += ` <span class="tool-input-meta">first ${obj.limit} lines</span>`;
534
- }
535
- return detail;
536
- }
537
-
538
- if (name === 'Write' && obj.file_path) {
539
- const lines = (obj.content || '').split('\n').length;
540
- return esc(obj.file_path) + ` <span class="tool-input-meta">${lines} lines</span>`;
541
- }
542
-
543
- if (name === 'Bash' && obj.command) {
544
- let html = '<code class="tool-input-cmd">' + esc(obj.command) + '</code>';
545
- if (obj.description) html = '<span class="tool-input-meta">' + esc(obj.description) + '</span> ' + html;
546
- return html;
547
- }
548
-
549
- if (name === 'Glob' && obj.pattern) {
550
- let html = '<code class="tool-input-cmd">' + esc(obj.pattern) + '</code>';
551
- if (obj.path) html += ' <span class="tool-input-meta">in ' + esc(obj.path) + '</span>';
552
- return html;
553
- }
554
-
555
- if (name === 'Grep' && obj.pattern) {
556
- let html = '<code class="tool-input-cmd">' + esc(obj.pattern) + '</code>';
557
- if (obj.path) html += ' <span class="tool-input-meta">in ' + esc(obj.path) + '</span>';
558
- return html;
559
- }
560
-
561
- if (name === 'TodoWrite' && Array.isArray(obj.todos)) {
562
- let html = '<div class="todo-list">';
563
- for (const t of obj.todos) {
564
- const s = t.status;
565
- const icon = s === 'completed' ? '<span class="todo-icon done">\u2713</span>'
566
- : s === 'in_progress' ? '<span class="todo-icon active">\u25CF</span>'
567
- : '<span class="todo-icon">\u25CB</span>';
568
- const cls = s === 'completed' ? ' todo-done' : s === 'in_progress' ? ' todo-active' : '';
569
- html += '<div class="todo-item' + cls + '">' + icon + '<span class="todo-text">' + esc(t.content || t.activeForm || '') + '</span></div>';
570
- }
571
- html += '</div>';
572
- return html;
573
- }
574
-
575
- if (name === 'Task') {
576
- let html = '';
577
- if (obj.description) html += '<div class="task-field"><span class="tool-input-meta">Description</span> ' + esc(obj.description) + '</div>';
578
- if (obj.subagent_type) html += '<div class="task-field"><span class="tool-input-meta">Agent</span> <code class="tool-input-cmd">' + esc(obj.subagent_type) + '</code></div>';
579
- if (obj.prompt) {
580
- const short = obj.prompt.length > 200 ? obj.prompt.slice(0, 200) + '...' : obj.prompt;
581
- html += '<div class="task-field"><span class="tool-input-meta">Prompt</span></div><div class="task-prompt">' + esc(short) + '</div>';
582
- }
583
- if (html) return html;
584
- }
585
-
586
- if (name === 'WebSearch' && obj.query) {
587
- return '<code class="tool-input-cmd">' + esc(obj.query) + '</code>';
588
- }
589
-
590
- if (name === 'WebFetch' && obj.url) {
591
- let html = '<a class="tool-link" href="' + esc(obj.url) + '" target="_blank" rel="noopener">' + esc(obj.url) + '</a>';
592
- if (obj.prompt) html += '<div class="task-field"><span class="tool-input-meta">' + esc(obj.prompt) + '</span></div>';
593
- return html;
594
- }
595
-
596
- } catch {}
597
- return null;
598
- }
599
-
600
- function getEditDiffHtml(msg) {
601
- try {
602
- const obj = JSON.parse(msg.toolInput);
603
- if (!obj.old_string && !obj.new_string) return null;
604
- const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
605
- const filePath = obj.file_path || '';
606
- const oldLines = (obj.old_string || '').split('\n');
607
- const newLines = (obj.new_string || '').split('\n');
608
- let html = '';
609
- if (filePath) {
610
- html += '<div class="diff-file">' + esc(filePath) + (obj.replace_all ? ' <span class="diff-replace-all">(replace all)</span>' : '') + '</div>';
611
- }
612
- html += '<div class="diff-lines">';
613
- for (const line of oldLines) {
614
- html += '<div class="diff-removed">' + '<span class="diff-sign">-</span>' + esc(line) + '</div>';
615
- }
616
- for (const line of newLines) {
617
- html += '<div class="diff-added">' + '<span class="diff-sign">+</span>' + esc(line) + '</div>';
618
- }
619
- html += '</div>';
620
- return html;
621
- } catch { return null; }
622
- }
623
-
624
- // ── AskUserQuestion interaction ──
625
- function selectQuestionOption(msg, qIndex, optLabel) {
626
- if (msg.answered) return;
627
- const q = msg.questions[qIndex];
628
- if (!q) return;
629
- if (q.multiSelect) {
630
- // Toggle selection
631
- const sel = msg.selectedAnswers[qIndex] || [];
632
- const idx = sel.indexOf(optLabel);
633
- if (idx >= 0) sel.splice(idx, 1);
634
- else sel.push(optLabel);
635
- msg.selectedAnswers[qIndex] = [...sel];
636
- } else {
637
- msg.selectedAnswers[qIndex] = optLabel;
638
- msg.customTexts[qIndex] = ''; // clear custom text when option selected
639
- }
640
- }
641
-
642
- function submitQuestionAnswer(msg) {
643
- if (msg.answered || !ws) return;
644
- // Build answers object keyed by question text: { "question text": "selected label" }
645
- // This matches the format Claude CLI expects for AskUserQuestion answers
646
- const answers = {};
647
- for (let i = 0; i < msg.questions.length; i++) {
648
- const q = msg.questions[i];
649
- const key = q.question || String(i);
650
- const custom = (msg.customTexts[i] || '').trim();
651
- if (custom) {
652
- answers[key] = custom;
653
- } else {
654
- const sel = msg.selectedAnswers[i];
655
- if (Array.isArray(sel) && sel.length > 0) {
656
- answers[key] = sel.join(', ');
657
- } else if (sel != null) {
658
- answers[key] = sel;
659
- }
660
- }
661
- }
662
- msg.answered = true;
663
- wsSend({ type: 'ask_user_answer', requestId: msg.requestId, answers });
664
- }
665
-
666
- function hasQuestionAnswer(msg) {
667
- // Check if at least one question has a selection or custom text
668
- for (let i = 0; i < msg.questions.length; i++) {
669
- const sel = msg.selectedAnswers[i];
670
- const custom = (msg.customTexts[i] || '').trim();
671
- if (custom || (Array.isArray(sel) ? sel.length > 0 : sel != null)) return true;
672
- }
673
- return false;
674
- }
675
-
676
- function getQuestionResponseSummary(msg) {
677
- // Build a summary string of the user's answers
678
- const parts = [];
679
- for (let i = 0; i < msg.questions.length; i++) {
680
- const custom = (msg.customTexts[i] || '').trim();
681
- if (custom) {
682
- parts.push(custom);
683
- } else {
684
- const sel = msg.selectedAnswers[i];
685
- if (Array.isArray(sel)) parts.push(sel.join(', '));
686
- else if (sel) parts.push(sel);
687
- }
688
- }
689
- return parts.join(' | ');
690
- }
691
-
692
- // ── Sidebar: session management ──
693
- function requestSessionList() {
694
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
695
- loadingSessions.value = true;
696
- wsSend({ type: 'list_sessions' });
697
- }
698
-
699
- function resumeSession(session) {
700
- if (isProcessing.value) return;
701
- // Auto-close sidebar on mobile
702
- if (window.innerWidth <= 768) sidebarOpen.value = false;
703
- // Clear current conversation
704
- messages.value = [];
705
- visibleLimit.value = 50;
706
- messageIdCounter = 0;
707
- streamingMessageId = null;
708
- pendingText = '';
709
- if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
710
-
711
- currentClaudeSessionId.value = session.sessionId;
712
- needsResume.value = true;
713
- loadingHistory.value = true;
714
-
715
- // Notify agent to prepare for resume (agent will respond with history)
716
- wsSend({
717
- type: 'resume_conversation',
718
- claudeSessionId: session.sessionId,
719
- });
720
- }
721
-
722
- function newConversation() {
723
- if (isProcessing.value) return;
724
- // Auto-close sidebar on mobile
725
- if (window.innerWidth <= 768) sidebarOpen.value = false;
726
- messages.value = [];
727
- visibleLimit.value = 50;
728
- messageIdCounter = 0;
729
- streamingMessageId = null;
730
- pendingText = '';
731
- currentClaudeSessionId.value = null;
732
- needsResume.value = false;
733
-
734
- messages.value.push({
735
- id: ++messageIdCounter, role: 'system',
736
- content: 'New conversation started.',
737
- timestamp: new Date(),
738
- });
739
- }
740
-
741
- function toggleSidebar() {
742
- sidebarOpen.value = !sidebarOpen.value;
743
- }
744
-
745
- // ── Folder picker: change working directory ──
746
- function openFolderPicker() {
747
- folderPickerOpen.value = true;
748
- folderPickerSelected.value = '';
749
- folderPickerLoading.value = true;
750
- folderPickerPath.value = workDir.value || '';
751
- folderPickerEntries.value = [];
752
- wsSend({ type: 'list_directory', dirPath: workDir.value || '' });
753
- }
754
-
755
- function loadFolderPickerDir(dirPath) {
756
- folderPickerLoading.value = true;
757
- folderPickerSelected.value = '';
758
- folderPickerEntries.value = [];
759
- wsSend({ type: 'list_directory', dirPath });
760
- }
761
-
762
- function folderPickerNavigateUp() {
763
- if (!folderPickerPath.value) return;
764
- const isWin = folderPickerPath.value.includes('\\');
765
- const parts = folderPickerPath.value.replace(/[/\\]$/, '').split(/[/\\]/);
766
- parts.pop();
767
- if (parts.length === 0) {
768
- folderPickerPath.value = '';
769
- loadFolderPickerDir('');
770
- } else if (isWin && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
771
- folderPickerPath.value = parts[0] + '\\';
772
- loadFolderPickerDir(parts[0] + '\\');
773
- } else {
774
- const sep = isWin ? '\\' : '/';
775
- const parent = parts.join(sep);
776
- folderPickerPath.value = parent;
777
- loadFolderPickerDir(parent);
778
- }
779
- }
780
-
781
- function folderPickerSelectItem(entry) {
782
- folderPickerSelected.value = entry.name;
783
- }
784
-
785
- function folderPickerEnter(entry) {
786
- const sep = folderPickerPath.value.includes('\\') || /^[A-Z]:/.test(entry.name) ? '\\' : '/';
787
- let newPath;
788
- if (!folderPickerPath.value) {
789
- newPath = entry.name + (entry.name.endsWith('\\') ? '' : '\\');
790
- } else {
791
- newPath = folderPickerPath.value.replace(/[/\\]$/, '') + sep + entry.name;
792
- }
793
- folderPickerPath.value = newPath;
794
- folderPickerSelected.value = '';
795
- loadFolderPickerDir(newPath);
796
- }
797
-
798
- function folderPickerGoToPath() {
799
- const path = folderPickerPath.value.trim();
800
- if (!path) {
801
- loadFolderPickerDir('');
802
- return;
803
- }
804
- folderPickerSelected.value = '';
805
- loadFolderPickerDir(path);
806
- }
807
-
808
- function confirmFolderPicker() {
809
- let path = folderPickerPath.value;
810
- if (!path) return;
811
- if (folderPickerSelected.value) {
812
- const sep = path.includes('\\') ? '\\' : '/';
813
- path = path.replace(/[/\\]$/, '') + sep + folderPickerSelected.value;
814
- }
815
- folderPickerOpen.value = false;
816
- wsSend({ type: 'change_workdir', workDir: path });
817
- }
818
-
819
- // ── Sidebar: grouped sessions by time ──
820
- const groupedSessions = computed(() => {
821
- if (!historySessions.value.length) return [];
822
- const now = new Date();
823
- const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
824
- const yesterdayStart = todayStart - 86400000;
825
- const weekStart = todayStart - 6 * 86400000;
826
-
827
- const groups = {};
828
- for (const s of historySessions.value) {
829
- let label;
830
- if (s.lastModified >= todayStart) label = 'Today';
831
- else if (s.lastModified >= yesterdayStart) label = 'Yesterday';
832
- else if (s.lastModified >= weekStart) label = 'This week';
833
- else label = 'Earlier';
834
- if (!groups[label]) groups[label] = [];
835
- groups[label].push(s);
836
- }
837
- // Return in a consistent order
838
- const order = ['Today', 'Yesterday', 'This week', 'Earlier'];
839
- return order.filter(k => groups[k]).map(k => ({ label: k, sessions: groups[k] }));
840
- });
841
-
842
- // ── WebSocket ──
843
- let reconnectAttempts = 0;
844
- const MAX_RECONNECT_ATTEMPTS = 50;
845
- const RECONNECT_BASE_DELAY = 1000;
846
- const RECONNECT_MAX_DELAY = 15000;
847
- let reconnectTimer = null;
848
-
849
- function connect() {
850
- const sid = getSessionId();
851
- if (!sid) {
852
- status.value = 'No Session';
853
- error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
854
- return;
855
- }
856
- sessionId.value = sid;
857
- status.value = 'Connecting...';
858
- error.value = '';
859
-
860
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
861
- const wsUrl = `${protocol}//${window.location.host}/?type=web&sessionId=${sid}`;
862
- ws = new WebSocket(wsUrl);
863
-
864
- ws.onopen = () => { error.value = ''; reconnectAttempts = 0; };
865
-
866
- ws.onmessage = (event) => {
867
- let msg;
868
- const parsed = JSON.parse(event.data);
869
-
870
- // The 'connected' message is always plain text (key exchange)
871
- if (parsed.type === 'connected') {
872
- msg = parsed;
873
- if (typeof parsed.sessionKey === 'string') {
874
- sessionKey = decodeKey(parsed.sessionKey);
875
- }
876
- } else if (sessionKey && isEncrypted(parsed)) {
877
- msg = decrypt(parsed, sessionKey);
878
- if (!msg) {
879
- console.error('[WS] Failed to decrypt message');
880
- return;
881
- }
882
- } else {
883
- msg = parsed;
884
- }
885
-
886
- if (msg.type === 'connected') {
887
- if (msg.agent) {
888
- status.value = 'Connected';
889
- agentName.value = msg.agent.name;
890
- hostname.value = msg.agent.hostname || '';
891
- workDir.value = msg.agent.workDir;
892
- // If we have a saved workDir from a previous session, restore it
893
- const savedDir = localStorage.getItem('agentlink-workdir');
894
- if (savedDir && savedDir !== msg.agent.workDir) {
895
- wsSend({ type: 'change_workdir', workDir: savedDir });
896
- }
897
- // Request session list once connected
898
- requestSessionList();
899
- } else {
900
- status.value = 'Waiting';
901
- error.value = 'Agent is not connected yet.';
902
- }
903
- } else if (msg.type === 'agent_disconnected') {
904
- status.value = 'Waiting';
905
- agentName.value = '';
906
- hostname.value = '';
907
- error.value = 'Agent disconnected. Waiting for reconnect...';
908
- isProcessing.value = false;
909
- isCompacting.value = false;
910
- } else if (msg.type === 'agent_reconnected') {
911
- status.value = 'Connected';
912
- error.value = '';
913
- if (msg.agent) {
914
- agentName.value = msg.agent.name;
915
- hostname.value = msg.agent.hostname || '';
916
- workDir.value = msg.agent.workDir;
917
- }
918
- requestSessionList();
919
- } else if (msg.type === 'error') {
920
- status.value = 'Error';
921
- error.value = msg.message;
922
- isProcessing.value = false;
923
- isCompacting.value = false;
924
- } else if (msg.type === 'claude_output') {
925
- handleClaudeOutput(msg);
926
- } else if (msg.type === 'command_output') {
927
- flushReveal();
928
- finalizeStreamingMsg();
929
- messages.value.push({
930
- id: ++messageIdCounter, role: 'user',
931
- content: msg.content, isCommandOutput: true,
932
- timestamp: new Date(),
933
- });
934
- scrollToBottom();
935
- } else if (msg.type === 'context_compaction') {
936
- if (msg.status === 'started') {
937
- isCompacting.value = true;
938
- } else if (msg.status === 'completed') {
939
- isCompacting.value = false;
940
- }
941
- } else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
942
- isProcessing.value = false;
943
- isCompacting.value = false;
944
- flushReveal();
945
- finalizeStreamingMsg();
946
- if (msg.type === 'execution_cancelled') {
947
- messages.value.push({
948
- id: ++messageIdCounter, role: 'system',
949
- content: 'Generation stopped.', timestamp: new Date(),
950
- });
951
- scrollToBottom();
952
- }
953
- } else if (msg.type === 'ask_user_question') {
954
- flushReveal();
955
- finalizeStreamingMsg();
956
- // Remove any preceding tool message for AskUserQuestion (tool_use arrives before control_request)
957
- for (let i = messages.value.length - 1; i >= 0; i--) {
958
- const m = messages.value[i];
959
- if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
960
- messages.value.splice(i, 1);
961
- break;
962
- }
963
- // Only look back within recent messages
964
- if (m.role === 'user') break;
965
- }
966
- // Render interactive question card
967
- const questions = msg.questions || [];
968
- const selectedAnswers = {};
969
- const customTexts = {};
970
- for (let i = 0; i < questions.length; i++) {
971
- selectedAnswers[i] = questions[i].multiSelect ? [] : null;
972
- customTexts[i] = '';
973
- }
974
- messages.value.push({
975
- id: ++messageIdCounter,
976
- role: 'ask-question',
977
- requestId: msg.requestId,
978
- questions,
979
- answered: false,
980
- selectedAnswers,
981
- customTexts,
982
- timestamp: new Date(),
983
- });
984
- scrollToBottom();
985
- } else if (msg.type === 'sessions_list') {
986
- historySessions.value = msg.sessions || [];
987
- loadingSessions.value = false;
988
- } else if (msg.type === 'conversation_resumed') {
989
- currentClaudeSessionId.value = msg.claudeSessionId;
990
- // Build history messages in a plain array first, then assign once
991
- // to avoid triggering Vue reactivity on every individual push.
992
- if (msg.history && Array.isArray(msg.history)) {
993
- const batch = [];
994
- for (const h of msg.history) {
995
- if (h.role === 'user') {
996
- if (isContextSummary(h.content)) {
997
- batch.push({
998
- id: ++messageIdCounter, role: 'context-summary',
999
- content: h.content, contextExpanded: false,
1000
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
1001
- });
1002
- } else {
1003
- batch.push({
1004
- id: ++messageIdCounter, role: 'user',
1005
- content: h.content, isCommandOutput: !!h.isCommandOutput,
1006
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
1007
- });
1008
- }
1009
- } else if (h.role === 'assistant') {
1010
- // Merge with previous assistant message if consecutive
1011
- const last = batch[batch.length - 1];
1012
- if (last && last.role === 'assistant' && !last.isStreaming) {
1013
- last.content += '\n\n' + h.content;
1014
- } else {
1015
- batch.push({
1016
- id: ++messageIdCounter, role: 'assistant',
1017
- content: h.content, isStreaming: false,
1018
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
1019
- });
1020
- }
1021
- } else if (h.role === 'tool') {
1022
- batch.push({
1023
- id: ++messageIdCounter, role: 'tool',
1024
- toolId: h.toolId || '', toolName: h.toolName || 'unknown',
1025
- toolInput: h.toolInput || '', hasResult: true,
1026
- expanded: h.toolName === 'Edit', timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
1027
- });
1028
- }
1029
- }
1030
- // Single reactive assignment — triggers Vue reactivity only once
1031
- messages.value = batch;
1032
- }
1033
- loadingHistory.value = false;
1034
- // Show ready-for-input hint
1035
- messages.value.push({
1036
- id: ++messageIdCounter, role: 'system',
1037
- content: 'Session restored. You can continue the conversation.',
1038
- timestamp: new Date(),
1039
- });
1040
- scrollToBottom();
1041
- } else if (msg.type === 'directory_listing') {
1042
- folderPickerLoading.value = false;
1043
- folderPickerEntries.value = (msg.entries || [])
1044
- .filter(e => e.type === 'directory')
1045
- .sort((a, b) => a.name.localeCompare(b.name));
1046
- if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
1047
- } else if (msg.type === 'workdir_changed') {
1048
- workDir.value = msg.workDir;
1049
- localStorage.setItem('agentlink-workdir', msg.workDir);
1050
- messages.value = [];
1051
- visibleLimit.value = 50;
1052
- messageIdCounter = 0;
1053
- streamingMessageId = null;
1054
- pendingText = '';
1055
- currentClaudeSessionId.value = null;
1056
- isProcessing.value = false;
1057
- if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
1058
- messages.value.push({
1059
- id: ++messageIdCounter, role: 'system',
1060
- content: 'Working directory changed to: ' + msg.workDir,
1061
- timestamp: new Date(),
1062
- });
1063
- requestSessionList();
1064
- }
1065
- };
1066
-
1067
- ws.onclose = () => {
1068
- sessionKey = null;
1069
- const wasConnected = status.value === 'Connected' || status.value === 'Connecting...';
1070
- isProcessing.value = false;
1071
- isCompacting.value = false;
1072
-
1073
- if (wasConnected || reconnectAttempts > 0) {
1074
- scheduleReconnect();
1075
- }
1076
- };
1077
-
1078
- ws.onerror = () => {};
1079
- }
1080
-
1081
- function scheduleReconnect() {
1082
- if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
1083
- status.value = 'Disconnected';
1084
- error.value = 'Unable to reconnect. Please refresh the page.';
1085
- return;
1086
- }
1087
- const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts), RECONNECT_MAX_DELAY);
1088
- reconnectAttempts++;
1089
- status.value = 'Reconnecting...';
1090
- error.value = 'Connection lost. Reconnecting... (attempt ' + reconnectAttempts + ')';
1091
- if (reconnectTimer) clearTimeout(reconnectTimer);
1092
- reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay);
1093
- }
1094
-
1095
- function handleClaudeOutput(msg) {
1096
- const data = msg.data;
1097
- if (!data) return;
1098
-
1099
- if (data.type === 'content_block_delta' && data.delta) {
1100
- pendingText += data.delta;
1101
- startReveal();
1102
- return;
1103
- }
1104
-
1105
- if (data.type === 'tool_use' && data.tools) {
1106
- flushReveal();
1107
- finalizeStreamingMsg();
1108
-
1109
- for (const tool of data.tools) {
1110
- messages.value.push({
1111
- id: ++messageIdCounter, role: 'tool',
1112
- toolId: tool.id, toolName: tool.name || 'unknown',
1113
- toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
1114
- hasResult: false, expanded: (tool.name === 'Edit'), timestamp: new Date(),
1115
- });
1116
- }
1117
- scrollToBottom();
1118
- return;
1119
- }
1120
-
1121
- if (data.type === 'user' && data.tool_use_result) {
1122
- const result = data.tool_use_result;
1123
- const results = Array.isArray(result) ? result : [result];
1124
- for (const r of results) {
1125
- const toolMsg = [...messages.value].reverse().find(
1126
- m => m.role === 'tool' && m.toolId === r.tool_use_id
1127
- );
1128
- if (toolMsg) {
1129
- toolMsg.toolOutput = typeof r.content === 'string'
1130
- ? r.content : JSON.stringify(r.content, null, 2);
1131
- toolMsg.hasResult = true;
1132
- }
1133
- }
1134
- scrollToBottom();
1135
- return;
1136
- }
1137
- }
1138
-
1139
- // Apply syntax highlighting after DOM updates (throttled)
1140
- let _hlTimer = null;
1141
- function scheduleHighlight() {
1142
- if (_hlTimer) return;
1143
- _hlTimer = setTimeout(() => {
1144
- _hlTimer = null;
1145
- if (typeof hljs !== 'undefined') {
1146
- document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
1147
- hljs.highlightElement(block);
1148
- block.dataset.highlighted = 'true';
1149
- });
1150
- }
1151
- }, 300);
1152
- }
1153
- // Trigger highlight when messages are added/removed (shallow watch on length)
1154
- // Deep watch is too expensive for large conversations — it traverses every
1155
- // property of every message object on every mutation (including streaming ticks).
218
+ // ── Watchers ──
1156
219
  const messageCount = computed(() => messages.value.length);
1157
220
  watch(messageCount, () => { nextTick(scheduleHighlight); });
1158
221
 
1159
- onMounted(() => { connect(); });
1160
- onUnmounted(() => { if (reconnectTimer) clearTimeout(reconnectTimer); if (ws) ws.close(); });
1161
-
1162
- // Dynamic page title
1163
222
  watch(agentName, (name) => {
1164
223
  document.title = name ? `${name} — AgentLink` : 'AgentLink';
1165
224
  });
1166
225
 
226
+ // ── Lifecycle ──
227
+ onMounted(() => { connect(scheduleHighlight); });
228
+ onUnmounted(() => { closeWs(); streaming.cleanup(); });
229
+
1167
230
  return {
1168
231
  status, agentName, hostname, workDir, sessionId, error,
1169
232
  messages, visibleMessages, hasMoreMessages, loadMoreMessages,
1170
233
  inputText, isProcessing, isCompacting, canSend, inputRef,
1171
234
  sendMessage, handleKeydown, cancelExecution, onMessageListScroll,
1172
- getRenderedContent, copyMessage, toggleTool, isPrevAssistant, toggleContextSummary, formatTimestamp,
235
+ getRenderedContent, copyMessage, toggleTool,
236
+ isPrevAssistant: _isPrevAssistant,
237
+ toggleContextSummary, formatTimestamp,
1173
238
  getToolIcon, getToolSummary, isEditTool, getEditDiffHtml, getFormattedToolInput, autoResize,
1174
239
  // AskUserQuestion
1175
- selectQuestionOption, submitQuestionAnswer, hasQuestionAnswer, getQuestionResponseSummary,
240
+ selectQuestionOption,
241
+ submitQuestionAnswer: _submitQuestionAnswer,
242
+ hasQuestionAnswer, getQuestionResponseSummary,
1176
243
  // Theme
1177
244
  theme, toggleTheme,
1178
245
  // Sidebar
1179
246
  sidebarOpen, historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
1180
- toggleSidebar, resumeSession, newConversation, requestSessionList,
1181
- formatRelativeTime, groupedSessions,
247
+ toggleSidebar: sidebar.toggleSidebar,
248
+ resumeSession: sidebar.resumeSession,
249
+ newConversation: sidebar.newConversation,
250
+ requestSessionList: sidebar.requestSessionList,
251
+ formatRelativeTime,
252
+ groupedSessions: sidebar.groupedSessions,
1182
253
  // Folder picker
1183
254
  folderPickerOpen, folderPickerPath, folderPickerEntries,
1184
255
  folderPickerLoading, folderPickerSelected,
1185
- openFolderPicker, folderPickerNavigateUp, folderPickerSelectItem,
1186
- folderPickerEnter, folderPickerGoToPath, confirmFolderPicker,
256
+ openFolderPicker: sidebar.openFolderPicker,
257
+ folderPickerNavigateUp: sidebar.folderPickerNavigateUp,
258
+ folderPickerSelectItem: sidebar.folderPickerSelectItem,
259
+ folderPickerEnter: sidebar.folderPickerEnter,
260
+ folderPickerGoToPath: sidebar.folderPickerGoToPath,
261
+ confirmFolderPicker: sidebar.confirmFolderPicker,
1187
262
  // File attachments
1188
263
  attachments, fileInputRef, dragOver,
1189
- triggerFileInput, handleFileSelect, removeAttachment, formatFileSize,
1190
- handleDragOver, handleDragLeave, handleDrop, handlePaste,
264
+ triggerFileInput: fileAttach.triggerFileInput,
265
+ handleFileSelect: fileAttach.handleFileSelect,
266
+ removeAttachment: fileAttach.removeAttachment,
267
+ formatFileSize,
268
+ handleDragOver: fileAttach.handleDragOver,
269
+ handleDragLeave: fileAttach.handleDragLeave,
270
+ handleDrop: fileAttach.handleDrop,
271
+ handlePaste: fileAttach.handlePaste,
1191
272
  };
1192
273
  },
1193
274
  template: `