@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
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// ── Message helpers: formatting, tool summaries, diff display ─────────────────
|
|
2
|
+
import { renderMarkdown } from './markdown.js';
|
|
3
|
+
|
|
4
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
5
|
+
const CONTEXT_SUMMARY_PREFIX = 'This session is being continued from a previous conversation';
|
|
6
|
+
|
|
7
|
+
export function isContextSummary(text) {
|
|
8
|
+
return typeof text === 'string' && text.trimStart().startsWith(CONTEXT_SUMMARY_PREFIX);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatRelativeTime(ts) {
|
|
12
|
+
const diff = Date.now() - ts;
|
|
13
|
+
const mins = Math.floor(diff / 60000);
|
|
14
|
+
if (mins < 1) return 'just now';
|
|
15
|
+
if (mins < 60) return `${mins}m ago`;
|
|
16
|
+
const hours = Math.floor(mins / 60);
|
|
17
|
+
if (hours < 24) return `${hours}h ago`;
|
|
18
|
+
const days = Math.floor(hours / 24);
|
|
19
|
+
if (days < 30) return `${days}d ago`;
|
|
20
|
+
return new Date(ts).toLocaleDateString();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatTimestamp(ts) {
|
|
24
|
+
if (!ts) return '';
|
|
25
|
+
const d = ts instanceof Date ? ts : new Date(ts);
|
|
26
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' · ' + d.toLocaleDateString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getRenderedContent(msg) {
|
|
30
|
+
if (msg.role !== 'assistant' && !msg.isCommandOutput) return msg.content;
|
|
31
|
+
return renderMarkdown(msg.content);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function copyMessage(msg) {
|
|
35
|
+
try {
|
|
36
|
+
await navigator.clipboard.writeText(msg.content);
|
|
37
|
+
msg.copied = true;
|
|
38
|
+
setTimeout(() => { msg.copied = false; }, 2000);
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isPrevAssistant(visibleMessages, idx) {
|
|
43
|
+
if (idx <= 0) return false;
|
|
44
|
+
const prev = visibleMessages[idx - 1];
|
|
45
|
+
return prev && (prev.role === 'assistant' || prev.role === 'tool');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toggleContextSummary(msg) {
|
|
49
|
+
msg.contextExpanded = !msg.contextExpanded;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function toggleTool(msg) {
|
|
53
|
+
msg.expanded = !msg.expanded;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getToolSummary(msg) {
|
|
57
|
+
const name = msg.toolName;
|
|
58
|
+
const input = msg.toolInput;
|
|
59
|
+
try {
|
|
60
|
+
const obj = JSON.parse(input);
|
|
61
|
+
if (name === 'Read' && obj.file_path) return obj.file_path;
|
|
62
|
+
if (name === 'Edit' && obj.file_path) return obj.file_path;
|
|
63
|
+
if (name === 'Write' && obj.file_path) return obj.file_path;
|
|
64
|
+
if (name === 'Bash' && obj.command) return obj.command.length > 60 ? obj.command.slice(0, 60) + '...' : obj.command;
|
|
65
|
+
if (name === 'Glob' && obj.pattern) return obj.pattern;
|
|
66
|
+
if (name === 'Grep' && obj.pattern) return obj.pattern;
|
|
67
|
+
if (name === 'TodoWrite' && obj.todos) {
|
|
68
|
+
const done = obj.todos.filter(t => t.status === 'completed').length;
|
|
69
|
+
return `${done}/${obj.todos.length} done`;
|
|
70
|
+
}
|
|
71
|
+
if (name === 'Task' && obj.description) return obj.description;
|
|
72
|
+
if (name === 'WebSearch' && obj.query) return obj.query;
|
|
73
|
+
if (name === 'WebFetch' && obj.url) return obj.url.length > 60 ? obj.url.slice(0, 60) + '...' : obj.url;
|
|
74
|
+
} catch {}
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isEditTool(msg) {
|
|
79
|
+
return msg.role === 'tool' && msg.toolName === 'Edit' && msg.toolInput;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getFormattedToolInput(msg) {
|
|
83
|
+
if (!msg.toolInput) return null;
|
|
84
|
+
try {
|
|
85
|
+
const obj = JSON.parse(msg.toolInput);
|
|
86
|
+
const esc = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
87
|
+
const name = msg.toolName;
|
|
88
|
+
|
|
89
|
+
if (name === 'Read' && obj.file_path) {
|
|
90
|
+
let detail = esc(obj.file_path);
|
|
91
|
+
if (obj.offset && obj.limit) {
|
|
92
|
+
detail += ` <span class="tool-input-meta">lines ${obj.offset}\u2013${obj.offset + obj.limit - 1}</span>`;
|
|
93
|
+
} else if (obj.offset) {
|
|
94
|
+
detail += ` <span class="tool-input-meta">from line ${obj.offset}</span>`;
|
|
95
|
+
} else if (obj.limit) {
|
|
96
|
+
detail += ` <span class="tool-input-meta">first ${obj.limit} lines</span>`;
|
|
97
|
+
}
|
|
98
|
+
return detail;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (name === 'Write' && obj.file_path) {
|
|
102
|
+
const lines = (obj.content || '').split('\n').length;
|
|
103
|
+
return esc(obj.file_path) + ` <span class="tool-input-meta">${lines} lines</span>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (name === 'Bash' && obj.command) {
|
|
107
|
+
let html = '<code class="tool-input-cmd">' + esc(obj.command) + '</code>';
|
|
108
|
+
if (obj.description) html = '<span class="tool-input-meta">' + esc(obj.description) + '</span> ' + html;
|
|
109
|
+
return html;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (name === 'Glob' && obj.pattern) {
|
|
113
|
+
let html = '<code class="tool-input-cmd">' + esc(obj.pattern) + '</code>';
|
|
114
|
+
if (obj.path) html += ' <span class="tool-input-meta">in ' + esc(obj.path) + '</span>';
|
|
115
|
+
return html;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (name === 'Grep' && obj.pattern) {
|
|
119
|
+
let html = '<code class="tool-input-cmd">' + esc(obj.pattern) + '</code>';
|
|
120
|
+
if (obj.path) html += ' <span class="tool-input-meta">in ' + esc(obj.path) + '</span>';
|
|
121
|
+
return html;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (name === 'TodoWrite' && Array.isArray(obj.todos)) {
|
|
125
|
+
let html = '<div class="todo-list">';
|
|
126
|
+
for (const t of obj.todos) {
|
|
127
|
+
const s = t.status;
|
|
128
|
+
const icon = s === 'completed' ? '<span class="todo-icon done">\u2713</span>'
|
|
129
|
+
: s === 'in_progress' ? '<span class="todo-icon active">\u25CF</span>'
|
|
130
|
+
: '<span class="todo-icon">\u25CB</span>';
|
|
131
|
+
const cls = s === 'completed' ? ' todo-done' : s === 'in_progress' ? ' todo-active' : '';
|
|
132
|
+
html += '<div class="todo-item' + cls + '">' + icon + '<span class="todo-text">' + esc(t.content || t.activeForm || '') + '</span></div>';
|
|
133
|
+
}
|
|
134
|
+
html += '</div>';
|
|
135
|
+
return html;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (name === 'Task') {
|
|
139
|
+
let html = '';
|
|
140
|
+
if (obj.description) html += '<div class="task-field"><span class="tool-input-meta">Description</span> ' + esc(obj.description) + '</div>';
|
|
141
|
+
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>';
|
|
142
|
+
if (obj.prompt) {
|
|
143
|
+
const short = obj.prompt.length > 200 ? obj.prompt.slice(0, 200) + '...' : obj.prompt;
|
|
144
|
+
html += '<div class="task-field"><span class="tool-input-meta">Prompt</span></div><div class="task-prompt">' + esc(short) + '</div>';
|
|
145
|
+
}
|
|
146
|
+
if (html) return html;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (name === 'WebSearch' && obj.query) {
|
|
150
|
+
return '<code class="tool-input-cmd">' + esc(obj.query) + '</code>';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (name === 'WebFetch' && obj.url) {
|
|
154
|
+
let html = '<a class="tool-link" href="' + esc(obj.url) + '" target="_blank" rel="noopener">' + esc(obj.url) + '</a>';
|
|
155
|
+
if (obj.prompt) html += '<div class="task-field"><span class="tool-input-meta">' + esc(obj.prompt) + '</span></div>';
|
|
156
|
+
return html;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
} catch {}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function getEditDiffHtml(msg) {
|
|
164
|
+
try {
|
|
165
|
+
const obj = JSON.parse(msg.toolInput);
|
|
166
|
+
if (!obj.old_string && !obj.new_string) return null;
|
|
167
|
+
const esc = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
168
|
+
const filePath = obj.file_path || '';
|
|
169
|
+
const oldLines = (obj.old_string || '').split('\n');
|
|
170
|
+
const newLines = (obj.new_string || '').split('\n');
|
|
171
|
+
let html = '';
|
|
172
|
+
if (filePath) {
|
|
173
|
+
html += '<div class="diff-file">' + esc(filePath) + (obj.replace_all ? ' <span class="diff-replace-all">(replace all)</span>' : '') + '</div>';
|
|
174
|
+
}
|
|
175
|
+
html += '<div class="diff-lines">';
|
|
176
|
+
for (const line of oldLines) {
|
|
177
|
+
html += '<div class="diff-removed">' + '<span class="diff-sign">-</span>' + esc(line) + '</div>';
|
|
178
|
+
}
|
|
179
|
+
for (const line of newLines) {
|
|
180
|
+
html += '<div class="diff-added">' + '<span class="diff-sign">+</span>' + esc(line) + '</div>';
|
|
181
|
+
}
|
|
182
|
+
html += '</div>';
|
|
183
|
+
return html;
|
|
184
|
+
} catch { return null; }
|
|
185
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// ── Sidebar: session management, folder picker, grouped sessions ─────────────
|
|
2
|
+
const { computed } = Vue;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates sidebar functionality bound to reactive state.
|
|
6
|
+
* @param {object} deps
|
|
7
|
+
* @param {Function} deps.wsSend
|
|
8
|
+
* @param {import('vue').Ref} deps.messages
|
|
9
|
+
* @param {import('vue').Ref} deps.isProcessing
|
|
10
|
+
* @param {import('vue').Ref} deps.sidebarOpen
|
|
11
|
+
* @param {import('vue').Ref} deps.historySessions
|
|
12
|
+
* @param {import('vue').Ref} deps.currentClaudeSessionId
|
|
13
|
+
* @param {import('vue').Ref} deps.needsResume
|
|
14
|
+
* @param {import('vue').Ref} deps.loadingSessions
|
|
15
|
+
* @param {import('vue').Ref} deps.loadingHistory
|
|
16
|
+
* @param {import('vue').Ref} deps.workDir
|
|
17
|
+
* @param {import('vue').Ref} deps.visibleLimit
|
|
18
|
+
* @param {import('vue').Ref} deps.folderPickerOpen
|
|
19
|
+
* @param {import('vue').Ref} deps.folderPickerPath
|
|
20
|
+
* @param {import('vue').Ref} deps.folderPickerEntries
|
|
21
|
+
* @param {import('vue').Ref} deps.folderPickerLoading
|
|
22
|
+
* @param {import('vue').Ref} deps.folderPickerSelected
|
|
23
|
+
* @param {object} deps.streaming - streaming controller
|
|
24
|
+
*/
|
|
25
|
+
export function createSidebar(deps) {
|
|
26
|
+
const {
|
|
27
|
+
wsSend, messages, isProcessing, sidebarOpen,
|
|
28
|
+
historySessions, currentClaudeSessionId, needsResume,
|
|
29
|
+
loadingSessions, loadingHistory, workDir, visibleLimit,
|
|
30
|
+
folderPickerOpen, folderPickerPath, folderPickerEntries,
|
|
31
|
+
folderPickerLoading, folderPickerSelected, streaming,
|
|
32
|
+
} = deps;
|
|
33
|
+
|
|
34
|
+
// ── Session management ──
|
|
35
|
+
|
|
36
|
+
function requestSessionList() {
|
|
37
|
+
loadingSessions.value = true;
|
|
38
|
+
wsSend({ type: 'list_sessions' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resumeSession(session) {
|
|
42
|
+
if (isProcessing.value) return;
|
|
43
|
+
if (window.innerWidth <= 768) sidebarOpen.value = false;
|
|
44
|
+
messages.value = [];
|
|
45
|
+
visibleLimit.value = 50;
|
|
46
|
+
streaming.setMessageIdCounter(0);
|
|
47
|
+
streaming.setStreamingMessageId(null);
|
|
48
|
+
streaming.reset();
|
|
49
|
+
|
|
50
|
+
currentClaudeSessionId.value = session.sessionId;
|
|
51
|
+
needsResume.value = true;
|
|
52
|
+
loadingHistory.value = true;
|
|
53
|
+
|
|
54
|
+
wsSend({
|
|
55
|
+
type: 'resume_conversation',
|
|
56
|
+
claudeSessionId: session.sessionId,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function newConversation() {
|
|
61
|
+
if (isProcessing.value) return;
|
|
62
|
+
if (window.innerWidth <= 768) sidebarOpen.value = false;
|
|
63
|
+
messages.value = [];
|
|
64
|
+
visibleLimit.value = 50;
|
|
65
|
+
streaming.setMessageIdCounter(0);
|
|
66
|
+
streaming.setStreamingMessageId(null);
|
|
67
|
+
streaming.reset();
|
|
68
|
+
currentClaudeSessionId.value = null;
|
|
69
|
+
needsResume.value = false;
|
|
70
|
+
|
|
71
|
+
messages.value.push({
|
|
72
|
+
id: streaming.nextId(), role: 'system',
|
|
73
|
+
content: 'New conversation started.',
|
|
74
|
+
timestamp: new Date(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toggleSidebar() {
|
|
79
|
+
sidebarOpen.value = !sidebarOpen.value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Folder picker ──
|
|
83
|
+
|
|
84
|
+
function openFolderPicker() {
|
|
85
|
+
folderPickerOpen.value = true;
|
|
86
|
+
folderPickerSelected.value = '';
|
|
87
|
+
folderPickerLoading.value = true;
|
|
88
|
+
folderPickerPath.value = workDir.value || '';
|
|
89
|
+
folderPickerEntries.value = [];
|
|
90
|
+
wsSend({ type: 'list_directory', dirPath: workDir.value || '' });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function loadFolderPickerDir(dirPath) {
|
|
94
|
+
folderPickerLoading.value = true;
|
|
95
|
+
folderPickerSelected.value = '';
|
|
96
|
+
folderPickerEntries.value = [];
|
|
97
|
+
wsSend({ type: 'list_directory', dirPath });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function folderPickerNavigateUp() {
|
|
101
|
+
if (!folderPickerPath.value) return;
|
|
102
|
+
const isWin = folderPickerPath.value.includes('\\');
|
|
103
|
+
const parts = folderPickerPath.value.replace(/[/\\]$/, '').split(/[/\\]/);
|
|
104
|
+
parts.pop();
|
|
105
|
+
if (parts.length === 0) {
|
|
106
|
+
folderPickerPath.value = '';
|
|
107
|
+
loadFolderPickerDir('');
|
|
108
|
+
} else if (isWin && parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) {
|
|
109
|
+
folderPickerPath.value = parts[0] + '\\';
|
|
110
|
+
loadFolderPickerDir(parts[0] + '\\');
|
|
111
|
+
} else {
|
|
112
|
+
const sep = isWin ? '\\' : '/';
|
|
113
|
+
const parent = parts.join(sep);
|
|
114
|
+
folderPickerPath.value = parent;
|
|
115
|
+
loadFolderPickerDir(parent);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function folderPickerSelectItem(entry) {
|
|
120
|
+
folderPickerSelected.value = entry.name;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function folderPickerEnter(entry) {
|
|
124
|
+
const sep = folderPickerPath.value.includes('\\') || /^[A-Z]:/.test(entry.name) ? '\\' : '/';
|
|
125
|
+
let newPath;
|
|
126
|
+
if (!folderPickerPath.value) {
|
|
127
|
+
newPath = entry.name + (entry.name.endsWith('\\') ? '' : '\\');
|
|
128
|
+
} else {
|
|
129
|
+
newPath = folderPickerPath.value.replace(/[/\\]$/, '') + sep + entry.name;
|
|
130
|
+
}
|
|
131
|
+
folderPickerPath.value = newPath;
|
|
132
|
+
folderPickerSelected.value = '';
|
|
133
|
+
loadFolderPickerDir(newPath);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function folderPickerGoToPath() {
|
|
137
|
+
const path = folderPickerPath.value.trim();
|
|
138
|
+
if (!path) {
|
|
139
|
+
loadFolderPickerDir('');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
folderPickerSelected.value = '';
|
|
143
|
+
loadFolderPickerDir(path);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function confirmFolderPicker() {
|
|
147
|
+
let path = folderPickerPath.value;
|
|
148
|
+
if (!path) return;
|
|
149
|
+
if (folderPickerSelected.value) {
|
|
150
|
+
const sep = path.includes('\\') ? '\\' : '/';
|
|
151
|
+
path = path.replace(/[/\\]$/, '') + sep + folderPickerSelected.value;
|
|
152
|
+
}
|
|
153
|
+
folderPickerOpen.value = false;
|
|
154
|
+
wsSend({ type: 'change_workdir', workDir: path });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Grouped sessions ──
|
|
158
|
+
|
|
159
|
+
const groupedSessions = computed(() => {
|
|
160
|
+
if (!historySessions.value.length) return [];
|
|
161
|
+
const now = new Date();
|
|
162
|
+
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
163
|
+
const yesterdayStart = todayStart - 86400000;
|
|
164
|
+
const weekStart = todayStart - 6 * 86400000;
|
|
165
|
+
|
|
166
|
+
const groups = {};
|
|
167
|
+
for (const s of historySessions.value) {
|
|
168
|
+
let label;
|
|
169
|
+
if (s.lastModified >= todayStart) label = 'Today';
|
|
170
|
+
else if (s.lastModified >= yesterdayStart) label = 'Yesterday';
|
|
171
|
+
else if (s.lastModified >= weekStart) label = 'This week';
|
|
172
|
+
else label = 'Earlier';
|
|
173
|
+
if (!groups[label]) groups[label] = [];
|
|
174
|
+
groups[label].push(s);
|
|
175
|
+
}
|
|
176
|
+
const order = ['Today', 'Yesterday', 'This week', 'Earlier'];
|
|
177
|
+
return order.filter(k => groups[k]).map(k => ({ label: k, sessions: groups[k] }));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
requestSessionList, resumeSession, newConversation, toggleSidebar,
|
|
182
|
+
openFolderPicker, folderPickerNavigateUp, folderPickerSelectItem,
|
|
183
|
+
folderPickerEnter, folderPickerGoToPath, confirmFolderPicker,
|
|
184
|
+
groupedSessions,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// ── Progressive text streaming / reveal animation ────────────────────────────
|
|
2
|
+
|
|
3
|
+
const CHARS_PER_TICK = 5;
|
|
4
|
+
const TICK_MS = 16;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a streaming text reveal controller.
|
|
8
|
+
* @param {object} deps
|
|
9
|
+
* @param {import('vue').Ref} deps.messages - messages ref array
|
|
10
|
+
* @param {() => void} deps.scrollToBottom - scroll callback
|
|
11
|
+
*/
|
|
12
|
+
export function createStreaming({ messages, scrollToBottom }) {
|
|
13
|
+
let pendingText = '';
|
|
14
|
+
let revealTimer = null;
|
|
15
|
+
let streamingMessageId = null;
|
|
16
|
+
let messageIdCounter = 0;
|
|
17
|
+
|
|
18
|
+
function getMessageIdCounter() { return messageIdCounter; }
|
|
19
|
+
function setMessageIdCounter(v) { messageIdCounter = v; }
|
|
20
|
+
function getStreamingMessageId() { return streamingMessageId; }
|
|
21
|
+
function setStreamingMessageId(v) { streamingMessageId = v; }
|
|
22
|
+
function nextId() { return ++messageIdCounter; }
|
|
23
|
+
|
|
24
|
+
function startReveal() {
|
|
25
|
+
if (revealTimer !== null) return;
|
|
26
|
+
revealTimer = setTimeout(revealTick, TICK_MS);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function revealTick() {
|
|
30
|
+
revealTimer = null;
|
|
31
|
+
if (!pendingText) return;
|
|
32
|
+
|
|
33
|
+
const streamMsg = streamingMessageId !== null
|
|
34
|
+
? messages.value.find(m => m.id === streamingMessageId)
|
|
35
|
+
: null;
|
|
36
|
+
|
|
37
|
+
if (!streamMsg) {
|
|
38
|
+
const id = ++messageIdCounter;
|
|
39
|
+
const chunk = pendingText.slice(0, CHARS_PER_TICK);
|
|
40
|
+
pendingText = pendingText.slice(CHARS_PER_TICK);
|
|
41
|
+
messages.value.push({
|
|
42
|
+
id, role: 'assistant', content: chunk,
|
|
43
|
+
isStreaming: true, timestamp: new Date(),
|
|
44
|
+
});
|
|
45
|
+
streamingMessageId = id;
|
|
46
|
+
} else {
|
|
47
|
+
const chunk = pendingText.slice(0, CHARS_PER_TICK);
|
|
48
|
+
pendingText = pendingText.slice(CHARS_PER_TICK);
|
|
49
|
+
streamMsg.content += chunk;
|
|
50
|
+
}
|
|
51
|
+
scrollToBottom();
|
|
52
|
+
if (pendingText) revealTimer = setTimeout(revealTick, TICK_MS);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function flushReveal() {
|
|
56
|
+
if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
|
|
57
|
+
if (!pendingText) return;
|
|
58
|
+
const streamMsg = streamingMessageId !== null
|
|
59
|
+
? messages.value.find(m => m.id === streamingMessageId) : null;
|
|
60
|
+
if (streamMsg) {
|
|
61
|
+
streamMsg.content += pendingText;
|
|
62
|
+
} else {
|
|
63
|
+
const id = ++messageIdCounter;
|
|
64
|
+
messages.value.push({
|
|
65
|
+
id, role: 'assistant', content: pendingText,
|
|
66
|
+
isStreaming: true, timestamp: new Date(),
|
|
67
|
+
});
|
|
68
|
+
streamingMessageId = id;
|
|
69
|
+
}
|
|
70
|
+
pendingText = '';
|
|
71
|
+
scrollToBottom();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function appendPending(text) {
|
|
75
|
+
pendingText += text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reset() {
|
|
79
|
+
pendingText = '';
|
|
80
|
+
if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cleanup() {
|
|
84
|
+
if (revealTimer !== null) { clearTimeout(revealTimer); revealTimer = null; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
startReveal, flushReveal, appendPending, reset, cleanup,
|
|
89
|
+
getMessageIdCounter, setMessageIdCounter,
|
|
90
|
+
getStreamingMessageId, setStreamingMessageId,
|
|
91
|
+
nextId,
|
|
92
|
+
};
|
|
93
|
+
}
|
package/web/style.css
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
padding: 0;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/* Keyboard focus outlines (visible only for keyboard navigation) */
|
|
10
|
+
:focus-visible {
|
|
11
|
+
outline: 2px solid var(--accent);
|
|
12
|
+
outline-offset: 2px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
:focus:not(:focus-visible) {
|
|
16
|
+
outline: none;
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
:root {
|
|
10
20
|
--bg-primary: #0f172a;
|
|
11
21
|
--bg-secondary: #1e293b;
|
|
@@ -29,7 +39,7 @@
|
|
|
29
39
|
--bg-secondary: #f8f9fa;
|
|
30
40
|
--bg-tertiary: #e9ecef;
|
|
31
41
|
--text-primary: #1a1a1a;
|
|
32
|
-
--text-secondary: #
|
|
42
|
+
--text-secondary: #4b5563;
|
|
33
43
|
--accent: #2563eb;
|
|
34
44
|
--accent-hover: #1d4ed8;
|
|
35
45
|
--success: #16a34a;
|
|
@@ -753,6 +763,13 @@ body {
|
|
|
753
763
|
margin-left: 20px;
|
|
754
764
|
border-left: 1px solid var(--border);
|
|
755
765
|
padding-left: 8px;
|
|
766
|
+
overflow: hidden;
|
|
767
|
+
animation: toolExpand 0.15s ease-out;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
@keyframes toolExpand {
|
|
771
|
+
from { opacity: 0; max-height: 0; }
|
|
772
|
+
to { opacity: 1; max-height: 500px; }
|
|
756
773
|
}
|
|
757
774
|
|
|
758
775
|
.tool-block {
|