acdev 1.0.5 → 1.0.7

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,833 @@ 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: 'anthropic/claude-3.5-sonnet', label: 'Claude 3.5 Sonnet (Anthropic)' },
3643
+ ];
3644
+ }
3645
+ return [
3646
+ { id: 'claude-sonnet-5', label: 'Sonnet 5' },
3647
+ { id: 'claude-opus-5', label: 'Opus 5' },
3648
+ { id: 'claude-fable-5', label: 'Fable 5' },
3649
+ { id: 'claude-haiku-4-5', label: 'Haiku 4.5' },
3650
+ { id: 'sonnet', label: 'sonnet (latest)' },
3651
+ { id: 'opus', label: 'opus (latest)' },
3652
+ ];
3653
+ }
3654
+
3655
+ /**
3656
+ * @param {'anthropic' | 'openrouter' | 'fallback' | string | undefined} source
3657
+ * @param {'claude' | 'openrouter'} [provider]
3658
+ */
3659
+ function updateModelSourceHint(source, provider = 'claude') {
2942
3660
  if (!els.settingsModelHint) return;
3661
+ const name = provider === 'openrouter' ? 'OpenRouter' : 'Claude';
2943
3662
  if (source === 'anthropic') {
2944
3663
  els.settingsModelHint.textContent =
2945
- 'Claude model for agent runs. List loaded from Anthropic Models API.';
2946
- } else if (source === 'fallback') {
3664
+ `${name} model for agent runs. List loaded from Anthropic Models API.`;
3665
+ } else if (source === 'openrouter') {
2947
3666
  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).';
3667
+ 'OpenRouter model for agent runs (anthropic/* only Claude Agent SDK uses Anthropic-compatible API). List loaded from OpenRouter Models API.';
3668
+ } else if (source === 'fallback') {
3669
+ if (provider === 'openrouter') {
3670
+ els.settingsModelHint.textContent =
3671
+ 'OpenRouter model for agent runs (anthropic/* only). Showing curated Anthropic models (live list unavailable — set OPENROUTER_API_KEY in Authentication).';
3672
+ } else {
3673
+ els.settingsModelHint.textContent =
3674
+ 'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
3675
+ }
2949
3676
  } else {
2950
- els.settingsModelHint.textContent = 'Claude model for agent runs.';
3677
+ els.settingsModelHint.textContent = `${name} model for agent runs.`;
2951
3678
  }
2952
3679
  }
2953
3680
 
2954
3681
  /**
2955
- * @param {{ models?: Array<{id:string,label?:string,name?:string}>, selected?: string, source?: string }} data
3682
+ * @param {{ id: string, label?: string, name?: string }} model
3683
+ * @returns {string}
3684
+ */
3685
+ function modelPickerLabel(model) {
3686
+ return model.label || model.name || model.id;
3687
+ }
3688
+
3689
+ /**
3690
+ * @param {{ id: string, label?: string, name?: string }} model
3691
+ * @param {string} query
3692
+ * @returns {boolean}
3693
+ */
3694
+ function modelMatchesSearchQuery(model, query) {
3695
+ if (!query) return true;
3696
+ const q = query.toLowerCase();
3697
+ return [model.id, model.label, model.name]
3698
+ .filter(Boolean)
3699
+ .some((part) => String(part).toLowerCase().includes(q));
3700
+ }
3701
+
3702
+ /**
3703
+ * @returns {'claude' | 'openrouter'}
3704
+ */
3705
+ function currentLlmProviderSelection() {
3706
+ const fromOverview = els.overviewLlmProvider?.value;
3707
+ if (fromOverview === 'openrouter' || fromOverview === 'claude') return fromOverview;
3708
+ const fromForm = els.settingsLlmProvider?.value;
3709
+ if (fromForm === 'openrouter' || fromForm === 'claude') return fromForm;
3710
+ return appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
3711
+ }
3712
+
3713
+ /**
3714
+ * @param {'claude' | 'openrouter'} provider
3715
+ */
3716
+ function syncLlmProviderSelects(provider) {
3717
+ const value = provider === 'openrouter' ? 'openrouter' : 'claude';
3718
+ if (els.settingsLlmProvider) els.settingsLlmProvider.value = value;
3719
+ if (els.overviewLlmProvider) els.overviewLlmProvider.value = value;
3720
+ syncAgentPickerTabs(value);
3721
+ if (els.settingsLlmProviderHint) {
3722
+ els.settingsLlmProviderHint.textContent =
3723
+ value === 'openrouter'
3724
+ ? 'Agent runs route through OpenRouter (requires OPENROUTER_API_KEY). Use anthropic/* models only.'
3725
+ : 'Agent runs use Claude via Anthropic API, OAuth token, or claude auth login.';
3726
+ }
3727
+ }
3728
+
3729
+ /**
3730
+ * @param {{ models?: Array<{id:string,label?:string,name?:string}>, selected?: string, source?: string, provider?: string }} data
2956
3731
  */
