cli-jaw 1.6.13 → 1.6.15

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 (56) 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/assets/shark.svg +1 -0
  17. package/public/css/chat.css +20 -3
  18. package/public/css/layout.css +46 -15
  19. package/public/css/variables.css +3 -15
  20. package/public/dist/assets/{employees-C2G0-Rg9.js → employees-V7lNStu1.js} +1 -1
  21. package/public/dist/assets/index-Cpe1jccL.js +50 -0
  22. package/public/dist/assets/index-DVTRbkJF.css +1 -0
  23. package/public/dist/assets/locale-CxI5nTcf.js +3 -0
  24. package/public/dist/assets/render-BoxeLlL9.js +25 -0
  25. package/public/dist/assets/settings-BcKp6ppP.js +1 -0
  26. package/public/dist/assets/settings-CBCg5Jhh.js +40 -0
  27. package/public/dist/assets/skills-BuAXFNgp.js +1 -0
  28. package/public/dist/assets/{skills-C9o5E1Pc.js → skills-RbauGmBZ.js} +1 -1
  29. package/public/dist/assets/{slash-commands-DveLHSQt.js → slash-commands-BgKxc49D.js} +1 -1
  30. package/public/dist/assets/slash-commands-DXGb_iGA.js +1 -0
  31. package/public/dist/assets/ui-KQ8_sSP8.js +131 -0
  32. package/public/dist/assets/ui-rD__Mvbs.js +1 -0
  33. package/public/dist/assets/vendor-icons-C6LXvgi0.js +1 -0
  34. package/public/dist/assets/{ws-D39_cIa_.js → ws-BtTpgocf.js} +1 -1
  35. package/public/dist/index.html +25 -18
  36. package/public/index.html +23 -16
  37. package/public/js/features/avatar.ts +23 -63
  38. package/public/js/features/memory.ts +17 -5
  39. package/public/js/features/settings-templates.ts +6 -5
  40. package/public/js/icons.ts +10 -4
  41. package/public/js/locale.ts +30 -0
  42. package/public/js/render.ts +2 -1
  43. package/public/js/ui.ts +87 -71
  44. package/public/js/virtual-scroll-bootstrap.ts +42 -0
  45. package/public/js/virtual-scroll.ts +165 -51
  46. package/public/dist/assets/index-CDdXQQmm.css +0 -1
  47. package/public/dist/assets/index-CIWCSFl-.js +0 -50
  48. package/public/dist/assets/locale-DVVWjxKN.js +0 -1
  49. package/public/dist/assets/render-BFAkzW1S.js +0 -25
  50. package/public/dist/assets/settings-BtX9STQd.js +0 -41
  51. package/public/dist/assets/settings-DUWhygHi.js +0 -1
  52. package/public/dist/assets/skills-C6aTdbWY.js +0 -1
  53. package/public/dist/assets/slash-commands-C1p8kRBv.js +0 -1
  54. package/public/dist/assets/ui-BpZlLDtM.js +0 -1
  55. package/public/dist/assets/ui-Dx3w-H-4.js +0 -131
  56. package/public/dist/assets/vendor-icons-BqxEYYco.js +0 -1
@@ -1,5 +1,6 @@
1
1
  import { escapeHtml } from '../render.js';
2
2
  import { api, getAuthToken } from '../api.js';
3
+ import { ICONS } from '../icons.js';
3
4
 
4
5
  type AvatarRole = 'agent' | 'user';
