acdev 1.0.2 → 1.0.4

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,20 @@ 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,
166
+ * stubAgent?: boolean,
150
167
  * }} */
151
168
  let appConfig = {};
152
169
 
@@ -157,11 +174,14 @@ const els = {
157
174
  repoName: document.getElementById('repo-name'),
158
175
  repoBranch: document.getElementById('repo-branch'),
159
176
  ticketSourceMeta: document.getElementById('ticket-source-meta'),
160
- modelToggle: document.getElementById('model-toggle'),
177
+ modelCurrent: document.getElementById('model-current'),
161
178
  navBadgeOverview: document.getElementById('nav-badge-overview'),
162
179
  navBadgeRuns: document.getElementById('nav-badge-runs'),
163
180
  navBadgeReview: document.getElementById('nav-badge-review'),
164
181
  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'),
165
185
  issueUrls: document.getElementById('issue-urls'),
166
186
  issueUrlsLabel: document.getElementById('issue-urls-label'),
167
187
  enqueueFeedback: document.getElementById('enqueue-feedback'),
@@ -221,6 +241,7 @@ const els = {
221
241
  settingsForm: document.getElementById('settings-form'),
222
242
  settingsBaseBranch: document.getElementById('settings-base-branch'),
223
243
  settingsModel: document.getElementById('settings-model'),
244
+ settingsModelHint: document.getElementById('settings-model-hint'),
224
245
  settingsMaxTurns: document.getElementById('settings-max-turns'),
225
246
  settingsTimeout: document.getElementById('settings-timeout'),
226
247
  settingsTimeoutMs: document.getElementById('settings-timeout-ms'),
@@ -238,13 +259,30 @@ const els = {
238
259
  jiraTestBtn: document.getElementById('jira-test-btn'),
239
260
  jiraStatus: document.getElementById('jira-status'),
240
261
  settingsJiraRuleEnabled: document.getElementById('settings-jira-rule-enabled'),
262
+ settingsJiraRuleAction: document.getElementById('settings-jira-rule-action'),
241
263
  settingsJiraRuleStatus: document.getElementById('settings-jira-rule-status'),
264
+ settingsJiraRuleStatusField: document.getElementById('settings-jira-rule-status-field'),
265
+ settingsJiraRuleLabel: document.getElementById('settings-jira-rule-label'),
266
+ settingsJiraRuleLabelField: document.getElementById('settings-jira-rule-label-field'),
242
267
  settingsGithubRuleEnabled: document.getElementById('settings-github-rule-enabled'),
243
268
  settingsGithubRuleAction: document.getElementById('settings-github-rule-action'),
269
+ settingsGithubRuleStatus: document.getElementById('settings-github-rule-status'),
270
+ settingsGithubRuleStatusField: document.getElementById('settings-github-rule-status-field'),
244
271
  settingsGithubRuleLabel: document.getElementById('settings-github-rule-label'),
245
272
  settingsGithubRuleLabelField: document.getElementById('settings-github-rule-label-field'),
246
- jiraRulesPanel: document.getElementById('jira-rules-panel'),
247
- githubRulesPanel: document.getElementById('github-rules-panel'),
273
+ settingsGhStatus: document.getElementById('settings-gh-status'),
274
+ settingsGhOrigin: document.getElementById('settings-gh-origin'),
275
+ settingsGhToken: document.getElementById('settings-gh-token'),
276
+ settingsGhTokenClear: document.getElementById('settings-gh-token-clear'),
277
+ settingsGhTokenHint: document.getElementById('settings-gh-token-hint'),
278
+ settingsClaudeStatus: document.getElementById('settings-claude-status'),
279
+ settingsAnthropicKey: document.getElementById('settings-anthropic-key'),
280
+ settingsAnthropicKeyClear: document.getElementById('settings-anthropic-key-clear'),
281
+ settingsAnthropicKeyHint: document.getElementById('settings-anthropic-key-hint'),
282
+ settingsClaudeOauth: document.getElementById('settings-claude-oauth'),
283
+ settingsClaudeOauthClear: document.getElementById('settings-claude-oauth-clear'),
284
+ settingsClaudeOauthHint: document.getElementById('settings-claude-oauth-hint'),
285
+ settingsTabs: document.getElementById('settings-tabs'),
248
286
  };
249
287
 
250
288
  function loadDismissed() {
@@ -2890,13 +2928,94 @@ async function readJson(res) {
2890
2928
  }
2891
2929
  }
2892
2930
 
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;
2931
+ 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`;
2936
+ }
2937
+
2938
+ /**
2939
+ * @param {'anthropic' | 'fallback' | string | undefined} source
2940
+ */
2941
+ function updateModelSourceHint(source) {
2942
+ if (!els.settingsModelHint) return;
2943
+ if (source === 'anthropic') {
2944
+ els.settingsModelHint.textContent =
2945
+ 'Claude model for agent runs. List loaded from Anthropic Models API.';
2946
+ } else if (source === 'fallback') {
2947
+ 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).';
2949
+ } else {
2950
+ els.settingsModelHint.textContent = 'Claude model for agent runs.';
2951
+ }
2952
+ }
2953
+
2954
+ /**
2955
+ * @param {{ models?: Array<{id:string,label?:string,name?:string}>, selected?: string, source?: string }} data
2956
+ */
2957
+ function populateSettingsModels(data) {
2958
+ const models = Array.isArray(data?.models) && data.models.length
2959
+ ? 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
+ }
2988
+ }
2989
+ updateModelLabel();
2990
+ }
2991
+
2992
+ async function fetchModels(opts = {}) {
2993
+ const refresh = opts.refresh === true;
2994
+ try {
2995
+ const res = await fetch(refresh ? '/api/models?refresh=1' : '/api/models');
2996
+ if (!res.ok) {
2997
+ populateSettingsModels({
2998
+ models: availableModels.length
2999
+ ? availableModels
3000
+ : [
3001
+ { id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
3002
+ ],
3003
+ selected: currentModel,
3004
+ });
3005
+ return;
3006
+ }
3007
+ const data = await readJson(res);
3008
+ populateSettingsModels(data);
3009
+ } catch (err) {
3010
+ console.error('Failed to fetch models:', err);
3011
+ populateSettingsModels({
3012
+ models: availableModels.length
3013
+ ? availableModels
3014
+ : [
3015
+ { id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
3016
+ ],
3017
+ selected: currentModel,
3018
+ });
2900
3019
  }
2901
3020
  }
2902
3021
 
@@ -2924,9 +3043,160 @@ function knownToolsList(cfg = appConfig) {
2924
3043
  : DEFAULT_KNOWN_TOOLS;
2925
3044
  }
2926
3045
 
3046
+ /**
3047
+ * Switch Settings top-level tab (Ticket source / Authentication / Rules / Configuration).
3048
+ * @param {'ticket' | 'auth' | 'rules' | 'config'} tab
3049
+ */
3050
+ function setSettingsTab(tab) {
3051
+ const next =
3052
+ tab === 'auth' || tab === 'rules' || tab === 'config' ? tab : 'ticket';
3053
+ settingsTab = next;
3054
+
3055
+ if (els.settingsTabs) {
3056
+ els.settingsTabs.querySelectorAll('[role="tab"]').forEach((btn) => {
3057
+ const selected = btn.dataset.settingsTab === next;
3058
+ btn.setAttribute('aria-selected', selected ? 'true' : 'false');
3059
+ btn.tabIndex = selected ? 0 : -1;
3060
+ });
3061
+ }
3062
+
3063
+ document.querySelectorAll('[data-settings-panel]').forEach((panel) => {
3064
+ const show = panel.dataset.settingsPanel === next;
3065
+ panel.classList.toggle('hidden', !show);
3066
+ });
3067
+
3068
+ if (next === 'config') {
3069
+ void fetchModels();
3070
+ }
3071
+ }
3072
+
3073
+ /**
3074
+ * Empty = keep existing secret; data-clear + empty = send null to clear.
3075
+ * @param {HTMLInputElement | null} inputEl
3076
+ * @returns {string | null | undefined}
3077
+ */
3078
+ function secretPatchValue(inputEl) {
3079
+ const typed = inputEl?.value?.trim() || '';
3080
+ if (typed) return typed;
3081
+ if (inputEl?.dataset.clear === '1') return null;
3082
+ return undefined;
3083
+ }
3084
+
3085
+ /**
3086
+ * @param {HTMLInputElement | null} inputEl
3087
+ * @param {HTMLButtonElement | null} clearBtn
3088
+ * @param {boolean} set
3089
+ * @param {string | null | undefined} masked
3090
+ * @param {string} unsetPlaceholder
3091
+ */
3092
+ function fillSecretInput(inputEl, clearBtn, set, masked, unsetPlaceholder) {
3093
+ if (!inputEl) return;
3094
+ inputEl.value = '';
3095
+ delete inputEl.dataset.clear;
3096
+ inputEl.placeholder = set
3097
+ ? `Saved (${masked || '••••'}) — leave blank to keep`
3098
+ : unsetPlaceholder;
3099
+ if (clearBtn) {
3100
+ clearBtn.classList.toggle('hidden', !set);
3101
+ }
3102
+ }
3103
+
3104
+ function ghStatusText(cfg) {
3105
+ if (!cfg.ghInstalled) {
3106
+ return 'gh is not installed (required for issues and PRs)';
3107
+ }
3108
+ if (cfg.ghAuthOk) {
3109
+ const method =
3110
+ cfg.ghAuthMethod === 'token'
3111
+ ? 'env token (GH_TOKEN)'
3112
+ : 'gh auth login';
3113
+ return `Authenticated via ${method}`;
3114
+ }
3115
+ if (cfg.ghTokenSet) {
3116
+ return 'Token saved, but gh is not authenticated — check the token';
3117
+ }
3118
+ return 'Not authenticated — paste a PAT or run gh auth login';
3119
+ }
3120
+
3121
+ function claudeStatusText(cfg) {
3122
+ switch (cfg.claudeAuthMethod) {
3123
+ case 'api-key':
3124
+ return 'Authenticated via ANTHROPIC_API_KEY (API billing)';
3125
+ case 'auth-token':
3126
+ return 'Authenticated via ANTHROPIC_AUTH_TOKEN';
3127
+ case 'oauth-token':
3128
+ return 'Authenticated via CLAUDE_CODE_OAUTH_TOKEN';
3129
+ case 'claude-code-login':
3130
+ return 'Authenticated via claude auth login (subscription)';
3131
+ default:
3132
+ return 'Not authenticated — add an API key, OAuth token, or run claude auth login';
3133
+ }
3134
+ }
3135
+
3136
+ function fillAuthSettings(cfg) {
3137
+ if (els.settingsGhStatus) {
3138
+ els.settingsGhStatus.textContent = ghStatusText(cfg);
3139
+ els.settingsGhStatus.className =
3140
+ cfg.ghAuthOk ? 'auth-status ok' : 'auth-status err';
3141
+ }
3142
+ if (els.settingsGhOrigin) {
3143
+ if (cfg.originRemoteUrl) {
3144
+ els.settingsGhOrigin.textContent = cfg.originRemoteSsh
3145
+ ? `origin: ${cfg.originRemoteUrl} (SSH — git push/fetch only, not gh API auth)`
3146
+ : `origin: ${cfg.originRemoteUrl}`;
3147
+ } else {
3148
+ els.settingsGhOrigin.textContent = '';
3149
+ }
3150
+ }
3151
+ fillSecretInput(
3152
+ els.settingsGhToken,
3153
+ els.settingsGhTokenClear,
3154
+ Boolean(cfg.ghTokenSet),
3155
+ cfg.ghTokenMasked,
3156
+ 'Paste a GitHub PAT'
3157
+ );
3158
+ if (els.settingsGhTokenHint) {
3159
+ els.settingsGhTokenHint.innerHTML = cfg.ghTokenSet
3160
+ ? '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.'
3161
+ : '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.';
3162
+ }
3163
+
3164
+ if (els.settingsClaudeStatus) {
3165
+ els.settingsClaudeStatus.textContent = claudeStatusText(cfg);
3166
+ els.settingsClaudeStatus.className =
3167
+ cfg.claudeAuthOk ? 'auth-status ok' : 'auth-status err';
3168
+ }
3169
+ fillSecretInput(
3170
+ els.settingsAnthropicKey,
3171
+ els.settingsAnthropicKeyClear,
3172
+ Boolean(cfg.anthropicApiKeySet),
3173
+ cfg.anthropicApiKeyMasked,
3174
+ 'Paste an Anthropic API key'
3175
+ );
3176
+ fillSecretInput(
3177
+ els.settingsClaudeOauth,
3178
+ els.settingsClaudeOauthClear,
3179
+ Boolean(cfg.claudeOauthTokenSet),
3180
+ cfg.claudeOauthTokenMasked,
3181
+ 'Paste token from claude setup-token'
3182
+ );
3183
+ if (els.settingsAnthropicKeyHint) {
3184
+ els.settingsAnthropicKeyHint.innerHTML = cfg.anthropicApiKeySet
3185
+ ? 'API key stored in <code>.acdev/.env</code> (not committed). Takes precedence over subscription login — clear it to use <code>claude auth login</code>.'
3186
+ : '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.';
3187
+ }
3188
+ if (els.settingsClaudeOauthHint) {
3189
+ els.settingsClaudeOauthHint.innerHTML = cfg.claudeOauthTokenSet
3190
+ ? '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
+ : '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
+ }
3193
+ }
3194
+
2927
3195
  function fillSettingsForm(cfg) {
2928
3196
  if (!els.settingsForm || !cfg) return;
2929
3197
 
3198
+ fillAuthSettings(cfg);
3199
+
2930
3200
  updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
2931
3201
 
2932
3202
  if (els.settingsJiraBaseUrl) {
@@ -2954,24 +3224,37 @@ function fillSettingsForm(cfg) {
2954
3224
  if (els.settingsJiraRuleEnabled) {
2955
3225
  els.settingsJiraRuleEnabled.checked = Boolean(jiraRule.enabled);
2956
3226
  }
3227
+ if (els.settingsJiraRuleAction) {
3228
+ const action = ['none', 'set_status', 'add_label', 'close_issue'].includes(jiraRule.action)
3229
+ ? jiraRule.action
3230
+ : 'none';
3231
+ els.settingsJiraRuleAction.value = action;
3232
+ }
2957
3233
  if (els.settingsJiraRuleStatus) {
2958
3234
  els.settingsJiraRuleStatus.value = jiraRule.targetStatus || 'In Review';
2959
3235
  }
3236
+ if (els.settingsJiraRuleLabel) {
3237
+ els.settingsJiraRuleLabel.value = jiraRule.label || '';
3238
+ }
2960
3239
 
2961
3240
  const ghRule = cfg.githubRules?.afterPrOpened || {};
2962
3241
  if (els.settingsGithubRuleEnabled) {
2963
3242
  els.settingsGithubRuleEnabled.checked = Boolean(ghRule.enabled);
2964
3243
  }
2965
3244
  if (els.settingsGithubRuleAction) {
2966
- const action = ['none', 'add_label', 'close_issue'].includes(ghRule.action)
3245
+ const action = ['none', 'set_status', 'add_label', 'close_issue'].includes(ghRule.action)
2967
3246
  ? ghRule.action
2968
3247
  : 'none';
2969
3248
  els.settingsGithubRuleAction.value = action;
2970
3249
  }
3250
+ if (els.settingsGithubRuleStatus) {
3251
+ els.settingsGithubRuleStatus.value = ghRule.targetStatus || 'In Review';
3252
+ }
2971
3253
  if (els.settingsGithubRuleLabel) {
2972
3254
  els.settingsGithubRuleLabel.value = ghRule.label || '';
2973
3255
  }
2974
- updateGithubRuleLabelVisibility();
3256
+ updateJiraRuleFieldsVisibility();
3257
+ updateGithubRuleFieldsVisibility();
2975
3258
 
2976
3259
  if (els.jiraStatus) {
2977
3260
  els.jiraStatus.textContent = cfg.jiraConfigured
@@ -2987,19 +3270,15 @@ function fillSettingsForm(cfg) {
2987
3270
  }
2988
3271
 
2989
3272
  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
- }
3273
+ if (availableModels.length) {
3274
+ populateSettingsModels({ models: availableModels, selected: cfg.model || currentModel });
3275
+ } else if (cfg.model) {
3276
+ populateSettingsModels({
3277
+ models: [{ id: cfg.model, label: cfg.model }],
3278
+ selected: cfg.model,
3279
+ });
3001
3280
  }
3002
- if (cfg.model) els.settingsModel.value = cfg.model;
3281
+ void fetchModels();
3003
3282
  }
3004
3283
 
3005
3284
  if (els.settingsMaxTurns) {
@@ -3041,12 +3320,39 @@ function fillSettingsForm(cfg) {
3041
3320
  }
3042
3321
 
3043
3322
  /**
3044
- * Show GitHub label field only when action is add_label.
3323
+ * Show status / label fields based on the selected post-PR action.
3324
+ * @param {'jira' | 'github'} source
3045
3325
  */
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);
3326
+ function updateRuleFieldsVisibility(source) {
3327
+ const actionEl =
3328
+ source === 'jira' ? els.settingsJiraRuleAction : els.settingsGithubRuleAction;
3329
+ const statusField =
3330
+ source === 'jira'
3331
+ ? els.settingsJiraRuleStatusField
3332
+ : els.settingsGithubRuleStatusField;
3333
+ const labelField =
3334
+ source === 'jira'
3335
+ ? els.settingsJiraRuleLabelField
3336
+ : els.settingsGithubRuleLabelField;
3337
+ if (!actionEl) return;
3338
+ const action = actionEl.value;
3339
+ if (statusField) {
3340
+ statusField.classList.toggle('hidden', action !== 'set_status');
3341
+ }
3342
+ if (labelField) {
3343
+ labelField.classList.toggle('hidden', action !== 'add_label');
3344
+ }
3345
+ }
3346
+
3347
+ function updateJiraRuleFieldsVisibility() {
3348
+ updateRuleFieldsVisibility('jira');
3349
+ }
3350
+
3351
+ /**
3352
+ * Show status / label fields based on the selected GitHub post-PR action.
3353
+ */
3354
+ function updateGithubRuleFieldsVisibility() {
3355
+ updateRuleFieldsVisibility('github');
3050
3356
  }
3051
3357
 
3052
3358
  /**
@@ -3066,12 +3372,8 @@ function updateTicketSourceUI(source) {
3066
3372
  els.jiraConnectPanel.classList.toggle('hidden', ticketSource !== 'jira');
3067
3373
  }
3068
3374
 
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
- }
3375
+ // Rules tab always shows both GitHub and Jira post-PR options
3376
+ // (ticket source only gates the Jira connect panel above).
3075
3377
 
3076
3378
  if (els.ticketSourceMeta) {
3077
3379
  els.ticketSourceMeta.textContent =
@@ -3121,7 +3423,7 @@ function updateTimeoutMsHint(minutes) {
3121
3423
  function applyConfigSnapshot(data) {
3122
3424
  appConfig = data || {};
3123
3425
  if (data?.model) currentModel = data.model;
3124
- updateModelButtons();
3426
+ updateModelLabel();
3125
3427
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
3126
3428
  if (els.repoName) {
3127
3429
  els.repoName.textContent = data?.repoName || 'local repo';
@@ -3129,11 +3431,33 @@ function applyConfigSnapshot(data) {
3129
3431
  if (els.repoBranch) {
3130
3432
  els.repoBranch.textContent = data?.baseBranch || '—';
3131
3433
  }
3434
+ updateAuthBanner(appConfig);
3132
3435
  if (currentView === 'settings') {
3133
3436
  fillSettingsForm(appConfig);
3134
3437
  }
3135
3438
  }
3136
3439
 
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
+
3137
3461
  async function fetchConfig() {
3138
3462
  try {
3139
3463
  const res = await fetch('/api/config');
@@ -3145,35 +3469,6 @@ async function fetchConfig() {
3145
3469
  }
3146
3470
  }
3147
3471
 
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
3472
  function readSettingsForm() {
3178
3473
  const baseBranch = els.settingsBaseBranch?.value?.trim() || '';
3179
3474
  const model = els.settingsModel?.value || currentModel;
@@ -3197,6 +3492,13 @@ function readSettingsForm() {
3197
3492
  ticketSource,
3198
3493
  };
3199
3494
 
3495
+ const ghToken = secretPatchValue(els.settingsGhToken);
3496
+ if (ghToken !== undefined) patch.ghToken = ghToken;
3497
+ const anthropicApiKey = secretPatchValue(els.settingsAnthropicKey);
3498
+ if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
3499
+ const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
3500
+ if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
3501
+
3200
3502
  if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
3201
3503
  patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
3202
3504
  patch.jiraPrLinkPhrase =
@@ -3207,28 +3509,31 @@ function readSettingsForm() {
3207
3509
  if (token) patch.jiraApiToken = token;
3208
3510
  }
3209
3511
 
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
- }
3512
+ // Both rule panels are always visible on the Rules tab PATCH both.
3513
+ const jiraAction = els.settingsJiraRuleAction?.value || 'none';
3514
+ patch.jiraRules = {
3515
+ afterPrOpened: {
3516
+ enabled: Boolean(els.settingsJiraRuleEnabled?.checked),
3517
+ action: ['none', 'set_status', 'add_label', 'close_issue'].includes(jiraAction)
3518
+ ? jiraAction
3519
+ : 'none',
3520
+ targetStatus:
3521
+ els.settingsJiraRuleStatus?.value?.trim() || 'In Review',
3522
+ label: els.settingsJiraRuleLabel?.value?.trim() || '',
3523
+ },
3524
+ };
3525
+ const ghAction = els.settingsGithubRuleAction?.value || 'none';
3526
+ patch.githubRules = {
3527
+ afterPrOpened: {
3528
+ enabled: Boolean(els.settingsGithubRuleEnabled?.checked),
3529
+ action: ['none', 'set_status', 'add_label', 'close_issue'].includes(ghAction)
3530
+ ? ghAction
3531
+ : 'none',
3532
+ targetStatus:
3533
+ els.settingsGithubRuleStatus?.value?.trim() || 'In Review',
3534
+ label: els.settingsGithubRuleLabel?.value?.trim() || '',
3535
+ },
3536
+ };
3232
3537
 
3233
3538
  return patch;
3234
3539
  }
@@ -3267,6 +3572,7 @@ async function saveSettings(event) {
3267
3572
  }
3268
3573
  applyConfigSnapshot(data);
3269
3574
  fillSettingsForm(data);
3575
+ void fetchModels({ refresh: true });
3270
3576
  setSettingsFeedback('Settings saved. Next jobs will use these values.', 'ok');
3271
3577
  } catch (err) {
3272
3578
  setSettingsFeedback(err.message || 'Save failed', 'error');
@@ -3281,20 +3587,89 @@ document.querySelectorAll('.nav-item').forEach((btn) => {
3281
3587
  btn.addEventListener('click', () => setView(/** @type {ViewId} */ (btn.dataset.view)));
3282
3588
  });
3283
3589
 
3590
+ els.authBannerBtn?.addEventListener('click', () => {
3591
+ setView('settings');
3592
+ setSettingsTab('auth');
3593
+ });
3594
+
3284
3595
  els.themeToggle.addEventListener('click', () => {
3285
3596
  const next = els.app.dataset.theme === 'dark' ? 'light' : 'dark';
3286
3597
  applyTheme(next);
3287
3598
  });
3288
3599
 
3289
- els.modelToggle.querySelectorAll('.model-btn').forEach((btn) => {
3290
- btn.addEventListener('click', () => setModel(btn.dataset.model));
3291
- });
3292
-
3293
3600
  els.settingsForm?.addEventListener('submit', saveSettings);
3294
3601
  els.settingsTimeout?.addEventListener('input', () => {
3295
3602
  updateTimeoutMsHint(els.settingsTimeout.value);
3296
3603
  });
3297
- els.settingsGithubRuleAction?.addEventListener('change', updateGithubRuleLabelVisibility);
3604
+ els.settingsJiraRuleAction?.addEventListener('change', updateJiraRuleFieldsVisibility);
3605
+ els.settingsGithubRuleAction?.addEventListener('change', updateGithubRuleFieldsVisibility);
3606
+
3607
+ els.settingsTabs?.addEventListener('click', (e) => {
3608
+ const btn = e.target.closest('[data-settings-tab]');
3609
+ if (!btn?.dataset.settingsTab) return;
3610
+ setSettingsTab(
3611
+ /** @type {'ticket' | 'auth' | 'rules' | 'config'} */ (btn.dataset.settingsTab)
3612
+ );
3613
+ });
3614
+
3615
+ els.settingsTabs?.addEventListener('keydown', (e) => {
3616
+ const tabs = [...(els.settingsTabs?.querySelectorAll('[role="tab"]') || [])];
3617
+ if (!tabs.length) return;
3618
+ const current = document.activeElement;
3619
+ const idx = tabs.indexOf(/** @type {HTMLElement} */ (current));
3620
+ if (idx < 0) return;
3621
+ let nextIdx = -1;
3622
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
3623
+ nextIdx = (idx + 1) % tabs.length;
3624
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
3625
+ nextIdx = (idx - 1 + tabs.length) % tabs.length;
3626
+ } else if (e.key === 'Home') {
3627
+ nextIdx = 0;
3628
+ } else if (e.key === 'End') {
3629
+ nextIdx = tabs.length - 1;
3630
+ }
3631
+ if (nextIdx < 0) return;
3632
+ e.preventDefault();
3633
+ const nextTab = tabs[nextIdx];
3634
+ setSettingsTab(
3635
+ /** @type {'ticket' | 'auth' | 'rules' | 'config'} */ (nextTab.dataset.settingsTab)
3636
+ );
3637
+ nextTab.focus();
3638
+ });
3639
+
3640
+ /**
3641
+ * @param {HTMLInputElement | null} inputEl
3642
+ * @param {HTMLElement | null} hintEl
3643
+ * @param {string} clearedHint
3644
+ */
3645
+ function bindSecretClear(clearBtn, inputEl, hintEl, clearedHint) {
3646
+ clearBtn?.addEventListener('click', () => {
3647
+ if (!inputEl) return;
3648
+ inputEl.value = '';
3649
+ inputEl.dataset.clear = '1';
3650
+ inputEl.placeholder = 'Will be removed on save';
3651
+ if (hintEl) hintEl.textContent = clearedHint;
3652
+ });
3653
+ }
3654
+
3655
+ bindSecretClear(
3656
+ els.settingsGhTokenClear,
3657
+ els.settingsGhToken,
3658
+ els.settingsGhTokenHint,
3659
+ 'Saved GH_TOKEN will be removed when you click Save settings.'
3660
+ );
3661
+ bindSecretClear(
3662
+ els.settingsAnthropicKeyClear,
3663
+ els.settingsAnthropicKey,
3664
+ els.settingsAnthropicKeyHint,
3665
+ 'Saved API key will be removed on save so subscription login can be used.'
3666
+ );
3667
+ bindSecretClear(
3668
+ els.settingsClaudeOauthClear,
3669
+ els.settingsClaudeOauth,
3670
+ els.settingsClaudeOauthHint,
3671
+ 'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
3672
+ );
3298
3673
 
3299
3674
  els.addBtn.addEventListener('click', async () => {
3300
3675
  els.enqueueFeedback.classList.add('hidden');
@@ -3615,5 +3990,6 @@ els.logJobFilter.addEventListener('change', () => {
3615
3990
 
3616
3991
  applyTheme(loadTheme());
3617
3992
  fetchConfig();
3993
+ fetchModels();
3618
3994
  fetchJobs();
3619
3995
  setInterval(fetchJobs, 3000);