cli-jaw 1.6.12 → 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 (84) hide show
  1. package/dist/bin/commands/dispatch.js +41 -14
  2. package/dist/bin/commands/dispatch.js.map +1 -1
  3. package/dist/bin/commands/launchd.js +3 -2
  4. package/dist/bin/commands/launchd.js.map +1 -1
  5. package/dist/bin/commands/memory.js +11 -0
  6. package/dist/bin/commands/memory.js.map +1 -1
  7. package/dist/bin/commands/service.js +4 -3
  8. package/dist/bin/commands/service.js.map +1 -1
  9. package/dist/server.js +34 -6
  10. package/dist/server.js.map +1 -1
  11. package/dist/src/agent/lifecycle-handler.js +10 -4
  12. package/dist/src/agent/lifecycle-handler.js.map +1 -1
  13. package/dist/src/agent/memory-flush-controller.js +50 -13
  14. package/dist/src/agent/memory-flush-controller.js.map +1 -1
  15. package/dist/src/agent/spawn.js +48 -13
  16. package/dist/src/agent/spawn.js.map +1 -1
  17. package/dist/src/cli/handlers-runtime.js +4 -1
  18. package/dist/src/cli/handlers-runtime.js.map +1 -1
  19. package/dist/src/cli/handlers.js +4 -4
  20. package/dist/src/cli/handlers.js.map +1 -1
  21. package/dist/src/core/config.js +40 -3
  22. package/dist/src/core/config.js.map +1 -1
  23. package/dist/src/core/db.js +12 -0
  24. package/dist/src/core/db.js.map +1 -1
  25. package/dist/src/core/instance.js +18 -4
  26. package/dist/src/core/instance.js.map +1 -1
  27. package/dist/src/core/runtime-path.js +69 -0
  28. package/dist/src/core/runtime-path.js.map +1 -0
  29. package/dist/src/memory/identity.js +6 -3
  30. package/dist/src/memory/identity.js.map +1 -1
  31. package/dist/src/memory/reflect.js +47 -0
  32. package/dist/src/memory/reflect.js.map +1 -1
  33. package/dist/src/memory/shared.js +48 -15
  34. package/dist/src/memory/shared.js.map +1 -1
  35. package/dist/src/routes/avatar.js +120 -0
  36. package/dist/src/routes/avatar.js.map +1 -0
  37. package/dist/src/routes/jaw-memory.js +25 -1
  38. package/dist/src/routes/jaw-memory.js.map +1 -1
  39. package/dist/src/routes/memory.js +4 -1
  40. package/dist/src/routes/memory.js.map +1 -1
  41. package/dist/src/telegram/bot.js +7 -1
  42. package/dist/src/telegram/bot.js.map +1 -1
  43. package/package.json +1 -1
  44. package/public/css/chat.css +47 -5
  45. package/public/css/layout.css +57 -0
  46. package/public/css/variables.css +3 -15
  47. package/public/dist/assets/{employees-CA_DI2gy.js → employees-zxrU6ZV_.js} +1 -1
  48. package/public/dist/assets/index-D61icK-D.js +50 -0
  49. package/public/dist/assets/index-DVTRbkJF.css +1 -0
  50. package/public/dist/assets/locale-CxI5nTcf.js +3 -0
  51. package/public/dist/assets/render-CVr6a-dp.js +25 -0
  52. package/public/dist/assets/settings-BHIV4l1s.js +1 -0
  53. package/public/dist/assets/settings-Dl3RnWsB.js +40 -0
  54. package/public/dist/assets/{skills-D3cWRZOl.js → skills-DhiCSGws.js} +1 -1
  55. package/public/dist/assets/skills-JuDja1UC.js +1 -0
  56. package/public/dist/assets/{slash-commands-PkW1NPle.js → slash-commands-B1k1vFJG.js} +1 -1
  57. package/public/dist/assets/slash-commands-DyLS0abr.js +1 -0
  58. package/public/dist/assets/ui-BXZhbE_1.js +131 -0
  59. package/public/dist/assets/ui-qR28iS0L.js +1 -0
  60. package/public/dist/assets/{ws-Dcq99IkD.js → ws-CleMWrLF.js} +1 -1
  61. package/public/dist/index.html +28 -11
  62. package/public/index.html +26 -9
  63. package/public/js/features/avatar.ts +165 -33
  64. package/public/js/features/memory.ts +17 -5
  65. package/public/js/features/settings-templates.ts +6 -5
  66. package/public/js/locale.ts +30 -0
  67. package/public/js/main.ts +1 -1
  68. package/public/js/render.ts +2 -1
  69. package/public/js/ui.ts +30 -22
  70. package/public/js/uuid.ts +24 -0
  71. package/public/js/virtual-scroll.ts +60 -21
  72. package/public/js/ws.ts +6 -2
  73. package/public/locales/en.json +2 -3
  74. package/public/locales/ko.json +2 -3
  75. package/public/dist/assets/index-6UFnW9uO.js +0 -50
  76. package/public/dist/assets/index-ck7lqnh7.css +0 -1
  77. package/public/dist/assets/locale-DVVWjxKN.js +0 -1
  78. package/public/dist/assets/render-C2tuSVTL.js +0 -25
  79. package/public/dist/assets/settings-BJcG1r6G.js +0 -41
  80. package/public/dist/assets/settings-DV5X1U8f.js +0 -1
  81. package/public/dist/assets/skills-idPvxY0n.js +0 -1
  82. package/public/dist/assets/slash-commands-BHtBaFWh.js +0 -1
  83. package/public/dist/assets/ui-BdW-cWnY.js +0 -131
  84. package/public/dist/assets/ui-qcenMIau.js +0 -1
