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