acdev 1.0.5 → 1.0.6

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/public/app.js CHANGED
@@ -127,10 +127,14 @@ let reviewExcludedByJob = {};
127
127
  let settingsSaving = false;
128
128
  /** @type {'ticket' | 'auth' | 'rules' | 'config'} */
129
129
  let settingsTab = 'ticket';
130
+ /** Sentinel: no model selected (jobs blocked until user picks one). */
131
+ const NO_MODEL = '-';
130
132
  /** @type {string} */
131
133
  let currentModel = 'claude-sonnet-5';
132
134
  /** @type {Array<{id:string,label?:string,name?:string}>} */
133
135
  let availableModels = [];
136
+ /** @type {'claude' | 'openrouter' | null} */
137
+ let availableModelsProvider = null;
134
138
  /** @type {'github' | 'jira'} */
135
139
  let ticketSource = 'github';
136
140
  /** @type {{
@@ -163,6 +167,12 @@ let ticketSource = 'github';
163
167
  * anthropicApiKeyMasked?: string | null,
164
168
  * claudeOauthTokenSet?: boolean,
165
169
  * claudeOauthTokenMasked?: string | null,
170
+ * llmProvider?: 'claude' | 'openrouter',
171
+ * lastModelsByProvider?: Record<string, string>,
172
+ * openrouterAuthOk?: boolean,
173
+ * openrouterApiKeySet?: boolean,
174
+ * openrouterApiKeyMasked?: string | null,
175
+ * llmAuthOk?: boolean,
166
176
  * stubAgent?: boolean,
167
177
  * }} */
168
178
  let appConfig = {};