2957
3732
  function populateSettingsModels(data) {
2958
- const models = Array.isArray(data?.models) && data.models.length
3733
+ const provider = data?.provider || currentLlmProviderSelection();
3734
+ const cached = getAvailableModels(provider);
3735
+ const models = Array.isArray(data?.models)
2959
3736
  ? 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
- }
3737
+ : data?.reconcileProvider
3738
+ ? []
3739
+ : cached.length
3740
+ ? cached
3741
+ : defaultModelsForProvider(provider);
3742
+ setAvailableModels(provider, models);
3743
+
3744
+ if (data?.reconcileProvider) {
3745
+ currentModel = reconcileModelForProvider(
3746
+ models,
3747
+ data.reconcileCandidate,
3748
+ lastModelForProvider(provider)
3749
+ );
3750
+ } else if (data?.selected != null) {
3751
+ const next = isNoModelSelection(data.selected) ? NO_MODEL : data.selected;
3752
+ currentModel = reconcileModelForProvider(models, next, lastModelForProvider(provider));
2988
3753
  }
3754
+ if (data?.source) updateModelSourceHint(data.source, provider);
3755
+
3756
+ syncAllModelComboboxValues(currentModel);
3757
+ renderAllModelComboboxLists(currentModel);
2989
3758
  updateModelLabel();
2990
3759
  }
2991
3760
 
