acdev 1.0.2 → 1.0.3

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
@@ -124,10 +124,13 @@ let reviewDrafts = {};
124
124
  let reviewFileLists = {};
125
125
  /** @type {Record<string, Set<string>>} excluded paths per job while reviewing */
126
126
  let reviewExcludedByJob = {};
127
- let modelSaving = false;
128
127
  let settingsSaving = false;
128
+ /** @type {'ticket' | 'auth' | 'rules' | 'config'} */
129
+ let settingsTab = 'ticket';
129
130
  /** @type {string} */
130
131
  let currentModel = 'claude-sonnet-5';
132
+ /** @type {Array<{id:string,label?:string,name?:string}>} */
133
+ let availableModels = [];
131
134
  /** @type {'github' | 'jira'} */
132
135
  let ticketSource = 'github';
133
136
  /** @type {{
@@ -147,6 +150,19 @@ let ticketSource = 'github';
147
150
  * jiraApiTokenMasked?: string | null,
148
151
  * jiraConfigured?: boolean,
149
152
  * jiraPrLinkPhrase?: string,
153
+ * ghAuthOk?: boolean,
154
+ * ghInstalled?: boolean,
155
+ * ghTokenSet?: boolean,
156
+ * ghTokenMasked?: string | null,
157
+ * ghAuthMethod?: 'token' | 'gh-login' | null,
158
+ * originRemoteUrl?: string | null,
159
+ * originRemoteSsh?: boolean,
160
+ * claudeAuthOk?: boolean,
161
+ * claudeAuthMethod?: string,
162
+ * anthropicApiKeySet?: boolean,
163
+ * anthropicApiKeyMasked?: string | null,
164
+ * claudeOauthTokenSet?: boolean,
165
+ * claudeOauthTokenMasked?: string | null,
150
166
  * }} */
151
167
  let appConfig = {};
152
168
 