@@ -174,18 +184,33 @@ const els = {
174
184
  repoName: document.getElementById('repo-name'),
175
185
  repoBranch: document.getElementById('repo-branch'),
176
186
  ticketSourceMeta: document.getElementById('ticket-source-meta'),
187
+ modelProvider: document.getElementById('model-provider'),
177
188
  modelCurrent: document.getElementById('model-current'),
189
+ modelCombined: document.getElementById('model-combined'),
190
+ overviewLlmProvider: document.getElementById('overview-llm-provider'),
191
+ overviewModel: document.getElementById('overview-model'),
192
+ overviewAgentPicker: document.getElementById('overview-agent-picker'),
193
+ overviewAgentTrigger: document.getElementById('overview-agent-trigger'),
194
+ overviewAgentValue: document.getElementById('overview-agent-value'),
195
+ overviewAgentIcon: document.getElementById('overview-agent-icon'),
196
+ overviewAgentPanel: document.getElementById('overview-agent-panel'),
197
+ overviewAgentSearch: document.getElementById('overview-agent-search'),
198
+ overviewAgentModelList: document.getElementById('overview-agent-model-list'),
199
+ overviewLlmFeedback: document.getElementById('overview-llm-feedback'),
178
200
  navBadgeOverview: document.getElementById('nav-badge-overview'),
179
201
  navBadgeRuns: document.getElementById('nav-badge-runs'),
180
202
  navBadgeReview: document.getElementById('nav-badge-review'),
181
203
  navBadgeAlerts: document.getElementById('nav-badge-alerts'),
182
- authBanner: document.getElementById('auth-banner'),
183
- authBannerText: document.getElementById('auth-banner-text'),
184
- authBannerBtn: document.getElementById('auth-banner-btn'),
185
204
  issueUrls: document.getElementById('issue-urls'),
186
205
  issueUrlsLabel: document.getElementById('issue-urls-label'),
206
+ enqueueQueuedCount: document.getElementById('enqueue-queued-count'),
207
+ enqueueInputHint: document.getElementById('enqueue-input-hint'),
208
+ enqueueHintExample: document.getElementById('enqueue-hint-example'),
209
+ enqueueInfoText: document.getElementById('enqueue-info-text'),
210
+ enqueueJiraHint: document.getElementById('enqueue-jira-hint'),
211
+ enqueueJiraSettingsLink: document.getElementById('enqueue-jira-settings-link'),
212
+ overviewTicketSource: document.getElementById('overview-ticket-source'),
187
213
  enqueueFeedback: document.getElementById('enqueue-feedback'),
188
- enqueueHint: document.getElementById('enqueue-hint'),
189
214
  addBtn: document.getElementById('add-btn'),
190
215
  statsGrid: document.getElementById('stats-grid'),
191
216
  overviewRunning: document.getElementById('overview-running'),
@@ -241,7 +266,15 @@ const els = {
241
266
  settingsForm: document.getElementById('settings-form'),
242
267
  settingsBaseBranch: document.getElementById('settings-base-branch'),
243
268
  settingsModel: document.getElementById('settings-model'),
269
+ settingsModelPicker: document.getElementById('settings-model-picker'),
270
+ settingsModelTrigger: document.getElementById('settings-model-trigger'),
271
+ settingsModelValue: document.getElementById('settings-model-value'),
272
+ settingsModelPanel: document.getElementById('settings-model-panel'),
273
+ settingsModelList: document.getElementById('settings-model-list'),
274
+ settingsModelSearch: document.getElementById('settings-model-search'),
244
275
  settingsModelHint: document.getElementById('settings-model-hint'),
276
+ settingsLlmProvider: document.getElementById('settings-llm-provider'),
277
+ settingsLlmProviderHint: document.getElementById('settings-llm-provider-hint'),
245
278
  settingsMaxTurns: document.getElementById('settings-max-turns'),
246
279
  settingsTimeout: document.getElementById('settings-timeout'),
247
280
  settingsTimeoutMs: document.getElementById('settings-timeout-ms'),
@@ -282,6 +315,10 @@ const els = {
282
315
  settingsClaudeOauth: document.getElementById('settings-claude-oauth'),
283
316
  settingsClaudeOauthClear: document.getElementById('settings-claude-oauth-clear'),
284
317
  settingsClaudeOauthHint: document.getElementById('settings-claude-oauth-hint'),
318
+ settingsOpenrouterStatus: document.getElementById('settings-openrouter-status'),
319
+ settingsOpenrouterKey: document.getElementById('settings-openrouter-key'),
320
+ settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
321
+ settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
285
322
  settingsTabs: document.getElementById('settings-tabs'),
286
323
  };
287
324
 
@@ -1911,6 +1948,16 @@ function updateNavBadges(jobs) {
1911
1948
  setNavBadge(els.navBadgeRuns, running);
1912
1949
  setNavBadge(els.navBadgeReview, review);
1913
1950
  setNavBadge(els.navBadgeAlerts, alerts);
1951
+ updateEnqueueQueuedCount(queued);
1952
+ }
1953
+
1954
+ /**
1955
+ * @param {number} count
1956
+ */
1957
+ function updateEnqueueQueuedCount(count) {
1958
+ if (!els.enqueueQueuedCount) return;
1959
+ const n = Number.isFinite(count) && count >= 0 ? count : 0;
1960
+ els.enqueueQueuedCount.textContent = `${n} queued`;
1914
1961
  }
1915
1962
 
1916
1963
  function collectAlerts(jobs) {
@@ -2928,93 +2975,834 @@ async function readJson(res) {
2928
2975
  }
2929
2976
  }
2930
2977
 
2978
+ function llmProviderLabel(provider) {
2979
+ return provider === 'openrouter' ? 'OpenRouter' : 'Claude';
2980
+ }
2981
+
2982
+ /**
2983
+ * @param {'claude' | 'openrouter'} [provider]
2984
+ * @param {string} [modelId]
2985
+ * @returns {string}
2986
+ */
2987
+ function agentDisplayText(provider = currentLlmProviderSelection(), modelId = currentModel) {
2988
+ const providerName = llmProviderLabel(provider);
2989
+ const modelText = isNoModelSelection(modelId) ? '—' : modelPickerDisplayText(modelId);
2990
+ return `${providerName} • ${modelText}`;
2991
+ }
2992
+
2931
2993
  function updateModelLabel() {
2932
- if (!els.modelCurrent) return;
2933
- const id = currentModel || '—';
2934
- els.modelCurrent.textContent = id;
2935
- els.modelCurrent.title = `Model: ${id} — change in Settings → Configuration`;
2994
+ const provider = appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
2995
+ const providerName = llmProviderLabel(provider);
2996
+ const modelText = isNoModelSelection(currentModel) ? '—' : currentModel;
2997
+ const modelTitle = isNoModelSelection(currentModel)
2998
+ ? 'none selected'
2999
+ : currentModel;
3000
+ const combined = agentDisplayText(provider, currentModel);
3001
+
3002
+ if (els.modelProvider) {
3003
+ els.modelProvider.textContent = providerName;
3004
+ els.modelProvider.title = `${providerName} — switch in Overview or Settings`;
3005
+ }
3006
+ if (els.modelCurrent) {
3007
+ els.modelCurrent.textContent = modelText;
3008
+ els.modelCurrent.title = `Model: ${modelTitle} — switch in Overview or Settings`;
3009
+ }
3010
+ if (els.modelCombined) {
3011
+ els.modelCombined.textContent = combined;
3012
+ els.modelCombined.title = combined;
3013
+ }
3014
+ syncAgentPickerDisplay();
3015
+ }
3016
+
3017
+ /**
3018
+ * @param {'claude' | 'openrouter'} [provider]
3019
+ * @returns {Array<{id:string,label?:string,name?:string}>}
3020
+ */
3021
+ function getAvailableModels(provider = currentLlmProviderSelection()) {
3022
+ if (availableModelsProvider === provider) return availableModels;
3023
+ return [];
3024
+ }
3025
+
3026
+ /**
3027
+ * @param {'claude' | 'openrouter'} provider
3028
+ * @param {Array<{id:string,label?:string,name?:string}>} models
3029
+ */
3030
+ function setAvailableModels(provider, models) {
3031
+ availableModelsProvider = provider;
3032
+ availableModels = models;
3033
+ }
3034
+
3035
+ function clearAvailableModels() {
3036
+ availableModels = [];
3037
+ availableModelsProvider = null;
3038
+ }
3039
+
3040
+ /**
3041
+ * @param {string | undefined | null} value
3042
+ * @returns {boolean}
3043
+ */
3044
+ function isNoModelSelection(value) {
3045
+ const s = String(value ?? '').trim();
3046
+ return s === '' || s === NO_MODEL;
3047
+ }
3048
+
3049
+ /**
3050
+ * @param {string} [selectedId]
3051
+ * @returns {string}
3052
+ */
3053
+ function modelPickerDisplayText(selectedId = currentModel) {
3054
+ if (isNoModelSelection(selectedId)) return '—';
3055
+ const models = getAvailableModels();
3056
+ const hit = models.find((m) => m.id === selectedId);
3057
+ return hit ? modelPickerLabel(hit) : selectedId;
3058
+ }
3059
+
3060
+ /**
3061
+ * @typedef {Object} ModelComboboxInstance
3062
+ * @property {HTMLElement} root
3063
+ * @property {HTMLButtonElement} trigger
3064
+ * @property {HTMLElement} panel
3065
+ * @property {HTMLInputElement} search
3066
+ * @property {HTMLElement} list
3067
+ * @property {HTMLInputElement | null} [hiddenInput]
3068
+ * @property {HTMLElement} valueEl
3069
+ * @property {string} optionIdPrefix
3070
+ * @property {boolean} open
3071
+ * @property {number} activeIndex
3072
+ * @property {(modelId: string) => void} onSelect
3073
+ */
3074
+
3075
+ /** @type {ModelComboboxInstance[]} */
3076
+ const modelComboboxes = [];
3077
+
3078
+ /**
3079
+ * @param {HTMLElement} panel
3080
+ * @param {boolean} open
3081
+ */
3082
+ function setOverlayPanelOpen(panel, open) {
3083
+ panel.classList.toggle('is-open', open);
3084
+ panel.setAttribute('aria-hidden', open ? 'false' : 'true');
3085
+ }
3086
+
3087
+ /**
3088
+ * @param {HTMLInputElement} input
3089
+ */
3090
+ function focusWithoutScroll(input) {
3091
+ try {
3092
+ input.focus({ preventScroll: true });
3093
+ } catch {
3094
+ input.focus();
3095
+ }
3096
+ }
3097
+
3098
+ /**
3099
+ * @param {{
3100
+ * root: HTMLElement,
3101
+ * trigger: HTMLButtonElement,
3102
+ * panel: HTMLElement,
3103
+ * search: HTMLInputElement,
3104
+ * list: HTMLElement,
3105
+ * hiddenInput?: HTMLInputElement | null,
3106
+ * valueEl: HTMLElement,
3107
+ * optionIdPrefix?: string,
3108
+ * onSelect: (modelId: string) => void,
3109
+ * }} opts
3110
+ * @returns {ModelComboboxInstance}
3111
+ */
3112
+ function initModelCombobox(opts) {
3113
+ /** @type {ModelComboboxInstance} */
3114
+ const instance = {
3115
+ root: opts.root,
3116
+ trigger: opts.trigger,
3117
+ panel: opts.panel,
3118
+ search: opts.search,
3119
+ list: opts.list,
3120
+ hiddenInput: opts.hiddenInput ?? null,
3121
+ valueEl: opts.valueEl,
3122
+ optionIdPrefix: opts.optionIdPrefix || 'model-opt',
3123
+ open: false,
3124
+ activeIndex: -1,
3125
+ onSelect: opts.onSelect,
3126
+ };
3127
+ modelComboboxes.push(instance);
3128
+
3129
+ instance.trigger.addEventListener('click', () => {
3130
+ setModelComboboxOpen(instance, !instance.open);
3131
+ });
3132
+
3133
+ instance.search.addEventListener('input', () => {
3134
+ instance.activeIndex = -1;
3135
+ renderModelComboboxList(instance, currentModel);
3136
+ });
3137
+
3138
+ instance.search.addEventListener('keydown', (ev) => {
3139
+ if (ev.key === 'Escape') {
3140
+ ev.preventDefault();
3141
+ setModelComboboxOpen(instance, false);
3142
+ instance.trigger.focus();
3143
+ return;
3144
+ }
3145
+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
3146
+ ev.preventDefault();
3147
+ const options = [
3148
+ ...(instance.list.querySelectorAll('.model-picker-option') || []),
3149
+ ];
3150
+ if (!options.length) return;
3151
+ if (ev.key === 'ArrowDown') {
3152
+ instance.activeIndex =
3153
+ instance.activeIndex < options.length - 1 ? instance.activeIndex + 1 : 0;
3154
+ } else {
3155
+ instance.activeIndex =
3156
+ instance.activeIndex > 0 ? instance.activeIndex - 1 : options.length - 1;
3157
+ }
3158
+ renderModelComboboxList(instance, currentModel);
3159
+ options[instance.activeIndex]?.scrollIntoView({ block: 'nearest' });
3160
+ }
3161
+ if (ev.key === 'Enter' && instance.activeIndex >= 0) {
3162
+ ev.preventDefault();
3163
+ const options = [
3164
+ ...(instance.list.querySelectorAll('.model-picker-option') || []),
3165
+ ];
3166
+ const active = options[instance.activeIndex];
3167
+ if (active?.dataset.modelId) {
3168
+ instance.onSelect(active.dataset.modelId);
3169
+ setModelComboboxOpen(instance, false);
3170
+ }
3171
+ }
3172
+ });
3173
+
3174
+ return instance;
3175
+ }
3176
+
3177
+ /**
3178
+ * @param {ModelComboboxInstance} instance
3179
+ * @param {string} [selectedId]
3180
+ */
3181
+ function syncModelComboboxValue(instance, selectedId = currentModel) {
3182
+ if (instance.hiddenInput) {
3183
+ instance.hiddenInput.value = isNoModelSelection(selectedId) ? NO_MODEL : selectedId;
3184
+ }
3185
+ const text = modelPickerDisplayText(selectedId);
3186
+ instance.valueEl.textContent = text;
3187
+ instance.valueEl.classList.toggle('is-empty', isNoModelSelection(selectedId));
3188
+ }
3189
+
3190
+ function syncAllModelComboboxValues(selectedId = currentModel) {
3191
+ for (const cb of modelComboboxes) {
3192
+ syncModelComboboxValue(cb, selectedId);
3193
+ }
3194
+ syncAgentPickerDisplay(selectedId);
3195
+ }
3196
+
3197
+ /**
3198
+ * @param {ModelComboboxInstance} instance
3199
+ * @param {boolean} open
3200
+ */
3201
+ function setModelComboboxOpen(instance, open) {
3202
+ if (open) {
3203
+ for (const cb of modelComboboxes) {
3204
+ if (cb !== instance) setModelComboboxOpen(cb, false);
3205
+ }
3206
+ for (const ap of agentPickers) {
3207
+ if (ap.open) setAgentPickerOpen(ap, false);
3208
+ }
3209
+ }
3210
+
3211
+ instance.open = open;
3212
+ instance.trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
3213
+ setOverlayPanelOpen(instance.panel, open);
3214
+
3215
+ if (open) {
3216
+ instance.activeIndex = -1;
3217
+ instance.search.value = '';
3218
+ renderModelComboboxList(instance, currentModel);
3219
+ focusWithoutScroll(instance.search);
3220
+ } else {
3221
+ instance.search.value = '';
3222
+ renderModelComboboxList(instance, currentModel);
3223
+ }
3224
+ }
3225
+
3226
+ /**
3227
+ * @param {ModelComboboxInstance} instance
3228
+ * @returns {string}
3229
+ */
3230
+ function getModelComboboxSearchQuery(instance) {
3231
+ return instance.search?.value?.trim() || '';
3232
+ }
3233
+
3234
+ /**
3235
+ * @param {ModelComboboxInstance} instance
3236
+ * @param {Array<{id:string,label?:string,name?:string}>} allModels
3237
+ * @param {string} [selectedId]
3238
+ * @returns {Array<{id:string,label?:string,name?:string}>}
3239
+ */
3240
+ function modelsForCombobox(instance, allModels, selectedId) {
3241
+ const query = getModelComboboxSearchQuery(instance);
3242
+ return query
3243
+ ? allModels.filter((m) => modelMatchesSearchQuery(m, query))
3244
+ : [...allModels];
3245
+ }
3246
+
3247
+ /**
3248
+ * @param {ModelComboboxInstance} instance
3249
+ * @param {string} [selectedId]
3250
+ */
3251
+ function renderModelComboboxList(instance, selectedId = currentModel) {
3252
+ const models = modelsForCombobox(instance, getAvailableModels(), selectedId);
3253
+ const query = getModelComboboxSearchQuery(instance);
3254
+
3255
+ instance.list.innerHTML = '';
3256
+ if (!models.length) {
3257
+ const empty = document.createElement('li');
3258
+ empty.className = 'model-picker-empty';
3259
+ empty.textContent = query ? 'No models match your search' : 'No models available';
3260
+ empty.setAttribute('role', 'presentation');
3261
+ instance.list.appendChild(empty);
3262
+ return;
3263
+ }
3264
+
3265
+ models.forEach((m, index) => {
3266
+ const li = document.createElement('li');
3267
+ li.setAttribute('role', 'presentation');
3268
+ const btn = document.createElement('button');
3269
+ btn.type = 'button';
3270
+ btn.className = 'model-picker-option';
3271
+ btn.setAttribute('role', 'option');
3272
+ btn.dataset.modelId = m.id;
3273
+ btn.id = `${instance.optionIdPrefix}-${index}`;
3274
+ if (m.id === selectedId) {
3275
+ btn.classList.add('is-selected');
3276
+ btn.setAttribute('aria-selected', 'true');
3277
+ } else {
3278
+ btn.setAttribute('aria-selected', 'false');
3279
+ }
3280
+ if (index === instance.activeIndex) {
3281
+ btn.classList.add('is-active');
3282
+ }
3283
+
3284
+ const title = document.createElement('span');
3285
+ title.textContent = modelPickerLabel(m);
3286
+ btn.appendChild(title);
3287
+
3288
+ if (m.id !== modelPickerLabel(m)) {
3289
+ const sub = document.createElement('span');
3290
+ sub.className = 'model-picker-option-id';
3291
+ sub.textContent = m.id;
3292
+ btn.appendChild(sub);
3293
+ }
3294
+
3295
+ btn.addEventListener('click', () => {
3296
+ instance.onSelect(m.id);
3297
+ setModelComboboxOpen(instance, false);
3298
+ });
3299
+ li.appendChild(btn);
3300
+ instance.list.appendChild(li);
3301
+ });
3302
+ }
3303
+
3304
+ function renderAllModelComboboxLists(selectedId = currentModel) {
3305
+ for (const cb of modelComboboxes) {
3306
+ renderModelComboboxList(cb, selectedId);
3307
+ }
3308
+ for (const ap of agentPickers) {
3309
+ renderAgentPickerModelList(ap, selectedId);
3310
+ }
3311
+ }
3312
+
3313
+ /**
3314
+ * @typedef {Object} AgentPickerInstance
3315
+ * @property {HTMLElement} root
3316
+ * @property {HTMLButtonElement} trigger
3317
+ * @property {HTMLElement} panel
3318
+ * @property {HTMLInputElement} search
3319
+ * @property {HTMLElement} list
3320
+ * @property {HTMLElement} valueEl
3321
+ * @property {HTMLElement | null} iconEl
3322
+ * @property {HTMLElement[]} tabs
3323
+ * @property {HTMLInputElement | null} hiddenInput
3324
+ * @property {string} optionIdPrefix
3325
+ * @property {boolean} open
3326
+ * @property {number} activeIndex
3327
+ * @property {(modelId: string) => void} onSelect
3328
+ */
3329
+
3330
+ /** @type {AgentPickerInstance[]} */
3331
+ const agentPickers = [];
3332
+
3333
+ const PROVIDER_UNAVAILABLE_TOOLTIP =
3334
+ 'Not configured — add credentials in Settings → Authentication';
3335
+
3336
+ /**
3337
+ * @param {'claude' | 'openrouter'} provider
3338
+ * @param {typeof appConfig} [cfg]
3339
+ */
3340
+ function isLlmProviderAvailable(provider, cfg = appConfig) {
3341
+ if (cfg.stubAgent) return true;
3342
+ if (provider === 'openrouter') return cfg.openrouterAuthOk === true;
3343
+ return cfg.claudeAuthOk === true;
2936
3344
  }
2937
3345
 
2938
3346
  /**
2939
- * @param {'anthropic' | 'fallback' | string | undefined} source
3347
+ * Disable provider tabs and select options when auth is missing.
3348
+ * @param {typeof appConfig} [cfg]
2940
3349
  */
2941
- function updateModelSourceHint(source) {
3350
+ function syncLlmProviderAvailability(cfg = appConfig) {
3351
+ for (const ap of agentPickers) {
3352
+ for (const tab of ap.tabs) {
3353
+ const provider =
3354
+ tab.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
3355
+ const available = isLlmProviderAvailable(provider, cfg);
3356
+ tab.disabled = !available;
3357
+ tab.classList.toggle('is-disabled', !available);
3358
+ tab.setAttribute('aria-disabled', available ? 'false' : 'true');
3359
+ if (available) {
3360
+ tab.removeAttribute('title');
3361
+ } else {
3362
+ tab.title = PROVIDER_UNAVAILABLE_TOOLTIP;
3363
+ }
3364
+ }
3365
+ }
3366
+
3367
+ for (const select of [els.settingsLlmProvider, els.overviewLlmProvider]) {
3368
+ if (!select) continue;
3369
+ for (const opt of select.options) {
3370
+ const provider = opt.value === 'openrouter' ? 'openrouter' : 'claude';
3371
+ const available = isLlmProviderAvailable(provider, cfg);
3372
+ opt.disabled = !available;
3373
+ if (available) {
3374
+ opt.removeAttribute('title');
3375
+ } else {
3376
+ opt.title = PROVIDER_UNAVAILABLE_TOOLTIP;
3377
+ }
3378
+ }
3379
+ }
3380
+ }
3381
+
3382
+ /**
3383
+ * @param {'claude' | 'openrouter'} provider
3384
+ */
3385
+ function syncAgentPickerTabs(provider) {
3386
+ const value = provider === 'openrouter' ? 'openrouter' : 'claude';
3387
+ for (const ap of agentPickers) {
3388
+ for (const tab of ap.tabs) {
3389
+ const active = tab.dataset.provider === value;
3390
+ tab.classList.toggle('is-active', active);
3391
+ tab.setAttribute('aria-selected', active ? 'true' : 'false');
3392
+ }
3393
+ if (ap.iconEl) {
3394
+ ap.iconEl.classList.toggle('agent-pill-icon--claude', value === 'claude');
3395
+ ap.iconEl.classList.toggle('agent-pill-icon--openrouter', value === 'openrouter');
3396
+ }
3397
+ }
3398
+ }
3399
+
3400
+ function syncAgentPickerDisplay(selectedId = currentModel) {
3401
+ const provider = currentLlmProviderSelection();
3402
+ const text = agentDisplayText(provider, selectedId);
3403
+ for (const ap of agentPickers) {
3404
+ ap.valueEl.textContent = text;
3405
+ ap.valueEl.classList.toggle('is-empty', isNoModelSelection(selectedId));
3406
+ if (ap.hiddenInput) {
3407
+ ap.hiddenInput.value = isNoModelSelection(selectedId) ? NO_MODEL : selectedId;
3408
+ }
3409
+ }
3410
+ syncAgentPickerTabs(provider);
3411
+ }
3412
+
3413
+ /**
3414
+ * @param {AgentPickerInstance} instance
3415
+ * @param {boolean} open
3416
+ */
3417
+ function setAgentPickerOpen(instance, open) {
3418
+ if (open) {
3419
+ for (const ap of agentPickers) {
3420
+ if (ap !== instance) setAgentPickerOpen(ap, false);
3421
+ }
3422
+ for (const cb of modelComboboxes) {
3423
+ if (cb.open) setModelComboboxOpen(cb, false);
3424
+ }
3425
+ }
3426
+
3427
+ instance.open = open;
3428
+ instance.trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
3429
+ setOverlayPanelOpen(instance.panel, open);
3430
+
3431
+ if (open) {
3432
+ instance.activeIndex = -1;
3433
+ instance.search.value = '';
3434
+ syncAgentPickerTabs(currentLlmProviderSelection());
3435
+ renderAgentPickerModelList(instance, currentModel);
3436
+ focusWithoutScroll(instance.search);
3437
+ } else {
3438
+ instance.search.value = '';
3439
+ renderAgentPickerModelList(instance, currentModel);
3440
+ }
3441
+ }
3442
+
3443
+ /**
3444
+ * @param {AgentPickerInstance} instance
3445
+ * @param {string} [selectedId]
3446
+ */
3447
+ function renderAgentPickerModelList(instance, selectedId = currentModel) {
3448
+ const models = modelsForCombobox(instance, getAvailableModels(), selectedId);
3449
+ const query = getModelComboboxSearchQuery(instance);
3450
+
3451
+ instance.list.innerHTML = '';
3452
+ if (!models.length) {
3453
+ const empty = document.createElement('li');
3454
+ empty.className = 'model-picker-empty';
3455
+ empty.textContent = query ? 'No models match your search' : 'No models available';
3456
+ empty.setAttribute('role', 'presentation');
3457
+ instance.list.appendChild(empty);
3458
+ return;
3459
+ }
3460
+
3461
+ models.forEach((m, index) => {
3462
+ const li = document.createElement('li');
3463
+ li.setAttribute('role', 'presentation');
3464
+ const btn = document.createElement('button');
3465
+ btn.type = 'button';
3466
+ btn.className = 'model-picker-option';
3467
+ btn.setAttribute('role', 'option');
3468
+ btn.dataset.modelId = m.id;
3469
+ btn.id = `${instance.optionIdPrefix}-${index}`;
3470
+ if (m.id === selectedId) {
3471
+ btn.classList.add('is-selected');
3472
+ btn.setAttribute('aria-selected', 'true');
3473
+ } else {
3474
+ btn.setAttribute('aria-selected', 'false');
3475
+ }
3476
+ if (index === instance.activeIndex) {
3477
+ btn.classList.add('is-active');
3478
+ }
3479
+
3480
+ const title = document.createElement('span');
3481
+ title.textContent = modelPickerLabel(m);
3482
+ btn.appendChild(title);
3483
+
3484
+ if (m.id !== modelPickerLabel(m)) {
3485
+ const sub = document.createElement('span');
3486
+ sub.className = 'model-picker-option-id';
3487
+ sub.textContent = m.id;
3488
+ btn.appendChild(sub);
3489
+ }
3490
+
3491
+ btn.addEventListener('click', () => {
3492
+ instance.onSelect(m.id);
3493
+ setAgentPickerOpen(instance, false);
3494
+ });
3495
+ li.appendChild(btn);
3496
+ instance.list.appendChild(li);
3497
+ });
3498
+ }
3499
+
3500
+ /**
3501
+ * @param {{
3502
+ * root: HTMLElement,
3503
+ * trigger: HTMLButtonElement,
3504
+ * panel: HTMLElement,
3505
+ * search: HTMLInputElement,
3506
+ * list: HTMLElement,
3507
+ * valueEl: HTMLElement,
3508
+ * iconEl?: HTMLElement | null,
3509
+ * tabs?: HTMLElement[],
3510
+ * hiddenInput?: HTMLInputElement | null,
3511
+ * optionIdPrefix?: string,
3512
+ * onSelect: (modelId: string) => void,
3513
+ * }} opts
3514
+ * @returns {AgentPickerInstance}
3515
+ */
3516
+ function initAgentPicker(opts) {
3517
+ /** @type {AgentPickerInstance} */
3518
+ const instance = {
3519
+ root: opts.root,
3520
+ trigger: opts.trigger,
3521
+ panel: opts.panel,
3522
+ search: opts.search,
3523
+ list: opts.list,
3524
+ valueEl: opts.valueEl,
3525
+ iconEl: opts.iconEl ?? null,
3526
+ tabs: opts.tabs || [],
3527
+ hiddenInput: opts.hiddenInput ?? null,
3528
+ optionIdPrefix: opts.optionIdPrefix || 'agent-model-opt',
3529
+ open: false,
3530
+ activeIndex: -1,
3531
+ onSelect: opts.onSelect,
3532
+ };
3533
+ agentPickers.push(instance);
3534
+
3535
+ instance.trigger.addEventListener('click', () => {
3536
+ setAgentPickerOpen(instance, !instance.open);
3537
+ });
3538
+
3539
+ instance.search.addEventListener('input', () => {
3540
+ instance.activeIndex = -1;
3541
+ renderAgentPickerModelList(instance, currentModel);
3542
+ });
3543
+
3544
+ instance.search.addEventListener('keydown', (ev) => {
3545
+ if (ev.key === 'Escape') {
3546
+ ev.preventDefault();
3547
+ setAgentPickerOpen(instance, false);
3548
+ instance.trigger.focus();
3549
+ return;
3550
+ }
3551
+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
3552
+ ev.preventDefault();
3553
+ const options = [
3554
+ ...(instance.list.querySelectorAll('.model-picker-option') || []),
3555
+ ];
3556
+ if (!options.length) return;
3557
+ if (ev.key === 'ArrowDown') {
3558
+ instance.activeIndex =
3559
+ instance.activeIndex < options.length - 1 ? instance.activeIndex + 1 : 0;
3560
+ } else {
3561
+ instance.activeIndex =
3562
+ instance.activeIndex > 0 ? instance.activeIndex - 1 : options.length - 1;
3563
+ }
3564
+ renderAgentPickerModelList(instance, currentModel);
3565
+ options[instance.activeIndex]?.scrollIntoView({ block: 'nearest' });
3566
+ }
3567
+ if (ev.key === 'Enter' && instance.activeIndex >= 0) {
3568
+ ev.preventDefault();
3569
+ const options = [
3570
+ ...(instance.list.querySelectorAll('.model-picker-option') || []),
3571
+ ];
3572
+ const active = options[instance.activeIndex];
3573
+ if (active?.dataset.modelId) {
3574
+ instance.onSelect(active.dataset.modelId);
3575
+ setAgentPickerOpen(instance, false);
3576
+ }
3577
+ }
3578
+ });
3579
+
3580
+ for (const tab of instance.tabs) {
3581
+ tab.addEventListener('click', () => {
3582
+ if (tab.disabled) return;
3583
+ const provider =
3584
+ tab.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
3585
+ if (!isLlmProviderAvailable(provider)) return;
3586
+ if (provider === currentLlmProviderSelection()) return;
3587
+ syncLlmProviderSelects(provider);
3588
+ syncAgentPickerTabs(provider);
3589
+ void onLlmProviderChange(provider);
3590
+ });
3591
+ }
3592
+
3593
+ return instance;
3594
+ }
3595
+
3596
+ /** @deprecated use syncModelComboboxValue */
3597
+ function syncModelPickerValue(selectedId = currentModel) {
3598
+ syncAllModelComboboxValues(selectedId);
3599
+ }
3600
+
3601
+ /**
3602
+ * Reconcile current model against a provider's model list.
3603
+ * @param {Array<{id:string}>} models
3604
+ * @param {string} [candidate]
3605
+ * @param {string} [storedCandidate] last valid model for this provider
3606
+ * @returns {string}
3607
+ */
3608
+ function reconcileModelForProvider(models, candidate = currentModel, storedCandidate) {
3609
+ if (
3610
+ !isNoModelSelection(candidate) &&
3611
+ models.some((m) => m.id === candidate)
3612
+ ) {
3613
+ return candidate;
3614
+ }
3615
+ if (
3616
+ storedCandidate &&
3617
+ !isNoModelSelection(storedCandidate) &&
3618
+ models.some((m) => m.id === storedCandidate)
3619
+ ) {
3620
+ return storedCandidate;
3621
+ }
3622
+ return NO_MODEL;
3623
+ }
3624
+
3625
+ /**
3626
+ * @param {'claude' | 'openrouter'} provider
3627
+ * @returns {string | undefined}
3628
+ */
3629
+ function lastModelForProvider(provider) {
3630
+ const stored = appConfig.lastModelsByProvider?.[provider];
3631
+ return typeof stored === 'string' && !isNoModelSelection(stored) ? stored : undefined;
3632
+ }
3633
+
3634
+ /**
3635
+ * @returns {Array<{id:string,label?:string,name?:string}>}
3636
+ */
3637
+ function defaultModelsForProvider(provider = 'claude') {
3638
+ if (provider === 'openrouter') {
3639
+ return [
3640
+ { id: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (Anthropic)' },
3641
+ { id: 'anthropic/claude-opus-4', label: 'Claude Opus 4 (Anthropic)' },
3642
+ { id: 'openai/gpt-4o', label: 'GPT-4o (OpenAI)' },
3643
+ { id: 'x-ai/grok-2', label: 'Grok 2 (xAI)' },
3644
+ ];
3645
+ }
3646
+ return [
3647
+ { id: 'claude-sonnet-5', label: 'Sonnet 5' },
3648
+ { id: 'claude-opus-5', label: 'Opus 5' },
3649
+ { id: 'claude-fable-5', label: 'Fable 5' },
3650
+ { id: 'claude-haiku-4-5', label: 'Haiku 4.5' },
3651
+ { id: 'sonnet', label: 'sonnet (latest)' },
3652
+ { id: 'opus', label: 'opus (latest)' },
3653
+ ];
3654
+ }
3655
+
3656
+ /**
3657
+ * @param {'anthropic' | 'openrouter' | 'fallback' | string | undefined} source
3658
+ * @param {'claude' | 'openrouter'} [provider]
3659
+ */
3660
+ function updateModelSourceHint(source, provider = 'claude') {
2942
3661
  if (!els.settingsModelHint) return;
3662
+ const name = provider === 'openrouter' ? 'OpenRouter' : 'Claude';
2943
3663
  if (source === 'anthropic') {
2944
3664
  els.settingsModelHint.textContent =
2945
- 'Claude model for agent runs. List loaded from Anthropic Models API.';
2946
- } else if (source === 'fallback') {
3665
+ `${name} model for agent runs. List loaded from Anthropic Models API.`;
3666
+ } else if (source === 'openrouter') {
2947
3667
  els.settingsModelHint.textContent =
2948
- 'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
3668
+ 'OpenRouter model for agent runs. List loaded from OpenRouter Models API.';
3669
+ } else if (source === 'fallback') {
3670
+ if (provider === 'openrouter') {
3671
+ els.settingsModelHint.textContent =
3672
+ 'OpenRouter model for agent runs. Showing curated models (live list unavailable — set OPENROUTER_API_KEY in Authentication).';
3673
+ } else {
3674
+ els.settingsModelHint.textContent =
3675
+ 'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
3676
+ }
2949
3677
  } else {
2950
- els.settingsModelHint.textContent = 'Claude model for agent runs.';
3678
+ els.settingsModelHint.textContent = `${name} model for agent runs.`;
2951
3679
  }
2952
3680
  }
2953
3681
 
2954
3682
  /**
2955
- * @param {{ models?: Array<{id:string,label?:string,name?:string}>, selected?: string, source?: string }} data
3683
+ * @param {{ id: string, label?: string, name?: string }} model
3684
+ * @returns {string}
3685
+ */
3686
+ function modelPickerLabel(model) {
3687
+ return model.label || model.name || model.id;
3688
+ }
3689
+
3690
+ /**
3691
+ * @param {{ id: string, label?: string, name?: string }} model
3692
+ * @param {string} query
3693
+ * @returns {boolean}
3694
+ */
3695
+ function modelMatchesSearchQuery(model, query) {
3696
+ if (!query) return true;
3697
+ const q = query.toLowerCase();
3698
+ return [model.id, model.label, model.name]
3699
+ .filter(Boolean)
3700
+ .some((part) => String(part).toLowerCase().includes(q));
3701
+ }
3702
+
3703
+ /**
3704
+ * @returns {'claude' | 'openrouter'}
3705
+ */
3706
+ function currentLlmProviderSelection() {
3707
+ const fromOverview = els.overviewLlmProvider?.value;
3708
+ if (fromOverview === 'openrouter' || fromOverview === 'claude') return fromOverview;
3709
+ const fromForm = els.settingsLlmProvider?.value;
3710
+ if (fromForm === 'openrouter' || fromForm === 'claude') return fromForm;
3711
+ return appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
3712
+ }
3713
+
3714
+ /**
3715
+ * @param {'claude' | 'openrouter'} provider
3716
+ */
3717
+ function syncLlmProviderSelects(provider) {
3718
+ const value = provider === 'openrouter' ? 'openrouter' : 'claude';
3719
+ if (els.settingsLlmProvider) els.settingsLlmProvider.value = value;
3720
+ if (els.overviewLlmProvider) els.overviewLlmProvider.value = value;
3721
+ syncAgentPickerTabs(value);
3722
+ if (els.settingsLlmProviderHint) {
3723
+ els.settingsLlmProviderHint.textContent =
3724
+ value === 'openrouter'
3725
+ ? 'Agent runs route through OpenRouter (requires OPENROUTER_API_KEY in Authentication).'
3726
+ : 'Agent runs use Claude via Anthropic API, OAuth token, or claude auth login.';
3727
+ }
3728
+ }
3729
+
3730
+ /**
3731
+ * @param {{ models?: Array<{id:string,label?:string,name?:string}>, selected?: string, source?: string, provider?: string }} data
2956
3732
  */
2957
3733
  function populateSettingsModels(data) {
2958
- const models = Array.isArray(data?.models) && data.models.length
3734
+ const provider = data?.provider || currentLlmProviderSelection();
3735
+ const cached = getAvailableModels(provider);
3736
+ const models = Array.isArray(data?.models)
2959
3737
  ? data.models
2960
- : availableModels.length
2961
- ? availableModels
2962
- : [
2963
- { id: 'claude-sonnet-5', label: 'Sonnet 5' },
2964
- { id: 'claude-opus-5', label: 'Opus 5' },
2965
- { id: 'claude-fable-5', label: 'Fable 5' },
2966
- { id: 'claude-haiku-4-5', label: 'Haiku 4.5' },
2967
- ];
2968
- availableModels = models;
2969
- if (data?.selected) currentModel = data.selected;
2970
- if (data?.source) updateModelSourceHint(data.source);
2971
-
2972
- if (els.settingsModel) {
2973
- const currentIds = [...els.settingsModel.options].map((o) => o.value);
2974
- const nextIds = models.map((m) => m.id);
2975
- if (currentIds.join(',') !== nextIds.join(',')) {
2976
- els.settingsModel.innerHTML = '';
2977
- for (const m of models) {
2978
- const opt = document.createElement('option');
2979
- opt.value = m.id;
2980
- opt.textContent = m.label || m.name || m.id;
2981
- els.settingsModel.appendChild(opt);
2982
- }
2983
- }
2984
- const selected = currentModel;
2985
- if (selected && [...els.settingsModel.options].some((o) => o.value === selected)) {
2986
- els.settingsModel.value = selected;
2987
- }
3738
+ : data?.reconcileProvider
3739
+ ? []
3740
+ : cached.length
3741
+ ? cached
3742
+ : defaultModelsForProvider(provider);
3743
+ setAvailableModels(provider, models);
3744
+
3745
+ if (data?.reconcileProvider) {
3746
+ currentModel = reconcileModelForProvider(
3747
+ models,
3748
+ data.reconcileCandidate,
3749
+ lastModelForProvider(provider)
3750
+ );
3751
+ } else if (data?.selected != null) {
3752
+ const next = isNoModelSelection(data.selected) ? NO_MODEL : data.selected;
3753
+ currentModel = reconcileModelForProvider(models, next, lastModelForProvider(provider));
2988
3754
  }
3755
+ if (data?.source) updateModelSourceHint(data.source, provider);
3756
+
3757
+ syncAllModelComboboxValues(currentModel);
3758
+ renderAllModelComboboxLists(currentModel);
2989
3759
  updateModelLabel();
2990
3760
  }
2991
3761
 
3762
+ function modelsForFetchFallback(provider, reconcileProvider) {
3763
+ if (reconcileProvider) return [];
3764
+ const cached = getAvailableModels(provider);
3765
+ if (cached.length) return cached;
3766
+ return defaultModelsForProvider(provider);
3767
+ }
3768
+
2992
3769
  async function fetchModels(opts = {}) {
2993
3770
  const refresh = opts.refresh === true;
3771
+ const provider = opts.provider || currentLlmProviderSelection();
3772
+ const reconcileProvider = opts.reconcileProvider === true;
3773
+ const reconcileCandidate = opts.reconcileCandidate;
3774
+ if (reconcileProvider) {
3775
+ clearAvailableModels();
3776
+ }
3777
+ const query = new URLSearchParams({ provider });
3778
+ if (refresh) query.set('refresh', '1');
2994
3779
  try {
2995
- const res = await fetch(refresh ? '/api/models?refresh=1' : '/api/models');
3780
+ const res = await fetch(`/api/models?${query.toString()}`);
2996
3781
  if (!res.ok) {
2997
3782
  populateSettingsModels({
2998
- models: availableModels.length
2999
- ? availableModels
3000
- : [
3001
- { id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
3002
- ],
3783
+ models: modelsForFetchFallback(provider, reconcileProvider),
3003
3784
  selected: currentModel,
3785
+ provider,
3786
+ reconcileProvider,
3787
+ reconcileCandidate,
3004
3788
  });
3005
3789
  return;
3006
3790
  }
3007
3791
  const data = await readJson(res);
3008
- populateSettingsModels(data);
3792
+ populateSettingsModels({
3793
+ ...data,
3794
+ provider: data.provider || provider,
3795
+ reconcileProvider,
3796
+ reconcileCandidate,
3797
+ });
3009
3798
  } catch (err) {
3010
3799
  console.error('Failed to fetch models:', err);
3011
3800
  populateSettingsModels({
3012
- models: availableModels.length
3013
- ? availableModels
3014
- : [
3015
- { id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
3016
- ],
3801
+ models: modelsForFetchFallback(provider, reconcileProvider),
3017
3802
  selected: currentModel,
3803
+ provider,
3804
+ reconcileProvider,
3805
+ reconcileCandidate,
3018
3806
  });
3019
3807
  }
3020
3808
  }
@@ -3133,6 +3921,13 @@ function claudeStatusText(cfg) {
3133
3921
  }
3134
3922
  }
3135
3923
 
3924
+ function openrouterStatusText(cfg) {
3925
+ if (cfg.openrouterAuthOk) {
3926
+ return 'Authenticated via OPENROUTER_API_KEY';
3927
+ }
3928
+ return 'Not authenticated — add an OpenRouter API key below';
3929
+ }
3930
+
3136
3931
  function fillAuthSettings(cfg) {
3137
3932
  if (els.settingsGhStatus) {
3138
3933
  els.settingsGhStatus.textContent = ghStatusText(cfg);
@@ -3163,8 +3958,9 @@ function fillAuthSettings(cfg) {
3163
3958
 
3164
3959
  if (els.settingsClaudeStatus) {
3165
3960
  els.settingsClaudeStatus.textContent = claudeStatusText(cfg);
3166
- els.settingsClaudeStatus.className =
3167
- cfg.claudeAuthOk ? 'auth-status ok' : 'auth-status err';
3961
+ els.settingsClaudeStatus.className = cfg.claudeAuthOk
3962
+ ? 'auth-status ok'
3963
+ : 'auth-status err';
3168
3964
  }
3169
3965
  fillSecretInput(
3170
3966
  els.settingsAnthropicKey,
@@ -3190,6 +3986,25 @@ function fillAuthSettings(cfg) {
3190
3986
  ? 'OAuth token stored in <code>.acdev/.env</code> as <code>CLAUDE_CODE_OAUTH_TOKEN</code>. Settings cannot complete browser OAuth — that still needs <code>claude auth login</code> on this host.'
3191
3987
  : 'From <code>claude setup-token</code> for non-interactive subscription auth. Stored as <code>CLAUDE_CODE_OAUTH_TOKEN</code>. Settings cannot complete browser OAuth — that still needs <code>claude auth login</code> on this host.';
3192
3988
  }
3989
+
3990
+ if (els.settingsOpenrouterStatus) {
3991
+ els.settingsOpenrouterStatus.textContent = openrouterStatusText(cfg);
3992
+ els.settingsOpenrouterStatus.className = cfg.openrouterAuthOk
3993
+ ? 'auth-status ok'
3994
+ : 'auth-status err';
3995
+ }
3996
+ fillSecretInput(
3997
+ els.settingsOpenrouterKey,
3998
+ els.settingsOpenrouterKeyClear,
3999
+ Boolean(cfg.openrouterApiKeySet),
4000
+ cfg.openrouterApiKeyMasked,
4001
+ 'Paste an OpenRouter API key'
4002
+ );
4003
+ if (els.settingsOpenrouterKeyHint) {
4004
+ els.settingsOpenrouterKeyHint.innerHTML = cfg.openrouterApiKeySet
4005
+ ? 'API key stored in <code>.acdev/.env</code> as <code>OPENROUTER_API_KEY</code> (not committed).'
4006
+ : 'Required when LLM provider is OpenRouter. Get a key at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer">openrouter.ai/keys</a>. Stored as <code>OPENROUTER_API_KEY</code> in <code>.acdev/.env</code>.';
4007
+ }
3193
4008
  }
3194
4009
 
3195
4010
  function fillSettingsForm(cfg) {
@@ -3269,16 +4084,23 @@ function fillSettingsForm(cfg) {
3269
4084
  els.settingsBaseBranch.value = cfg.baseBranch || '';
3270
4085
  }
3271
4086
 
3272
- if (els.settingsModel) {
3273
- if (availableModels.length) {
3274
- populateSettingsModels({ models: availableModels, selected: cfg.model || currentModel });
3275
- } else if (cfg.model) {
4087
+ if (els.settingsLlmProvider) {
4088
+ syncLlmProviderSelects(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4089
+ }
4090
+
4091
+ if (els.settingsModelPicker) {
4092
+ const provider = cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
4093
+ if (getAvailableModels(provider).length) {
3276
4094
  populateSettingsModels({
3277
- models: [{ id: cfg.model, label: cfg.model }],
3278
- selected: cfg.model,
4095
+ models: getAvailableModels(provider),
4096
+ selected: cfg.model ?? currentModel,
4097
+ provider,
3279
4098
  });
4099
+ } else if (cfg.model != null) {
4100
+ currentModel = isNoModelSelection(cfg.model) ? NO_MODEL : cfg.model;
4101
+ syncModelPickerValue(currentModel);
3280
4102
  }
3281
- void fetchModels();
4103
+ void fetchModels({ provider });
3282
4104
  }
3283
4105
 
3284
4106
  if (els.settingsMaxTurns) {
@@ -3361,10 +4183,12 @@ function updateGithubRuleFieldsVisibility() {
3361
4183
  function updateTicketSourceUI(source) {
3362
4184
  ticketSource = source === 'jira' ? 'jira' : 'github';
3363
4185
 
3364
- if (els.settingsTicketSource) {
3365
- els.settingsTicketSource.querySelectorAll('.source-btn').forEach((btn) => {
4186
+ for (const toggle of [els.settingsTicketSource, els.overviewTicketSource]) {
4187
+ if (!toggle) continue;
4188
+ toggle.querySelectorAll('.source-btn').forEach((btn) => {
3366
4189
  const active = btn.dataset.source === ticketSource;
3367
4190
  btn.classList.toggle('active', active);
4191
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
3368
4192
  });
3369
4193
  }
3370
4194
 
@@ -3382,11 +4206,9 @@ function updateTicketSourceUI(source) {
3382
4206
 
3383
4207
  if (els.issueUrls) {
3384
4208
  if (ticketSource === 'jira') {
3385
- els.issueUrls.placeholder =
3386
- 'Paste Jira keys or browse URLs — one per line, or comma-separated\ne.g. PROJ-123 or https://your-domain.atlassian.net/browse/PROJ-123';
4209
+ els.issueUrls.placeholder = 'Paste Jira keys or browse URLs…';
3387
4210
  } else {
3388
- els.issueUrls.placeholder =
3389
- 'Paste GitHub issue links — one per line, or comma-separated\ne.g. https://github.com/acme/storefront/issues/482';
4211
+ els.issueUrls.placeholder = 'Paste GitHub issue links or URLs…';
3390
4212
  }
3391
4213
  }
3392
4214
 
@@ -3395,11 +4217,24 @@ function updateTicketSourceUI(source) {
3395
4217
  ticketSource === 'jira' ? 'Jira issue keys or URLs' : 'GitHub issue URLs';
3396
4218
  }
3397
4219
 
3398
- if (els.enqueueHint && !els.enqueueHint.classList.contains('hidden')) {
3399
- els.enqueueHint.textContent =
3400
- ticketSource === 'jira'
3401
- ? 'Jira issues run one at a time. PRs still open on GitHub via gh.'
3402
- : 'Issues run one at a time, each in its own workspace and branch.';
4220
+ if (els.enqueueHintExample) {
4221
+ els.enqueueHintExample.textContent =
4222
+ ticketSource === 'jira' ? 'PROJ-123' : 'owner/repo#42';
4223
+ }
4224
+
4225
+ if (els.enqueueInfoText) {
4226
+ if (ticketSource === 'jira') {
4227
+ els.enqueueInfoText.innerHTML =
4228
+ '<strong>Jira issues run one at a time.</strong> Open GitHub PRs are picked up automatically via gh.';
4229
+ } else {
4230
+ els.enqueueInfoText.innerHTML =
4231
+ '<strong>GitHub issues run one at a time.</strong> Open PRs are picked up automatically via gh.';
4232
+ }
4233
+ }
4234
+
4235
+ if (els.enqueueJiraHint) {
4236
+ const showJiraHint = ticketSource === 'jira' && !appConfig.jiraConfigured;
4237
+ els.enqueueJiraHint.classList.toggle('hidden', !showJiraHint);
3403
4238
  }
3404
4239
 
3405
4240
  if (els.overviewQueueEmpty) {
@@ -3410,6 +4245,27 @@ function updateTicketSourceUI(source) {
3410
4245
  }
3411
4246
  }
3412
4247
 
4248
+ /**
4249
+ * @param {'github' | 'jira'} next
4250
+ */
4251
+ async function saveTicketSource(next) {
4252
+ const source = next === 'jira' ? 'jira' : 'github';
4253
+ updateTicketSourceUI(source);
4254
+ try {
4255
+ const res = await fetch('/api/config', {
4256
+ method: 'PATCH',
4257
+ headers: { 'Content-Type': 'application/json' },
4258
+ body: JSON.stringify({ ticketSource: source }),
4259
+ });
4260
+ const data = await readJson(res);
4261
+ if (res.ok) {
4262
+ applyConfigSnapshot({ ...appConfig, ...data });
4263
+ }
4264
+ } catch {
4265
+ // local UI already updated; save via Settings if needed
4266
+ }
4267
+ }
4268
+
3413
4269
  function updateTimeoutMsHint(minutes) {
3414
4270
  if (!els.settingsTimeoutMs) return;
3415
4271
  const m = Number(minutes);
@@ -3420,10 +4276,94 @@ function updateTimeoutMsHint(minutes) {
3420
4276
  els.settingsTimeoutMs.textContent = String(Math.round(m * 60_000));
3421
4277
  }
3422
4278
 
4279
+ function setOverviewLlmFeedback(message, kind = 'ok') {
4280
+ if (!els.overviewLlmFeedback) return;
4281
+ if (!message) {
4282
+ els.overviewLlmFeedback.textContent = '';
4283
+ els.overviewLlmFeedback.className = 'feedback hidden';
4284
+ return;
4285
+ }
4286
+ els.overviewLlmFeedback.textContent = message;
4287
+ els.overviewLlmFeedback.className = `feedback ${kind}`;
4288
+ els.overviewLlmFeedback.classList.remove('hidden');
4289
+ }
4290
+
4291
+ /**
4292
+ * @param {typeof appConfig} cfg
4293
+ */
4294
+ function fillOverviewLlmControls(cfg) {
4295
+ if (!cfg) return;
4296
+ const provider = cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
4297
+ syncLlmProviderSelects(provider);
4298
+ syncAllModelComboboxValues(currentModel);
4299
+ }
4300
+
4301
+ let llmConfigSaving = false;
4302
+
4303
+ /**
4304
+ * PATCH llmProvider and/or model immediately (Overview switcher).
4305
+ * @param {{ llmProvider?: 'claude' | 'openrouter', model?: string }} patch
4306
+ */
4307
+ async function patchLlmConfig(patch) {
4308
+ if (llmConfigSaving) return;
4309
+ llmConfigSaving = true;
4310
+ setOverviewLlmFeedback(null);
4311
+ try {
4312
+ const res = await fetch('/api/config', {
4313
+ method: 'PATCH',
4314
+ headers: { 'Content-Type': 'application/json' },
4315
+ body: JSON.stringify(patch),
4316
+ });
4317
+ const data = await readJson(res);
4318
+ if (!res.ok) {
4319
+ setOverviewLlmFeedback(data.error || `Update failed (HTTP ${res.status})`, 'error');
4320
+ return;
4321
+ }
4322
+ applyConfigSnapshot(data);
4323
+ fillOverviewLlmControls(data);
4324
+ if (currentView === 'settings') {
4325
+ fillSettingsForm(data);
4326
+ }
4327
+ setOverviewLlmFeedback('LLM updated. Next jobs will use these values.', 'ok');
4328
+ window.setTimeout(() => setOverviewLlmFeedback(null), 2500);
4329
+ } catch (err) {
4330
+ setOverviewLlmFeedback(err.message || 'Update failed', 'error');
4331
+ } finally {
4332
+ llmConfigSaving = false;
4333
+ }
4334
+ }
4335
+
4336
+ /**
4337
+ * @param {'claude' | 'openrouter'} provider
4338
+ */
4339
+ async function onLlmProviderChange(provider) {
4340
+ const previousModel = currentModel;
4341
+ syncLlmProviderSelects(provider);
4342
+ clearAvailableModels();
4343
+ currentModel = NO_MODEL;
4344
+ syncAllModelComboboxValues(currentModel);
4345
+ renderAllModelComboboxLists(currentModel);
4346
+ updateModelLabel();
4347
+ await fetchModels({
4348
+ refresh: true,
4349
+ provider,
4350
+ reconcileProvider: true,
4351
+ reconcileCandidate: previousModel,
4352
+ });
4353
+ await patchLlmConfig({
4354
+ llmProvider: provider,
4355
+ model: currentModel,
4356
+ });
4357
+ }
4358
+
3423
4359
  function applyConfigSnapshot(data) {
3424
4360
  appConfig = data || {};
3425
- if (data?.model) currentModel = data.model;
4361
+ if (data?.model != null) {
4362
+ currentModel = isNoModelSelection(data.model) ? NO_MODEL : data.model;
4363
+ }
4364
+ syncAllModelComboboxValues(currentModel);
3426
4365
  updateModelLabel();
4366
+ fillOverviewLlmControls(data);
3427
4367
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
3428
4368
  if (els.repoName) {
3429
4369
  els.repoName.textContent = data?.repoName || 'local repo';
@@ -3431,33 +4371,12 @@ function applyConfigSnapshot(data) {
3431
4371
  if (els.repoBranch) {
3432
4372
  els.repoBranch.textContent = data?.baseBranch || '—';
3433
4373
  }
3434
- updateAuthBanner(appConfig);
4374
+ syncLlmProviderAvailability(appConfig);
3435
4375
  if (currentView === 'settings') {
3436
4376
  fillSettingsForm(appConfig);
3437
4377
  }
3438
4378
  }
3439
4379
 
3440
- /**
3441
- * Surface missing GitHub / Claude auth on Overview so users can open Settings.
3442
- * @param {typeof appConfig} cfg
3443
- */
3444
- function updateAuthBanner(cfg) {
3445
- if (!els.authBanner || !els.authBannerText) return;
3446
- const missing = [];
3447
- if (!cfg.ghAuthOk) missing.push('GitHub');
3448
- if (!cfg.claudeAuthOk && !cfg.stubAgent) missing.push('Claude');
3449
- if (missing.length === 0) {
3450
- els.authBanner.classList.add('hidden');
3451
- els.authBanner.hidden = true;
3452
- els.authBannerText.textContent = '';
3453
- return;
3454
- }
3455
- const label = missing.join(' and ');
3456
- els.authBannerText.textContent = `Configure ${label} in Settings → Authentication before starting jobs.`;
3457
- els.authBanner.classList.remove('hidden');
3458
- els.authBanner.hidden = false;
3459
- }
3460
-
3461
4380
  async function fetchConfig() {
3462
4381
  try {
3463
4382
  const res = await fetch('/api/config');
@@ -3471,7 +4390,8 @@ async function fetchConfig() {
3471
4390
 
3472
4391
  function readSettingsForm() {
3473
4392
  const baseBranch = els.settingsBaseBranch?.value?.trim() || '';
3474
- const model = els.settingsModel?.value || currentModel;
4393
+ const rawModel = els.settingsModel?.value ?? currentModel;
4394
+ const model = isNoModelSelection(rawModel) ? NO_MODEL : rawModel;
3475
4395
  const maxAgentTurns = Number(els.settingsMaxTurns?.value);
3476
4396
  const timeoutMinutes = Number(els.settingsTimeout?.value);
3477
4397
  const testRaw = els.settingsTestCommand?.value ?? '';
@@ -3485,6 +4405,7 @@ function readSettingsForm() {
3485
4405
  const patch = {
3486
4406
  baseBranch,
3487
4407
  model,
4408
+ llmProvider: els.settingsLlmProvider?.value === 'openrouter' ? 'openrouter' : 'claude',
3488
4409
  maxAgentTurns,
3489
4410
  agentTimeoutMs: Math.round(timeoutMinutes * 60_000),
3490
4411
  testCommand: testTrimmed === '' ? null : testTrimmed,
@@ -3498,6 +4419,8 @@ function readSettingsForm() {
3498
4419
  if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
3499
4420
  const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
3500
4421
  if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
4422
+ const openrouterApiKey = secretPatchValue(els.settingsOpenrouterKey);
4423
+ if (openrouterApiKey !== undefined) patch.openrouterApiKey = openrouterApiKey;
3501
4424
 
3502
4425
  if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
3503
4426
  patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
@@ -3587,11 +4510,6 @@ document.querySelectorAll('.nav-item').forEach((btn) => {
3587
4510
  btn.addEventListener('click', () => setView(/** @type {ViewId} */ (btn.dataset.view)));
3588
4511
  });
3589
4512
 
3590
- els.authBannerBtn?.addEventListener('click', () => {
3591
- setView('settings');
3592
- setSettingsTab('auth');
3593
- });
3594
-
3595
4513
  els.themeToggle.addEventListener('click', () => {
3596
4514
  const next = els.app.dataset.theme === 'dark' ? 'light' : 'dark';
3597
4515
  applyTheme(next);
@@ -3670,10 +4588,141 @@ bindSecretClear(
3670
4588
  els.settingsClaudeOauthHint,
3671
4589
  'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
3672
4590
  );
4591
+ bindSecretClear(
4592
+ els.settingsOpenrouterKeyClear,
4593
+ els.settingsOpenrouterKey,
4594
+ els.settingsOpenrouterKeyHint,
4595
+ 'Saved OPENROUTER_API_KEY will be removed when you click Save settings.'
4596
+ );
4597
+
4598
+
4599
+ document.addEventListener('click', (ev) => {
4600
+ const target = /** @type {Node | null} */ (ev.target);
4601
+ for (const cb of modelComboboxes) {
4602
+ if (!cb.open) continue;
4603
+ if (cb.root.contains(target)) continue;
4604
+ setModelComboboxOpen(cb, false);
4605
+ }
4606
+ for (const ap of agentPickers) {
4607
+ if (!ap.open) continue;
4608
+ if (ap.root.contains(target)) continue;
4609
+ setAgentPickerOpen(ap, false);
4610
+ }
4611
+ });
4612
+
4613
+ document.addEventListener('keydown', (ev) => {
4614
+ if (ev.key !== 'Escape') return;
4615
+ for (const ap of agentPickers) {
4616
+ if (!ap.open) continue;
4617
+ setAgentPickerOpen(ap, false);
4618
+ ap.trigger.focus();
4619
+ return;
4620
+ }
4621
+ for (const cb of modelComboboxes) {
4622
+ if (!cb.open) continue;
4623
+ setModelComboboxOpen(cb, false);
4624
+ cb.trigger.focus();
4625
+ break;
4626
+ }
4627
+ });
4628
+
4629
+ els.settingsLlmProvider?.addEventListener('change', () => {
4630
+ const provider = currentLlmProviderSelection();
4631
+ if (!isLlmProviderAvailable(provider)) {
4632
+ const fallback =
4633
+ appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
4634
+ syncLlmProviderSelects(fallback);
4635
+ return;
4636
+ }
4637
+ for (const cb of modelComboboxes) {
4638
+ setModelComboboxOpen(cb, false);
4639
+ }
4640
+ void onLlmProviderChange(provider);
4641
+ });
4642
+
4643
+ els.overviewLlmProvider?.addEventListener('change', () => {
4644
+ const provider =
4645
+ els.overviewLlmProvider.value === 'openrouter' ? 'openrouter' : 'claude';
4646
+ if (!isLlmProviderAvailable(provider)) {
4647
+ const fallback =
4648
+ appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
4649
+ syncLlmProviderSelects(fallback);
4650
+ return;
4651
+ }
4652
+ for (const cb of modelComboboxes) {
4653
+ setModelComboboxOpen(cb, false);
4654
+ }
4655
+ for (const ap of agentPickers) {
4656
+ setAgentPickerOpen(ap, false);
4657
+ }
4658
+ void onLlmProviderChange(provider);
4659
+ });
4660
+
4661
+ if (
4662
+ els.settingsModelPicker &&
4663
+ els.settingsModelTrigger &&
4664
+ els.settingsModelPanel &&
4665
+ els.settingsModelSearch &&
4666
+ els.settingsModelList &&
4667
+ els.settingsModelValue
4668
+ ) {
4669
+ initModelCombobox({
4670
+ root: els.settingsModelPicker,
4671
+ trigger: els.settingsModelTrigger,
4672
+ panel: els.settingsModelPanel,
4673
+ search: els.settingsModelSearch,
4674
+ list: els.settingsModelList,
4675
+ hiddenInput: els.settingsModel,
4676
+ valueEl: els.settingsModelValue,
4677
+ optionIdPrefix: 'settings-model-opt',
4678
+ onSelect: (modelId) => {
4679
+ currentModel = isNoModelSelection(modelId) ? NO_MODEL : modelId;
4680
+ syncAllModelComboboxValues(currentModel);
4681
+ renderAllModelComboboxLists(currentModel);
4682
+ updateModelLabel();
4683
+ },
4684
+ });
4685
+ }
4686
+
4687
+ if (
4688
+ els.overviewAgentPicker &&
4689
+ els.overviewAgentTrigger &&
4690
+ els.overviewAgentPanel &&
4691
+ els.overviewAgentSearch &&
4692
+ els.overviewAgentModelList &&
4693
+ els.overviewAgentValue
4694
+ ) {
4695
+ initAgentPicker({
4696
+ root: els.overviewAgentPicker,
4697
+ trigger: els.overviewAgentTrigger,
4698
+ panel: els.overviewAgentPanel,
4699
+ search: els.overviewAgentSearch,
4700
+ list: els.overviewAgentModelList,
4701
+ valueEl: els.overviewAgentValue,
4702
+ iconEl: els.overviewAgentIcon,
4703
+ tabs: [
4704
+ ...(els.overviewAgentPanel.querySelectorAll('.agent-picker-tab') || []),
4705
+ ],
4706
+ hiddenInput: els.overviewModel,
4707
+ optionIdPrefix: 'overview-agent-model-opt',
4708
+ onSelect: (modelId) => {
4709
+ currentModel = isNoModelSelection(modelId) ? NO_MODEL : modelId;
4710
+ syncAllModelComboboxValues(currentModel);
4711
+ renderAllModelComboboxLists(currentModel);
4712
+ updateModelLabel();
4713
+ void patchLlmConfig({ model: currentModel });
4714
+ },
4715
+ });
4716
+ }
4717
+
4718
+ els.issueUrls?.addEventListener('keydown', (ev) => {
4719
+ if (ev.key !== 'Enter' || ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) return;
4720
+ ev.preventDefault();
4721
+ els.addBtn?.click();
4722
+ });
3673
4723
 
3674
4724
  els.addBtn.addEventListener('click', async () => {
3675
4725
  els.enqueueFeedback.classList.add('hidden');
3676
- if (els.enqueueHint) els.enqueueHint.classList.remove('hidden');
3677
4726
  const text = els.issueUrls.value.trim();
3678
4727
  if (!text) return;
3679
4728
 
@@ -3687,7 +4736,6 @@ els.addBtn.addEventListener('click', async () => {
3687
4736
  });
3688
4737
  const data = await res.json();
3689
4738
  if (!res.ok) {
3690
- if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
3691
4739
  els.enqueueFeedback.textContent =
3692
4740
  data.error + (data.invalidUrls ? `: ${data.invalidUrls.join(', ')}` : '');
3693
4741
  els.enqueueFeedback.className = 'feedback error';
@@ -3701,7 +4749,6 @@ els.addBtn.addEventListener('click', async () => {
3701
4749
  parts.push(`Skipped duplicates: ${data.skipped.join(', ')}`);
3702
4750
  }
3703
4751
  if (parts.length) {
3704
- if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
3705
4752
  els.enqueueFeedback.textContent = parts.join(' · ');
3706
4753
  els.enqueueFeedback.className =
3707
4754
  'feedback ' + (data.skipped?.length ? 'warn' : 'ok');
@@ -3713,31 +4760,27 @@ els.addBtn.addEventListener('click', async () => {
3713
4760
  selectRun(data.jobs[0].id);
3714
4761
  }
3715
4762
  } catch (err) {
3716
- if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
3717
4763
  els.enqueueFeedback.textContent = err.message;
3718
4764
  els.enqueueFeedback.className = 'feedback error';
3719
4765
  els.enqueueFeedback.classList.remove('hidden');
3720
4766
  }
3721
4767
  });
3722
4768
 
3723
- els.settingsTicketSource?.addEventListener('click', async (e) => {
4769
+ function handleTicketSourceToggleClick(e) {
3724
4770
  const btn = e.target.closest('.source-btn');
3725
4771
  if (!btn?.dataset.source) return;
3726
4772
  const next = btn.dataset.source === 'jira' ? 'jira' : 'github';
3727
- updateTicketSourceUI(next);
3728
- try {
3729
- const res = await fetch('/api/config', {
3730
- method: 'PATCH',
3731
- headers: { 'Content-Type': 'application/json' },
3732
- body: JSON.stringify({ ticketSource: next }),
3733
- });
3734
- const data = await readJson(res);
3735
- if (res.ok) {
3736
- applyConfigSnapshot({ ...appConfig, ...data });
3737
- }
3738
- } catch {
3739
- // local UI already updated; save via Settings if needed
3740
- }
4773
+ if (next === ticketSource) return;
4774
+ void saveTicketSource(next);
4775
+ }
4776
+
4777
+ els.settingsTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
4778
+ els.overviewTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
4779
+
4780
+ els.enqueueJiraSettingsLink?.addEventListener('click', (e) => {
4781
+ e.preventDefault();
4782
+ setView('settings');
4783
+ setSettingsTab('ticket');
3741
4784
  });
3742
4785
 
3743
4786
  els.jiraTestBtn?.addEventListener('click', async () => {