cli-jaw 2.2.10 → 2.2.12
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/README.ja.md +15 -0
- package/README.ko.md +15 -0
- package/README.md +15 -0
- package/dist/bin/cli-jaw.js +5 -1
- package/dist/bin/cli-jaw.js.map +1 -1
- package/dist/bin/commands/doctor.js +22 -2
- package/dist/bin/commands/doctor.js.map +1 -1
- package/dist/bin/commands/init.js +7 -0
- package/dist/bin/commands/init.js.map +1 -1
- package/dist/bin/commands/slack.js +232 -0
- package/dist/bin/commands/slack.js.map +1 -0
- package/dist/bin/commands/tui/slash-command-runner.js +11 -2
- package/dist/bin/commands/tui/slash-command-runner.js.map +1 -1
- package/dist/server.js +18 -2
- package/dist/server.js.map +1 -1
- package/dist/src/cli/commands.js +68 -15
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/opencodex-models.js +67 -14
- package/dist/src/cli/opencodex-models.js.map +1 -1
- package/dist/src/cli/registry-live.js +55 -2
- package/dist/src/cli/registry-live.js.map +1 -1
- package/dist/src/cli/registry.js +8 -2
- package/dist/src/cli/registry.js.map +1 -1
- package/dist/src/core/config.js +26 -1
- package/dist/src/core/config.js.map +1 -1
- package/dist/src/messaging/channel-health.js +6 -0
- package/dist/src/messaging/channel-health.js.map +1 -1
- package/dist/src/messaging/channel-validate.js +85 -0
- package/dist/src/messaging/channel-validate.js.map +1 -0
- package/dist/src/routes/messaging.js +9 -0
- package/dist/src/routes/messaging.js.map +1 -1
- package/dist/src/routes/system.js +5 -0
- package/dist/src/routes/system.js.map +1 -1
- package/dist/src/slack/api.js +32 -3
- package/dist/src/slack/api.js.map +1 -1
- package/dist/src/slack/bot.js +37 -1
- package/dist/src/slack/bot.js.map +1 -1
- package/dist/src/slack/events.js +23 -0
- package/dist/src/slack/events.js.map +1 -1
- package/dist/src/slack/hot-notify.js +33 -0
- package/dist/src/slack/hot-notify.js.map +1 -0
- package/dist/src/slack/manifest.js +85 -0
- package/dist/src/slack/manifest.js.map +1 -0
- package/dist/src/slack/progress.js +98 -0
- package/dist/src/slack/progress.js.map +1 -0
- package/dist/src/slack/send-only-client.js +3 -2
- package/dist/src/slack/send-only-client.js.map +1 -1
- package/dist/src/slack/slack-file.js +4 -2
- package/dist/src/slack/slack-file.js.map +1 -1
- package/package.json +3 -1
- package/public/css/sidebar.css +200 -0
- package/public/dist/assets/Agent-BjbZVZ9p.js +1 -0
- package/public/dist/assets/{Heartbeat-vDGhPfct.js → Heartbeat-DDmZARWw.js} +1 -1
- package/public/dist/assets/{Memory-B_UZ-iCA.js → Memory-CUbviW7N.js} +1 -1
- package/public/dist/assets/ModelProvider-ngg8xTuB.js +1 -0
- package/public/dist/assets/{agent-meta-C8DqYXM6.js → agent-meta-Y3DE6sxh.js} +1 -1
- package/public/dist/assets/app-BzD_ZLvt.js +55 -0
- package/public/dist/assets/{app-Nu2VPaO5.css → app-DX4e6isX.css} +1 -1
- package/public/dist/assets/bgtask-badge-Dqbp-Y4e.js +1 -0
- package/public/dist/assets/{bgtask-badge-DXca8E7c.js → bgtask-badge-qMkKOZx0.js} +1 -1
- package/public/dist/assets/{constants-CSZ011-j.js → constants-Df7-7Rky.js} +1 -1
- package/public/dist/assets/{employees-BL4qMxJj.js → employees-BnobWM_2.js} +1 -1
- package/public/dist/assets/{iframe-renderer-DUI0LYBy.js → iframe-renderer-C5vRbbl9.js} +2 -2
- package/public/dist/assets/iframe-renderer-Df4KIVyx.js +1 -0
- package/public/dist/assets/{manager-B-gpf-3j.js → manager-DL1GygQL.js} +1 -1
- package/public/dist/assets/memory-D-bULTHl.js +1 -0
- package/public/dist/assets/{memory-B_Y0L0KD.js → memory-DF4kPwwC.js} +1 -1
- package/public/dist/assets/{message-history-Dl8dJzez.js → message-history-C4WOisGx.js} +1 -1
- package/public/dist/assets/message-history-ZBoza6ew.js +1 -0
- package/public/dist/assets/{render-BI1aqKoc.js → render-DYX6GRQ0.js} +1 -1
- package/public/dist/assets/{settings-B0Sj4sUH.js → settings-DbJpGoaX.js} +1 -1
- package/public/dist/assets/settings-SreihUje.js +1 -0
- package/public/dist/assets/settings-core-B1Y5Nb4Z.js +1 -0
- package/public/dist/assets/settings-core-DobZWIZ7.js +140 -0
- package/public/dist/assets/{sidebar-DOR9fTgN.js → sidebar-Cp22g0Gv.js} +3 -3
- package/public/dist/assets/skills-DwPIP08e.js +1 -0
- package/public/dist/assets/{skills-wt0fkV9Y.js → skills-IGquen_j.js} +1 -1
- package/public/dist/assets/slash-commands-BBVfwcnW.js +1 -0
- package/public/dist/assets/{slash-commands-BZbqTg3v.js → slash-commands-ZRDOL99E.js} +1 -1
- package/public/dist/assets/{trace-drawer-kMYzaVFW.js → trace-drawer-D8To8fYt.js} +1 -1
- package/public/dist/assets/ui-CAicpUZc.js +1 -0
- package/public/dist/assets/{ui-D4D5HUfo.js → ui-Hx_mNoDf.js} +1 -1
- package/public/dist/index.html +90 -47
- package/public/dist/manager/index.html +1 -1
- package/public/index.html +88 -45
- package/public/js/constants.ts +50 -0
- package/public/js/features/channel-onboarding-flow.ts +197 -0
- package/public/js/features/channel-onboarding.ts +322 -0
- package/public/js/features/channel-setup-guide.ts +26 -0
- package/public/js/features/channel-setup-rules.ts +32 -0
- package/public/js/features/notifications.ts +38 -0
- package/public/js/features/settings-channel.ts +4 -0
- package/public/js/features/settings-core.ts +73 -9
- package/public/js/features/settings-discord.ts +2 -0
- package/public/js/features/settings-slack.ts +60 -2
- package/public/js/features/settings-telegram.ts +2 -0
- package/public/js/features/settings-types.ts +1 -1
- package/public/js/features/settings.ts +1 -1
- package/public/js/features/transport-status-row.ts +7 -3
- package/public/js/main.ts +5 -1
- package/public/locales/en.json +65 -3
- package/public/locales/ja.json +76 -3
- package/public/locales/ko.json +65 -3
- package/public/locales/zh.json +76 -3
- package/public/manager/src/settings/pages/Agent.tsx +25 -4
- package/public/manager/src/settings/pages/components/PerCliRow.tsx +13 -5
- package/public/manager/src/settings/pages/components/agent/agent-meta.ts +98 -0
- package/scripts/bundle-sidecar.sh +13 -4
- package/scripts/check-sidecar-prune-safety.mjs +167 -0
- package/scripts/pick-gyp-python.sh +61 -0
- package/scripts/release-gates.mjs +14 -0
- package/public/dist/assets/Agent-BgkP4EnS.js +0 -1
- package/public/dist/assets/ModelProvider-CgUvs6pE.js +0 -1
- package/public/dist/assets/app-BGIAy4he.js +0 -55
- package/public/dist/assets/bgtask-badge-GRSiNILQ.js +0 -1
- package/public/dist/assets/iframe-renderer-BUSk0Rw9.js +0 -1
- package/public/dist/assets/memory-CX6YahTc.js +0 -1
- package/public/dist/assets/message-history-Det7u76S.js +0 -1
- package/public/dist/assets/settings-C7NutP6X.js +0 -1
- package/public/dist/assets/settings-core-CyX-px6_.js +0 -100
- package/public/dist/assets/settings-core-DVpFmrhY.js +0 -1
- package/public/dist/assets/skills-BWhKreQ2.js +0 -1
- package/public/dist/assets/slash-commands-Di4NDA4w.js +0 -1
- package/public/dist/assets/ui-BghexWrB.js +0 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// ── Channel Onboarding Wizard (UI shell) ──
|
|
2
|
+
// Four numbered steps for every channel: 안내 → 자격 증명 입력 → 연결 검증 →
|
|
3
|
+
// 저장. Each step is gated by channel-onboarding-flow.ts, so "다음" refuses to
|
|
4
|
+
// move while that step's own contract is unmet, and draft values survive
|
|
5
|
+
// moving back and forth. This module owns rendering and IO only.
|
|
6
|
+
import { api, apiJson } from '../api.js';
|
|
7
|
+
import { t } from './i18n.js';
|
|
8
|
+
import { escapeHtml } from '../render.js';
|
|
9
|
+
import { refreshTransportStatusRow } from './transport-status-row.js';
|
|
10
|
+
import {
|
|
11
|
+
TOTAL_STEPS,
|
|
12
|
+
ISSUER_URLS,
|
|
13
|
+
advance,
|
|
14
|
+
applyValidation,
|
|
15
|
+
blockerForStep,
|
|
16
|
+
canAdvance,
|
|
17
|
+
createFlow,
|
|
18
|
+
fieldsFor,
|
|
19
|
+
goBack,
|
|
20
|
+
markSaved,
|
|
21
|
+
setField,
|
|
22
|
+
settingsPatch,
|
|
23
|
+
validationPayload,
|
|
24
|
+
type FlowState,
|
|
25
|
+
type OnboardChannel,
|
|
26
|
+
} from './channel-onboarding-flow.js';
|
|
27
|
+
import { maybeRequestNotificationPermission } from './notifications.js';
|
|
28
|
+
|
|
29
|
+
export type { OnboardChannel };
|
|
30
|
+
|
|
31
|
+
let overlay: HTMLDivElement | null = null;
|
|
32
|
+
let flow: FlowState | null = null;
|
|
33
|
+
let validating = false;
|
|
34
|
+
let saving = false;
|
|
35
|
+
/**
|
|
36
|
+
* Bumped whenever the wizard is (re)opened. An in-flight validation compares
|
|
37
|
+
* it on return: switching to another channel — or reopening the same one —
|
|
38
|
+
* must not let a stale response mark the new flow as verified.
|
|
39
|
+
*/
|
|
40
|
+
let flowGeneration = 0;
|
|
41
|
+
|
|
42
|
+
export function initChannelOnboarding(): void {
|
|
43
|
+
document.addEventListener('click', (ev) => {
|
|
44
|
+
const btn = (ev.target as HTMLElement | null)?.closest('[data-onboard-channel]') as HTMLElement | null;
|
|
45
|
+
if (!btn) return;
|
|
46
|
+
ev.preventDefault();
|
|
47
|
+
openChannelOnboarding(btn.getAttribute('data-onboard-channel') as OnboardChannel);
|
|
48
|
+
});
|
|
49
|
+
// Capture phase, matching help-dialog: whichever overlay is open handles
|
|
50
|
+
// Escape and stops it there, so the two never both react.
|
|
51
|
+
document.addEventListener('keydown', (ev) => {
|
|
52
|
+
if (ev.key !== 'Escape' || !flow) return;
|
|
53
|
+
ev.preventDefault();
|
|
54
|
+
ev.stopImmediatePropagation();
|
|
55
|
+
close();
|
|
56
|
+
}, true);
|
|
57
|
+
// Focus trap: a modal that lets Tab wander into the page behind it is a
|
|
58
|
+
// keyboard dead end — the user cannot see what is focused and cannot get
|
|
59
|
+
// back without a mouse.
|
|
60
|
+
document.addEventListener('keydown', (ev) => {
|
|
61
|
+
if (ev.key !== 'Tab' || !flow || !overlay) return;
|
|
62
|
+
const focusable = [...overlay.querySelectorAll<HTMLElement>(
|
|
63
|
+
'button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
|
|
64
|
+
)];
|
|
65
|
+
if (!focusable.length) return;
|
|
66
|
+
const first = focusable[0]!;
|
|
67
|
+
const last = focusable[focusable.length - 1]!;
|
|
68
|
+
const active = document.activeElement as HTMLElement | null;
|
|
69
|
+
if (ev.shiftKey && (active === first || !overlay.contains(active))) {
|
|
70
|
+
ev.preventDefault();
|
|
71
|
+
last.focus();
|
|
72
|
+
} else if (!ev.shiftKey && active === last) {
|
|
73
|
+
ev.preventDefault();
|
|
74
|
+
first.focus();
|
|
75
|
+
}
|
|
76
|
+
}, true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function openChannelOnboarding(channel: OnboardChannel): void {
|
|
80
|
+
if (channel !== 'telegram' && channel !== 'discord' && channel !== 'slack') return;
|
|
81
|
+
// Seed the draft from whatever the settings section already holds, so an
|
|
82
|
+
// existing token does not have to be retyped.
|
|
83
|
+
const draft: Record<string, string> = {};
|
|
84
|
+
for (const field of fieldsFor(channel)) {
|
|
85
|
+
draft[field.key] = readSettingsInput(field.settingsId);
|
|
86
|
+
}
|
|
87
|
+
flow = createFlow(channel, draft);
|
|
88
|
+
validating = false;
|
|
89
|
+
saving = false;
|
|
90
|
+
flowGeneration += 1;
|
|
91
|
+
ensureOverlay();
|
|
92
|
+
render();
|
|
93
|
+
overlay?.classList.add('open');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function close(): void {
|
|
97
|
+
overlay?.classList.remove('open');
|
|
98
|
+
flow = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readSettingsInput(id: string): string {
|
|
102
|
+
return (document.getElementById(id) as HTMLInputElement | null)?.value.trim() || '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeSettingsInput(id: string, value: string): void {
|
|
106
|
+
const el = document.getElementById(id) as HTMLInputElement | null;
|
|
107
|
+
if (el) el.value = value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ensureOverlay(): void {
|
|
111
|
+
if (overlay) return;
|
|
112
|
+
overlay = document.createElement('div');
|
|
113
|
+
overlay.className = 'modal-overlay onboarding-overlay';
|
|
114
|
+
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
|
|
115
|
+
document.body.appendChild(overlay);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function stepBody(state: FlowState): string {
|
|
119
|
+
if (state.step === 1) {
|
|
120
|
+
return `
|
|
121
|
+
<p class="onboarding-guide">${escapeHtml(t(`onboarding.guide.${state.channel}`))}</p>
|
|
122
|
+
<div class="onboarding-actions">
|
|
123
|
+
<button type="button" class="perm-btn" data-onboard-issuer="1">
|
|
124
|
+
${escapeHtml(t('onboarding.openIssuer'))}
|
|
125
|
+
</button>
|
|
126
|
+
</div>`;
|
|
127
|
+
}
|
|
128
|
+
if (state.step === 2) {
|
|
129
|
+
return fieldsFor(state.channel).map((field) => {
|
|
130
|
+
const label = t(`onboarding.token.${field.key}`) + (field.optional ? ` (${t('onboarding.optional')})` : '');
|
|
131
|
+
return `
|
|
132
|
+
<div class="onboarding-field">
|
|
133
|
+
<label for="onboard-${field.key}">${escapeHtml(label)}</label>
|
|
134
|
+
<input id="onboard-${field.key}" data-onboard-field="${field.key}" class="input-sm"
|
|
135
|
+
type="${field.secret ? 'password' : 'text'}"
|
|
136
|
+
placeholder="${escapeHtml(field.example)}"
|
|
137
|
+
autocomplete="off" spellcheck="false"
|
|
138
|
+
value="${escapeHtml(state.draft[field.key] || '')}">
|
|
139
|
+
<span class="onboarding-hint">${escapeHtml(t(`onboarding.hint.${state.channel}.${field.key}`))}</span>
|
|
140
|
+
</div>`;
|
|
141
|
+
}).join('');
|
|
142
|
+
}
|
|
143
|
+
if (state.step === 3) {
|
|
144
|
+
const status = validating
|
|
145
|
+
? `<p class="onboarding-guide">${escapeHtml(t('onboarding.validating'))}</p>`
|
|
146
|
+
: state.validatedIdentity
|
|
147
|
+
? `<p class="onboarding-identity">${escapeHtml(t('onboarding.valid', { identity: state.validatedIdentity }))}</p>`
|
|
148
|
+
: `<p class="onboarding-guide">${escapeHtml(t('onboarding.validateHint'))}</p>`;
|
|
149
|
+
return `${status}
|
|
150
|
+
<div class="onboarding-actions">
|
|
151
|
+
<button type="button" class="perm-btn" data-onboard-validate="1" ${validating ? 'disabled' : ''}>
|
|
152
|
+
${escapeHtml(t('onboarding.validate'))}
|
|
153
|
+
</button>
|
|
154
|
+
</div>`;
|
|
155
|
+
}
|
|
156
|
+
return `<p class="onboarding-next">${escapeHtml(t(
|
|
157
|
+
state.saved ? `onboarding.next.${state.channel}` : 'onboarding.saveHint',
|
|
158
|
+
))}</p>`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function footer(state: FlowState): string {
|
|
162
|
+
if (state.step === TOTAL_STEPS && state.saved) {
|
|
163
|
+
return `<button type="button" class="perm-btn active" data-onboard-close="1">${escapeHtml(t('onboarding.close'))}</button>`;
|
|
164
|
+
}
|
|
165
|
+
const back = state.step > 1
|
|
166
|
+
? `<button type="button" class="perm-btn" data-onboard-back="1">${escapeHtml(t('onboarding.back'))}</button>`
|
|
167
|
+
: '';
|
|
168
|
+
const primary = state.step === TOTAL_STEPS
|
|
169
|
+
? `<button type="button" class="perm-btn active" data-onboard-save="1">${escapeHtml(t('onboarding.save'))}</button>`
|
|
170
|
+
: `<button type="button" class="perm-btn ${canAdvance(state) ? 'active' : ''}" data-onboard-next="1">${escapeHtml(t('onboarding.next'))}</button>`;
|
|
171
|
+
return `${back}${primary}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function render(): void {
|
|
175
|
+
if (!overlay || !flow) return;
|
|
176
|
+
const state = flow;
|
|
177
|
+
overlay.innerHTML = `
|
|
178
|
+
<div class="modal-box onboarding-box" role="dialog" aria-modal="true"
|
|
179
|
+
aria-labelledby="onboarding-title" aria-describedby="onboarding-step-title">
|
|
180
|
+
<div class="onboarding-head">
|
|
181
|
+
<span class="onboarding-title" id="onboarding-title">${escapeHtml(t(`onboarding.title.${state.channel}`))}</span>
|
|
182
|
+
<span class="onboarding-progress" role="status" aria-live="polite"
|
|
183
|
+
aria-label="${escapeHtml(t('onboarding.progressLabel', { step: state.step, total: TOTAL_STEPS }))}">${state.step}/${TOTAL_STEPS}</span>
|
|
184
|
+
<button type="button" class="help-trigger onboarding-close" data-onboard-close="1"
|
|
185
|
+
aria-label="${escapeHtml(t('onboarding.close'))}">✕</button>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="onboarding-steps" aria-hidden="true">
|
|
188
|
+
${Array.from({ length: TOTAL_STEPS }, (_, i) =>
|
|
189
|
+
`<span class="onboarding-dot${i + 1 === state.step ? ' is-active' : ''}${i + 1 < state.step ? ' is-done' : ''}"></span>`).join('')}
|
|
190
|
+
</div>
|
|
191
|
+
<div class="onboarding-step-title" id="onboarding-step-title">${escapeHtml(t(`onboarding.step.${state.step}`))}</div>
|
|
192
|
+
${stepBody(state)}
|
|
193
|
+
<div class="onboarding-error" role="alert" style="display:${state.error ? '' : 'none'}">
|
|
194
|
+
${state.error ? escapeHtml(t(`onboarding.error.${state.error}`)) : ''}
|
|
195
|
+
${state.missingScopes.length ? `<span class="onboarding-scopes">${escapeHtml(state.missingScopes.join(', '))}</span>` : ''}
|
|
196
|
+
</div>
|
|
197
|
+
<div class="onboarding-actions onboarding-footer">${footer(state)}</div>
|
|
198
|
+
</div>`;
|
|
199
|
+
|
|
200
|
+
overlay.querySelectorAll('[data-onboard-close]').forEach(el => el.addEventListener('click', close));
|
|
201
|
+
overlay.querySelector('[data-onboard-issuer]')?.addEventListener('click', () => {
|
|
202
|
+
if (flow) window.open(ISSUER_URLS[flow.channel], '_blank', 'noopener');
|
|
203
|
+
});
|
|
204
|
+
overlay.querySelector('[data-onboard-back]')?.addEventListener('click', () => {
|
|
205
|
+
if (!flow) return;
|
|
206
|
+
flow = goBack(flow);
|
|
207
|
+
render();
|
|
208
|
+
});
|
|
209
|
+
overlay.querySelector('[data-onboard-next]')?.addEventListener('click', () => {
|
|
210
|
+
if (!flow) return;
|
|
211
|
+
captureInputs();
|
|
212
|
+
flow = advance(flow);
|
|
213
|
+
render();
|
|
214
|
+
});
|
|
215
|
+
overlay.querySelector('[data-onboard-validate]')?.addEventListener('click', () => { void runValidation(); });
|
|
216
|
+
overlay.querySelector('[data-onboard-save]')?.addEventListener('click', () => { void runSave(); });
|
|
217
|
+
// Live capture keeps the draft authoritative even if the user closes the
|
|
218
|
+
// step without pressing 다음.
|
|
219
|
+
overlay.querySelectorAll('[data-onboard-field]').forEach(el => {
|
|
220
|
+
el.addEventListener('input', () => {
|
|
221
|
+
if (!flow) return;
|
|
222
|
+
const key = (el as HTMLElement).getAttribute('data-onboard-field') || '';
|
|
223
|
+
flow = setField(flow, key, (el as HTMLInputElement).value);
|
|
224
|
+
});
|
|
225
|
+
// Enter submits the step instead of doing nothing — the wizard is a
|
|
226
|
+
// form, and a form that ignores Enter reads as broken.
|
|
227
|
+
el.addEventListener('keydown', (ev) => {
|
|
228
|
+
const key = (ev as KeyboardEvent).key;
|
|
229
|
+
if (key !== 'Enter' || (ev as KeyboardEvent).shiftKey || (ev as KeyboardEvent).isComposing) return;
|
|
230
|
+
ev.preventDefault();
|
|
231
|
+
primaryAction();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
focusFirstEmptyField();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whatever the current step's primary button does — Enter mirrors it. */
|
|
239
|
+
function primaryAction(): void {
|
|
240
|
+
if (!flow) return;
|
|
241
|
+
if (flow.step === 3) { void runValidation(); return; }
|
|
242
|
+
if (flow.step === TOTAL_STEPS) { if (!flow.saved) void runSave(); return; }
|
|
243
|
+
captureInputs();
|
|
244
|
+
flow = advance(flow);
|
|
245
|
+
render();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* render() replaces innerHTML, so focus is lost on every state change. Restore
|
|
250
|
+
* it to the first field still needing input (or the first field at all), which
|
|
251
|
+
* is also what a freshly opened step-2 should focus.
|
|
252
|
+
*/
|
|
253
|
+
function focusFirstEmptyField(): void {
|
|
254
|
+
const inputs = [...(overlay?.querySelectorAll('[data-onboard-field]') ?? [])] as HTMLInputElement[];
|
|
255
|
+
if (!inputs.length) return;
|
|
256
|
+
const target = inputs.find(input => !input.value.trim()) ?? inputs[0];
|
|
257
|
+
target?.focus();
|
|
258
|
+
// Caret at the end so an existing value can be corrected, not overwritten.
|
|
259
|
+
const end = target?.value.length ?? 0;
|
|
260
|
+
target?.setSelectionRange?.(end, end);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function captureInputs(): void {
|
|
264
|
+
if (!flow) return;
|
|
265
|
+
for (const el of overlay?.querySelectorAll('[data-onboard-field]') ?? []) {
|
|
266
|
+
const key = el.getAttribute('data-onboard-field') || '';
|
|
267
|
+
flow = setField(flow, key, (el as HTMLInputElement).value);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function runValidation(): Promise<void> {
|
|
272
|
+
if (!flow || validating) return;
|
|
273
|
+
const generation = flowGeneration;
|
|
274
|
+
validating = true;
|
|
275
|
+
render();
|
|
276
|
+
const res = await api<{ ok?: boolean; identity?: string; teamId?: string; error?: string; missing?: string[] }>(
|
|
277
|
+
'/api/channels/validate',
|
|
278
|
+
{
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: { 'Content-Type': 'application/json' },
|
|
281
|
+
body: JSON.stringify(validationPayload(flow)),
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
// The wizard may have been closed or pointed at another channel while the
|
|
285
|
+
// request was in flight; applying this result then would verify the wrong
|
|
286
|
+
// credentials.
|
|
287
|
+
if (generation !== flowGeneration) return;
|
|
288
|
+
validating = false;
|
|
289
|
+
if (!flow) return;
|
|
290
|
+
flow = applyValidation(flow, res || { ok: false, error: 'network' });
|
|
291
|
+
render();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function runSave(): Promise<void> {
|
|
295
|
+
if (!flow || saving) return;
|
|
296
|
+
const blocker = blockerForStep({ ...flow, step: 3 });
|
|
297
|
+
if (blocker) {
|
|
298
|
+
flow = { ...flow, step: 3, error: blocker };
|
|
299
|
+
render();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
// Mirror into the settings section so the existing load/save surfaces and
|
|
303
|
+
// the transport status row stay consistent with the wizard.
|
|
304
|
+
for (const field of fieldsFor(flow.channel)) {
|
|
305
|
+
writeSettingsInput(field.settingsId, (flow.draft[field.key] || '').trim());
|
|
306
|
+
}
|
|
307
|
+
const generation = flowGeneration;
|
|
308
|
+
saving = true;
|
|
309
|
+
try {
|
|
310
|
+
await apiJson('/api/settings', 'PUT', settingsPatch(flow));
|
|
311
|
+
} finally {
|
|
312
|
+
saving = false;
|
|
313
|
+
}
|
|
314
|
+
if (generation !== flowGeneration || !flow) return;
|
|
315
|
+
flow = markSaved(flow);
|
|
316
|
+
render();
|
|
317
|
+
void refreshTransportStatusRow();
|
|
318
|
+
// The save click is a user gesture, which is the only moment a browser
|
|
319
|
+
// will honour a notification request — and the only moment it is relevant,
|
|
320
|
+
// since a channel just started delivering messages.
|
|
321
|
+
void maybeRequestNotificationPermission();
|
|
322
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// ── Channel Setup Guide Popup ──
|
|
2
|
+
// When a user points cli-jaw at a channel that has no credentials, open the
|
|
3
|
+
// existing help dialog's setup guide instead of leaving a silently dead
|
|
4
|
+
// channel. Fires only on explicit user intent: switching the active channel
|
|
5
|
+
// or enabling a channel toggle — never on page load, so a settings visit
|
|
6
|
+
// does not pop a dialog the user did not ask for.
|
|
7
|
+
import { openChannelOnboarding } from './channel-onboarding.js';
|
|
8
|
+
import { isTelegramConfigured, isDiscordConfigured, isSlackConfigured } from './channel-setup-rules.js';
|
|
9
|
+
|
|
10
|
+
type SetupChannel = 'telegram' | 'discord' | 'slack';
|
|
11
|
+
|
|
12
|
+
function inputValue(id: string): string {
|
|
13
|
+
return (document.getElementById(id) as HTMLInputElement | null)?.value ?? '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Open the channel's setup guide when its required credentials are missing. */
|
|
17
|
+
export function openSetupGuideIfUnconfigured(ch: SetupChannel): void {
|
|
18
|
+
const configured =
|
|
19
|
+
ch === 'telegram' ? isTelegramConfigured(inputValue('tgToken')) :
|
|
20
|
+
ch === 'discord' ? isDiscordConfigured(inputValue('dcToken'), inputValue('dcGuildId')) :
|
|
21
|
+
isSlackConfigured(inputValue('slBotToken'));
|
|
22
|
+
// The wizard supersedes the read-only help dialog here: it carries the
|
|
23
|
+
// same guidance AND lets the user finish setup (validate + save) inside
|
|
24
|
+
// the popup. The "?" help trigger remains for deep reference.
|
|
25
|
+
if (!configured) openChannelOnboarding(ch);
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ── Channel "configured?" rules (data-only) ──
|
|
2
|
+
// Kept import-free and DOM-free so unit tests can pull them without the
|
|
3
|
+
// help-dialog module graph — same separation as help-content vs help-dialog.
|
|
4
|
+
//
|
|
5
|
+
// The rules mirror doctor's hard failures, NOT its warnings: telegram needs
|
|
6
|
+
// a token; discord needs token + guildId (doctor additionally hard-fails on
|
|
7
|
+
// missing channelIds, but the popup targets zero-credentials, not partial
|
|
8
|
+
// config — guild+token is enough to receive); slack needs only botToken —
|
|
9
|
+
// a missing appToken is outbound-only, a legitimate state the doctor WARNs
|
|
10
|
+
// about rather than fails.
|
|
11
|
+
|
|
12
|
+
export function isTelegramConfigured(token: string): boolean {
|
|
13
|
+
return token.trim().length > 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isDiscordConfigured(token: string, guildId: string): boolean {
|
|
17
|
+
return token.trim().length > 0 && guildId.trim().length > 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isSlackConfigured(botToken: string): boolean {
|
|
21
|
+
return botToken.trim().length > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Token-shape hints for inline field validation. Prefixes are how Slack
|
|
25
|
+
// namespaces its tokens; a wrong prefix is almost always a swapped paste.
|
|
26
|
+
export function hasSlackBotTokenPrefix(token: string): boolean {
|
|
27
|
+
return token.trim().startsWith('xoxb-');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hasSlackAppTokenPrefix(token: string): boolean {
|
|
31
|
+
return token.trim().startsWith('xapp-');
|
|
32
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// ── Browser notification permission ──
|
|
2
|
+
// Asked ONCE, and only from a real user gesture. Browsers reject unprompted
|
|
3
|
+
// requests, and a permission prompt on page load is the fastest way to earn a
|
|
4
|
+
// permanent "block" — so the ask rides the wizard's save step, where the user
|
|
5
|
+
// just connected a channel and inbound messages are about to arrive.
|
|
6
|
+
|
|
7
|
+
const ASKED_KEY = 'jaw:notificationsAsked';
|
|
8
|
+
|
|
9
|
+
export function notificationsAsked(): boolean {
|
|
10
|
+
try {
|
|
11
|
+
return localStorage.getItem(ASKED_KEY) === '1';
|
|
12
|
+
} catch {
|
|
13
|
+
// Private mode / disabled storage: treat as asked so a blocked write
|
|
14
|
+
// cannot turn into a prompt on every save.
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function markAsked(): void {
|
|
20
|
+
try { localStorage.setItem(ASKED_KEY, '1'); } catch { /* best-effort */ }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Request notification permission if it has never been asked in this browser.
|
|
25
|
+
* Returns the resulting permission, or null when nothing was asked.
|
|
26
|
+
* A denial is remembered too: never nag.
|
|
27
|
+
*/
|
|
28
|
+
export async function maybeRequestNotificationPermission(): Promise<NotificationPermission | null> {
|
|
29
|
+
if (typeof Notification === 'undefined') return null;
|
|
30
|
+
if (Notification.permission !== 'default') return Notification.permission;
|
|
31
|
+
if (notificationsAsked()) return null;
|
|
32
|
+
markAsked();
|
|
33
|
+
try {
|
|
34
|
+
return await Notification.requestPermission();
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -5,6 +5,7 @@ import { getCliMeta } from '../constants.js';
|
|
|
5
5
|
import { providerLabel } from '../provider-icons.js';
|
|
6
6
|
import { t } from './i18n.js';
|
|
7
7
|
import { refreshTransportStatusRow } from './transport-status-row.js';
|
|
8
|
+
import { openSetupGuideIfUnconfigured } from './channel-setup-guide.js';
|
|
8
9
|
import type { SettingsData } from './settings-types.js';
|
|
9
10
|
|
|
10
11
|
export async function setActiveChannel(ch: 'telegram' | 'discord' | 'slack'): Promise<void> {
|
|
@@ -16,6 +17,9 @@ export async function setActiveChannel(ch: 'telegram' | 'discord' | 'slack'): Pr
|
|
|
16
17
|
document.getElementById('channelSlackSettings')?.style.setProperty('display', ch === 'slack' ? '' : 'none');
|
|
17
18
|
await apiJson('/api/settings', 'PUT', { channel: ch });
|
|
18
19
|
await refreshTransportStatusRow();
|
|
20
|
+
// Switching to a channel with no credentials leaves a silently dead
|
|
21
|
+
// channel — open its setup guide right away.
|
|
22
|
+
openSetupGuideIfUnconfigured(ch);
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export function loadActiveChannel(s: SettingsData): void {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// ── Settings Core ──
|
|
2
2
|
import { MODEL_MAP, loadCliRegistry, getCliKeys, getCliMeta, PRIMARY_CLIS } from '../constants.js';
|
|
3
|
+
import type { CliEntry } from '../constants.js';
|
|
3
4
|
import { escapeHtml } from '../render.js';
|
|
4
5
|
import { syncStoredLocale } from '../locale.js';
|
|
5
6
|
import { t } from './i18n.js';
|
|
@@ -190,6 +191,41 @@ function normalizeModelForDisplay(_cli: string, model: string): string {
|
|
|
190
191
|
return (model || '').trim();
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Effort choices for one model.
|
|
196
|
+
*
|
|
197
|
+
* A live opencodex catalog advertises a different effort set per model
|
|
198
|
+
* (`gpt-5.6-sol` reaches `ultra`, `gpt-5.6-luna` stops at `max`, routed models
|
|
199
|
+
* take none), and the chosen value is forwarded to the wire, so the per-model
|
|
200
|
+
* set wins over the provider/registry lists. An entry that EXISTS but is empty
|
|
201
|
+
* means "no effort for this model" and must not fall back.
|
|
202
|
+
*/
|
|
203
|
+
function resolveEffortChoices(
|
|
204
|
+
meta: CliEntry | null,
|
|
205
|
+
model: string,
|
|
206
|
+
providerEfforts: string[] | null,
|
|
207
|
+
provider?: string,
|
|
208
|
+
): string[] {
|
|
209
|
+
const key = (model || '').trim();
|
|
210
|
+
// Provider-scoped map wins for provider-split runtimes (ai-e): the same model
|
|
211
|
+
// id can allow different efforts per provider. A missing model key falls back
|
|
212
|
+
// to the provider list rather than inventing an empty "no effort" set.
|
|
213
|
+
if (provider) {
|
|
214
|
+
const scoped = meta?.effortsByModelByProvider?.[provider]?.[key];
|
|
215
|
+
if (scoped) return scoped;
|
|
216
|
+
}
|
|
217
|
+
const byModel = meta?.effortsByModel?.[key];
|
|
218
|
+
if (byModel) return byModel;
|
|
219
|
+
return providerEfforts || meta?.efforts || [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveDefaultEffort(meta: CliEntry | null, model: string, provider?: string): string {
|
|
223
|
+
const key = (model || '').trim();
|
|
224
|
+
return (provider ? meta?.defaultEffortByModelByProvider?.[provider]?.[key] : undefined)
|
|
225
|
+
?? meta?.defaultEffortByModel?.[key]
|
|
226
|
+
?? '';
|
|
227
|
+
}
|
|
228
|
+
|
|
193
229
|
function syncPerCliModelAndEffortControls(settings: SettingsData | null = null): void {
|
|
194
230
|
for (const cli of getCliKeys()) {
|
|
195
231
|
const meta = getCliMeta(cli);
|
|
@@ -224,8 +260,15 @@ function syncPerCliModelAndEffortControls(settings: SettingsData | null = null):
|
|
|
224
260
|
const providerEfforts = cliProvider
|
|
225
261
|
? (meta?.effortsByProvider?.[cliProvider] || [])
|
|
226
262
|
: null;
|
|
227
|
-
const
|
|
228
|
-
const
|
|
263
|
+
const selectedModel = normalizeModelForDisplay(cli, settings?.perCli?.[cli]?.model || getModelSelect(cli)?.value || '');
|
|
264
|
+
const effortsList = resolveEffortChoices(meta, selectedModel, providerEfforts, cliProvider || undefined);
|
|
265
|
+
const options = [''].concat(effortsList);
|
|
266
|
+
const saved = settings?.perCli?.[cli]?.effort ?? effortSel.value ?? '';
|
|
267
|
+
// Drop a saved effort the current model does not support — it would
|
|
268
|
+
// otherwise reach the wire as `-c model_reasoning_effort=<bad>`.
|
|
269
|
+
const selected = !saved || effortsList.includes(saved)
|
|
270
|
+
? saved
|
|
271
|
+
: resolveDefaultEffort(meta, selectedModel, cliProvider || undefined);
|
|
229
272
|
const unique = [...new Set(options)];
|
|
230
273
|
const noneLabel = (unique.length === 1 && !unique[0] && meta?.effortNote) ? meta.effortNote : '— none';
|
|
231
274
|
effortSel.innerHTML = unique.map(v => {
|
|
@@ -233,13 +276,18 @@ function syncPerCliModelAndEffortControls(settings: SettingsData | null = null):
|
|
|
233
276
|
return `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`;
|
|
234
277
|
}).join('');
|
|
235
278
|
if (meta?.effortNote) effortSel.title = meta.effortNote;
|
|
236
|
-
|
|
279
|
+
// A model with an explicitly EMPTY effort set takes no effort at all.
|
|
280
|
+
effortSel.disabled = (unique.length === 1 && !unique[0]);
|
|
237
281
|
if (Array.from(effortSel.options).some(o => o.value === selected)) effortSel.value = selected;
|
|
238
282
|
}
|
|
239
283
|
}
|
|
240
284
|
}
|
|
241
285
|
|
|
242
|
-
function
|
|
286
|
+
function getActiveEffortValue(): string {
|
|
287
|
+
return (document.getElementById('selEffort') as HTMLSelectElement | null)?.value || '';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function syncActiveEffortOptions(cli: string, selected = '', model?: string): void {
|
|
243
291
|
const selEffort = document.getElementById('selEffort') as HTMLSelectElement | null;
|
|
244
292
|
if (!selEffort) return;
|
|
245
293
|
const meta = getCliMeta(cli);
|
|
@@ -247,10 +295,15 @@ function syncActiveEffortOptions(cli: string, selected = ''): void {
|
|
|
247
295
|
const providerEfforts = cliProvider
|
|
248
296
|
? (meta?.effortsByProvider?.[cliProvider] || [])
|
|
249
297
|
: null;
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
298
|
+
const activeModel = normalizeModelForDisplay(
|
|
299
|
+
cli,
|
|
300
|
+
model ?? (document.getElementById('selModel') as HTMLSelectElement | null)?.value ?? '',
|
|
301
|
+
);
|
|
302
|
+
const effortsList = resolveEffortChoices(meta, activeModel, providerEfforts, cliProvider || undefined);
|
|
303
|
+
if (effortsList.length === 0) {
|
|
304
|
+
const note = meta?.effortNote || '— none';
|
|
305
|
+
selEffort.innerHTML = `<option value="">${escapeHtml(note)}</option>`;
|
|
306
|
+
selEffort.title = note;
|
|
254
307
|
selEffort.disabled = true;
|
|
255
308
|
return;
|
|
256
309
|
}
|
|
@@ -262,7 +315,12 @@ function syncActiveEffortOptions(cli: string, selected = ''): void {
|
|
|
262
315
|
}).join('');
|
|
263
316
|
selEffort.disabled = false;
|
|
264
317
|
selEffort.title = meta?.effortNote || '';
|
|
265
|
-
|
|
318
|
+
// Coerce a saved effort the active model does not support, so the value that
|
|
319
|
+
// reaches `-c model_reasoning_effort=` is always one the model advertises.
|
|
320
|
+
const resolved = !selected || effortsList.includes(selected)
|
|
321
|
+
? selected
|
|
322
|
+
: resolveDefaultEffort(meta, activeModel, cliProvider || undefined);
|
|
323
|
+
if (Array.from(selEffort.options).some(o => o.value === resolved)) selEffort.value = resolved;
|
|
266
324
|
}
|
|
267
325
|
|
|
268
326
|
function syncAiEProviderOptions(select: HTMLSelectElement | null, current: string, providers: string[]): string {
|
|
@@ -543,6 +601,9 @@ export function onCliChange(save = true): void {
|
|
|
543
601
|
appendCustomOption(modelSel, val);
|
|
544
602
|
modelSel.value = val;
|
|
545
603
|
(this as HTMLInputElement).style.display = 'none';
|
|
604
|
+
// A custom model has no advertised effort set; re-resolve so the picker
|
|
605
|
+
// falls back to the registry list instead of keeping the old model's.
|
|
606
|
+
syncActiveEffortOptions(cli, getActiveEffortValue(), val);
|
|
546
607
|
saveActiveCliSettings();
|
|
547
608
|
};
|
|
548
609
|
if (!modelSel) { if (save) updateSettings(); return; }
|
|
@@ -553,6 +614,9 @@ export function onCliChange(save = true): void {
|
|
|
553
614
|
inp.focus();
|
|
554
615
|
} else {
|
|
555
616
|
inp.style.display = 'none';
|
|
617
|
+
// Efforts are per-model on a live opencodex catalog, so the picker
|
|
618
|
+
// must follow the model rather than keep the previous model's set.
|
|
619
|
+
syncActiveEffortOptions(cli, getActiveEffortValue(), (this as HTMLSelectElement).value);
|
|
556
620
|
saveActiveCliSettings();
|
|
557
621
|
}
|
|
558
622
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// ── Discord Settings ──
|
|
2
2
|
import { apiJson } from '../api.js';
|
|
3
|
+
import { openSetupGuideIfUnconfigured } from './channel-setup-guide.js';
|
|
3
4
|
import type { SettingsData } from './settings-types.js';
|
|
4
5
|
|
|
5
6
|
export async function saveDiscordSettings(): Promise<void> {
|
|
@@ -16,6 +17,7 @@ export async function setDiscord(enabled: boolean): Promise<void> {
|
|
|
16
17
|
document.getElementById('dcOn')?.classList.toggle('active', enabled);
|
|
17
18
|
document.getElementById('dcOff')?.classList.toggle('active', !enabled);
|
|
18
19
|
await apiJson('/api/settings', 'PUT', { discord: { enabled } });
|
|
20
|
+
if (enabled) openSetupGuideIfUnconfigured('discord');
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export async function setDiscordForwardAll(enabled: boolean): Promise<void> {
|