cli-jaw 1.7.33 → 1.7.34

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 (77) hide show
  1. package/dist/bin/commands/dispatch.js +8 -0
  2. package/dist/bin/commands/dispatch.js.map +1 -1
  3. package/dist/bin/commands/memory.js +7 -1
  4. package/dist/bin/commands/memory.js.map +1 -1
  5. package/dist/bin/commands/orchestrate.js +67 -8
  6. package/dist/bin/commands/orchestrate.js.map +1 -1
  7. package/dist/src/agent/args.js +4 -0
  8. package/dist/src/agent/args.js.map +1 -1
  9. package/dist/src/agent/events.js +50 -20
  10. package/dist/src/agent/events.js.map +1 -1
  11. package/dist/src/agent/opencode-diagnostics.js +106 -0
  12. package/dist/src/agent/opencode-diagnostics.js.map +1 -0
  13. package/dist/src/agent/spawn-env.js +75 -4
  14. package/dist/src/agent/spawn-env.js.map +1 -1
  15. package/dist/src/agent/spawn.js +104 -15
  16. package/dist/src/agent/spawn.js.map +1 -1
  17. package/dist/src/cli/commands.js +1 -1
  18. package/dist/src/cli/commands.js.map +1 -1
  19. package/dist/src/cli/handlers-runtime.js +23 -5
  20. package/dist/src/cli/handlers-runtime.js.map +1 -1
  21. package/dist/src/core/compact.js +8 -7
  22. package/dist/src/core/compact.js.map +1 -1
  23. package/dist/src/core/runtime-settings-gate.js +40 -0
  24. package/dist/src/core/runtime-settings-gate.js.map +1 -0
  25. package/dist/src/core/runtime-settings.js +71 -64
  26. package/dist/src/core/runtime-settings.js.map +1 -1
  27. package/dist/src/orchestrator/pipeline.js +20 -0
  28. package/dist/src/orchestrator/pipeline.js.map +1 -1
  29. package/dist/src/orchestrator/state-machine.js +8 -5
  30. package/dist/src/orchestrator/state-machine.js.map +1 -1
  31. package/dist/src/prompt/templates/a1-system.md +9 -1
  32. package/dist/src/prompt/templates/employee.md +5 -1
  33. package/dist/src/routes/orchestrate.js +52 -11
  34. package/dist/src/routes/orchestrate.js.map +1 -1
  35. package/package.json +6 -5
  36. package/public/css/modals.css +126 -0
  37. package/public/dist/assets/{employees-Do9d6Xi5.js → employees-p53cgGmH.js} +1 -1
  38. package/public/dist/assets/{index-qALA03H1.css → index-CLKLbGzn.css} +1 -1
  39. package/public/dist/assets/index-wUWc2M5K.js +32 -0
  40. package/public/dist/assets/memory-6zLEr-qI.js +1 -0
  41. package/public/dist/assets/{memory-DeZSzBAb.js → memory-C2i7ZIvv.js} +2 -2
  42. package/public/dist/assets/{render-CQnnZ-_i.js → render-CulTuvJs.js} +1 -1
  43. package/public/dist/assets/settings-BUEiZgkm.js +40 -0
  44. package/public/dist/assets/settings-BhrOslae.js +1 -0
  45. package/public/dist/assets/{skills-Ci5t_dsV.js → skills-CSuSbBWa.js} +1 -1
  46. package/public/dist/assets/skills-CgwxEvFx.js +1 -0
  47. package/public/dist/assets/slash-commands-Bo8jvBfI.js +1 -0
  48. package/public/dist/assets/{slash-commands-0RvnZU9z.js → slash-commands-D-v0DlbY.js} +1 -1
  49. package/public/dist/assets/ui-4JiRyxJy.js +131 -0
  50. package/public/dist/assets/ui-Dx0MwI23.js +1 -0
  51. package/public/dist/assets/ws-DKtFfZsY.js +14 -0
  52. package/public/dist/index.html +74 -15
  53. package/public/index.html +72 -13
  54. package/public/js/features/attention-badge.ts +151 -0
  55. package/public/js/features/chat.ts +16 -0
  56. package/public/js/features/help-content.ts +75 -0
  57. package/public/js/features/help-dialog.ts +164 -0
  58. package/public/js/features/memory.ts +2 -2
  59. package/public/js/features/orchestrate-scope.ts +4 -0
  60. package/public/js/features/settings-core.ts +36 -11
  61. package/public/js/main.ts +4 -0
  62. package/public/js/ui.ts +21 -1
  63. package/public/js/virtual-scroll.ts +72 -8
  64. package/public/js/ws.ts +50 -6
  65. package/public/locales/en.json +183 -2
  66. package/public/locales/ko.json +183 -2
  67. package/scripts/smoke/opencode-external-dir-smoke.ts +350 -0
  68. package/public/dist/assets/index-yGExjgR_.js +0 -32
  69. package/public/dist/assets/memory-Dpe-qPbZ.js +0 -1
  70. package/public/dist/assets/settings-C8bSXG3q.js +0 -40
  71. package/public/dist/assets/settings-COrhSfDh.js +0 -1
  72. package/public/dist/assets/skills-BO0V4aHG.js +0 -1
  73. package/public/dist/assets/slash-commands-DbUvFtCk.js +0 -1
  74. package/public/dist/assets/ui-Cxk1_e0b.js +0 -1
  75. package/public/dist/assets/ui-IWxpAzJ7.js +0 -131
  76. package/public/dist/assets/ws-FsYmCE65.js +0 -14
  77. /package/public/dist/assets/{constants-IeOVgtYz.js → constants-BU8a_R5s.js} +0 -0
