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