@agent-link/server 0.1.27 → 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,97 +78,76 @@ 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;
81
+ // ── Scroll management ──
82
+ let _scrollTimer = null;
83
+ let _userScrolledUp = false;
281
84
 
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
- }
85
+ function onMessageListScroll(e) {
86
+ const el = e.target;
87
+ _userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > 80;
290
88
  }
291
89
 
292
- // Progressive text reveal state
293
- let pendingText = '';
294
- let revealTimer = null;
295
- const CHARS_PER_TICK = 5;
296
- const TICK_MS = 16;
90
+ function scrollToBottom(force) {
91
+ if (_userScrolledUp && !force) return;
92
+ if (_scrollTimer) return;
93
+ _scrollTimer = setTimeout(() => {
94
+ _scrollTimer = null;
95
+ const el = document.querySelector('.message-list');
96
+ if (el) el.scrollTop = el.scrollHeight;
97
+ }, 50);
98
+ }
297
99
 
298
- function startReveal() {
299
- if (revealTimer !== null) return;
300
- revealTimer = setTimeout(revealTick, TICK_MS);
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);
301
113
  }
302
114
 
303
- function revealTick() {
304
- revealTimer = null;
305
- if (!pendingText) return;
115
+ // ── Create module instances ──
306
116
 
307
- const streamMsg = streamingMessageId !== null
308
- ? messages.value.find(m => m.id === streamingMessageId)
309
- : null;
117
+ const streaming = createStreaming({ messages, scrollToBottom });
310
118
 
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
- }
119
+ const fileAttach = createFileAttachments(attachments, fileInputRef, dragOver);
328
120
 
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
- }
121
+ // Sidebar needs wsSend, but connection creates wsSend.
122
+ // Resolve circular dependency with a forwarding function.
123
+ let _wsSend = () => {};
347
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 ──
348
146
  const canSend = computed(() =>
349
147
  status.value === 'Connected' && (inputText.value.trim() || attachments.value.length > 0) && !isProcessing.value && !isCompacting.value
350
148
  && !messages.value.some(m => m.role === 'ask-question' && !m.answered)
351
149
  );
352
150
 
353
- function getSessionId() {
354
- const match = window.location.pathname.match(/^\/s\/([^/]+)/);
355
- return match ? match[1] : null;
356
- }
357
-
358
- let _scrollTimer = null;
359
- function scrollToBottom() {
360
- if (_scrollTimer) return;
361
- _scrollTimer = setTimeout(() => {
362
- _scrollTimer = null;
363
- const el = document.querySelector('.message-list');
364
- if (el) el.scrollTop = el.scrollHeight;
365
- }, 50);
366
- }
367
-
368
151
  // ── Auto-resize textarea ──
369
152
  function autoResize() {
370
153
  const ta = inputRef.value;
@@ -374,6 +157,7 @@ const App = {
374
157
  }
375
158
  }
376
159
 