@@ -0,0 +1,75 @@
1
+ // Contextual help topic registry. Keep this module data-only so tests can import it safely.
2
+
3
+ export type HelpTopicId =
4
+ | 'activeCli'
5
+ | 'model'
6
+ | 'effort'
7
+ | 'permissions'
8
+ | 'flushAgent'
9
+ | 'employees'
10
+ | 'skills'
11
+ | 'activeChannel'
12
+ | 'telegram'
13
+ | 'discord'
14
+ | 'fallbackOrder'
15
+ | 'mcp'
16
+ | 'memory'
17
+ | 'stt'
18
+ | 'promptTemplates';
19
+
20
+ export interface HelpTopic {
21
+ titleKey: string;
22
+ introKey: string;
23
+ effectKey: string;
24
+ useWhenKeys: string[];
25
+ howToKeys: string[];
26
+ exampleKeys: string[];
27
+ avoidWhenKeys?: string[];
28
+ relatedKeys?: string[];
29
+ }
30
+
31
+ export const HELP_TOPICS: Record<HelpTopicId, HelpTopic> = {
32
+ activeCli: topic('activeCli', 2, 1, 2),
33
+ model: topic('model', 2, 1, 1),
34
+ effort: topic('effort', 2, 1, 1),
35
+ permissions: topic('permissions', 2, 1, 1),
36
+ flushAgent: topic('flushAgent', 2, 1, 1),
37
+ employees: topic('employees', 3, 3, 2),
38
+ skills: topic('skills', 2, 1, 1),
39
+ activeChannel: topic('activeChannel', 2, 1, 1),
40
+ telegram: topic('telegram', 2, 1, 2),
41
+ discord: topic('discord', 2, 1, 2),
42
+ fallbackOrder: topic('fallbackOrder', 2, 1, 1),
43
+ mcp: topic('mcp', 2, 1, 1),
44
+ memory: topic('memory', 2, 1, 2),
45
+ stt: topic('stt', 2, 1, 1),
46
+ promptTemplates: topic('promptTemplates', 2, 1, 1),
47
+ };
48
+
49
+ export function isHelpTopicId(value: string | null | undefined): value is HelpTopicId {
50
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(HELP_TOPICS, value);
51
+ }
52
+
53
+ function topic(
54
+ id: HelpTopicId,
55
+ useCount: number,
56
+ avoidCount: number,
57
+ relatedCount: number,
58
+ howToCount = 2,
59
+ exampleCount = 1,
60
+ ): HelpTopic {
61
+ return {
62
+ titleKey: `help.${id}.title`,
63
+ introKey: `help.${id}.intro`,
64
+ effectKey: `help.${id}.effect`,
65
+ useWhenKeys: rangeKeys(`help.${id}.use`, useCount),
66
+ howToKeys: rangeKeys(`help.${id}.howTo`, howToCount),
67
+ exampleKeys: rangeKeys(`help.${id}.example`, exampleCount),
68
+ avoidWhenKeys: rangeKeys(`help.${id}.avoid`, avoidCount),
69
+ relatedKeys: rangeKeys(`help.${id}.related`, relatedCount),
70
+ };
71
+ }
72
+
73
+ function rangeKeys(prefix: string, count: number): string[] {
74
+ return Array.from({ length: count }, (_, i) => `${prefix}.${i + 1}`);
75
+ }
@@ -0,0 +1,164 @@
1
+ import { t } from './i18n.js';
2
+ import { HELP_TOPICS, isHelpTopicId, type HelpTopic, type HelpTopicId } from './help-content.js';
3
+
4
+ let initialized = false;
5
+ let overlay: HTMLDivElement | null = null;
6
+ let titleEl: HTMLSpanElement | null = null;
7
+ let bodyEl: HTMLDivElement | null = null;
8
+ let closeBtn: HTMLButtonElement | null = null;
9
+ let lastOpener: HTMLElement | null = null;
10
+ let openState = false;
11
+
12
+ export function initHelpDialog(): void {
13
+ if (initialized) return;
14
+ initialized = true;
15
+ document.addEventListener('click', handleDocumentClick);
16
+ document.addEventListener('keydown', handleKeydownCapture, true);
17
+ }
18
+
19
+ export function openHelpDialog(topicId: HelpTopicId, opener: HTMLElement | null = null): void {
20
+ const topic = HELP_TOPICS[topicId];
21
+ if (!topic) {
22
+ console.warn('[help-dialog] unknown topic:', topicId);
23
+ return;
24
+ }
25
+ ensureDialog();
26
+ lastOpener = opener;
27
+ renderTopic(topic);
28
+ overlay?.classList.add('open');
29
+ overlay?.setAttribute('aria-hidden', 'false');
30
+ openState = true;
31
+ requestAnimationFrame(() => closeBtn?.focus());
32
+ }
33
+
34
+ export function closeHelpDialog(): void {
35
+ if (!openState) return;
36
+ overlay?.classList.remove('open');
37
+ overlay?.setAttribute('aria-hidden', 'true');
38
+ openState = false;
39
+ const opener = lastOpener;
40
+ lastOpener = null;
41
+ opener?.focus();
42
+ }
43
+
44
+ export function isHelpDialogOpen(): boolean {
45
+ return openState;
46
+ }
47
+
48
+ function handleDocumentClick(event: MouseEvent): void {
49
+ const trigger = (event.target as HTMLElement | null)?.closest('[data-help-topic]') as HTMLElement | null;
50
+ if (!trigger) return;
51
+ const topicId = trigger.getAttribute('data-help-topic');
52
+ if (!isHelpTopicId(topicId)) {
53
+ console.warn('[help-dialog] invalid topic:', topicId);
54
+ return;
55
+ }
56
+ event.preventDefault();
57
+ openHelpDialog(topicId, trigger);
58
+ }
59
+
60
+ function handleKeydownCapture(event: KeyboardEvent): void {
61
+ if (!openState || event.key !== 'Escape') return;
62
+ event.preventDefault();
63
+ event.stopImmediatePropagation();
64
+ closeHelpDialog();
65
+ }
66
+
67
+ function ensureDialog(): void {
68
+ if (overlay && titleEl && bodyEl && closeBtn) return;
69
+
70
+ overlay = document.createElement('div');
71
+ overlay.id = 'helpDialog';
72
+ overlay.className = 'modal-overlay help-dialog-overlay';
73
+ overlay.setAttribute('role', 'presentation');
74
+ overlay.setAttribute('aria-hidden', 'true');
75
+ overlay.addEventListener('click', (event: MouseEvent) => {
76
+ if (event.target === overlay) closeHelpDialog();
77
+ });
78
+
79
+ const box = document.createElement('div');
80
+ box.className = 'modal-box help-dialog-box';
81
+ box.setAttribute('role', 'dialog');
82
+ box.setAttribute('aria-modal', 'true');
83
+ box.setAttribute('aria-labelledby', 'helpDialogTitle');
84
+ box.addEventListener('click', (event: MouseEvent) => event.stopPropagation());
85
+
86
+ const header = document.createElement('div');
87
+ header.className = 'modal-header help-dialog-header';
88
+
89
+ titleEl = document.createElement('span');
90
+ titleEl.id = 'helpDialogTitle';
91
+
92
+ closeBtn = document.createElement('button');
93
+ closeBtn.type = 'button';
94
+ closeBtn.className = 'btn-modal-close help-dialog-close';
95
+ closeBtn.setAttribute('aria-label', t('help.close'));
96
+ closeBtn.textContent = 'x';
97
+ closeBtn.addEventListener('click', () => closeHelpDialog());
98
+
99
+ bodyEl = document.createElement('div');
100
+ bodyEl.className = 'help-dialog-body';
101
+
102
+ const footer = document.createElement('div');
103
+ footer.className = 'modal-footer help-dialog-footer';
104
+
105
+ const done = document.createElement('button');
106
+ done.type = 'button';
107
+ done.className = 'btn-save help-dialog-done';
108
+ done.textContent = t('help.close');
109
+ done.addEventListener('click', () => closeHelpDialog());
110
+
111
+ header.append(titleEl, closeBtn);
112
+ footer.append(done);
113
+ box.append(header, bodyEl, footer);
114
+ overlay.append(box);
115
+ document.body.append(overlay);
116
+ }
117
+
118
+ function renderTopic(topic: HelpTopic): void {
119
+ if (!titleEl || !bodyEl || !closeBtn) return;
120
+ titleEl.textContent = t(topic.titleKey);
121
+ closeBtn.setAttribute('aria-label', t('help.close'));
122
+ bodyEl.replaceChildren();
123
+
124
+ appendTextSection(t('help.section.what'), t(topic.introKey));
125
+ appendTextSection(t('help.section.effect'), t(topic.effectKey), 'help-effect-text');
126
+ appendListSection(t('help.section.useWhen'), topic.useWhenKeys);
127
+ appendListSection(t('help.section.howTo'), topic.howToKeys);
128
+ appendListSection(t('help.section.example'), topic.exampleKeys, false, 'help-example-list');
129
+ if (topic.avoidWhenKeys?.length) appendListSection(t('help.section.avoidWhen'), topic.avoidWhenKeys);
130
+ if (topic.relatedKeys?.length) appendListSection(t('help.section.related'), topic.relatedKeys, true);
131
+ }
132
+
133
+ function appendTextSection(heading: string, text: string, className?: string): void {
134
+ if (!bodyEl) return;
135
+ const section = createSection(heading);
136
+ const p = document.createElement('p');
137
+ if (className) p.className = className;
138
+ p.textContent = text;
139
+ section.append(p);
140
+ bodyEl.append(section);
141
+ }
142
+
143
+ function appendListSection(heading: string, keys: string[], related = false, className?: string): void {
144
+ if (!bodyEl) return;
145
+ const section = createSection(heading);
146
+ const list = document.createElement('ul');
147
+ list.className = className ?? (related ? 'help-related-list' : 'help-dialog-list');
148
+ for (const key of keys) {
149
+ const item = document.createElement('li');
150
+ item.textContent = t(key);
151
+ list.append(item);
152
+ }
153
+ section.append(list);
154
+ bodyEl.append(section);
155
+ }
156
+
157
+ function createSection(heading: string): HTMLElement {
158
+ const section = document.createElement('section');
159
+ section.className = 'help-dialog-section';
160
+ const h3 = document.createElement('h3');
161
+ h3.textContent = heading;
162
+ section.append(h3);
163
+ return section;
164
+ }
@@ -349,7 +349,7 @@ export async function upgradeSoulMemory(): Promise<void> {
349
349
  }