5
6
  type AvatarServerEntry = {
@@ -16,8 +17,8 @@ type AvatarState = {
16
17
 
17
18
  const AGENT_KEY = 'agentAvatar';
18
19
  const USER_KEY = 'userAvatar';
19
- const DEFAULT_AGENT = '🦈';
20
- const DEFAULT_USER = '👤';
20
+ const DEFAULT_AGENT = ICONS.shark;
21
+ const DEFAULT_USER = ICONS.user;
21
22
 
22
23
  const avatarState: Record<AvatarRole, AvatarState> = {
23
24
  agent: { emoji: DEFAULT_AGENT, imageUrl: '', updatedAt: null },
@@ -30,40 +31,25 @@ function stateFor(role: AvatarRole): AvatarState {
30
31
  return avatarState[role];
31
32
  }
32
33
 
33
- function defaultEmoji(role: AvatarRole): string {
34
- return role === 'agent' ? DEFAULT_AGENT : DEFAULT_USER;
35
- }
36
-
37
34
  function storageKey(role: AvatarRole): string {
38
35
  return role === 'agent' ? AGENT_KEY : USER_KEY;
39
36
  }
40
37
 
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';
38
+ function inputId(_role: AvatarRole): string {
39
+ return _role === 'agent' ? 'agentAvatarPreview' : 'userAvatarPreview';
47
40
  }
48
41
 
49
42
  function iconSelector(role: AvatarRole): string {
50
43
  return role === 'agent' ? '.agent-icon' : '.user-icon';
51
44
  }
52
45
 
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;
46
+ function syncPreview(role: AvatarRole): void {
47
+ const preview = document.getElementById(inputId(role));
48
+ if (preview) {
49
+ preview.innerHTML = avatarMarkup(role);
50
+ const kind = stateFor(role).imageUrl ? 'image' : 'emoji';
51
+ preview.setAttribute('data-avatar-kind', kind);
52
+ }
67
53
  }
68
54
 
69
55
  function avatarMarkup(role: AvatarRole): string {
@@ -71,7 +57,8 @@ function avatarMarkup(role: AvatarRole): string {
71
57
  if (current.imageUrl) {
72
58
  return `<img class="avatar-image" src="${escapeHtml(current.imageUrl)}" alt="" loading="lazy" decoding="async">`;
73
59
  }
74
- return escapeHtml(current.emoji);
60
+ // Default icons are Lucide SVG strings — render as-is
61
+ return current.emoji;
75
62
  }
76
63
 
77
64
  function applyAvatar(role: AvatarRole): void {
@@ -91,7 +78,7 @@ function setServerAvatar(role: AvatarRole, payload?: AvatarServerEntry | null):
91
78
  stateFor(role).imageUrl = '';
92
79
  stateFor(role).updatedAt = payload?.updatedAt ?? null;
93
80
  }
94
- syncInputs(role);
81
+ syncPreview(role);
95
82
  applyAvatar(role);
96
83
  }
97
84
 
@@ -110,28 +97,20 @@ async function authorizedFetch(path: string, init: RequestInit): Promise<Respons
110
97
  }
111
98
 
112
99
  async function uploadAvatar(role: AvatarRole, file: File): Promise<void> {
113
- setStatus(role, 'uploading...');
114
100
  const res = await authorizedFetch(`/api/avatar/${role}/upload`, {
115
101
  method: 'POST',
116
102
  headers: { 'X-Filename': encodeURIComponent(file.name) },
117
103
  body: file,
118
104
  });
119
105
  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
- }
106
+ if (!res.ok) throw new Error(json?.error || `avatar upload failed (${res.status})`);
124
107
  setServerAvatar(role, json?.data || json);
125
108
  }
126
109
 
127
110
  async function resetAvatarImage(role: AvatarRole): Promise<void> {
128
- setStatus(role, 'resetting...');
129
111
  const res = await authorizedFetch(`/api/avatar/${role}/image`, { method: 'DELETE' });
130
112
  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
- }
113
+ if (!res.ok) throw new Error(json?.error || `avatar reset failed (${res.status})`);
135
114
  setServerAvatar(role, json?.data || json);
136
115
  }
137
116
 
@@ -166,8 +145,8 @@ function bindRoleControls(role: AvatarRole): void {
166
145
  });
167
146
  }
168
147
 
169
- export function getAgentAvatar(): string { return getEmoji('agent'); }
170
- export function getUserAvatar(): string { return getEmoji('user'); }
148
+ export function getAgentAvatar(): string { return stateFor('agent').emoji; }
149
+ export function getUserAvatar(): string { return stateFor('user').emoji; }
171
150
  export function getAgentAvatarMarkup(): string { return avatarMarkup('agent'); }
