@agent-link/server 0.1.0

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 ADDED
@@ -0,0 +1,1399 @@
1
+ const { createApp, ref, nextTick, onMounted, onUnmounted, computed, watch } = Vue;
2
+ import { encrypt, decrypt, isEncrypted, decodeKey } from './encryption.js';
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
+ function renderMarkdown(text) {
19
+ if (!text) return '';
20
+ try {
21
+ if (typeof marked !== 'undefined') {
22
+ let html = marked.parse(text);
23
+ // Add copy buttons to code blocks
24
+ html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
25
+ (match, attrs, code) => {
26
+ const langMatch = attrs.match(/class="language-(\w+)"/);
27
+ const lang = langMatch ? langMatch[1] : '';
28
+ return `<div class="code-block-wrapper">
29
+ <div class="code-block-header">
30
+ <span class="code-lang">${lang}</span>
31
+ <button class="code-copy-btn" onclick="window.__copyCodeBlock(this)" title="Copy">
32
+ <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>
33
+ </button>
34
+ </div>
35
+ <pre><code${attrs}>${code}</code></pre>
36
+ </div>`;
37
+ }
38
+ );
39
+ return html;
40
+ }
41
+ } catch {}
42
+ // Fallback: escape HTML
43
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
44
+ }
45
+
46
+ // Global code copy handler
47
+ window.__copyCodeBlock = async function(btn) {
48
+ const wrapper = btn.closest('.code-block-wrapper');
49
+ const code = wrapper?.querySelector('code');
50
+ if (!code) return;
51
+ try {
52
+ await navigator.clipboard.writeText(code.textContent);
53
+ 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>';
54
+ setTimeout(() => {
55
+ 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>';
56
+ }, 2000);
57
+ } catch {}
58
+ };
59
+
60
+ // Tool icons (monochrome SVG)
61
+ const TOOL_SVG = {
62
+ 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>',
63
+ 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>',
64
+ 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>',
65
+ 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>',
66
+ 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>',
67
+ 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>',
68
+ 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>',
69
+ 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>',
70
+ 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>',
71
+ 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>',
72
+ };
73
+ 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>';
74
+ function getToolIcon(name) { return TOOL_SVG[name] || TOOL_SVG_DEFAULT; }
75
+
76
+ // ── Helpers ─────────────────────────────────────────────────────────────────
77
+ const CONTEXT_SUMMARY_PREFIX = 'This session is being continued from a previous conversation';
78
+
79
+ function isContextSummary(text) {
80
+ return typeof text === 'string' && text.trimStart().startsWith(CONTEXT_SUMMARY_PREFIX);
81
+ }
82
+
83
+ function formatRelativeTime(ts) {
84
+ const diff = Date.now() - ts;
85
+ const mins = Math.floor(diff / 60000);
86
+ if (mins < 1) return 'just now';
87
+ if (mins < 60) return `${mins}m ago`;
88
+ const hours = Math.floor(mins / 60);
89
+ if (hours < 24) return `${hours}h ago`;
90
+ const days = Math.floor(hours / 24);
91
+ if (days < 30) return `${days}d ago`;
92
+ return new Date(ts).toLocaleDateString();
93
+ }
94
+
95
+ // ── App ─────────────────────────────────────────────────────────────────────
96
+ const App = {
97
+ setup() {
98
+ const status = ref('Connecting...');
99
+ const agentName = ref('');
100
+ const hostname = ref('');
101
+ const workDir = ref('');
102
+ const sessionId = ref('');
103
+ const error = ref('');
104
+ const messages = ref([]);
105
+ const inputText = ref('');
106
+ const isProcessing = ref(false);
107
+ const isCompacting = ref(false);
108
+ const inputRef = ref(null);
109
+
110
+ // Sidebar state
111
+ const sidebarOpen = ref(true);
112
+ const historySessions = ref([]);
113
+ const currentClaudeSessionId = ref(null);
114
+ const needsResume = ref(false);
115
+ const loadingSessions = ref(false);
116
+ const loadingHistory = ref(false);
117
+
118
+ // Folder picker state
119
+ const folderPickerOpen = ref(false);
120
+ const folderPickerPath = ref('');
121
+ const folderPickerEntries = ref([]);
122
+ const folderPickerLoading = ref(false);
123
+ const folderPickerSelected = ref('');
124
+
125
+ // File attachment state
126
+ const attachments = ref([]);
127
+ const fileInputRef = ref(null);
128
+ const dragOver = ref(false);
129
+
130
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
131
+ const MAX_FILES = 5;
132
+ const ACCEPTED_EXTENSIONS = [
133
+ '.pdf', '.json', '.md', '.py', '.js', '.ts', '.tsx', '.jsx', '.css',
134
+ '.html', '.xml', '.yaml', '.yml', '.toml', '.sh', '.sql', '.csv',
135
+ '.c', '.cpp', '.h', '.hpp', '.java', '.go', '.rs', '.rb', '.php',
136
+ '.swift', '.kt', '.scala', '.r', '.m', '.vue', '.svelte', '.txt',
137
+ '.log', '.cfg', '.ini', '.env', '.gitignore', '.dockerfile',
138
+ ];
139
+
140
+ function isAcceptedFile(file) {
141
+ if (file.type.startsWith('image/')) return true;
142
+ if (file.type.startsWith('text/')) return true;
143
+ const ext = '.' + file.name.split('.').pop().toLowerCase();
144
+ return ACCEPTED_EXTENSIONS.includes(ext);
145
+ }
146
+
147
+ function formatFileSize(bytes) {
148
+ if (bytes < 1024) return bytes + ' B';
149
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
150
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
151
+ }
152
+
153
+ function readFileAsBase64(file) {
154
+ return new Promise((resolve, reject) => {
155
+ const reader = new FileReader();
156
+ reader.onload = () => {
157
+ // result is "data:<mime>;base64,<data>" — extract just the base64 part
158
+ const base64 = reader.result.split(',')[1];
159
+ resolve(base64);
160
+ };
161
+ reader.onerror = reject;
162
+ reader.readAsDataURL(file);
163
+ });
164
+ }
165
+
166
+ async function addFiles(fileList) {
167
+ const currentCount = attachments.value.length;
168
+ const remaining = MAX_FILES - currentCount;
169
+ if (remaining <= 0) return;
170
+
171
+ const files = Array.from(fileList).slice(0, remaining);
172
+ for (const file of files) {
173
+ if (!isAcceptedFile(file)) continue;
174
+ if (file.size > MAX_FILE_SIZE) continue;
175
+ // Skip duplicates
176
+ if (attachments.value.some(a => a.name === file.name && a.size === file.size)) continue;
177
+
178
+ const data = await readFileAsBase64(file);
179
+ const isImage = file.type.startsWith('image/');
180
+ let thumbUrl = null;
181
+ if (isImage) {
182
+ thumbUrl = URL.createObjectURL(file);
183
+ }
184
+ attachments.value.push({
185
+ name: file.name,
186
+ mimeType: file.type || 'application/octet-stream',
187
+ size: file.size,
188
+ data,
189
+ isImage,
190
+ thumbUrl,
191
+ });
192
+ }
193
+ }
194
+
195
+ function removeAttachment(index) {
196
+ const att = attachments.value[index];
197
+ if (att.thumbUrl) URL.revokeObjectURL(att.thumbUrl);
198
+ attachments.value.splice(index, 1);
199
+ }
200
+
201
+ function triggerFileInput() {
202
+ if (fileInputRef.value) fileInputRef.value.click();
203
+ }
204
+
205
+ function handleFileSelect(e) {
206
+ if (e.target.files) addFiles(e.target.files);
207
+ e.target.value = ''; // reset so same file can be selected again
208
+ }
209
+
210
+ function handleDragOver(e) {
211
+ e.preventDefault();
212
+ dragOver.value = true;
213
+ }
214
+
215
+ function handleDragLeave(e) {
216
+ e.preventDefault();
217
+ dragOver.value = false;
218
+ }
219
+
220
+ function handleDrop(e) {
221
+ e.preventDefault();
222
+ dragOver.value = false;
223
+ if (e.dataTransfer?.files) addFiles(e.dataTransfer.files);
224
+ }
225
+
226
+ function handlePaste(e) {
227
+ const items = e.clipboardData?.items;
228
+ if (!items) return;
229
+ const files = [];
230
+ for (const item of items) {
231
+ if (item.kind === 'file') {
232
+ const file = item.getAsFile();
233
+ if (file) files.push(file);
234
+ }
235
+ }
236
+ if (files.length > 0) {
237
+ e.preventDefault();
238
+ addFiles(files);
239
+ }
240
+ }
241
+
242
+ // Theme state
243
+ const theme = ref(localStorage.getItem('agentlink-theme') || 'dark');
244
+ function applyTheme() {
245
+ document.documentElement.setAttribute('data-theme', theme.value);
246
+ const link = document.getElementById('hljs-theme');
247
+ if (link) link.href = theme.value === 'light'
248
+ ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'
249
+ : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css';
250
+ }
251
+ function toggleTheme() {
252
+ theme.value = theme.value === 'dark' ? 'light' : 'dark';
253
+ localStorage.setItem('agentlink-theme', theme.value);
254
+ applyTheme();
255
+ }
256
+ applyTheme();
257
+
258
+ let ws = null;
259
+ let sessionKey = null;
260
+ let messageIdCounter = 0;
261
+ let streamingMessageId = null;
262
+
263
+ function wsSend(msg) {
264
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
265
+ if (sessionKey) {
266
+ const encrypted = encrypt(msg, sessionKey);
267
+ ws.send(JSON.stringify(encrypted));
268
+ } else {
269
+ ws.send(JSON.stringify(msg));
270
+ }
271
+ }
272
+
273
+ // Progressive text reveal state
274
+ let pendingText = '';
275
+ let revealTimer = null;
276
+ const CHARS_PER_TICK = 3;
277
+ const TICK_MS = 12;
278
+
279
+ function startReveal() {
280
+ if (revealTimer !== null) return;
281
+ revealTimer = setTimeout(revealTick, TICK_MS);
282
+ }
283
+
284
+ function revealTick() {
285
+ revealTimer = null;
286
+ if (!pendingText) return;
287
+
288
+ const streamMsg = streamingMessageId !== null
289
+ ? messages.value.find(m => m.id === streamingMessageId)
290
+ : null;
291
+
292
+ if (!streamMsg) {
293
+ const id = ++messageIdCounter;
294
+ const chunk = pendingText.slice(0, CHARS_PER_TICK);
295
+ pendingText = pendingText.slice(CHARS_PER_TICK);
296
+ messages.value.push({
297
+ id, role: 'assistant', content: chunk,
298
+ isStreaming: true, timestamp: new Date(),
299
+ });
300
+ streamingMessageId = id;
301
+ } else {
302
+ const chunk = pendingText.slice(0, CHARS_PER_TICK);
303
+ pendingText = pendingText.slice(CHARS_PER_TICK);
304
+ streamMsg.content += chunk;
305
+ }
306
+ scrollToBottom();
307
+ if (pendingText) revealTimer = setTimeout(revealTick, TICK_MS);
308
+ }
309
+
310
+ function flushReveal() {
311
+ if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
312
+ if (!pendingText) return;
313
+ const streamMsg = streamingMessageId !== null
314
+ ? messages.value.find(m => m.id === streamingMessageId) : null;
315
+ if (streamMsg) {
316
+ streamMsg.content += pendingText;
317
+ } else {
318
+ const id = ++messageIdCounter;
319
+ messages.value.push({
320
+ id, role: 'assistant', content: pendingText,
321
+ isStreaming: true, timestamp: new Date(),
322
+ });
323
+ streamingMessageId = id;
324
+ }
325
+ pendingText = '';
326
+ scrollToBottom();
327
+ }
328
+
329
+ const canSend = computed(() =>
330
+ status.value === 'Connected' && (inputText.value.trim() || attachments.value.length > 0) && !isProcessing.value && !isCompacting.value
331
+ && !messages.value.some(m => m.role === 'ask-question' && !m.answered)
332
+ );
333
+
334
+ function getSessionId() {
335
+ const match = window.location.pathname.match(/^\/s\/([^/]+)/);
336
+ return match ? match[1] : null;
337
+ }
338
+
339
+ function scrollToBottom() {
340
+ nextTick(() => {
341
+ const el = document.querySelector('.message-list');
342
+ if (el) el.scrollTop = el.scrollHeight;
343
+ });
344
+ }
345
+
346
+ // ── Auto-resize textarea ──
347
+ function autoResize() {
348
+ const ta = inputRef.value;
349
+ if (ta) {
350
+ ta.style.height = 'auto';
351
+ ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
352
+ }
353
+ }
354
+
355
+ function sendMessage() {
356
+ if (!canSend.value) return;
357
+
358
+ const text = inputText.value.trim();
359
+ const files = attachments.value.slice();
360
+ inputText.value = '';
361
+ if (inputRef.value) inputRef.value.style.height = 'auto';
362
+
363
+ // Build message display with attachment info
364
+ const msgAttachments = files.map(f => ({
365
+ name: f.name, size: f.size, isImage: f.isImage, thumbUrl: f.thumbUrl,
366
+ }));
367
+
368
+ messages.value.push({
369
+ id: ++messageIdCounter, role: 'user',
370
+ content: text || (files.length > 0 ? `[${files.length} file${files.length > 1 ? 's' : ''} attached]` : ''),
371
+ attachments: msgAttachments.length > 0 ? msgAttachments : undefined,
372
+ timestamp: new Date(),
373
+ });
374
+ isProcessing.value = true;
375
+ scrollToBottom();
376
+
377
+ // Build payload
378
+ const payload = { type: 'chat', prompt: text || '(see attached files)' };
379
+ if (needsResume.value && currentClaudeSessionId.value) {
380
+ payload.resumeSessionId = currentClaudeSessionId.value;
381
+ needsResume.value = false;
382
+ }
383
+ if (files.length > 0) {
384
+ payload.files = files.map(f => ({
385
+ name: f.name,
386
+ mimeType: f.mimeType,
387
+ data: f.data,
388
+ }));
389
+ }
390
+ wsSend(payload);
391
+
392
+ // Clear attachments (don't revoke thumbUrls — they're referenced by the message now)
393
+ attachments.value = [];
394
+ }
395
+
396
+ function cancelExecution() {
397
+ if (!ws || !isProcessing.value) return;
398
+ wsSend({ type: 'cancel_execution' });
399
+ }
400
+
401
+ function handleKeydown(e) {
402
+ if (e.key === 'Enter' && !e.shiftKey) {
403
+ e.preventDefault();
404
+ sendMessage();
405
+ }
406
+ }
407
+
408
+ // ── Rendered markdown for assistant messages ──
409
+ function getRenderedContent(msg) {
410
+ if (msg.role !== 'assistant') return msg.content;
411
+ return renderMarkdown(msg.content);
412
+ }
413
+
414
+ // ── Copy full message ──
415
+ async function copyMessage(msg) {
416
+ try {
417
+ await navigator.clipboard.writeText(msg.content);
418
+ msg.copied = true;
419
+ setTimeout(() => { msg.copied = false; }, 2000);
420
+ } catch {}
421
+ }
422
+
423
+ // ── Check if previous message is also assistant (to suppress repeated label) ──
424
+ function isPrevAssistant(msg) {
425
+ const idx = messages.value.indexOf(msg);
426
+ if (idx <= 0) return false;
427
+ const prev = messages.value[idx - 1];
428
+ return prev.role === 'assistant' || prev.role === 'tool';
429
+ }
430
+
431
+ // ── Context summary toggle ──
432
+ function toggleContextSummary(msg) {
433
+ msg.contextExpanded = !msg.contextExpanded;
434
+ }
435
+
436
+ // ── Finalize a streaming message (mark done, detect context summary) ──
437
+ function finalizeStreamingMsg() {
438
+ if (streamingMessageId === null) return;
439
+ const streamMsg = messages.value.find(m => m.id === streamingMessageId);
440
+ if (streamMsg) {
441
+ streamMsg.isStreaming = false;
442
+ if (isContextSummary(streamMsg.content)) {
443
+ streamMsg.role = 'context-summary';
444
+ streamMsg.contextExpanded = false;
445
+ }
446
+ }
447
+ streamingMessageId = null;
448
+ }
449
+
450
+ // ── Tool expand/collapse ──
451
+ function toggleTool(msg) {
452
+ msg.expanded = !msg.expanded;
453
+ }
454
+
455
+ function getToolSummary(msg) {
456
+ const name = msg.toolName;
457
+ const input = msg.toolInput;
458
+ try {
459
+ const obj = JSON.parse(input);
460
+ if (name === 'Read' && obj.file_path) return obj.file_path;
461
+ if (name === 'Edit' && obj.file_path) return obj.file_path;
462
+ if (name === 'Write' && obj.file_path) return obj.file_path;
463
+ if (name === 'Bash' && obj.command) return obj.command.length > 60 ? obj.command.slice(0, 60) + '...' : obj.command;
464
+ if (name === 'Glob' && obj.pattern) return obj.pattern;
465
+ if (name === 'Grep' && obj.pattern) return obj.pattern;
466
+ if (name === 'TodoWrite' && obj.todos) {
467
+ const done = obj.todos.filter(t => t.status === 'completed').length;
468
+ return `${done}/${obj.todos.length} done`;
469
+ }
470
+ if (name === 'Task' && obj.description) return obj.description;
471
+ if (name === 'WebSearch' && obj.query) return obj.query;
472
+ if (name === 'WebFetch' && obj.url) return obj.url.length > 60 ? obj.url.slice(0, 60) + '...' : obj.url;
473
+ } catch {}
474
+ return '';
475
+ }
476
+
477
+ function isEditTool(msg) {
478
+ return msg.role === 'tool' && msg.toolName === 'Edit' && msg.toolInput;
479
+ }
480
+
481
+ function getFormattedToolInput(msg) {
482
+ if (!msg.toolInput) return null;
483
+ try {
484
+ const obj = JSON.parse(msg.toolInput);
485
+ const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
486
+ const name = msg.toolName;
487
+
488
+ if (name === 'Read' && obj.file_path) {
489
+ let detail = esc(obj.file_path);
490
+ if (obj.offset && obj.limit) {
491
+ detail += ` <span class="tool-input-meta">lines ${obj.offset}\u2013${obj.offset + obj.limit - 1}</span>`;
492
+ } else if (obj.offset) {
493
+ detail += ` <span class="tool-input-meta">from line ${obj.offset}</span>`;
494
+ } else if (obj.limit) {
495
+ detail += ` <span class="tool-input-meta">first ${obj.limit} lines</span>`;
496
+ }
497
+ return detail;
498
+ }
499
+
500
+ if (name === 'Write' && obj.file_path) {
501
+ const lines = (obj.content || '').split('\n').length;
502
+ return esc(obj.file_path) + ` <span class="tool-input-meta">${lines} lines</span>`;
503
+ }
504
+
505
+ if (name === 'Bash' && obj.command) {
506
+ let html = '<code class="tool-input-cmd">' + esc(obj.command) + '</code>';
507
+ if (obj.description) html = '<span class="tool-input-meta">' + esc(obj.description) + '</span> ' + html;
508
+ return html;
509
+ }
510
+
511
+ if (name === 'Glob' && obj.pattern) {
512
+ let html = '<code class="tool-input-cmd">' + esc(obj.pattern) + '</code>';
513
+ if (obj.path) html += ' <span class="tool-input-meta">in ' + esc(obj.path) + '</span>';
514
+ return html;
515
+ }
516
+
517
+ if (name === 'Grep' && obj.pattern) {
518
+ let html = '<code class="tool-input-cmd">' + esc(obj.pattern) + '</code>';
519
+ if (obj.path) html += ' <span class="tool-input-meta">in ' + esc(obj.path) + '</span>';
520
+ return html;
521
+ }
522
+
523
+ if (name === 'TodoWrite' && Array.isArray(obj.todos)) {
524
+ let html = '<div class="todo-list">';
525
+ for (const t of obj.todos) {
526
+ const s = t.status;
527
+ const icon = s === 'completed' ? '<span class="todo-icon done">\u2713</span>'
528
+ : s === 'in_progress' ? '<span class="todo-icon active">\u25CF</span>'
529
+ : '<span class="todo-icon">\u25CB</span>';
530
+ const cls = s === 'completed' ? ' todo-done' : s === 'in_progress' ? ' todo-active' : '';
531
+ html += '<div class="todo-item' + cls + '">' + icon + '<span class="todo-text">' + esc(t.content || t.activeForm || '') + '</span></div>';
532
+ }
533
+ html += '</div>';
534
+ return html;
535
+ }
536
+
537
+ if (name === 'Task') {
538
+ let html = '';
539
+ if (obj.description) html += '<div class="task-field"><span class="tool-input-meta">Description</span> ' + esc(obj.description) + '</div>';
540
+ 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>';
541
+ if (obj.prompt) {
542
+ const short = obj.prompt.length > 200 ? obj.prompt.slice(0, 200) + '...' : obj.prompt;
543
+ html += '<div class="task-field"><span class="tool-input-meta">Prompt</span></div><div class="task-prompt">' + esc(short) + '</div>';
544
+ }
545
+ if (html) return html;
546
+ }
547
+
548
+ if (name === 'WebSearch' && obj.query) {
549
+ return '<code class="tool-input-cmd">' + esc(obj.query) + '</code>';
550
+ }
551
+
552
+ if (name === 'WebFetch' && obj.url) {
553
+ let html = '<a class="tool-link" href="' + esc(obj.url) + '" target="_blank" rel="noopener">' + esc(obj.url) + '</a>';
554
+ if (obj.prompt) html += '<div class="task-field"><span class="tool-input-meta">' + esc(obj.prompt) + '</span></div>';
555
+ return html;
556
+ }
557
+
558
+ } catch {}
559
+ return null;
560
+ }
561
+
562
+ function getEditDiffHtml(msg) {
563
+ try {
564
+ const obj = JSON.parse(msg.toolInput);
565
+ if (!obj.old_string && !obj.new_string) return null;
566
+ const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
567
+ const filePath = obj.file_path || '';
568
+ const oldLines = (obj.old_string || '').split('\n');
569
+ const newLines = (obj.new_string || '').split('\n');
570
+ let html = '';
571
+ if (filePath) {
572
+ html += '<div class="diff-file">' + esc(filePath) + (obj.replace_all ? ' <span class="diff-replace-all">(replace all)</span>' : '') + '</div>';
573
+ }
574
+ html += '<div class="diff-lines">';
575
+ for (const line of oldLines) {
576
+ html += '<div class="diff-removed">' + '<span class="diff-sign">-</span>' + esc(line) + '</div>';
577
+ }
578
+ for (const line of newLines) {
579
+ html += '<div class="diff-added">' + '<span class="diff-sign">+</span>' + esc(line) + '</div>';
580
+ }
581
+ html += '</div>';
582
+ return html;
583
+ } catch { return null; }
584
+ }
585
+
586
+ // ── AskUserQuestion interaction ──
587
+ function selectQuestionOption(msg, qIndex, optLabel) {
588
+ if (msg.answered) return;
589
+ const q = msg.questions[qIndex];
590
+ if (!q) return;
591
+ if (q.multiSelect) {
592
+ // Toggle selection
593
+ const sel = msg.selectedAnswers[qIndex] || [];
594
+ const idx = sel.indexOf(optLabel);
595
+ if (idx >= 0) sel.splice(idx, 1);
596
+ else sel.push(optLabel);
597
+ msg.selectedAnswers[qIndex] = [...sel];
598
+ } else {
599
+ msg.selectedAnswers[qIndex] = optLabel;
600
+ msg.customTexts[qIndex] = ''; // clear custom text when option selected
601
+ }
602
+ }
603
+
604
+ function submitQuestionAnswer(msg) {
605
+ if (msg.answered || !ws) return;
606
+ // Build answers object keyed by question text: { "question text": "selected label" }
607
+ // This matches the format Claude CLI expects for AskUserQuestion answers
608
+ const answers = {};
609
+ for (let i = 0; i < msg.questions.length; i++) {
610
+ const q = msg.questions[i];
611
+ const key = q.question || String(i);
612
+ const custom = (msg.customTexts[i] || '').trim();
613
+ if (custom) {
614
+ answers[key] = custom;
615
+ } else {
616
+ const sel = msg.selectedAnswers[i];
617
+ if (Array.isArray(sel) && sel.length > 0) {
618
+ answers[key] = sel.join(', ');
619
+ } else if (sel != null) {
620
+ answers[key] = sel;
621
+ }
622
+ }
623
+ }
624
+ msg.answered = true;
625
+ wsSend({ type: 'ask_user_answer', requestId: msg.requestId, answers });
626
+ }
627
+
628
+ function hasQuestionAnswer(msg) {
629
+ // Check if at least one question has a selection or custom text
630
+ for (let i = 0; i < msg.questions.length; i++) {
631
+ const sel = msg.selectedAnswers[i];
632
+ const custom = (msg.customTexts[i] || '').trim();
633
+ if (custom || (Array.isArray(sel) ? sel.length > 0 : sel != null)) return true;
634
+ }
635
+ return false;
636
+ }
637
+
638
+ function getQuestionResponseSummary(msg) {
639
+ // Build a summary string of the user's answers
640
+ const parts = [];
641
+ for (let i = 0; i < msg.questions.length; i++) {
642
+ const custom = (msg.customTexts[i] || '').trim();
643
+ if (custom) {
644
+ parts.push(custom);
645
+ } else {
646
+ const sel = msg.selectedAnswers[i];
647
+ if (Array.isArray(sel)) parts.push(sel.join(', '));
648
+ else if (sel) parts.push(sel);
649
+ }
650
+ }
651
+ return parts.join(' | ');
652
+ }
653
+
654
+ // ── Sidebar: session management ──
655
+ function requestSessionList() {
656
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
657
+ loadingSessions.value = true;
658
+ wsSend({ type: 'list_sessions' });
659
+ }
660
+
661
+ function resumeSession(session) {
662
+ if (isProcessing.value) return;
663
+ // Clear current conversation
664
+ messages.value = [];
665
+ messageIdCounter = 0;
666
+ streamingMessageId = null;
667
+ pendingText = '';
668
+ if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
669
+
670
+ currentClaudeSessionId.value = session.sessionId;
671
+ needsResume.value = true;
672
+ loadingHistory.value = true;
673
+
674
+ // Notify agent to prepare for resume (agent will respond with history)
675
+ wsSend({
676
+ type: 'resume_conversation',
677
+ claudeSessionId: session.sessionId,
678
+ });
679
+ }
680
+
681
+ function newConversation() {
682
+ if (isProcessing.value) return;
683
+ messages.value = [];
684
+ messageIdCounter = 0;
685
+ streamingMessageId = null;
686
+ pendingText = '';
687
+ currentClaudeSessionId.value = null;
688
+ needsResume.value = false;
689
+
690
+ messages.value.push({
691
+ id: ++messageIdCounter, role: 'system',
692
+ content: 'New conversation started.',
693
+ timestamp: new Date(),
694
+ });
695
+ }
696
+
697
+ function toggleSidebar() {
698
+ sidebarOpen.value = !sidebarOpen.value;
699
+ }
700
+
701
+ // ── Folder picker: change working directory ──
702
+ function openFolderPicker() {
703
+ folderPickerOpen.value = true;
704
+ folderPickerSelected.value = '';
705
+ folderPickerLoading.value = true;
706
+ folderPickerPath.value = workDir.value || '';
707
+ folderPickerEntries.value = [];
708
+ wsSend({ type: 'list_directory', dirPath: workDir.value || '' });
709
+ }
710
+
711
+ function loadFolderPickerDir(dirPath) {
712
+ folderPickerLoading.value = true;
713
+ folderPickerSelected.value = '';
714
+ folderPickerEntries.value = [];
715
+ wsSend({ type: 'list_directory', dirPath });
716
+ }
717
+
718
+ function folderPickerNavigateUp() {
719
+ if (!folderPickerPath.value) return;
720
+ const isWin = folderPickerPath.value.includes('\\');
721
+ const parts = folderPickerPath.value.replace(/[/\\]$/, '').split(/[/\\]/);
722
+ parts.pop();
723
+ if (parts.length === 0) {
724
+ folderPickerPath.value = '';
725
+ loadFolderPickerDir('');
726
+ } else if (isWin && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
727
+ folderPickerPath.value = parts[0] + '\\';
728
+ loadFolderPickerDir(parts[0] + '\\');
729
+ } else {
730
+ const sep = isWin ? '\\' : '/';
731
+ const parent = parts.join(sep);
732
+ folderPickerPath.value = parent;
733
+ loadFolderPickerDir(parent);
734
+ }
735
+ }
736
+
737
+ function folderPickerSelectItem(entry) {
738
+ folderPickerSelected.value = entry.name;
739
+ }
740
+
741
+ function folderPickerEnter(entry) {
742
+ const sep = folderPickerPath.value.includes('\\') || /^[A-Z]:/.test(entry.name) ? '\\' : '/';
743
+ let newPath;
744
+ if (!folderPickerPath.value) {
745
+ newPath = entry.name + (entry.name.endsWith('\\') ? '' : '\\');
746
+ } else {
747
+ newPath = folderPickerPath.value.replace(/[/\\]$/, '') + sep + entry.name;
748
+ }
749
+ folderPickerPath.value = newPath;
750
+ folderPickerSelected.value = '';
751
+ loadFolderPickerDir(newPath);
752
+ }
753
+
754
+ function confirmFolderPicker() {
755
+ let path = folderPickerPath.value;
756
+ if (!path) return;
757
+ if (folderPickerSelected.value) {
758
+ const sep = path.includes('\\') ? '\\' : '/';
759
+ path = path.replace(/[/\\]$/, '') + sep + folderPickerSelected.value;
760
+ }
761
+ folderPickerOpen.value = false;
762
+ wsSend({ type: 'change_workdir', workDir: path });
763
+ }
764
+
765
+ // ── Sidebar: grouped sessions by time ──
766
+ const groupedSessions = computed(() => {
767
+ if (!historySessions.value.length) return [];
768
+ const now = new Date();
769
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
770
+ const yesterdayStart = todayStart - 86400000;
771
+ const weekStart = todayStart - 6 * 86400000;
772
+
773
+ const groups = {};
774
+ for (const s of historySessions.value) {
775
+ let label;
776
+ if (s.lastModified >= todayStart) label = 'Today';
777
+ else if (s.lastModified >= yesterdayStart) label = 'Yesterday';
778
+ else if (s.lastModified >= weekStart) label = 'This week';
779
+ else label = 'Earlier';
780
+ if (!groups[label]) groups[label] = [];
781
+ groups[label].push(s);
782
+ }
783
+ // Return in a consistent order
784
+ const order = ['Today', 'Yesterday', 'This week', 'Earlier'];
785
+ return order.filter(k => groups[k]).map(k => ({ label: k, sessions: groups[k] }));
786
+ });
787
+
788
+ // ── WebSocket ──
789
+ function connect() {
790
+ const sid = getSessionId();
791
+ if (!sid) {
792
+ status.value = 'No Session';
793
+ error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
794
+ return;
795
+ }
796
+ sessionId.value = sid;
797
+
798
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
799
+ const wsUrl = `${protocol}//${window.location.host}/?type=web&sessionId=${sid}`;
800
+ ws = new WebSocket(wsUrl);
801
+
802
+ ws.onopen = () => { error.value = ''; };
803
+
804
+ ws.onmessage = (event) => {
805
+ let msg;
806
+ const parsed = JSON.parse(event.data);
807
+
808
+ // The 'connected' message is always plain text (key exchange)
809
+ if (parsed.type === 'connected') {
810
+ msg = parsed;
811
+ if (typeof parsed.sessionKey === 'string') {
812
+ sessionKey = decodeKey(parsed.sessionKey);
813
+ }
814
+ } else if (sessionKey && isEncrypted(parsed)) {
815
+ msg = decrypt(parsed, sessionKey);
816
+ if (!msg) {
817
+ console.error('[WS] Failed to decrypt message');
818
+ return;
819
+ }
820
+ } else {
821
+ msg = parsed;
822
+ }
823
+
824
+ if (msg.type === 'connected') {
825
+ if (msg.agent) {
826
+ status.value = 'Connected';
827
+ agentName.value = msg.agent.name;
828
+ hostname.value = msg.agent.hostname || '';
829
+ workDir.value = msg.agent.workDir;
830
+ // Request session list once connected
831
+ requestSessionList();
832
+ } else {
833
+ status.value = 'Waiting';
834
+ error.value = 'Agent is not connected yet.';
835
+ }
836
+ } else if (msg.type === 'agent_disconnected') {
837
+ status.value = 'Disconnected';
838
+ agentName.value = '';
839
+ hostname.value = '';
840
+ workDir.value = '';
841
+ error.value = 'Agent has disconnected.';
842
+ isProcessing.value = false;
843
+ isCompacting.value = false;
844
+ } else if (msg.type === 'error') {
845
+ status.value = 'Error';
846
+ error.value = msg.message;
847
+ isProcessing.value = false;
848
+ isCompacting.value = false;
849
+ } else if (msg.type === 'claude_output') {
850
+ handleClaudeOutput(msg);
851
+ } else if (msg.type === 'context_compaction') {
852
+ if (msg.status === 'started') {
853
+ isCompacting.value = true;
854
+ } else if (msg.status === 'completed') {
855
+ isCompacting.value = false;
856
+ }
857
+ } else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
858
+ isProcessing.value = false;
859
+ isCompacting.value = false;
860
+ flushReveal();
861
+ finalizeStreamingMsg();
862
+ if (msg.type === 'execution_cancelled') {
863
+ messages.value.push({
864
+ id: ++messageIdCounter, role: 'system',
865
+ content: 'Generation stopped.', timestamp: new Date(),
866
+ });
867
+ scrollToBottom();
868
+ }
869
+ } else if (msg.type === 'ask_user_question') {
870
+ flushReveal();
871
+ finalizeStreamingMsg();
872
+ // Remove any preceding tool message for AskUserQuestion (tool_use arrives before control_request)
873
+ for (let i = messages.value.length - 1; i >= 0; i--) {
874
+ const m = messages.value[i];
875
+ if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
876
+ messages.value.splice(i, 1);
877
+ break;
878
+ }
879
+ // Only look back within recent messages
880
+ if (m.role === 'user') break;
881
+ }
882
+ // Render interactive question card
883
+ const questions = msg.questions || [];
884
+ const selectedAnswers = {};
885
+ const customTexts = {};
886
+ for (let i = 0; i < questions.length; i++) {
887
+ selectedAnswers[i] = questions[i].multiSelect ? [] : null;
888
+ customTexts[i] = '';
889
+ }
890
+ messages.value.push({
891
+ id: ++messageIdCounter,
892
+ role: 'ask-question',
893
+ requestId: msg.requestId,
894
+ questions,
895
+ answered: false,
896
+ selectedAnswers,
897
+ customTexts,
898
+ timestamp: new Date(),
899
+ });
900
+ scrollToBottom();
901
+ } else if (msg.type === 'sessions_list') {
902
+ historySessions.value = msg.sessions || [];
903
+ loadingSessions.value = false;
904
+ } else if (msg.type === 'conversation_resumed') {
905
+ currentClaudeSessionId.value = msg.claudeSessionId;
906
+ // Load history messages into the chat
907
+ if (msg.history && Array.isArray(msg.history)) {
908
+ for (const h of msg.history) {
909
+ if (h.role === 'user') {
910
+ if (isContextSummary(h.content)) {
911
+ messages.value.push({
912
+ id: ++messageIdCounter, role: 'context-summary',
913
+ content: h.content, contextExpanded: false,
914
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
915
+ });
916
+ } else {
917
+ messages.value.push({
918
+ id: ++messageIdCounter, role: 'user',
919
+ content: h.content, timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
920
+ });
921
+ }
922
+ } else if (h.role === 'assistant') {
923
+ // Merge with previous assistant message if consecutive
924
+ const last = messages.value[messages.value.length - 1];
925
+ if (last && last.role === 'assistant' && !last.isStreaming) {
926
+ last.content += '\n\n' + h.content;
927
+ } else {
928
+ messages.value.push({
929
+ id: ++messageIdCounter, role: 'assistant',
930
+ content: h.content, isStreaming: false,
931
+ timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
932
+ });
933
+ }
934
+ } else if (h.role === 'tool') {
935
+ messages.value.push({
936
+ id: ++messageIdCounter, role: 'tool',
937
+ toolId: h.toolId || '', toolName: h.toolName || 'unknown',
938
+ toolInput: h.toolInput || '', hasResult: true,
939
+ expanded: false, timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
940
+ });
941
+ }
942
+ }
943
+ scrollToBottom();
944
+ }
945
+ loadingHistory.value = false;
946
+ // Show ready-for-input hint
947
+ messages.value.push({
948
+ id: ++messageIdCounter, role: 'system',
949
+ content: 'Session restored. You can continue the conversation.',
950
+ timestamp: new Date(),
951
+ });
952
+ scrollToBottom();
953
+ } else if (msg.type === 'directory_listing') {
954
+ folderPickerLoading.value = false;
955
+ folderPickerEntries.value = (msg.entries || [])
956
+ .filter(e => e.type === 'directory')
957
+ .sort((a, b) => a.name.localeCompare(b.name));
958
+ if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
959
+ } else if (msg.type === 'workdir_changed') {
960
+ workDir.value = msg.workDir;
961
+ messages.value = [];
962
+ messageIdCounter = 0;
963
+ streamingMessageId = null;
964
+ pendingText = '';
965
+ currentClaudeSessionId.value = null;
966
+ isProcessing.value = false;
967
+ if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
968
+ messages.value.push({
969
+ id: ++messageIdCounter, role: 'system',
970
+ content: 'Working directory changed to: ' + msg.workDir,
971
+ timestamp: new Date(),
972
+ });
973
+ requestSessionList();
974
+ }
975
+ };
976
+
977
+ ws.onclose = () => {
978
+ sessionKey = null;
979
+ if (status.value === 'Connected' || status.value === 'Connecting...') {
980
+ status.value = 'Disconnected';
981
+ error.value = 'Connection to server lost.';
982
+ }
983
+ isProcessing.value = false;
984
+ isCompacting.value = false;
985
+ };
986
+
987
+ ws.onerror = () => {};
988
+ }
989
+
990
+ function handleClaudeOutput(msg) {
991
+ const data = msg.data;
992
+ if (!data) return;
993
+
994
+ if (data.type === 'content_block_delta' && data.delta) {
995
+ pendingText += data.delta;
996
+ startReveal();
997
+ return;
998
+ }
999
+
1000
+ if (data.type === 'tool_use' && data.tools) {
1001
+ flushReveal();
1002
+ finalizeStreamingMsg();
1003
+
1004
+ for (const tool of data.tools) {
1005
+ messages.value.push({
1006
+ id: ++messageIdCounter, role: 'tool',
1007
+ toolId: tool.id, toolName: tool.name || 'unknown',
1008
+ toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
1009
+ hasResult: false, expanded: false, timestamp: new Date(),
1010
+ });
1011
+ }
1012
+ scrollToBottom();
1013
+ return;
1014
+ }
1015
+
1016
+ if (data.type === 'user' && data.tool_use_result) {
1017
+ const result = data.tool_use_result;
1018
+ const results = Array.isArray(result) ? result : [result];
1019
+ for (const r of results) {
1020
+ const toolMsg = [...messages.value].reverse().find(
1021
+ m => m.role === 'tool' && m.toolId === r.tool_use_id
1022
+ );
1023
+ if (toolMsg) {
1024
+ toolMsg.toolOutput = typeof r.content === 'string'
1025
+ ? r.content : JSON.stringify(r.content, null, 2);
1026
+ toolMsg.hasResult = true;
1027
+ }
1028
+ }
1029
+ scrollToBottom();
1030
+ return;
1031
+ }
1032
+ }
1033
+
1034
+ // Apply syntax highlighting after DOM updates
1035
+ watch(messages, () => {
1036
+ nextTick(() => {
1037
+ if (typeof hljs !== 'undefined') {
1038
+ document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
1039
+ hljs.highlightElement(block);
1040
+ block.dataset.highlighted = 'true';
1041
+ });
1042
+ }
1043
+ });
1044
+ }, { deep: true });
1045
+
1046
+ onMounted(() => { connect(); });
1047
+ onUnmounted(() => { if (ws) ws.close(); });
1048
+
1049
+ return {
1050
+ status, agentName, hostname, workDir, sessionId, error,
1051
+ messages, inputText, isProcessing, isCompacting, canSend, inputRef,
1052
+ sendMessage, handleKeydown, cancelExecution,
1053
+ getRenderedContent, copyMessage, toggleTool, isPrevAssistant, toggleContextSummary,
1054
+ getToolIcon, getToolSummary, isEditTool, getEditDiffHtml, getFormattedToolInput, autoResize,
1055
+ // AskUserQuestion
1056
+ selectQuestionOption, submitQuestionAnswer, hasQuestionAnswer, getQuestionResponseSummary,
1057
+ // Theme
1058
+ theme, toggleTheme,
1059
+ // Sidebar
1060
+ sidebarOpen, historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
1061
+ toggleSidebar, resumeSession, newConversation, requestSessionList,
1062
+ formatRelativeTime, groupedSessions,
1063
+ // Folder picker
1064
+ folderPickerOpen, folderPickerPath, folderPickerEntries,
1065
+ folderPickerLoading, folderPickerSelected,
1066
+ openFolderPicker, folderPickerNavigateUp, folderPickerSelectItem,
1067
+ folderPickerEnter, confirmFolderPicker,
1068
+ // File attachments
1069
+ attachments, fileInputRef, dragOver,
1070
+ triggerFileInput, handleFileSelect, removeAttachment, formatFileSize,
1071
+ handleDragOver, handleDragLeave, handleDrop, handlePaste,
1072
+ };
1073
+ },
1074
+ template: `
1075
+ <div class="layout">
1076
+ <header class="top-bar">
1077
+ <div class="top-bar-left">
1078
+ <button class="sidebar-toggle" @click="toggleSidebar" title="Toggle sidebar">
1079
+ <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
1080
+ </button>
1081
+ <h1>AgentLink</h1>
1082
+ </div>
1083
+ <div class="top-bar-info">
1084
+ <span :class="['badge', status.toLowerCase()]">{{ status }}</span>
1085
+ <span v-if="agentName" class="agent-label">{{ agentName }}</span>
1086
+ <button class="theme-toggle" @click="toggleTheme" :title="theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
1087
+ <svg v-if="theme === 'dark'" viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0a.996.996 0 0 0 0-1.41l-1.06-1.06zm1.06-10.96a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/></svg>
1088
+ <svg v-else viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"/></svg>
1089
+ </button>
1090
+ </div>
1091
+ </header>
1092
+
1093
+ <div v-if="status === 'No Session' || (status !== 'Connected' && status !== 'Connecting...' && messages.length === 0)" class="center-card">
1094
+ <div class="status-card">
1095
+ <p class="status">
1096
+ <span class="label">Status:</span>
1097
+ <span :class="['badge', status.toLowerCase()]">{{ status }}</span>
1098
+ </p>
1099
+ <p v-if="agentName" class="info"><span class="label">Agent:</span> {{ agentName }}</p>
1100
+ <p v-if="workDir" class="info"><span class="label">Directory:</span> {{ workDir }}</p>
1101
+ <p v-if="sessionId" class="info muted"><span class="label">Session:</span> {{ sessionId }}</p>
1102
+ <p v-if="error" class="error-msg">{{ error }}</p>
1103
+ </div>
1104
+ </div>
1105
+
1106
+ <div v-else class="main-body">
1107
+ <!-- Sidebar -->
1108
+ <aside v-if="sidebarOpen" class="sidebar">
1109
+ <div class="sidebar-section">
1110
+ <div class="sidebar-workdir">
1111
+ <div v-if="hostname" class="sidebar-hostname">
1112
+ <svg class="sidebar-hostname-icon" viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M3.5 2A1.5 1.5 0 0 0 2 3.5v5A1.5 1.5 0 0 0 3.5 10h9A1.5 1.5 0 0 0 14 8.5v-5A1.5 1.5 0 0 0 12.5 2h-9zM.5 3.5A3 3 0 0 1 3.5.5h9A3 3 0 0 1 15.5 3.5v5a3 3 0 0 1-3 3h-9a3 3 0 0 1-3-3v-5zM5 13.25a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75zM3.25 15a.75.75 0 0 0 0 1.5h9.5a.75.75 0 0 0 0-1.5h-9.5z"/></svg>
1113
+ <span>{{ hostname }}</span>
1114
+ </div>
1115
+ <div class="sidebar-workdir-header">
1116
+ <div class="sidebar-workdir-label">Working Directory</div>
1117
+ <button class="sidebar-change-dir-btn" @click="openFolderPicker" title="Change working directory" :disabled="isProcessing">
1118
+ <svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
1119
+ </button>
1120
+ </div>
1121
+ <div class="sidebar-workdir-path" :title="workDir">{{ workDir }}</div>
1122
+ </div>
1123
+ </div>
1124
+
1125
+ <div class="sidebar-section sidebar-sessions">
1126
+ <div class="sidebar-section-header">
1127
+ <span>History</span>
1128
+ <button class="sidebar-refresh-btn" @click="requestSessionList" title="Refresh" :disabled="loadingSessions">
1129
+ <svg :class="{ spinning: loadingSessions }" viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
1130
+ </button>
1131
+ </div>
1132
+
1133
+ <button class="new-conversation-btn" @click="newConversation" :disabled="isProcessing">
1134
+ <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
1135
+ New conversation
1136
+ </button>
1137
+
1138
+ <div v-if="loadingSessions && historySessions.length === 0" class="sidebar-loading">
1139
+ Loading sessions...
1140
+ </div>
1141
+ <div v-else-if="historySessions.length === 0" class="sidebar-empty">
1142
+ No previous sessions found.
1143
+ </div>
1144
+ <div v-else class="session-list">
1145
+ <div v-for="group in groupedSessions" :key="group.label" class="session-group">
1146
+ <div class="session-group-label">{{ group.label }}</div>
1147
+ <div
1148
+ v-for="s in group.sessions" :key="s.sessionId"
1149
+ :class="['session-item', { active: currentClaudeSessionId === s.sessionId }]"
1150
+ @click="resumeSession(s)"
1151
+ :title="s.preview"
1152
+ >
1153
+ <div class="session-title">{{ s.title }}</div>
1154
+ <div class="session-meta">{{ formatRelativeTime(s.lastModified) }}</div>
1155
+ </div>
1156
+ </div>
1157
+ </div>
1158
+ </div>
1159
+ </aside>
1160
+
1161
+ <!-- Chat area -->
1162
+ <div class="chat-area">
1163
+ <div class="message-list">
1164
+ <div class="message-list-inner">
1165
+ <div v-if="messages.length === 0 && status === 'Connected' && !loadingHistory" class="empty-state">
1166
+ <div class="empty-state-icon">
1167
+ <svg viewBox="0 0 24 24" width="48" height="48"><path fill="currentColor" opacity="0.4" d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/></svg>
1168
+ </div>
1169
+ <p>Connected to <strong>{{ agentName }}</strong></p>
1170
+ <p class="muted">{{ workDir }}</p>
1171
+ <p class="muted" style="margin-top: 0.5rem;">Send a message to start.</p>
1172
+ </div>
1173
+
1174
+ <div v-if="loadingHistory" class="history-loading">
1175
+ <div class="history-loading-spinner"></div>
1176
+ <span>Loading conversation history...</span>
1177
+ </div>
1178
+
1179
+ <div v-for="msg in messages" :key="msg.id" :class="['message', 'message-' + msg.role]">
1180
+
1181
+ <!-- User message -->
1182
+ <template v-if="msg.role === 'user'">
1183
+ <div class="message-role-label user-label">You</div>
1184
+ <div class="message-bubble user-bubble">
1185
+ <div class="message-content">{{ msg.content }}</div>
1186
+ <div v-if="msg.attachments && msg.attachments.length" class="message-attachments">
1187
+ <div v-for="(att, ai) in msg.attachments" :key="ai" class="message-attachment-chip">
1188
+ <img v-if="att.isImage && att.thumbUrl" :src="att.thumbUrl" class="message-attachment-thumb" />
1189
+ <span v-else class="message-attachment-file-icon">
1190
+ <svg viewBox="0 0 16 16" width="12" height="12"><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.5z"/></svg>
1191
+ </span>
1192
+ <span>{{ att.name }}</span>
1193
+ </div>
1194
+ </div>
1195
+ </div>
1196
+ </template>
1197
+
1198
+ <!-- Assistant message (markdown) -->
1199
+ <template v-else-if="msg.role === 'assistant'">
1200
+ <div v-if="!isPrevAssistant(msg)" class="message-role-label assistant-label">Claude</div>
1201
+ <div :class="['message-bubble', 'assistant-bubble', { streaming: msg.isStreaming }]">
1202
+ <div class="message-actions">
1203
+ <button class="icon-btn" @click="copyMessage(msg)" :title="msg.copied ? 'Copied!' : 'Copy'">
1204
+ <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>
1205
+ <svg v-else 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>
1206
+ </button>
1207
+ </div>
1208
+ <div class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
1209
+ </div>
1210
+ </template>
1211
+
1212
+ <!-- Tool use block (collapsible) -->
1213
+ <div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
1214
+ <div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
1215
+ <span class="tool-icon" v-html="getToolIcon(msg.toolName)"></span>
1216
+ <span class="tool-name">{{ msg.toolName }}</span>
1217
+ <span class="tool-summary">{{ getToolSummary(msg) }}</span>
1218
+ <span class="tool-status-icon" v-if="msg.hasResult">\u{2713}</span>
1219
+ <span class="tool-status-icon running-dots" v-else>
1220
+ <span></span><span></span><span></span>
1221
+ </span>
1222
+ <span class="tool-toggle">{{ msg.expanded ? '\u{25B2}' : '\u{25BC}' }}</span>
1223
+ </div>
1224
+ <div v-if="msg.expanded" class="tool-expand">
1225
+ <div v-if="isEditTool(msg) && getEditDiffHtml(msg)" class="tool-diff" v-html="getEditDiffHtml(msg)"></div>
1226
+ <div v-else-if="getFormattedToolInput(msg)" class="tool-input-formatted" v-html="getFormattedToolInput(msg)"></div>
1227
+ <pre v-else-if="msg.toolInput" class="tool-block">{{ msg.toolInput }}</pre>
1228
+ <pre v-if="msg.toolOutput" class="tool-block tool-output">{{ msg.toolOutput }}</pre>
1229
+ </div>
1230
+ </div>
1231
+
1232
+ <!-- AskUserQuestion interactive card -->
1233
+ <div v-else-if="msg.role === 'ask-question'" class="ask-question-wrapper">
1234
+ <div v-if="!msg.answered" class="ask-question-card">
1235
+ <div v-for="(q, qi) in msg.questions" :key="qi" class="ask-question-block">
1236
+ <div v-if="q.header" class="ask-question-header">{{ q.header }}</div>
1237
+ <div class="ask-question-text">{{ q.question }}</div>
1238
+ <div class="ask-question-options">
1239
+ <div
1240
+ v-for="(opt, oi) in q.options" :key="oi"
1241
+ :class="['ask-question-option', {
1242
+ selected: q.multiSelect
1243
+ ? (msg.selectedAnswers[qi] || []).includes(opt.label)
1244
+ : msg.selectedAnswers[qi] === opt.label
1245
+ }]"
1246
+ @click="selectQuestionOption(msg, qi, opt.label)"
1247
+ >
1248
+ <div class="ask-option-label">{{ opt.label }}</div>
1249
+ <div v-if="opt.description" class="ask-option-desc">{{ opt.description }}</div>
1250
+ </div>
1251
+ </div>
1252
+ <div class="ask-question-custom">
1253
+ <input
1254
+ type="text"
1255
+ v-model="msg.customTexts[qi]"
1256
+ placeholder="Or type a custom response..."
1257
+ @input="msg.selectedAnswers[qi] = q.multiSelect ? [] : null"
1258
+ @keydown.enter="hasQuestionAnswer(msg) && submitQuestionAnswer(msg)"
1259
+ />
1260
+ </div>
1261
+ </div>
1262
+ <div class="ask-question-actions">
1263
+ <button class="ask-question-submit" :disabled="!hasQuestionAnswer(msg)" @click="submitQuestionAnswer(msg)">
1264
+ Submit
1265
+ </button>
1266
+ </div>
1267
+ </div>
1268
+ <div v-else class="ask-question-answered">
1269
+ <span class="ask-answered-icon">\u{2713}</span>
1270
+ <span class="ask-answered-text">{{ getQuestionResponseSummary(msg) }}</span>
1271
+ </div>
1272
+ </div>
1273
+
1274
+ <!-- Context summary (collapsed by default) -->
1275
+ <div v-else-if="msg.role === 'context-summary'" class="context-summary-wrapper">
1276
+ <div class="context-summary-bar" @click="toggleContextSummary(msg)">
1277
+ <svg class="context-summary-icon" viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/></svg>
1278
+ <span class="context-summary-label">Context continued from previous conversation</span>
1279
+ <span class="context-summary-toggle">{{ msg.contextExpanded ? 'Hide' : 'Show' }}</span>
1280
+ </div>
1281
+ <div v-if="msg.contextExpanded" class="context-summary-body">
1282
+ <div class="markdown-body" v-html="getRenderedContent({ role: 'assistant', content: msg.content })"></div>
1283
+ </div>
1284
+ </div>
1285
+
1286
+ <!-- System message -->
1287
+ <div v-else-if="msg.role === 'system'" class="system-msg">
1288
+ {{ msg.content }}
1289
+ </div>
1290
+ </div>
1291
+
1292
+ <div v-if="isProcessing && !isCompacting && !messages.some(m => m.isStreaming)" class="typing-indicator">
1293
+ <span></span><span></span><span></span>
1294
+ </div>
1295
+
1296
+ <div v-if="isCompacting" class="compacting-banner">
1297
+ <div class="compacting-spinner"></div>
1298
+ <span class="compacting-text">Context compacting in progress...</span>
1299
+ </div>
1300
+ </div>
1301
+ </div>
1302
+
1303
+ <div class="input-area">
1304
+ <input
1305
+ type="file"
1306
+ ref="fileInputRef"
1307
+ multiple
1308
+ style="display: none"
1309
+ @change="handleFileSelect"
1310
+ accept="image/*,text/*,.pdf,.json,.md,.py,.js,.ts,.tsx,.jsx,.css,.html,.xml,.yaml,.yml,.toml,.sh,.sql,.csv"
1311
+ />
1312
+ <div
1313
+ :class="['input-card', { 'drag-over': dragOver }]"
1314
+ @dragover="handleDragOver"
1315
+ @dragleave="handleDragLeave"
1316
+ @drop="handleDrop"
1317
+ >
1318
+ <textarea
1319
+ ref="inputRef"
1320
+ v-model="inputText"
1321
+ @keydown="handleKeydown"
1322
+ @input="autoResize"
1323
+ @paste="handlePaste"
1324
+ :disabled="status !== 'Connected' || isCompacting"
1325
+ :placeholder="isCompacting ? 'Context compacting in progress...' : 'Send a message...'"
1326
+ rows="1"
1327
+ ></textarea>
1328
+ <div v-if="attachments.length > 0" class="attachment-bar">
1329
+ <div v-for="(att, i) in attachments" :key="i" class="attachment-chip">
1330
+ <img v-if="att.isImage && att.thumbUrl" :src="att.thumbUrl" class="attachment-thumb" />
1331
+ <div v-else class="attachment-file-icon">
1332
+ <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.5z"/></svg>
1333
+ </div>
1334
+ <div class="attachment-info">
1335
+ <div class="attachment-name">{{ att.name }}</div>
1336
+ <div class="attachment-size">{{ formatFileSize(att.size) }}</div>
1337
+ </div>
1338
+ <button class="attachment-remove" @click="removeAttachment(i)" title="Remove">&times;</button>
1339
+ </div>
1340
+ </div>
1341
+ <div class="input-bottom-row">
1342
+ <button class="attach-btn" @click="triggerFileInput" :disabled="status !== 'Connected' || isCompacting || attachments.length >= 5" title="Attach files">
1343
+ <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5a2.5 2.5 0 0 1 5 0v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5a2.5 2.5 0 0 0 5 0V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>
1344
+ </button>
1345
+ <button v-if="isProcessing" @click="cancelExecution" class="send-btn stop-btn" title="Stop generation">
1346
+ <svg viewBox="0 0 24 24" width="14" height="14"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
1347
+ </button>
1348
+ <button v-else @click="sendMessage" :disabled="!canSend" class="send-btn" title="Send">
1349
+ <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
1350
+ </button>
1351
+ </div>
1352
+ </div>
1353
+ </div>
1354
+ </div>
1355
+ </div>
1356
+
1357
+ <!-- Folder Picker Modal -->
1358
+ <div class="folder-picker-overlay" v-if="folderPickerOpen" @click.self="folderPickerOpen = false">
1359
+ <div class="folder-picker-dialog">
1360
+ <div class="folder-picker-header">
1361
+ <span>Select Working Directory</span>
1362
+ <button class="folder-picker-close" @click="folderPickerOpen = false">&times;</button>
1363
+ </div>
1364
+ <div class="folder-picker-nav">
1365
+ <button class="folder-picker-up" @click="folderPickerNavigateUp" :disabled="!folderPickerPath" title="Go to parent directory">
1366
+ <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
1367
+ </button>
1368
+ <span class="folder-picker-current" :title="folderPickerPath">{{ folderPickerPath || 'Drives' }}</span>
1369
+ </div>
1370
+ <div class="folder-picker-list">
1371
+ <div v-if="folderPickerLoading" class="folder-picker-loading">
1372
+ <div class="history-loading-spinner"></div>
1373
+ <span>Loading...</span>
1374
+ </div>
1375
+ <template v-else>
1376
+ <div
1377
+ v-for="entry in folderPickerEntries" :key="entry.name"
1378
+ :class="['folder-picker-item', { 'folder-picker-selected': folderPickerSelected === entry.name }]"
1379
+ @click="folderPickerSelectItem(entry)"
1380
+ @dblclick="folderPickerEnter(entry)"
1381
+ >
1382
+ <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
1383
+ <span>{{ entry.name }}</span>
1384
+ </div>
1385
+ <div v-if="folderPickerEntries.length === 0" class="folder-picker-empty">No subdirectories found.</div>
1386
+ </template>
1387
+ </div>
1388
+ <div class="folder-picker-footer">
1389
+ <button class="folder-picker-cancel" @click="folderPickerOpen = false">Cancel</button>
1390
+ <button class="folder-picker-confirm" @click="confirmFolderPicker" :disabled="!folderPickerPath">Open</button>
1391
+ </div>
1392
+ </div>
1393
+ </div>
1394
+ </div>
1395
+ `
1396
+ };
1397
+
1398
+ const app = createApp(App);
1399
+ app.mount('#app');