@@ -157,7 +173,7 @@ const els = {
157
173
  repoName: document.getElementById('repo-name'),
158
174
  repoBranch: document.getElementById('repo-branch'),
159
175
  ticketSourceMeta: document.getElementById('ticket-source-meta'),
160
- modelToggle: document.getElementById('model-toggle'),
176
+ modelCurrent: document.getElementById('model-current'),
161
177
  navBadgeOverview: document.getElementById('nav-badge-overview'),
162
178
  navBadgeRuns: document.getElementById('nav-badge-runs'),
163
179
  navBadgeReview: document.getElementById('nav-badge-review'),
@@ -221,6 +237,7 @@ const els = {
221
237
  settingsForm: document.getElementById('settings-form'),
222
238
  settingsBaseBranch: document.getElementById('settings-base-branch'),
223
239
  settingsModel: document.getElementById('settings-model'),
240
+ settingsModelHint: document.getElementById('settings-model-hint'),
224
241
  settingsMaxTurns: document.getElementById('settings-max-turns'),
225
242
  settingsTimeout: document.getElementById('settings-timeout'),
226
243
  settingsTimeoutMs: document.getElementById('settings-timeout-ms'),
@@ -238,13 +255,30 @@ const els = {
238
255
  jiraTestBtn: document.getElementById('jira-test-btn'),
239
256
  jiraStatus: document.getElementById('jira-status'),
240
257
  settingsJiraRuleEnabled: document.getElementById('settings-jira-rule-enabled'),
258
+ settingsJiraRuleAction: document.getElementById('settings-jira-rule-action'),
241
259
  settingsJiraRuleStatus: document.getElementById('settings-jira-rule-status'),
260
+ settingsJiraRuleStatusField: document.getElementById('settings-jira-rule-status-field'),
261
+ settingsJiraRuleLabel: document.getElementById('settings-jira-rule-label'),
262
+ settingsJiraRuleLabelField: document.getElementById('settings-jira-rule-label-field'),
242
263
  settingsGithubRuleEnabled: document.getElementById('settings-github-rule-enabled'),
243
264
  settingsGithubRuleAction: document.getElementById('settings-github-rule-action'),
265
+ settingsGithubRuleStatus: document.getElementById('settings-github-rule-status'),
266
+ settingsGithubRuleStatusField: document.getElementById('settings-github-rule-status-field'),
244
267
  settingsGithubRuleLabel: document.getElementById('settings-github-rule-label'),
245
268
  settingsGithubRuleLabelField: document.getElementById('settings-github-rule-label-field'),
246
- jiraRulesPanel: document.getElementById('jira-rules-panel'),
247
- githubRulesPanel: document.getElementById('github-rules-panel'),
269
+ settingsGhStatus: document.getElementById('settings-gh-status'),
270
+ settingsGhOrigin: document.getElementById('settings-gh-origin'),
271
+ settingsGhToken: document.getElementById('settings-gh-token'),
272
+ settingsGhTokenClear: document.getElementById('settings-gh-token-clear'),
273
+ settingsGhTokenHint: document.getElementById('settings-gh-token-hint'),
274
+ settingsClaudeStatus: document.getElementById('settings-claude-status'),
275
+ settingsAnthropicKey: document.getElementById('settings-anthropic-key'),
276
+ settingsAnthropicKeyClear: document.getElementById('settings-anthropic-key-clear'),
277
+ settingsAnthropicKeyHint: document.getElementById('settings-anthropic-key-hint'),
278
+ settingsClaudeOauth: document.getElementById('settings-claude-oauth'),
279
+ settingsClaudeOauthClear: document.getElementById('settings-claude-oauth-clear'),
280
+ settingsClaudeOauthHint: document.getElementById('settings-claude-oauth-hint'),
281
+ settingsTabs: document.getElementById('settings-tabs'),
248
282
  };
249
283
 
250
284
  function loadDismissed() {
@@ -2890,13 +2924,94 @@ async function readJson(res) {
2890
2924
  }
2891
2925
  }
2892
2926
 
2893
- function updateModelButtons() {
2894
- els.modelToggle.querySelectorAll('.model-btn').forEach((btn) => {
2895
- btn.classList.toggle('active', btn.dataset.model === currentModel);
2896
- });
2897
- if (els.settingsModel && els.settingsModel.value !== currentModel) {
2898
- const opt = [...els.settingsModel.options].find((o) => o.value === currentModel);
2899
- if (opt) els.settingsModel.value = currentModel;
2927
+ function updateModelLabel() {
2928
+ if (!els.modelCurrent) return;
2929
+ const id = currentModel || '—';
2930
+ els.modelCurrent.textContent = id;
2931
+ els.modelCurrent.title = `Model: ${id} change in Settings → Configuration`;
2932
+ }
2933
+
2934
+ /**
2935
+ * @param {'anthropic' | 'fallback' | string | undefined} source
2936
+ */
2937
+ function updateModelSourceHint(source) {
2938
+ if (!els.settingsModelHint) return;
2939
+ if (source === 'anthropic') {
2940
+ els.settingsModelHint.textContent =
2941
+ 'Claude model for agent runs. List loaded from Anthropic Models API.';
2942
+ } else if (source === 'fallback') {
2943
+ els.settingsModelHint.textContent =
2944
+ 'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
2945
+ } else {
2946
+ els.settingsModelHint.textContent = 'Claude model for agent runs.';
2947
+ }
2948
+ }
2949
+
2950
+ /**
2951
+ * @param {{ models?: Array<{id:string,label?:string,name?:string}>, selected?: string, source?: string }} data
2952
+ */
2953
+ function populateSettingsModels(data) {
2954
+ const models = Array.isArray(data?.models) && data.models.length
2955
+ ? data.models
2956
+ : availableModels.length
2957
+ ? availableModels
2958
+ : [
2959
+ { id: 'claude-sonnet-5', label: 'Sonnet 5' },
2960
+ { id: 'claude-opus-5', label: 'Opus 5' },
2961
+ { id: 'claude-fable-5', label: 'Fable 5' },
2962
+ { id: 'claude-haiku-4-5', label: 'Haiku 4.5' },
2963
+ ];
2964
+ availableModels = models;
2965
+ if (data?.selected) currentModel = data.selected;
2966
+ if (data?.source) updateModelSourceHint(data.source);
2967
+
2968
+ if (els.settingsModel) {
2969
+ const currentIds = [...els.settingsModel.options].map((o) => o.value);
2970
+ const nextIds = models.map((m) => m.id);
2971
+ if (currentIds.join(',') !== nextIds.join(',')) {
2972
+ els.settingsModel.innerHTML = '';
2973
+ for (const m of models) {
2974
+ const opt = document.createElement('option');
2975
+ opt.value = m.id;
2976
+ opt.textContent = m.label || m.name || m.id;
2977
+ els.settingsModel.appendChild(opt);
2978
+ }
2979
+ }
2980
+ const selected = currentModel;
2981
+ if (selected && [...els.settingsModel.options].some((o) => o.value === selected)) {
2982
+ els.settingsModel.value = selected;
2983
+ }
2984
+ }
2985
+ updateModelLabel();
2986
+ }
2987
+
2988
+ async function fetchModels(opts = {}) {
2989
+ const refresh = opts.refresh === true;
2990
+ try {
2991
+ const res = await fetch(refresh ? '/api/models?refresh=1' : '/api/models');
2992
+ if (!res.ok) {
2993
+ populateSettingsModels({
2994
+ models: availableModels.length
2995
+ ? availableModels
2996
+ : [
2997
+ { id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
2998
+ ],
2999
+ selected: currentModel,
3000
+ });
3001
+ return;
3002
+ }
3003
+ const data = await readJson(res);
3004
+ populateSettingsModels(data);
3005
+ } catch (err) {
3006
+ console.error('Failed to fetch models:', err);
3007
+ populateSettingsModels({
3008
+ models: availableModels.length
3009
+ ? availableModels
3010
+ : [
3011
+ { id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
3012
+ ],
3013
+ selected: currentModel,
3014
+ });
2900
3015
  }
2901
3016
  }
2902
3017
 
@@ -2924,9 +3039,160 @@ function knownToolsList(cfg = appConfig) {
2924
3039
  : DEFAULT_KNOWN_TOOLS;
2925
3040
  }
2926
3041
 
3042
+ /**
3043
+ * Switch Settings top-level tab (Ticket source / Authentication / Rules / Configuration).
3044
+ * @param {'ticket' | 'auth' | 'rules' | 'config'} tab
3045
+ */
3046
+ function setSettingsTab(tab) {
3047
+ const next =
3048
+ tab === 'auth' || tab === 'rules' || tab === 'config' ? tab : 'ticket';
3049
+ settingsTab = next;
3050
+
3051
+ if (els.settingsTabs) {
3052
+ els.settingsTabs.querySelectorAll('[role="tab"]').forEach((btn) => {
3053
+ const selected = btn.dataset.settingsTab === next;
3054
+ btn.setAttribute('aria-selected', selected ? 'true' : 'false');
3055
+ btn.tabIndex = selected ? 0 : -1;
3056
+ });
3057
+ }
3058
+
3059
+ document.querySelectorAll('[data-settings-panel]').forEach((panel) => {
3060
+ const show = panel.dataset.settingsPanel === next;
3061
+ panel.classList.toggle('hidden', !show);
3062
+ });
3063
+
3064
+ if (next === 'config') {
3065
+ void fetchModels();
3066
+ }
3067
+ }
3068
+
3069
+ /**
3070
+ * Empty = keep existing secret; data-clear + empty = send null to clear.
3071
+ * @param {HTMLInputElement | null} inputEl
3072
+ * @returns {string | null | undefined}
3073
+ */
3074
+ function secretPatchValue(inputEl) {
3075
+ const typed = inputEl?.value?.trim() || '';
3076
+ if (typed) return typed;
3077
+ if (inputEl?.dataset.clear === '1') return null;
3078
+ return undefined;
3079
+ }
3080
+
3081
+ /**
3082
+ * @param {HTMLInputElement | null} inputEl
3083
+ * @param {HTMLButtonElement | null} clearBtn
3084
+ * @param {boolean} set
3085
+ * @param {string | null | undefined} masked
3086
+ * @param {string} unsetPlaceholder
3087
+ */
3088
+ function fillSecretInput(inputEl, clearBtn, set, masked, unsetPlaceholder) {
3089
+ if (!inputEl) return;
3090
+ inputEl.value = '';
3091
+ delete inputEl.dataset.clear;
3092
+ inputEl.placeholder = set
3093
+ ? `Saved (${masked || '••••'}) — leave blank to keep`
3094
+ : unsetPlaceholder;
3095
+ if (clearBtn) {
3096
+ clearBtn.classList.toggle('hidden', !set);
3097
+ }
3098
+ }
3099
+
3100
+ function ghStatusText(cfg) {
3101
+ if (!cfg.ghInstalled) {
3102
+ return 'gh is not installed (required for issues and PRs)';
3103
+ }
3104
+ if (cfg.ghAuthOk) {
3105
+ const method =
3106
+ cfg.ghAuthMethod === 'token'
3107
+ ? 'env token (GH_TOKEN)'
3108
+ : 'gh auth login';
3109
+ return `Authenticated via ${method}`;
3110
+ }
3111
+ if (cfg.ghTokenSet) {
3112
+ return 'Token saved, but gh is not authenticated — check the token';
3113
+ }
3114
+ return 'Not authenticated — paste a PAT or run gh auth login';
3115
+ }
3116
+
3117
+ function claudeStatusText(cfg) {
3118
+ switch (cfg.claudeAuthMethod) {
3119
+ case 'api-key':
3120
+ return 'Authenticated via ANTHROPIC_API_KEY (API billing)';
3121
+ case 'auth-token':
3122
+ return 'Authenticated via ANTHROPIC_AUTH_TOKEN';
3123
+ case 'oauth-token':
3124
+ return 'Authenticated via CLAUDE_CODE_OAUTH_TOKEN';
3125
+ case 'claude-code-login':
3126
+ return 'Authenticated via claude auth login (subscription)';
3127
+ default:
3128
+ return 'Not authenticated — add an API key, OAuth token, or run claude auth login';
3129
+ }
3130
+ }
3131
+
3132
+ function fillAuthSettings(cfg) {
3133
+ if (els.settingsGhStatus) {
3134
+ els.settingsGhStatus.textContent = ghStatusText(cfg);
3135
+ els.settingsGhStatus.className =
3136
+ cfg.ghAuthOk ? 'auth-status ok' : 'auth-status err';
3137
+ }
3138
+ if (els.settingsGhOrigin) {
3139
+ if (cfg.originRemoteUrl) {
3140
+ els.settingsGhOrigin.textContent = cfg.originRemoteSsh
3141
+ ? `origin: ${cfg.originRemoteUrl} (SSH — git push/fetch only, not gh API auth)`
3142
+ : `origin: ${cfg.originRemoteUrl}`;
3143
+ } else {
3144
+ els.settingsGhOrigin.textContent = '';
3145
+ }
3146
+ }
3147
+ fillSecretInput(
3148
+ els.settingsGhToken,
3149
+ els.settingsGhTokenClear,
3150
+ Boolean(cfg.ghTokenSet),
3151
+ cfg.ghTokenMasked,
3152
+ 'Paste a GitHub PAT'
3153
+ );
3154
+ if (els.settingsGhTokenHint) {
3155
+ els.settingsGhTokenHint.innerHTML = cfg.ghTokenSet
3156
+ ? 'Token stored in <code>.acdev/.env</code> as <code>GH_TOKEN</code> (not committed). Leave blank to keep. SSH remotes can push/fetch git but do <strong>not</strong> authenticate <code>gh</code> for issues or PRs.'
3157
+ : 'If <code>gh auth login</code> already works on this machine, leave this blank. Otherwise paste a PAT (<code>repo</code> and pull-request scopes). Stored as <code>GH_TOKEN</code> so <code>gh</code> can auth without a browser. SSH remotes can push/fetch git but do <strong>not</strong> authenticate <code>gh</code> for issues or PRs.';
3158
+ }
3159
+
3160
+ if (els.settingsClaudeStatus) {
3161
+ els.settingsClaudeStatus.textContent = claudeStatusText(cfg);
3162
+ els.settingsClaudeStatus.className =
3163
+ cfg.claudeAuthOk ? 'auth-status ok' : 'auth-status err';
3164
+ }
3165
+ fillSecretInput(
3166
+ els.settingsAnthropicKey,
3167
+ els.settingsAnthropicKeyClear,
3168
+ Boolean(cfg.anthropicApiKeySet),
3169
+ cfg.anthropicApiKeyMasked,
3170
+ 'Paste an Anthropic API key'
3171
+ );
3172
+ fillSecretInput(
3173
+ els.settingsClaudeOauth,
3174
+ els.settingsClaudeOauthClear,
3175
+ Boolean(cfg.claudeOauthTokenSet),
3176
+ cfg.claudeOauthTokenMasked,
3177
+ 'Paste token from claude setup-token'
3178
+ );
3179
+ if (els.settingsAnthropicKeyHint) {
3180
+ els.settingsAnthropicKeyHint.innerHTML = cfg.anthropicApiKeySet
3181
+ ? 'API key stored in <code>.acdev/.env</code> (not committed). Takes precedence over subscription login — clear it to use <code>claude auth login</code>.'
3182
+ : 'Console / pay-as-you-go billing. Takes precedence over subscription login. Stored as <code>ANTHROPIC_API_KEY</code>. Clear or omit the key to use <code>claude auth login</code> instead.';
3183
+ }
3184
+ if (els.settingsClaudeOauthHint) {
3185
+ els.settingsClaudeOauthHint.innerHTML = cfg.claudeOauthTokenSet
3186
+ ? '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.'
3187
+ : '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.';
3188
+ }
3189
+ }
3190
+
2927
3191
  function fillSettingsForm(cfg) {
2928
3192
  if (!els.settingsForm || !cfg) return;
2929
3193
 
3194
+ fillAuthSettings(cfg);
3195
+
2930
3196
  updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
2931
3197
 
2932
3198
  if (els.settingsJiraBaseUrl) {
@@ -2954,24 +3220,37 @@ function fillSettingsForm(cfg) {
2954
3220
  if (els.settingsJiraRuleEnabled) {
2955
3221
  els.settingsJiraRuleEnabled.checked = Boolean(jiraRule.enabled);
2956
3222
  }
3223
+ if (els.settingsJiraRuleAction) {
3224
+ const action = ['none', 'set_status', 'add_label', 'close_issue'].includes(jiraRule.action)
3225
+ ? jiraRule.action
3226
+ : 'none';
3227
+ els.settingsJiraRuleAction.value = action;
3228
+ }
2957
3229
  if (els.settingsJiraRuleStatus) {
2958
3230
  els.settingsJiraRuleStatus.value = jiraRule.targetStatus || 'In Review';
2959
3231
  }
3232
+ if (els.settingsJiraRuleLabel) {
3233
+ els.settingsJiraRuleLabel.value = jiraRule.label || '';
3234
+ }
2960
3235
 
2961
3236
  const ghRule = cfg.githubRules?.afterPrOpened || {};
2962
3237
  if (els.settingsGithubRuleEnabled) {
2963
3238
  els.settingsGithubRuleEnabled.checked = Boolean(ghRule.enabled);
2964
3239
  }
2965
3240
  if (els.settingsGithubRuleAction) {
2966
- const action = ['none', 'add_label', 'close_issue'].includes(ghRule.action)
3241
+ const action = ['none', 'set_status', 'add_label', 'close_issue'].includes(ghRule.action)
2967
3242
  ? ghRule.action
2968
3243
  : 'none';
2969
3244
  els.settingsGithubRuleAction.value = action;
2970
3245
  }
3246
+ if (els.settingsGithubRuleStatus) {
3247
+ els.settingsGithubRuleStatus.value = ghRule.targetStatus || 'In Review';
3248
+ }
2971
3249
  if (els.settingsGithubRuleLabel) {
2972
3250
  els.settingsGithubRuleLabel.value = ghRule.label || '';
2973
3251
  }
2974
- updateGithubRuleLabelVisibility();
3252
+ updateJiraRuleFieldsVisibility();
3253
+ updateGithubRuleFieldsVisibility();
2975
3254
 
2976
3255
  if (els.jiraStatus) {
2977
3256
  els.jiraStatus.textContent = cfg.jiraConfigured
@@ -2987,19 +3266,15 @@ function fillSettingsForm(cfg) {
2987
3266
  }
2988
3267
 
2989
3268
  if (els.settingsModel) {
2990
- const models = Array.isArray(cfg.models) ? cfg.models : [];
2991
- const currentIds = [...els.settingsModel.options].map((o) => o.value);
2992
- const nextIds = models.map((m) => m.id);
2993
- if (currentIds.join(',') !== nextIds.join(',')) {
2994
- els.settingsModel.innerHTML = '';
2995
- for (const m of models) {
2996
- const opt = document.createElement('option');
2997
- opt.value = m.id;
2998
- opt.textContent = m.label || m.id;
2999
- els.settingsModel.appendChild(opt);
3000
- }
3269
+ if (availableModels.length) {
3270
+ populateSettingsModels({ models: availableModels, selected: cfg.model || currentModel });
3271
+ } else if (cfg.model) {
3272
+ populateSettingsModels({
3273
+ models: [{ id: cfg.model, label: cfg.model }],
3274
+ selected: cfg.model,
3275
+ });
3001
3276
  }
3002
- if (cfg.model) els.settingsModel.value = cfg.model;
3277
+ void fetchModels();
3003
3278
  }
3004
3279
 
3005
3280
  if (els.settingsMaxTurns) {
@@ -3041,12 +3316,39 @@ function fillSettingsForm(cfg) {
3041
3316
  }
3042
3317
 
3043
3318
  /**
3044
- * Show GitHub label field only when action is add_label.
3319
+ * Show status / label fields based on the selected post-PR action.
3320
+ * @param {'jira' | 'github'} source
3045
3321
  */
3046
- function updateGithubRuleLabelVisibility() {
3047
- if (!els.settingsGithubRuleLabelField || !els.settingsGithubRuleAction) return;
3048
- const show = els.settingsGithubRuleAction.value === 'add_label';
3049
- els.settingsGithubRuleLabelField.classList.toggle('hidden', !show);
3322
+ function updateRuleFieldsVisibility(source) {
3323
+ const actionEl =
3324
+ source === 'jira' ? els.settingsJiraRuleAction : els.settingsGithubRuleAction;
3325
+ const statusField =
3326
+ source === 'jira'
3327
+ ? els.settingsJiraRuleStatusField
3328
+ : els.settingsGithubRuleStatusField;
3329
+ const labelField =
3330
+ source === 'jira'
3331
+ ? els.settingsJiraRuleLabelField
3332
+ : els.settingsGithubRuleLabelField;
3333
+ if (!actionEl) return;
3334
+ const action = actionEl.value;
3335
+ if (statusField) {
3336
+ statusField.classList.toggle('hidden', action !== 'set_status');
3337
+ }
3338
+ if (labelField) {
3339
+ labelField.classList.toggle('hidden', action !== 'add_label');
3340
+ }
3341
+ }
3342
+
3343
+ function updateJiraRuleFieldsVisibility() {
3344
+ updateRuleFieldsVisibility('jira');
3345
+ }
3346
+
3347
+ /**
3348
+ * Show status / label fields based on the selected GitHub post-PR action.
3349
+ */
3350
+ function updateGithubRuleFieldsVisibility() {
3351
+ updateRuleFieldsVisibility('github');
3050
3352
  }
3051
3353
 
3052
3354
  /**
@@ -3066,12 +3368,8 @@ function updateTicketSourceUI(source) {
3066
3368
  els.jiraConnectPanel.classList.toggle('hidden', ticketSource !== 'jira');
3067
3369
  }
3068
3370
 
3069
- if (els.jiraRulesPanel) {
3070
- els.jiraRulesPanel.classList.toggle('hidden', ticketSource !== 'jira');
3071
- }
3072
- if (els.githubRulesPanel) {
3073
- els.githubRulesPanel.classList.toggle('hidden', ticketSource !== 'github');
3074
- }
3371
+ // Rules tab always shows both GitHub and Jira post-PR options
3372
+ // (ticket source only gates the Jira connect panel above).
3075
3373
 
3076
3374
  if (els.ticketSourceMeta) {
3077
3375
  els.ticketSourceMeta.textContent =
@@ -3121,7 +3419,7 @@ function updateTimeoutMsHint(minutes) {
3121
3419
  function applyConfigSnapshot(data) {
3122
3420
  appConfig = data || {};
3123
3421
  if (data?.model) currentModel = data.model;
3124
- updateModelButtons();
3422
+ updateModelLabel();
3125
3423
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
3126
3424
  if (els.repoName) {
3127
3425
  els.repoName.textContent = data?.repoName || 'local repo';
@@ -3145,35 +3443,6 @@ async function fetchConfig() {
3145
3443
  }
3146
3444
  }
3147
3445
 
3148
- async function setModel(model) {
3149
- if (modelSaving || model === currentModel) return;
3150
- modelSaving = true;
3151
- const prev = currentModel;
3152
- currentModel = model;
3153
- updateModelButtons();
3154
- try {
3155
- const res = await fetch('/api/config', {
3156
- method: 'PATCH',
3157
- headers: { 'Content-Type': 'application/json' },
3158
- body: JSON.stringify({ model }),
3159
- });
3160
- const data = await readJson(res);
3161
- if (!res.ok) {
3162
- currentModel = prev;
3163
- updateModelButtons();
3164
- alert(`Model update failed: ${data.error || `HTTP ${res.status}`}`);
3165
- return;
3166
- }
3167
- applyConfigSnapshot({ ...appConfig, ...data });
3168
- } catch (err) {
3169
- currentModel = prev;
3170
- updateModelButtons();
3171
- alert(`Model update failed: ${err.message}`);
3172
- } finally {
3173
- modelSaving = false;
3174
- }
3175
- }
3176
-
3177
3446
  function readSettingsForm() {
3178
3447
  const baseBranch = els.settingsBaseBranch?.value?.trim() || '';
3179
3448
  const model = els.settingsModel?.value || currentModel;
@@ -3197,6 +3466,13 @@ function readSettingsForm() {
3197
3466
  ticketSource,
3198
3467
  };
3199
3468
 
3469
+ const ghToken = secretPatchValue(els.settingsGhToken);
3470
+ if (ghToken !== undefined) patch.ghToken = ghToken;
3471
+ const anthropicApiKey = secretPatchValue(els.settingsAnthropicKey);
3472
+ if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
3473
+ const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
3474
+ if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
3475
+
3200
3476
  if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
3201
3477
  patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
3202
3478
  patch.jiraPrLinkPhrase =
@@ -3207,28 +3483,31 @@ function readSettingsForm() {
3207
3483
  if (token) patch.jiraApiToken = token;
3208
3484
  }
3209
3485
 
3210
- // Only PATCH the visible source's rules so saving on one source
3211
- // never resets the other source's saved config (updateConfig merges).
3212
- if (ticketSource === 'jira') {
3213
- patch.jiraRules = {
3214
- afterPrOpened: {
3215
- enabled: Boolean(els.settingsJiraRuleEnabled?.checked),
3216
- targetStatus:
3217
- els.settingsJiraRuleStatus?.value?.trim() || 'In Review',
3218
- },
3219
- };
3220
- } else {
3221
- const ghAction = els.settingsGithubRuleAction?.value || 'none';
3222
- patch.githubRules = {
3223
- afterPrOpened: {
3224
- enabled: Boolean(els.settingsGithubRuleEnabled?.checked),
3225
- action: ['none', 'add_label', 'close_issue'].includes(ghAction)
3226
- ? ghAction
3227
- : 'none',
3228
- label: els.settingsGithubRuleLabel?.value?.trim() || '',
3229
- },
3230
- };
3231
- }
3486
+ // Both rule panels are always visible on the Rules tab PATCH both.
3487
+ const jiraAction = els.settingsJiraRuleAction?.value || 'none';
3488
+ patch.jiraRules = {
3489
+ afterPrOpened: {
3490
+ enabled: Boolean(els.settingsJiraRuleEnabled?.checked),
3491
+ action: ['none', 'set_status', 'add_label', 'close_issue'].includes(jiraAction)
3492
+ ? jiraAction
3493
+ : 'none',
3494
+ targetStatus:
3495
+ els.settingsJiraRuleStatus?.value?.trim() || 'In Review',
3496
+ label: els.settingsJiraRuleLabel?.value?.trim() || '',
3497
+ },
3498
+ };
3499
+ const ghAction = els.settingsGithubRuleAction?.value || 'none';
3500
+ patch.githubRules = {
3501
+ afterPrOpened: {
3502
+ enabled: Boolean(els.settingsGithubRuleEnabled?.checked),
3503
+ action: ['none', 'set_status', 'add_label', 'close_issue'].includes(ghAction)
3504
+ ? ghAction
3505
+ : 'none',
3506
+ targetStatus:
3507
+ els.settingsGithubRuleStatus?.value?.trim() || 'In Review',
3508
+ label: els.settingsGithubRuleLabel?.value?.trim() || '',
3509
+ },
3510
+ };
3232
3511
 
3233
3512
  return patch;
3234
3513
  }
@@ -3267,6 +3546,7 @@ async function saveSettings(event) {
3267
3546
  }
3268
3547
  applyConfigSnapshot(data);
3269
3548
  fillSettingsForm(data);
3549
+ void fetchModels({ refresh: true });
3270
3550
  setSettingsFeedback('Settings saved. Next jobs will use these values.', 'ok');
3271
3551
  } catch (err) {
3272
3552
  setSettingsFeedback(err.message || 'Save failed', 'error');
@@ -3286,15 +3566,79 @@ els.themeToggle.addEventListener('click', () => {
3286
3566
  applyTheme(next);
3287
3567
  });
3288
3568
 
3289
- els.modelToggle.querySelectorAll('.model-btn').forEach((btn) => {
3290
- btn.addEventListener('click', () => setModel(btn.dataset.model));
3291
- });
3292
-
3293
3569
  els.settingsForm?.addEventListener('submit', saveSettings);
3294
3570
  els.settingsTimeout?.addEventListener('input', () => {
3295
3571
  updateTimeoutMsHint(els.settingsTimeout.value);
3296
3572
  });
3297
- els.settingsGithubRuleAction?.addEventListener('change', updateGithubRuleLabelVisibility);
3573
+ els.settingsJiraRuleAction?.addEventListener('change', updateJiraRuleFieldsVisibility);
3574
+ els.settingsGithubRuleAction?.addEventListener('change', updateGithubRuleFieldsVisibility);
3575
+
3576
+ els.settingsTabs?.addEventListener('click', (e) => {
3577
+ const btn = e.target.closest('[data-settings-tab]');
3578
+ if (!btn?.dataset.settingsTab) return;
3579
+ setSettingsTab(
3580
+ /** @type {'ticket' | 'auth' | 'rules' | 'config'} */ (btn.dataset.settingsTab)
3581
+ );
3582
+ });
3583
+
3584
+ els.settingsTabs?.addEventListener('keydown', (e) => {
3585
+ const tabs = [...(els.settingsTabs?.querySelectorAll('[role="tab"]') || [])];
3586
+ if (!tabs.length) return;
3587
+ const current = document.activeElement;
3588
+ const idx = tabs.indexOf(/** @type {HTMLElement} */ (current));
3589
+ if (idx < 0) return;
3590
+ let nextIdx = -1;
3591
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
3592
+ nextIdx = (idx + 1) % tabs.length;
3593
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
3594
+ nextIdx = (idx - 1 + tabs.length) % tabs.length;
3595
+ } else if (e.key === 'Home') {
3596
+ nextIdx = 0;
3597
+ } else if (e.key === 'End') {
3598
+ nextIdx = tabs.length - 1;
3599
+ }
3600
+ if (nextIdx < 0) return;
3601
+ e.preventDefault();
3602
+ const nextTab = tabs[nextIdx];
3603
+ setSettingsTab(
3604
+ /** @type {'ticket' | 'auth' | 'rules' | 'config'} */ (nextTab.dataset.settingsTab)
3605
+ );
3606
+ nextTab.focus();
3607
+ });
3608
+
3609
+ /**
3610
+ * @param {HTMLInputElement | null} inputEl
3611
+ * @param {HTMLElement | null} hintEl
3612
+ * @param {string} clearedHint
3613
+ */
3614
+ function bindSecretClear(clearBtn, inputEl, hintEl, clearedHint) {
3615
+ clearBtn?.addEventListener('click', () => {
3616
+ if (!inputEl) return;
3617
+ inputEl.value = '';
3618
+ inputEl.dataset.clear = '1';
3619
+ inputEl.placeholder = 'Will be removed on save';
3620
+ if (hintEl) hintEl.textContent = clearedHint;
3621
+ });
3622
+ }
3623
+
3624
+ bindSecretClear(
3625
+ els.settingsGhTokenClear,
3626
+ els.settingsGhToken,
3627
+ els.settingsGhTokenHint,
3628
+ 'Saved GH_TOKEN will be removed when you click Save settings.'
3629
+ );
3630
+ bindSecretClear(
3631
+ els.settingsAnthropicKeyClear,
3632
+ els.settingsAnthropicKey,
3633
+ els.settingsAnthropicKeyHint,
3634
+ 'Saved API key will be removed on save so subscription login can be used.'
3635
+ );
3636
+ bindSecretClear(
3637
+ els.settingsClaudeOauthClear,
3638
+ els.settingsClaudeOauth,
3639
+ els.settingsClaudeOauthHint,
3640
+ 'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
3641
+ );
3298
3642
 
3299
3643
  els.addBtn.addEventListener('click', async () => {
3300
3644
  els.enqueueFeedback.classList.add('hidden');
@@ -3615,5 +3959,6 @@ els.logJobFilter.addEventListener('change', () => {
3615
3959
 
3616
3960
  applyTheme(loadTheme());
3617
3961
  fetchConfig();
3962
+ fetchModels();
3618
3963
  fetchJobs();
3619
3964
  setInterval(fetchJobs, 3000);