3761
+ function modelsForFetchFallback(provider, reconcileProvider) {
3762
+ if (reconcileProvider) return [];
3763
+ const cached = getAvailableModels(provider);
3764
+ if (cached.length) return cached;
3765
+ return defaultModelsForProvider(provider);
3766
+ }
3767
+
2992
3768
  async function fetchModels(opts = {}) {
2993
3769
  const refresh = opts.refresh === true;
3770
+ const provider = opts.provider || currentLlmProviderSelection();
3771
+ const reconcileProvider = opts.reconcileProvider === true;
3772
+ const reconcileCandidate = opts.reconcileCandidate;
3773
+ if (reconcileProvider) {
3774
+ clearAvailableModels();
3775
+ }
3776
+ const query = new URLSearchParams({ provider });
3777
+ if (refresh) query.set('refresh', '1');
2994
3778
  try {
2995
- const res = await fetch(refresh ? '/api/models?refresh=1' : '/api/models');
3779
+ const res = await fetch(`/api/models?${query.toString()}`);
2996
3780
  if (!res.ok) {
2997
3781
  populateSettingsModels({
2998
- models: availableModels.length
2999
- ? availableModels
3000
- : [
3001
- { id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
3002
- ],
3782
+ models: modelsForFetchFallback(provider, reconcileProvider),
3003
3783
  selected: currentModel,
3784
+ provider,
3785
+ reconcileProvider,
3786
+ reconcileCandidate,
3004
3787
  });
3005
3788
  return;
3006
3789
  }
3007
3790
  const data = await readJson(res);
3008
- populateSettingsModels(data);
3791
+ populateSettingsModels({
3792
+ ...data,
3793
+ provider: data.provider || provider,
3794
+ reconcileProvider,
3795
+ reconcileCandidate,
3796
+ });
3009
3797
  } catch (err) {
3010
3798
  console.error('Failed to fetch models:', err);
3011
3799
  populateSettingsModels({
3012
- models: availableModels.length
3013
- ? availableModels
3014
- : [
3015
- { id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
3016
- ],
3800
+ models: modelsForFetchFallback(provider, reconcileProvider),
3017
3801
  selected: currentModel,
3802
+ provider,
3803
+ reconcileProvider,
3804
+ reconcileCandidate,
3018
3805
  });
3019
3806
  }
3020
3807
  }
@@ -3133,6 +3920,13 @@ function claudeStatusText(cfg) {
3133
3920
  }
3134
3921
  }
3135
3922
 
3923
+ function openrouterStatusText(cfg) {
3924
+ if (cfg.openrouterAuthOk) {
3925
+ return 'Authenticated via OPENROUTER_API_KEY';
3926
+ }
3927
+ return 'Not authenticated — add an OpenRouter API key below';
3928
+ }
3929
+
3136
3930
  function fillAuthSettings(cfg) {
3137
3931
  if (els.settingsGhStatus) {
3138
3932
  els.settingsGhStatus.textContent = ghStatusText(cfg);
@@ -3163,8 +3957,9 @@ function fillAuthSettings(cfg) {
3163
3957
 
3164
3958
  if (els.settingsClaudeStatus) {
3165
3959
  els.settingsClaudeStatus.textContent = claudeStatusText(cfg);
3166
- els.settingsClaudeStatus.className =
3167
- cfg.claudeAuthOk ? 'auth-status ok' : 'auth-status err';
3960
+ els.settingsClaudeStatus.className = cfg.claudeAuthOk
3961
+ ? 'auth-status ok'
3962
+ : 'auth-status err';
3168
3963
  }
3169
3964
  fillSecretInput(
3170
3965
  els.settingsAnthropicKey,
@@ -3190,6 +3985,25 @@ function fillAuthSettings(cfg) {
3190
3985
  ? '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
3986
  : '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
3987
  }
3988
+
3989
+ if (els.settingsOpenrouterStatus) {
3990
+ els.settingsOpenrouterStatus.textContent = openrouterStatusText(cfg);
3991
+ els.settingsOpenrouterStatus.className = cfg.openrouterAuthOk
3992
+ ? 'auth-status ok'
3993
+ : 'auth-status err';
3994
+ }
3995
+ fillSecretInput(
3996
+ els.settingsOpenrouterKey,
3997
+ els.settingsOpenrouterKeyClear,
3998
+ Boolean(cfg.openrouterApiKeySet),
3999
+ cfg.openrouterApiKeyMasked,
4000
+ 'Paste an OpenRouter API key'
4001
+ );
4002
+ if (els.settingsOpenrouterKeyHint) {
4003
+ els.settingsOpenrouterKeyHint.innerHTML = cfg.openrouterApiKeySet
4004
+ ? 'API key stored in <code>.acdev/.env</code> as <code>OPENROUTER_API_KEY</code> (not committed).'
4005
+ : '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>.';
4006
+ }
3193
4007
  }
3194
4008
 
3195
4009
  function fillSettingsForm(cfg) {
@@ -3269,16 +4083,23 @@ function fillSettingsForm(cfg) {
3269
4083
  els.settingsBaseBranch.value = cfg.baseBranch || '';
3270
4084
  }
3271
4085
 
3272
- if (els.settingsModel) {
3273
- if (availableModels.length) {
3274
- populateSettingsModels({ models: availableModels, selected: cfg.model || currentModel });
3275
- } else if (cfg.model) {
4086
+ if (els.settingsLlmProvider) {
4087
+ syncLlmProviderSelects(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4088
+ }
4089
+
4090
+ if (els.settingsModelPicker) {
4091
+ const provider = cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
4092
+ if (getAvailableModels(provider).length) {
3276
4093
  populateSettingsModels({
3277
- models: [{ id: cfg.model, label: cfg.model }],
3278
- selected: cfg.model,
4094
+ models: getAvailableModels(provider),
4095
+ selected: cfg.model ?? currentModel,
4096
+ provider,
3279
4097
  });
4098
+ } else if (cfg.model != null) {
4099
+ currentModel = isNoModelSelection(cfg.model) ? NO_MODEL : cfg.model;
4100
+ syncModelPickerValue(currentModel);
3280
4101
  }
3281
- void fetchModels();
4102
+ void fetchModels({ provider });
3282
4103
  }
3283
4104
 
3284
4105
  if (els.settingsMaxTurns) {
@@ -3361,10 +4182,12 @@ function updateGithubRuleFieldsVisibility() {
3361
4182
  function updateTicketSourceUI(source) {
3362
4183
  ticketSource = source === 'jira' ? 'jira' : 'github';
3363
4184
 
3364
- if (els.settingsTicketSource) {
3365
- els.settingsTicketSource.querySelectorAll('.source-btn').forEach((btn) => {
4185
+ for (const toggle of [els.settingsTicketSource, els.overviewTicketSource]) {
4186
+ if (!toggle) continue;
4187
+ toggle.querySelectorAll('.source-btn').forEach((btn) => {
3366
4188
  const active = btn.dataset.source === ticketSource;
3367
4189
  btn.classList.toggle('active', active);
4190
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
3368
4191
  });
3369
4192
  }
3370
4193
 
@@ -3382,11 +4205,9 @@ function updateTicketSourceUI(source) {
3382
4205
 
3383
4206
  if (els.issueUrls) {
3384
4207
  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';
4208
+ els.issueUrls.placeholder = 'Paste Jira keys or browse URLs…';
3387
4209
  } 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';
4210
+ els.issueUrls.placeholder = 'Paste GitHub issue links or URLs…';
3390
4211
  }
3391
4212
  }
3392
4213
 
@@ -3395,11 +4216,24 @@ function updateTicketSourceUI(source) {
3395
4216
  ticketSource === 'jira' ? 'Jira issue keys or URLs' : 'GitHub issue URLs';
3396
4217
  }
3397
4218
 
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.';
4219
+ if (els.enqueueHintExample) {
4220
+ els.enqueueHintExample.textContent =
4221
+ ticketSource === 'jira' ? 'PROJ-123' : 'owner/repo#42';
4222
+ }
4223
+
4224
+ if (els.enqueueInfoText) {
4225
+ if (ticketSource === 'jira') {
4226
+ els.enqueueInfoText.innerHTML =
4227
+ '<strong>Jira issues run one at a time.</strong> Open GitHub PRs are picked up automatically via gh.';
4228
+ } else {
4229
+ els.enqueueInfoText.innerHTML =
4230
+ '<strong>GitHub issues run one at a time.</strong> Open PRs are picked up automatically via gh.';
4231
+ }
4232
+ }
4233
+
4234
+ if (els.enqueueJiraHint) {
4235
+ const showJiraHint = ticketSource === 'jira' && !appConfig.jiraConfigured;
4236
+ els.enqueueJiraHint.classList.toggle('hidden', !showJiraHint);
3403
4237
  }
3404
4238
 
3405
4239
  if (els.overviewQueueEmpty) {
@@ -3410,6 +4244,27 @@ function updateTicketSourceUI(source) {
3410
4244
  }
3411
4245
  }
3412
4246
 
4247
+ /**
4248
+ * @param {'github' | 'jira'} next
4249
+ */
4250
+ async function saveTicketSource(next) {
4251
+ const source = next === 'jira' ? 'jira' : 'github';
4252
+ updateTicketSourceUI(source);
4253
+ try {
4254
+ const res = await fetch('/api/config', {
4255
+ method: 'PATCH',
4256
+ headers: { 'Content-Type': 'application/json' },
4257
+ body: JSON.stringify({ ticketSource: source }),
4258
+ });
4259
+ const data = await readJson(res);
4260
+ if (res.ok) {
4261
+ applyConfigSnapshot({ ...appConfig, ...data });
4262
+ }
4263
+ } catch {
4264
+ // local UI already updated; save via Settings if needed
4265
+ }
4266
+ }
4267
+
3413
4268
  function updateTimeoutMsHint(minutes) {
3414
4269
  if (!els.settingsTimeoutMs) return;
3415
4270
  const m = Number(minutes);
@@ -3420,10 +4275,94 @@ function updateTimeoutMsHint(minutes) {
3420
4275
  els.settingsTimeoutMs.textContent = String(Math.round(m * 60_000));
3421
4276
  }
3422
4277
 
4278
+ function setOverviewLlmFeedback(message, kind = 'ok') {
4279
+ if (!els.overviewLlmFeedback) return;
4280
+ if (!message) {
4281
+ els.overviewLlmFeedback.textContent = '';
4282
+ els.overviewLlmFeedback.className = 'feedback hidden';
4283
+ return;
4284
+ }
4285
+ els.overviewLlmFeedback.textContent = message;
4286
+ els.overviewLlmFeedback.className = `feedback ${kind}`;
4287
+ els.overviewLlmFeedback.classList.remove('hidden');
4288
+ }
4289
+
4290
+ /**
4291
+ * @param {typeof appConfig} cfg
4292
+ */
4293
+ function fillOverviewLlmControls(cfg) {
4294
+ if (!cfg) return;
4295
+ const provider = cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
4296
+ syncLlmProviderSelects(provider);
4297
+ syncAllModelComboboxValues(currentModel);
4298
+ }
4299
+
4300
+ let llmConfigSaving = false;
4301
+
4302
+ /**
4303
+ * PATCH llmProvider and/or model immediately (Overview switcher).
4304
+ * @param {{ llmProvider?: 'claude' | 'openrouter', model?: string }} patch
4305
+ */
4306
+ async function patchLlmConfig(patch) {
4307
+ if (llmConfigSaving) return;
4308
+ llmConfigSaving = true;
4309
+ setOverviewLlmFeedback(null);
4310
+ try {
4311
+ const res = await fetch('/api/config', {
4312
+ method: 'PATCH',
4313
+ headers: { 'Content-Type': 'application/json' },
4314
+ body: JSON.stringify(patch),
4315
+ });
4316
+ const data = await readJson(res);
4317
+ if (!res.ok) {
4318
+ setOverviewLlmFeedback(data.error || `Update failed (HTTP ${res.status})`, 'error');
4319
+ return;
4320
+ }
4321
+ applyConfigSnapshot(data);
4322
+ fillOverviewLlmControls(data);
4323
+ if (currentView === 'settings') {
4324
+ fillSettingsForm(data);
4325
+ }
4326
+ setOverviewLlmFeedback('LLM updated. Next jobs will use these values.', 'ok');
4327
+ window.setTimeout(() => setOverviewLlmFeedback(null), 2500);
4328
+ } catch (err) {
4329
+ setOverviewLlmFeedback(err.message || 'Update failed', 'error');
4330
+ } finally {
4331
+ llmConfigSaving = false;
4332
+ }
4333
+ }
4334
+
4335
+ /**
4336
+ * @param {'claude' | 'openrouter'} provider
4337
+ */
4338
+ async function onLlmProviderChange(provider) {
4339
+ const previousModel = currentModel;
4340
+ syncLlmProviderSelects(provider);
4341
+ clearAvailableModels();
4342
+ currentModel = NO_MODEL;
4343
+ syncAllModelComboboxValues(currentModel);
4344
+ renderAllModelComboboxLists(currentModel);
4345
+ updateModelLabel();
4346
+ await fetchModels({
4347
+ refresh: true,
4348
+ provider,
4349
+ reconcileProvider: true,
4350
+ reconcileCandidate: previousModel,
4351
+ });
4352
+ await patchLlmConfig({
4353
+ llmProvider: provider,
4354
+ model: currentModel,
4355
+ });
4356
+ }
4357
+
3423
4358
  function applyConfigSnapshot(data) {
3424
4359
  appConfig = data || {};
3425
- if (data?.model) currentModel = data.model;
4360
+ if (data?.model != null) {
4361
+ currentModel = isNoModelSelection(data.model) ? NO_MODEL : data.model;
4362
+ }
4363
+ syncAllModelComboboxValues(currentModel);
3426
4364
  updateModelLabel();
4365
+ fillOverviewLlmControls(data);
3427
4366
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
3428
4367
  if (els.repoName) {
3429
4368
  els.repoName.textContent = data?.repoName || 'local repo';
@@ -3431,33 +4370,12 @@ function applyConfigSnapshot(data) {
3431
4370
  if (els.repoBranch) {
3432
4371
  els.repoBranch.textContent = data?.baseBranch || '—';
3433
4372
  }
3434
- updateAuthBanner(appConfig);
4373
+ syncLlmProviderAvailability(appConfig);
3435
4374
  if (currentView === 'settings') {
3436
4375
  fillSettingsForm(appConfig);
3437
4376
  }
3438
4377
  }
3439
4378
 
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
4379
  async function fetchConfig() {
3462
4380
  try {
3463
4381
  const res = await fetch('/api/config');
@@ -3471,7 +4389,8 @@ async function fetchConfig() {
3471
4389
 
3472
4390
  function readSettingsForm() {
3473
4391
  const baseBranch = els.settingsBaseBranch?.value?.trim() || '';
3474
- const model = els.settingsModel?.value || currentModel;
4392
+ const rawModel = els.settingsModel?.value ?? currentModel;
4393
+ const model = isNoModelSelection(rawModel) ? NO_MODEL : rawModel;
3475
4394
  const maxAgentTurns = Number(els.settingsMaxTurns?.value);
3476
4395
  const timeoutMinutes = Number(els.settingsTimeout?.value);
3477
4396
  const testRaw = els.settingsTestCommand?.value ?? '';
@@ -3485,6 +4404,7 @@ function readSettingsForm() {
3485
4404
  const patch = {
3486
4405
  baseBranch,
3487
4406
  model,
4407
+ llmProvider: els.settingsLlmProvider?.value === 'openrouter' ? 'openrouter' : 'claude',
3488
4408
  maxAgentTurns,
3489
4409
  agentTimeoutMs: Math.round(timeoutMinutes * 60_000),
3490
4410
  testCommand: testTrimmed === '' ? null : testTrimmed,
@@ -3498,6 +4418,8 @@ function readSettingsForm() {
3498
4418
  if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
3499
4419
  const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
3500
4420
  if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
4421
+ const openrouterApiKey = secretPatchValue(els.settingsOpenrouterKey);
4422
+ if (openrouterApiKey !== undefined) patch.openrouterApiKey = openrouterApiKey;
3501
4423
 
3502
4424
  if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
3503
4425
  patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
@@ -3587,11 +4509,6 @@ document.querySelectorAll('.nav-item').forEach((btn) => {
3587
4509
  btn.addEventListener('click', () => setView(/** @type {ViewId} */ (btn.dataset.view)));
3588
4510
  });
3589
4511
 
3590
- els.authBannerBtn?.addEventListener('click', () => {
3591
- setView('settings');
3592
- setSettingsTab('auth');
3593
- });
3594
-
3595
4512
  els.themeToggle.addEventListener('click', () => {
3596
4513
  const next = els.app.dataset.theme === 'dark' ? 'light' : 'dark';
3597
4514
  applyTheme(next);
@@ -3670,10 +4587,141 @@ bindSecretClear(
3670
4587
  els.settingsClaudeOauthHint,
3671
4588
  'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
3672
4589
  );
4590
+ bindSecretClear(
4591
+ els.settingsOpenrouterKeyClear,
4592
+ els.settingsOpenrouterKey,
4593
+ els.settingsOpenrouterKeyHint,
4594
+ 'Saved OPENROUTER_API_KEY will be removed when you click Save settings.'
4595
+ );
4596
+
4597
+
4598
+ document.addEventListener('click', (ev) => {
4599
+ const target = /** @type {Node | null} */ (ev.target);
4600
+ for (const cb of modelComboboxes) {
4601
+ if (!cb.open) continue;
4602
+ if (cb.root.contains(target)) continue;
4603
+ setModelComboboxOpen(cb, false);
4604
+ }
4605
+ for (const ap of agentPickers) {
4606
+ if (!ap.open) continue;
4607
+ if (ap.root.contains(target)) continue;
4608
+ setAgentPickerOpen(ap, false);
4609
+ }
4610
+ });
4611
+
4612
+ document.addEventListener('keydown', (ev) => {
4613
+ if (ev.key !== 'Escape') return;
4614
+ for (const ap of agentPickers) {
4615
+ if (!ap.open) continue;
4616
+ setAgentPickerOpen(ap, false);
4617
+ ap.trigger.focus();
4618
+ return;
4619
+ }
4620
+ for (const cb of modelComboboxes) {
4621
+ if (!cb.open) continue;
4622
+ setModelComboboxOpen(cb, false);
4623
+ cb.trigger.focus();
4624
+ break;
4625
+ }
4626
+ });
4627
+
4628
+ els.settingsLlmProvider?.addEventListener('change', () => {
4629
+ const provider = currentLlmProviderSelection();
4630
+ if (!isLlmProviderAvailable(provider)) {
4631
+ const fallback =
4632
+ appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
4633
+ syncLlmProviderSelects(fallback);
4634
+ return;
4635
+ }
4636
+ for (const cb of modelComboboxes) {
4637
+ setModelComboboxOpen(cb, false);
4638
+ }
4639
+ void onLlmProviderChange(provider);
4640
+ });
4641
+
4642
+ els.overviewLlmProvider?.addEventListener('change', () => {
4643
+ const provider =
4644
+ els.overviewLlmProvider.value === 'openrouter' ? 'openrouter' : 'claude';
4645
+ if (!isLlmProviderAvailable(provider)) {
4646
+ const fallback =
4647
+ appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
4648
+ syncLlmProviderSelects(fallback);
4649
+ return;
4650
+ }
4651
+ for (const cb of modelComboboxes) {
4652
+ setModelComboboxOpen(cb, false);
4653
+ }
4654
+ for (const ap of agentPickers) {
4655
+ setAgentPickerOpen(ap, false);
4656
+ }
4657
+ void onLlmProviderChange(provider);
4658
+ });
4659
+
4660
+ if (
4661
+ els.settingsModelPicker &&
4662
+ els.settingsModelTrigger &&
4663
+ els.settingsModelPanel &&
4664
+ els.settingsModelSearch &&
4665
+ els.settingsModelList &&
4666
+ els.settingsModelValue
4667
+ ) {
4668
+ initModelCombobox({
4669
+ root: els.settingsModelPicker,
4670
+ trigger: els.settingsModelTrigger,
4671
+ panel: els.settingsModelPanel,
4672
+ search: els.settingsModelSearch,
4673
+ list: els.settingsModelList,
4674
+ hiddenInput: els.settingsModel,
4675
+ valueEl: els.settingsModelValue,
4676
+ optionIdPrefix: 'settings-model-opt',
4677
+ onSelect: (modelId) => {
4678
+ currentModel = isNoModelSelection(modelId) ? NO_MODEL : modelId;
4679
+ syncAllModelComboboxValues(currentModel);
4680
+ renderAllModelComboboxLists(currentModel);
4681
+ updateModelLabel();
4682
+ },
4683
+ });
4684
+ }
4685
+
4686
+ if (
4687
+ els.overviewAgentPicker &&
4688
+ els.overviewAgentTrigger &&
4689
+ els.overviewAgentPanel &&
4690
+ els.overviewAgentSearch &&
4691
+ els.overviewAgentModelList &&
4692
+ els.overviewAgentValue
4693
+ ) {
4694
+ initAgentPicker({
4695
+ root: els.overviewAgentPicker,
4696
+ trigger: els.overviewAgentTrigger,
4697
+ panel: els.overviewAgentPanel,
4698
+ search: els.overviewAgentSearch,
4699
+ list: els.overviewAgentModelList,
4700
+ valueEl: els.overviewAgentValue,
4701
+ iconEl: els.overviewAgentIcon,
4702
+ tabs: [
4703
+ ...(els.overviewAgentPanel.querySelectorAll('.agent-picker-tab') || []),
4704
+ ],
4705
+ hiddenInput: els.overviewModel,
4706
+ optionIdPrefix: 'overview-agent-model-opt',
4707
+ onSelect: (modelId) => {
4708
+ currentModel = isNoModelSelection(modelId) ? NO_MODEL : modelId;
4709
+ syncAllModelComboboxValues(currentModel);
4710
+ renderAllModelComboboxLists(currentModel);
4711
+ updateModelLabel();
4712
+ void patchLlmConfig({ model: currentModel });
4713
+ },
4714
+ });
4715
+ }
4716
+
4717
+ els.issueUrls?.addEventListener('keydown', (ev) => {
4718
+ if (ev.key !== 'Enter' || ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) return;
4719
+ ev.preventDefault();
4720
+ els.addBtn?.click();
4721
+ });
3673
4722
 
3674
4723
  els.addBtn.addEventListener('click', async () => {
3675
4724
  els.enqueueFeedback.classList.add('hidden');
3676
- if (els.enqueueHint) els.enqueueHint.classList.remove('hidden');
3677
4725
  const text = els.issueUrls.value.trim();
3678
4726
  if (!text) return;
3679
4727
 
@@ -3687,7 +4735,6 @@ els.addBtn.addEventListener('click', async () => {
3687
4735
  });
3688
4736
  const data = await res.json();
3689
4737
  if (!res.ok) {
3690
- if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
3691
4738
  els.enqueueFeedback.textContent =
3692
4739
  data.error + (data.invalidUrls ? `: ${data.invalidUrls.join(', ')}` : '');
3693
4740
  els.enqueueFeedback.className = 'feedback error';
@@ -3701,7 +4748,6 @@ els.addBtn.addEventListener('click', async () => {
3701
4748
  parts.push(`Skipped duplicates: ${data.skipped.join(', ')}`);
3702
4749
  }
3703
4750
  if (parts.length) {
3704
- if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
3705
4751
  els.enqueueFeedback.textContent = parts.join(' · ');
3706
4752
  els.enqueueFeedback.className =
3707
4753
  'feedback ' + (data.skipped?.length ? 'warn' : 'ok');
@@ -3713,31 +4759,27 @@ els.addBtn.addEventListener('click', async () => {
3713
4759
  selectRun(data.jobs[0].id);
3714
4760
  }
3715
4761
  } catch (err) {
3716
- if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
3717
4762
  els.enqueueFeedback.textContent = err.message;
3718
4763
  els.enqueueFeedback.className = 'feedback error';
3719
4764
  els.enqueueFeedback.classList.remove('hidden');
3720
4765
  }
3721
4766
  });
3722
4767
 
3723
- els.settingsTicketSource?.addEventListener('click', async (e) => {
4768
+ function handleTicketSourceToggleClick(e) {
3724
4769
  const btn = e.target.closest('.source-btn');
3725
4770
  if (!btn?.dataset.source) return;
3726
4771
  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
- }
4772
+ if (next === ticketSource) return;
4773
+ void saveTicketSource(next);
4774
+ }
4775
+
4776
+ els.settingsTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
4777
+ els.overviewTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
4778
+
4779
+ els.enqueueJiraSettingsLink?.addEventListener('click', (e) => {
4780
+ e.preventDefault();
4781
+ setView('settings');
4782
+ setSettingsTab('ticket');
3741
4783
  });
3742
4784
 
3743
4785
  els.jiraTestBtn?.addEventListener('click', async () => {