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.
Files changed (51) hide show
  1. package/dist/bin/commands/memory.js +11 -0
  2. package/dist/bin/commands/memory.js.map +1 -1
  3. package/dist/server.js +2 -1
  4. package/dist/server.js.map +1 -1
  5. package/dist/src/agent/memory-flush-controller.js +50 -13
  6. package/dist/src/agent/memory-flush-controller.js.map +1 -1
  7. package/dist/src/memory/identity.js +6 -3
  8. package/dist/src/memory/identity.js.map +1 -1
  9. package/dist/src/memory/reflect.js +47 -0
  10. package/dist/src/memory/reflect.js.map +1 -1
  11. package/dist/src/routes/jaw-memory.js +25 -1
  12. package/dist/src/routes/jaw-memory.js.map +1 -1
  13. package/dist/src/routes/memory.js +4 -1
  14. package/dist/src/routes/memory.js.map +1 -1
  15. package/package.json +1 -1
  16. package/public/css/chat.css +20 -3
  17. package/public/css/layout.css +46 -15
  18. package/public/css/variables.css +3 -15
  19. package/public/dist/assets/{employees-C2G0-Rg9.js → employees-zxrU6ZV_.js} +1 -1
  20. package/public/dist/assets/index-D61icK-D.js +50 -0
  21. package/public/dist/assets/index-DVTRbkJF.css +1 -0
  22. package/public/dist/assets/locale-CxI5nTcf.js +3 -0
  23. package/public/dist/assets/render-CVr6a-dp.js +25 -0
  24. package/public/dist/assets/settings-BHIV4l1s.js +1 -0
  25. package/public/dist/assets/settings-Dl3RnWsB.js +40 -0
  26. package/public/dist/assets/{skills-C9o5E1Pc.js → skills-DhiCSGws.js} +1 -1
  27. package/public/dist/assets/skills-JuDja1UC.js +1 -0
  28. package/public/dist/assets/{slash-commands-DveLHSQt.js → slash-commands-B1k1vFJG.js} +1 -1
  29. package/public/dist/assets/slash-commands-DyLS0abr.js +1 -0
  30. package/public/dist/assets/ui-BXZhbE_1.js +131 -0
  31. package/public/dist/assets/ui-qR28iS0L.js +1 -0
  32. package/public/dist/assets/{ws-D39_cIa_.js → ws-CleMWrLF.js} +1 -1
  33. package/public/dist/index.html +25 -18
  34. package/public/index.html +23 -16
  35. package/public/js/features/avatar.ts +18 -60
  36. package/public/js/features/memory.ts +17 -5
  37. package/public/js/features/settings-templates.ts +6 -5
  38. package/public/js/locale.ts +30 -0
  39. package/public/js/render.ts +2 -1
  40. package/public/js/ui.ts +17 -15
  41. package/public/js/virtual-scroll.ts +43 -20
  42. package/public/dist/assets/index-CDdXQQmm.css +0 -1
  43. package/public/dist/assets/index-CIWCSFl-.js +0 -50
  44. package/public/dist/assets/locale-DVVWjxKN.js +0 -1
  45. package/public/dist/assets/render-BFAkzW1S.js +0 -25
  46. package/public/dist/assets/settings-BtX9STQd.js +0 -41
  47. package/public/dist/assets/settings-DUWhygHi.js +0 -1
  48. package/public/dist/assets/skills-C6aTdbWY.js +0 -1
  49. package/public/dist/assets/slash-commands-C1p8kRBv.js +0 -1
  50. package/public/dist/assets/ui-BpZlLDtM.js +0 -1
  51. 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(role: AvatarRole): string {
42
- return role === 'agent' ? 'agentAvatarInput' : 'userAvatarInput';
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 getEmoji(role: AvatarRole): string {
54
- return stateFor(role).emoji;
55
- }
56
-
57
- function syncInputs(role: AvatarRole): void {
58
- const input = document.getElementById(inputId(role)) as HTMLInputElement | null;
59
- if (input) input.value = getEmoji(role);
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
- syncInputs(role);
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 getEmoji('agent'); }
170
- export function getUserAvatar(): string { return getEmoji('user'); }
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
- syncInputs('agent');
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
- syncInputs('user');
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
- syncInputs('agent');
194
- syncInputs('user');
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)">업데이트 필요</span>`;
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">메모리 업데이트하기</button>`;
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('메모리 구조를 업데이트하는 중...', true);
328
- await apiJson<{ message?: string }>('/api/memory/reindex', 'POST', {});
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
- setAdvBanner('✓ Memory structure upgraded. Soul identity created.');
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('⚠ 프롬프트를 직접 수정하면 예상치 못한 동작이 발생할 수 있습니다.\n계속하시겠습니까?')) return;
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} 개발자 모드 ON` : `${ICONS.tool} 개발자 모드`;
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} 저장 + 핫리로드 완료!`; setTimeout(() => { label.innerHTML = `${ICONS.file} ${escapeHtml(id)}.md`; }, 2000); }
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'); }
@@ -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
+ }
@@ -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
- // Lazy render store skeleton HTML, render on viewport entry
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 = 0;
28
- private lastVisible = 0;
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 = 0;
57
- this.lastVisible = 0;
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
- // Render immediately then scroll again after height is remeasured
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
- private activate(): void {
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; // fallback to bottom, not top
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
- if (first === this.firstVisible && last === this.lastVisible) return;
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
- this.scheduleRender();
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 = 0;
267
- this.lastVisible = 0;
289
+ this.firstVisible = -1;
290
+ this.lastVisible = -1;
268
291
  this.onLazyRender = null;
269
292
  this.onPostRender = null;
270
293
  if (this.rafId) {