172
151
  export function getUserAvatarMarkup(): string { return avatarMarkup('user'); }
173
152
 
@@ -175,7 +154,7 @@ export function setAgentAvatar(emoji: string): void {
175
154
  const next = (emoji || '').trim() || DEFAULT_AGENT;
176
155
  stateFor('agent').emoji = next;
177
156
  localStorage.setItem(storageKey('agent'), next);
178
- syncInputs('agent');
157
+ syncPreview('agent');
179
158
  if (!stateFor('agent').imageUrl) applyAvatar('agent');
180
159
  }
181
160
 
@@ -183,37 +162,18 @@ export function setUserAvatar(emoji: string): void {
183
162
  const next = (emoji || '').trim() || DEFAULT_USER;
184
163
  stateFor('user').emoji = next;
185
164
  localStorage.setItem(storageKey('user'), next);
186
- syncInputs('user');
165
+ syncPreview('user');
187
166
  if (!stateFor('user').imageUrl) applyAvatar('user');
188
167
  }
189
168
 
190
169
  export async function initAvatar(): Promise<void> {
191
170
  stateFor('agent').emoji = localStorage.getItem(AGENT_KEY) || DEFAULT_AGENT;
192
171
  stateFor('user').emoji = localStorage.getItem(USER_KEY) || DEFAULT_USER;
193
- syncInputs('agent');
194
- syncInputs('user');
172
+ syncPreview('agent');
173
+ syncPreview('user');
195
174
 
196
175
  if (!initialized) {
197
176
  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
177
  bindRoleControls('agent');
218
178
  bindRoleControls('user');
219
179
  }
@@ -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'); }
@@ -29,6 +29,7 @@ import {
29
29
  Package,
30
30
  ClipboardList,
31
31
  Bot,
32
+ CircleUserRound,
32
33
  Palette,
33
34
  Link,
34
35
  HandMetal,
@@ -54,6 +55,9 @@ import {
54
55
  Download,
55
56
  } from '@lucide/icons';
56
57
 
58
+ // ── Inline SVG assets (embedded to avoid ?raw import issues in Node.js tests) ──
59
+ const sharkSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12c0 0 2-4 6-4 1 0 2 .5 3 1l3-6c0 0 1 5 3 7 1.5 1.5 5 2 5 2s-1 4-5 4c-1 0-2-.3-3-.8L12 18c0 0-2-1-4-1-4 0-6-5-6-5z"/><circle cx="17" cy="11" r="0.5" fill="currentColor" stroke="none"/><path d="M7 12l1 2"/><path d="M9.5 12l1 2"/></svg>';
60
+
57
61
  // ── Size presets ──
58
62
  const S = 14; // inline / small
59
63
  const M = 16; // default UI
@@ -62,8 +66,7 @@ function luc(data: Parameters<typeof buildLucideSvg>[0], size = M): string {
62
66
  return buildLucideSvg(data, { size });
63
67
  }
64
68
 
65
- // ── Shark mascot (🦈 emoji — brand identity) ──
66
- const SHARK_SVG = '🦈';
69
+ // ── Default avatar icons (Lucide-based, no emoji literals) ──
67
70
 
68
71
  // ── Icon registry ──
69
72
  // Keys match the semantic role, NOT the old emoji codepoint.
@@ -101,8 +104,9 @@ export const ICONS = {
101
104
  link: luc(Link),
102
105
  salute: luc(HandMetal),
103
106
 
104
- // Mascot
105
- shark: SHARK_SVG,
107
+ // Avatar defaults
108
+ shark: sharkSvg,
109
+ user: luc(CircleUserRound),
106
110
 
107
111
  // HTML template icons
108
112
  paperclip: luc(Paperclip),
@@ -165,6 +169,7 @@ const iconMap: Partial<Record<IconName, (s: number) => string>> = {
165
169
  palette: (s) => luc(Palette, s),
166
170
  link: (s) => luc(Link, s),
167
171
  salute: (s) => luc(HandMetal, s),
172
+ user: (s) => luc(CircleUserRound, s),
168
173
  paperclip: (s) => luc(Paperclip, s),
169
174
  save: (s) => luc(Save, s),
170
175
  gamepad: (s) => luc(Gamepad2, s),
@@ -197,6 +202,7 @@ const EMOJI_TO_ICON: Record<string, IconName> = {
197
202
  '⚠️': 'warning',
198
203
  '💡': 'lightbulb',
199
204
  '🦈': 'shark',
205
+ '👤': 'user',
200
206
  '💭': 'thinking',
201
207
  '🔍': 'search',
202
208
  '🌐': 'web',
@@ -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
@@ -7,7 +7,8 @@ import { getAgentAvatarMarkup, getUserAvatarMarkup } from './features/avatar.js'
7
7
  import { t } from './features/i18n.js';
8
8
  import { api } from './api.js';
9
9
  import { cacheMessages, getCachedMessages, appendCachedMessage, upsertMessage, setMessageScope, getScopedMessages } from './features/idb-cache.js';
10
- import { getVirtualScroll, VS_THRESHOLD } from './virtual-scroll.js';
10
+ import { getVirtualScroll, VS_THRESHOLD, type VirtualItem } from './virtual-scroll.js';
11
+ import { bootstrapVirtualHistory, BOOTSTRAP_SEED_COUNT, type VirtualHistoryBootstrapDeps } from './virtual-scroll-bootstrap.js';
11
12
  import { createStreamRenderer, appendChunk, finalizeStream, type StreamState } from './streaming-render.js';
12
13
  import { activateWidgets } from './diagram/iframe-renderer.js';
13
14
  import { renderLiveToolActivity, cleanupToolElements, bindToolItemInteractions, type ToolLogEntry } from './features/tool-ui.js';
@@ -385,6 +386,87 @@ export async function loadStats(): Promise<void> {
385
386
  updateStatMsgs(msgs.length);
386
387
  }
387
388
 
389
+ // ── Virtual scroll bootstrap helpers ──
390
+
391
+ function buildVirtualHistoryItems(msgs: MessageItem[]): VirtualItem[] {
392
+ const vsItems: VirtualItem[] = [];
393
+ for (const m of msgs) {
394
+ const role = m.role === 'assistant' ? 'agent' : m.role;
395
+ const rawContent = stripOrchestration(m.content);
396
+ const label = escapeHtml(role === 'user' ? t('msg.you') : getAppName());
397
+ const tools = m.role === 'assistant' ? parseToolLog(m.tool_log) : [];
398
+ const toolHtml = tools.length > 0 ? buildProcessBlockHtml(toProcessSteps(tools), true) : '';
399
+ const skeletonContent = '<div class="skeleton-line"></div><div class="skeleton-line"></div>';
400
+ const html = role === 'agent'
401
+ ? `<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>`
402
+ : `<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>`;
403
+ vsItems.push({ id: generateId(), html, height: 80 });
404
+ }
405
+ return vsItems;
406
+ }
407
+
408
+ function registerVirtualScrollCallbacks(vs: ReturnType<typeof getVirtualScroll>): void {
409
+ vs.onLazyRender = (targets: HTMLElement[]) => {
410
+ for (const el of targets) {
411
+ if (!el.classList.contains('lazy-pending')) continue;
412
+ const raw = el.getAttribute('data-raw') || '';
413
+ el.innerHTML = raw ? renderMarkdown(raw) : '';
414
+ el.classList.remove('lazy-pending');
415
+ activateWidgets(el);
416
+ const msgEl = el.closest('[data-vs-idx]') as HTMLElement | null;
417
+ if (msgEl) {
418
+ const idx = Number(msgEl.dataset.vsIdx);
419
+ vs.updateItemHtml(idx, msgEl.outerHTML);
420
+ }
421
+ }
422
+ };
423
+ vs.onPostRender = (viewport: HTMLElement) => {
424
+ activateWidgets(viewport);
425
+ linkifyFilePaths(viewport);
426
+ };
427
+ }
428
+
429
+ function measureTailWindow(
430
+ chatEl: HTMLElement,
431
+ items: VirtualItem[],
432
+ seedCount: number,
433
+ ): number[] {
434
+ const start = Math.max(0, items.length - seedCount);
435
+ const slice = items.slice(start);
436
+ if (slice.length === 0) return [];
437
+
438
+ // Render tail items temporarily into empty chatEl, measure, then clear
439
+ const fragment = document.createDocumentFragment();
440
+ for (const item of slice) {
441
+ const wrapper = document.createElement('div');
442
+ wrapper.innerHTML = item.html;
443
+ const el = wrapper.firstElementChild;
444
+ if (el) fragment.appendChild(el);
445
+ }
446
+ chatEl.appendChild(fragment);
447
+ const heights: number[] = [];
448
+ const children = chatEl.children;
449
+ for (let i = 0; i < children.length; i++) {
450
+ heights.push(children[i].getBoundingClientRect().height);
451
+ }
452
+ chatEl.innerHTML = '';
453
+ return heights;
454
+ }
455
+
456
+ function makeBootstrapDeps(
457
+ vs: ReturnType<typeof getVirtualScroll>,
458
+ chatEl: HTMLElement,
459
+ ): VirtualHistoryBootstrapDeps {
460
+ return {
461
+ registerCallbacks: () => registerVirtualScrollCallbacks(vs),
462
+ measureTailWindow: (items, seedCount) => measureTailWindow(chatEl, items, seedCount),
463
+ setItems: (items, opts) => vs.setItems(items, opts),
464
+ seedMeasuredHeights: (start, h) => vs.seedMeasuredHeights(start, h),
465
+ activateIfNeeded: (toBottom) => vs.activateIfNeeded(toBottom),
466
+ scrollToBottom: () => vs.scrollToBottom(),
467
+ };
468
+ }
469
+
388
470
  export async function loadMessages(): Promise<void> {
389
471
  const vs = getVirtualScroll();
390
472
  const chatEl = document.getElementById('chatMessages');
@@ -403,45 +485,8 @@ export async function loadMessages(): Promise<void> {
403
485
  if (chatEl) chatEl.innerHTML = '';
404
486
 
405
487
  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
421
- vs.onLazyRender = (targets: HTMLElement[]) => {
422
- for (const el of targets) {
423
- if (!el.classList.contains('lazy-pending')) continue;
424
- const raw = el.getAttribute('data-raw') || '';
425
- el.innerHTML = raw ? renderMarkdown(raw) : '';
426
- el.classList.remove('lazy-pending');
427
- activateWidgets(el);
428
-
429
- // Persist rendered HTML back into VS cache
430
- const msgEl = el.closest('[data-vs-idx]') as HTMLElement | null;
431
- if (msgEl) {
432
- const idx = Number(msgEl.dataset.vsIdx);
433
- vs.updateItemHtml(idx, msgEl.outerHTML);
434
- }
435
- }
436
- };
437
-
438
- // Activate widgets + file path linkification on all VS-rendered items
439
- vs.onPostRender = (viewport: HTMLElement) => {
440
- activateWidgets(viewport);
441
- linkifyFilePaths(viewport);
442
- };
443
-
444
- vs.scrollToBottom();
488
+ const vsItems = buildVirtualHistoryItems(msgs);
489
+ bootstrapVirtualHistory(vsItems, makeBootstrapDeps(vs, chatEl!));
445
490
  } else {
446
491
  msgs.forEach(m => {
447
492
  const div = addMessage(m.role === 'assistant' ? 'agent' : m.role, m.content, m.cli);
@@ -476,37 +521,8 @@ export async function loadMessages(): Promise<void> {
476
521
  const cached = await getScopedMessages();
477
522
  if (cached.length > 0) {
478
523
  if (cached.length >= VS_THRESHOLD) {
479
- for (const m of cached) {
480
- const role = m.role === 'assistant' ? 'agent' : m.role;
481
- const rawContent = stripOrchestration(m.content);
482
- const label = escapeHtml(role === 'user' ? t('msg.you') : getAppName());
483
- const tools = m.role === 'assistant' && m.tool_log ? parseToolLog(m.tool_log) : [];
484
- const toolHtml = tools.length > 0 ? buildProcessBlockHtml(toProcessSteps(tools), true) : '';
485
- const skeletonContent = '<div class="skeleton-line"></div><div class="skeleton-line"></div>';
486
- const html = role === 'agent'
487
- ? `<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>`
488
- : `<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>`;
489
- vs.addItem(generateId(), html);
490
- }
491
- vs.onLazyRender = (targets: HTMLElement[]) => {
492
- for (const el of targets) {
493
- if (!el.classList.contains('lazy-pending')) continue;
494
- const raw = el.getAttribute('data-raw') || '';
495
- el.innerHTML = raw ? renderMarkdown(raw) : '';
496
- el.classList.remove('lazy-pending');
497
- activateWidgets(el);
498
- const msgEl = el.closest('[data-vs-idx]') as HTMLElement | null;
499
- if (msgEl) {
500
- const idx = Number(msgEl.dataset.vsIdx);
501
- vs.updateItemHtml(idx, msgEl.outerHTML);
502
- }
503
- }
504
- };
505
- vs.onPostRender = (viewport: HTMLElement) => {
506
- activateWidgets(viewport);
507
- linkifyFilePaths(viewport);
508
- };
509
- vs.scrollToBottom();
524
+ const vsItems = buildVirtualHistoryItems(cached as MessageItem[]);
525
+ bootstrapVirtualHistory(vsItems, makeBootstrapDeps(vs, chatEl!));
510
526
  } else {
511
527
  cached.forEach(m => {
512
528
  const div = addMessage(m.role === 'assistant' ? 'agent' : m.role, m.content, m.cli);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure-logic bootstrap orchestrator for virtual scroll.
3
+ * No DOM imports — safe to import in Node test environment.
4
+ */
5
+ import type { VirtualItem, LazyRenderCallback } from './virtual-scroll.js';
6
+
7
+ export const BOOTSTRAP_SEED_COUNT = 20;
8
+
9
+ export interface VirtualHistoryBootstrapDeps {
10
+ registerCallbacks: () => void;
11
+ measureTailWindow: (items: VirtualItem[], seedCount: number) => number[];
12
+ setItems: (items: VirtualItem[], options?: { autoActivate?: boolean; toBottom?: boolean }) => void;
13
+ seedMeasuredHeights: (startIndex: number, heights: number[]) => void;
14
+ activateIfNeeded: (toBottom: boolean) => void;
15
+ scrollToBottom: () => void;
16
+ }
17
+
18
+ /**
19
+ * Orchestrates virtual scroll bootstrap in correct order:
20
+ * 1. registerCallbacks (onLazyRender, onPostRender)
21
+ * 2. setItems with autoActivate:false (load all items without triggering activate)
22
+ * 3. measureTailWindow (measure last N items for accurate initial heights)
23
+ * 4. seedMeasuredHeights (feed measured heights back)
24
+ * 5. activateIfNeeded (switch to VS mode with accurate bottom heights)
25
+ * 6. scrollToBottom
26
+ */
27
+ export function bootstrapVirtualHistory(
28
+ items: VirtualItem[],
29
+ deps: VirtualHistoryBootstrapDeps,
30
+ ): void {
31
+ deps.registerCallbacks();
32
+ deps.setItems(items, { autoActivate: false });
33
+
34
+ const seedStart = Math.max(0, items.length - BOOTSTRAP_SEED_COUNT);
35
+ const heights = deps.measureTailWindow(items, BOOTSTRAP_SEED_COUNT);
36
+ if (heights.length > 0) {
37
+ deps.seedMeasuredHeights(seedStart, heights);
38
+ }
39
+
40
+ deps.activateIfNeeded(true);
41
+ deps.scrollToBottom();
42
+ }