160
+ // ── Send message ──
377
161
  function sendMessage() {
378
162
  if (!canSend.value) return;
379
163
 
@@ -382,21 +166,19 @@ const App = {
382
166
  inputText.value = '';
383
167
  if (inputRef.value) inputRef.value.style.height = 'auto';
384
168
 
385
- // Build message display with attachment info
386
169
  const msgAttachments = files.map(f => ({
387
170
  name: f.name, size: f.size, isImage: f.isImage, thumbUrl: f.thumbUrl,
388
171
  }));
389
172
 
390
173
  messages.value.push({
391
- id: ++messageIdCounter, role: 'user',
174
+ id: streaming.nextId(), role: 'user',
392
175
  content: text || (files.length > 0 ? `[${files.length} file${files.length > 1 ? 's' : ''} attached]` : ''),
393
176
  attachments: msgAttachments.length > 0 ? msgAttachments : undefined,
394
177
  timestamp: new Date(),
395
178
  });
396
179
  isProcessing.value = true;
397
- scrollToBottom();
180
+ scrollToBottom(true);
398
181
 
399
- // Build payload
400
182
  const payload = { type: 'chat', prompt: text || '(see attached files)' };
401
183
  if (needsResume.value && currentClaudeSessionId.value) {
402
184
  payload.resumeSessionId = currentClaudeSessionId.value;
@@ -404,19 +186,15 @@ const App = {
404
186
  }
405
187
  if (files.length > 0) {
406
188
  payload.files = files.map(f => ({
407
- name: f.name,
408
- mimeType: f.mimeType,
409
- data: f.data,
189
+ name: f.name, mimeType: f.mimeType, data: f.data,
410
190
  }));
411
191
  }
412
192
  wsSend(payload);
413
-
414
- // Clear attachments (don't revoke thumbUrls — they're referenced by the message now)
415
193
  attachments.value = [];
416
194
  }
417
195
 
418
196
  function cancelExecution() {
419
- if (!ws || !isProcessing.value) return;
197
+ if (!isProcessing.value) return;
420
198
  wsSend({ type: 'cancel_execution' });
421
199
  }
422
200
 
@@ -427,747 +205,70 @@ const App = {
427
205
  }
428
206
  }
429
207
 
430
- // ── Rendered markdown for assistant messages ──
431
- function getRenderedContent(msg) {
432
- if (msg.role !== 'assistant' && !msg.isCommandOutput) return msg.content;
433
- return renderMarkdown(msg.content);
434
- }
435
-
436
- // ── Copy full message ──
437
- async function copyMessage(msg) {
438
- try {
439
- await navigator.clipboard.writeText(msg.content);
440
- msg.copied = true;
441
- setTimeout(() => { msg.copied = false; }, 2000);
442
- } catch {}
443
- }
444
-
445
- // ── Check if previous message is also assistant (to suppress repeated label) ──
446
- function isPrevAssistant(idx) {
447
- if (idx <= 0) return false;
448
- const prev = visibleMessages.value[idx - 1];
449
- return prev && (prev.role === 'assistant' || prev.role === 'tool');
450
- }
451
-
452
- // ── Context summary toggle ──
453
- function toggleContextSummary(msg) {
454
- msg.contextExpanded = !msg.contextExpanded;
455
- }
456
-
457
- // ── Finalize a streaming message (mark done, detect context summary) ──
458
- function finalizeStreamingMsg() {
459
- if (streamingMessageId === null) return;
460
- const streamMsg = messages.value.find(m => m.id === streamingMessageId);
461
- if (streamMsg) {
462
- streamMsg.isStreaming = false;
463
- if (isContextSummary(streamMsg.content)) {
464
- streamMsg.role = 'context-summary';
465
- streamMsg.contextExpanded = false;
466
- }
467
- }
468
- streamingMessageId = null;
469
- // Trigger syntax highlighting for the finalized message content
470
- nextTick(scheduleHighlight);
471
- }
472
-
473
- // ── Tool expand/collapse ──
474
- function toggleTool(msg) {
475
- msg.expanded = !msg.expanded;
476
- }
477
-
478
- function getToolSummary(msg) {
479
- const name = msg.toolName;
480
- const input = msg.toolInput;
481
- try {
482
- const obj = JSON.parse(input);
483
- if (name === 'Read' && obj.file_path) return obj.file_path;
484
- if (name === 'Edit' && obj.file_path) return obj.file_path;
485
- if (name === 'Write' && obj.file_path) return obj.file_path;
486
- if (name === 'Bash' && obj.command) return obj.command.length > 60 ? obj.command.slice(0, 60) + '...' : obj.command;
487
- if (name === 'Glob' && obj.pattern) return obj.pattern;
488
- if (name === 'Grep' && obj.pattern) return obj.pattern;
489
- if (name === 'TodoWrite' && obj.todos) {
490
- const done = obj.todos.filter(t => t.status === 'completed').length;
491
- return `${done}/${obj.todos.length} done`;
492
- }
493
- if (name === 'Task' && obj.description) return obj.description;
494
- if (name === 'WebSearch' && obj.query) return obj.query;
495
- if (name === 'WebFetch' && obj.url) return obj.url.length > 60 ? obj.url.slice(0, 60) + '...' : obj.url;
496
- } catch {}
497
- return '';
498
- }
499
-
500
- function isEditTool(msg) {
501
- return msg.role === 'tool' && msg.toolName === 'Edit' && msg.toolInput;
502
- }
503
-
504
- function getFormattedToolInput(msg) {
505
- if (!msg.toolInput) return null;
506
- try {
507
- const obj = JSON.parse(msg.toolInput);
508
- const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
509
- const name = msg.toolName;
510
-
511
- if (name === 'Read' && obj.file_path) {
512
- let detail = esc(obj.file_path);
513
- if (obj.offset && obj.limit) {
514
- detail += ` <span class="tool-input-meta">lines ${obj.offset}\u2013${obj.offset + obj.limit - 1}</span>`;
515
- } else if (obj.offset) {
516
- detail += ` <span class="tool-input-meta">from line ${obj.offset}</span>`;
517
- } else if (obj.limit) {
518
- detail += ` <span class="tool-input-meta">first ${obj.limit} lines</span>`;
519
- }
520
- return detail;
521
- }
522
-
523
- if (name === 'Write' && obj.file_path) {
524
- const lines = (obj.content || '').split('\n').length;
525
- return esc(obj.file_path) + ` <span class="tool-input-meta">${lines} lines</span>`;
526
- }
527
-
528
- if (name === 'Bash' && obj.command) {
529
- let html = '<code class="tool-input-cmd">' + esc(obj.command) + '</code>';
530
- if (obj.description) html = '<span class="tool-input-meta">' + esc(obj.description) + '</span> ' + html;
531
- return html;
532
- }
533
-
534
- if (name === 'Glob' && obj.pattern) {
535
- let html = '<code class="tool-input-cmd">' + esc(obj.pattern) + '</code>';
536
- if (obj.path) html += ' <span class="tool-input-meta">in ' + esc(obj.path) + '</span>';
537
- return html;
538
- }
539
-
540
- if (name === 'Grep' && obj.pattern) {
541
- let html = '<code class="tool-input-cmd">' + esc(obj.pattern) + '</code>';
542
- if (obj.path) html += ' <span class="tool-input-meta">in ' + esc(obj.path) + '</span>';
543
- return html;
544
- }
545
-
546
- if (name === 'TodoWrite' && Array.isArray(obj.todos)) {
547
- let html = '<div class="todo-list">';
548
- for (const t of obj.todos) {
549
- const s = t.status;
550
- const icon = s === 'completed' ? '<span class="todo-icon done">\u2713</span>'
551
- : s === 'in_progress' ? '<span class="todo-icon active">\u25CF</span>'
552
- : '<span class="todo-icon">\u25CB</span>';
553
- const cls = s === 'completed' ? ' todo-done' : s === 'in_progress' ? ' todo-active' : '';
554
- html += '<div class="todo-item' + cls + '">' + icon + '<span class="todo-text">' + esc(t.content || t.activeForm || '') + '</span></div>';
555
- }
556
- html += '</div>';
557
- return html;
558
- }
559
-
560
- if (name === 'Task') {
561
- let html = '';
562
- if (obj.description) html += '<div class="task-field"><span class="tool-input-meta">Description</span> ' + esc(obj.description) + '</div>';
563
- 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>';
564
- if (obj.prompt) {
565
- const short = obj.prompt.length > 200 ? obj.prompt.slice(0, 200) + '...' : obj.prompt;
566
- html += '<div class="task-field"><span class="tool-input-meta">Prompt</span></div><div class="task-prompt">' + esc(short) + '</div>';
567
- }
568
- if (html) return html;
569
- }
570
-
571
- if (name === 'WebSearch' && obj.query) {
572
- return '<code class="tool-input-cmd">' + esc(obj.query) + '</code>';
573
- }
574
-
575
- if (name === 'WebFetch' && obj.url) {
576
- let html = '<a class="tool-link" href="' + esc(obj.url) + '" target="_blank" rel="noopener">' + esc(obj.url) + '</a>';
577
- if (obj.prompt) html += '<div class="task-field"><span class="tool-input-meta">' + esc(obj.prompt) + '</span></div>';
578
- return html;
579
- }
580
-
581
- } catch {}
582
- return null;
583
- }
584
-
585
- function getEditDiffHtml(msg) {
586
- try {
587
- const obj = JSON.parse(msg.toolInput);
588
- if (!obj.old_string && !obj.new_string) return null;
589
- const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
590
- const filePath = obj.file_path || '';
591
- const oldLines = (obj.old_string || '').split('\n');
592
- const newLines = (obj.new_string || '').split('\n');
593
- let html = '';
594
- if (filePath) {
595
- html += '<div class="diff-file">' + esc(filePath) + (obj.replace_all ? ' <span class="diff-replace-all">(replace all)</span>' : '') + '</div>';
596
- }
597
- html += '<div class="diff-lines">';
598
- for (const line of oldLines) {
599
- html += '<div class="diff-removed">' + '<span class="diff-sign">-</span>' + esc(line) + '</div>';
600
- }
601
- for (const line of newLines) {
602
- html += '<div class="diff-added">' + '<span class="diff-sign">+</span>' + esc(line) + '</div>';
603
- }
604
- html += '</div>';
605
- return html;
606
- } catch { return null; }
607
- }
608
-
609
- // ── AskUserQuestion interaction ──
610
- function selectQuestionOption(msg, qIndex, optLabel) {
611
- if (msg.answered) return;
612
- const q = msg.questions[qIndex];
613
- if (!q) return;
614
- if (q.multiSelect) {
615
- // Toggle selection
616
- const sel = msg.selectedAnswers[qIndex] || [];
617
- const idx = sel.indexOf(optLabel);
618
- if (idx >= 0) sel.splice(idx, 1);
619
- else sel.push(optLabel);
620
- msg.selectedAnswers[qIndex] = [...sel];
621
- } else {
622
- msg.selectedAnswers[qIndex] = optLabel;
623
- msg.customTexts[qIndex] = ''; // clear custom text when option selected
624
- }
625
- }
626
-
627
- function submitQuestionAnswer(msg) {
628
- if (msg.answered || !ws) return;
629
- // Build answers object keyed by question text: { "question text": "selected label" }
630
- // This matches the format Claude CLI expects for AskUserQuestion answers
631
- const answers = {};
632
- for (let i = 0; i < msg.questions.length; i++) {
633
- const q = msg.questions[i];
634
- const key = q.question || String(i);
635
- const custom = (msg.customTexts[i] || '').trim();
636
- if (custom) {
637
- answers[key] = custom;
638
- } else {
639
- const sel = msg.selectedAnswers[i];
640
- if (Array.isArray(sel) && sel.length > 0) {
641
- answers[key] = sel.join(', ');
642
- } else if (sel != null) {
643
- answers[key] = sel;
644
- }
645
- }
646
- }
647
- msg.answered = true;
648
- wsSend({ type: 'ask_user_answer', requestId: msg.requestId, answers });
649
- }
650
-
651
- function hasQuestionAnswer(msg) {
652
- // Check if at least one question has a selection or custom text
653
- for (let i = 0; i < msg.questions.length; i++) {
654
- const sel = msg.selectedAnswers[i];
655
- const custom = (msg.customTexts[i] || '').trim();
656
- if (custom || (Array.isArray(sel) ? sel.length > 0 : sel != null)) return true;
657
- }
658
- return false;
659
- }
660
-
661
- function getQuestionResponseSummary(msg) {
662
- // Build a summary string of the user's answers
663
- const parts = [];
664
- for (let i = 0; i < msg.questions.length; i++) {
665
- const custom = (msg.customTexts[i] || '').trim();
666
- if (custom) {
667
- parts.push(custom);
668
- } else {
669
- const sel = msg.selectedAnswers[i];
670
- if (Array.isArray(sel)) parts.push(sel.join(', '));
671
- else if (sel) parts.push(sel);
672
- }
673
- }
674
- return parts.join(' | ');
675
- }
676
-
677
- // ── Sidebar: session management ──
678
- function requestSessionList() {
679
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
680
- loadingSessions.value = true;
681
- wsSend({ type: 'list_sessions' });
682
- }
683
-
684
- function resumeSession(session) {
685
- if (isProcessing.value) return;
686
- // Auto-close sidebar on mobile
687
- if (window.innerWidth <= 768) sidebarOpen.value = false;
688
- // Clear current conversation
689
- messages.value = [];
690
- visibleLimit.value = 50;
691
- messageIdCounter = 0;
692
- streamingMessageId = null;
693
- pendingText = '';
694
- if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
695
-
696
- currentClaudeSessionId.value = session.sessionId;
697
- needsResume.value = true;
698
- loadingHistory.value = true;
699
-
700
- // Notify agent to prepare for resume (agent will respond with history)
701
- wsSend({
702
- type: 'resume_conversation',
703
- claudeSessionId: session.sessionId,
704
- });
705
- }
706
-
707
- function newConversation() {
708
- if (isProcessing.value) return;
709
- // Auto-close sidebar on mobile
710
- if (window.innerWidth <= 768) sidebarOpen.value = false;
711
- messages.value = [];
712
- visibleLimit.value = 50;
713
- messageIdCounter = 0;
714
- streamingMessageId = null;
715
- pendingText = '';
716
- currentClaudeSessionId.value = null;
717
- needsResume.value = false;
718
-
719
- messages.value.push({
720
- id: ++messageIdCounter, role: 'system',
721
- content: 'New conversation started.',
722
- timestamp: new Date(),
723
- });
724
- }
725
-
726
- function toggleSidebar() {
727
- sidebarOpen.value = !sidebarOpen.value;
728
- }
729
-
730
- // ── Folder picker: change working directory ──
731
- function openFolderPicker() {
732
- folderPickerOpen.value = true;
733
- folderPickerSelected.value = '';
734
- folderPickerLoading.value = true;
735
- folderPickerPath.value = workDir.value || '';
736
- folderPickerEntries.value = [];
737
- wsSend({ type: 'list_directory', dirPath: workDir.value || '' });
738
- }
739
-
740
- function loadFolderPickerDir(dirPath) {
741
- folderPickerLoading.value = true;
742
- folderPickerSelected.value = '';
743
- folderPickerEntries.value = [];
744
- wsSend({ type: 'list_directory', dirPath });
745
- }
746
-
747
- function folderPickerNavigateUp() {
748
- if (!folderPickerPath.value) return;
749
- const isWin = folderPickerPath.value.includes('\\');
750
- const parts = folderPickerPath.value.replace(/[/\\]$/, '').split(/[/\\]/);
751
- parts.pop();
752
- if (parts.length === 0) {
753
- folderPickerPath.value = '';
754
- loadFolderPickerDir('');
755
- } else if (isWin && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
756
- folderPickerPath.value = parts[0] + '\\';
757
- loadFolderPickerDir(parts[0] + '\\');
758
- } else {
759
- const sep = isWin ? '\\' : '/';
760
- const parent = parts.join(sep);
761
- folderPickerPath.value = parent;
762
- loadFolderPickerDir(parent);
763
- }
764
- }
765
-
766
- function folderPickerSelectItem(entry) {
767
- folderPickerSelected.value = entry.name;
768
- }
769
-
770
- function folderPickerEnter(entry) {
771
- const sep = folderPickerPath.value.includes('\\') || /^[A-Z]:/.test(entry.name) ? '\\' : '/';
772
- let newPath;
773
- if (!folderPickerPath.value) {
774
- newPath = entry.name + (entry.name.endsWith('\\') ? '' : '\\');
775
- } else {
776
- newPath = folderPickerPath.value.replace(/[/\\]$/, '') + sep + entry.name;
777
- }
778
- folderPickerPath.value = newPath;
779
- folderPickerSelected.value = '';
780
- loadFolderPickerDir(newPath);
781
- }
782
-
783
- function folderPickerGoToPath() {
784
- const path = folderPickerPath.value.trim();
785
- if (!path) {
786
- loadFolderPickerDir('');
787
- return;
788
- }
789
- folderPickerSelected.value = '';
790
- loadFolderPickerDir(path);
791
- }
792
-
793
- function confirmFolderPicker() {
794
- let path = folderPickerPath.value;
795
- if (!path) return;
796
- if (folderPickerSelected.value) {
797
- const sep = path.includes('\\') ? '\\' : '/';
798
- path = path.replace(/[/\\]$/, '') + sep + folderPickerSelected.value;
799
- }
800
- folderPickerOpen.value = false;
801
- wsSend({ type: 'change_workdir', workDir: path });
802
- }
803
-
804
- // ── Sidebar: grouped sessions by time ──
805
- const groupedSessions = computed(() => {
806
- if (!historySessions.value.length) return [];
807
- const now = new Date();
808
- const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
809
- const yesterdayStart = todayStart - 86400000;
810
- const weekStart = todayStart - 6 * 86400000;
811
-
812
- const groups = {};
813
- for (const s of historySessions.value) {
814
- let label;
815
- if (s.lastModified >= todayStart) label = 'Today';
816
- else if (s.lastModified >= yesterdayStart) label = 'Yesterday';
817
- else if (s.lastModified >= weekStart) label = 'This week';
818
- else label = 'Earlier';
819
- if (!groups[label]) groups[label] = [];
820
- groups[label].push(s);
821
- }
822
- // Return in a consistent order
823
- const order = ['Today', 'Yesterday', 'This week', 'Earlier'];
824
- return order.filter(k => groups[k]).map(k => ({ label: k, sessions: groups[k] }));
825
- });
826
-
827
- // ── WebSocket ──
828
- let reconnectAttempts = 0;
829
- const MAX_RECONNECT_ATTEMPTS = 50;
830
- const RECONNECT_BASE_DELAY = 1000;
831
- const RECONNECT_MAX_DELAY = 15000;
832
- let reconnectTimer = null;
833
-
834
- function connect() {
835
- const sid = getSessionId();
836
- if (!sid) {
837
- status.value = 'No Session';
838
- error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
839
- return;
840
- }
841
- sessionId.value = sid;
842
- status.value = 'Connecting...';
843
- error.value = '';
844
-
845
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
846
- const wsUrl = `${protocol}//${window.location.host}/?type=web&sessionId=${sid}`;
847
- ws = new WebSocket(wsUrl);
848
-
849
- ws.onopen = () => { error.value = ''; reconnectAttempts = 0; };
850
-
851
- ws.onmessage = (event) => {
852
- let msg;
853
- const parsed = JSON.parse(event.data);
854
-
855
- // The 'connected' message is always plain text (key exchange)
856
- if (parsed.type === 'connected') {
857
- msg = parsed;
858
- if (typeof parsed.sessionKey === 'string') {
859
- sessionKey = decodeKey(parsed.sessionKey);
860
- }
861
- } else if (sessionKey && isEncrypted(parsed)) {
862
- msg = decrypt(parsed, sessionKey);
863
- if (!msg) {
864
- console.error('[WS] Failed to decrypt message');
865
- return;
866
- }
867
- } else {
868
- msg = parsed;
869
- }
870
-
871
- if (msg.type === 'connected') {
872
- if (msg.agent) {
873
- status.value = 'Connected';
874
- agentName.value = msg.agent.name;
875
- hostname.value = msg.agent.hostname || '';
876
- workDir.value = msg.agent.workDir;
877
- // If we have a saved workDir from a previous session, restore it
878
- const savedDir = localStorage.getItem('agentlink-workdir');
879
- if (savedDir && savedDir !== msg.agent.workDir) {
880
- wsSend({ type: 'change_workdir', workDir: savedDir });
881
- }
882
- // Request session list once connected
883
- requestSessionList();
884
- } else {
885
- status.value = 'Waiting';
886
- error.value = 'Agent is not connected yet.';
887
- }
888
- } else if (msg.type === 'agent_disconnected') {
889
- status.value = 'Waiting';
890
- agentName.value = '';
891
- hostname.value = '';
892
- error.value = 'Agent disconnected. Waiting for reconnect...';
893
- isProcessing.value = false;
894
- isCompacting.value = false;
895
- } else if (msg.type === 'agent_reconnected') {
896
- status.value = 'Connected';
897
- error.value = '';
898
- if (msg.agent) {
899
- agentName.value = msg.agent.name;
900
- hostname.value = msg.agent.hostname || '';
901
- workDir.value = msg.agent.workDir;
902
- }
903
- requestSessionList();
904
- } else if (msg.type === 'error') {
905
- status.value = 'Error';
906
- error.value = msg.message;
907
- isProcessing.value = false;
908
- isCompacting.value = false;
909
- } else if (msg.type === 'claude_output') {
910
- handleClaudeOutput(msg);
911
- } else if (msg.type === 'command_output') {
912
- flushReveal();
913
- finalizeStreamingMsg();
914
- messages.value.push({
915
- id: ++messageIdCounter, role: 'user',
916
- content: msg.content, isCommandOutput: true,
917
- timestamp: new Date(),
918
- });
919
- scrollToBottom();
920
- } else if (msg.type === 'context_compaction') {
921
- if (msg.status === 'started') {
922
- isCompacting.value = true;
923
- } else if (msg.status === 'completed') {
924
- isCompacting.value = false;
925
- }
926
- } else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
927
- isProcessing.value = false;
928
- isCompacting.value = false;
929
- flushReveal();
930
- finalizeStreamingMsg();
931
- if (msg.type === 'execution_cancelled') {
932
- messages.value.push({
933
- id: ++messageIdCounter, role: 'system',
934
- content: 'Generation stopped.', timestamp: new Date(),
935
- });
936
- scrollToBottom();
937
- }
938
- } else if (msg.type === 'ask_user_question') {
939
- flushReveal();
940
- finalizeStreamingMsg();
941
- // Remove any preceding tool message for AskUserQuestion (tool_use arrives before control_request)
942
- for (let i = messages.value.length - 1; i >= 0; i--) {
943
- const m = messages.value[i];
944
- if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
945
- messages.value.splice(i, 1);
946
- break;
947
- }
948
- // Only look back within recent messages
949
- if (m.role === 'user') break;
950
- }
951
- // Render interactive question card
952
- const questions = msg.questions || [];
953
- const selectedAnswers = {};
954
- const customTexts = {};
955
- for (let i = 0; i < questions.length; i++) {
956
- selectedAnswers[i] = questions[i].multiSelect ? [] : null;
957
- customTexts[i] = '';
958
- }
959
- messages.value.push({
960
- id: ++messageIdCounter,
961
- role: 'ask-question',
962
- requestId: msg.requestId,
963
- questions,
964
- answered: false,
965
- selectedAnswers,
966
- customTexts,
967
- timestamp: new Date(),
968
- });
969
- scrollToBottom();
970
- } else if (msg.type === 'sessions_list') {
971
- historySessions.value = msg.sessions || [];
972
- loadingSessions.value = false;
973
- } else if (msg.type === 'conversation_resumed') {
974
- currentClaudeSessionId.value = msg.claudeSessionId;
975
- // Build history messages in a plain array first, then assign once
976
- // to avoid triggering Vue reactivity on every individual push.
977
- if (msg.history && Array.isArray(msg.history)) {
978
- const batch = [];
979
- for (const h of msg.history) {
980
- if (h.role === 'user') {
981
- if (isContextSummary(h.content)) {
982
- batch.push({
983
- id: ++messageIdCounter, role: 'context-summary',
984
- content: h.content, contextExpanded: false,
985
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
986
- });
987
- } else {
988
- batch.push({
989
- id: ++messageIdCounter, role: 'user',
990
- content: h.content, isCommandOutput: !!h.isCommandOutput,
991
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
992
- });
993
- }
994
- } else if (h.role === 'assistant') {
995
- // Merge with previous assistant message if consecutive
996
- const last = batch[batch.length - 1];
997
- if (last && last.role === 'assistant' && !last.isStreaming) {
998
- last.content += '\n\n' + h.content;
999
- } else {
1000
- batch.push({
1001
- id: ++messageIdCounter, role: 'assistant',
1002
- content: h.content, isStreaming: false,
1003
- timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
1004
- });
1005
- }
1006
- } else if (h.role === 'tool') {
1007
- batch.push({
1008
- id: ++messageIdCounter, role: 'tool',
1009
- toolId: h.toolId || '', toolName: h.toolName || 'unknown',
1010
- toolInput: h.toolInput || '', hasResult: true,
1011
- expanded: h.toolName === 'Edit', timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
1012
- });
1013
- }
1014
- }
1015
- // Single reactive assignment — triggers Vue reactivity only once
1016
- messages.value = batch;
1017
- }
1018
- loadingHistory.value = false;
1019
- // Show ready-for-input hint
1020
- messages.value.push({
1021
- id: ++messageIdCounter, role: 'system',
1022
- content: 'Session restored. You can continue the conversation.',
1023
- timestamp: new Date(),
1024
- });
1025
- scrollToBottom();
1026
- } else if (msg.type === 'directory_listing') {
1027
- folderPickerLoading.value = false;
1028
- folderPickerEntries.value = (msg.entries || [])
1029
- .filter(e => e.type === 'directory')
1030
- .sort((a, b) => a.name.localeCompare(b.name));
1031
- if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
1032
- } else if (msg.type === 'workdir_changed') {
1033
- workDir.value = msg.workDir;
1034
- localStorage.setItem('agentlink-workdir', msg.workDir);
1035
- messages.value = [];
1036
- visibleLimit.value = 50;
1037
- messageIdCounter = 0;
1038
- streamingMessageId = null;
1039
- pendingText = '';
1040
- currentClaudeSessionId.value = null;
1041
- isProcessing.value = false;
1042
- if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
1043
- messages.value.push({
1044
- id: ++messageIdCounter, role: 'system',
1045
- content: 'Working directory changed to: ' + msg.workDir,
1046
- timestamp: new Date(),
1047
- });
1048
- requestSessionList();
1049
- }
1050
- };
1051
-
1052
- ws.onclose = () => {
1053
- sessionKey = null;
1054
- const wasConnected = status.value === 'Connected' || status.value === 'Connecting...';
1055
- isProcessing.value = false;
1056
- isCompacting.value = false;
1057
-
1058
- if (wasConnected || reconnectAttempts > 0) {
1059
- scheduleReconnect();
1060
- }
1061
- };
1062
-
1063
- ws.onerror = () => {};
1064
- }
1065
-
1066
- function scheduleReconnect() {
1067
- if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
1068
- status.value = 'Disconnected';
1069
- error.value = 'Unable to reconnect. Please refresh the page.';
1070
- return;
1071
- }
1072
- const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts), RECONNECT_MAX_DELAY);
1073
- reconnectAttempts++;
1074
- status.value = 'Reconnecting...';
1075
- error.value = 'Connection lost. Reconnecting... (attempt ' + reconnectAttempts + ')';
1076
- if (reconnectTimer) clearTimeout(reconnectTimer);
1077
- reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay);
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);
1078
212
  }