350
350
  await openMemoryModal();
351
351
  switchMemTab('status');
352
- const freshStatus = await apiJson<any>('/api/memory/status');
352
+ const freshStatus = await api<any>('/api/memory/status');
353
353
  syncSidebarBadge(freshStatus, 0);
354
354
  renderStatusBanner(freshStatus);
355
355
  }
@@ -375,7 +375,7 @@ export async function synthesizeSoul(): Promise<void> {
375
375
  setAdvBanner('✓ Soul 최적화 프롬프트 전송됨. 채팅창을 확인하세요.');
376
376
  await openMemoryModal();
377
377
  switchMemTab('status');
378
- const freshStatus = await apiJson<any>('/api/memory/status');
378
+ const freshStatus = await api<any>('/api/memory/status');
379
379
  syncSidebarBadge(freshStatus, 0);
380
380
  renderStatusBanner(freshStatus);
381
381
  }
@@ -0,0 +1,4 @@
1
+ export function shouldApplyOrcStateEvent(eventScope: unknown, currentScope: string): boolean {
2
+ if (!eventScope || !currentScope) return true;
3
+ return eventScope === 'all' || eventScope === currentScope;
4
+ }
@@ -12,6 +12,28 @@ import { loadActiveChannel, loadFallbackOrder } from './settings-channel.js';
12
12
  import { loadMcpServers } from './settings-mcp.js';