@@ -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
+ }
package/public/js/main.ts CHANGED
@@ -432,7 +432,7 @@ async function bootstrap(): Promise<void> {
432
432
  loadEmployees();
433
433
  initHeartbeatBadge();
434
434
  initAppName();
435
- initAvatar();
435
+ await initAvatar();
436
436
  initSidebar();
437
437
  initMsgCopy();
438
438
  initGestures();
@@ -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
@@ -1,8 +1,9 @@
1
1
  // ── UI Utilities ──
2
2
  import { state } from './state.js';
3
3
  import { renderMarkdown, escapeHtml, stripOrchestration, linkifyFilePaths } from './render.js';
4
+ import { generateId } from './uuid.js';
4
5
  import { getAppName } from './features/appname.js';
5
- import { getAgentAvatar, getUserAvatar } from './features/avatar.js';
6
+ import { getAgentAvatarMarkup, getUserAvatarMarkup } from './features/avatar.js';
6
7
  import { t } from './features/i18n.js';
7
8
  import { api } from './api.js';
8
9
  import { cacheMessages, getCachedMessages, appendCachedMessage, upsertMessage, setMessageScope, getScopedMessages } from './features/idb-cache.js';
@@ -35,12 +36,12 @@ function parseToolLog(toolLog?: string | null): ToolLogEntry[] {
35
36
  }
36
37
 
37
38
  function getAgentIcon(_cli?: string | null): string {
38
- return getAgentAvatar();
39
+ return getAgentAvatarMarkup();
39
40
  }
40
41
 
41
42
  function toProcessSteps(tools: ToolLogEntry[]): ProcessStep[] {
42
43
  return tools.map((tool: any) => ({
43
- id: crypto.randomUUID(),
44
+ id: generateId(),
44
45
  icon: tool.icon ? emojiToIcon(tool.icon) : ICONS.tool,
45
46
  label: tool.label || tool.name || 'tool',
46
47
  type: tool.toolType || 'tool',
@@ -295,7 +296,7 @@ export function addMessage(role: string, text: string, cli?: string | null): HTM
295
296
  div.innerHTML = `<div class="agent-icon" aria-hidden="true">${getAgentIcon(cli)}</div><div class="agent-body"><div class="msg-content">${rendered}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div>`;
296
297
  } else {
297
298
  div.className = `msg msg-${role}`;
298
- div.innerHTML = `<div class="user-body"><div class="msg-label">${label}</div><div class="msg-content">${rendered}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div><div class="user-icon" aria-hidden="true">${getUserAvatar()}</div>`;
299
+ div.innerHTML = `<div class="user-body"><div class="msg-label">${label}</div><div class="msg-content">${rendered}</div><button class="msg-copy" title="Copy" aria-label="Copy message"></button></div><div class="user-icon" aria-hidden="true">${getUserAvatarMarkup()}</div>`;
299
300
  }
300
301
  const contentEl = div.querySelector('.msg-content');
301
302
  if (contentEl) contentEl.setAttribute('data-raw', stripOrchestration(text));
@@ -316,7 +317,7 @@ export function addMessage(role: string, text: string, cli?: string | null): HTM
316
317
  if (msgCount >= VS_THRESHOLD) {
317
318
  // Feed all existing DOM messages into VS items array
318
319
  container.querySelectorAll('.msg').forEach(el => {
319
- vs.addItem(crypto.randomUUID(), el.outerHTML);
320
+ vs.addItem(generateId(), el.outerHTML);
320
321
  });
321
322
  // Wire widget activation + file path linkification for VS-rendered items
322
323
  vs.onPostRender = (viewport: HTMLElement) => {
@@ -333,6 +334,11 @@ export function addMessage(role: string, text: string, cli?: string | null): HTM
333
334
  let scrollRAF: number | null = null;
334
335
 
335
336
  export function scrollToBottom(): void {
337
+ const vs = getVirtualScroll();
338
+ if (vs.active) {
339
+ vs.scrollToBottom();
340
+ return;
341
+ }
336
342
  if (scrollRAF) return;
337
343
  scrollRAF = requestAnimationFrame(() => {
338
344
  scrollRAF = null;
@@ -397,21 +403,7 @@ export async function loadMessages(): Promise<void> {
397
403
  if (chatEl) chatEl.innerHTML = '';
398
404
 
399
405
  if (msgs.length >= VS_THRESHOLD) {
400
- // Lazy render store skeleton HTML, render on viewport entry
401
- for (const m of msgs) {
402
- const role = m.role === 'assistant' ? 'agent' : m.role;
403
- const rawContent = stripOrchestration(m.content);
404
- const label = escapeHtml(role === 'user' ? t('msg.you') : getAppName());
405
- const tools = m.role === 'assistant' ? parseToolLog(m.tool_log) : [];
406
- const toolHtml = tools.length > 0 ? buildProcessBlockHtml(toProcessSteps(tools), true) : '';
407
- const skeletonContent = '<div class="skeleton-line"></div><div class="skeleton-line"></div>';
408
- const html = role === 'agent'
409
- ? `<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>`
410
- : `<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">${getUserAvatar()}</div></div>`;
411
- vs.addItem(crypto.randomUUID(), html);
412
- }
413
-
414
- // Register lazy render callback
406
+ // RC5 fix: register callbacks BEFORE feeding items so activate() has them
415
407
  vs.onLazyRender = (targets: HTMLElement[]) => {
416
408
  for (const el of targets) {
417
409
  if (!el.classList.contains('lazy-pending')) continue;
@@ -435,6 +427,22 @@ export async function loadMessages(): Promise<void> {
435
427
  linkifyFilePaths(viewport);
436
428
  };
437
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
+
438
446
  vs.scrollToBottom();
439
447
  } else {
440
448
  msgs.forEach(m => {
@@ -479,8 +487,8 @@ export async function loadMessages(): Promise<void> {
479
487
  const skeletonContent = '<div class="skeleton-line"></div><div class="skeleton-line"></div>';
480
488
  const html = role === 'agent'
481
489
  ? `<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>`
482
- : `<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">${getUserAvatar()}</div></div>`;
483
- vs.addItem(crypto.randomUUID(), html);
490
+ : `<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>`;
491
+ vs.addItem(generateId(), html);
484
492
  }
485
493
  vs.onLazyRender = (targets: HTMLElement[]) => {
486
494
  for (const el of targets) {
@@ -0,0 +1,24 @@
1
+ // ── UUID Utility ──
2
+ /**
3
+ * Secure-context-safe UUID v4 generator.
4
+ * crypto.randomUUID() requires Secure Context (HTTPS / localhost).
5
+ * Fallback chain: randomUUID → getRandomValues (RFC 4122) → Math.random.
6
+ * Math.random tier is NOT cryptographically secure — used only for UI element IDs.
7
+ */
8
+ export function generateId(): string {
9
+ const c = globalThis.crypto;
10
+ if (typeof c?.randomUUID === 'function') return c.randomUUID();
11
+ if (typeof c?.getRandomValues !== 'function') {
12
+ // Last resort: Math.random (never reached in modern browsers)
13
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, ch => {
14
+ const r = (Math.random() * 16) | 0;
15
+ return (ch === 'x' ? r : (r & 0x3) | 0x8).toString(16);
16
+ });
17
+ }
18
+ const bytes = new Uint8Array(16);
19
+ c.getRandomValues(bytes);
20
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
21
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
22
+ const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
23
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
24
+ }
@@ -1,4 +1,5 @@
1
1
  // ── Virtual Scroll ──
2
+ import { generateId } from './uuid.js';
2
3
  // Activates at THRESHOLD messages to prevent DOM bloat
3
4
  // Below threshold: standard DOM append (zero overhead)
4
5
 
@@ -23,8 +24,8 @@ export class VirtualScroll {
23
24
  private _active = false;
24
25
  private _totalHeight = 0;
25
26
  private rafId: number | null = null;
26
- private firstVisible = 0;
27
- private lastVisible = 0;
27
+ private firstVisible = -1;
28
+ private lastVisible = -1;
28
29
 
29
30
  /** Called after render() mounts items in viewport — for lazy rendering and widget activation */
30
31
  onLazyRender: LazyRenderCallback | null = null;
@@ -47,22 +48,33 @@ export class VirtualScroll {
47
48
  * Called on conversation clear or explicit reset. */
48
49
  flushToDOM(): void {
49
50
  if (!this._active) return;
51
+ this.container.classList.remove('vs-active');
50
52
  this.container.removeEventListener('scroll', this.scrollHandler);
51
53
  if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
52
54
  this.container.innerHTML = this.items.map(it => it.html).join('');
53
55
  this._active = false;
54
- this.firstVisible = 0;
55
- this.lastVisible = 0;
56
+ this.firstVisible = -1;
57
+ this.lastVisible = -1;
56
58
  this.items = [];
57
59
  this._totalHeight = 0;
58
60
  }
59
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
+
60
72
  addItem(id: string, html: string): void {
61
73
  const item: VirtualItem = { id, html, height: EST_HEIGHT };
62
74
  this.items.push(item);
63
75
  this._totalHeight += EST_HEIGHT;
64
76
  if (!this._active && this.items.length >= THRESHOLD) {
65
- this.activate();
77
+ this.activate(true);
66
78
  }
67
79
  if (this._active) {
68
80
  this.scheduleRender();
@@ -74,13 +86,12 @@ export class VirtualScroll {
74
86
  appendLiveItem(div: HTMLElement): void {
75
87
  if (!this._active) return;
76
88
  const html = div.outerHTML;
77
- const id = crypto.randomUUID();
89
+ const id = generateId();
78
90
  const item: VirtualItem = { id, html, height: EST_HEIGHT };
79
91
  this.items.push(item);
80
92
  this._totalHeight += EST_HEIGHT;
81
- // Render immediately then scroll again after height is remeasured
82
- this.render();
83
- this.container.scrollTop = this._totalHeight;
93
+ // RC7 fix: use synchronous scrollToBottom (which handles spacers + render)
94
+ this.scrollToBottom();
84
95
  }
85
96
 
86
97
  /** Update cached HTML for a specific item index (used by lazy render). */
@@ -92,19 +103,30 @@ export class VirtualScroll {
92
103
 
93
104
  private scrollHandler = () => this.scheduleRender();
94
105
 
95
- private activate(): void {
106
+ /** RC6 fix: activate always scrolls to bottom (new messages are at the end) */
107
+ private activate(toBottom = false): void {
96
108
  this._active = true;
97
109
  this._totalHeight = 0;
98
110
  const existing = this.container.querySelectorAll('.msg');
99
111
  existing.forEach((el, i) => {
100
112
  if (this.items[i]) {
101
113
  this.items[i].height = el.getBoundingClientRect().height;
102
- this._totalHeight += this.items[i].height;
103
114
  }
104
115
  });
105
- // Atomic swap avoids visible blank frame during activation
116
+ for (const item of this.items) {
117
+ this._totalHeight += item.height;
118
+ }
119
+ this.container.classList.add('vs-active');
106
120
  this.container.replaceChildren(this.spacerTop, this.viewport, this.spacerBottom);
107
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
+ }
108
130
  this.render();
109
131
  }
110
132
 
@@ -121,7 +143,7 @@ export class VirtualScroll {
121
143
  const viewHeight = this.container.clientHeight;
122
144
 
123
145
  let accum = 0;
124
- let startIdx = 0;
146
+ let startIdx = this.items.length - 1;
125
147
  for (let i = 0; i < this.items.length; i++) {
126
148
  if (accum + this.items[i].height > scrollTop) {
127
149
  startIdx = i;
@@ -140,10 +162,7 @@ export class VirtualScroll {
140
162
  }
141
163
  const last = Math.min(this.items.length - 1, endIdx + BUFFER);
142
164
 
143
- if (first === this.firstVisible && last === this.lastVisible) return;
144
- this.firstVisible = first;
145
- this.lastVisible = last;
146
-
165
+ // RC3 fix: always update spacers even if range unchanged
147
166
  let topSpace = 0;
148
167
  for (let i = 0; i < first; i++) topSpace += this.items[i].height;
149
168
  let bottomSpace = 0;
@@ -152,6 +171,10 @@ export class VirtualScroll {
152
171
  this.spacerTop.style.height = `${topSpace}px`;
153
172
  this.spacerBottom.style.height = `${bottomSpace}px`;
154
173
 
174
+ if (first === this.firstVisible && last === this.lastVisible) return;
175
+ this.firstVisible = first;
176
+ this.lastVisible = last;
177
+
155
178
  // Build map of currently mounted items by vsIdx
156
179
  const mounted = new Map<number, HTMLElement>();
157
180
  for (const child of Array.from(this.viewport.children) as HTMLElement[]) {
@@ -215,6 +238,8 @@ export class VirtualScroll {
215
238
  /** Batch-read heights from visible elements, batch-write to items array.
216
239
  * Separated read/write passes = single forced reflow. */
217
240
  private remeasureVisible(): void {
241
+ const wasAtBottom = this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < 80;
242
+
218
243
  const rects: { idx: number; newH: number }[] = [];
219
244
  this.viewport.querySelectorAll('[data-vs-idx]').forEach(el => {
220
245
  const idx = Number((el as HTMLElement).dataset.vsIdx);
@@ -222,24 +247,38 @@ export class VirtualScroll {
222
247
  rects.push({ idx, newH: el.getBoundingClientRect().height });
223
248
  }
224
249
  });
250
+ let heightChanged = false;
225
251
  for (const { idx, newH } of rects) {
226
252
  const oldH = this.items[idx].height;
227
253
  if (oldH !== newH) {
228
254
  this.items[idx].height = newH;
229
255
  this._totalHeight += (newH - oldH);
256
+ heightChanged = true;
230
257
  }
231
258
  }
259
+ if (heightChanged && wasAtBottom) {
260
+ this.scrollToBottom();
261
+ }
232
262
  }
233
263
 
264
+ /** RC2 fix: synchronous scroll — cancel pending RAF, update spacers, render directly */
234
265
  scrollToBottom(): void {
235
- this.container.scrollTop = this._totalHeight;
236
- this.scheduleRender();
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';
270
+ this.container.scrollTop = this.container.scrollHeight;
271
+ // Reset visible range to force full re-render at new position
272
+ this.firstVisible = -1;
273
+ this.lastVisible = -1;
274
+ this.render();
237
275
  }
238
276
 
239
277
  clear(): void {
240
278
  this.items = [];
241
279
  this._totalHeight = 0;
242
280
  if (this._active) {
281
+ this.container.classList.remove('vs-active');
243
282
  this.container.removeEventListener('scroll', this.scrollHandler);
244
283
  this.viewport.innerHTML = '';
245
284
  this.spacerTop.style.height = '0';
@@ -247,8 +286,8 @@ export class VirtualScroll {
247
286
  this.container.innerHTML = '';
248
287
  }
249
288
  this._active = false;
250
- this.firstVisible = 0;
251
- this.lastVisible = 0;
289
+ this.firstVisible = -1;
290
+ this.lastVisible = -1;
252
291
  this.onLazyRender = null;
253
292
  this.onPostRender = null;
254
293
  if (this.rafId) {
package/public/js/ws.ts CHANGED
@@ -284,8 +284,12 @@ export function connect(): void {
284
284
  import('./ui.js').then(async m => {
285
285
  m.cleanupToolActivity();
286
286
  if (!skipReload) {
287
- await m.loadMessages();
288
- lastLoadTs = Date.now();
287
+ try {
288
+ await m.loadMessages();
289
+ lastLoadTs = Date.now();
290
+ } catch (error) {
291
+ console.error('[ws] loadMessages failed', error);
292
+ }
289
293
  }
290
294
  m.setStatus('idle');
291
295
  });
@@ -53,9 +53,8 @@
53
53
  "cmd.skill.resetDone": "Skill reset completed.",
54
54
  "cmd.employee.resetUnavailable": "/employee reset is not available in this environment.",
55
55
  "cmd.employee.resetDone": "Employees reset to defaults ({count} agents)",
56
- "cmd.clear.telegram": "/clear only shows a notice on Telegram (no screen clearing).",
57
- "cmd.clear.discord": "/clear only shows a notice on Discord (no screen clearing).",
58
- "cmd.clear.done": "Screen cleared. (Chat history preserved)",
56
+ "cmd.clear.remote": "Conversation history cleared. Starting fresh.",
57
+ "cmd.clear.done": "Conversation history cleared and screen reset.",
59
58
  "cmd.reset.confirm": "Full reset: MCP, skills, employees, and session will be reset to defaults.\nType /reset confirm to proceed.",
60
59
  "cmd.reset.unavailable": "Reset is not supported in this environment.",
61
60
  "cmd.reset.done": "Reset complete: {items}",
@@ -53,9 +53,8 @@
53
53
  "cmd.skill.resetDone": "스킬 초기화를 실행했습니다.",
54
54
  "cmd.employee.resetUnavailable": "이 환경에서는 /employee reset을 사용할 수 없습니다.",
55
55
  "cmd.employee.resetDone": "직원 기본값으로 재설정 완료 ({count}명)",
56
- "cmd.clear.telegram": "Telegram에서는 /clear가 화면 정리 없이 안내만 합니다.",
57
- "cmd.clear.discord": "Discord에서는 /clear가 화면 정리 없이 안내만 합니다.",
58
- "cmd.clear.done": "화면을 정리했습니다. (대화 기록은 유지됨)",
56
+ "cmd.clear.remote": "대화 히스토리를 초기화했습니다. 대화를 시작합니다.",
57
+ "cmd.clear.done": "대화 히스토리를 초기화하고 화면을 정리했습니다.",
59
58
  "cmd.reset.confirm": "전체 초기화: MCP, 스킬, 직원, 세션을 기본값으로 재설정합니다.\n실행하려면 /reset confirm 을 입력하세요.",
60
59
  "cmd.reset.unavailable": "이 환경에서는 초기화가 지원되지 않습니다.",
61
60
  "cmd.reset.done": "초기화 완료: {items}",