1079
213
 
1080
- function handleClaudeOutput(msg) {
1081
- const data = msg.data;
1082
- if (!data) return;
1083
-
1084
- if (data.type === 'content_block_delta' && data.delta) {
1085
- pendingText += data.delta;
1086
- startReveal();
1087
- return;
1088
- }
1089
-
1090
- if (data.type === 'tool_use' && data.tools) {
1091
- flushReveal();
1092
- finalizeStreamingMsg();
1093
-
1094
- for (const tool of data.tools) {
1095
- messages.value.push({
1096
- id: ++messageIdCounter, role: 'tool',
1097
- toolId: tool.id, toolName: tool.name || 'unknown',
1098
- toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
1099
- hasResult: false, expanded: (tool.name === 'Edit'), timestamp: new Date(),
1100
- });
1101
- }
1102
- scrollToBottom();
1103
- return;
1104
- }
1105
-
1106
- if (data.type === 'user' && data.tool_use_result) {
1107
- const result = data.tool_use_result;
1108
- const results = Array.isArray(result) ? result : [result];
1109
- for (const r of results) {
1110
- const toolMsg = [...messages.value].reverse().find(
1111
- m => m.role === 'tool' && m.toolId === r.tool_use_id
1112
- );
1113
- if (toolMsg) {
1114
- toolMsg.toolOutput = typeof r.content === 'string'
1115
- ? r.content : JSON.stringify(r.content, null, 2);
1116
- toolMsg.hasResult = true;
1117
- }
1118
- }
1119
- scrollToBottom();
1120
- return;
1121
- }
214
+ function _submitQuestionAnswer(msg) {
215
+ submitQuestionAnswer(msg, wsSend);
1122
216
  }
1123
217
 
1124
- // Apply syntax highlighting after DOM updates (throttled)
1125
- let _hlTimer = null;
1126
- function scheduleHighlight() {
1127
- if (_hlTimer) return;
1128
- _hlTimer = setTimeout(() => {
1129
- _hlTimer = null;
1130
- if (typeof hljs !== 'undefined') {
1131
- document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
1132
- hljs.highlightElement(block);
1133
- block.dataset.highlighted = 'true';
1134
- });
1135
- }
1136
- }, 300);
1137
- }
1138
- // Trigger highlight when messages are added/removed (shallow watch on length)
1139
- // Deep watch is too expensive for large conversations — it traverses every
1140
- // property of every message object on every mutation (including streaming ticks).
218
+ // ── Watchers ──
1141
219
  const messageCount = computed(() => messages.value.length);