13
13
  import { providerIcon } from '../provider-icons.js';
14
14
 
15
+ let activeSettingsSave: Promise<void> | null = null;
16
+
17
+ function setHeaderCli(cli: string): void {
18
+ const hdr = document.getElementById('headerCli');
19
+ if (!hdr) return;
20
+ const ico = providerIcon(cli);
21
+ hdr.innerHTML = ico ? `${ico} ${escapeHtml(cli)}` : escapeHtml(cli);
22
+ }
23
+
24
+ function trackSettingsSave(promise: Promise<void>): Promise<void> {
25
+ const tracked = promise.finally(() => {
26
+ if (activeSettingsSave === tracked) activeSettingsSave = null;
27
+ });
28
+ activeSettingsSave = tracked;
29
+ return tracked;
30
+ }
31
+
32
+ export async function waitForSettingsSaveIdle(): Promise<void> {
33
+ const pending = activeSettingsSave;
34
+ if (pending) await pending;
35
+ }
36
+
15
37
  function toCap(cli: string): string {
16
38
  return cli.charAt(0).toUpperCase() + cli.slice(1);
17
39
  }
@@ -224,12 +246,19 @@ export async function updateSettings(): Promise<void> {
224
246
  const s = {
225
247
  cli: (document.getElementById('selCli') as HTMLSelectElement)?.value || 'claude',
226
248
  };
227
- const hdr = document.getElementById('headerCli');
228
- if (hdr) {
229
- const ico = providerIcon(s.cli);
230
- hdr.innerHTML = ico ? `${ico} ${escapeHtml(s.cli)}` : escapeHtml(s.cli);
231
- }
232
- await apiJson('/api/settings', 'PUT', s);
249
+ return trackSettingsSave((async () => {
250
+ const result = await apiJson<SettingsData>('/api/settings', 'PUT', s);
251
+ if (!result) {
252
+ await loadSettings();
253
+ return;
254
+ }
255
+ const confirmedCli = result.cli || s.cli;
256
+ const selCli = document.getElementById('selCli') as HTMLSelectElement | null;
257
+ if (selCli && Array.from(selCli.options).some(o => o.value === confirmedCli)) {
258
+ selCli.value = confirmedCli;
259
+ }
260
+ setHeaderCli(confirmedCli);
261
+ })());
233
262
  }
