@agent-link/server 0.1.124 → 0.1.126
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/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +54 -54
- package/web/app.js +1192 -1192
- package/web/favicon.svg +10 -10
- package/web/landing.html +1262 -1241
- package/web/landing.zh.html +1261 -0
- package/web/modules/connection.js +880 -880
- package/web/modules/fileBrowser.js +379 -379
- package/web/modules/filePreview.js +187 -187
- package/web/modules/sidebar.js +376 -376
- package/web/modules/streaming.js +110 -110
- package/web/style.css +2941 -2941
package/web/app.js
CHANGED
|
@@ -1,1192 +1,1192 @@
|
|
|
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
|
-
import { createFileBrowser } from './modules/fileBrowser.js';
|
|
20
|
-
import { createFilePreview } from './modules/filePreview.js';
|
|
21
|
-
|
|
22
|
-
// ── App ─────────────────────────────────────────────────────────────────────
|
|
23
|
-
const App = {
|
|
24
|
-
setup() {
|
|
25
|
-
// ── Reactive state ──
|
|
26
|
-
const status = ref('Connecting...');
|
|
27
|
-
const agentName = ref('');
|
|
28
|
-
const hostname = ref('');
|
|
29
|
-
const workDir = ref('');
|
|
30
|
-
const sessionId = ref('');
|
|
31
|
-
const error = ref('');
|
|
32
|
-
const serverVersion = ref('');
|
|
33
|
-
const agentVersion = ref('');
|
|
34
|
-
const messages = ref([]);
|
|
35
|
-
const visibleLimit = ref(50);
|
|
36
|
-
const hasMoreMessages = computed(() => messages.value.length > visibleLimit.value);
|
|
37
|
-
const visibleMessages = computed(() => {
|
|
38
|
-
if (messages.value.length <= visibleLimit.value) return messages.value;
|
|
39
|
-
return messages.value.slice(messages.value.length - visibleLimit.value);
|
|
40
|
-
});
|
|
41
|
-
function loadMoreMessages() {
|
|
42
|
-
const el = document.querySelector('.message-list');
|
|
43
|
-
const prevHeight = el ? el.scrollHeight : 0;
|
|
44
|
-
visibleLimit.value += 50;
|
|
45
|
-
nextTick(() => {
|
|
46
|
-
if (el) el.scrollTop += el.scrollHeight - prevHeight;
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
const inputText = ref('');
|
|
50
|
-
const isProcessing = ref(false);
|
|
51
|
-
const isCompacting = ref(false);
|
|
52
|
-
const latency = ref(null);
|
|
53
|
-
const queuedMessages = ref([]);
|
|
54
|
-
const usageStats = ref(null);
|
|
55
|
-
const inputRef = ref(null);
|
|
56
|
-
|
|
57
|
-
// Sidebar state
|
|
58
|
-
const sidebarOpen = ref(window.innerWidth > 768);
|
|
59
|
-
const historySessions = ref([]);
|
|
60
|
-
const currentClaudeSessionId = ref(null);
|
|
61
|
-
const needsResume = ref(false);
|
|
62
|
-
const loadingSessions = ref(false);
|
|
63
|
-
const loadingHistory = ref(false);
|
|
64
|
-
|
|
65
|
-
// Folder picker state
|
|
66
|
-
const folderPickerOpen = ref(false);
|
|
67
|
-
const folderPickerPath = ref('');
|
|
68
|
-
const folderPickerEntries = ref([]);
|
|
69
|
-
const folderPickerLoading = ref(false);
|
|
70
|
-
const folderPickerSelected = ref('');
|
|
71
|
-
|
|
72
|
-
// Delete confirmation dialog state
|
|
73
|
-
const deleteConfirmOpen = ref(false);
|
|
74
|
-
const deleteConfirmTitle = ref('');
|
|
75
|
-
|
|
76
|
-
// Rename session state
|
|
77
|
-
const renamingSessionId = ref(null);
|
|
78
|
-
const renameText = ref('');
|
|
79
|
-
|
|
80
|
-
// Working directory history
|
|
81
|
-
const workdirHistory = ref([]);
|
|
82
|
-
|
|
83
|
-
// Authentication state
|
|
84
|
-
const authRequired = ref(false);
|
|
85
|
-
const authPassword = ref('');
|
|
86
|
-
const authError = ref('');
|
|
87
|
-
const authAttempts = ref(null);
|
|
88
|
-
const authLocked = ref(false);
|
|
89
|
-
|
|
90
|
-
// File attachment state
|
|
91
|
-
const attachments = ref([]);
|
|
92
|
-
const fileInputRef = ref(null);
|
|
93
|
-
const dragOver = ref(false);
|
|
94
|
-
|
|
95
|
-
// Multi-session parallel state
|
|
96
|
-
const conversationCache = ref({}); // conversationId → saved state snapshot
|
|
97
|
-
const currentConversationId = ref(crypto.randomUUID()); // currently visible conversation
|
|
98
|
-
const processingConversations = ref({}); // conversationId → boolean
|
|
99
|
-
|
|
100
|
-
// File browser state
|
|
101
|
-
const filePanelOpen = ref(false);
|
|
102
|
-
const filePanelWidth = ref(parseInt(localStorage.getItem('agentlink-file-panel-width'), 10) || 280);
|
|
103
|
-
const fileTreeRoot = ref(null);
|
|
104
|
-
const fileTreeLoading = ref(false);
|
|
105
|
-
const fileContextMenu = ref(null);
|
|
106
|
-
const sidebarView = ref('sessions'); // 'sessions' | 'files' | 'preview' (mobile only)
|
|
107
|
-
const isMobile = ref(window.innerWidth <= 768);
|
|
108
|
-
const workdirMenuOpen = ref(false);
|
|
109
|
-
|
|
110
|
-
// File preview state
|
|
111
|
-
const previewPanelOpen = ref(false);
|
|
112
|
-
const previewPanelWidth = ref(parseInt(localStorage.getItem('agentlink-preview-panel-width'), 10) || 400);
|
|
113
|
-
const previewFile = ref(null);
|
|
114
|
-
const previewLoading = ref(false);
|
|
115
|
-
|
|
116
|
-
// ── switchConversation: save current → load target ──
|
|
117
|
-
// Defined here and used by sidebar.newConversation, sidebar.resumeSession, workdir_changed
|
|
118
|
-
// Needs access to streaming / connection which are created later, so we use late-binding refs.
|
|
119
|
-
let _getToolMsgMap = () => new Map();
|
|
120
|
-
let _restoreToolMsgMap = () => {};
|
|
121
|
-
let _clearToolMsgMap = () => {};
|
|
122
|
-
|
|
123
|
-
function switchConversation(newConvId) {
|
|
124
|
-
const oldConvId = currentConversationId.value;
|
|
125
|
-
|
|
126
|
-
// Save current state (if there is one)
|
|
127
|
-
if (oldConvId) {
|
|
128
|
-
const streamState = streaming.saveState();
|
|
129
|
-
conversationCache.value[oldConvId] = {
|
|
130
|
-
messages: messages.value,
|
|
131
|
-
isProcessing: isProcessing.value,
|
|
132
|
-
isCompacting: isCompacting.value,
|
|
133
|
-
loadingHistory: loadingHistory.value,
|
|
134
|
-
claudeSessionId: currentClaudeSessionId.value,
|
|
135
|
-
visibleLimit: visibleLimit.value,
|
|
136
|
-
needsResume: needsResume.value,
|
|
137
|
-
streamingState: streamState,
|
|
138
|
-
toolMsgMap: _getToolMsgMap(),
|
|
139
|
-
messageIdCounter: streaming.getMessageIdCounter(),
|
|
140
|
-
queuedMessages: queuedMessages.value,
|
|
141
|
-
usageStats: usageStats.value,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Load target state
|
|
146
|
-
const cached = conversationCache.value[newConvId];
|
|
147
|
-
if (cached) {
|
|
148
|
-
messages.value = cached.messages;
|
|
149
|
-
isProcessing.value = cached.isProcessing;
|
|
150
|
-
isCompacting.value = cached.isCompacting;
|
|
151
|
-
loadingHistory.value = cached.loadingHistory || false;
|
|
152
|
-
currentClaudeSessionId.value = cached.claudeSessionId;
|
|
153
|
-
visibleLimit.value = cached.visibleLimit;
|
|
154
|
-
needsResume.value = cached.needsResume;
|
|
155
|
-
streaming.restoreState(cached.streamingState || { pendingText: '', streamingMessageId: null, messageIdCounter: cached.messageIdCounter || 0 });
|
|
156
|
-
// Background routing may have incremented messageIdCounter beyond what
|
|
157
|
-
// streamingState recorded at save time — use the authoritative value.
|
|
158
|
-
streaming.setMessageIdCounter(cached.messageIdCounter || 0);
|
|
159
|
-
_restoreToolMsgMap(cached.toolMsgMap || new Map());
|
|
160
|
-
queuedMessages.value = cached.queuedMessages || [];
|
|
161
|
-
usageStats.value = cached.usageStats || null;
|
|
162
|
-
} else {
|
|
163
|
-
// New blank conversation
|
|
164
|
-
messages.value = [];
|
|
165
|
-
isProcessing.value = false;
|
|
166
|
-
isCompacting.value = false;
|
|
167
|
-
loadingHistory.value = false;
|
|
168
|
-
currentClaudeSessionId.value = null;
|
|
169
|
-
visibleLimit.value = 50;
|
|
170
|
-
needsResume.value = false;
|
|
171
|
-
streaming.setMessageIdCounter(0);
|
|
172
|
-
streaming.setStreamingMessageId(null);
|
|
173
|
-
streaming.reset();
|
|
174
|
-
_clearToolMsgMap();
|
|
175
|
-
queuedMessages.value = [];
|
|
176
|
-
usageStats.value = null;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
currentConversationId.value = newConvId;
|
|
180
|
-
scrollToBottom(true);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Theme
|
|
184
|
-
const theme = ref(localStorage.getItem('agentlink-theme') || 'light');
|
|
185
|
-
function applyTheme() {
|
|
186
|
-
document.documentElement.setAttribute('data-theme', theme.value);
|
|
187
|
-
const link = document.getElementById('hljs-theme');
|
|
188
|
-
if (link) link.href = theme.value === 'light'
|
|
189
|
-
? '/vendor/github.min.css'
|
|
190
|
-
: '/vendor/github-dark.min.css';
|
|
191
|
-
}
|
|
192
|
-
function toggleTheme() {
|
|
193
|
-
theme.value = theme.value === 'dark' ? 'light' : 'dark';
|
|
194
|
-
localStorage.setItem('agentlink-theme', theme.value);
|
|
195
|
-
applyTheme();
|
|
196
|
-
}
|
|
197
|
-
applyTheme();
|
|
198
|
-
|
|
199
|
-
// ── Scroll management ──
|
|
200
|
-
let _scrollTimer = null;
|
|
201
|
-
let _userScrolledUp = false;
|
|
202
|
-
|
|
203
|
-
function onMessageListScroll(e) {
|
|
204
|
-
const el = e.target;
|
|
205
|
-
_userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > 80;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function scrollToBottom(force) {
|
|
209
|
-
if (_userScrolledUp && !force) return;
|
|
210
|
-
if (_scrollTimer) return;
|
|
211
|
-
_scrollTimer = setTimeout(() => {
|
|
212
|
-
_scrollTimer = null;
|
|
213
|
-
const el = document.querySelector('.message-list');
|
|
214
|
-
if (el) el.scrollTop = el.scrollHeight;
|
|
215
|
-
}, 50);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ── Highlight.js scheduling ──
|
|
219
|
-
let _hlTimer = null;
|
|
220
|
-
function scheduleHighlight() {
|
|
221
|
-
if (_hlTimer) return;
|
|
222
|
-
_hlTimer = setTimeout(() => {
|
|
223
|
-
_hlTimer = null;
|
|
224
|
-
if (typeof hljs !== 'undefined') {
|
|
225
|
-
document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
|
|
226
|
-
hljs.highlightElement(block);
|
|
227
|
-
block.dataset.highlighted = 'true';
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
}, 300);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// ── Create module instances ──
|
|
234
|
-
|
|
235
|
-
const streaming = createStreaming({ messages, scrollToBottom });
|
|
236
|
-
|
|
237
|
-
const fileAttach = createFileAttachments(attachments, fileInputRef, dragOver);
|
|
238
|
-
|
|
239
|
-
// Sidebar needs wsSend, but connection creates wsSend.
|
|
240
|
-
// Resolve circular dependency with a forwarding function.
|
|
241
|
-
let _wsSend = () => {};
|
|
242
|
-
|
|
243
|
-
const sidebar = createSidebar({
|
|
244
|
-
wsSend: (msg) => _wsSend(msg),
|
|
245
|
-
messages, isProcessing, sidebarOpen,
|
|
246
|
-
historySessions, currentClaudeSessionId, needsResume,
|
|
247
|
-
loadingSessions, loadingHistory, workDir, visibleLimit,
|
|
248
|
-
folderPickerOpen, folderPickerPath, folderPickerEntries,
|
|
249
|
-
folderPickerLoading, folderPickerSelected, streaming,
|
|
250
|
-
deleteConfirmOpen, deleteConfirmTitle,
|
|
251
|
-
renamingSessionId, renameText,
|
|
252
|
-
hostname, workdirHistory,
|
|
253
|
-
// Multi-session parallel
|
|
254
|
-
currentConversationId, conversationCache, processingConversations,
|
|
255
|
-
switchConversation,
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
const { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap } = createConnection({
|
|
259
|
-
status, agentName, hostname, workDir, sessionId, error,
|
|
260
|
-
serverVersion, agentVersion, latency,
|
|
261
|
-
messages, isProcessing, isCompacting, visibleLimit, queuedMessages, usageStats,
|
|
262
|
-
historySessions, currentClaudeSessionId, needsResume, loadingSessions, loadingHistory,
|
|
263
|
-
folderPickerLoading, folderPickerEntries, folderPickerPath,
|
|
264
|
-
authRequired, authPassword, authError, authAttempts, authLocked,
|
|
265
|
-
streaming, sidebar, scrollToBottom,
|
|
266
|
-
// Multi-session parallel
|
|
267
|
-
currentConversationId, processingConversations, conversationCache,
|
|
268
|
-
switchConversation,
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
// Now wire up the forwarding function
|
|
272
|
-
_wsSend = wsSend;
|
|
273
|
-
setDequeueNext(dequeueNext);
|
|
274
|
-
// Wire up late-binding toolMsgMap functions for switchConversation
|
|
275
|
-
_getToolMsgMap = getToolMsgMap;
|
|
276
|
-
_restoreToolMsgMap = restoreToolMsgMap;
|
|
277
|
-
_clearToolMsgMap = clearToolMsgMap;
|
|
278
|
-
|
|
279
|
-
// File browser module
|
|
280
|
-
const fileBrowser = createFileBrowser({
|
|
281
|
-
wsSend, workDir, inputText, inputRef, sendMessage,
|
|
282
|
-
filePanelOpen, filePanelWidth, fileTreeRoot, fileTreeLoading, fileContextMenu,
|
|
283
|
-
sidebarOpen, sidebarView,
|
|
284
|
-
});
|
|
285
|
-
setFileBrowser(fileBrowser);
|
|
286
|
-
|
|
287
|
-
// File preview module
|
|
288
|
-
const filePreview = createFilePreview({
|
|
289
|
-
wsSend, previewPanelOpen, previewPanelWidth, previewFile, previewLoading,
|
|
290
|
-
sidebarView, sidebarOpen, isMobile,
|
|
291
|
-
});
|
|
292
|
-
setFilePreview(filePreview);
|
|
293
|
-
|
|
294
|
-
// Track mobile state on resize
|
|
295
|
-
let _resizeHandler = () => { isMobile.value = window.innerWidth <= 768; };
|
|
296
|
-
window.addEventListener('resize', _resizeHandler);
|
|
297
|
-
|
|
298
|
-
// Close workdir menu on outside click or Escape
|
|
299
|
-
let _workdirMenuClickHandler = (e) => {
|
|
300
|
-
if (!workdirMenuOpen.value) return;
|
|
301
|
-
const row = document.querySelector('.sidebar-workdir-path-row');
|
|
302
|
-
const menu = document.querySelector('.workdir-menu');
|
|
303
|
-
if ((row && row.contains(e.target)) || (menu && menu.contains(e.target))) return;
|
|
304
|
-
workdirMenuOpen.value = false;
|
|
305
|
-
};
|
|
306
|
-
let _workdirMenuKeyHandler = (e) => {
|
|
307
|
-
if (e.key === 'Escape' && workdirMenuOpen.value) workdirMenuOpen.value = false;
|
|
308
|
-
};
|
|
309
|
-
document.addEventListener('click', _workdirMenuClickHandler);
|
|
310
|
-
document.addEventListener('keydown', _workdirMenuKeyHandler);
|
|
311
|
-
|
|
312
|
-
// ── Computed ──
|
|
313
|
-
const hasInput = computed(() => !!(inputText.value.trim() || attachments.value.length > 0));
|
|
314
|
-
const canSend = computed(() =>
|
|
315
|
-
status.value === 'Connected' && hasInput.value && !isCompacting.value
|
|
316
|
-
&& !messages.value.some(m => m.role === 'ask-question' && !m.answered)
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
// ── Auto-resize textarea ──
|
|
320
|
-
function autoResize() {
|
|
321
|
-
const ta = inputRef.value;
|
|
322
|
-
if (ta) {
|
|
323
|
-
ta.style.height = 'auto';
|
|
324
|
-
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// ── Send message ──
|
|
329
|
-
function sendMessage() {
|
|
330
|
-
if (!canSend.value) return;
|
|
331
|
-
|
|
332
|
-
const text = inputText.value.trim();
|
|
333
|
-
const files = attachments.value.slice();
|
|
334
|
-
inputText.value = '';
|
|
335
|
-
if (inputRef.value) inputRef.value.style.height = 'auto';
|
|
336
|
-
|
|
337
|
-
const msgAttachments = files.map(f => ({
|
|
338
|
-
name: f.name, size: f.size, isImage: f.isImage, thumbUrl: f.thumbUrl,
|
|
339
|
-
}));
|
|
340
|
-
|
|
341
|
-
const payload = { type: 'chat', prompt: text || '(see attached files)' };
|
|
342
|
-
if (currentConversationId.value) {
|
|
343
|
-
payload.conversationId = currentConversationId.value;
|
|
344
|
-
}
|
|
345
|
-
if (needsResume.value && currentClaudeSessionId.value) {
|
|
346
|
-
payload.resumeSessionId = currentClaudeSessionId.value;
|
|
347
|
-
needsResume.value = false;
|
|
348
|
-
}
|
|
349
|
-
if (files.length > 0) {
|
|
350
|
-
payload.files = files.map(f => ({
|
|
351
|
-
name: f.name, mimeType: f.mimeType, data: f.data,
|
|
352
|
-
}));
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const userMsg = {
|
|
356
|
-
id: streaming.nextId(), role: 'user',
|
|
357
|
-
content: text || (files.length > 0 ? `[${files.length} file${files.length > 1 ? 's' : ''} attached]` : ''),
|
|
358
|
-
attachments: msgAttachments.length > 0 ? msgAttachments : undefined,
|
|
359
|
-
timestamp: new Date(),
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
if (isProcessing.value) {
|
|
363
|
-
queuedMessages.value.push({ id: streaming.nextId(), content: userMsg.content, attachments: userMsg.attachments, payload });
|
|
364
|
-
} else {
|
|
365
|
-
userMsg.status = 'sent';
|
|
366
|
-
messages.value.push(userMsg);
|
|
367
|
-
isProcessing.value = true;
|
|
368
|
-
if (currentConversationId.value) {
|
|
369
|
-
processingConversations.value[currentConversationId.value] = true;
|
|
370
|
-
}
|
|
371
|
-
wsSend(payload);
|
|
372
|
-
}
|
|
373
|
-
scrollToBottom(true);
|
|
374
|
-
attachments.value = [];
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function cancelExecution() {
|
|
378
|
-
if (!isProcessing.value) return;
|
|
379
|
-
const cancelPayload = { type: 'cancel_execution' };
|
|
380
|
-
if (currentConversationId.value) {
|
|
381
|
-
cancelPayload.conversationId = currentConversationId.value;
|
|
382
|
-
}
|
|
383
|
-
wsSend(cancelPayload);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function dequeueNext() {
|
|
387
|
-
if (queuedMessages.value.length === 0) return;
|
|
388
|
-
const queued = queuedMessages.value.shift();
|
|
389
|
-
const userMsg = {
|
|
390
|
-
id: queued.id, role: 'user', status: 'sent',
|
|
391
|
-
content: queued.content, attachments: queued.attachments,
|
|
392
|
-
timestamp: new Date(),
|
|
393
|
-
};
|
|
394
|
-
messages.value.push(userMsg);
|
|
395
|
-
isProcessing.value = true;
|
|
396
|
-
if (currentConversationId.value) {
|
|
397
|
-
processingConversations.value[currentConversationId.value] = true;
|
|
398
|
-
}
|
|
399
|
-
wsSend(queued.payload);
|
|
400
|
-
scrollToBottom(true);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function removeQueuedMessage(msgId) {
|
|
404
|
-
const idx = queuedMessages.value.findIndex(m => m.id === msgId);
|
|
405
|
-
if (idx !== -1) queuedMessages.value.splice(idx, 1);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function handleKeydown(e) {
|
|
409
|
-
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
410
|
-
e.preventDefault();
|
|
411
|
-
sendMessage();
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// ── Template adapter wrappers ──
|
|
416
|
-
// These adapt the module function signatures to the template's call conventions.
|
|
417
|
-
function _isPrevAssistant(idx) {
|
|
418
|
-
return isPrevAssistant(visibleMessages.value, idx);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function _submitQuestionAnswer(msg) {
|
|
422
|
-
submitQuestionAnswer(msg, wsSend);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// ── Watchers ──
|
|
426
|
-
const messageCount = computed(() => messages.value.length);
|
|
427
|
-
watch(messageCount, () => { nextTick(scheduleHighlight); });
|
|
428
|
-
|
|
429
|
-
watch(hostname, (name) => {
|
|
430
|
-
document.title = name ? `${name} — AgentLink` : 'AgentLink';
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
// ── Usage formatting ──
|
|
434
|
-
function formatTokens(n) {
|
|
435
|
-
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
|
436
|
-
return String(n);
|
|
437
|
-
}
|
|
438
|
-
function formatUsage(u) {
|
|
439
|
-
if (!u) return '';
|
|
440
|
-
const pct = u.contextWindow ? Math.round(u.inputTokens / u.contextWindow * 100) : 0;
|
|
441
|
-
const ctx = formatTokens(u.inputTokens) + ' / ' + formatTokens(u.contextWindow) + ' (' + pct + '%)';
|
|
442
|
-
const cost = '$' + u.totalCost.toFixed(2);
|
|
443
|
-
const model = u.model.replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/-1m$/, '');
|
|
444
|
-
const dur = (u.durationMs / 1000).toFixed(1) + 's';
|
|
445
|
-
return 'Context ' + ctx + ' \u00b7 Cost ' + cost + ' \u00b7 ' + model + ' \u00b7 ' + dur;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// ── Lifecycle ──
|
|
449
|
-
onMounted(() => { connect(scheduleHighlight); });
|
|
450
|
-
onUnmounted(() => { closeWs(); streaming.cleanup(); window.removeEventListener('resize', _resizeHandler); document.removeEventListener('click', _workdirMenuClickHandler); document.removeEventListener('keydown', _workdirMenuKeyHandler); });
|
|
451
|
-
|
|
452
|
-
return {
|
|
453
|
-
status, agentName, hostname, workDir, sessionId, error,
|
|
454
|
-
serverVersion, agentVersion, latency,
|
|
455
|
-
messages, visibleMessages, hasMoreMessages, loadMoreMessages,
|
|
456
|
-
inputText, isProcessing, isCompacting, canSend, hasInput, inputRef, queuedMessages, usageStats,
|
|
457
|
-
sendMessage, handleKeydown, cancelExecution, removeQueuedMessage, onMessageListScroll,
|
|
458
|
-
getRenderedContent, copyMessage, toggleTool,
|
|
459
|
-
isPrevAssistant: _isPrevAssistant,
|
|
460
|
-
toggleContextSummary, formatTimestamp, formatUsage,
|
|
461
|
-
getToolIcon, getToolSummary, isEditTool, getEditDiffHtml, getFormattedToolInput, autoResize,
|
|
462
|
-
// AskUserQuestion
|
|
463
|
-
selectQuestionOption,
|
|
464
|
-
submitQuestionAnswer: _submitQuestionAnswer,
|
|
465
|
-
hasQuestionAnswer, getQuestionResponseSummary,
|
|
466
|
-
// Theme
|
|
467
|
-
theme, toggleTheme,
|
|
468
|
-
// Sidebar
|
|
469
|
-
sidebarOpen, historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
|
|
470
|
-
toggleSidebar: sidebar.toggleSidebar,
|
|
471
|
-
resumeSession: sidebar.resumeSession,
|
|
472
|
-
newConversation: sidebar.newConversation,
|
|
473
|
-
requestSessionList: sidebar.requestSessionList,
|
|
474
|
-
formatRelativeTime,
|
|
475
|
-
groupedSessions: sidebar.groupedSessions,
|
|
476
|
-
isSessionProcessing: sidebar.isSessionProcessing,
|
|
477
|
-
processingConversations,
|
|
478
|
-
// Folder picker
|
|
479
|
-
folderPickerOpen, folderPickerPath, folderPickerEntries,
|
|
480
|
-
folderPickerLoading, folderPickerSelected,
|
|
481
|
-
openFolderPicker: sidebar.openFolderPicker,
|
|
482
|
-
folderPickerNavigateUp: sidebar.folderPickerNavigateUp,
|
|
483
|
-
folderPickerSelectItem: sidebar.folderPickerSelectItem,
|
|
484
|
-
folderPickerEnter: sidebar.folderPickerEnter,
|
|
485
|
-
folderPickerGoToPath: sidebar.folderPickerGoToPath,
|
|
486
|
-
confirmFolderPicker: sidebar.confirmFolderPicker,
|
|
487
|
-
// Delete session
|
|
488
|
-
deleteConfirmOpen, deleteConfirmTitle,
|
|
489
|
-
deleteSession: sidebar.deleteSession,
|
|
490
|
-
confirmDeleteSession: sidebar.confirmDeleteSession,
|
|
491
|
-
cancelDeleteSession: sidebar.cancelDeleteSession,
|
|
492
|
-
// Rename session
|
|
493
|
-
renamingSessionId, renameText,
|
|
494
|
-
startRename: sidebar.startRename,
|
|
495
|
-
confirmRename: sidebar.confirmRename,
|
|
496
|
-
cancelRename: sidebar.cancelRename,
|
|
497
|
-
// Working directory history
|
|
498
|
-
filteredWorkdirHistory: sidebar.filteredWorkdirHistory,
|
|
499
|
-
switchToWorkdir: sidebar.switchToWorkdir,
|
|
500
|
-
removeFromWorkdirHistory: sidebar.removeFromWorkdirHistory,
|
|
501
|
-
// Authentication
|
|
502
|
-
authRequired, authPassword, authError, authAttempts, authLocked,
|
|
503
|
-
submitPassword,
|
|
504
|
-
// File attachments
|
|
505
|
-
attachments, fileInputRef, dragOver,
|
|
506
|
-
triggerFileInput: fileAttach.triggerFileInput,
|
|
507
|
-
handleFileSelect: fileAttach.handleFileSelect,
|
|
508
|
-
removeAttachment: fileAttach.removeAttachment,
|
|
509
|
-
formatFileSize,
|
|
510
|
-
handleDragOver: fileAttach.handleDragOver,
|
|
511
|
-
handleDragLeave: fileAttach.handleDragLeave,
|
|
512
|
-
handleDrop: fileAttach.handleDrop,
|
|
513
|
-
handlePaste: fileAttach.handlePaste,
|
|
514
|
-
// File browser
|
|
515
|
-
filePanelOpen, filePanelWidth, fileTreeRoot, fileTreeLoading, fileContextMenu,
|
|
516
|
-
sidebarView, isMobile, fileBrowser,
|
|
517
|
-
flattenedTree: fileBrowser.flattenedTree,
|
|
518
|
-
// File preview
|
|
519
|
-
previewPanelOpen, previewPanelWidth, previewFile, previewLoading, filePreview,
|
|
520
|
-
workdirMenuOpen,
|
|
521
|
-
toggleWorkdirMenu() { workdirMenuOpen.value = !workdirMenuOpen.value; },
|
|
522
|
-
workdirMenuBrowse() {
|
|
523
|
-
workdirMenuOpen.value = false;
|
|
524
|
-
if (isMobile.value) { sidebarView.value = 'files'; fileBrowser.openPanel(); }
|
|
525
|
-
else { fileBrowser.togglePanel(); }
|
|
526
|
-
},
|
|
527
|
-
workdirMenuChangeDir() {
|
|
528
|
-
workdirMenuOpen.value = false;
|
|
529
|
-
sidebar.openFolderPicker();
|
|
530
|
-
},
|
|
531
|
-
workdirMenuCopyPath() {
|
|
532
|
-
workdirMenuOpen.value = false;
|
|
533
|
-
navigator.clipboard.writeText(workDir.value);
|
|
534
|
-
},
|
|
535
|
-
};
|
|
536
|
-
},
|
|
537
|
-
template: `
|
|
538
|
-
<div class="layout">
|
|
539
|
-
<header class="top-bar">
|
|
540
|
-
<div class="top-bar-left">
|
|
541
|
-
<button class="sidebar-toggle" @click="toggleSidebar" title="Toggle sidebar">
|
|
542
|
-
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
|
543
|
-
</button>
|
|
544
|
-
<h1>AgentLink</h1>
|
|
545
|
-
</div>
|
|
546
|
-
<div class="top-bar-info">
|
|
547
|
-
<span :class="['badge', status.toLowerCase()]">{{ status }}</span>
|
|
548
|
-
<span v-if="latency !== null && status === 'Connected'" class="latency" :class="{ good: latency < 100, ok: latency >= 100 && latency < 500, bad: latency >= 500 }">{{ latency }}ms</span>
|
|
549
|
-
<span v-if="agentName" class="agent-label">{{ agentName }}</span>
|
|
550
|
-
<button class="theme-toggle" @click="toggleTheme" :title="theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
|
|
551
|
-
<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>
|
|
552
|
-
<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>
|
|
553
|
-
</button>
|
|
554
|
-
</div>
|
|
555
|
-
</header>
|
|
556
|
-
|
|
557
|
-
<div v-if="status === 'No Session' || (status !== 'Connected' && status !== 'Connecting...' && status !== 'Reconnecting...' && messages.length === 0)" class="center-card">
|
|
558
|
-
<div class="status-card">
|
|
559
|
-
<p class="status">
|
|
560
|
-
<span class="label">Status:</span>
|
|
561
|
-
<span :class="['badge', status.toLowerCase()]">{{ status }}</span>
|
|
562
|
-
</p>
|
|
563
|
-
<p v-if="agentName" class="info"><span class="label">Agent:</span> {{ agentName }}</p>
|
|
564
|
-
<p v-if="workDir" class="info"><span class="label">Directory:</span> {{ workDir }}</p>
|
|
565
|
-
<p v-if="sessionId" class="info muted"><span class="label">Session:</span> {{ sessionId }}</p>
|
|
566
|
-
<p v-if="error" class="error-msg">{{ error }}</p>
|
|
567
|
-
</div>
|
|
568
|
-
</div>
|
|
569
|
-
|
|
570
|
-
<div v-else class="main-body">
|
|
571
|
-
<!-- Sidebar backdrop (mobile) -->
|
|
572
|
-
<div v-if="sidebarOpen" class="sidebar-backdrop" @click="toggleSidebar(); sidebarView = 'sessions'"></div>
|
|
573
|
-
<!-- Sidebar -->
|
|
574
|
-
<aside v-if="sidebarOpen" class="sidebar">
|
|
575
|
-
<!-- Mobile: file browser view -->
|
|
576
|
-
<div v-if="isMobile && sidebarView === 'files'" class="file-panel-mobile">
|
|
577
|
-
<div class="file-panel-mobile-header">
|
|
578
|
-
<button class="file-panel-mobile-back" @click="sidebarView = 'sessions'">
|
|
579
|
-
<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>
|
|
580
|
-
Sessions
|
|
581
|
-
</button>
|
|
582
|
-
<button class="file-panel-btn" @click="fileBrowser.refreshTree()" title="Refresh">
|
|
583
|
-
<svg 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>
|
|
584
|
-
</button>
|
|
585
|
-
</div>
|
|
586
|
-
<div class="file-panel-breadcrumb" :title="workDir">{{ workDir }}</div>
|
|
587
|
-
<div v-if="fileTreeLoading" class="file-panel-loading">Loading...</div>
|
|
588
|
-
<div v-else-if="!fileTreeRoot || !fileTreeRoot.children || fileTreeRoot.children.length === 0" class="file-panel-empty">
|
|
589
|
-
No files found.
|
|
590
|
-
</div>
|
|
591
|
-
<div v-else class="file-tree">
|
|
592
|
-
<template v-for="item in flattenedTree" :key="item.node.path">
|
|
593
|
-
<div
|
|
594
|
-
class="file-tree-item"
|
|
595
|
-
:class="{ folder: item.node.type === 'directory' }"
|
|
596
|
-
:style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
|
|
597
|
-
@click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : filePreview.openPreview(item.node.path)"
|
|
598
|
-
@contextmenu.prevent="item.node.type !== 'directory' ? fileBrowser.onFileClick($event, item.node) : null"
|
|
599
|
-
>
|
|
600
|
-
<span v-if="item.node.type === 'directory'" class="file-tree-arrow" :class="{ expanded: item.node.expanded }">▶</span>
|
|
601
|
-
<span v-else class="file-tree-file-icon">
|
|
602
|
-
<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
|
603
|
-
</span>
|
|
604
|
-
<span class="file-tree-name" :title="item.node.path">{{ item.node.name }}</span>
|
|
605
|
-
<span v-if="item.node.loading" class="file-tree-spinner"></span>
|
|
606
|
-
</div>
|
|
607
|
-
<div v-if="item.node.type === 'directory' && item.node.expanded && item.node.children && item.node.children.length === 0 && !item.node.loading" class="file-tree-empty" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">(empty)</div>
|
|
608
|
-
<div v-if="item.node.error" class="file-tree-error" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">{{ item.node.error }}</div>
|
|
609
|
-
</template>
|
|
610
|
-
</div>
|
|
611
|
-
</div>
|
|
612
|
-
|
|
613
|
-
<!-- Mobile: file preview view -->
|
|
614
|
-
<div v-else-if="isMobile && sidebarView === 'preview'" class="file-preview-mobile">
|
|
615
|
-
<div class="file-preview-mobile-header">
|
|
616
|
-
<button class="file-panel-mobile-back" @click="filePreview.closePreview()">
|
|
617
|
-
<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>
|
|
618
|
-
Files
|
|
619
|
-
</button>
|
|
620
|
-
<span v-if="previewFile" class="file-preview-mobile-size">
|
|
621
|
-
{{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
622
|
-
</span>
|
|
623
|
-
</div>
|
|
624
|
-
<div class="file-preview-mobile-filename" :title="previewFile?.filePath">
|
|
625
|
-
{{ previewFile?.fileName || 'Preview' }}
|
|
626
|
-
</div>
|
|
627
|
-
<div class="preview-panel-body">
|
|
628
|
-
<div v-if="previewLoading" class="preview-loading">Loading...</div>
|
|
629
|
-
<div v-else-if="previewFile?.error" class="preview-error">
|
|
630
|
-
{{ previewFile.error }}
|
|
631
|
-
</div>
|
|
632
|
-
<div v-else-if="previewFile?.encoding === 'base64' && previewFile?.content"
|
|
633
|
-
class="preview-image-container">
|
|
634
|
-
<img :src="'data:' + previewFile.mimeType + ';base64,' + previewFile.content"
|
|
635
|
-
:alt="previewFile.fileName" class="preview-image" />
|
|
636
|
-
</div>
|
|
637
|
-
<div v-else-if="previewFile?.content" class="preview-text-container">
|
|
638
|
-
<pre class="preview-code"><code v-html="filePreview.highlightCode(previewFile.content, previewFile.fileName)"></code></pre>
|
|
639
|
-
<div v-if="previewFile.truncated" class="preview-truncated-notice">
|
|
640
|
-
File truncated — showing first 100 KB of {{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
641
|
-
</div>
|
|
642
|
-
</div>
|
|
643
|
-
<div v-else-if="previewFile && !previewFile.content && !previewFile.error" class="preview-binary-info">
|
|
644
|
-
<p>Binary file — {{ previewFile.mimeType }}</p>
|
|
645
|
-
<p>{{ filePreview.formatFileSize(previewFile.totalSize) }}</p>
|
|
646
|
-
</div>
|
|
647
|
-
</div>
|
|
648
|
-
</div>
|
|
649
|
-
|
|
650
|
-
<!-- Normal sidebar content (sessions view) -->
|
|
651
|
-
<template v-else>
|
|
652
|
-
<div class="sidebar-section">
|
|
653
|
-
<div class="sidebar-workdir">
|
|
654
|
-
<div v-if="hostname" class="sidebar-hostname">
|
|
655
|
-
<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>
|
|
656
|
-
<span>{{ hostname }}</span>
|
|
657
|
-
</div>
|
|
658
|
-
<div class="sidebar-workdir-header">
|
|
659
|
-
<div class="sidebar-workdir-label">Working Directory</div>
|
|
660
|
-
</div>
|
|
661
|
-
<div class="sidebar-workdir-path-row" @click.stop="toggleWorkdirMenu()">
|
|
662
|
-
<div class="sidebar-workdir-path" :title="workDir">{{ workDir }}</div>
|
|
663
|
-
<svg class="sidebar-workdir-chevron" :class="{ open: workdirMenuOpen }" viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg>
|
|
664
|
-
</div>
|
|
665
|
-
<div v-if="workdirMenuOpen" class="workdir-menu">
|
|
666
|
-
<div class="workdir-menu-item" @click.stop="workdirMenuBrowse()">
|
|
667
|
-
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10zM8 13h8v2H8v-2z"/></svg>
|
|
668
|
-
<span>Browse files</span>
|
|
669
|
-
</div>
|
|
670
|
-
<div class="workdir-menu-item" @click.stop="workdirMenuChangeDir()">
|
|
671
|
-
<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>
|
|
672
|
-
<span>Change directory</span>
|
|
673
|
-
</div>
|
|
674
|
-
<div class="workdir-menu-item" @click.stop="workdirMenuCopyPath()">
|
|
675
|
-
<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>
|
|
676
|
-
<span>Copy path</span>
|
|
677
|
-
</div>
|
|
678
|
-
</div>
|
|
679
|
-
<div v-if="filteredWorkdirHistory.length > 0" class="workdir-history">
|
|
680
|
-
<div class="workdir-history-label">Recent Directories</div>
|
|
681
|
-
<div class="workdir-history-list">
|
|
682
|
-
<div
|
|
683
|
-
v-for="path in filteredWorkdirHistory" :key="path"
|
|
684
|
-
class="workdir-history-item"
|
|
685
|
-
@click="switchToWorkdir(path)"
|
|
686
|
-
:title="path"
|
|
687
|
-
>
|
|
688
|
-
<span class="workdir-history-path">{{ path }}</span>
|
|
689
|
-
<button class="workdir-history-delete" @click.stop="removeFromWorkdirHistory(path)" title="Remove from history">
|
|
690
|
-
<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>
|
|
691
|
-
</button>
|
|
692
|
-
</div>
|
|
693
|
-
</div>
|
|
694
|
-
</div>
|
|
695
|
-
</div>
|
|
696
|
-
</div>
|
|
697
|
-
|
|
698
|
-
<div class="sidebar-section sidebar-sessions">
|
|
699
|
-
<div class="sidebar-section-header">
|
|
700
|
-
<span>History</span>
|
|
701
|
-
<button class="sidebar-refresh-btn" @click="requestSessionList" title="Refresh" :disabled="loadingSessions">
|
|
702
|
-
<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>
|
|
703
|
-
</button>
|
|
704
|
-
</div>
|
|
705
|
-
|
|
706
|
-
<button class="new-conversation-btn" @click="newConversation">
|
|
707
|
-
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
|
708
|
-
New conversation
|
|
709
|
-
</button>
|
|
710
|
-
|
|
711
|
-
<div v-if="loadingSessions && historySessions.length === 0" class="sidebar-loading">
|
|
712
|
-
Loading sessions...
|
|
713
|
-
</div>
|
|
714
|
-
<div v-else-if="historySessions.length === 0" class="sidebar-empty">
|
|
715
|
-
No previous sessions found.
|
|
716
|
-
</div>
|
|
717
|
-
<div v-else class="session-list">
|
|
718
|
-
<div v-for="group in groupedSessions" :key="group.label" class="session-group">
|
|
719
|
-
<div class="session-group-label">{{ group.label }}</div>
|
|
720
|
-
<div
|
|
721
|
-
v-for="s in group.sessions" :key="s.sessionId"
|
|
722
|
-
:class="['session-item', { active: currentClaudeSessionId === s.sessionId, processing: isSessionProcessing(s.sessionId) }]"
|
|
723
|
-
@click="renamingSessionId !== s.sessionId && resumeSession(s)"
|
|
724
|
-
:title="s.preview"
|
|
725
|
-
:aria-label="(s.title || s.sessionId.slice(0, 8)) + (isSessionProcessing(s.sessionId) ? ' (processing)' : '')"
|
|
726
|
-
>
|
|
727
|
-
<div v-if="renamingSessionId === s.sessionId" class="session-rename-row">
|
|
728
|
-
<input
|
|
729
|
-
class="session-rename-input"
|
|
730
|
-
v-model="renameText"
|
|
731
|
-
@click.stop
|
|
732
|
-
@keydown.enter.stop="confirmRename"
|
|
733
|
-
@keydown.escape.stop="cancelRename"
|
|
734
|
-
@vue:mounted="$event.el.focus()"
|
|
735
|
-
/>
|
|
736
|
-
<button class="session-rename-ok" @click.stop="confirmRename" title="Confirm">✓</button>
|
|
737
|
-
<button class="session-rename-cancel" @click.stop="cancelRename" title="Cancel">×</button>
|
|
738
|
-
</div>
|
|
739
|
-
<div v-else class="session-title">{{ s.title }}</div>
|
|
740
|
-
<div class="session-meta">
|
|
741
|
-
<span>{{ formatRelativeTime(s.lastModified) }}</span>
|
|
742
|
-
<span v-if="renamingSessionId !== s.sessionId" class="session-actions">
|
|
743
|
-
<button
|
|
744
|
-
class="session-rename-btn"
|
|
745
|
-
@click.stop="startRename(s)"
|
|
746
|
-
title="Rename session"
|
|
747
|
-
>
|
|
748
|
-
<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
|
749
|
-
</button>
|
|
750
|
-
<button
|
|
751
|
-
v-if="currentClaudeSessionId !== s.sessionId"
|
|
752
|
-
class="session-delete-btn"
|
|
753
|
-
@click.stop="deleteSession(s)"
|
|
754
|
-
title="Delete session"
|
|
755
|
-
>
|
|
756
|
-
<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>
|
|
757
|
-
</button>
|
|
758
|
-
</span>
|
|
759
|
-
</div>
|
|
760
|
-
</div>
|
|
761
|
-
</div>
|
|
762
|
-
</div>
|
|
763
|
-
</div>
|
|
764
|
-
|
|
765
|
-
<div v-if="serverVersion || agentVersion" class="sidebar-version-footer">
|
|
766
|
-
<span v-if="serverVersion">server {{ serverVersion }}</span>
|
|
767
|
-
<span v-if="serverVersion && agentVersion" class="sidebar-version-sep">/</span>
|
|
768
|
-
<span v-if="agentVersion">agent {{ agentVersion }}</span>
|
|
769
|
-
</div>
|
|
770
|
-
</template>
|
|
771
|
-
</aside>
|
|
772
|
-
|
|
773
|
-
<!-- File browser panel (desktop) -->
|
|
774
|
-
<Transition name="file-panel">
|
|
775
|
-
<div v-if="filePanelOpen && !isMobile" class="file-panel" :style="{ width: filePanelWidth + 'px' }">
|
|
776
|
-
<div class="file-panel-resize-handle" @mousedown="fileBrowser.onResizeStart($event)" @touchstart="fileBrowser.onResizeStart($event)"></div>
|
|
777
|
-
<div class="file-panel-header">
|
|
778
|
-
<span class="file-panel-title">Files</span>
|
|
779
|
-
<div class="file-panel-actions">
|
|
780
|
-
<button class="file-panel-btn" @click="fileBrowser.refreshTree()" title="Refresh">
|
|
781
|
-
<svg 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>
|
|
782
|
-
</button>
|
|
783
|
-
<button class="file-panel-btn" @click="filePanelOpen = false" title="Close">
|
|
784
|
-
<svg viewBox="0 0 24 24" width="14" height="14"><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>
|
|
785
|
-
</button>
|
|
786
|
-
</div>
|
|
787
|
-
</div>
|
|
788
|
-
<div class="file-panel-breadcrumb" :title="workDir">{{ workDir }}</div>
|
|
789
|
-
<div v-if="fileTreeLoading" class="file-panel-loading">Loading...</div>
|
|
790
|
-
<div v-else-if="!fileTreeRoot || !fileTreeRoot.children || fileTreeRoot.children.length === 0" class="file-panel-empty">
|
|
791
|
-
No files found.
|
|
792
|
-
</div>
|
|
793
|
-
<div v-else class="file-tree">
|
|
794
|
-
<template v-for="item in flattenedTree" :key="item.node.path">
|
|
795
|
-
<div
|
|
796
|
-
class="file-tree-item"
|
|
797
|
-
:class="{ folder: item.node.type === 'directory' }"
|
|
798
|
-
:style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
|
|
799
|
-
@click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : filePreview.openPreview(item.node.path)"
|
|
800
|
-
@contextmenu.prevent="item.node.type !== 'directory' ? fileBrowser.onFileClick($event, item.node) : null"
|
|
801
|
-
>
|
|
802
|
-
<span v-if="item.node.type === 'directory'" class="file-tree-arrow" :class="{ expanded: item.node.expanded }">▶</span>
|
|
803
|
-
<span v-else class="file-tree-file-icon">
|
|
804
|
-
<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
|
805
|
-
</span>
|
|
806
|
-
<span class="file-tree-name" :title="item.node.path">{{ item.node.name }}</span>
|
|
807
|
-
<span v-if="item.node.loading" class="file-tree-spinner"></span>
|
|
808
|
-
</div>
|
|
809
|
-
<div v-if="item.node.type === 'directory' && item.node.expanded && item.node.children && item.node.children.length === 0 && !item.node.loading" class="file-tree-empty" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">(empty)</div>
|
|
810
|
-
<div v-if="item.node.error" class="file-tree-error" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">{{ item.node.error }}</div>
|
|
811
|
-
</template>
|
|
812
|
-
</div>
|
|
813
|
-
</div>
|
|
814
|
-
</Transition>
|
|
815
|
-
|
|
816
|
-
<!-- Chat area -->
|
|
817
|
-
<div class="chat-area">
|
|
818
|
-
<div class="message-list" @scroll="onMessageListScroll">
|
|
819
|
-
<div class="message-list-inner">
|
|
820
|
-
<div v-if="messages.length === 0 && status === 'Connected' && !loadingHistory" class="empty-state">
|
|
821
|
-
<div class="empty-state-icon">
|
|
822
|
-
<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>
|
|
823
|
-
</div>
|
|
824
|
-
<p>Connected to <strong>{{ agentName }}</strong></p>
|
|
825
|
-
<p class="muted">{{ workDir }}</p>
|
|
826
|
-
<p class="muted" style="margin-top: 0.5rem;">Send a message to start.</p>
|
|
827
|
-
</div>
|
|
828
|
-
|
|
829
|
-
<div v-if="loadingHistory" class="history-loading">
|
|
830
|
-
<div class="history-loading-spinner"></div>
|
|
831
|
-
<span>Loading conversation history...</span>
|
|
832
|
-
</div>
|
|
833
|
-
|
|
834
|
-
<div v-if="hasMoreMessages" class="load-more-wrapper">
|
|
835
|
-
<button class="load-more-btn" @click="loadMoreMessages">Load earlier messages</button>
|
|
836
|
-
</div>
|
|
837
|
-
|
|
838
|
-
<div v-for="(msg, msgIdx) in visibleMessages" :key="msg.id" :class="['message', 'message-' + msg.role]">
|
|
839
|
-
|
|
840
|
-
<!-- User message -->
|
|
841
|
-
<template v-if="msg.role === 'user'">
|
|
842
|
-
<div class="message-role-label user-label">You</div>
|
|
843
|
-
<div class="message-bubble user-bubble" :title="formatTimestamp(msg.timestamp)">
|
|
844
|
-
<div class="message-content">{{ msg.content }}</div>
|
|
845
|
-
<div v-if="msg.attachments && msg.attachments.length" class="message-attachments">
|
|
846
|
-
<div v-for="(att, ai) in msg.attachments" :key="ai" class="message-attachment-chip">
|
|
847
|
-
<img v-if="att.isImage && att.thumbUrl" :src="att.thumbUrl" class="message-attachment-thumb" />
|
|
848
|
-
<span v-else class="message-attachment-file-icon">
|
|
849
|
-
<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>
|
|
850
|
-
</span>
|
|
851
|
-
<span>{{ att.name }}</span>
|
|
852
|
-
</div>
|
|
853
|
-
</div>
|
|
854
|
-
</div>
|
|
855
|
-
</template>
|
|
856
|
-
|
|
857
|
-
<!-- Assistant message (markdown) -->
|
|
858
|
-
<template v-else-if="msg.role === 'assistant'">
|
|
859
|
-
<div v-if="!isPrevAssistant(msgIdx)" class="message-role-label assistant-label">Claude</div>
|
|
860
|
-
<div :class="['message-bubble', 'assistant-bubble', { streaming: msg.isStreaming }]" :title="formatTimestamp(msg.timestamp)">
|
|
861
|
-
<div class="message-actions">
|
|
862
|
-
<button class="icon-btn" @click="copyMessage(msg)" :title="msg.copied ? 'Copied!' : 'Copy'">
|
|
863
|
-
<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>
|
|
864
|
-
<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>
|
|
865
|
-
</button>
|
|
866
|
-
</div>
|
|
867
|
-
<div class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
|
|
868
|
-
</div>
|
|
869
|
-
</template>
|
|
870
|
-
|
|
871
|
-
<!-- Tool use block (collapsible) -->
|
|
872
|
-
<div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
|
|
873
|
-
<div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
|
|
874
|
-
<span class="tool-icon" v-html="getToolIcon(msg.toolName)"></span>
|
|
875
|
-
<span class="tool-name">{{ msg.toolName }}</span>
|
|
876
|
-
<span class="tool-summary">{{ getToolSummary(msg) }}</span>
|
|
877
|
-
<span class="tool-status-icon" v-if="msg.hasResult">\u{2713}</span>
|
|
878
|
-
<span class="tool-status-icon running-dots" v-else>
|
|
879
|
-
<span></span><span></span><span></span>
|
|
880
|
-
</span>
|
|
881
|
-
<span class="tool-toggle">{{ msg.expanded ? '\u{25B2}' : '\u{25BC}' }}</span>
|
|
882
|
-
</div>
|
|
883
|
-
<div v-show="msg.expanded" class="tool-expand">
|
|
884
|
-
<div v-if="isEditTool(msg) && getEditDiffHtml(msg)" class="tool-diff" v-html="getEditDiffHtml(msg)"></div>
|
|
885
|
-
<div v-else-if="getFormattedToolInput(msg)" class="tool-input-formatted" v-html="getFormattedToolInput(msg)"></div>
|
|
886
|
-
<pre v-else-if="msg.toolInput" class="tool-block">{{ msg.toolInput }}</pre>
|
|
887
|
-
<pre v-if="msg.toolOutput" class="tool-block tool-output">{{ msg.toolOutput }}</pre>
|
|
888
|
-
</div>
|
|
889
|
-
</div>
|
|
890
|
-
|
|
891
|
-
<!-- AskUserQuestion interactive card -->
|
|
892
|
-
<div v-else-if="msg.role === 'ask-question'" class="ask-question-wrapper">
|
|
893
|
-
<div v-if="!msg.answered" class="ask-question-card">
|
|
894
|
-
<div v-for="(q, qi) in msg.questions" :key="qi" class="ask-question-block">
|
|
895
|
-
<div v-if="q.header" class="ask-question-header">{{ q.header }}</div>
|
|
896
|
-
<div class="ask-question-text">{{ q.question }}</div>
|
|
897
|
-
<div class="ask-question-options">
|
|
898
|
-
<div
|
|
899
|
-
v-for="(opt, oi) in q.options" :key="oi"
|
|
900
|
-
:class="['ask-question-option', {
|
|
901
|
-
selected: q.multiSelect
|
|
902
|
-
? (msg.selectedAnswers[qi] || []).includes(opt.label)
|
|
903
|
-
: msg.selectedAnswers[qi] === opt.label
|
|
904
|
-
}]"
|
|
905
|
-
@click="selectQuestionOption(msg, qi, opt.label)"
|
|
906
|
-
>
|
|
907
|
-
<div class="ask-option-label">{{ opt.label }}</div>
|
|
908
|
-
<div v-if="opt.description" class="ask-option-desc">{{ opt.description }}</div>
|
|
909
|
-
</div>
|
|
910
|
-
</div>
|
|
911
|
-
<div class="ask-question-custom">
|
|
912
|
-
<input
|
|
913
|
-
type="text"
|
|
914
|
-
v-model="msg.customTexts[qi]"
|
|
915
|
-
placeholder="Or type a custom response..."
|
|
916
|
-
@input="msg.selectedAnswers[qi] = q.multiSelect ? [] : null"
|
|
917
|
-
@keydown.enter="hasQuestionAnswer(msg) && submitQuestionAnswer(msg)"
|
|
918
|
-
/>
|
|
919
|
-
</div>
|
|
920
|
-
</div>
|
|
921
|
-
<div class="ask-question-actions">
|
|
922
|
-
<button class="ask-question-submit" :disabled="!hasQuestionAnswer(msg)" @click="submitQuestionAnswer(msg)">
|
|
923
|
-
Submit
|
|
924
|
-
</button>
|
|
925
|
-
</div>
|
|
926
|
-
</div>
|
|
927
|
-
<div v-else class="ask-question-answered">
|
|
928
|
-
<span class="ask-answered-icon">\u{2713}</span>
|
|
929
|
-
<span class="ask-answered-text">{{ getQuestionResponseSummary(msg) }}</span>
|
|
930
|
-
</div>
|
|
931
|
-
</div>
|
|
932
|
-
|
|
933
|
-
<!-- Context summary (collapsed by default) -->
|
|
934
|
-
<div v-else-if="msg.role === 'context-summary'" class="context-summary-wrapper">
|
|
935
|
-
<div class="context-summary-bar" @click="toggleContextSummary(msg)">
|
|
936
|
-
<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>
|
|
937
|
-
<span class="context-summary-label">Context continued from previous conversation</span>
|
|
938
|
-
<span class="context-summary-toggle">{{ msg.contextExpanded ? 'Hide' : 'Show' }}</span>
|
|
939
|
-
</div>
|
|
940
|
-
<div v-if="msg.contextExpanded" class="context-summary-body">
|
|
941
|
-
<div class="markdown-body" v-html="getRenderedContent({ role: 'assistant', content: msg.content })"></div>
|
|
942
|
-
</div>
|
|
943
|
-
</div>
|
|
944
|
-
|
|
945
|
-
<!-- System message -->
|
|
946
|
-
<div v-else-if="msg.role === 'system'" :class="['system-msg', { 'compact-msg': msg.isCompactStart, 'command-output-msg': msg.isCommandOutput, 'error-msg': msg.isError }]">
|
|
947
|
-
<template v-if="msg.isCompactStart && !msg.compactDone">
|
|
948
|
-
<span class="compact-inline-spinner"></span>
|
|
949
|
-
</template>
|
|
950
|
-
<template v-if="msg.isCompactStart && msg.compactDone">
|
|
951
|
-
<span class="compact-done-icon">✓</span>
|
|
952
|
-
</template>
|
|
953
|
-
<div v-if="msg.isCommandOutput" class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
|
|
954
|
-
<template v-else>{{ msg.content }}</template>
|
|
955
|
-
</div>
|
|
956
|
-
</div>
|
|
957
|
-
|
|
958
|
-
<div v-if="isProcessing && !messages.some(m => m.isStreaming)" class="typing-indicator">
|
|
959
|
-
<span></span><span></span><span></span>
|
|
960
|
-
</div>
|
|
961
|
-
</div>
|
|
962
|
-
</div>
|
|
963
|
-
|
|
964
|
-
<div class="input-area">
|
|
965
|
-
<input
|
|
966
|
-
type="file"
|
|
967
|
-
ref="fileInputRef"
|
|
968
|
-
multiple
|
|
969
|
-
style="display: none"
|
|
970
|
-
@change="handleFileSelect"
|
|
971
|
-
accept="image/*,text/*,.pdf,.json,.md,.py,.js,.ts,.tsx,.jsx,.css,.html,.xml,.yaml,.yml,.toml,.sh,.sql,.csv"
|
|
972
|
-
/>
|
|
973
|
-
<div v-if="queuedMessages.length > 0" class="queue-bar">
|
|
974
|
-
<div v-for="(qm, qi) in queuedMessages" :key="qm.id" class="queue-item">
|
|
975
|
-
<span class="queue-item-num">{{ qi + 1 }}.</span>
|
|
976
|
-
<span class="queue-item-text">{{ qm.content }}</span>
|
|
977
|
-
<span v-if="qm.attachments && qm.attachments.length" class="queue-item-attach" :title="qm.attachments.map(a => a.name).join(', ')">
|
|
978
|
-
<svg viewBox="0 0 24 24" width="11" height="11"><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>
|
|
979
|
-
{{ qm.attachments.length }}
|
|
980
|
-
</span>
|
|
981
|
-
<button class="queue-item-remove" @click="removeQueuedMessage(qm.id)" title="Remove from queue">×</button>
|
|
982
|
-
</div>
|
|
983
|
-
</div>
|
|
984
|
-
<div v-if="usageStats" class="usage-bar">{{ formatUsage(usageStats) }}</div>
|
|
985
|
-
<div
|
|
986
|
-
:class="['input-card', { 'drag-over': dragOver }]"
|
|
987
|
-
@dragover="handleDragOver"
|
|
988
|
-
@dragleave="handleDragLeave"
|
|
989
|
-
@drop="handleDrop"
|
|
990
|
-
>
|
|
991
|
-
<textarea
|
|
992
|
-
ref="inputRef"
|
|
993
|
-
v-model="inputText"
|
|
994
|
-
@keydown="handleKeydown"
|
|
995
|
-
@input="autoResize"
|
|
996
|
-
@paste="handlePaste"
|
|
997
|
-
:disabled="status !== 'Connected' || isCompacting"
|
|
998
|
-
:placeholder="isCompacting ? 'Context compacting in progress...' : 'Send a message · Enter to send'"
|
|
999
|
-
rows="1"
|
|
1000
|
-
></textarea>
|
|
1001
|
-
<div v-if="attachments.length > 0" class="attachment-bar">
|
|
1002
|
-
<div v-for="(att, i) in attachments" :key="i" class="attachment-chip">
|
|
1003
|
-
<img v-if="att.isImage && att.thumbUrl" :src="att.thumbUrl" class="attachment-thumb" />
|
|
1004
|
-
<div v-else class="attachment-file-icon">
|
|
1005
|
-
<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>
|
|
1006
|
-
</div>
|
|
1007
|
-
<div class="attachment-info">
|
|
1008
|
-
<div class="attachment-name">{{ att.name }}</div>
|
|
1009
|
-
<div class="attachment-size">{{ formatFileSize(att.size) }}</div>
|
|
1010
|
-
</div>
|
|
1011
|
-
<button class="attachment-remove" @click="removeAttachment(i)" title="Remove">×</button>
|
|
1012
|
-
</div>
|
|
1013
|
-
</div>
|
|
1014
|
-
<div class="input-bottom-row">
|
|
1015
|
-
<button class="attach-btn" @click="triggerFileInput" :disabled="status !== 'Connected' || isCompacting || attachments.length >= 5" title="Attach files">
|
|
1016
|
-
<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>
|
|
1017
|
-
</button>
|
|
1018
|
-
<button v-if="isProcessing && !hasInput" @click="cancelExecution" class="send-btn stop-btn" title="Stop generation">
|
|
1019
|
-
<svg viewBox="0 0 24 24" width="14" height="14"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
|
|
1020
|
-
</button>
|
|
1021
|
-
<button v-else @click="sendMessage" :disabled="!canSend" class="send-btn" title="Send">
|
|
1022
|
-
<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>
|
|
1023
|
-
</button>
|
|
1024
|
-
</div>
|
|
1025
|
-
</div>
|
|
1026
|
-
</div>
|
|
1027
|
-
</div>
|
|
1028
|
-
|
|
1029
|
-
<!-- Preview Panel (desktop) -->
|
|
1030
|
-
<Transition name="file-panel">
|
|
1031
|
-
<div v-if="previewPanelOpen && !isMobile" class="preview-panel" :style="{ width: previewPanelWidth + 'px' }">
|
|
1032
|
-
<div class="preview-panel-resize-handle"
|
|
1033
|
-
@mousedown="filePreview.onResizeStart($event)"
|
|
1034
|
-
@touchstart="filePreview.onResizeStart($event)"></div>
|
|
1035
|
-
<div class="preview-panel-header">
|
|
1036
|
-
<span class="preview-panel-filename" :title="previewFile?.filePath">
|
|
1037
|
-
{{ previewFile?.fileName || 'Preview' }}
|
|
1038
|
-
</span>
|
|
1039
|
-
<span v-if="previewFile" class="preview-panel-size">
|
|
1040
|
-
{{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
1041
|
-
</span>
|
|
1042
|
-
<button class="preview-panel-close" @click="filePreview.closePreview()" title="Close preview">×</button>
|
|
1043
|
-
</div>
|
|
1044
|
-
<div class="preview-panel-body">
|
|
1045
|
-
<div v-if="previewLoading" class="preview-loading">Loading...</div>
|
|
1046
|
-
<div v-else-if="previewFile?.error" class="preview-error">
|
|
1047
|
-
{{ previewFile.error }}
|
|
1048
|
-
</div>
|
|
1049
|
-
<div v-else-if="previewFile?.encoding === 'base64' && previewFile?.content"
|
|
1050
|
-
class="preview-image-container">
|
|
1051
|
-
<img :src="'data:' + previewFile.mimeType + ';base64,' + previewFile.content"
|
|
1052
|
-
:alt="previewFile.fileName" class="preview-image" />
|
|
1053
|
-
</div>
|
|
1054
|
-
<div v-else-if="previewFile?.content" class="preview-text-container">
|
|
1055
|
-
<pre class="preview-code"><code v-html="filePreview.highlightCode(previewFile.content, previewFile.fileName)"></code></pre>
|
|
1056
|
-
<div v-if="previewFile.truncated" class="preview-truncated-notice">
|
|
1057
|
-
File truncated — showing first 100 KB of {{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
1058
|
-
</div>
|
|
1059
|
-
</div>
|
|
1060
|
-
<div v-else-if="previewFile && !previewFile.content && !previewFile.error" class="preview-binary-info">
|
|
1061
|
-
<div class="preview-binary-icon">
|
|
1062
|
-
<svg viewBox="0 0 24 24" width="48" height="48"><path fill="currentColor" opacity="0.4" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
|
1063
|
-
</div>
|
|
1064
|
-
<p>Binary file</p>
|
|
1065
|
-
<p class="preview-binary-meta">{{ previewFile.mimeType }}</p>
|
|
1066
|
-
<p class="preview-binary-meta">{{ filePreview.formatFileSize(previewFile.totalSize) }}</p>
|
|
1067
|
-
</div>
|
|
1068
|
-
</div>
|
|
1069
|
-
</div>
|
|
1070
|
-
</Transition>
|
|
1071
|
-
|
|
1072
|
-
</div>
|
|
1073
|
-
|
|
1074
|
-
<!-- Folder Picker Modal -->
|
|
1075
|
-
<div class="folder-picker-overlay" v-if="folderPickerOpen" @click.self="folderPickerOpen = false">
|
|
1076
|
-
<div class="folder-picker-dialog">
|
|
1077
|
-
<div class="folder-picker-header">
|
|
1078
|
-
<span>Select Working Directory</span>
|
|
1079
|
-
<button class="folder-picker-close" @click="folderPickerOpen = false">×</button>
|
|
1080
|
-
</div>
|
|
1081
|
-
<div class="folder-picker-nav">
|
|
1082
|
-
<button class="folder-picker-up" @click="folderPickerNavigateUp" :disabled="!folderPickerPath" title="Go to parent directory">
|
|
1083
|
-
<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>
|
|
1084
|
-
</button>
|
|
1085
|
-
<input class="folder-picker-path-input" type="text" v-model="folderPickerPath" @keydown.enter="folderPickerGoToPath" placeholder="Enter path..." spellcheck="false" />
|
|
1086
|
-
</div>
|
|
1087
|
-
<div class="folder-picker-list">
|
|
1088
|
-
<div v-if="folderPickerLoading" class="folder-picker-loading">
|
|
1089
|
-
<div class="history-loading-spinner"></div>
|
|
1090
|
-
<span>Loading...</span>
|
|
1091
|
-
</div>
|
|
1092
|
-
<template v-else>
|
|
1093
|
-
<div
|
|
1094
|
-
v-for="entry in folderPickerEntries" :key="entry.name"
|
|
1095
|
-
:class="['folder-picker-item', { 'folder-picker-selected': folderPickerSelected === entry.name }]"
|
|
1096
|
-
@click="folderPickerSelectItem(entry)"
|
|
1097
|
-
@dblclick="folderPickerEnter(entry)"
|
|
1098
|
-
>
|
|
1099
|
-
<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>
|
|
1100
|
-
<span>{{ entry.name }}</span>
|
|
1101
|
-
</div>
|
|
1102
|
-
<div v-if="folderPickerEntries.length === 0" class="folder-picker-empty">No subdirectories found.</div>
|
|
1103
|
-
</template>
|
|
1104
|
-
</div>
|
|
1105
|
-
<div class="folder-picker-footer">
|
|
1106
|
-
<button class="folder-picker-cancel" @click="folderPickerOpen = false">Cancel</button>
|
|
1107
|
-
<button class="folder-picker-confirm" @click="confirmFolderPicker" :disabled="!folderPickerPath">Open</button>
|
|
1108
|
-
</div>
|
|
1109
|
-
</div>
|
|
1110
|
-
</div>
|
|
1111
|
-
|
|
1112
|
-
<!-- Delete Session Confirmation Dialog -->
|
|
1113
|
-
<div class="folder-picker-overlay" v-if="deleteConfirmOpen" @click.self="cancelDeleteSession">
|
|
1114
|
-
<div class="delete-confirm-dialog">
|
|
1115
|
-
<div class="delete-confirm-header">Delete Session</div>
|
|
1116
|
-
<div class="delete-confirm-body">
|
|
1117
|
-
<p>Are you sure you want to delete this session?</p>
|
|
1118
|
-
<p class="delete-confirm-title">{{ deleteConfirmTitle }}</p>
|
|
1119
|
-
<p class="delete-confirm-warning">This action cannot be undone.</p>
|
|
1120
|
-
</div>
|
|
1121
|
-
<div class="delete-confirm-footer">
|
|
1122
|
-
<button class="folder-picker-cancel" @click="cancelDeleteSession">Cancel</button>
|
|
1123
|
-
<button class="delete-confirm-btn" @click="confirmDeleteSession">Delete</button>
|
|
1124
|
-
</div>
|
|
1125
|
-
</div>
|
|
1126
|
-
</div>
|
|
1127
|
-
|
|
1128
|
-
<!-- Password Authentication Dialog -->
|
|
1129
|
-
<div class="folder-picker-overlay" v-if="authRequired && !authLocked">
|
|
1130
|
-
<div class="auth-dialog">
|
|
1131
|
-
<div class="auth-dialog-header">
|
|
1132
|
-
<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>
|
|
1133
|
-
<span>Session Protected</span>
|
|
1134
|
-
</div>
|
|
1135
|
-
<div class="auth-dialog-body">
|
|
1136
|
-
<p>This session requires a password to access.</p>
|
|
1137
|
-
<input
|
|
1138
|
-
type="password"
|
|
1139
|
-
class="auth-password-input"
|
|
1140
|
-
v-model="authPassword"
|
|
1141
|
-
@keydown.enter="submitPassword"
|
|
1142
|
-
placeholder="Enter password..."
|
|
1143
|
-
autofocus
|
|
1144
|
-
/>
|
|
1145
|
-
<p v-if="authError" class="auth-error">{{ authError }}</p>
|
|
1146
|
-
<p v-if="authAttempts" class="auth-attempts">{{ authAttempts }}</p>
|
|
1147
|
-
</div>
|
|
1148
|
-
<div class="auth-dialog-footer">
|
|
1149
|
-
<button class="auth-submit-btn" @click="submitPassword" :disabled="!authPassword.trim()">Unlock</button>
|
|
1150
|
-
</div>
|
|
1151
|
-
</div>
|
|
1152
|
-
</div>
|
|
1153
|
-
|
|
1154
|
-
<!-- Auth Locked Out -->
|
|
1155
|
-
<div class="folder-picker-overlay" v-if="authLocked">
|
|
1156
|
-
<div class="auth-dialog auth-dialog-locked">
|
|
1157
|
-
<div class="auth-dialog-header">
|
|
1158
|
-
<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>
|
|
1159
|
-
<span>Access Locked</span>
|
|
1160
|
-
</div>
|
|
1161
|
-
<div class="auth-dialog-body">
|
|
1162
|
-
<p>{{ authError }}</p>
|
|
1163
|
-
<p class="auth-locked-hint">Close this tab and try again later.</p>
|
|
1164
|
-
</div>
|
|
1165
|
-
</div>
|
|
1166
|
-
</div>
|
|
1167
|
-
|
|
1168
|
-
<!-- File context menu -->
|
|
1169
|
-
<div
|
|
1170
|
-
v-if="fileContextMenu"
|
|
1171
|
-
class="file-context-menu"
|
|
1172
|
-
:style="{ left: fileContextMenu.x + 'px', top: fileContextMenu.y + 'px' }"
|
|
1173
|
-
>
|
|
1174
|
-
<div class="file-context-item" @click="fileBrowser.askClaudeRead()">
|
|
1175
|
-
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM5 15h14v2H5zm0-4h14v2H5zm0-4h14v2H5z"/></svg>
|
|
1176
|
-
Ask Claude to read
|
|
1177
|
-
</div>
|
|
1178
|
-
<div class="file-context-item" @click="fileBrowser.copyPath()">
|
|
1179
|
-
<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>
|
|
1180
|
-
{{ fileContextMenu.copied ? 'Copied!' : 'Copy path' }}
|
|
1181
|
-
</div>
|
|
1182
|
-
<div class="file-context-item" @click="fileBrowser.insertPath()">
|
|
1183
|
-
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
|
|
1184
|
-
Insert path to input
|
|
1185
|
-
</div>
|
|
1186
|
-
</div>
|
|
1187
|
-
</div>
|
|
1188
|
-
`
|
|
1189
|
-
};
|
|
1190
|
-
|
|
1191
|
-
const app = createApp(App);
|
|
1192
|
-
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
|
+
import { createFileBrowser } from './modules/fileBrowser.js';
|
|
20
|
+
import { createFilePreview } from './modules/filePreview.js';
|
|
21
|
+
|
|
22
|
+
// ── App ─────────────────────────────────────────────────────────────────────
|
|
23
|
+
const App = {
|
|
24
|
+
setup() {
|
|
25
|
+
// ── Reactive state ──
|
|
26
|
+
const status = ref('Connecting...');
|
|
27
|
+
const agentName = ref('');
|
|
28
|
+
const hostname = ref('');
|
|
29
|
+
const workDir = ref('');
|
|
30
|
+
const sessionId = ref('');
|
|
31
|
+
const error = ref('');
|
|
32
|
+
const serverVersion = ref('');
|
|
33
|
+
const agentVersion = ref('');
|
|
34
|
+
const messages = ref([]);
|
|
35
|
+
const visibleLimit = ref(50);
|
|
36
|
+
const hasMoreMessages = computed(() => messages.value.length > visibleLimit.value);
|
|
37
|
+
const visibleMessages = computed(() => {
|
|
38
|
+
if (messages.value.length <= visibleLimit.value) return messages.value;
|
|
39
|
+
return messages.value.slice(messages.value.length - visibleLimit.value);
|
|
40
|
+
});
|
|
41
|
+
function loadMoreMessages() {
|
|
42
|
+
const el = document.querySelector('.message-list');
|
|
43
|
+
const prevHeight = el ? el.scrollHeight : 0;
|
|
44
|
+
visibleLimit.value += 50;
|
|
45
|
+
nextTick(() => {
|
|
46
|
+
if (el) el.scrollTop += el.scrollHeight - prevHeight;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const inputText = ref('');
|
|
50
|
+
const isProcessing = ref(false);
|
|
51
|
+
const isCompacting = ref(false);
|
|
52
|
+
const latency = ref(null);
|
|
53
|
+
const queuedMessages = ref([]);
|
|
54
|
+
const usageStats = ref(null);
|
|
55
|
+
const inputRef = ref(null);
|
|
56
|
+
|
|
57
|
+
// Sidebar state
|
|
58
|
+
const sidebarOpen = ref(window.innerWidth > 768);
|
|
59
|
+
const historySessions = ref([]);
|
|
60
|
+
const currentClaudeSessionId = ref(null);
|
|
61
|
+
const needsResume = ref(false);
|
|
62
|
+
const loadingSessions = ref(false);
|
|
63
|
+
const loadingHistory = ref(false);
|
|
64
|
+
|
|
65
|
+
// Folder picker state
|
|
66
|
+
const folderPickerOpen = ref(false);
|
|
67
|
+
const folderPickerPath = ref('');
|
|
68
|
+
const folderPickerEntries = ref([]);
|
|
69
|
+
const folderPickerLoading = ref(false);
|
|
70
|
+
const folderPickerSelected = ref('');
|
|
71
|
+
|
|
72
|
+
// Delete confirmation dialog state
|
|
73
|
+
const deleteConfirmOpen = ref(false);
|
|
74
|
+
const deleteConfirmTitle = ref('');
|
|
75
|
+
|
|
76
|
+
// Rename session state
|
|
77
|
+
const renamingSessionId = ref(null);
|
|
78
|
+
const renameText = ref('');
|
|
79
|
+
|
|
80
|
+
// Working directory history
|
|
81
|
+
const workdirHistory = ref([]);
|
|
82
|
+
|
|
83
|
+
// Authentication state
|
|
84
|
+
const authRequired = ref(false);
|
|
85
|
+
const authPassword = ref('');
|
|
86
|
+
const authError = ref('');
|
|
87
|
+
const authAttempts = ref(null);
|
|
88
|
+
const authLocked = ref(false);
|
|
89
|
+
|
|
90
|
+
// File attachment state
|
|
91
|
+
const attachments = ref([]);
|
|
92
|
+
const fileInputRef = ref(null);
|
|
93
|
+
const dragOver = ref(false);
|
|
94
|
+
|
|
95
|
+
// Multi-session parallel state
|
|
96
|
+
const conversationCache = ref({}); // conversationId → saved state snapshot
|
|
97
|
+
const currentConversationId = ref(crypto.randomUUID()); // currently visible conversation
|
|
98
|
+
const processingConversations = ref({}); // conversationId → boolean
|
|
99
|
+
|
|
100
|
+
// File browser state
|
|
101
|
+
const filePanelOpen = ref(false);
|
|
102
|
+
const filePanelWidth = ref(parseInt(localStorage.getItem('agentlink-file-panel-width'), 10) || 280);
|
|
103
|
+
const fileTreeRoot = ref(null);
|
|
104
|
+
const fileTreeLoading = ref(false);
|
|
105
|
+
const fileContextMenu = ref(null);
|
|
106
|
+
const sidebarView = ref('sessions'); // 'sessions' | 'files' | 'preview' (mobile only)
|
|
107
|
+
const isMobile = ref(window.innerWidth <= 768);
|
|
108
|
+
const workdirMenuOpen = ref(false);
|
|
109
|
+
|
|
110
|
+
// File preview state
|
|
111
|
+
const previewPanelOpen = ref(false);
|
|
112
|
+
const previewPanelWidth = ref(parseInt(localStorage.getItem('agentlink-preview-panel-width'), 10) || 400);
|
|
113
|
+
const previewFile = ref(null);
|
|
114
|
+
const previewLoading = ref(false);
|
|
115
|
+
|
|
116
|
+
// ── switchConversation: save current → load target ──
|
|
117
|
+
// Defined here and used by sidebar.newConversation, sidebar.resumeSession, workdir_changed
|
|
118
|
+
// Needs access to streaming / connection which are created later, so we use late-binding refs.
|
|
119
|
+
let _getToolMsgMap = () => new Map();
|
|
120
|
+
let _restoreToolMsgMap = () => {};
|
|
121
|
+
let _clearToolMsgMap = () => {};
|
|
122
|
+
|
|
123
|
+
function switchConversation(newConvId) {
|
|
124
|
+
const oldConvId = currentConversationId.value;
|
|
125
|
+
|
|
126
|
+
// Save current state (if there is one)
|
|
127
|
+
if (oldConvId) {
|
|
128
|
+
const streamState = streaming.saveState();
|
|
129
|
+
conversationCache.value[oldConvId] = {
|
|
130
|
+
messages: messages.value,
|
|
131
|
+
isProcessing: isProcessing.value,
|
|
132
|
+
isCompacting: isCompacting.value,
|
|
133
|
+
loadingHistory: loadingHistory.value,
|
|
134
|
+
claudeSessionId: currentClaudeSessionId.value,
|
|
135
|
+
visibleLimit: visibleLimit.value,
|
|
136
|
+
needsResume: needsResume.value,
|
|
137
|
+
streamingState: streamState,
|
|
138
|
+
toolMsgMap: _getToolMsgMap(),
|
|
139
|
+
messageIdCounter: streaming.getMessageIdCounter(),
|
|
140
|
+
queuedMessages: queuedMessages.value,
|
|
141
|
+
usageStats: usageStats.value,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Load target state
|
|
146
|
+
const cached = conversationCache.value[newConvId];
|
|
147
|
+
if (cached) {
|
|
148
|
+
messages.value = cached.messages;
|
|
149
|
+
isProcessing.value = cached.isProcessing;
|
|
150
|
+
isCompacting.value = cached.isCompacting;
|
|
151
|
+
loadingHistory.value = cached.loadingHistory || false;
|
|
152
|
+
currentClaudeSessionId.value = cached.claudeSessionId;
|
|
153
|
+
visibleLimit.value = cached.visibleLimit;
|
|
154
|
+
needsResume.value = cached.needsResume;
|
|
155
|
+
streaming.restoreState(cached.streamingState || { pendingText: '', streamingMessageId: null, messageIdCounter: cached.messageIdCounter || 0 });
|
|
156
|
+
// Background routing may have incremented messageIdCounter beyond what
|
|
157
|
+
// streamingState recorded at save time — use the authoritative value.
|
|
158
|
+
streaming.setMessageIdCounter(cached.messageIdCounter || 0);
|
|
159
|
+
_restoreToolMsgMap(cached.toolMsgMap || new Map());
|
|
160
|
+
queuedMessages.value = cached.queuedMessages || [];
|
|
161
|
+
usageStats.value = cached.usageStats || null;
|
|
162
|
+
} else {
|
|
163
|
+
// New blank conversation
|
|
164
|
+
messages.value = [];
|
|
165
|
+
isProcessing.value = false;
|
|
166
|
+
isCompacting.value = false;
|
|
167
|
+
loadingHistory.value = false;
|
|
168
|
+
currentClaudeSessionId.value = null;
|
|
169
|
+
visibleLimit.value = 50;
|
|
170
|
+
needsResume.value = false;
|
|
171
|
+
streaming.setMessageIdCounter(0);
|
|
172
|
+
streaming.setStreamingMessageId(null);
|
|
173
|
+
streaming.reset();
|
|
174
|
+
_clearToolMsgMap();
|
|
175
|
+
queuedMessages.value = [];
|
|
176
|
+
usageStats.value = null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
currentConversationId.value = newConvId;
|
|
180
|
+
scrollToBottom(true);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Theme
|
|
184
|
+
const theme = ref(localStorage.getItem('agentlink-theme') || 'light');
|
|
185
|
+
function applyTheme() {
|
|
186
|
+
document.documentElement.setAttribute('data-theme', theme.value);
|
|
187
|
+
const link = document.getElementById('hljs-theme');
|
|
188
|
+
if (link) link.href = theme.value === 'light'
|
|
189
|
+
? '/vendor/github.min.css'
|
|
190
|
+
: '/vendor/github-dark.min.css';
|
|
191
|
+
}
|
|
192
|
+
function toggleTheme() {
|
|
193
|
+
theme.value = theme.value === 'dark' ? 'light' : 'dark';
|
|
194
|
+
localStorage.setItem('agentlink-theme', theme.value);
|
|
195
|
+
applyTheme();
|
|
196
|
+
}
|
|
197
|
+
applyTheme();
|
|
198
|
+
|
|
199
|
+
// ── Scroll management ──
|
|
200
|
+
let _scrollTimer = null;
|
|
201
|
+
let _userScrolledUp = false;
|
|
202
|
+
|
|
203
|
+
function onMessageListScroll(e) {
|
|
204
|
+
const el = e.target;
|
|
205
|
+
_userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > 80;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function scrollToBottom(force) {
|
|
209
|
+
if (_userScrolledUp && !force) return;
|
|
210
|
+
if (_scrollTimer) return;
|
|
211
|
+
_scrollTimer = setTimeout(() => {
|
|
212
|
+
_scrollTimer = null;
|
|
213
|
+
const el = document.querySelector('.message-list');
|
|
214
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
215
|
+
}, 50);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Highlight.js scheduling ──
|
|
219
|
+
let _hlTimer = null;
|
|
220
|
+
function scheduleHighlight() {
|
|
221
|
+
if (_hlTimer) return;
|
|
222
|
+
_hlTimer = setTimeout(() => {
|
|
223
|
+
_hlTimer = null;
|
|
224
|
+
if (typeof hljs !== 'undefined') {
|
|
225
|
+
document.querySelectorAll('pre code:not([data-highlighted])').forEach(block => {
|
|
226
|
+
hljs.highlightElement(block);
|
|
227
|
+
block.dataset.highlighted = 'true';
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}, 300);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Create module instances ──
|
|
234
|
+
|
|
235
|
+
const streaming = createStreaming({ messages, scrollToBottom });
|
|
236
|
+
|
|
237
|
+
const fileAttach = createFileAttachments(attachments, fileInputRef, dragOver);
|
|
238
|
+
|
|
239
|
+
// Sidebar needs wsSend, but connection creates wsSend.
|
|
240
|
+
// Resolve circular dependency with a forwarding function.
|
|
241
|
+
let _wsSend = () => {};
|
|
242
|
+
|
|
243
|
+
const sidebar = createSidebar({
|
|
244
|
+
wsSend: (msg) => _wsSend(msg),
|
|
245
|
+
messages, isProcessing, sidebarOpen,
|
|
246
|
+
historySessions, currentClaudeSessionId, needsResume,
|
|
247
|
+
loadingSessions, loadingHistory, workDir, visibleLimit,
|
|
248
|
+
folderPickerOpen, folderPickerPath, folderPickerEntries,
|
|
249
|
+
folderPickerLoading, folderPickerSelected, streaming,
|
|
250
|
+
deleteConfirmOpen, deleteConfirmTitle,
|
|
251
|
+
renamingSessionId, renameText,
|
|
252
|
+
hostname, workdirHistory,
|
|
253
|
+
// Multi-session parallel
|
|
254
|
+
currentConversationId, conversationCache, processingConversations,
|
|
255
|
+
switchConversation,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap } = createConnection({
|
|
259
|
+
status, agentName, hostname, workDir, sessionId, error,
|
|
260
|
+
serverVersion, agentVersion, latency,
|
|
261
|
+
messages, isProcessing, isCompacting, visibleLimit, queuedMessages, usageStats,
|
|
262
|
+
historySessions, currentClaudeSessionId, needsResume, loadingSessions, loadingHistory,
|
|
263
|
+
folderPickerLoading, folderPickerEntries, folderPickerPath,
|
|
264
|
+
authRequired, authPassword, authError, authAttempts, authLocked,
|
|
265
|
+
streaming, sidebar, scrollToBottom,
|
|
266
|
+
// Multi-session parallel
|
|
267
|
+
currentConversationId, processingConversations, conversationCache,
|
|
268
|
+
switchConversation,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Now wire up the forwarding function
|
|
272
|
+
_wsSend = wsSend;
|
|
273
|
+
setDequeueNext(dequeueNext);
|
|
274
|
+
// Wire up late-binding toolMsgMap functions for switchConversation
|
|
275
|
+
_getToolMsgMap = getToolMsgMap;
|
|
276
|
+
_restoreToolMsgMap = restoreToolMsgMap;
|
|
277
|
+
_clearToolMsgMap = clearToolMsgMap;
|
|
278
|
+
|
|
279
|
+
// File browser module
|
|
280
|
+
const fileBrowser = createFileBrowser({
|
|
281
|
+
wsSend, workDir, inputText, inputRef, sendMessage,
|
|
282
|
+
filePanelOpen, filePanelWidth, fileTreeRoot, fileTreeLoading, fileContextMenu,
|
|
283
|
+
sidebarOpen, sidebarView,
|
|
284
|
+
});
|
|
285
|
+
setFileBrowser(fileBrowser);
|
|
286
|
+
|
|
287
|
+
// File preview module
|
|
288
|
+
const filePreview = createFilePreview({
|
|
289
|
+
wsSend, previewPanelOpen, previewPanelWidth, previewFile, previewLoading,
|
|
290
|
+
sidebarView, sidebarOpen, isMobile,
|
|
291
|
+
});
|
|
292
|
+
setFilePreview(filePreview);
|
|
293
|
+
|
|
294
|
+
// Track mobile state on resize
|
|
295
|
+
let _resizeHandler = () => { isMobile.value = window.innerWidth <= 768; };
|
|
296
|
+
window.addEventListener('resize', _resizeHandler);
|
|
297
|
+
|
|
298
|
+
// Close workdir menu on outside click or Escape
|
|
299
|
+
let _workdirMenuClickHandler = (e) => {
|
|
300
|
+
if (!workdirMenuOpen.value) return;
|
|
301
|
+
const row = document.querySelector('.sidebar-workdir-path-row');
|
|
302
|
+
const menu = document.querySelector('.workdir-menu');
|
|
303
|
+
if ((row && row.contains(e.target)) || (menu && menu.contains(e.target))) return;
|
|
304
|
+
workdirMenuOpen.value = false;
|
|
305
|
+
};
|
|
306
|
+
let _workdirMenuKeyHandler = (e) => {
|
|
307
|
+
if (e.key === 'Escape' && workdirMenuOpen.value) workdirMenuOpen.value = false;
|
|
308
|
+
};
|
|
309
|
+
document.addEventListener('click', _workdirMenuClickHandler);
|
|
310
|
+
document.addEventListener('keydown', _workdirMenuKeyHandler);
|
|
311
|
+
|
|
312
|
+
// ── Computed ──
|
|
313
|
+
const hasInput = computed(() => !!(inputText.value.trim() || attachments.value.length > 0));
|
|
314
|
+
const canSend = computed(() =>
|
|
315
|
+
status.value === 'Connected' && hasInput.value && !isCompacting.value
|
|
316
|
+
&& !messages.value.some(m => m.role === 'ask-question' && !m.answered)
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// ── Auto-resize textarea ──
|
|
320
|
+
function autoResize() {
|
|
321
|
+
const ta = inputRef.value;
|
|
322
|
+
if (ta) {
|
|
323
|
+
ta.style.height = 'auto';
|
|
324
|
+
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Send message ──
|
|
329
|
+
function sendMessage() {
|
|
330
|
+
if (!canSend.value) return;
|
|
331
|
+
|
|
332
|
+
const text = inputText.value.trim();
|
|
333
|
+
const files = attachments.value.slice();
|
|
334
|
+
inputText.value = '';
|
|
335
|
+
if (inputRef.value) inputRef.value.style.height = 'auto';
|
|
336
|
+
|
|
337
|
+
const msgAttachments = files.map(f => ({
|
|
338
|
+
name: f.name, size: f.size, isImage: f.isImage, thumbUrl: f.thumbUrl,
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
const payload = { type: 'chat', prompt: text || '(see attached files)' };
|
|
342
|
+
if (currentConversationId.value) {
|
|
343
|
+
payload.conversationId = currentConversationId.value;
|
|
344
|
+
}
|
|
345
|
+
if (needsResume.value && currentClaudeSessionId.value) {
|
|
346
|
+
payload.resumeSessionId = currentClaudeSessionId.value;
|
|
347
|
+
needsResume.value = false;
|
|
348
|
+
}
|
|
349
|
+
if (files.length > 0) {
|
|
350
|
+
payload.files = files.map(f => ({
|
|
351
|
+
name: f.name, mimeType: f.mimeType, data: f.data,
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const userMsg = {
|
|
356
|
+
id: streaming.nextId(), role: 'user',
|
|
357
|
+
content: text || (files.length > 0 ? `[${files.length} file${files.length > 1 ? 's' : ''} attached]` : ''),
|
|
358
|
+
attachments: msgAttachments.length > 0 ? msgAttachments : undefined,
|
|
359
|
+
timestamp: new Date(),
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
if (isProcessing.value) {
|
|
363
|
+
queuedMessages.value.push({ id: streaming.nextId(), content: userMsg.content, attachments: userMsg.attachments, payload });
|
|
364
|
+
} else {
|
|
365
|
+
userMsg.status = 'sent';
|
|
366
|
+
messages.value.push(userMsg);
|
|
367
|
+
isProcessing.value = true;
|
|
368
|
+
if (currentConversationId.value) {
|
|
369
|
+
processingConversations.value[currentConversationId.value] = true;
|
|
370
|
+
}
|
|
371
|
+
wsSend(payload);
|
|
372
|
+
}
|
|
373
|
+
scrollToBottom(true);
|
|
374
|
+
attachments.value = [];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function cancelExecution() {
|
|
378
|
+
if (!isProcessing.value) return;
|
|
379
|
+
const cancelPayload = { type: 'cancel_execution' };
|
|
380
|
+
if (currentConversationId.value) {
|
|
381
|
+
cancelPayload.conversationId = currentConversationId.value;
|
|
382
|
+
}
|
|
383
|
+
wsSend(cancelPayload);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function dequeueNext() {
|
|
387
|
+
if (queuedMessages.value.length === 0) return;
|
|
388
|
+
const queued = queuedMessages.value.shift();
|
|
389
|
+
const userMsg = {
|
|
390
|
+
id: queued.id, role: 'user', status: 'sent',
|
|
391
|
+
content: queued.content, attachments: queued.attachments,
|
|
392
|
+
timestamp: new Date(),
|
|
393
|
+
};
|
|
394
|
+
messages.value.push(userMsg);
|
|
395
|
+
isProcessing.value = true;
|
|
396
|
+
if (currentConversationId.value) {
|
|
397
|
+
processingConversations.value[currentConversationId.value] = true;
|
|
398
|
+
}
|
|
399
|
+
wsSend(queued.payload);
|
|
400
|
+
scrollToBottom(true);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function removeQueuedMessage(msgId) {
|
|
404
|
+
const idx = queuedMessages.value.findIndex(m => m.id === msgId);
|
|
405
|
+
if (idx !== -1) queuedMessages.value.splice(idx, 1);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function handleKeydown(e) {
|
|
409
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
sendMessage();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Template adapter wrappers ──
|
|
416
|
+
// These adapt the module function signatures to the template's call conventions.
|
|
417
|
+
function _isPrevAssistant(idx) {
|
|
418
|
+
return isPrevAssistant(visibleMessages.value, idx);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function _submitQuestionAnswer(msg) {
|
|
422
|
+
submitQuestionAnswer(msg, wsSend);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Watchers ──
|
|
426
|
+
const messageCount = computed(() => messages.value.length);
|
|
427
|
+
watch(messageCount, () => { nextTick(scheduleHighlight); });
|
|
428
|
+
|
|
429
|
+
watch(hostname, (name) => {
|
|
430
|
+
document.title = name ? `${name} — AgentLink` : 'AgentLink';
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// ── Usage formatting ──
|
|
434
|
+
function formatTokens(n) {
|
|
435
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
|
436
|
+
return String(n);
|
|
437
|
+
}
|
|
438
|
+
function formatUsage(u) {
|
|
439
|
+
if (!u) return '';
|
|
440
|
+
const pct = u.contextWindow ? Math.round(u.inputTokens / u.contextWindow * 100) : 0;
|
|
441
|
+
const ctx = formatTokens(u.inputTokens) + ' / ' + formatTokens(u.contextWindow) + ' (' + pct + '%)';
|
|
442
|
+
const cost = '$' + u.totalCost.toFixed(2);
|
|
443
|
+
const model = u.model.replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/-1m$/, '');
|
|
444
|
+
const dur = (u.durationMs / 1000).toFixed(1) + 's';
|
|
445
|
+
return 'Context ' + ctx + ' \u00b7 Cost ' + cost + ' \u00b7 ' + model + ' \u00b7 ' + dur;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── Lifecycle ──
|
|
449
|
+
onMounted(() => { connect(scheduleHighlight); });
|
|
450
|
+
onUnmounted(() => { closeWs(); streaming.cleanup(); window.removeEventListener('resize', _resizeHandler); document.removeEventListener('click', _workdirMenuClickHandler); document.removeEventListener('keydown', _workdirMenuKeyHandler); });
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
status, agentName, hostname, workDir, sessionId, error,
|
|
454
|
+
serverVersion, agentVersion, latency,
|
|
455
|
+
messages, visibleMessages, hasMoreMessages, loadMoreMessages,
|
|
456
|
+
inputText, isProcessing, isCompacting, canSend, hasInput, inputRef, queuedMessages, usageStats,
|
|
457
|
+
sendMessage, handleKeydown, cancelExecution, removeQueuedMessage, onMessageListScroll,
|
|
458
|
+
getRenderedContent, copyMessage, toggleTool,
|
|
459
|
+
isPrevAssistant: _isPrevAssistant,
|
|
460
|
+
toggleContextSummary, formatTimestamp, formatUsage,
|
|
461
|
+
getToolIcon, getToolSummary, isEditTool, getEditDiffHtml, getFormattedToolInput, autoResize,
|
|
462
|
+
// AskUserQuestion
|
|
463
|
+
selectQuestionOption,
|
|
464
|
+
submitQuestionAnswer: _submitQuestionAnswer,
|
|
465
|
+
hasQuestionAnswer, getQuestionResponseSummary,
|
|
466
|
+
// Theme
|
|
467
|
+
theme, toggleTheme,
|
|
468
|
+
// Sidebar
|
|
469
|
+
sidebarOpen, historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
|
|
470
|
+
toggleSidebar: sidebar.toggleSidebar,
|
|
471
|
+
resumeSession: sidebar.resumeSession,
|
|
472
|
+
newConversation: sidebar.newConversation,
|
|
473
|
+
requestSessionList: sidebar.requestSessionList,
|
|
474
|
+
formatRelativeTime,
|
|
475
|
+
groupedSessions: sidebar.groupedSessions,
|
|
476
|
+
isSessionProcessing: sidebar.isSessionProcessing,
|
|
477
|
+
processingConversations,
|
|
478
|
+
// Folder picker
|
|
479
|
+
folderPickerOpen, folderPickerPath, folderPickerEntries,
|
|
480
|
+
folderPickerLoading, folderPickerSelected,
|
|
481
|
+
openFolderPicker: sidebar.openFolderPicker,
|
|
482
|
+
folderPickerNavigateUp: sidebar.folderPickerNavigateUp,
|
|
483
|
+
folderPickerSelectItem: sidebar.folderPickerSelectItem,
|
|
484
|
+
folderPickerEnter: sidebar.folderPickerEnter,
|
|
485
|
+
folderPickerGoToPath: sidebar.folderPickerGoToPath,
|
|
486
|
+
confirmFolderPicker: sidebar.confirmFolderPicker,
|
|
487
|
+
// Delete session
|
|
488
|
+
deleteConfirmOpen, deleteConfirmTitle,
|
|
489
|
+
deleteSession: sidebar.deleteSession,
|
|
490
|
+
confirmDeleteSession: sidebar.confirmDeleteSession,
|
|
491
|
+
cancelDeleteSession: sidebar.cancelDeleteSession,
|
|
492
|
+
// Rename session
|
|
493
|
+
renamingSessionId, renameText,
|
|
494
|
+
startRename: sidebar.startRename,
|
|
495
|
+
confirmRename: sidebar.confirmRename,
|
|
496
|
+
cancelRename: sidebar.cancelRename,
|
|
497
|
+
// Working directory history
|
|
498
|
+
filteredWorkdirHistory: sidebar.filteredWorkdirHistory,
|
|
499
|
+
switchToWorkdir: sidebar.switchToWorkdir,
|
|
500
|
+
removeFromWorkdirHistory: sidebar.removeFromWorkdirHistory,
|
|
501
|
+
// Authentication
|
|
502
|
+
authRequired, authPassword, authError, authAttempts, authLocked,
|
|
503
|
+
submitPassword,
|
|
504
|
+
// File attachments
|
|
505
|
+
attachments, fileInputRef, dragOver,
|
|
506
|
+
triggerFileInput: fileAttach.triggerFileInput,
|
|
507
|
+
handleFileSelect: fileAttach.handleFileSelect,
|
|
508
|
+
removeAttachment: fileAttach.removeAttachment,
|
|
509
|
+
formatFileSize,
|
|
510
|
+
handleDragOver: fileAttach.handleDragOver,
|
|
511
|
+
handleDragLeave: fileAttach.handleDragLeave,
|
|
512
|
+
handleDrop: fileAttach.handleDrop,
|
|
513
|
+
handlePaste: fileAttach.handlePaste,
|
|
514
|
+
// File browser
|
|
515
|
+
filePanelOpen, filePanelWidth, fileTreeRoot, fileTreeLoading, fileContextMenu,
|
|
516
|
+
sidebarView, isMobile, fileBrowser,
|
|
517
|
+
flattenedTree: fileBrowser.flattenedTree,
|
|
518
|
+
// File preview
|
|
519
|
+
previewPanelOpen, previewPanelWidth, previewFile, previewLoading, filePreview,
|
|
520
|
+
workdirMenuOpen,
|
|
521
|
+
toggleWorkdirMenu() { workdirMenuOpen.value = !workdirMenuOpen.value; },
|
|
522
|
+
workdirMenuBrowse() {
|
|
523
|
+
workdirMenuOpen.value = false;
|
|
524
|
+
if (isMobile.value) { sidebarView.value = 'files'; fileBrowser.openPanel(); }
|
|
525
|
+
else { fileBrowser.togglePanel(); }
|
|
526
|
+
},
|
|
527
|
+
workdirMenuChangeDir() {
|
|
528
|
+
workdirMenuOpen.value = false;
|
|
529
|
+
sidebar.openFolderPicker();
|
|
530
|
+
},
|
|
531
|
+
workdirMenuCopyPath() {
|
|
532
|
+
workdirMenuOpen.value = false;
|
|
533
|
+
navigator.clipboard.writeText(workDir.value);
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
},
|
|
537
|
+
template: `
|
|
538
|
+
<div class="layout">
|
|
539
|
+
<header class="top-bar">
|
|
540
|
+
<div class="top-bar-left">
|
|
541
|
+
<button class="sidebar-toggle" @click="toggleSidebar" title="Toggle sidebar">
|
|
542
|
+
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
|
543
|
+
</button>
|
|
544
|
+
<h1>AgentLink</h1>
|
|
545
|
+
</div>
|
|
546
|
+
<div class="top-bar-info">
|
|
547
|
+
<span :class="['badge', status.toLowerCase()]">{{ status }}</span>
|
|
548
|
+
<span v-if="latency !== null && status === 'Connected'" class="latency" :class="{ good: latency < 100, ok: latency >= 100 && latency < 500, bad: latency >= 500 }">{{ latency }}ms</span>
|
|
549
|
+
<span v-if="agentName" class="agent-label">{{ agentName }}</span>
|
|
550
|
+
<button class="theme-toggle" @click="toggleTheme" :title="theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
|
|
551
|
+
<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>
|
|
552
|
+
<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>
|
|
553
|
+
</button>
|
|
554
|
+
</div>
|
|
555
|
+
</header>
|
|
556
|
+
|
|
557
|
+
<div v-if="status === 'No Session' || (status !== 'Connected' && status !== 'Connecting...' && status !== 'Reconnecting...' && messages.length === 0)" class="center-card">
|
|
558
|
+
<div class="status-card">
|
|
559
|
+
<p class="status">
|
|
560
|
+
<span class="label">Status:</span>
|
|
561
|
+
<span :class="['badge', status.toLowerCase()]">{{ status }}</span>
|
|
562
|
+
</p>
|
|
563
|
+
<p v-if="agentName" class="info"><span class="label">Agent:</span> {{ agentName }}</p>
|
|
564
|
+
<p v-if="workDir" class="info"><span class="label">Directory:</span> {{ workDir }}</p>
|
|
565
|
+
<p v-if="sessionId" class="info muted"><span class="label">Session:</span> {{ sessionId }}</p>
|
|
566
|
+
<p v-if="error" class="error-msg">{{ error }}</p>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
<div v-else class="main-body">
|
|
571
|
+
<!-- Sidebar backdrop (mobile) -->
|
|
572
|
+
<div v-if="sidebarOpen" class="sidebar-backdrop" @click="toggleSidebar(); sidebarView = 'sessions'"></div>
|
|
573
|
+
<!-- Sidebar -->
|
|
574
|
+
<aside v-if="sidebarOpen" class="sidebar">
|
|
575
|
+
<!-- Mobile: file browser view -->
|
|
576
|
+
<div v-if="isMobile && sidebarView === 'files'" class="file-panel-mobile">
|
|
577
|
+
<div class="file-panel-mobile-header">
|
|
578
|
+
<button class="file-panel-mobile-back" @click="sidebarView = 'sessions'">
|
|
579
|
+
<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>
|
|
580
|
+
Sessions
|
|
581
|
+
</button>
|
|
582
|
+
<button class="file-panel-btn" @click="fileBrowser.refreshTree()" title="Refresh">
|
|
583
|
+
<svg 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>
|
|
584
|
+
</button>
|
|
585
|
+
</div>
|
|
586
|
+
<div class="file-panel-breadcrumb" :title="workDir">{{ workDir }}</div>
|
|
587
|
+
<div v-if="fileTreeLoading" class="file-panel-loading">Loading...</div>
|
|
588
|
+
<div v-else-if="!fileTreeRoot || !fileTreeRoot.children || fileTreeRoot.children.length === 0" class="file-panel-empty">
|
|
589
|
+
No files found.
|
|
590
|
+
</div>
|
|
591
|
+
<div v-else class="file-tree">
|
|
592
|
+
<template v-for="item in flattenedTree" :key="item.node.path">
|
|
593
|
+
<div
|
|
594
|
+
class="file-tree-item"
|
|
595
|
+
:class="{ folder: item.node.type === 'directory' }"
|
|
596
|
+
:style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
|
|
597
|
+
@click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : filePreview.openPreview(item.node.path)"
|
|
598
|
+
@contextmenu.prevent="item.node.type !== 'directory' ? fileBrowser.onFileClick($event, item.node) : null"
|
|
599
|
+
>
|
|
600
|
+
<span v-if="item.node.type === 'directory'" class="file-tree-arrow" :class="{ expanded: item.node.expanded }">▶</span>
|
|
601
|
+
<span v-else class="file-tree-file-icon">
|
|
602
|
+
<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
|
603
|
+
</span>
|
|
604
|
+
<span class="file-tree-name" :title="item.node.path">{{ item.node.name }}</span>
|
|
605
|
+
<span v-if="item.node.loading" class="file-tree-spinner"></span>
|
|
606
|
+
</div>
|
|
607
|
+
<div v-if="item.node.type === 'directory' && item.node.expanded && item.node.children && item.node.children.length === 0 && !item.node.loading" class="file-tree-empty" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">(empty)</div>
|
|
608
|
+
<div v-if="item.node.error" class="file-tree-error" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">{{ item.node.error }}</div>
|
|
609
|
+
</template>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
|
|
613
|
+
<!-- Mobile: file preview view -->
|
|
614
|
+
<div v-else-if="isMobile && sidebarView === 'preview'" class="file-preview-mobile">
|
|
615
|
+
<div class="file-preview-mobile-header">
|
|
616
|
+
<button class="file-panel-mobile-back" @click="filePreview.closePreview()">
|
|
617
|
+
<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>
|
|
618
|
+
Files
|
|
619
|
+
</button>
|
|
620
|
+
<span v-if="previewFile" class="file-preview-mobile-size">
|
|
621
|
+
{{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
622
|
+
</span>
|
|
623
|
+
</div>
|
|
624
|
+
<div class="file-preview-mobile-filename" :title="previewFile?.filePath">
|
|
625
|
+
{{ previewFile?.fileName || 'Preview' }}
|
|
626
|
+
</div>
|
|
627
|
+
<div class="preview-panel-body">
|
|
628
|
+
<div v-if="previewLoading" class="preview-loading">Loading...</div>
|
|
629
|
+
<div v-else-if="previewFile?.error" class="preview-error">
|
|
630
|
+
{{ previewFile.error }}
|
|
631
|
+
</div>
|
|
632
|
+
<div v-else-if="previewFile?.encoding === 'base64' && previewFile?.content"
|
|
633
|
+
class="preview-image-container">
|
|
634
|
+
<img :src="'data:' + previewFile.mimeType + ';base64,' + previewFile.content"
|
|
635
|
+
:alt="previewFile.fileName" class="preview-image" />
|
|
636
|
+
</div>
|
|
637
|
+
<div v-else-if="previewFile?.content" class="preview-text-container">
|
|
638
|
+
<pre class="preview-code"><code v-html="filePreview.highlightCode(previewFile.content, previewFile.fileName)"></code></pre>
|
|
639
|
+
<div v-if="previewFile.truncated" class="preview-truncated-notice">
|
|
640
|
+
File truncated — showing first 100 KB of {{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
<div v-else-if="previewFile && !previewFile.content && !previewFile.error" class="preview-binary-info">
|
|
644
|
+
<p>Binary file — {{ previewFile.mimeType }}</p>
|
|
645
|
+
<p>{{ filePreview.formatFileSize(previewFile.totalSize) }}</p>
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
|
|
650
|
+
<!-- Normal sidebar content (sessions view) -->
|
|
651
|
+
<template v-else>
|
|
652
|
+
<div class="sidebar-section">
|
|
653
|
+
<div class="sidebar-workdir">
|
|
654
|
+
<div v-if="hostname" class="sidebar-hostname">
|
|
655
|
+
<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>
|
|
656
|
+
<span>{{ hostname }}</span>
|
|
657
|
+
</div>
|
|
658
|
+
<div class="sidebar-workdir-header">
|
|
659
|
+
<div class="sidebar-workdir-label">Working Directory</div>
|
|
660
|
+
</div>
|
|
661
|
+
<div class="sidebar-workdir-path-row" @click.stop="toggleWorkdirMenu()">
|
|
662
|
+
<div class="sidebar-workdir-path" :title="workDir">{{ workDir }}</div>
|
|
663
|
+
<svg class="sidebar-workdir-chevron" :class="{ open: workdirMenuOpen }" viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg>
|
|
664
|
+
</div>
|
|
665
|
+
<div v-if="workdirMenuOpen" class="workdir-menu">
|
|
666
|
+
<div class="workdir-menu-item" @click.stop="workdirMenuBrowse()">
|
|
667
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10zM8 13h8v2H8v-2z"/></svg>
|
|
668
|
+
<span>Browse files</span>
|
|
669
|
+
</div>
|
|
670
|
+
<div class="workdir-menu-item" @click.stop="workdirMenuChangeDir()">
|
|
671
|
+
<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>
|
|
672
|
+
<span>Change directory</span>
|
|
673
|
+
</div>
|
|
674
|
+
<div class="workdir-menu-item" @click.stop="workdirMenuCopyPath()">
|
|
675
|
+
<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>
|
|
676
|
+
<span>Copy path</span>
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
<div v-if="filteredWorkdirHistory.length > 0" class="workdir-history">
|
|
680
|
+
<div class="workdir-history-label">Recent Directories</div>
|
|
681
|
+
<div class="workdir-history-list">
|
|
682
|
+
<div
|
|
683
|
+
v-for="path in filteredWorkdirHistory" :key="path"
|
|
684
|
+
class="workdir-history-item"
|
|
685
|
+
@click="switchToWorkdir(path)"
|
|
686
|
+
:title="path"
|
|
687
|
+
>
|
|
688
|
+
<span class="workdir-history-path">{{ path }}</span>
|
|
689
|
+
<button class="workdir-history-delete" @click.stop="removeFromWorkdirHistory(path)" title="Remove from history">
|
|
690
|
+
<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>
|
|
691
|
+
</button>
|
|
692
|
+
</div>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
|
|
698
|
+
<div class="sidebar-section sidebar-sessions">
|
|
699
|
+
<div class="sidebar-section-header">
|
|
700
|
+
<span>History</span>
|
|
701
|
+
<button class="sidebar-refresh-btn" @click="requestSessionList" title="Refresh" :disabled="loadingSessions">
|
|
702
|
+
<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>
|
|
703
|
+
</button>
|
|
704
|
+
</div>
|
|
705
|
+
|
|
706
|
+
<button class="new-conversation-btn" @click="newConversation">
|
|
707
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
|
708
|
+
New conversation
|
|
709
|
+
</button>
|
|
710
|
+
|
|
711
|
+
<div v-if="loadingSessions && historySessions.length === 0" class="sidebar-loading">
|
|
712
|
+
Loading sessions...
|
|
713
|
+
</div>
|
|
714
|
+
<div v-else-if="historySessions.length === 0" class="sidebar-empty">
|
|
715
|
+
No previous sessions found.
|
|
716
|
+
</div>
|
|
717
|
+
<div v-else class="session-list">
|
|
718
|
+
<div v-for="group in groupedSessions" :key="group.label" class="session-group">
|
|
719
|
+
<div class="session-group-label">{{ group.label }}</div>
|
|
720
|
+
<div
|
|
721
|
+
v-for="s in group.sessions" :key="s.sessionId"
|
|
722
|
+
:class="['session-item', { active: currentClaudeSessionId === s.sessionId, processing: isSessionProcessing(s.sessionId) }]"
|
|
723
|
+
@click="renamingSessionId !== s.sessionId && resumeSession(s)"
|
|
724
|
+
:title="s.preview"
|
|
725
|
+
:aria-label="(s.title || s.sessionId.slice(0, 8)) + (isSessionProcessing(s.sessionId) ? ' (processing)' : '')"
|
|
726
|
+
>
|
|
727
|
+
<div v-if="renamingSessionId === s.sessionId" class="session-rename-row">
|
|
728
|
+
<input
|
|
729
|
+
class="session-rename-input"
|
|
730
|
+
v-model="renameText"
|
|
731
|
+
@click.stop
|
|
732
|
+
@keydown.enter.stop="confirmRename"
|
|
733
|
+
@keydown.escape.stop="cancelRename"
|
|
734
|
+
@vue:mounted="$event.el.focus()"
|
|
735
|
+
/>
|
|
736
|
+
<button class="session-rename-ok" @click.stop="confirmRename" title="Confirm">✓</button>
|
|
737
|
+
<button class="session-rename-cancel" @click.stop="cancelRename" title="Cancel">×</button>
|
|
738
|
+
</div>
|
|
739
|
+
<div v-else class="session-title">{{ s.title }}</div>
|
|
740
|
+
<div class="session-meta">
|
|
741
|
+
<span>{{ formatRelativeTime(s.lastModified) }}</span>
|
|
742
|
+
<span v-if="renamingSessionId !== s.sessionId" class="session-actions">
|
|
743
|
+
<button
|
|
744
|
+
class="session-rename-btn"
|
|
745
|
+
@click.stop="startRename(s)"
|
|
746
|
+
title="Rename session"
|
|
747
|
+
>
|
|
748
|
+
<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
|
749
|
+
</button>
|
|
750
|
+
<button
|
|
751
|
+
v-if="currentClaudeSessionId !== s.sessionId"
|
|
752
|
+
class="session-delete-btn"
|
|
753
|
+
@click.stop="deleteSession(s)"
|
|
754
|
+
title="Delete session"
|
|
755
|
+
>
|
|
756
|
+
<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>
|
|
757
|
+
</button>
|
|
758
|
+
</span>
|
|
759
|
+
</div>
|
|
760
|
+
</div>
|
|
761
|
+
</div>
|
|
762
|
+
</div>
|
|
763
|
+
</div>
|
|
764
|
+
|
|
765
|
+
<div v-if="serverVersion || agentVersion" class="sidebar-version-footer">
|
|
766
|
+
<span v-if="serverVersion">server {{ serverVersion }}</span>
|
|
767
|
+
<span v-if="serverVersion && agentVersion" class="sidebar-version-sep">/</span>
|
|
768
|
+
<span v-if="agentVersion">agent {{ agentVersion }}</span>
|
|
769
|
+
</div>
|
|
770
|
+
</template>
|
|
771
|
+
</aside>
|
|
772
|
+
|
|
773
|
+
<!-- File browser panel (desktop) -->
|
|
774
|
+
<Transition name="file-panel">
|
|
775
|
+
<div v-if="filePanelOpen && !isMobile" class="file-panel" :style="{ width: filePanelWidth + 'px' }">
|
|
776
|
+
<div class="file-panel-resize-handle" @mousedown="fileBrowser.onResizeStart($event)" @touchstart="fileBrowser.onResizeStart($event)"></div>
|
|
777
|
+
<div class="file-panel-header">
|
|
778
|
+
<span class="file-panel-title">Files</span>
|
|
779
|
+
<div class="file-panel-actions">
|
|
780
|
+
<button class="file-panel-btn" @click="fileBrowser.refreshTree()" title="Refresh">
|
|
781
|
+
<svg 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>
|
|
782
|
+
</button>
|
|
783
|
+
<button class="file-panel-btn" @click="filePanelOpen = false" title="Close">
|
|
784
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><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>
|
|
785
|
+
</button>
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
<div class="file-panel-breadcrumb" :title="workDir">{{ workDir }}</div>
|
|
789
|
+
<div v-if="fileTreeLoading" class="file-panel-loading">Loading...</div>
|
|
790
|
+
<div v-else-if="!fileTreeRoot || !fileTreeRoot.children || fileTreeRoot.children.length === 0" class="file-panel-empty">
|
|
791
|
+
No files found.
|
|
792
|
+
</div>
|
|
793
|
+
<div v-else class="file-tree">
|
|
794
|
+
<template v-for="item in flattenedTree" :key="item.node.path">
|
|
795
|
+
<div
|
|
796
|
+
class="file-tree-item"
|
|
797
|
+
:class="{ folder: item.node.type === 'directory' }"
|
|
798
|
+
:style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
|
|
799
|
+
@click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : filePreview.openPreview(item.node.path)"
|
|
800
|
+
@contextmenu.prevent="item.node.type !== 'directory' ? fileBrowser.onFileClick($event, item.node) : null"
|
|
801
|
+
>
|
|
802
|
+
<span v-if="item.node.type === 'directory'" class="file-tree-arrow" :class="{ expanded: item.node.expanded }">▶</span>
|
|
803
|
+
<span v-else class="file-tree-file-icon">
|
|
804
|
+
<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
|
805
|
+
</span>
|
|
806
|
+
<span class="file-tree-name" :title="item.node.path">{{ item.node.name }}</span>
|
|
807
|
+
<span v-if="item.node.loading" class="file-tree-spinner"></span>
|
|
808
|
+
</div>
|
|
809
|
+
<div v-if="item.node.type === 'directory' && item.node.expanded && item.node.children && item.node.children.length === 0 && !item.node.loading" class="file-tree-empty" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">(empty)</div>
|
|
810
|
+
<div v-if="item.node.error" class="file-tree-error" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">{{ item.node.error }}</div>
|
|
811
|
+
</template>
|
|
812
|
+
</div>
|
|
813
|
+
</div>
|
|
814
|
+
</Transition>
|
|
815
|
+
|
|
816
|
+
<!-- Chat area -->
|
|
817
|
+
<div class="chat-area">
|
|
818
|
+
<div class="message-list" @scroll="onMessageListScroll">
|
|
819
|
+
<div class="message-list-inner">
|
|
820
|
+
<div v-if="messages.length === 0 && status === 'Connected' && !loadingHistory" class="empty-state">
|
|
821
|
+
<div class="empty-state-icon">
|
|
822
|
+
<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>
|
|
823
|
+
</div>
|
|
824
|
+
<p>Connected to <strong>{{ agentName }}</strong></p>
|
|
825
|
+
<p class="muted">{{ workDir }}</p>
|
|
826
|
+
<p class="muted" style="margin-top: 0.5rem;">Send a message to start.</p>
|
|
827
|
+
</div>
|
|
828
|
+
|
|
829
|
+
<div v-if="loadingHistory" class="history-loading">
|
|
830
|
+
<div class="history-loading-spinner"></div>
|
|
831
|
+
<span>Loading conversation history...</span>
|
|
832
|
+
</div>
|
|
833
|
+
|
|
834
|
+
<div v-if="hasMoreMessages" class="load-more-wrapper">
|
|
835
|
+
<button class="load-more-btn" @click="loadMoreMessages">Load earlier messages</button>
|
|
836
|
+
</div>
|
|
837
|
+
|
|
838
|
+
<div v-for="(msg, msgIdx) in visibleMessages" :key="msg.id" :class="['message', 'message-' + msg.role]">
|
|
839
|
+
|
|
840
|
+
<!-- User message -->
|
|
841
|
+
<template v-if="msg.role === 'user'">
|
|
842
|
+
<div class="message-role-label user-label">You</div>
|
|
843
|
+
<div class="message-bubble user-bubble" :title="formatTimestamp(msg.timestamp)">
|
|
844
|
+
<div class="message-content">{{ msg.content }}</div>
|
|
845
|
+
<div v-if="msg.attachments && msg.attachments.length" class="message-attachments">
|
|
846
|
+
<div v-for="(att, ai) in msg.attachments" :key="ai" class="message-attachment-chip">
|
|
847
|
+
<img v-if="att.isImage && att.thumbUrl" :src="att.thumbUrl" class="message-attachment-thumb" />
|
|
848
|
+
<span v-else class="message-attachment-file-icon">
|
|
849
|
+
<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>
|
|
850
|
+
</span>
|
|
851
|
+
<span>{{ att.name }}</span>
|
|
852
|
+
</div>
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
</template>
|
|
856
|
+
|
|
857
|
+
<!-- Assistant message (markdown) -->
|
|
858
|
+
<template v-else-if="msg.role === 'assistant'">
|
|
859
|
+
<div v-if="!isPrevAssistant(msgIdx)" class="message-role-label assistant-label">Claude</div>
|
|
860
|
+
<div :class="['message-bubble', 'assistant-bubble', { streaming: msg.isStreaming }]" :title="formatTimestamp(msg.timestamp)">
|
|
861
|
+
<div class="message-actions">
|
|
862
|
+
<button class="icon-btn" @click="copyMessage(msg)" :title="msg.copied ? 'Copied!' : 'Copy'">
|
|
863
|
+
<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>
|
|
864
|
+
<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>
|
|
865
|
+
</button>
|
|
866
|
+
</div>
|
|
867
|
+
<div class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
|
|
868
|
+
</div>
|
|
869
|
+
</template>
|
|
870
|
+
|
|
871
|
+
<!-- Tool use block (collapsible) -->
|
|
872
|
+
<div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
|
|
873
|
+
<div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
|
|
874
|
+
<span class="tool-icon" v-html="getToolIcon(msg.toolName)"></span>
|
|
875
|
+
<span class="tool-name">{{ msg.toolName }}</span>
|
|
876
|
+
<span class="tool-summary">{{ getToolSummary(msg) }}</span>
|
|
877
|
+
<span class="tool-status-icon" v-if="msg.hasResult">\u{2713}</span>
|
|
878
|
+
<span class="tool-status-icon running-dots" v-else>
|
|
879
|
+
<span></span><span></span><span></span>
|
|
880
|
+
</span>
|
|
881
|
+
<span class="tool-toggle">{{ msg.expanded ? '\u{25B2}' : '\u{25BC}' }}</span>
|
|
882
|
+
</div>
|
|
883
|
+
<div v-show="msg.expanded" class="tool-expand">
|
|
884
|
+
<div v-if="isEditTool(msg) && getEditDiffHtml(msg)" class="tool-diff" v-html="getEditDiffHtml(msg)"></div>
|
|
885
|
+
<div v-else-if="getFormattedToolInput(msg)" class="tool-input-formatted" v-html="getFormattedToolInput(msg)"></div>
|
|
886
|
+
<pre v-else-if="msg.toolInput" class="tool-block">{{ msg.toolInput }}</pre>
|
|
887
|
+
<pre v-if="msg.toolOutput" class="tool-block tool-output">{{ msg.toolOutput }}</pre>
|
|
888
|
+
</div>
|
|
889
|
+
</div>
|
|
890
|
+
|
|
891
|
+
<!-- AskUserQuestion interactive card -->
|
|
892
|
+
<div v-else-if="msg.role === 'ask-question'" class="ask-question-wrapper">
|
|
893
|
+
<div v-if="!msg.answered" class="ask-question-card">
|
|
894
|
+
<div v-for="(q, qi) in msg.questions" :key="qi" class="ask-question-block">
|
|
895
|
+
<div v-if="q.header" class="ask-question-header">{{ q.header }}</div>
|
|
896
|
+
<div class="ask-question-text">{{ q.question }}</div>
|
|
897
|
+
<div class="ask-question-options">
|
|
898
|
+
<div
|
|
899
|
+
v-for="(opt, oi) in q.options" :key="oi"
|
|
900
|
+
:class="['ask-question-option', {
|
|
901
|
+
selected: q.multiSelect
|
|
902
|
+
? (msg.selectedAnswers[qi] || []).includes(opt.label)
|
|
903
|
+
: msg.selectedAnswers[qi] === opt.label
|
|
904
|
+
}]"
|
|
905
|
+
@click="selectQuestionOption(msg, qi, opt.label)"
|
|
906
|
+
>
|
|
907
|
+
<div class="ask-option-label">{{ opt.label }}</div>
|
|
908
|
+
<div v-if="opt.description" class="ask-option-desc">{{ opt.description }}</div>
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
<div class="ask-question-custom">
|
|
912
|
+
<input
|
|
913
|
+
type="text"
|
|
914
|
+
v-model="msg.customTexts[qi]"
|
|
915
|
+
placeholder="Or type a custom response..."
|
|
916
|
+
@input="msg.selectedAnswers[qi] = q.multiSelect ? [] : null"
|
|
917
|
+
@keydown.enter="hasQuestionAnswer(msg) && submitQuestionAnswer(msg)"
|
|
918
|
+
/>
|
|
919
|
+
</div>
|
|
920
|
+
</div>
|
|
921
|
+
<div class="ask-question-actions">
|
|
922
|
+
<button class="ask-question-submit" :disabled="!hasQuestionAnswer(msg)" @click="submitQuestionAnswer(msg)">
|
|
923
|
+
Submit
|
|
924
|
+
</button>
|
|
925
|
+
</div>
|
|
926
|
+
</div>
|
|
927
|
+
<div v-else class="ask-question-answered">
|
|
928
|
+
<span class="ask-answered-icon">\u{2713}</span>
|
|
929
|
+
<span class="ask-answered-text">{{ getQuestionResponseSummary(msg) }}</span>
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
932
|
+
|
|
933
|
+
<!-- Context summary (collapsed by default) -->
|
|
934
|
+
<div v-else-if="msg.role === 'context-summary'" class="context-summary-wrapper">
|
|
935
|
+
<div class="context-summary-bar" @click="toggleContextSummary(msg)">
|
|
936
|
+
<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>
|
|
937
|
+
<span class="context-summary-label">Context continued from previous conversation</span>
|
|
938
|
+
<span class="context-summary-toggle">{{ msg.contextExpanded ? 'Hide' : 'Show' }}</span>
|
|
939
|
+
</div>
|
|
940
|
+
<div v-if="msg.contextExpanded" class="context-summary-body">
|
|
941
|
+
<div class="markdown-body" v-html="getRenderedContent({ role: 'assistant', content: msg.content })"></div>
|
|
942
|
+
</div>
|
|
943
|
+
</div>
|
|
944
|
+
|
|
945
|
+
<!-- System message -->
|
|
946
|
+
<div v-else-if="msg.role === 'system'" :class="['system-msg', { 'compact-msg': msg.isCompactStart, 'command-output-msg': msg.isCommandOutput, 'error-msg': msg.isError }]">
|
|
947
|
+
<template v-if="msg.isCompactStart && !msg.compactDone">
|
|
948
|
+
<span class="compact-inline-spinner"></span>
|
|
949
|
+
</template>
|
|
950
|
+
<template v-if="msg.isCompactStart && msg.compactDone">
|
|
951
|
+
<span class="compact-done-icon">✓</span>
|
|
952
|
+
</template>
|
|
953
|
+
<div v-if="msg.isCommandOutput" class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
|
|
954
|
+
<template v-else>{{ msg.content }}</template>
|
|
955
|
+
</div>
|
|
956
|
+
</div>
|
|
957
|
+
|
|
958
|
+
<div v-if="isProcessing && !messages.some(m => m.isStreaming)" class="typing-indicator">
|
|
959
|
+
<span></span><span></span><span></span>
|
|
960
|
+
</div>
|
|
961
|
+
</div>
|
|
962
|
+
</div>
|
|
963
|
+
|
|
964
|
+
<div class="input-area">
|
|
965
|
+
<input
|
|
966
|
+
type="file"
|
|
967
|
+
ref="fileInputRef"
|
|
968
|
+
multiple
|
|
969
|
+
style="display: none"
|
|
970
|
+
@change="handleFileSelect"
|
|
971
|
+
accept="image/*,text/*,.pdf,.json,.md,.py,.js,.ts,.tsx,.jsx,.css,.html,.xml,.yaml,.yml,.toml,.sh,.sql,.csv"
|
|
972
|
+
/>
|
|
973
|
+
<div v-if="queuedMessages.length > 0" class="queue-bar">
|
|
974
|
+
<div v-for="(qm, qi) in queuedMessages" :key="qm.id" class="queue-item">
|
|
975
|
+
<span class="queue-item-num">{{ qi + 1 }}.</span>
|
|
976
|
+
<span class="queue-item-text">{{ qm.content }}</span>
|
|
977
|
+
<span v-if="qm.attachments && qm.attachments.length" class="queue-item-attach" :title="qm.attachments.map(a => a.name).join(', ')">
|
|
978
|
+
<svg viewBox="0 0 24 24" width="11" height="11"><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>
|
|
979
|
+
{{ qm.attachments.length }}
|
|
980
|
+
</span>
|
|
981
|
+
<button class="queue-item-remove" @click="removeQueuedMessage(qm.id)" title="Remove from queue">×</button>
|
|
982
|
+
</div>
|
|
983
|
+
</div>
|
|
984
|
+
<div v-if="usageStats" class="usage-bar">{{ formatUsage(usageStats) }}</div>
|
|
985
|
+
<div
|
|
986
|
+
:class="['input-card', { 'drag-over': dragOver }]"
|
|
987
|
+
@dragover="handleDragOver"
|
|
988
|
+
@dragleave="handleDragLeave"
|
|
989
|
+
@drop="handleDrop"
|
|
990
|
+
>
|
|
991
|
+
<textarea
|
|
992
|
+
ref="inputRef"
|
|
993
|
+
v-model="inputText"
|
|
994
|
+
@keydown="handleKeydown"
|
|
995
|
+
@input="autoResize"
|
|
996
|
+
@paste="handlePaste"
|
|
997
|
+
:disabled="status !== 'Connected' || isCompacting"
|
|
998
|
+
:placeholder="isCompacting ? 'Context compacting in progress...' : 'Send a message · Enter to send'"
|
|
999
|
+
rows="1"
|
|
1000
|
+
></textarea>
|
|
1001
|
+
<div v-if="attachments.length > 0" class="attachment-bar">
|
|
1002
|
+
<div v-for="(att, i) in attachments" :key="i" class="attachment-chip">
|
|
1003
|
+
<img v-if="att.isImage && att.thumbUrl" :src="att.thumbUrl" class="attachment-thumb" />
|
|
1004
|
+
<div v-else class="attachment-file-icon">
|
|
1005
|
+
<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>
|
|
1006
|
+
</div>
|
|
1007
|
+
<div class="attachment-info">
|
|
1008
|
+
<div class="attachment-name">{{ att.name }}</div>
|
|
1009
|
+
<div class="attachment-size">{{ formatFileSize(att.size) }}</div>
|
|
1010
|
+
</div>
|
|
1011
|
+
<button class="attachment-remove" @click="removeAttachment(i)" title="Remove">×</button>
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
<div class="input-bottom-row">
|
|
1015
|
+
<button class="attach-btn" @click="triggerFileInput" :disabled="status !== 'Connected' || isCompacting || attachments.length >= 5" title="Attach files">
|
|
1016
|
+
<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>
|
|
1017
|
+
</button>
|
|
1018
|
+
<button v-if="isProcessing && !hasInput" @click="cancelExecution" class="send-btn stop-btn" title="Stop generation">
|
|
1019
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
|
|
1020
|
+
</button>
|
|
1021
|
+
<button v-else @click="sendMessage" :disabled="!canSend" class="send-btn" title="Send">
|
|
1022
|
+
<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>
|
|
1023
|
+
</button>
|
|
1024
|
+
</div>
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>
|
|
1027
|
+
</div>
|
|
1028
|
+
|
|
1029
|
+
<!-- Preview Panel (desktop) -->
|
|
1030
|
+
<Transition name="file-panel">
|
|
1031
|
+
<div v-if="previewPanelOpen && !isMobile" class="preview-panel" :style="{ width: previewPanelWidth + 'px' }">
|
|
1032
|
+
<div class="preview-panel-resize-handle"
|
|
1033
|
+
@mousedown="filePreview.onResizeStart($event)"
|
|
1034
|
+
@touchstart="filePreview.onResizeStart($event)"></div>
|
|
1035
|
+
<div class="preview-panel-header">
|
|
1036
|
+
<span class="preview-panel-filename" :title="previewFile?.filePath">
|
|
1037
|
+
{{ previewFile?.fileName || 'Preview' }}
|
|
1038
|
+
</span>
|
|
1039
|
+
<span v-if="previewFile" class="preview-panel-size">
|
|
1040
|
+
{{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
1041
|
+
</span>
|
|
1042
|
+
<button class="preview-panel-close" @click="filePreview.closePreview()" title="Close preview">×</button>
|
|
1043
|
+
</div>
|
|
1044
|
+
<div class="preview-panel-body">
|
|
1045
|
+
<div v-if="previewLoading" class="preview-loading">Loading...</div>
|
|
1046
|
+
<div v-else-if="previewFile?.error" class="preview-error">
|
|
1047
|
+
{{ previewFile.error }}
|
|
1048
|
+
</div>
|
|
1049
|
+
<div v-else-if="previewFile?.encoding === 'base64' && previewFile?.content"
|
|
1050
|
+
class="preview-image-container">
|
|
1051
|
+
<img :src="'data:' + previewFile.mimeType + ';base64,' + previewFile.content"
|
|
1052
|
+
:alt="previewFile.fileName" class="preview-image" />
|
|
1053
|
+
</div>
|
|
1054
|
+
<div v-else-if="previewFile?.content" class="preview-text-container">
|
|
1055
|
+
<pre class="preview-code"><code v-html="filePreview.highlightCode(previewFile.content, previewFile.fileName)"></code></pre>
|
|
1056
|
+
<div v-if="previewFile.truncated" class="preview-truncated-notice">
|
|
1057
|
+
File truncated — showing first 100 KB of {{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
1058
|
+
</div>
|
|
1059
|
+
</div>
|
|
1060
|
+
<div v-else-if="previewFile && !previewFile.content && !previewFile.error" class="preview-binary-info">
|
|
1061
|
+
<div class="preview-binary-icon">
|
|
1062
|
+
<svg viewBox="0 0 24 24" width="48" height="48"><path fill="currentColor" opacity="0.4" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
|
1063
|
+
</div>
|
|
1064
|
+
<p>Binary file</p>
|
|
1065
|
+
<p class="preview-binary-meta">{{ previewFile.mimeType }}</p>
|
|
1066
|
+
<p class="preview-binary-meta">{{ filePreview.formatFileSize(previewFile.totalSize) }}</p>
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
</div>
|
|
1070
|
+
</Transition>
|
|
1071
|
+
|
|
1072
|
+
</div>
|
|
1073
|
+
|
|
1074
|
+
<!-- Folder Picker Modal -->
|
|
1075
|
+
<div class="folder-picker-overlay" v-if="folderPickerOpen" @click.self="folderPickerOpen = false">
|
|
1076
|
+
<div class="folder-picker-dialog">
|
|
1077
|
+
<div class="folder-picker-header">
|
|
1078
|
+
<span>Select Working Directory</span>
|
|
1079
|
+
<button class="folder-picker-close" @click="folderPickerOpen = false">×</button>
|
|
1080
|
+
</div>
|
|
1081
|
+
<div class="folder-picker-nav">
|
|
1082
|
+
<button class="folder-picker-up" @click="folderPickerNavigateUp" :disabled="!folderPickerPath" title="Go to parent directory">
|
|
1083
|
+
<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>
|
|
1084
|
+
</button>
|
|
1085
|
+
<input class="folder-picker-path-input" type="text" v-model="folderPickerPath" @keydown.enter="folderPickerGoToPath" placeholder="Enter path..." spellcheck="false" />
|
|
1086
|
+
</div>
|
|
1087
|
+
<div class="folder-picker-list">
|
|
1088
|
+
<div v-if="folderPickerLoading" class="folder-picker-loading">
|
|
1089
|
+
<div class="history-loading-spinner"></div>
|
|
1090
|
+
<span>Loading...</span>
|
|
1091
|
+
</div>
|
|
1092
|
+
<template v-else>
|
|
1093
|
+
<div
|
|
1094
|
+
v-for="entry in folderPickerEntries" :key="entry.name"
|
|
1095
|
+
:class="['folder-picker-item', { 'folder-picker-selected': folderPickerSelected === entry.name }]"
|
|
1096
|
+
@click="folderPickerSelectItem(entry)"
|
|
1097
|
+
@dblclick="folderPickerEnter(entry)"
|
|
1098
|
+
>
|
|
1099
|
+
<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>
|
|
1100
|
+
<span>{{ entry.name }}</span>
|
|
1101
|
+
</div>
|
|
1102
|
+
<div v-if="folderPickerEntries.length === 0" class="folder-picker-empty">No subdirectories found.</div>
|
|
1103
|
+
</template>
|
|
1104
|
+
</div>
|
|
1105
|
+
<div class="folder-picker-footer">
|
|
1106
|
+
<button class="folder-picker-cancel" @click="folderPickerOpen = false">Cancel</button>
|
|
1107
|
+
<button class="folder-picker-confirm" @click="confirmFolderPicker" :disabled="!folderPickerPath">Open</button>
|
|
1108
|
+
</div>
|
|
1109
|
+
</div>
|
|
1110
|
+
</div>
|
|
1111
|
+
|
|
1112
|
+
<!-- Delete Session Confirmation Dialog -->
|
|
1113
|
+
<div class="folder-picker-overlay" v-if="deleteConfirmOpen" @click.self="cancelDeleteSession">
|
|
1114
|
+
<div class="delete-confirm-dialog">
|
|
1115
|
+
<div class="delete-confirm-header">Delete Session</div>
|
|
1116
|
+
<div class="delete-confirm-body">
|
|
1117
|
+
<p>Are you sure you want to delete this session?</p>
|
|
1118
|
+
<p class="delete-confirm-title">{{ deleteConfirmTitle }}</p>
|
|
1119
|
+
<p class="delete-confirm-warning">This action cannot be undone.</p>
|
|
1120
|
+
</div>
|
|
1121
|
+
<div class="delete-confirm-footer">
|
|
1122
|
+
<button class="folder-picker-cancel" @click="cancelDeleteSession">Cancel</button>
|
|
1123
|
+
<button class="delete-confirm-btn" @click="confirmDeleteSession">Delete</button>
|
|
1124
|
+
</div>
|
|
1125
|
+
</div>
|
|
1126
|
+
</div>
|
|
1127
|
+
|
|
1128
|
+
<!-- Password Authentication Dialog -->
|
|
1129
|
+
<div class="folder-picker-overlay" v-if="authRequired && !authLocked">
|
|
1130
|
+
<div class="auth-dialog">
|
|
1131
|
+
<div class="auth-dialog-header">
|
|
1132
|
+
<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>
|
|
1133
|
+
<span>Session Protected</span>
|
|
1134
|
+
</div>
|
|
1135
|
+
<div class="auth-dialog-body">
|
|
1136
|
+
<p>This session requires a password to access.</p>
|
|
1137
|
+
<input
|
|
1138
|
+
type="password"
|
|
1139
|
+
class="auth-password-input"
|
|
1140
|
+
v-model="authPassword"
|
|
1141
|
+
@keydown.enter="submitPassword"
|
|
1142
|
+
placeholder="Enter password..."
|
|
1143
|
+
autofocus
|
|
1144
|
+
/>
|
|
1145
|
+
<p v-if="authError" class="auth-error">{{ authError }}</p>
|
|
1146
|
+
<p v-if="authAttempts" class="auth-attempts">{{ authAttempts }}</p>
|
|
1147
|
+
</div>
|
|
1148
|
+
<div class="auth-dialog-footer">
|
|
1149
|
+
<button class="auth-submit-btn" @click="submitPassword" :disabled="!authPassword.trim()">Unlock</button>
|
|
1150
|
+
</div>
|
|
1151
|
+
</div>
|
|
1152
|
+
</div>
|
|
1153
|
+
|
|
1154
|
+
<!-- Auth Locked Out -->
|
|
1155
|
+
<div class="folder-picker-overlay" v-if="authLocked">
|
|
1156
|
+
<div class="auth-dialog auth-dialog-locked">
|
|
1157
|
+
<div class="auth-dialog-header">
|
|
1158
|
+
<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>
|
|
1159
|
+
<span>Access Locked</span>
|
|
1160
|
+
</div>
|
|
1161
|
+
<div class="auth-dialog-body">
|
|
1162
|
+
<p>{{ authError }}</p>
|
|
1163
|
+
<p class="auth-locked-hint">Close this tab and try again later.</p>
|
|
1164
|
+
</div>
|
|
1165
|
+
</div>
|
|
1166
|
+
</div>
|
|
1167
|
+
|
|
1168
|
+
<!-- File context menu -->
|
|
1169
|
+
<div
|
|
1170
|
+
v-if="fileContextMenu"
|
|
1171
|
+
class="file-context-menu"
|
|
1172
|
+
:style="{ left: fileContextMenu.x + 'px', top: fileContextMenu.y + 'px' }"
|
|
1173
|
+
>
|
|
1174
|
+
<div class="file-context-item" @click="fileBrowser.askClaudeRead()">
|
|
1175
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM5 15h14v2H5zm0-4h14v2H5zm0-4h14v2H5z"/></svg>
|
|
1176
|
+
Ask Claude to read
|
|
1177
|
+
</div>
|
|
1178
|
+
<div class="file-context-item" @click="fileBrowser.copyPath()">
|
|
1179
|
+
<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>
|
|
1180
|
+
{{ fileContextMenu.copied ? 'Copied!' : 'Copy path' }}
|
|
1181
|
+
</div>
|
|
1182
|
+
<div class="file-context-item" @click="fileBrowser.insertPath()">
|
|
1183
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
|
|
1184
|
+
Insert path to input
|
|
1185
|
+
</div>
|
|
1186
|
+
</div>
|
|
1187
|
+
</div>
|
|
1188
|
+
`
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
const app = createApp(App);
|
|
1192
|
+
app.mount('#app');
|