1142
220
  watch(messageCount, () => { nextTick(scheduleHighlight); });
1143
221
 
1144
- onMounted(() => { connect(); });
1145
- onUnmounted(() => { if (reconnectTimer) clearTimeout(reconnectTimer); if (ws) ws.close(); });
222
+ watch(agentName, (name) => {
223
+ document.title = name ? `${name} AgentLink` : 'AgentLink';
224
+ });
225
+
226
+ // ── Lifecycle ──
227
+ onMounted(() => { connect(scheduleHighlight); });
228
+ onUnmounted(() => { closeWs(); streaming.cleanup(); });
1146
229
 
1147
230
  return {
1148
231
  status, agentName, hostname, workDir, sessionId, error,
1149
232
  messages, visibleMessages, hasMoreMessages, loadMoreMessages,
1150
233
  inputText, isProcessing, isCompacting, canSend, inputRef,
1151
- sendMessage, handleKeydown, cancelExecution,
1152
- getRenderedContent, copyMessage, toggleTool, isPrevAssistant, toggleContextSummary,
234
+ sendMessage, handleKeydown, cancelExecution, onMessageListScroll,
235
+ getRenderedContent, copyMessage, toggleTool,
236
+ isPrevAssistant: _isPrevAssistant,
237
+ toggleContextSummary, formatTimestamp,
1153
238
  getToolIcon, getToolSummary, isEditTool, getEditDiffHtml, getFormattedToolInput, autoResize,
1154
239
  // AskUserQuestion
1155
- selectQuestionOption, submitQuestionAnswer, hasQuestionAnswer, getQuestionResponseSummary,
240
+ selectQuestionOption,
241
+ submitQuestionAnswer: _submitQuestionAnswer,
242
+ hasQuestionAnswer, getQuestionResponseSummary,
1156
243
  // Theme
1157
244
  theme, toggleTheme,
1158
245
  // Sidebar
1159
246
  sidebarOpen, historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
1160
- toggleSidebar, resumeSession, newConversation, requestSessionList,
1161
- formatRelativeTime, groupedSessions,
247
+ toggleSidebar: sidebar.toggleSidebar,
248
+ resumeSession: sidebar.resumeSession,
249
+ newConversation: sidebar.newConversation,
250
+ requestSessionList: sidebar.requestSessionList,
251
+ formatRelativeTime,
252
+ groupedSessions: sidebar.groupedSessions,
1162
253
  // Folder picker
1163
254
  folderPickerOpen, folderPickerPath, folderPickerEntries,
1164
255
  folderPickerLoading, folderPickerSelected,
1165
- openFolderPicker, folderPickerNavigateUp, folderPickerSelectItem,
1166
- 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,
1167
262
  // File attachments
1168
263
  attachments, fileInputRef, dragOver,
1169
- triggerFileInput, handleFileSelect, removeAttachment, formatFileSize,
1170
- 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,
1171
272
  };