234
263
 
235
264
  export function setPerm(_p: string, save = true): void {
@@ -308,11 +337,7 @@ export function onCliChange(save = true): void {
308
337
  const models = MODEL_MAP[cli] || [];
309
338
  const modelSel = document.getElementById('selModel') as HTMLSelectElement | null;
310
339
  setSelectOptions(modelSel, models, { includeCustom: true, includeDefault: true });
311
- const hdrCli = document.getElementById('headerCli');
312
- if (hdrCli) {
313
- const ico = providerIcon(cli);
314
- hdrCli.innerHTML = ico ? `${ico} ${escapeHtml(cli)}` : escapeHtml(cli);
315
- }
340
+ setHeaderCli(cli);
316
341
  syncActiveEffortOptions(cli);
317
342
 
318
343
  const oldInput = document.getElementById('selModelCustom');
package/public/js/main.ts CHANGED
@@ -83,6 +83,8 @@ import { toggleRecording, cancelRecording } from './features/voice-recorder.js';
83
83
  import { ICONS, hydrateIcons } from './icons.js';
84
84
  import { hydrateProviderIcons } from './provider-icons.js';
85
85
  import { initPendingQueue } from './features/pending-queue.js';
86
+ import { initAttentionBadge } from './features/attention-badge.js';
87
+ import { initHelpDialog } from './features/help-dialog.js';
86
88
 
87
89
  // ── Chat Actions ──
88
90
  document.getElementById('btnSend')?.addEventListener('click', sendMessage);
@@ -449,6 +451,8 @@ async function bootstrap(): Promise<void> {
449
451
  if (langBtn) langBtn.innerHTML = `${ICONS.web} ${t('lang.' + getLang())}`;
450
452
  await loadCliRegistry();
451
453
  bindPerCliControlEvents();
454
+ initHelpDialog();
455
+ initAttentionBadge();
452
456
  connect();
453
457
  initDragDrop();
454
458
  initAutoResize();
package/public/js/ui.ts CHANGED
@@ -8,7 +8,7 @@ import { getAgentAvatarMarkup, getUserAvatarMarkup } from './features/avatar.js'
8
8
  import { t } from './features/i18n.js';
9
9
  import { api } from './api.js';
10
10
  import { cacheMessages, getCachedMessages, appendCachedMessage, upsertMessage, setMessageScope, getScopedMessages } from './features/idb-cache.js';
11
- import { getVirtualScroll, VS_THRESHOLD, type VirtualItem } from './virtual-scroll.js';
11
+ import { getVirtualScroll, VS_THRESHOLD, type RestoreReason, type VirtualItem } from './virtual-scroll.js';
12
12
  import { bootstrapVirtualHistory, type VirtualHistoryBootstrapDeps } from './virtual-scroll-bootstrap.js';
13
13
  import { createStreamRenderer, appendChunk, finalizeStream, hydrateStreamRenderer, type StreamState } from './streaming-render.js';
14
14
  import { activateWidgets } from './diagram/iframe-renderer.js';
@@ -506,6 +506,26 @@ export function reconcileChatBottomAfterLayout(shouldFollow = isChatNearBottom()
506
506
  });
507
507
  }
508
508
 
509
+ export function reconcileChatBottomAfterRestore(reason: string): void {
510
+ ensureScrollTracking();
511
+ userNearBottom = true;
512
+ const vs = getVirtualScroll();
513
+ if (vs.active) {
514
+ vs.forceBottomAfterRestore(reason as RestoreReason);
515
+ return;
516
+ }
517
+ const scroll = () => {
518
+ const c = document.getElementById('chatMessages');
519
+ if (c) c.scrollTop = c.scrollHeight;
520
+ };
521
+ scroll();
522
+ requestAnimationFrame(scroll);
523
+ requestAnimationFrame(() => requestAnimationFrame(scroll));
524
+ window.setTimeout(scroll, 250);
525
+ window.setTimeout(scroll, 1000);
526
+ void document.fonts?.ready.then(scroll);
527
+ }
528
+
509
529
  /** Scroll chat to bottom.
510
530
  * @param force - bypass user-scroll detection (use for explicit user actions) */
511
531
  export function scrollToBottom(force = false): void {
@@ -15,6 +15,17 @@ const EST_HEIGHT = 80;
15
15
  const OVERSCAN = 5;
16
16
  const BOTTOM_THRESHOLD = 80;
17
17
 
18
+ export type RestoreReason =
19
+ | 'pageshow'
20
+ | 'visibility'
21
+ | 'focus'
22
+ | 'pagehide'
23
+ | 'freeze'
24
+ | 'resume'
25
+ | 'discard'
26
+ | 'reconnect'
27
+ | 'manual';
28
+
18
29
  export interface VirtualItem {
19
30
  id: string;
20
31
  html: string;
@@ -60,6 +71,7 @@ export class VirtualScroll {
60
71
  private cleanupFn: (() => void) | null = null;
61
72
  private mounted = new Map<number, HTMLElement>();
62
73
  private itemGap = 0;
74
+ private restorePassTimers = new Set<number>();
63
75
 
64
76
  onLazyRender: LazyRenderCallback | null = null;
65
77
  onPostRender: ((viewport: HTMLElement) => void) | null = null;
@@ -182,7 +194,7 @@ export class VirtualScroll {
182
194
  return dist < threshold;
183
195
  }
184
196
 
185
- reconcileBottomAfterLayout(reason: 'pageshow' | 'visibility' | 'focus' | 'reconnect' | 'manual', shouldFollow = this.isNearBottom()): void {
197
+ reconcileBottomAfterLayout(reason: RestoreReason, shouldFollow = this.isNearBottom()): void {
186
198
  if (!shouldFollow) return;
187
199
  void reason;
188
200
  requestAnimationFrame(() => {
@@ -193,6 +205,44 @@ export class VirtualScroll {
193
205
  });
194
206
  }
195
207
 
208
+ forceBottomAfterRestore(reason: RestoreReason): void {
209
+ this.scheduleRestoreReconcile(reason);
210
+ }
211
+
212
+ private scheduleRestoreReconcile(reason: RestoreReason): void {
213
+ this.runRestoreReconcilePass(reason);
214
+ requestAnimationFrame(() => this.runRestoreReconcilePass(reason));
215
+ requestAnimationFrame(() => {
216
+ requestAnimationFrame(() => this.runRestoreReconcilePass(reason));
217
+ });
218
+ this.scheduleRestoreTimer(reason, 250);
219
+ this.scheduleRestoreTimer(reason, 1000);
220
+ void document.fonts?.ready.then(() => this.runRestoreReconcilePass(reason));
221
+ }
222
+
223
+ private scheduleRestoreTimer(reason: RestoreReason, delayMs: number): void {
224
+ const timer = window.setTimeout(() => {
225
+ this.restorePassTimers.delete(timer);
226
+ this.runRestoreReconcilePass(reason);
227
+ }, delayMs);
228
+ this.restorePassTimers.add(timer);
229
+ }
230
+
231
+ private runRestoreReconcilePass(reason: RestoreReason): void {
232
+ if (!this.virtualizer) return;
233
+ void reason;
234
+ this.invalidateLayout();
235
+ remeasureMountedVirtualItems(this.items, this.mounted, this.virtualizer);
236
+ this.scrollToBottom();
237
+ }
238
+
239
+ private clearRestoreTimers(): void {
240
+ for (const timer of this.restorePassTimers) {
241
+ window.clearTimeout(timer);
242
+ }
243
+ this.restorePassTimers.clear();
244
+ }
245
+
196
246
  flushToDOM(): void {
197
247
  if (!this._active) return;
198
248
  this.deactivate();
@@ -286,14 +336,12 @@ export class VirtualScroll {
286
336
  cleanupFns.push(() => containerObserver.disconnect());
287
337
  }
288
338
 
289
- // ── bfcache restoration ──
290
- // pageshow fires when page is restored from bfcache (persisted=true).
291
- // Regular reload goes through normal activate() flow, but bfcache
292
- // restores the JS heap with stale cached measurements.
293
- const restoreBottomAfterLayout = (reason: 'pageshow' | 'visibility' | 'focus') => {
339
+ // ── Browser restore reconciliation ──
340
+ // Resume/discard paths can restore stale virtualizer measurements.
341
+ // Product policy: browser restore/reconnect forces the newest message.
342
+ const restoreBottomAfterLayout = (reason: RestoreReason) => {
294
343
  if (!this.virtualizer) return;
295
- const shouldFollow = this.isNearBottom();
296
- this.reconcileBottomAfterLayout(reason, shouldFollow);
344
+ this.forceBottomAfterRestore(reason);
297
345
  };
298
346
  const onPageShow = (e: PageTransitionEvent) => {
299
347
  if (!e.persisted) return;
@@ -307,11 +355,21 @@ export class VirtualScroll {
307
355
  document.addEventListener('visibilitychange', onVisibilityChange);
308
356
  const onFocus = () => restoreBottomAfterLayout('focus');
309
357
  window.addEventListener('focus', onFocus);
358
+ const onResume = () => restoreBottomAfterLayout('resume');
359
+ document.addEventListener('resume', onResume);
360
+ const onPageHide = () => { /* diagnostic hook: restore happens on pageshow/resume */ };
361
+ window.addEventListener('pagehide', onPageHide);
362
+ const onFreeze = () => { /* diagnostic hook: restore happens on resume */ };
363
+ document.addEventListener('freeze', onFreeze);
310
364
  this.cleanupFn = () => {
311
365
  window.removeEventListener('pageshow', onPageShow);
312
366
  document.removeEventListener('visibilitychange', onVisibilityChange);
313
367
  window.removeEventListener('focus', onFocus);
368
+ document.removeEventListener('resume', onResume);
369
+ window.removeEventListener('pagehide', onPageHide);
370
+ document.removeEventListener('freeze', onFreeze);
314
371
  for (const cleanup of cleanupFns.reverse()) cleanup();
372
+ this.clearRestoreTimers();
315
373
  };
316
374
 
317
375
  this.virtualizer._willUpdate();
@@ -334,9 +392,15 @@ export class VirtualScroll {
334
392
  } else {
335
393
  this.renderItems();
336
394
  }
395
+ const wasDiscarded = 'wasDiscarded' in document
396
+ && Boolean((document as Document & { wasDiscarded?: boolean }).wasDiscarded);
397
+ if (wasDiscarded) {
398
+ this.forceBottomAfterRestore('discard');
399
+ }
337
400
  }
338
401
 
339
402
  private deactivate(): void {
403
+ this.clearRestoreTimers();
340
404
  if (this.cleanupFn) {
341
405
  this.cleanupFn();
342
406
  this.cleanupFn = null;
package/public/js/ws.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  // ── WebSocket Connection ──
2
2
  import { state } from './state.js';
3
- import { setStatus, updateQueueBadge, addSystemMsg, appendAgentText, finalizeAgent, addMessage, showProcessStep, cleanupToolActivity, applyQueuedOverlay, hydrateActiveRun, isChatNearBottom, reconcileChatBottomAfterLayout } from './ui.js';
3
+ import { setStatus, updateQueueBadge, addSystemMsg, appendAgentText, finalizeAgent, addMessage, showProcessStep, cleanupToolActivity, applyQueuedOverlay, hydrateActiveRun, reconcileChatBottomAfterRestore } from './ui.js';
4
4
  import { renderPendingQueue } from './features/pending-queue.js';
5
5
  import { t, getLang } from './features/i18n.js';
6
6
  import { getVirtualScroll } from './virtual-scroll.js';
7
7
  import { ICONS, emojiToIcon } from './icons.js';
8
8
  import { escapeHtml, cancelPostRender } from './render.js';
9
9
  import type { OrcStateName } from './state.js';
10
+ import { notifyUnreadResponse } from './features/attention-badge.js';
11
+ import { shouldApplyOrcStateEvent } from './features/orchestrate-scope.js';
10
12
 
11
13
  const ROADMAP_PHASES = ['P', 'A', 'B', 'C'] as const;
12
14
 
@@ -68,6 +70,7 @@ interface WsMessage {
68
70
  state?: string;
69
71
  title?: string;
70
72
  isEmployee?: boolean;
73
+ fromQueue?: boolean;
71
74
  }
72
75
 
73
76
  // Agent phase state (populated by agent_status events from orchestrator)
@@ -75,6 +78,10 @@ const agentPhaseState: Record<string, { phase: string; phaseLabel: string }> = {
75
78
 
76
79
  let currentOrcScope = '';
77
80
  let lastLoadTs = 0;
81
+ let snapshotSyncInFlight: Promise<void> | null = null;
82
+ let lastSnapshotSyncAt = 0;
83
+ let restoreHooksRegistered = false;
84
+ const SNAPSHOT_SYNC_THROTTLE_MS = 750;
78
85
 
79
86
  async function refreshRuntimeSnapshot(options: { hydrateRun?: boolean } = {}): Promise<void> {
80
87
  const response = await fetch('/api/orchestrate/snapshot');
@@ -92,6 +99,41 @@ async function refreshRuntimeSnapshot(options: { hydrateRun?: boolean } = {}): P
92
99
  });
93
100
  }
94
101
 
102
+ export function syncOrchestrateSnapshot(reason = 'manual', options: { hydrateRun?: boolean } = {}): Promise<void> {
103
+ const now = Date.now();
104
+ if (!options.hydrateRun) {
105
+ if (snapshotSyncInFlight) return snapshotSyncInFlight;
106
+ if (now - lastSnapshotSyncAt < SNAPSHOT_SYNC_THROTTLE_MS) return Promise.resolve();
107
+ lastSnapshotSyncAt = now;
108
+ snapshotSyncInFlight = refreshRuntimeSnapshot(options)
109
+ .catch(error => {
110
+ console.warn(`[ws] orchestrate snapshot sync failed (${reason})`, error);
111
+ throw error;
112
+ })
113
+ .finally(() => {
114
+ snapshotSyncInFlight = null;
115
+ });
116
+ return snapshotSyncInFlight;
117
+ }
118
+ return refreshRuntimeSnapshot(options);
119
+ }
120
+
121
+ function registerOrchestrateRestoreHooks(): void {
122
+ if (restoreHooksRegistered) return;
123
+ restoreHooksRegistered = true;
124
+ window.addEventListener('focus', () => {
125
+ syncOrchestrateSnapshot('focus').catch(() => {});
126
+ });
127
+ window.addEventListener('pageshow', () => {
128
+ syncOrchestrateSnapshot('pageshow').catch(() => {});
129
+ });
130
+ document.addEventListener('visibilitychange', () => {
131
+ if (document.visibilityState === 'visible') {
132
+ syncOrchestrateSnapshot('visibilitychange').catch(() => {});
133
+ }
134
+ });
135
+ }
136
+
95
137
  /** Hydrate agent phase cache from snapshot (used after reconnect) */
96
138
  export function hydrateAgentPhases(workers: Array<{
97
139
  agentId: string;
@@ -210,6 +252,7 @@ function applyOrcState(orcState: string, title?: string) {
210
252
  }
211
253
 
212
254
  export function connect(): void {
255
+ registerOrchestrateRestoreHooks();
213
256
  const wsBase = import.meta.env?.DEV ? 'ws://localhost:3458' : `ws://${location.host}`;
214
257
  state.ws = new WebSocket(`${wsBase}?lang=${getLang()}`);
215
258
  state.ws.onmessage = (e: MessageEvent) => {
@@ -237,7 +280,7 @@ export function connect(): void {
237
280
  }
238
281
  } else if (msg.type === 'queue_update') {
239
282
  updateQueueBadge(msg.pending || 0);
240
- refreshRuntimeSnapshot().catch(() => { /* snapshot not critical — UI recovers on next event */ });
283
+ syncOrchestrateSnapshot('queue_update').catch(() => { /* snapshot not critical — UI recovers on next event */ });
241
284
  } else if (msg.type === 'worklog_created') {
242
285
  addSystemMsg(`${ICONS.clipboard} Worklog: ${escapeHtml(msg.path || '')}`);
243
286
  } else if (msg.type === 'round_start') {
@@ -278,8 +321,10 @@ export function connect(): void {
278
321
  addSystemMsg(`${ICONS.warning} ${escapeHtml(msg.cli || 'agent')}: smoke response detected — auto-continuing`, 'tool-activity');
279
322
  } else if (msg.type === 'agent_done') {
280
323
  finalizeAgent(msg.text || '', msg.toolLog);
324
+ notifyUnreadResponse();
281
325
  } else if (msg.type === 'orchestrate_done') {
282
326
  finalizeAgent(msg.text || '');
327
+ notifyUnreadResponse();
283
328
  } else if (msg.type === 'clear') {
284
329
  cancelPostRender();
285
330
  cleanupToolActivity();
@@ -293,7 +338,7 @@ export function connect(): void {
293
338
  } else if (msg.type === 'agent_added' || msg.type === 'agent_updated' || msg.type === 'agent_deleted') {
294
339
  import('./features/employees.js').then(m => m.loadEmployees());
295
340
  } else if (msg.type === 'orc_state') {
296
- if (msg.scope && currentOrcScope && msg.scope !== currentOrcScope) return;
341
+ if (!shouldApplyOrcStateEvent(msg.scope, currentOrcScope)) return;
297
342
  applyOrcState(typeof msg.state === 'string' ? msg.state : 'IDLE', msg.title);
298
343
  } else if (msg.type === 'memory_status') {
299
344
  import('./features/memory.js').then(m => m.refreshMemorySidebar());
@@ -307,7 +352,6 @@ export function connect(): void {
307
352
  console.log('[ws] connected');
308
353
  const now = Date.now();
309
354
  const skipReload = now - lastLoadTs < 10000;
310
- const shouldFollowBottom = isChatNearBottom();
311
355
  import('./ui.js').then(async m => {
312
356
  m.cleanupToolActivity();
313
357
  if (!skipReload) {
@@ -318,9 +362,9 @@ export function connect(): void {
318
362
  console.error('[ws] loadMessages failed', error);
319
363
  }
320
364
  }
321
- refreshRuntimeSnapshot({ hydrateRun: true })
365
+ syncOrchestrateSnapshot('reconnect', { hydrateRun: true })
322
366
  .catch(() => { /* snapshot not critical — UI recovers on next WS event */ })
323
- .finally(() => reconcileChatBottomAfterLayout(shouldFollowBottom));
367
+ .finally(() => m.reconcileChatBottomAfterRestore('reconnect'));
324
368
  });
325
369
  };
326
370
  state.ws.onclose = () => {