@agent-link/server 0.1.84 → 0.1.86

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,733 +1,733 @@
1
- // ── AgentLink Web UI — Main coordinator ──────────────────────────────────────
2
- const { createApp, ref, nextTick, onMounted, onUnmounted, computed, watch } = Vue;
3
-
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';
19
-
20
- // ── App ─────────────────────────────────────────────────────────────────────
21
- const App = {
22
- setup() {
23
- // ── Reactive state ──
24
- const status = ref('Connecting...');
25
- const agentName = ref('');
26
- const hostname = ref('');
27
- const workDir = ref('');
28
- const sessionId = ref('');
29
- const error = ref('');
30
- const serverVersion = ref('');
31
- const agentVersion = ref('');
32
- const messages = ref([]);
33
- const visibleLimit = ref(50);
34
- const hasMoreMessages = computed(() => messages.value.length > visibleLimit.value);
35
- const visibleMessages = computed(() => {
36
- if (messages.value.length <= visibleLimit.value) return messages.value;
37
- return messages.value.slice(messages.value.length - visibleLimit.value);
38
- });
39
- function loadMoreMessages() {
40
- const el = document.querySelector('.message-list');
41
- const prevHeight = el ? el.scrollHeight : 0;
42
- visibleLimit.value += 50;
43
- nextTick(() => {
44
- if (el) el.scrollTop += el.scrollHeight - prevHeight;
45
- });
46
- }
47
- const inputText = ref('');
48
- const isProcessing = ref(false);
49
- const isCompacting = ref(false);
50
- const inputRef = ref(null);
51
-
52
- // Sidebar state
53
- const sidebarOpen = ref(window.innerWidth > 768);
54
- const historySessions = ref([]);
55
- const currentClaudeSessionId = ref(null);
56
- const needsResume = ref(false);
57
- const loadingSessions = ref(false);
58
- const loadingHistory = ref(false);
59
-
60
- // Folder picker state
61
- const folderPickerOpen = ref(false);
62
- const folderPickerPath = ref('');
63
- const folderPickerEntries = ref([]);
64
- const folderPickerLoading = ref(false);
65
- const folderPickerSelected = ref('');
66
-
67
- // Delete confirmation dialog state
68
- const deleteConfirmOpen = ref(false);
69
- const deleteConfirmTitle = ref('');
70
-
71
- // Working directory history
72
- const workdirHistory = ref([]);
73
-
74
- // Authentication state
75
- const authRequired = ref(false);
76
- const authPassword = ref('');
77
- const authError = ref('');
78
- const authAttempts = ref(null);
79
- const authLocked = ref(false);
80
-
81
- // File attachment state
82
- const attachments = ref([]);
83
- const fileInputRef = ref(null);
84
- const dragOver = ref(false);
85
-
86
- // Theme
87
- const theme = ref(localStorage.getItem('agentlink-theme') || 'light');
88
- function applyTheme() {
89
- document.documentElement.setAttribute('data-theme', theme.value);
90
- const link = document.getElementById('hljs-theme');
91
- if (link) link.href = theme.value === 'light'
92
- ? '/vendor/github.min.css'
93
- : '/vendor/github-dark.min.css';
94
- }
95
- function toggleTheme() {
96
- theme.value = theme.value === 'dark' ? 'light' : 'dark';
97
- localStorage.setItem('agentlink-theme', theme.value);
98
- applyTheme();
99
- }
100
- applyTheme();
101
-
102
- // ── Scroll management ──
103
- let _scrollTimer = null;
104
- let _userScrolledUp = false;
105
-
106
- function onMessageListScroll(e) {
107
- const el = e.target;
108
- _userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > 80;
109
- }
110
-
111
- function scrollToBottom(force) {
112
- if (_userScrolledUp && !force) return;
113
- if (_scrollTimer) return;
114
- _scrollTimer = setTimeout(() => {
115
- _scrollTimer = null;
116
- const el = document.querySelector('.message-list');
117
- if (el) el.scrollTop = el.scrollHeight;
118
- }, 50);
119
- }
120
-
121
- // ── Highlight.js scheduling ──
122
- let _hlTimer = null;
123
- function scheduleHighlight() {
124
- if (_hlTimer) return;
125
- _hlTimer = setTimeout(() => {
126
- _hlTimer = null;
127
- if (typeof hljs !== 'undefined') {
128
- document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
129
- hljs.highlightElement(block);
130
- block.dataset.highlighted = 'true';
131
- });
132
- }
133
- }, 300);
134
- }
135
-
136
- // ── Create module instances ──
137
-
138
- const streaming = createStreaming({ messages, scrollToBottom });
139
-
140
- const fileAttach = createFileAttachments(attachments, fileInputRef, dragOver);
141
-
142
- // Sidebar needs wsSend, but connection creates wsSend.
143
- // Resolve circular dependency with a forwarding function.
144
- let _wsSend = () => {};
145
-
146
- const sidebar = createSidebar({
147
- wsSend: (msg) => _wsSend(msg),
148
- messages, isProcessing, sidebarOpen,
149
- historySessions, currentClaudeSessionId, needsResume,
150
- loadingSessions, loadingHistory, workDir, visibleLimit,
151
- folderPickerOpen, folderPickerPath, folderPickerEntries,
152
- folderPickerLoading, folderPickerSelected, streaming,
153
- deleteConfirmOpen, deleteConfirmTitle,
154
- hostname, workdirHistory,
155
- });
156
-
157
- const { connect, wsSend, closeWs, submitPassword } = createConnection({
158
- status, agentName, hostname, workDir, sessionId, error,
159
- serverVersion, agentVersion,
160
- messages, isProcessing, isCompacting, visibleLimit,
161
- historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
162
- folderPickerLoading, folderPickerEntries, folderPickerPath,
163
- authRequired, authPassword, authError, authAttempts, authLocked,
164
- streaming, sidebar, scrollToBottom,
165
- });
166
-
167
- // Now wire up the forwarding function
168
- _wsSend = wsSend;
169
-
170
- // ── Computed ──
171
- const canSend = computed(() =>
172
- status.value === 'Connected' && (inputText.value.trim() || attachments.value.length > 0) && !isProcessing.value && !isCompacting.value
173
- && !messages.value.some(m => m.role === 'ask-question' && !m.answered)
174
- );
175
-
176
- // ── Auto-resize textarea ──
177
- function autoResize() {
178
- const ta = inputRef.value;
179
- if (ta) {
180
- ta.style.height = 'auto';
181
- ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
182
- }
183
- }
184
-
185
- // ── Send message ──
186
- function sendMessage() {
187
- if (!canSend.value) return;
188
-
189
- const text = inputText.value.trim();
190
- const files = attachments.value.slice();
191
- inputText.value = '';
192
- if (inputRef.value) inputRef.value.style.height = 'auto';
193
-
194
- const msgAttachments = files.map(f => ({
195
- name: f.name, size: f.size, isImage: f.isImage, thumbUrl: f.thumbUrl,
196
- }));
197
-
198
- messages.value.push({
199
- id: streaming.nextId(), role: 'user',
200
- content: text || (files.length > 0 ? `[${files.length} file${files.length > 1 ? 's' : ''} attached]` : ''),
201
- attachments: msgAttachments.length > 0 ? msgAttachments : undefined,
202
- timestamp: new Date(),
203
- });
204
- isProcessing.value = true;
205
- scrollToBottom(true);
206
-
207
- const payload = { type: 'chat', prompt: text || '(see attached files)' };
208
- if (needsResume.value && currentClaudeSessionId.value) {
209
- payload.resumeSessionId = currentClaudeSessionId.value;
210
- needsResume.value = false;
211
- }
212
- if (files.length > 0) {
213
- payload.files = files.map(f => ({
214
- name: f.name, mimeType: f.mimeType, data: f.data,
215
- }));
216
- }
217
- wsSend(payload);
218
- attachments.value = [];
219
- }
220
-
221
- function cancelExecution() {
222
- if (!isProcessing.value) return;
223
- wsSend({ type: 'cancel_execution' });
224
- }
225
-
226
- function handleKeydown(e) {
227
- if (e.key === 'Enter' && !e.shiftKey) {
228
- e.preventDefault();
229
- sendMessage();
230
- }
231
- }
232
-
233
- // ── Template adapter wrappers ──
234
- // These adapt the module function signatures to the template's call conventions.
235
- function _isPrevAssistant(idx) {
236
- return isPrevAssistant(visibleMessages.value, idx);
237
- }
238
-
239
- function _submitQuestionAnswer(msg) {
240
- submitQuestionAnswer(msg, wsSend);
241
- }
242
-
243
- // ── Watchers ──
244
- const messageCount = computed(() => messages.value.length);
245
- watch(messageCount, () => { nextTick(scheduleHighlight); });
246
-
247
- watch(hostname, (name) => {
248
- document.title = name ? `${name} — AgentLink` : 'AgentLink';
249
- });
250
-
251
- // ── Lifecycle ──
252
- onMounted(() => { connect(scheduleHighlight); });
253
- onUnmounted(() => { closeWs(); streaming.cleanup(); });
254
-
255
- return {
256
- status, agentName, hostname, workDir, sessionId, error,
257
- serverVersion, agentVersion,
258
- messages, visibleMessages, hasMoreMessages, loadMoreMessages,
259
- inputText, isProcessing, isCompacting, canSend, inputRef,
260
- sendMessage, handleKeydown, cancelExecution, onMessageListScroll,
261
- getRenderedContent, copyMessage, toggleTool,
262
- isPrevAssistant: _isPrevAssistant,
263
- toggleContextSummary, formatTimestamp,
264
- getToolIcon, getToolSummary, isEditTool, getEditDiffHtml, getFormattedToolInput, autoResize,
265
- // AskUserQuestion
266
- selectQuestionOption,
267
- submitQuestionAnswer: _submitQuestionAnswer,
268
- hasQuestionAnswer, getQuestionResponseSummary,
269
- // Theme
270
- theme, toggleTheme,
271
- // Sidebar
272
- sidebarOpen, historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
273
- toggleSidebar: sidebar.toggleSidebar,
274
- resumeSession: sidebar.resumeSession,
275
- newConversation: sidebar.newConversation,
276
- requestSessionList: sidebar.requestSessionList,
277
- formatRelativeTime,
278
- groupedSessions: sidebar.groupedSessions,
279
- // Folder picker
280
- folderPickerOpen, folderPickerPath, folderPickerEntries,
281
- folderPickerLoading, folderPickerSelected,
282
- openFolderPicker: sidebar.openFolderPicker,
283
- folderPickerNavigateUp: sidebar.folderPickerNavigateUp,
284
- folderPickerSelectItem: sidebar.folderPickerSelectItem,
285
- folderPickerEnter: sidebar.folderPickerEnter,
286
- folderPickerGoToPath: sidebar.folderPickerGoToPath,
287
- confirmFolderPicker: sidebar.confirmFolderPicker,
288
- // Delete session
289
- deleteConfirmOpen, deleteConfirmTitle,
290
- deleteSession: sidebar.deleteSession,
291
- confirmDeleteSession: sidebar.confirmDeleteSession,
292
- cancelDeleteSession: sidebar.cancelDeleteSession,
293
- // Working directory history
294
- filteredWorkdirHistory: sidebar.filteredWorkdirHistory,
295
- switchToWorkdir: sidebar.switchToWorkdir,
296
- removeFromWorkdirHistory: sidebar.removeFromWorkdirHistory,
297
- // Authentication
298
- authRequired, authPassword, authError, authAttempts, authLocked,
299
- submitPassword,
300
- // File attachments
301
- attachments, fileInputRef, dragOver,
302
- triggerFileInput: fileAttach.triggerFileInput,
303
- handleFileSelect: fileAttach.handleFileSelect,
304
- removeAttachment: fileAttach.removeAttachment,
305
- formatFileSize,
306
- handleDragOver: fileAttach.handleDragOver,
307
- handleDragLeave: fileAttach.handleDragLeave,
308
- handleDrop: fileAttach.handleDrop,
309
- handlePaste: fileAttach.handlePaste,
310
- };
311
- },
312
- template: `
313
- <div class="layout">
314
- <header class="top-bar">
315
- <div class="top-bar-left">
316
- <button class="sidebar-toggle" @click="toggleSidebar" title="Toggle sidebar">
317
- <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
318
- </button>
319
- <h1>AgentLink</h1>
320
- </div>
321
- <div class="top-bar-info">
322
- <span :class="['badge', status.toLowerCase()]">{{ status }}</span>
323
- <span v-if="agentName" class="agent-label">{{ agentName }}</span>
324
- <button class="theme-toggle" @click="toggleTheme" :title="theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
325
- <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>
326
- <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>
327
- </button>
328
- </div>
329
- </header>
330
-
331
- <div v-if="status === 'No Session' || (status !== 'Connected' && status !== 'Connecting...' && status !== 'Reconnecting...' && messages.length === 0)" class="center-card">
332
- <div class="status-card">
333
- <p class="status">
334
- <span class="label">Status:</span>
335
- <span :class="['badge', status.toLowerCase()]">{{ status }}</span>
336
- </p>
337
- <p v-if="agentName" class="info"><span class="label">Agent:</span> {{ agentName }}</p>
338
- <p v-if="workDir" class="info"><span class="label">Directory:</span> {{ workDir }}</p>
339
- <p v-if="sessionId" class="info muted"><span class="label">Session:</span> {{ sessionId }}</p>
340
- <p v-if="error" class="error-msg">{{ error }}</p>
341
- </div>
342
- </div>
343
-
344
- <div v-else class="main-body">
345
- <!-- Sidebar backdrop (mobile) -->
346
- <div v-if="sidebarOpen" class="sidebar-backdrop" @click="toggleSidebar"></div>
347
- <!-- Sidebar -->
348
- <aside v-if="sidebarOpen" class="sidebar">
349
- <div class="sidebar-section">
350
- <div class="sidebar-workdir">
351
- <div v-if="hostname" class="sidebar-hostname">
352
- <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>
353
- <span>{{ hostname }}</span>
354
- </div>
355
- <div class="sidebar-workdir-header">
356
- <div class="sidebar-workdir-label">Working Directory</div>
357
- <button class="sidebar-change-dir-btn" @click="openFolderPicker" title="Change working directory" :disabled="isProcessing">
358
- <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>
359
- </button>
360
- </div>
361
- <div class="sidebar-workdir-path" :title="workDir">{{ workDir }}</div>
362
- <div v-if="filteredWorkdirHistory.length > 0" class="workdir-history">
363
- <div class="workdir-history-label">Recent Directories</div>
364
- <div class="workdir-history-list">
365
- <div
366
- v-for="path in filteredWorkdirHistory" :key="path"
367
- class="workdir-history-item"
368
- @click="switchToWorkdir(path)"
369
- :title="path"
370
- >
371
- <span class="workdir-history-path">{{ path }}</span>
372
- <button class="workdir-history-delete" @click.stop="removeFromWorkdirHistory(path)" title="Remove from history">
373
- <svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
374
- </button>
375
- </div>
376
- </div>
377
- </div>
378
- </div>
379
- </div>
380
-
381
- <div class="sidebar-section sidebar-sessions">
382
- <div class="sidebar-section-header">
383
- <span>History</span>
384
- <button class="sidebar-refresh-btn" @click="requestSessionList" title="Refresh" :disabled="loadingSessions">
385
- <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>
386
- </button>
387
- </div>
388
-
389
- <button class="new-conversation-btn" @click="newConversation" :disabled="isProcessing">
390
- <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
391
- New conversation
392
- </button>
393
-
394
- <div v-if="loadingSessions && historySessions.length === 0" class="sidebar-loading">
395
- Loading sessions...
396
- </div>
397
- <div v-else-if="historySessions.length === 0" class="sidebar-empty">
398
- No previous sessions found.
399
- </div>
400
- <div v-else class="session-list">
401
- <div v-for="group in groupedSessions" :key="group.label" class="session-group">
402
- <div class="session-group-label">{{ group.label }}</div>
403
- <div
404
- v-for="s in group.sessions" :key="s.sessionId"
405
- :class="['session-item', { active: currentClaudeSessionId === s.sessionId }]"
406
- @click="resumeSession(s)"
407
- :title="s.preview"
408
- >
409
- <div class="session-title">{{ s.title }}</div>
410
- <div class="session-meta">
411
- <span>{{ formatRelativeTime(s.lastModified) }}</span>
412
- <button
413
- v-if="currentClaudeSessionId !== s.sessionId"
414
- class="session-delete-btn"
415
- @click.stop="deleteSession(s)"
416
- title="Delete session"
417
- >
418
- <svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
419
- </button>
420
- </div>
421
- </div>
422
- </div>
423
- </div>
424
- </div>
425
-
426
- <div v-if="serverVersion || agentVersion" class="sidebar-version-footer">
427
- <span v-if="serverVersion">server {{ serverVersion }}</span>
428
- <span v-if="serverVersion && agentVersion" class="sidebar-version-sep">/</span>
429
- <span v-if="agentVersion">agent {{ agentVersion }}</span>
430
- </div>
431
- </aside>
432
-
433
- <!-- Chat area -->
434
- <div class="chat-area">
435
- <div class="message-list" @scroll="onMessageListScroll">
436
- <div class="message-list-inner">
437
- <div v-if="messages.length === 0 && status === 'Connected' && !loadingHistory" class="empty-state">
438
- <div class="empty-state-icon">
439
- <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>
440
- </div>
441
- <p>Connected to <strong>{{ agentName }}</strong></p>
442
- <p class="muted">{{ workDir }}</p>
443
- <p class="muted" style="margin-top: 0.5rem;">Send a message to start.</p>
444
- </div>
445
-
446
- <div v-if="loadingHistory" class="history-loading">
447
- <div class="history-loading-spinner"></div>
448
- <span>Loading conversation history...</span>
449
- </div>
450
-
451
- <div v-if="hasMoreMessages" class="load-more-wrapper">
452
- <button class="load-more-btn" @click="loadMoreMessages">Load earlier messages</button>
453
- </div>
454
-
455
- <div v-for="(msg, msgIdx) in visibleMessages" :key="msg.id" :class="['message', 'message-' + msg.role]">
456
-
457
- <!-- User message -->
458
- <template v-if="msg.role === 'user'">
459
- <div class="message-role-label user-label">You</div>
460
- <div class="message-bubble user-bubble" :title="formatTimestamp(msg.timestamp)">
461
- <div class="message-content">{{ msg.content }}</div>
462
- <div v-if="msg.attachments && msg.attachments.length" class="message-attachments">
463
- <div v-for="(att, ai) in msg.attachments" :key="ai" class="message-attachment-chip">
464
- <img v-if="att.isImage && att.thumbUrl" :src="att.thumbUrl" class="message-attachment-thumb" />
465
- <span v-else class="message-attachment-file-icon">
466
- <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>
467
- </span>
468
- <span>{{ att.name }}</span>
469
- </div>
470
- </div>
471
- </div>
472
- </template>
473
-
474
- <!-- Assistant message (markdown) -->
475
- <template v-else-if="msg.role === 'assistant'">
476
- <div v-if="!isPrevAssistant(msgIdx)" class="message-role-label assistant-label">Claude</div>
477
- <div :class="['message-bubble', 'assistant-bubble', { streaming: msg.isStreaming }]" :title="formatTimestamp(msg.timestamp)">
478
- <div class="message-actions">
479
- <button class="icon-btn" @click="copyMessage(msg)" :title="msg.copied ? 'Copied!' : 'Copy'">
480
- <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>
481
- <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>
482
- </button>
483
- </div>
484
- <div class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
485
- </div>
486
- </template>
487
-
488
- <!-- Tool use block (collapsible) -->
489
- <div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
490
- <div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
491
- <span class="tool-icon" v-html="getToolIcon(msg.toolName)"></span>
492
- <span class="tool-name">{{ msg.toolName }}</span>
493
- <span class="tool-summary">{{ getToolSummary(msg) }}</span>
494
- <span class="tool-status-icon" v-if="msg.hasResult">\u{2713}</span>
495
- <span class="tool-status-icon running-dots" v-else>
496
- <span></span><span></span><span></span>
497
- </span>
498
- <span class="tool-toggle">{{ msg.expanded ? '\u{25B2}' : '\u{25BC}' }}</span>
499
- </div>
500
- <div v-show="msg.expanded" class="tool-expand">
501
- <div v-if="isEditTool(msg) && getEditDiffHtml(msg)" class="tool-diff" v-html="getEditDiffHtml(msg)"></div>
502
- <div v-else-if="getFormattedToolInput(msg)" class="tool-input-formatted" v-html="getFormattedToolInput(msg)"></div>
503
- <pre v-else-if="msg.toolInput" class="tool-block">{{ msg.toolInput }}</pre>
504
- <pre v-if="msg.toolOutput" class="tool-block tool-output">{{ msg.toolOutput }}</pre>
505
- </div>
506
- </div>
507
-
508
- <!-- AskUserQuestion interactive card -->
509
- <div v-else-if="msg.role === 'ask-question'" class="ask-question-wrapper">
510
- <div v-if="!msg.answered" class="ask-question-card">
511
- <div v-for="(q, qi) in msg.questions" :key="qi" class="ask-question-block">
512
- <div v-if="q.header" class="ask-question-header">{{ q.header }}</div>
513
- <div class="ask-question-text">{{ q.question }}</div>
514
- <div class="ask-question-options">
515
- <div
516
- v-for="(opt, oi) in q.options" :key="oi"
517
- :class="['ask-question-option', {
518
- selected: q.multiSelect
519
- ? (msg.selectedAnswers[qi] || []).includes(opt.label)
520
- : msg.selectedAnswers[qi] === opt.label
521
- }]"
522
- @click="selectQuestionOption(msg, qi, opt.label)"
523
- >
524
- <div class="ask-option-label">{{ opt.label }}</div>
525
- <div v-if="opt.description" class="ask-option-desc">{{ opt.description }}</div>
526
- </div>
527
- </div>
528
- <div class="ask-question-custom">
529
- <input
530
- type="text"
531
- v-model="msg.customTexts[qi]"
532
- placeholder="Or type a custom response..."
533
- @input="msg.selectedAnswers[qi] = q.multiSelect ? [] : null"
534
- @keydown.enter="hasQuestionAnswer(msg) && submitQuestionAnswer(msg)"
535
- />
536
- </div>
537
- </div>
538
- <div class="ask-question-actions">
539
- <button class="ask-question-submit" :disabled="!hasQuestionAnswer(msg)" @click="submitQuestionAnswer(msg)">
540
- Submit
541
- </button>
542
- </div>
543
- </div>
544
- <div v-else class="ask-question-answered">
545
- <span class="ask-answered-icon">\u{2713}</span>
546
- <span class="ask-answered-text">{{ getQuestionResponseSummary(msg) }}</span>
547
- </div>
548
- </div>
549
-
550
- <!-- Context summary (collapsed by default) -->
551
- <div v-else-if="msg.role === 'context-summary'" class="context-summary-wrapper">
552
- <div class="context-summary-bar" @click="toggleContextSummary(msg)">
553
- <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>
554
- <span class="context-summary-label">Context continued from previous conversation</span>
555
- <span class="context-summary-toggle">{{ msg.contextExpanded ? 'Hide' : 'Show' }}</span>
556
- </div>
557
- <div v-if="msg.contextExpanded" class="context-summary-body">
558
- <div class="markdown-body" v-html="getRenderedContent({ role: 'assistant', content: msg.content })"></div>
559
- </div>
560
- </div>
561
-
562
- <!-- System message -->
563
- <div v-else-if="msg.role === 'system'" :class="['system-msg', { 'compact-msg': msg.isCompactStart, 'command-output-msg': msg.isCommandOutput, 'error-msg': msg.isError }]">
564
- <template v-if="msg.isCompactStart && !msg.compactDone">
565
- <span class="compact-inline-spinner"></span>
566
- </template>
567
- <template v-if="msg.isCompactStart && msg.compactDone">
568
- <span class="compact-done-icon">✓</span>
569
- </template>
570
- <div v-if="msg.isCommandOutput" class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
571
- <template v-else>{{ msg.content }}</template>
572
- </div>
573
- </div>
574
-
575
- <div v-if="isProcessing && !messages.some(m => m.isStreaming)" class="typing-indicator">
576
- <span></span><span></span><span></span>
577
- </div>
578
- </div>
579
- </div>
580
-
581
- <div class="input-area">
582
- <input
583
- type="file"
584
- ref="fileInputRef"
585
- multiple
586
- style="display: none"
587
- @change="handleFileSelect"
588
- accept="image/*,text/*,.pdf,.json,.md,.py,.js,.ts,.tsx,.jsx,.css,.html,.xml,.yaml,.yml,.toml,.sh,.sql,.csv"
589
- />
590
- <div
591
- :class="['input-card', { 'drag-over': dragOver }]"
592
- @dragover="handleDragOver"
593
- @dragleave="handleDragLeave"
594
- @drop="handleDrop"
595
- >
596
- <textarea
597
- ref="inputRef"
598
- v-model="inputText"
599
- @keydown="handleKeydown"
600
- @input="autoResize"
601
- @paste="handlePaste"
602
- :disabled="status !== 'Connected' || isCompacting"
603
- :placeholder="isCompacting ? 'Context compacting in progress...' : 'Send a message · Enter to send'"
604
- rows="1"
605
- ></textarea>
606
- <div v-if="attachments.length > 0" class="attachment-bar">
607
- <div v-for="(att, i) in attachments" :key="i" class="attachment-chip">
608
- <img v-if="att.isImage && att.thumbUrl" :src="att.thumbUrl" class="attachment-thumb" />
609
- <div v-else class="attachment-file-icon">
610
- <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>
611
- </div>
612
- <div class="attachment-info">
613
- <div class="attachment-name">{{ att.name }}</div>
614
- <div class="attachment-size">{{ formatFileSize(att.size) }}</div>
615
- </div>
616
- <button class="attachment-remove" @click="removeAttachment(i)" title="Remove">&times;</button>
617
- </div>
618
- </div>
619
- <div class="input-bottom-row">
620
- <button class="attach-btn" @click="triggerFileInput" :disabled="status !== 'Connected' || isCompacting || attachments.length >= 5" title="Attach files">
621
- <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>
622
- </button>
623
- <button v-if="isProcessing" @click="cancelExecution" class="send-btn stop-btn" title="Stop generation">
624
- <svg viewBox="0 0 24 24" width="14" height="14"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
625
- </button>
626
- <button v-else @click="sendMessage" :disabled="!canSend" class="send-btn" title="Send">
627
- <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>
628
- </button>
629
- </div>
630
- </div>
631
- </div>
632
- </div>
633
- </div>
634
-
635
- <!-- Folder Picker Modal -->
636
- <div class="folder-picker-overlay" v-if="folderPickerOpen" @click.self="folderPickerOpen = false">
637
- <div class="folder-picker-dialog">
638
- <div class="folder-picker-header">
639
- <span>Select Working Directory</span>
640
- <button class="folder-picker-close" @click="folderPickerOpen = false">&times;</button>
641
- </div>
642
- <div class="folder-picker-nav">
643
- <button class="folder-picker-up" @click="folderPickerNavigateUp" :disabled="!folderPickerPath" title="Go to parent directory">
644
- <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>
645
- </button>
646
- <input class="folder-picker-path-input" type="text" v-model="folderPickerPath" @keydown.enter="folderPickerGoToPath" placeholder="Enter path..." spellcheck="false" />
647
- </div>
648
- <div class="folder-picker-list">
649
- <div v-if="folderPickerLoading" class="folder-picker-loading">
650
- <div class="history-loading-spinner"></div>
651
- <span>Loading...</span>
652
- </div>
653
- <template v-else>
654
- <div
655
- v-for="entry in folderPickerEntries" :key="entry.name"
656
- :class="['folder-picker-item', { 'folder-picker-selected': folderPickerSelected === entry.name }]"
657
- @click="folderPickerSelectItem(entry)"
658
- @dblclick="folderPickerEnter(entry)"
659
- >
660
- <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>
661
- <span>{{ entry.name }}</span>
662
- </div>
663
- <div v-if="folderPickerEntries.length === 0" class="folder-picker-empty">No subdirectories found.</div>
664
- </template>
665
- </div>
666
- <div class="folder-picker-footer">
667
- <button class="folder-picker-cancel" @click="folderPickerOpen = false">Cancel</button>
668
- <button class="folder-picker-confirm" @click="confirmFolderPicker" :disabled="!folderPickerPath">Open</button>
669
- </div>
670
- </div>
671
- </div>
672
-
673
- <!-- Delete Session Confirmation Dialog -->
674
- <div class="folder-picker-overlay" v-if="deleteConfirmOpen" @click.self="cancelDeleteSession">
675
- <div class="delete-confirm-dialog">
676
- <div class="delete-confirm-header">Delete Session</div>
677
- <div class="delete-confirm-body">
678
- <p>Are you sure you want to delete this session?</p>
679
- <p class="delete-confirm-title">{{ deleteConfirmTitle }}</p>
680
- <p class="delete-confirm-warning">This action cannot be undone.</p>
681
- </div>
682
- <div class="delete-confirm-footer">
683
- <button class="folder-picker-cancel" @click="cancelDeleteSession">Cancel</button>
684
- <button class="delete-confirm-btn" @click="confirmDeleteSession">Delete</button>
685
- </div>
686
- </div>
687
- </div>
688
-
689
- <!-- Password Authentication Dialog -->
690
- <div class="folder-picker-overlay" v-if="authRequired && !authLocked">
691
- <div class="auth-dialog">
692
- <div class="auth-dialog-header">
693
- <svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
694
- <span>Session Protected</span>
695
- </div>
696
- <div class="auth-dialog-body">
697
- <p>This session requires a password to access.</p>
698
- <input
699
- type="password"
700
- class="auth-password-input"
701
- v-model="authPassword"
702
- @keydown.enter="submitPassword"
703
- placeholder="Enter password..."
704
- autofocus
705
- />
706
- <p v-if="authError" class="auth-error">{{ authError }}</p>
707
- <p v-if="authAttempts" class="auth-attempts">{{ authAttempts }}</p>
708
- </div>
709
- <div class="auth-dialog-footer">
710
- <button class="auth-submit-btn" @click="submitPassword" :disabled="!authPassword.trim()">Unlock</button>
711
- </div>
712
- </div>
713
- </div>
714
-
715
- <!-- Auth Locked Out -->
716
- <div class="folder-picker-overlay" v-if="authLocked">
717
- <div class="auth-dialog auth-dialog-locked">
718
- <div class="auth-dialog-header">
719
- <svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
720
- <span>Access Locked</span>
721
- </div>
722
- <div class="auth-dialog-body">
723
- <p>{{ authError }}</p>
724
- <p class="auth-locked-hint">Close this tab and try again later.</p>
725
- </div>
726
- </div>
727
- </div>
728
- </div>
729
- `
730
- };
731
-
732
- const app = createApp(App);
733
- app.mount('#app');
1
+ // ── AgentLink Web UI — Main coordinator ──────────────────────────────────────
2
+ const { createApp, ref, nextTick, onMounted, onUnmounted, computed, watch } = Vue;
3
+
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';
19
+
20
+ // ── App ─────────────────────────────────────────────────────────────────────
21
+ const App = {
22
+ setup() {
23
+ // ── Reactive state ──
24
+ const status = ref('Connecting...');
25
+ const agentName = ref('');
26
+ const hostname = ref('');
27
+ const workDir = ref('');
28
+ const sessionId = ref('');
29
+ const error = ref('');
30
+ const serverVersion = ref('');
31
+ const agentVersion = ref('');
32
+ const messages = ref([]);
33
+ const visibleLimit = ref(50);
34
+ const hasMoreMessages = computed(() => messages.value.length > visibleLimit.value);
35
+ const visibleMessages = computed(() => {
36
+ if (messages.value.length <= visibleLimit.value) return messages.value;
37
+ return messages.value.slice(messages.value.length - visibleLimit.value);
38
+ });
39
+ function loadMoreMessages() {
40
+ const el = document.querySelector('.message-list');
41
+ const prevHeight = el ? el.scrollHeight : 0;
42
+ visibleLimit.value += 50;
43
+ nextTick(() => {
44
+ if (el) el.scrollTop += el.scrollHeight - prevHeight;
45
+ });
46
+ }
47
+ const inputText = ref('');
48
+ const isProcessing = ref(false);
49
+ const isCompacting = ref(false);
50
+ const inputRef = ref(null);
51
+
52
+ // Sidebar state
53
+ const sidebarOpen = ref(window.innerWidth > 768);
54
+ const historySessions = ref([]);
55
+ const currentClaudeSessionId = ref(null);
56
+ const needsResume = ref(false);
57
+ const loadingSessions = ref(false);
58
+ const loadingHistory = ref(false);
59
+
60
+ // Folder picker state
61
+ const folderPickerOpen = ref(false);
62
+ const folderPickerPath = ref('');
63
+ const folderPickerEntries = ref([]);
64
+ const folderPickerLoading = ref(false);
65
+ const folderPickerSelected = ref('');
66
+
67
+ // Delete confirmation dialog state
68
+ const deleteConfirmOpen = ref(false);
69
+ const deleteConfirmTitle = ref('');
70
+
71
+ // Working directory history
72
+ const workdirHistory = ref([]);
73
+
74
+ // Authentication state
75
+ const authRequired = ref(false);
76
+ const authPassword = ref('');
77
+ const authError = ref('');
78
+ const authAttempts = ref(null);
79
+ const authLocked = ref(false);
80
+
81
+ // File attachment state
82
+ const attachments = ref([]);
83
+ const fileInputRef = ref(null);
84
+ const dragOver = ref(false);
85
+
86
+ // Theme
87
+ const theme = ref(localStorage.getItem('agentlink-theme') || 'light');
88
+ function applyTheme() {
89
+ document.documentElement.setAttribute('data-theme', theme.value);
90
+ const link = document.getElementById('hljs-theme');
91
+ if (link) link.href = theme.value === 'light'
92
+ ? '/vendor/github.min.css'
93
+ : '/vendor/github-dark.min.css';
94
+ }
95
+ function toggleTheme() {
96
+ theme.value = theme.value === 'dark' ? 'light' : 'dark';
97
+ localStorage.setItem('agentlink-theme', theme.value);
98
+ applyTheme();
99
+ }
100
+ applyTheme();
101
+
102
+ // ── Scroll management ──
103
+ let _scrollTimer = null;
104
+ let _userScrolledUp = false;
105
+
106
+ function onMessageListScroll(e) {
107
+ const el = e.target;
108
+ _userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > 80;
109
+ }
110
+
111
+ function scrollToBottom(force) {
112
+ if (_userScrolledUp && !force) return;
113
+ if (_scrollTimer) return;
114
+ _scrollTimer = setTimeout(() => {
115
+ _scrollTimer = null;
116
+ const el = document.querySelector('.message-list');
117
+ if (el) el.scrollTop = el.scrollHeight;
118
+ }, 50);
119
+ }
120
+
121
+ // ── Highlight.js scheduling ──
122
+ let _hlTimer = null;
123
+ function scheduleHighlight() {
124
+ if (_hlTimer) return;
125
+ _hlTimer = setTimeout(() => {
126
+ _hlTimer = null;
127
+ if (typeof hljs !== 'undefined') {
128
+ document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
129
+ hljs.highlightElement(block);
130
+ block.dataset.highlighted = 'true';
131
+ });
132
+ }
133
+ }, 300);
134
+ }
135
+
136
+ // ── Create module instances ──
137
+
138
+ const streaming = createStreaming({ messages, scrollToBottom });
139
+
140
+ const fileAttach = createFileAttachments(attachments, fileInputRef, dragOver);
141
+
142
+ // Sidebar needs wsSend, but connection creates wsSend.
143
+ // Resolve circular dependency with a forwarding function.
144
+ let _wsSend = () => {};
145
+
146
+ const sidebar = createSidebar({
147
+ wsSend: (msg) => _wsSend(msg),
148
+ messages, isProcessing, sidebarOpen,
149
+ historySessions, currentClaudeSessionId, needsResume,
150
+ loadingSessions, loadingHistory, workDir, visibleLimit,
151
+ folderPickerOpen, folderPickerPath, folderPickerEntries,
152
+ folderPickerLoading, folderPickerSelected, streaming,
153
+ deleteConfirmOpen, deleteConfirmTitle,
154
+ hostname, workdirHistory,
155
+ });
156
+
157
+ const { connect, wsSend, closeWs, submitPassword } = createConnection({
158
+ status, agentName, hostname, workDir, sessionId, error,
159
+ serverVersion, agentVersion,
160
+ messages, isProcessing, isCompacting, visibleLimit,
161
+ historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
162
+ folderPickerLoading, folderPickerEntries, folderPickerPath,
163
+ authRequired, authPassword, authError, authAttempts, authLocked,
164
+ streaming, sidebar, scrollToBottom,
165
+ });
166
+
167
+ // Now wire up the forwarding function
168
+ _wsSend = wsSend;
169
+
170
+ // ── Computed ──
171
+ const canSend = computed(() =>
172
+ status.value === 'Connected' && (inputText.value.trim() || attachments.value.length > 0) && !isProcessing.value && !isCompacting.value
173
+ && !messages.value.some(m => m.role === 'ask-question' && !m.answered)
174
+ );
175
+
176
+ // ── Auto-resize textarea ──
177
+ function autoResize() {
178
+ const ta = inputRef.value;
179
+ if (ta) {
180
+ ta.style.height = 'auto';
181
+ ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
182
+ }
183
+ }
184
+
185
+ // ── Send message ──
186
+ function sendMessage() {
187
+ if (!canSend.value) return;
188
+
189
+ const text = inputText.value.trim();
190
+ const files = attachments.value.slice();
191
+ inputText.value = '';
192
+ if (inputRef.value) inputRef.value.style.height = 'auto';
193
+
194
+ const msgAttachments = files.map(f => ({
195
+ name: f.name, size: f.size, isImage: f.isImage, thumbUrl: f.thumbUrl,
196
+ }));
197
+
198
+ messages.value.push({
199
+ id: streaming.nextId(), role: 'user',
200
+ content: text || (files.length > 0 ? `[${files.length} file${files.length > 1 ? 's' : ''} attached]` : ''),
201
+ attachments: msgAttachments.length > 0 ? msgAttachments : undefined,
202
+ timestamp: new Date(),
203
+ });
204
+ isProcessing.value = true;
205
+ scrollToBottom(true);
206
+
207
+ const payload = { type: 'chat', prompt: text || '(see attached files)' };
208
+ if (needsResume.value && currentClaudeSessionId.value) {
209
+ payload.resumeSessionId = currentClaudeSessionId.value;
210
+ needsResume.value = false;
211
+ }
212
+ if (files.length > 0) {
213
+ payload.files = files.map(f => ({
214
+ name: f.name, mimeType: f.mimeType, data: f.data,
215
+ }));
216
+ }
217
+ wsSend(payload);
218
+ attachments.value = [];
219
+ }
220
+
221
+ function cancelExecution() {
222
+ if (!isProcessing.value) return;
223
+ wsSend({ type: 'cancel_execution' });
224
+ }
225
+
226
+ function handleKeydown(e) {
227
+ if (e.key === 'Enter' && !e.shiftKey) {
228
+ e.preventDefault();
229
+ sendMessage();
230
+ }
231
+ }
232
+
233
+ // ── Template adapter wrappers ──
234
+ // These adapt the module function signatures to the template's call conventions.
235
+ function _isPrevAssistant(idx) {
236
+ return isPrevAssistant(visibleMessages.value, idx);
237
+ }
238
+
239
+ function _submitQuestionAnswer(msg) {
240
+ submitQuestionAnswer(msg, wsSend);
241
+ }
242
+
243
+ // ── Watchers ──
244
+ const messageCount = computed(() => messages.value.length);
245
+ watch(messageCount, () => { nextTick(scheduleHighlight); });
246
+
247
+ watch(hostname, (name) => {
248
+ document.title = name ? `${name} — AgentLink` : 'AgentLink';
249
+ });
250
+
251
+ // ── Lifecycle ──
252
+ onMounted(() => { connect(scheduleHighlight); });
253
+ onUnmounted(() => { closeWs(); streaming.cleanup(); });
254
+
255
+ return {
256
+ status, agentName, hostname, workDir, sessionId, error,
257
+ serverVersion, agentVersion,
258
+ messages, visibleMessages, hasMoreMessages, loadMoreMessages,
259
+ inputText, isProcessing, isCompacting, canSend, inputRef,
260
+ sendMessage, handleKeydown, cancelExecution, onMessageListScroll,
261
+ getRenderedContent, copyMessage, toggleTool,
262
+ isPrevAssistant: _isPrevAssistant,
263
+ toggleContextSummary, formatTimestamp,
264
+ getToolIcon, getToolSummary, isEditTool, getEditDiffHtml, getFormattedToolInput, autoResize,
265
+ // AskUserQuestion
266
+ selectQuestionOption,
267
+ submitQuestionAnswer: _submitQuestionAnswer,
268
+ hasQuestionAnswer, getQuestionResponseSummary,
269
+ // Theme
270
+ theme, toggleTheme,
271
+ // Sidebar
272
+ sidebarOpen, historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
273
+ toggleSidebar: sidebar.toggleSidebar,
274
+ resumeSession: sidebar.resumeSession,
275
+ newConversation: sidebar.newConversation,
276
+ requestSessionList: sidebar.requestSessionList,
277
+ formatRelativeTime,
278
+ groupedSessions: sidebar.groupedSessions,
279
+ // Folder picker
280
+ folderPickerOpen, folderPickerPath, folderPickerEntries,
281
+ folderPickerLoading, folderPickerSelected,
282
+ openFolderPicker: sidebar.openFolderPicker,
283
+ folderPickerNavigateUp: sidebar.folderPickerNavigateUp,
284
+ folderPickerSelectItem: sidebar.folderPickerSelectItem,
285
+ folderPickerEnter: sidebar.folderPickerEnter,
286
+ folderPickerGoToPath: sidebar.folderPickerGoToPath,
287
+ confirmFolderPicker: sidebar.confirmFolderPicker,
288
+ // Delete session
289
+ deleteConfirmOpen, deleteConfirmTitle,
290
+ deleteSession: sidebar.deleteSession,
291
+ confirmDeleteSession: sidebar.confirmDeleteSession,
292
+ cancelDeleteSession: sidebar.cancelDeleteSession,
293
+ // Working directory history
294
+ filteredWorkdirHistory: sidebar.filteredWorkdirHistory,
295
+ switchToWorkdir: sidebar.switchToWorkdir,
296
+ removeFromWorkdirHistory: sidebar.removeFromWorkdirHistory,
297
+ // Authentication
298
+ authRequired, authPassword, authError, authAttempts, authLocked,
299
+ submitPassword,
300
+ // File attachments
301
+ attachments, fileInputRef, dragOver,
302
+ triggerFileInput: fileAttach.triggerFileInput,
303
+ handleFileSelect: fileAttach.handleFileSelect,
304
+ removeAttachment: fileAttach.removeAttachment,
305
+ formatFileSize,
306
+ handleDragOver: fileAttach.handleDragOver,
307
+ handleDragLeave: fileAttach.handleDragLeave,
308
+ handleDrop: fileAttach.handleDrop,
309
+ handlePaste: fileAttach.handlePaste,
310
+ };
311
+ },
312
+ template: `
313
+ <div class="layout">
314
+ <header class="top-bar">
315
+ <div class="top-bar-left">
316
+ <button class="sidebar-toggle" @click="toggleSidebar" title="Toggle sidebar">
317
+ <svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
318
+ </button>
319
+ <h1>AgentLink</h1>
320
+ </div>
321
+ <div class="top-bar-info">
322
+ <span :class="['badge', status.toLowerCase()]">{{ status }}</span>
323
+ <span v-if="agentName" class="agent-label">{{ agentName }}</span>
324
+ <button class="theme-toggle" @click="toggleTheme" :title="theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
325
+ <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>
326
+ <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>
327
+ </button>
328
+ </div>
329
+ </header>
330
+
331
+ <div v-if="status === 'No Session' || (status !== 'Connected' && status !== 'Connecting...' && status !== 'Reconnecting...' && messages.length === 0)" class="center-card">
332
+ <div class="status-card">
333
+ <p class="status">
334
+ <span class="label">Status:</span>
335
+ <span :class="['badge', status.toLowerCase()]">{{ status }}</span>
336
+ </p>
337
+ <p v-if="agentName" class="info"><span class="label">Agent:</span> {{ agentName }}</p>
338
+ <p v-if="workDir" class="info"><span class="label">Directory:</span> {{ workDir }}</p>
339
+ <p v-if="sessionId" class="info muted"><span class="label">Session:</span> {{ sessionId }}</p>
340
+ <p v-if="error" class="error-msg">{{ error }}</p>
341
+ </div>
342
+ </div>
343
+
344
+ <div v-else class="main-body">
345
+ <!-- Sidebar backdrop (mobile) -->
346
+ <div v-if="sidebarOpen" class="sidebar-backdrop" @click="toggleSidebar"></div>
347
+ <!-- Sidebar -->
348
+ <aside v-if="sidebarOpen" class="sidebar">
349
+ <div class="sidebar-section">
350
+ <div class="sidebar-workdir">
351
+ <div v-if="hostname" class="sidebar-hostname">
352
+ <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>
353
+ <span>{{ hostname }}</span>
354
+ </div>
355
+ <div class="sidebar-workdir-header">
356
+ <div class="sidebar-workdir-label">Working Directory</div>
357
+ <button class="sidebar-change-dir-btn" @click="openFolderPicker" title="Change working directory" :disabled="isProcessing">
358
+ <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>
359
+ </button>
360
+ </div>
361
+ <div class="sidebar-workdir-path" :title="workDir">{{ workDir }}</div>
362
+ <div v-if="filteredWorkdirHistory.length > 0" class="workdir-history">
363
+ <div class="workdir-history-label">Recent Directories</div>
364
+ <div class="workdir-history-list">
365
+ <div
366
+ v-for="path in filteredWorkdirHistory" :key="path"
367
+ class="workdir-history-item"
368
+ @click="switchToWorkdir(path)"
369
+ :title="path"
370
+ >
371
+ <span class="workdir-history-path">{{ path }}</span>
372
+ <button class="workdir-history-delete" @click.stop="removeFromWorkdirHistory(path)" title="Remove from history">
373
+ <svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
374
+ </button>
375
+ </div>
376
+ </div>
377
+ </div>
378
+ </div>
379
+ </div>
380
+
381
+ <div class="sidebar-section sidebar-sessions">
382
+ <div class="sidebar-section-header">
383
+ <span>History</span>
384
+ <button class="sidebar-refresh-btn" @click="requestSessionList" title="Refresh" :disabled="loadingSessions">
385
+ <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>
386
+ </button>
387
+ </div>
388
+
389
+ <button class="new-conversation-btn" @click="newConversation" :disabled="isProcessing">
390
+ <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
391
+ New conversation
392
+ </button>
393
+
394
+ <div v-if="loadingSessions && historySessions.length === 0" class="sidebar-loading">
395
+ Loading sessions...
396
+ </div>
397
+ <div v-else-if="historySessions.length === 0" class="sidebar-empty">
398
+ No previous sessions found.
399
+ </div>
400
+ <div v-else class="session-list">
401
+ <div v-for="group in groupedSessions" :key="group.label" class="session-group">
402
+ <div class="session-group-label">{{ group.label }}</div>
403
+ <div
404
+ v-for="s in group.sessions" :key="s.sessionId"
405
+ :class="['session-item', { active: currentClaudeSessionId === s.sessionId }]"
406
+ @click="resumeSession(s)"
407
+ :title="s.preview"
408
+ >
409
+ <div class="session-title">{{ s.title }}</div>
410
+ <div class="session-meta">
411
+ <span>{{ formatRelativeTime(s.lastModified) }}</span>
412
+ <button
413
+ v-if="currentClaudeSessionId !== s.sessionId"
414
+ class="session-delete-btn"
415
+ @click.stop="deleteSession(s)"
416
+ title="Delete session"
417
+ >
418
+ <svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
419
+ </button>
420
+ </div>
421
+ </div>
422
+ </div>
423
+ </div>
424
+ </div>
425
+
426
+ <div v-if="serverVersion || agentVersion" class="sidebar-version-footer">
427
+ <span v-if="serverVersion">server {{ serverVersion }}</span>
428
+ <span v-if="serverVersion && agentVersion" class="sidebar-version-sep">/</span>
429
+ <span v-if="agentVersion">agent {{ agentVersion }}</span>
430
+ </div>
431
+ </aside>
432
+
433
+ <!-- Chat area -->
434
+ <div class="chat-area">
435
+ <div class="message-list" @scroll="onMessageListScroll">
436
+ <div class="message-list-inner">
437
+ <div v-if="messages.length === 0 && status === 'Connected' && !loadingHistory" class="empty-state">
438
+ <div class="empty-state-icon">
439
+ <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>
440
+ </div>
441
+ <p>Connected to <strong>{{ agentName }}</strong></p>
442
+ <p class="muted">{{ workDir }}</p>
443
+ <p class="muted" style="margin-top: 0.5rem;">Send a message to start.</p>
444
+ </div>
445
+
446
+ <div v-if="loadingHistory" class="history-loading">
447
+ <div class="history-loading-spinner"></div>
448
+ <span>Loading conversation history...</span>
449
+ </div>
450
+
451
+ <div v-if="hasMoreMessages" class="load-more-wrapper">
452
+ <button class="load-more-btn" @click="loadMoreMessages">Load earlier messages</button>
453
+ </div>
454
+
455
+ <div v-for="(msg, msgIdx) in visibleMessages" :key="msg.id" :class="['message', 'message-' + msg.role]">
456
+
457
+ <!-- User message -->
458
+ <template v-if="msg.role === 'user'">
459
+ <div class="message-role-label user-label">You</div>
460
+ <div class="message-bubble user-bubble" :title="formatTimestamp(msg.timestamp)">
461
+ <div class="message-content">{{ msg.content }}</div>
462
+ <div v-if="msg.attachments && msg.attachments.length" class="message-attachments">
463
+ <div v-for="(att, ai) in msg.attachments" :key="ai" class="message-attachment-chip">
464
+ <img v-if="att.isImage && att.thumbUrl" :src="att.thumbUrl" class="message-attachment-thumb" />
465
+ <span v-else class="message-attachment-file-icon">
466
+ <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>
467
+ </span>
468
+ <span>{{ att.name }}</span>
469
+ </div>
470
+ </div>
471
+ </div>
472
+ </template>
473
+
474
+ <!-- Assistant message (markdown) -->
475
+ <template v-else-if="msg.role === 'assistant'">
476
+ <div v-if="!isPrevAssistant(msgIdx)" class="message-role-label assistant-label">Claude</div>
477
+ <div :class="['message-bubble', 'assistant-bubble', { streaming: msg.isStreaming }]" :title="formatTimestamp(msg.timestamp)">
478
+ <div class="message-actions">
479
+ <button class="icon-btn" @click="copyMessage(msg)" :title="msg.copied ? 'Copied!' : 'Copy'">
480
+ <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>
481
+ <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>
482
+ </button>
483
+ </div>
484
+ <div class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
485
+ </div>
486
+ </template>
487
+
488
+ <!-- Tool use block (collapsible) -->
489
+ <div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
490
+ <div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
491
+ <span class="tool-icon" v-html="getToolIcon(msg.toolName)"></span>
492
+ <span class="tool-name">{{ msg.toolName }}</span>
493
+ <span class="tool-summary">{{ getToolSummary(msg) }}</span>
494
+ <span class="tool-status-icon" v-if="msg.hasResult">\u{2713}</span>
495
+ <span class="tool-status-icon running-dots" v-else>
496
+ <span></span><span></span><span></span>
497
+ </span>
498
+ <span class="tool-toggle">{{ msg.expanded ? '\u{25B2}' : '\u{25BC}' }}</span>
499
+ </div>
500
+ <div v-show="msg.expanded" class="tool-expand">
501
+ <div v-if="isEditTool(msg) && getEditDiffHtml(msg)" class="tool-diff" v-html="getEditDiffHtml(msg)"></div>
502
+ <div v-else-if="getFormattedToolInput(msg)" class="tool-input-formatted" v-html="getFormattedToolInput(msg)"></div>
503
+ <pre v-else-if="msg.toolInput" class="tool-block">{{ msg.toolInput }}</pre>
504
+ <pre v-if="msg.toolOutput" class="tool-block tool-output">{{ msg.toolOutput }}</pre>
505
+ </div>
506
+ </div>
507
+
508
+ <!-- AskUserQuestion interactive card -->
509
+ <div v-else-if="msg.role === 'ask-question'" class="ask-question-wrapper">
510
+ <div v-if="!msg.answered" class="ask-question-card">
511
+ <div v-for="(q, qi) in msg.questions" :key="qi" class="ask-question-block">
512
+ <div v-if="q.header" class="ask-question-header">{{ q.header }}</div>
513
+ <div class="ask-question-text">{{ q.question }}</div>
514
+ <div class="ask-question-options">
515
+ <div
516
+ v-for="(opt, oi) in q.options" :key="oi"
517
+ :class="['ask-question-option', {
518
+ selected: q.multiSelect
519
+ ? (msg.selectedAnswers[qi] || []).includes(opt.label)
520
+ : msg.selectedAnswers[qi] === opt.label
521
+ }]"
522
+ @click="selectQuestionOption(msg, qi, opt.label)"
523
+ >
524
+ <div class="ask-option-label">{{ opt.label }}</div>
525
+ <div v-if="opt.description" class="ask-option-desc">{{ opt.description }}</div>
526
+ </div>
527
+ </div>
528
+ <div class="ask-question-custom">
529
+ <input
530
+ type="text"
531
+ v-model="msg.customTexts[qi]"
532
+ placeholder="Or type a custom response..."
533
+ @input="msg.selectedAnswers[qi] = q.multiSelect ? [] : null"
534
+ @keydown.enter="hasQuestionAnswer(msg) && submitQuestionAnswer(msg)"
535
+ />
536
+ </div>
537
+ </div>
538
+ <div class="ask-question-actions">
539
+ <button class="ask-question-submit" :disabled="!hasQuestionAnswer(msg)" @click="submitQuestionAnswer(msg)">
540
+ Submit
541
+ </button>
542
+ </div>
543
+ </div>
544
+ <div v-else class="ask-question-answered">
545
+ <span class="ask-answered-icon">\u{2713}</span>
546
+ <span class="ask-answered-text">{{ getQuestionResponseSummary(msg) }}</span>
547
+ </div>
548
+ </div>
549
+
550
+ <!-- Context summary (collapsed by default) -->
551
+ <div v-else-if="msg.role === 'context-summary'" class="context-summary-wrapper">
552
+ <div class="context-summary-bar" @click="toggleContextSummary(msg)">
553
+ <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>
554
+ <span class="context-summary-label">Context continued from previous conversation</span>
555
+ <span class="context-summary-toggle">{{ msg.contextExpanded ? 'Hide' : 'Show' }}</span>
556
+ </div>
557
+ <div v-if="msg.contextExpanded" class="context-summary-body">
558
+ <div class="markdown-body" v-html="getRenderedContent({ role: 'assistant', content: msg.content })"></div>
559
+ </div>
560
+ </div>
561
+
562
+ <!-- System message -->
563
+ <div v-else-if="msg.role === 'system'" :class="['system-msg', { 'compact-msg': msg.isCompactStart, 'command-output-msg': msg.isCommandOutput, 'error-msg': msg.isError }]">
564
+ <template v-if="msg.isCompactStart && !msg.compactDone">
565
+ <span class="compact-inline-spinner"></span>
566
+ </template>
567
+ <template v-if="msg.isCompactStart && msg.compactDone">
568
+ <span class="compact-done-icon">✓</span>
569
+ </template>
570
+ <div v-if="msg.isCommandOutput" class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
571
+ <template v-else>{{ msg.content }}</template>
572
+ </div>
573
+ </div>
574
+
575
+ <div v-if="isProcessing && !messages.some(m => m.isStreaming)" class="typing-indicator">
576
+ <span></span><span></span><span></span>
577
+ </div>
578
+ </div>
579
+ </div>
580
+
581
+ <div class="input-area">
582
+ <input
583
+ type="file"
584
+ ref="fileInputRef"
585
+ multiple
586
+ style="display: none"
587
+ @change="handleFileSelect"
588
+ accept="image/*,text/*,.pdf,.json,.md,.py,.js,.ts,.tsx,.jsx,.css,.html,.xml,.yaml,.yml,.toml,.sh,.sql,.csv"
589
+ />
590
+ <div
591
+ :class="['input-card', { 'drag-over': dragOver }]"
592
+ @dragover="handleDragOver"
593
+ @dragleave="handleDragLeave"
594
+ @drop="handleDrop"
595
+ >
596
+ <textarea
597
+ ref="inputRef"
598
+ v-model="inputText"
599
+ @keydown="handleKeydown"
600
+ @input="autoResize"
601
+ @paste="handlePaste"
602
+ :disabled="status !== 'Connected' || isCompacting"
603
+ :placeholder="isCompacting ? 'Context compacting in progress...' : 'Send a message · Enter to send'"
604
+ rows="1"
605
+ ></textarea>
606
+ <div v-if="attachments.length > 0" class="attachment-bar">
607
+ <div v-for="(att, i) in attachments" :key="i" class="attachment-chip">
608
+ <img v-if="att.isImage && att.thumbUrl" :src="att.thumbUrl" class="attachment-thumb" />
609
+ <div v-else class="attachment-file-icon">
610
+ <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>
611
+ </div>
612
+ <div class="attachment-info">
613
+ <div class="attachment-name">{{ att.name }}</div>
614
+ <div class="attachment-size">{{ formatFileSize(att.size) }}</div>
615
+ </div>
616
+ <button class="attachment-remove" @click="removeAttachment(i)" title="Remove">&times;</button>
617
+ </div>
618
+ </div>
619
+ <div class="input-bottom-row">
620
+ <button class="attach-btn" @click="triggerFileInput" :disabled="status !== 'Connected' || isCompacting || attachments.length >= 5" title="Attach files">
621
+ <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>
622
+ </button>
623
+ <button v-if="isProcessing" @click="cancelExecution" class="send-btn stop-btn" title="Stop generation">
624
+ <svg viewBox="0 0 24 24" width="14" height="14"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
625
+ </button>
626
+ <button v-else @click="sendMessage" :disabled="!canSend" class="send-btn" title="Send">
627
+ <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>
628
+ </button>
629
+ </div>
630
+ </div>
631
+ </div>
632
+ </div>
633
+ </div>
634
+
635
+ <!-- Folder Picker Modal -->
636
+ <div class="folder-picker-overlay" v-if="folderPickerOpen" @click.self="folderPickerOpen = false">
637
+ <div class="folder-picker-dialog">
638
+ <div class="folder-picker-header">
639
+ <span>Select Working Directory</span>
640
+ <button class="folder-picker-close" @click="folderPickerOpen = false">&times;</button>
641
+ </div>
642
+ <div class="folder-picker-nav">
643
+ <button class="folder-picker-up" @click="folderPickerNavigateUp" :disabled="!folderPickerPath" title="Go to parent directory">
644
+ <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>
645
+ </button>
646
+ <input class="folder-picker-path-input" type="text" v-model="folderPickerPath" @keydown.enter="folderPickerGoToPath" placeholder="Enter path..." spellcheck="false" />
647
+ </div>
648
+ <div class="folder-picker-list">
649
+ <div v-if="folderPickerLoading" class="folder-picker-loading">
650
+ <div class="history-loading-spinner"></div>
651
+ <span>Loading...</span>
652
+ </div>
653
+ <template v-else>
654
+ <div
655
+ v-for="entry in folderPickerEntries" :key="entry.name"
656
+ :class="['folder-picker-item', { 'folder-picker-selected': folderPickerSelected === entry.name }]"
657
+ @click="folderPickerSelectItem(entry)"
658
+ @dblclick="folderPickerEnter(entry)"
659
+ >
660
+ <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>
661
+ <span>{{ entry.name }}</span>
662
+ </div>
663
+ <div v-if="folderPickerEntries.length === 0" class="folder-picker-empty">No subdirectories found.</div>
664
+ </template>
665
+ </div>
666
+ <div class="folder-picker-footer">
667
+ <button class="folder-picker-cancel" @click="folderPickerOpen = false">Cancel</button>
668
+ <button class="folder-picker-confirm" @click="confirmFolderPicker" :disabled="!folderPickerPath">Open</button>
669
+ </div>
670
+ </div>
671
+ </div>
672
+
673
+ <!-- Delete Session Confirmation Dialog -->
674
+ <div class="folder-picker-overlay" v-if="deleteConfirmOpen" @click.self="cancelDeleteSession">
675
+ <div class="delete-confirm-dialog">
676
+ <div class="delete-confirm-header">Delete Session</div>
677
+ <div class="delete-confirm-body">
678
+ <p>Are you sure you want to delete this session?</p>
679
+ <p class="delete-confirm-title">{{ deleteConfirmTitle }}</p>
680
+ <p class="delete-confirm-warning">This action cannot be undone.</p>
681
+ </div>
682
+ <div class="delete-confirm-footer">
683
+ <button class="folder-picker-cancel" @click="cancelDeleteSession">Cancel</button>
684
+ <button class="delete-confirm-btn" @click="confirmDeleteSession">Delete</button>
685
+ </div>
686
+ </div>
687
+ </div>
688
+
689
+ <!-- Password Authentication Dialog -->
690
+ <div class="folder-picker-overlay" v-if="authRequired && !authLocked">
691
+ <div class="auth-dialog">
692
+ <div class="auth-dialog-header">
693
+ <svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
694
+ <span>Session Protected</span>
695
+ </div>
696
+ <div class="auth-dialog-body">
697
+ <p>This session requires a password to access.</p>
698
+ <input
699
+ type="password"
700
+ class="auth-password-input"
701
+ v-model="authPassword"
702
+ @keydown.enter="submitPassword"
703
+ placeholder="Enter password..."
704
+ autofocus
705
+ />
706
+ <p v-if="authError" class="auth-error">{{ authError }}</p>
707
+ <p v-if="authAttempts" class="auth-attempts">{{ authAttempts }}</p>
708
+ </div>
709
+ <div class="auth-dialog-footer">
710
+ <button class="auth-submit-btn" @click="submitPassword" :disabled="!authPassword.trim()">Unlock</button>
711
+ </div>
712
+ </div>
713
+ </div>
714
+
715
+ <!-- Auth Locked Out -->
716
+ <div class="folder-picker-overlay" v-if="authLocked">
717
+ <div class="auth-dialog auth-dialog-locked">
718
+ <div class="auth-dialog-header">
719
+ <svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
720
+ <span>Access Locked</span>
721
+ </div>
722
+ <div class="auth-dialog-body">
723
+ <p>{{ authError }}</p>
724
+ <p class="auth-locked-hint">Close this tab and try again later.</p>
725
+ </div>
726
+ </div>
727
+ </div>
728
+ </div>
729
+ `
730
+ };
731
+
732
+ const app = createApp(App);
733
+ app.mount('#app');