1172
273
  },
1173
274
  template: `
@@ -1261,7 +362,7 @@ const App = {
1261
362
 
1262
363
  <!-- Chat area -->
1263
364
  <div class="chat-area">
1264
- <div class="message-list">
365
+ <div class="message-list" @scroll="onMessageListScroll">
1265
366
  <div class="message-list-inner">
1266
367
  <div v-if="messages.length === 0 && status === 'Connected' && !loadingHistory" class="empty-state">
1267
368
  <div class="empty-state-icon">
@@ -1286,7 +387,7 @@ const App = {
1286
387
  <!-- User message -->
1287
388
  <template v-if="msg.role === 'user'">
1288
389
  <div class="message-role-label user-label">You</div>
1289
- <div class="message-bubble user-bubble">
390
+ <div class="message-bubble user-bubble" :title="formatTimestamp(msg.timestamp)">
1290
391
  <div v-if="msg.isCommandOutput" class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
1291
392
  <div v-else class="message-content">{{ msg.content }}</div>
1292
393
  <div v-if="msg.attachments && msg.attachments.length" class="message-attachments">
@@ -1304,7 +405,7 @@ const App = {
1304
405
  <!-- Assistant message (markdown) -->
1305
406
  <template v-else-if="msg.role === 'assistant'">
1306
407
  <div v-if="!isPrevAssistant(msgIdx)" class="message-role-label assistant-label">Claude</div>
1307
- <div :class="['message-bubble', 'assistant-bubble', { streaming: msg.isStreaming }]">
408
+ <div :class="['message-bubble', 'assistant-bubble', { streaming: msg.isStreaming }]" :title="formatTimestamp(msg.timestamp)">
1308
409
  <div class="message-actions">
1309
410
  <button class="icon-btn" @click="copyMessage(msg)" :title="msg.copied ? 'Copied!' : 'Copy'">
1310
411
  <svg v-if="!msg.copied" 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>
@@ -1327,7 +428,7 @@ const App = {
1327
428
  </span>
1328
429
  <span class="tool-toggle">{{ msg.expanded ? '\u{25B2}' : '\u{25BC}' }}</span>
1329
430
  </div>
1330
- <div v-if="msg.expanded" class="tool-expand">
431
+ <div v-show="msg.expanded" class="tool-expand">
1331
432
  <div v-if="isEditTool(msg) && getEditDiffHtml(msg)" class="tool-diff" v-html="getEditDiffHtml(msg)"></div>
1332
433
  <div v-else-if="getFormattedToolInput(msg)" class="tool-input-formatted" v-html="getFormattedToolInput(msg)"></div>
1333
434
  <pre v-else-if="msg.toolInput" class="tool-block">{{ msg.toolInput }}</pre>
@@ -1428,7 +529,7 @@ const App = {
1428
529
  @input="autoResize"
1429
530
  @paste="handlePaste"
1430
531
  :disabled="status !== 'Connected' || isCompacting"
1431
- :placeholder="isCompacting ? 'Context compacting in progress...' : 'Send a message...'"
532
+ :placeholder="isCompacting ? 'Context compacting in progress...' : 'Send a message · Enter to send'"
1432
533
  rows="1"
1433
534
  ></textarea>
1434
535
  <div v-if="attachments.length > 0" class="attachment-bar">