cli-jaw 1.6.13 → 1.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/commands/memory.js +11 -0
- package/dist/bin/commands/memory.js.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/src/agent/memory-flush-controller.js +50 -13
- package/dist/src/agent/memory-flush-controller.js.map +1 -1
- package/dist/src/memory/identity.js +6 -3
- package/dist/src/memory/identity.js.map +1 -1
- package/dist/src/memory/reflect.js +47 -0
- package/dist/src/memory/reflect.js.map +1 -1
- package/dist/src/routes/jaw-memory.js +25 -1
- package/dist/src/routes/jaw-memory.js.map +1 -1
- package/dist/src/routes/memory.js +4 -1
- package/dist/src/routes/memory.js.map +1 -1
- package/package.json +1 -1
- package/public/css/chat.css +20 -3
- package/public/css/layout.css +46 -15
- package/public/css/variables.css +3 -15
- package/public/dist/assets/{employees-C2G0-Rg9.js → employees-zxrU6ZV_.js} +1 -1
- package/public/dist/assets/index-D61icK-D.js +50 -0
- package/public/dist/assets/index-DVTRbkJF.css +1 -0
- package/public/dist/assets/locale-CxI5nTcf.js +3 -0
- package/public/dist/assets/render-CVr6a-dp.js +25 -0
- package/public/dist/assets/settings-BHIV4l1s.js +1 -0
- package/public/dist/assets/settings-Dl3RnWsB.js +40 -0
- package/public/dist/assets/{skills-C9o5E1Pc.js → skills-DhiCSGws.js} +1 -1
- package/public/dist/assets/skills-JuDja1UC.js +1 -0
- package/public/dist/assets/{slash-commands-DveLHSQt.js → slash-commands-B1k1vFJG.js} +1 -1
- package/public/dist/assets/slash-commands-DyLS0abr.js +1 -0
- package/public/dist/assets/ui-BXZhbE_1.js +131 -0
- package/public/dist/assets/ui-qR28iS0L.js +1 -0
- package/public/dist/assets/{ws-D39_cIa_.js → ws-CleMWrLF.js} +1 -1
- package/public/dist/index.html +25 -18
- package/public/index.html +23 -16
- package/public/js/features/avatar.ts +18 -60
- package/public/js/features/memory.ts +17 -5
- package/public/js/features/settings-templates.ts +6 -5
- package/public/js/locale.ts +30 -0
- package/public/js/render.ts +2 -1
- package/public/js/ui.ts +17 -15
- package/public/js/virtual-scroll.ts +43 -20
- package/public/dist/assets/index-CDdXQQmm.css +0 -1
- package/public/dist/assets/index-CIWCSFl-.js +0 -50
- package/public/dist/assets/locale-DVVWjxKN.js +0 -1
- package/public/dist/assets/render-BFAkzW1S.js +0 -25
- package/public/dist/assets/settings-BtX9STQd.js +0 -41
- package/public/dist/assets/settings-DUWhygHi.js +0 -1
- package/public/dist/assets/skills-C6aTdbWY.js +0 -1
- package/public/dist/assets/slash-commands-C1p8kRBv.js +0 -1
- package/public/dist/assets/ui-BpZlLDtM.js +0 -1
- package/public/dist/assets/ui-Dx3w-H-4.js +0 -131
|
@@ -30,40 +30,25 @@ function stateFor(role: AvatarRole): AvatarState {
|
|
|
30
30
|
return avatarState[role];
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function defaultEmoji(role: AvatarRole): string {
|
|
34
|
-
return role === 'agent' ? DEFAULT_AGENT : DEFAULT_USER;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
33
|
function storageKey(role: AvatarRole): string {
|
|
38
34
|
return role === 'agent' ? AGENT_KEY : USER_KEY;
|
|
39
35
|
}
|
|
40
36
|
|
|
41
|
-
function inputId(
|
|
42
|
-
return
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function statusId(role: AvatarRole): string {
|
|
46
|
-
return role === 'agent' ? 'agentAvatarStatus' : 'userAvatarStatus';
|
|
37
|
+
function inputId(_role: AvatarRole): string {
|
|
38
|
+
return _role === 'agent' ? 'agentAvatarPreview' : 'userAvatarPreview';
|
|
47
39
|
}
|
|
48
40
|
|
|
49
41
|
function iconSelector(role: AvatarRole): string {
|
|
50
42
|
return role === 'agent' ? '.agent-icon' : '.user-icon';
|
|
51
43
|
}
|
|
52
44
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const status = document.getElementById(statusId(role));
|
|
61
|
-
if (status) status.textContent = stateFor(role).imageUrl ? 'image active' : 'emoji active';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function setStatus(role: AvatarRole, text: string): void {
|
|
65
|
-
const status = document.getElementById(statusId(role));
|
|
66
|
-
if (status) status.textContent = text;
|
|
45
|
+
function syncPreview(role: AvatarRole): void {
|
|
46
|
+
const preview = document.getElementById(inputId(role));
|
|
47
|
+
if (preview) {
|
|
48
|
+
preview.innerHTML = avatarMarkup(role);
|
|
49
|
+
const kind = stateFor(role).imageUrl ? 'image' : 'emoji';
|
|
50
|
+
preview.setAttribute('data-avatar-kind', kind);
|
|
51
|
+
}
|
|
67
52
|
}
|
|
68
53
|
|
|
69
54
|
function avatarMarkup(role: AvatarRole): string {
|
|
@@ -91,7 +76,7 @@ function setServerAvatar(role: AvatarRole, payload?: AvatarServerEntry | null):
|
|
|
91
76
|
stateFor(role).imageUrl = '';
|
|
92
77
|
stateFor(role).updatedAt = payload?.updatedAt ?? null;
|
|
93
78
|
}
|
|
94
|
-
|
|
79
|
+
syncPreview(role);
|
|
95
80
|
applyAvatar(role);
|
|
96
81
|
}
|
|
97
82
|
|
|
@@ -110,28 +95,20 @@ async function authorizedFetch(path: string, init: RequestInit): Promise<Respons
|
|
|
110
95
|
}
|
|
111
96
|
|
|
112
97
|
async function uploadAvatar(role: AvatarRole, file: File): Promise<void> {
|
|
113
|
-
setStatus(role, 'uploading...');
|
|
114
98
|
const res = await authorizedFetch(`/api/avatar/${role}/upload`, {
|
|
115
99
|
method: 'POST',
|
|
116
100
|
headers: { 'X-Filename': encodeURIComponent(file.name) },
|
|
117
101
|
body: file,
|
|
118
102
|
});
|
|
119
103
|
const json = await res.json().catch(() => null);
|
|
120
|
-
if (!res.ok) {
|
|
121
|
-
setStatus(role, 'upload failed');
|
|
122
|
-
throw new Error(json?.error || `avatar upload failed (${res.status})`);
|
|
123
|
-
}
|
|
104
|
+
if (!res.ok) throw new Error(json?.error || `avatar upload failed (${res.status})`);
|
|
124
105
|
setServerAvatar(role, json?.data || json);
|
|
125
106
|
}
|
|
126
107
|
|
|
127
108
|
async function resetAvatarImage(role: AvatarRole): Promise<void> {
|
|
128
|
-
setStatus(role, 'resetting...');
|
|
129
109
|
const res = await authorizedFetch(`/api/avatar/${role}/image`, { method: 'DELETE' });
|
|
130
110
|
const json = await res.json().catch(() => null);
|
|
131
|
-
if (!res.ok) {
|
|
132
|
-
setStatus(role, 'reset failed');
|
|
133
|
-
throw new Error(json?.error || `avatar reset failed (${res.status})`);
|
|
134
|
-
}
|
|
111
|
+
if (!res.ok) throw new Error(json?.error || `avatar reset failed (${res.status})`);
|
|
135
112
|
setServerAvatar(role, json?.data || json);
|
|
136
113
|
}
|
|
137
114
|
|
|
@@ -166,8 +143,8 @@ function bindRoleControls(role: AvatarRole): void {
|
|
|
166
143
|
});
|
|
167
144
|
}
|
|
168
145
|
|
|
169
|
-
export function getAgentAvatar(): string { return
|
|
170
|
-
export function getUserAvatar(): string { return
|
|
146
|
+
export function getAgentAvatar(): string { return stateFor('agent').emoji; }
|
|
147
|
+
export function getUserAvatar(): string { return stateFor('user').emoji; }
|
|
171
148
|
export function getAgentAvatarMarkup(): string { return avatarMarkup('agent'); }
|
|
172
149
|
export function getUserAvatarMarkup(): string { return avatarMarkup('user'); }
|
|
173
150
|
|
|
@@ -175,7 +152,7 @@ export function setAgentAvatar(emoji: string): void {
|
|
|
175
152
|
const next = (emoji || '').trim() || DEFAULT_AGENT;
|
|
176
153
|
stateFor('agent').emoji = next;
|
|
177
154
|
localStorage.setItem(storageKey('agent'), next);
|
|
178
|
-
|
|
155
|
+
syncPreview('agent');
|
|
179
156
|
if (!stateFor('agent').imageUrl) applyAvatar('agent');
|
|
180
157
|
}
|
|
181
158
|
|
|
@@ -183,37 +160,18 @@ export function setUserAvatar(emoji: string): void {
|
|
|
183
160
|
const next = (emoji || '').trim() || DEFAULT_USER;
|
|
184
161
|
stateFor('user').emoji = next;
|
|
185
162
|
localStorage.setItem(storageKey('user'), next);
|
|
186
|
-
|
|
163
|
+
syncPreview('user');
|
|
187
164
|
if (!stateFor('user').imageUrl) applyAvatar('user');
|
|
188
165
|
}
|
|
189
166
|
|
|
190
167
|
export async function initAvatar(): Promise<void> {
|
|
191
168
|
stateFor('agent').emoji = localStorage.getItem(AGENT_KEY) || DEFAULT_AGENT;
|
|
192
169
|
stateFor('user').emoji = localStorage.getItem(USER_KEY) || DEFAULT_USER;
|
|
193
|
-
|
|
194
|
-
|
|
170
|
+
syncPreview('agent');
|
|
171
|
+
syncPreview('user');
|
|
195
172
|
|
|
196
173
|
if (!initialized) {
|
|
197
174
|
initialized = true;
|
|
198
|
-
|
|
199
|
-
document.getElementById('avatarSave')?.addEventListener('click', () => {
|
|
200
|
-
const agentInput = document.getElementById('agentAvatarInput') as HTMLInputElement | null;
|
|
201
|
-
const userInput = document.getElementById('userAvatarInput') as HTMLInputElement | null;
|
|
202
|
-
if (agentInput) setAgentAvatar(agentInput.value);
|
|
203
|
-
if (userInput) setUserAvatar(userInput.value);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
for (const id of ['agentAvatarInput', 'userAvatarInput']) {
|
|
207
|
-
document.getElementById(id)?.addEventListener('keydown', (e: Event) => {
|
|
208
|
-
const keyEvent = e as KeyboardEvent;
|
|
209
|
-
if (keyEvent.key === 'Enter') {
|
|
210
|
-
keyEvent.preventDefault();
|
|
211
|
-
document.getElementById('avatarSave')?.click();
|
|
212
|
-
(keyEvent.target as HTMLInputElement).blur();
|
|
213
|
-
}
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
175
|
bindRoleControls('agent');
|
|
218
176
|
bindRoleControls('user');
|
|
219
177
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { escapeHtml } from '../render.js';
|
|
3
3
|
import { api, apiJson } from '../api.js';
|
|
4
4
|
import { ICONS } from '../icons.js';
|
|
5
|
+
import { t } from '../locale.js';
|
|
5
6
|
|
|
6
7
|
interface MemoryFile {
|
|
7
8
|
name: string;
|
|
@@ -79,7 +80,7 @@ function syncSidebarBadge(status: AdvancedMemoryStatus | null, basicCount: numbe
|
|
|
79
80
|
const sideBtn = $('memorySidebarBtn');
|
|
80
81
|
if (!sideBtn) return;
|
|
81
82
|
if (status?.enabled && status?.hasSoul === false) {
|
|
82
|
-
sideBtn.innerHTML = `${ICONS.brain} Memory · <span style="color:var(--accent)"
|
|
83
|
+
sideBtn.innerHTML = `${ICONS.brain} Memory · <span style="color:var(--accent)">${t('updateNeeded')}</span>`;
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
85
86
|
const state = status?.indexState === 'ready'
|
|
@@ -101,7 +102,7 @@ function renderStatusBanner(status: AdvancedMemoryStatus | null) {
|
|
|
101
102
|
banner.style.display = '';
|
|
102
103
|
if (status.hasSoul === false) {
|
|
103
104
|
banner.innerHTML = `<span>Memory structure upgrade available.</span>
|
|
104
|
-
<button id="advUpgradeSoulBtn" class="btn-sm" style="margin-left:8px"
|
|
105
|
+
<button id="advUpgradeSoulBtn" class="btn-sm" style="margin-left:8px">${t('memoryUpdateBtn')}</button>`;
|
|
105
106
|
return;
|
|
106
107
|
}
|
|
107
108
|
if (status.state === 'not_initialized') {
|
|
@@ -324,12 +325,23 @@ export async function rerunAdvancedBootstrap(): Promise<void> {
|
|
|
324
325
|
|
|
325
326
|
export async function upgradeSoulMemory(): Promise<void> {
|
|
326
327
|
setAdvBusy(true);
|
|
327
|
-
setAdvBanner('
|
|
328
|
-
await apiJson<{
|
|
328
|
+
setAdvBanner(t('memoryUpdating'), true);
|
|
329
|
+
const result = await apiJson<{
|
|
330
|
+
activated: boolean;
|
|
331
|
+
created: boolean;
|
|
332
|
+
preview: string;
|
|
333
|
+
}>('/api/jaw-memory/soul/activate', 'POST', {});
|
|
329
334
|
setAdvBusy(false);
|
|
330
|
-
|
|
335
|
+
if (result?.created) {
|
|
336
|
+
setAdvBanner('✓ Soul identity created.');
|
|
337
|
+
} else {
|
|
338
|
+
setAdvBanner('✓ Soul already active.');
|
|
339
|
+
}
|
|
331
340
|
await openMemoryModal();
|
|
332
341
|
switchMemTab('status');
|
|
342
|
+
const freshStatus = await apiJson<any>('/api/memory/status');
|
|
343
|
+
syncSidebarBadge(freshStatus, 0);
|
|
344
|
+
renderStatusBanner(freshStatus);
|
|
333
345
|
}
|
|
334
346
|
|
|
335
347
|
export async function reindexAdvancedMemory(): Promise<void> {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { api, apiJson } from '../api.js';
|
|
3
3
|
import { ICONS } from '../icons.js';
|
|
4
4
|
import { escapeHtml } from '../render.js';
|
|
5
|
+
import { t as i18n, getLang, setLang } from '../locale.js';
|
|
5
6
|
|
|
6
7
|
// ── Prompt Modal ──
|
|
7
8
|
|
|
@@ -80,7 +81,7 @@ function openTemplateEditor(tmpl: TemplateInfo): void {
|
|
|
80
81
|
const saveBtn = document.getElementById('templateSaveBtn');
|
|
81
82
|
if (saveBtn) saveBtn.style.display = 'none';
|
|
82
83
|
const toggle = document.getElementById('templateDevToggle');
|
|
83
|
-
if (toggle) { toggle.style.color = 'var(--text-dim)'; toggle.style.borderColor = 'var(--border)'; toggle.innerHTML = `${ICONS.tool}
|
|
84
|
+
if (toggle) { toggle.style.color = 'var(--text-dim)'; toggle.style.borderColor = 'var(--border)'; toggle.innerHTML = `${ICONS.tool} ${i18n('devMode')}`; }
|
|
84
85
|
const title = document.getElementById('templateModalTitle');
|
|
85
86
|
if (title) title.innerHTML = `${ICONS.file} ${escapeHtml(tmpl.filename)}`;
|
|
86
87
|
showTemplateView('editor');
|
|
@@ -88,7 +89,7 @@ function openTemplateEditor(tmpl: TemplateInfo): void {
|
|
|
88
89
|
|
|
89
90
|
export function toggleDevMode(): void {
|
|
90
91
|
if (!_devMode) {
|
|
91
|
-
if (!confirm('
|
|
92
|
+
if (!confirm(i18n('promptEditWarning'))) return;
|
|
92
93
|
}
|
|
93
94
|
_devMode = !_devMode;
|
|
94
95
|
const editor = document.getElementById('templateEditor') as HTMLTextAreaElement;
|
|
@@ -99,7 +100,7 @@ export function toggleDevMode(): void {
|
|
|
99
100
|
if (toggle) {
|
|
100
101
|
toggle.style.color = _devMode ? 'var(--stop-btn)' : 'var(--text-dim)';
|
|
101
102
|
toggle.style.borderColor = _devMode ? 'var(--stop-btn)' : 'var(--border)';
|
|
102
|
-
toggle.innerHTML = _devMode ? `${ICONS.lockOpen}
|
|
103
|
+
toggle.innerHTML = _devMode ? `${ICONS.lockOpen} ${i18n('devModeOn')}` : `${ICONS.tool} ${i18n('devMode')}`;
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
|
|
@@ -109,7 +110,7 @@ export async function saveTemplateFromModal(): Promise<void> {
|
|
|
109
110
|
if (!id) return;
|
|
110
111
|
await apiJson(`/api/prompt-templates/${id}`, 'PUT', { content: editor.value });
|
|
111
112
|
const label = document.getElementById('templateEditorLabel');
|
|
112
|
-
if (label) { label.innerHTML = `${ICONS.check}
|
|
113
|
+
if (label) { label.innerHTML = `${ICONS.check} ${i18n('savedAndReloaded')}`; setTimeout(() => { label.innerHTML = `${ICONS.file} ${escapeHtml(id)}.md`; }, 2000); }
|
|
113
114
|
const t = _templates.find(x => x.id === id);
|
|
114
115
|
if (t) t.content = editor.value;
|
|
115
116
|
}
|
|
@@ -120,7 +121,7 @@ function showTemplateView(view: 'tree' | 'editor'): void {
|
|
|
120
121
|
if (treeView) treeView.style.display = view === 'tree' ? '' : 'none';
|
|
121
122
|
if (editorView) editorView.style.display = view === 'editor' ? 'flex' : 'none';
|
|
122
123
|
const title = document.getElementById('templateModalTitle');
|
|
123
|
-
if (title && view === 'tree') title.innerHTML = `${ICONS.plan}
|
|
124
|
+
if (title && view === 'tree') title.innerHTML = `${ICONS.plan} ${i18n('promptStructure')}`;
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
export function templateGoBack(): void { showTemplateView('tree'); }
|
package/public/js/locale.ts
CHANGED
|
@@ -21,3 +21,33 @@ export function syncStoredLocale(locale: string): void {
|
|
|
21
21
|
}
|
|
22
22
|
} catch { }
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
// ── Translation Map ──
|
|
26
|
+
type Lang = 'ko' | 'en';
|
|
27
|
+
|
|
28
|
+
const STRINGS: Record<string, Record<Lang, string>> = {
|
|
29
|
+
devMode: { ko: '개발자 모드', en: 'Developer Mode' },
|
|
30
|
+
devModeOn: { ko: '개발자 모드 ON', en: 'Developer Mode ON' },
|
|
31
|
+
promptEditWarning: {
|
|
32
|
+
ko: '⚠ 프롬프트를 직접 수정하면 예상치 못한 동작이 발생할 수 있습니다.\n계속하시겠습니까?',
|
|
33
|
+
en: '⚠ Editing prompts directly may cause unexpected behavior.\nContinue?'
|
|
34
|
+
},
|
|
35
|
+
savedAndReloaded: { ko: '저장 + 핫리로드 완료!', en: 'Saved + Hot Reloaded!' },
|
|
36
|
+
promptStructure: { ko: '프롬프트 구조', en: 'Prompt Structure' },
|
|
37
|
+
updateNeeded: { ko: '업데이트 필요', en: 'Update Needed' },
|
|
38
|
+
memoryUpdating: { ko: '메모리 구조를 업데이트하는 중...', en: 'Upgrading memory structure...' },
|
|
39
|
+
memoryUpdateBtn: { ko: '메모리 업데이트하기', en: 'Update Memory' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function detectLang(): Lang {
|
|
43
|
+
const pref = getPreferredLocale();
|
|
44
|
+
return pref.startsWith('ko') ? 'ko' : 'en';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let _lang: Lang = detectLang();
|
|
48
|
+
|
|
49
|
+
export function setLang(lang: Lang) { _lang = lang; syncStoredLocale(lang); }
|
|
50
|
+
export function getLang(): Lang { return _lang; }
|
|
51
|
+
export function t(key: string): string {
|
|
52
|
+
return STRINGS[key]?.[_lang] ?? STRINGS[key]?.['en'] ?? key;
|
|
53
|
+
}
|
package/public/js/render.ts
CHANGED
|
@@ -117,6 +117,7 @@ function applyMermaidTheme() {
|
|
|
117
117
|
mermaidModule!.default.initialize({
|
|
118
118
|
startOnLoad: false,
|
|
119
119
|
theme: 'base',
|
|
120
|
+
htmlLabels: false,
|
|
120
121
|
themeVariables: getMermaidThemeVars(),
|
|
121
122
|
securityLevel: 'strict',
|
|
122
123
|
suppressErrorRendering: true,
|
|
@@ -333,7 +334,7 @@ export async function rerenderMermaidDiagrams(): Promise<void> {
|
|
|
333
334
|
try {
|
|
334
335
|
applyMermaidTheme();
|
|
335
336
|
const { svg } = await mm.render(id, code);
|
|
336
|
-
el.innerHTML = svg;
|
|
337
|
+
el.innerHTML = sanitizeMermaidSvg(svg);
|
|
337
338
|
appendMermaidActionBtns(el as HTMLElement);
|
|
338
339
|
} catch { /* keep existing render on failure */ }
|
|
339
340
|
}
|
package/public/js/ui.ts
CHANGED
|
@@ -403,21 +403,7 @@ export async function loadMessages(): Promise<void> {
|
|
|
403
403
|
if (chatEl) chatEl.innerHTML = '';
|
|
404
404
|
|
|
405
405
|
if (msgs.length >= VS_THRESHOLD) {
|
|
406
|
-
//
|
|
407
|
-
for (const m of msgs) {
|
|
408
|
-
const role = m.role === 'assistant' ? 'agent' : m.role;
|
|
409
|
-
const rawContent = stripOrchestration(m.content);
|
|
410
|
-
const label = escapeHtml(role === 'user' ? t('msg.you') : getAppName());
|
|
411
|
-
const tools = m.role === 'assistant' ? parseToolLog(m.tool_log) : [];
|
|
412
|
-
const toolHtml = tools.length > 0 ? buildProcessBlockHtml(toProcessSteps(tools), true) : '';
|
|
413
|
-
const skeletonContent = '<div class="skeleton-line"></div><div class="skeleton-line"></div>';
|
|
414
|
-
const html = role === 'agent'
|
|
415
|
-
? `<div class="msg msg-agent"><div class="agent-icon" aria-hidden="true">${getAgentIcon(m.cli)}</div><div class="agent-body">${toolHtml}<div class="msg-content lazy-pending" data-raw="${escapeHtml(rawContent)}">${skeletonContent}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div></div>`
|
|
416
|
-
: `<div class="msg msg-${role}"><div class="user-body"><div class="msg-label">${label}</div><div class="msg-content lazy-pending" data-raw="${escapeHtml(rawContent)}">${skeletonContent}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div><div class="user-icon" aria-hidden="true">${getUserAvatarMarkup()}</div></div>`;
|
|
417
|
-
vs.addItem(generateId(), html);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Register lazy render callback
|
|
406
|
+
// RC5 fix: register callbacks BEFORE feeding items so activate() has them
|
|
421
407
|
vs.onLazyRender = (targets: HTMLElement[]) => {
|
|
422
408
|
for (const el of targets) {
|
|
423
409
|
if (!el.classList.contains('lazy-pending')) continue;
|
|
@@ -441,6 +427,22 @@ export async function loadMessages(): Promise<void> {
|
|
|
441
427
|
linkifyFilePaths(viewport);
|
|
442
428
|
};
|
|
443
429
|
|
|
430
|
+
// Bulk-load all items at once — avoids mid-loop activate (RC5 fix)
|
|
431
|
+
const vsItems: import('./virtual-scroll.js').VirtualItem[] = [];
|
|
432
|
+
for (const m of msgs) {
|
|
433
|
+
const role = m.role === 'assistant' ? 'agent' : m.role;
|
|
434
|
+
const rawContent = stripOrchestration(m.content);
|
|
435
|
+
const label = escapeHtml(role === 'user' ? t('msg.you') : getAppName());
|
|
436
|
+
const tools = m.role === 'assistant' ? parseToolLog(m.tool_log) : [];
|
|
437
|
+
const toolHtml = tools.length > 0 ? buildProcessBlockHtml(toProcessSteps(tools), true) : '';
|
|
438
|
+
const skeletonContent = '<div class="skeleton-line"></div><div class="skeleton-line"></div>';
|
|
439
|
+
const html = role === 'agent'
|
|
440
|
+
? `<div class="msg msg-agent"><div class="agent-icon" aria-hidden="true">${getAgentIcon(m.cli)}</div><div class="agent-body">${toolHtml}<div class="msg-content lazy-pending" data-raw="${escapeHtml(rawContent)}">${skeletonContent}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div></div>`
|
|
441
|
+
: `<div class="msg msg-${role}"><div class="user-body"><div class="msg-label">${label}</div><div class="msg-content lazy-pending" data-raw="${escapeHtml(rawContent)}">${skeletonContent}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div><div class="user-icon" aria-hidden="true">${getUserAvatarMarkup()}</div></div>`;
|
|
442
|
+
vsItems.push({ id: generateId(), html, height: 80 });
|
|
443
|
+
}
|
|
444
|
+
vs.setItems(vsItems);
|
|
445
|
+
|
|
444
446
|
vs.scrollToBottom();
|
|
445
447
|
} else {
|
|
446
448
|
msgs.forEach(m => {
|
|
@@ -24,8 +24,8 @@ export class VirtualScroll {
|
|
|
24
24
|
private _active = false;
|
|
25
25
|
private _totalHeight = 0;
|
|
26
26
|
private rafId: number | null = null;
|
|
27
|
-
private firstVisible =
|
|
28
|
-
private lastVisible =
|
|
27
|
+
private firstVisible = -1;
|
|
28
|
+
private lastVisible = -1;
|
|
29
29
|
|
|
30
30
|
/** Called after render() mounts items in viewport — for lazy rendering and widget activation */
|
|
31
31
|
onLazyRender: LazyRenderCallback | null = null;
|
|
@@ -53,18 +53,28 @@ export class VirtualScroll {
|
|
|
53
53
|
if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
|
|
54
54
|
this.container.innerHTML = this.items.map(it => it.html).join('');
|
|
55
55
|
this._active = false;
|
|
56
|
-
this.firstVisible =
|
|
57
|
-
this.lastVisible =
|
|
56
|
+
this.firstVisible = -1;
|
|
57
|
+
this.lastVisible = -1;
|
|
58
58
|
this.items = [];
|
|
59
59
|
this._totalHeight = 0;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** Bulk-load items without triggering activate mid-loop (RC5 fix).
|
|
63
|
+
* Call this AFTER registering onLazyRender/onPostRender. */
|
|
64
|
+
setItems(items: VirtualItem[]): void {
|
|
65
|
+
this.items = items;
|
|
66
|
+
this._totalHeight = items.reduce((sum, it) => sum + it.height, 0);
|
|
67
|
+
if (!this._active && this.items.length >= THRESHOLD) {
|
|
68
|
+
this.activate(true);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
addItem(id: string, html: string): void {
|
|
63
73
|
const item: VirtualItem = { id, html, height: EST_HEIGHT };
|
|
64
74
|
this.items.push(item);
|
|
65
75
|
this._totalHeight += EST_HEIGHT;
|
|
66
76
|
if (!this._active && this.items.length >= THRESHOLD) {
|
|
67
|
-
this.activate();
|
|
77
|
+
this.activate(true);
|
|
68
78
|
}
|
|
69
79
|
if (this._active) {
|
|
70
80
|
this.scheduleRender();
|
|
@@ -80,8 +90,7 @@ export class VirtualScroll {
|
|
|
80
90
|
const item: VirtualItem = { id, html, height: EST_HEIGHT };
|
|
81
91
|
this.items.push(item);
|
|
82
92
|
this._totalHeight += EST_HEIGHT;
|
|
83
|
-
//
|
|
84
|
-
this.render();
|
|
93
|
+
// RC7 fix: use synchronous scrollToBottom (which handles spacers + render)
|
|
85
94
|
this.scrollToBottom();
|
|
86
95
|
}
|
|
87
96
|
|
|
@@ -94,7 +103,8 @@ export class VirtualScroll {
|
|
|
94
103
|
|
|
95
104
|
private scrollHandler = () => this.scheduleRender();
|
|
96
105
|
|
|
97
|
-
|
|
106
|
+
/** RC6 fix: activate always scrolls to bottom (new messages are at the end) */
|
|
107
|
+
private activate(toBottom = false): void {
|
|
98
108
|
this._active = true;
|
|
99
109
|
this._totalHeight = 0;
|
|
100
110
|
const existing = this.container.querySelectorAll('.msg');
|
|
@@ -103,14 +113,20 @@ export class VirtualScroll {
|
|
|
103
113
|
this.items[i].height = el.getBoundingClientRect().height;
|
|
104
114
|
}
|
|
105
115
|
});
|
|
106
|
-
// Rebuild _totalHeight from items array (covers both DOM-measured and estimated heights)
|
|
107
116
|
for (const item of this.items) {
|
|
108
117
|
this._totalHeight += item.height;
|
|
109
118
|
}
|
|
110
|
-
// Atomic swap — avoids visible blank frame during activation
|
|
111
119
|
this.container.classList.add('vs-active');
|
|
112
120
|
this.container.replaceChildren(this.spacerTop, this.viewport, this.spacerBottom);
|
|
113
121
|
this.container.addEventListener('scroll', this.scrollHandler, { passive: true });
|
|
122
|
+
if (toBottom) {
|
|
123
|
+
// Set spacers for bottom position BEFORE render to avoid top flash
|
|
124
|
+
this.spacerTop.style.height = `${this._totalHeight}px`;
|
|
125
|
+
this.spacerBottom.style.height = '0px';
|
|
126
|
+
this.container.scrollTop = this.container.scrollHeight;
|
|
127
|
+
this.firstVisible = -1;
|
|
128
|
+
this.lastVisible = -1;
|
|
129
|
+
}
|
|
114
130
|
this.render();
|
|
115
131
|
}
|
|
116
132
|
|
|
@@ -127,7 +143,7 @@ export class VirtualScroll {
|
|
|
127
143
|
const viewHeight = this.container.clientHeight;
|
|
128
144
|
|
|
129
145
|
let accum = 0;
|
|
130
|
-
let startIdx = this.items.length - 1;
|
|
146
|
+
let startIdx = this.items.length - 1;
|
|
131
147
|
for (let i = 0; i < this.items.length; i++) {
|
|
132
148
|
if (accum + this.items[i].height > scrollTop) {
|
|
133
149
|
startIdx = i;
|
|
@@ -146,10 +162,7 @@ export class VirtualScroll {
|
|
|
146
162
|
}
|
|
147
163
|
const last = Math.min(this.items.length - 1, endIdx + BUFFER);
|
|
148
164
|
|
|
149
|
-
|
|
150
|
-
this.firstVisible = first;
|
|
151
|
-
this.lastVisible = last;
|
|
152
|
-
|
|
165
|
+
// RC3 fix: always update spacers even if range unchanged
|
|
153
166
|
let topSpace = 0;
|
|
154
167
|
for (let i = 0; i < first; i++) topSpace += this.items[i].height;
|
|
155
168
|
let bottomSpace = 0;
|
|
@@ -158,6 +171,10 @@ export class VirtualScroll {
|
|
|
158
171
|
this.spacerTop.style.height = `${topSpace}px`;
|
|
159
172
|
this.spacerBottom.style.height = `${bottomSpace}px`;
|
|
160
173
|
|
|
174
|
+
if (first === this.firstVisible && last === this.lastVisible) return;
|
|
175
|
+
this.firstVisible = first;
|
|
176
|
+
this.lastVisible = last;
|
|
177
|
+
|
|
161
178
|
// Build map of currently mounted items by vsIdx
|
|
162
179
|
const mounted = new Map<number, HTMLElement>();
|
|
163
180
|
for (const child of Array.from(this.viewport.children) as HTMLElement[]) {
|
|
@@ -221,7 +238,6 @@ export class VirtualScroll {
|
|
|
221
238
|
/** Batch-read heights from visible elements, batch-write to items array.
|
|
222
239
|
* Separated read/write passes = single forced reflow. */
|
|
223
240
|
private remeasureVisible(): void {
|
|
224
|
-
// Capture bottom-proximity BEFORE heights change
|
|
225
241
|
const wasAtBottom = this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 80;
|
|
226
242
|
|
|
227
243
|
const rects: { idx: number; newH: number }[] = [];
|
|
@@ -240,15 +256,22 @@ export class VirtualScroll {
|
|
|
240
256
|
heightChanged = true;
|
|
241
257
|
}
|
|
242
258
|
}
|
|
243
|
-
// Re-snap to bottom if user was there before heights grew
|
|
244
259
|
if (heightChanged && wasAtBottom) {
|
|
245
260
|
this.scrollToBottom();
|
|
246
261
|
}
|
|
247
262
|
}
|
|
248
263
|
|
|
264
|
+
/** RC2 fix: synchronous scroll — cancel pending RAF, update spacers, render directly */
|
|
249
265
|
scrollToBottom(): void {
|
|
266
|
+
if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
|
|
267
|
+
// Update spacers to reflect full content height at bottom position
|
|
268
|
+
this.spacerTop.style.height = `${this._totalHeight}px`;
|
|
269
|
+
this.spacerBottom.style.height = '0px';
|
|
250
270
|
this.container.scrollTop = this.container.scrollHeight;
|
|
251
|
-
|
|
271
|
+
// Reset visible range to force full re-render at new position
|
|
272
|
+
this.firstVisible = -1;
|
|
273
|
+
this.lastVisible = -1;
|
|
274
|
+
this.render();
|
|
252
275
|
}
|
|
253
276
|
|
|
254
277
|
clear(): void {
|
|
@@ -263,8 +286,8 @@ export class VirtualScroll {
|
|
|
263
286
|
this.container.innerHTML = '';
|
|
264
287
|
}
|
|
265
288
|
this._active = false;
|
|
266
|
-
this.firstVisible =
|
|
267
|
-
this.lastVisible =
|
|
289
|
+
this.firstVisible = -1;
|
|
290
|
+
this.lastVisible = -1;
|
|
268
291
|
this.onLazyRender = null;
|
|
269
292
|
this.onPostRender = null;
|
|
270
293
|
if (this.rafId) {
|