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.
- package/dist/bin/commands/dispatch.js +8 -0
- package/dist/bin/commands/dispatch.js.map +1 -1
- package/dist/bin/commands/memory.js +7 -1
- package/dist/bin/commands/memory.js.map +1 -1
- package/dist/bin/commands/orchestrate.js +67 -8
- package/dist/bin/commands/orchestrate.js.map +1 -1
- package/dist/src/agent/args.js +4 -0
- package/dist/src/agent/args.js.map +1 -1
- package/dist/src/agent/events.js +50 -20
- package/dist/src/agent/events.js.map +1 -1
- package/dist/src/agent/opencode-diagnostics.js +106 -0
- package/dist/src/agent/opencode-diagnostics.js.map +1 -0
- package/dist/src/agent/spawn-env.js +75 -4
- package/dist/src/agent/spawn-env.js.map +1 -1
- package/dist/src/agent/spawn.js +104 -15
- package/dist/src/agent/spawn.js.map +1 -1
- package/dist/src/cli/commands.js +1 -1
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/handlers-runtime.js +23 -5
- package/dist/src/cli/handlers-runtime.js.map +1 -1
- package/dist/src/core/compact.js +8 -7
- package/dist/src/core/compact.js.map +1 -1
- package/dist/src/core/runtime-settings-gate.js +40 -0
- package/dist/src/core/runtime-settings-gate.js.map +1 -0
- package/dist/src/core/runtime-settings.js +71 -64
- package/dist/src/core/runtime-settings.js.map +1 -1
- package/dist/src/orchestrator/pipeline.js +20 -0
- package/dist/src/orchestrator/pipeline.js.map +1 -1
- package/dist/src/orchestrator/state-machine.js +8 -5
- package/dist/src/orchestrator/state-machine.js.map +1 -1
- package/dist/src/prompt/templates/a1-system.md +9 -1
- package/dist/src/prompt/templates/employee.md +5 -1
- package/dist/src/routes/orchestrate.js +52 -11
- package/dist/src/routes/orchestrate.js.map +1 -1
- package/package.json +6 -5
- package/public/css/modals.css +126 -0
- package/public/dist/assets/{employees-Do9d6Xi5.js → employees-p53cgGmH.js} +1 -1
- package/public/dist/assets/{index-qALA03H1.css → index-CLKLbGzn.css} +1 -1
- package/public/dist/assets/index-wUWc2M5K.js +32 -0
- package/public/dist/assets/memory-6zLEr-qI.js +1 -0
- package/public/dist/assets/{memory-DeZSzBAb.js → memory-C2i7ZIvv.js} +2 -2
- package/public/dist/assets/{render-CQnnZ-_i.js → render-CulTuvJs.js} +1 -1
- package/public/dist/assets/settings-BUEiZgkm.js +40 -0
- package/public/dist/assets/settings-BhrOslae.js +1 -0
- package/public/dist/assets/{skills-Ci5t_dsV.js → skills-CSuSbBWa.js} +1 -1
- package/public/dist/assets/skills-CgwxEvFx.js +1 -0
- package/public/dist/assets/slash-commands-Bo8jvBfI.js +1 -0
- package/public/dist/assets/{slash-commands-0RvnZU9z.js → slash-commands-D-v0DlbY.js} +1 -1
- package/public/dist/assets/ui-4JiRyxJy.js +131 -0
- package/public/dist/assets/ui-Dx0MwI23.js +1 -0
- package/public/dist/assets/ws-DKtFfZsY.js +14 -0
- package/public/dist/index.html +74 -15
- package/public/index.html +72 -13
- package/public/js/features/attention-badge.ts +151 -0
- package/public/js/features/chat.ts +16 -0
- package/public/js/features/help-content.ts +75 -0
- package/public/js/features/help-dialog.ts +164 -0
- package/public/js/features/memory.ts +2 -2
- package/public/js/features/orchestrate-scope.ts +4 -0
- package/public/js/features/settings-core.ts +36 -11
- package/public/js/main.ts +4 -0
- package/public/js/ui.ts +21 -1
- package/public/js/virtual-scroll.ts +72 -8
- package/public/js/ws.ts +50 -6
- package/public/locales/en.json +183 -2
- package/public/locales/ko.json +183 -2
- package/scripts/smoke/opencode-external-dir-smoke.ts +350 -0
- package/public/dist/assets/index-yGExjgR_.js +0 -32
- package/public/dist/assets/memory-Dpe-qPbZ.js +0 -1
- package/public/dist/assets/settings-C8bSXG3q.js +0 -40
- package/public/dist/assets/settings-COrhSfDh.js +0 -1
- package/public/dist/assets/skills-BO0V4aHG.js +0 -1
- package/public/dist/assets/slash-commands-DbUvFtCk.js +0 -1
- package/public/dist/assets/ui-Cxk1_e0b.js +0 -1
- package/public/dist/assets/ui-IWxpAzJ7.js +0 -131
- package/public/dist/assets/ws-FsYmCE65.js +0 -14
- /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
|
|
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
|
|
378
|
+
const freshStatus = await api<any>('/api/memory/status');
|
|
379
379
|
syncSidebarBadge(freshStatus, 0);
|
|
380
380
|
renderStatusBanner(freshStatus);
|
|
381
381
|
}
|
|
@@ -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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
// ──
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
365
|
+
syncOrchestrateSnapshot('reconnect', { hydrateRun: true })
|
|
322
366
|
.catch(() => { /* snapshot not critical — UI recovers on next WS event */ })
|
|
323
|
-
.finally(() =>
|
|
367
|
+
.finally(() => m.reconcileChatBottomAfterRestore('reconnect'));
|
|
324
368
|
});
|
|
325
369
|
};
|
|
326
370
|
state.ws.onclose = () => {
|