acdev 1.0.1 → 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
@@ -108,6 +108,10 @@ let reviewSearch = loadReviewSearch();
108
108
  let activeInlineCommentKey = null;
109
109
  /** @type {string | null} id of line comment currently being edited */
110
110
  let editingCommentId = null;
111
+ /** @type {'inline' | 'pending' | null} where the edit form should render */
112
+ let editingCommentSurface = null;
113
+ /** Original body when edit started — restored on Cancel after live sync. */
114
+ let editingCommentOriginalBody = null;
111
115
  /** @type {Record<string, {
112
116
  * generalComment: string,
113
117
  * lineComments: Array<{ id: string, path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>,
@@ -120,10 +124,13 @@ let reviewDrafts = {};
120
124
  let reviewFileLists = {};
121
125
  /** @type {Record<string, Set<string>>} excluded paths per job while reviewing */
122
126
  let reviewExcludedByJob = {};
123
- let modelSaving = false;
124
127
  let settingsSaving = false;
128
+ /** @type {'ticket' | 'auth' | 'rules' | 'config'} */
129
+ let settingsTab = 'ticket';
125
130
  /** @type {string} */
126
131
  let currentModel = 'claude-sonnet-5';
132
+ /** @type {Array<{id:string,label?:string,name?:string}>} */
133
+ let availableModels = [];
127
134
  /** @type {'github' | 'jira'} */
128
135
  let ticketSource = 'github';
129
136
  /** @type {{
@@ -143,6 +150,19 @@ let ticketSource = 'github';
143
150
  * jiraApiTokenMasked?: string | null,
144
151
  * jiraConfigured?: boolean,
145
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,
146
166
  * }} */
147
167
  let appConfig = {};
148
168
 
@@ -153,7 +173,7 @@ const els = {
153
173
  repoName: document.getElementById('repo-name'),
154
174
  repoBranch: document.getElementById('repo-branch'),
155
175
  ticketSourceMeta: document.getElementById('ticket-source-meta'),
156
- modelToggle: document.getElementById('model-toggle'),
176
+ modelCurrent: document.getElementById('model-current'),
157
177
  navBadgeOverview: document.getElementById('nav-badge-overview'),
158
178
  navBadgeRuns: document.getElementById('nav-badge-runs'),
159
179
  navBadgeReview: document.getElementById('nav-badge-review'),
@@ -217,6 +237,7 @@ const els = {
217
237
  settingsForm: document.getElementById('settings-form'),
218
238
  settingsBaseBranch: document.getElementById('settings-base-branch'),
219
239
  settingsModel: document.getElementById('settings-model'),
240
+ settingsModelHint: document.getElementById('settings-model-hint'),
220
241
  settingsMaxTurns: document.getElementById('settings-max-turns'),
221
242
  settingsTimeout: document.getElementById('settings-timeout'),
222
243
  settingsTimeoutMs: document.getElementById('settings-timeout-ms'),
@@ -234,13 +255,30 @@ const els = {
234
255
  jiraTestBtn: document.getElementById('jira-test-btn'),
235
256
  jiraStatus: document.getElementById('jira-status'),
236
257
  settingsJiraRuleEnabled: document.getElementById('settings-jira-rule-enabled'),
258
+ settingsJiraRuleAction: document.getElementById('settings-jira-rule-action'),
237
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'),
238
263
  settingsGithubRuleEnabled: document.getElementById('settings-github-rule-enabled'),
239
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'),
240
267
  settingsGithubRuleLabel: document.getElementById('settings-github-rule-label'),
241
268
  settingsGithubRuleLabelField: document.getElementById('settings-github-rule-label-field'),
242
- jiraRulesPanel: document.getElementById('jira-rules-panel'),
243
- 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'),
244
282
  };
245
283
 
246
284
  function loadDismissed() {
@@ -447,6 +485,8 @@ function clearReviewDraft(jobId) {
447
485
  delete reviewDrafts[jobId];
448
486
  activeInlineCommentKey = null;
449
487
  editingCommentId = null;
488
+ editingCommentSurface = null;
489
+ editingCommentOriginalBody = null;
450
490
  try {
451
491
  sessionStorage.removeItem(reviewDraftStorageKey(jobId));
452
492
  } catch {
@@ -454,6 +494,86 @@ function clearReviewDraft(jobId) {
454
494
  }
455
495
  }
456
496
 
497
+ /** Clear in-progress line-comment edit state. */
498
+ function clearCommentEditState() {
499
+ editingCommentId = null;
500
+ editingCommentSurface = null;
501
+ editingCommentOriginalBody = null;
502
+ }
503
+
504
+ /**
505
+ * @param {string} jobId
506
+ * @param {string} commentId
507
+ * @param {string} body
508
+ */
509
+ function updateLineCommentBody(jobId, commentId, body) {
510
+ const d = loadReviewDraft(jobId);
511
+ const target = d.lineComments.find((x) => x.id === commentId);
512
+ if (!target) return;
513
+ target.body = body;
514
+ saveReviewDraft(jobId);
515
+ }
516
+
517
+ /**
518
+ * Refresh the non-active edit surface so both views stay in sync.
519
+ * @param {string} jobId
520
+ */
521
+ function mirrorCommentEditToOtherSurface(jobId) {
522
+ if (editingCommentSurface === 'inline') {
523
+ renderPendingCommentList(jobId);
524
+ syncSubmitReviewButton(jobId);
525
+ return;
526
+ }
527
+ if (editingCommentSurface === 'pending') {
528
+ const job = jobsById[jobId];
529
+ if (job) {
530
+ const diff =
531
+ job.status === 'awaiting_review'
532
+ ? filterDiffForReviewJob(job)
533
+ : job.diff || '';
534
+ renderDiff(diff, {
535
+ interactive: job.status === 'awaiting_review',
536
+ jobId,
537
+ });
538
+ }
539
+ syncSubmitReviewButton(jobId);
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Commit or discard an in-progress comment edit.
545
+ * @param {string} jobId
546
+ * @param {string} commentId
547
+ * @param {string | null} nextBody null = cancel / restore original
548
+ */
549
+ function finishCommentEdit(jobId, commentId, nextBody) {
550
+ if (nextBody == null) {
551
+ if (editingCommentOriginalBody != null) {
552
+ updateLineCommentBody(jobId, commentId, editingCommentOriginalBody);
553
+ }
554
+ } else {
555
+ updateLineCommentBody(jobId, commentId, nextBody);
556
+ }
557
+ clearCommentEditState();
558
+ refreshReviewCommentUi(jobId);
559
+ }
560
+
561
+ /**
562
+ * Start editing a pending line comment on a specific surface.
563
+ * @param {string} commentId
564
+ * @param {'inline' | 'pending'} surface
565
+ * @param {string} jobId
566
+ */
567
+ function startCommentEdit(commentId, surface, jobId) {
568
+ const draft = loadReviewDraft(jobId);
569
+ const target = draft.lineComments.find((x) => x.id === commentId);
570
+ editingCommentId = commentId;
571
+ editingCommentSurface = surface;
572
+ editingCommentOriginalBody = target ? target.body : '';
573
+ activeInlineCommentKey = null;
574
+ refreshReviewCommentUi(jobId);
575
+ }
576
+
457
577
  /**
458
578
  * Re-render diff + pending list after draft mutation.
459
579
  * @param {string} jobId
@@ -480,6 +600,7 @@ function refreshReviewCommentUi(jobId) {
480
600
  * saveLabel?: string,
481
601
  * onSave: (body: string) => void,
482
602
  * onCancel: () => void,
603
+ * onInput?: (body: string) => void,
483
604
  * }} opts
484
605
  */
485
606
  function buildCommentEditForm(opts) {
@@ -490,6 +611,9 @@ function buildCommentEditForm(opts) {
490
611
  ta.rows = 3;
491
612
  ta.value = opts.initialBody;
492
613
  ta.placeholder = 'Edit comment…';
614
+ if (opts.onInput) {
615
+ ta.addEventListener('input', () => opts.onInput(ta.value));
616
+ }
493
617
  const actions = document.createElement('div');
494
618
  actions.className = 'diff-inline-actions';
495
619
  const save = document.createElement('button');
@@ -514,6 +638,7 @@ function buildCommentEditForm(opts) {
514
638
  ta.focus();
515
639
  const len = ta.value.length;
516
640
  ta.setSelectionRange(len, len);
641
+ ta.scrollIntoView({ block: 'nearest', inline: 'nearest' });
517
642
  });
518
643
  return form;
519
644
  }
@@ -1317,7 +1442,7 @@ function renderDiff(diff, opts = {}) {
1317
1442
  plus.addEventListener('click', (e) => {
1318
1443
  e.stopPropagation();
1319
1444
  activeInlineCommentKey = lineCommentKey(path, line, side);
1320
- editingCommentId = null;
1445
+ clearCommentEditState();
1321
1446
  renderDiff(diff, opts);
1322
1447
  renderPendingCommentList(jobId);
1323
1448
  syncSubmitReviewButton(jobId);
@@ -1327,7 +1452,7 @@ function renderDiff(diff, opts = {}) {
1327
1452
  cell.addEventListener('click', (e) => {
1328
1453
  if (e.target.closest('button, textarea, a')) return;
1329
1454
  activeInlineCommentKey = lineCommentKey(path, line, side);
1330
- editingCommentId = null;
1455
+ clearCommentEditState();
1331
1456
  renderDiff(diff, opts);
1332
1457
  renderPendingCommentList(jobId);
1333
1458
  syncSubmitReviewButton(jobId);
@@ -1382,22 +1507,16 @@ function renderDiff(diff, opts = {}) {
1382
1507
  thread.appendChild(meta);
1383
1508
 
1384
1509
  for (const c of existing) {
1385
- if (editingCommentId === c.id) {
1510
+ if (editingCommentId === c.id && editingCommentSurface === 'inline') {
1386
1511
  thread.appendChild(
1387
1512
  buildCommentEditForm({
1388
1513
  initialBody: c.body,
1389
- onSave: (body) => {
1390
- const d = loadReviewDraft(jobId);
1391
- const target = d.lineComments.find((x) => x.id === c.id);
1392
- if (target) target.body = body;
1393
- saveReviewDraft(jobId);
1394
- editingCommentId = null;
1395
- refreshReviewCommentUi(jobId);
1396
- },
1397
- onCancel: () => {
1398
- editingCommentId = null;
1399
- refreshReviewCommentUi(jobId);
1514
+ onInput: (body) => {
1515
+ updateLineCommentBody(jobId, c.id, body);
1516
+ mirrorCommentEditToOtherSurface(jobId);
1400
1517
  },
1518
+ onSave: (body) => finishCommentEdit(jobId, c.id, body),
1519
+ onCancel: () => finishCommentEdit(jobId, c.id, null),
1401
1520
  })
1402
1521
  );
1403
1522
  continue;
@@ -1415,9 +1534,7 @@ function renderDiff(diff, opts = {}) {
1415
1534
  edit.className = 'btn btn-muted-text btn-sm';
1416
1535
  edit.textContent = 'Edit';
1417
1536
  edit.addEventListener('click', () => {
1418
- editingCommentId = c.id;
1419
- activeInlineCommentKey = null;
1420
- refreshReviewCommentUi(jobId);
1537
+ startCommentEdit(c.id, 'inline', jobId);
1421
1538
  });
1422
1539
  const remove = document.createElement('button');
1423
1540
  remove.type = 'button';
@@ -1427,7 +1544,7 @@ function renderDiff(diff, opts = {}) {
1427
1544
  const d = loadReviewDraft(jobId);
1428
1545
  d.lineComments = d.lineComments.filter((x) => x.id !== c.id);
1429
1546
  saveReviewDraft(jobId);
1430
- if (editingCommentId === c.id) editingCommentId = null;
1547
+ if (editingCommentId === c.id) clearCommentEditState();
1431
1548
  refreshReviewCommentUi(jobId);
1432
1549
  });
1433
1550
  actions.appendChild(edit);
@@ -1684,22 +1801,16 @@ function renderPendingCommentList(jobId) {
1684
1801
  loc.textContent = `${c.path}:${c.line} (${c.side})`;
1685
1802
  main.appendChild(loc);
1686
1803
 
1687
- if (editingCommentId === c.id) {
1804
+ if (editingCommentId === c.id && editingCommentSurface === 'pending') {
1688
1805
  main.appendChild(
1689
1806
  buildCommentEditForm({
1690
1807
  initialBody: c.body,
1691
- onSave: (body) => {
1692
- const d = loadReviewDraft(jobId);
1693
- const target = d.lineComments.find((x) => x.id === c.id);
1694
- if (target) target.body = body;
1695
- saveReviewDraft(jobId);
1696
- editingCommentId = null;
1697
- refreshReviewCommentUi(jobId);
1698
- },
1699
- onCancel: () => {
1700
- editingCommentId = null;
1701
- refreshReviewCommentUi(jobId);
1808
+ onInput: (body) => {
1809
+ updateLineCommentBody(jobId, c.id, body);
1810
+ mirrorCommentEditToOtherSurface(jobId);
1702
1811
  },
1812
+ onSave: (body) => finishCommentEdit(jobId, c.id, body),
1813
+ onCancel: () => finishCommentEdit(jobId, c.id, null),
1703
1814
  })
1704
1815
  );
1705
1816
  item.appendChild(main);
@@ -1718,9 +1829,7 @@ function renderPendingCommentList(jobId) {
1718
1829
  edit.className = 'btn btn-muted-text btn-sm';
1719
1830
  edit.textContent = 'Edit';
1720
1831
  edit.addEventListener('click', () => {
1721
- editingCommentId = c.id;
1722
- activeInlineCommentKey = null;
1723
- refreshReviewCommentUi(jobId);
1832
+ startCommentEdit(c.id, 'pending', jobId);
1724
1833
  });
1725
1834
  const remove = document.createElement('button');
1726
1835
  remove.type = 'button';
@@ -1730,7 +1839,7 @@ function renderPendingCommentList(jobId) {
1730
1839
  const d = loadReviewDraft(jobId);
1731
1840
  d.lineComments = d.lineComments.filter((x) => x.id !== c.id);
1732
1841
  saveReviewDraft(jobId);
1733
- if (editingCommentId === c.id) editingCommentId = null;
1842
+ if (editingCommentId === c.id) clearCommentEditState();
1734
1843
  refreshReviewCommentUi(jobId);
1735
1844
  });
1736
1845
  actions.appendChild(edit);
@@ -2321,7 +2430,7 @@ function renderReview(jobs) {
2321
2430
  const focusInPending = Boolean(
2322
2431
  els.reviewPendingList?.contains(document.activeElement)
2323
2432
  );
2324
- if (!(editingCommentId && focusInPending)) {
2433
+ if (!(editingCommentId && editingCommentSurface === 'pending' && focusInPending)) {
2325
2434
  renderPendingCommentList(job.id);
2326
2435
  }
2327
2436
  syncSubmitReviewButton(job.id);
@@ -2353,7 +2462,9 @@ function renderReview(jobs) {
2353
2462
 
2354
2463
  const focusInDiff = els.diffViewer?.contains(document.activeElement);
2355
2464
  const preservingDiffCompose =
2356
- (activeInlineCommentKey || editingCommentId) && focusInDiff;
2465
+ (Boolean(activeInlineCommentKey) ||
2466
+ (editingCommentId && editingCommentSurface === 'inline')) &&
2467
+ focusInDiff;
2357
2468
  if (!preservingDiffCompose) {
2358
2469
  renderDiff(displayDiff, { interactive: editable, jobId: job.id });
2359
2470
  }
@@ -2813,13 +2924,94 @@ async function readJson(res) {
2813
2924
  }
2814
2925
  }
2815
2926
 
2816
- function updateModelButtons() {
2817
- els.modelToggle.querySelectorAll('.model-btn').forEach((btn) => {
2818
- btn.classList.toggle('active', btn.dataset.model === currentModel);
2819
- });
2820
- if (els.settingsModel && els.settingsModel.value !== currentModel) {
2821
- const opt = [...els.settingsModel.options].find((o) => o.value === currentModel);
2822
- 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
+ });
2823
3015
  }
2824
3016
  }
2825
3017
 
@@ -2847,9 +3039,160 @@ function knownToolsList(cfg = appConfig) {
2847
3039
  : DEFAULT_KNOWN_TOOLS;
2848
3040
  }
2849
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
+
2850
3191
  function fillSettingsForm(cfg) {
2851
3192
  if (!els.settingsForm || !cfg) return;
2852
3193
 
3194
+ fillAuthSettings(cfg);
3195
+
2853
3196
  updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
2854
3197
 
2855
3198
  if (els.settingsJiraBaseUrl) {
@@ -2877,24 +3220,37 @@ function fillSettingsForm(cfg) {
2877
3220
  if (els.settingsJiraRuleEnabled) {
2878
3221
  els.settingsJiraRuleEnabled.checked = Boolean(jiraRule.enabled);
2879
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
+ }
2880
3229
  if (els.settingsJiraRuleStatus) {
2881
3230
  els.settingsJiraRuleStatus.value = jiraRule.targetStatus || 'In Review';
2882
3231
  }
3232
+ if (els.settingsJiraRuleLabel) {
3233
+ els.settingsJiraRuleLabel.value = jiraRule.label || '';
3234
+ }
2883
3235
 
2884
3236
  const ghRule = cfg.githubRules?.afterPrOpened || {};
2885
3237
  if (els.settingsGithubRuleEnabled) {
2886
3238
  els.settingsGithubRuleEnabled.checked = Boolean(ghRule.enabled);
2887
3239
  }
2888
3240
  if (els.settingsGithubRuleAction) {
2889
- const action = ['none', 'add_label', 'close_issue'].includes(ghRule.action)
3241
+ const action = ['none', 'set_status', 'add_label', 'close_issue'].includes(ghRule.action)
2890
3242
  ? ghRule.action
2891
3243
  : 'none';
2892
3244
  els.settingsGithubRuleAction.value = action;
2893
3245
  }
3246
+ if (els.settingsGithubRuleStatus) {
3247
+ els.settingsGithubRuleStatus.value = ghRule.targetStatus || 'In Review';
3248
+ }
2894
3249
  if (els.settingsGithubRuleLabel) {
2895
3250
  els.settingsGithubRuleLabel.value = ghRule.label || '';
2896
3251
  }
2897
- updateGithubRuleLabelVisibility();
3252
+ updateJiraRuleFieldsVisibility();
3253
+ updateGithubRuleFieldsVisibility();
2898
3254
 
2899
3255
  if (els.jiraStatus) {
2900
3256
  els.jiraStatus.textContent = cfg.jiraConfigured
@@ -2910,19 +3266,15 @@ function fillSettingsForm(cfg) {
2910
3266
  }
2911
3267
 
2912
3268
  if (els.settingsModel) {
2913
- const models = Array.isArray(cfg.models) ? cfg.models : [];
2914
- const currentIds = [...els.settingsModel.options].map((o) => o.value);
2915
- const nextIds = models.map((m) => m.id);
2916
- if (currentIds.join(',') !== nextIds.join(',')) {
2917
- els.settingsModel.innerHTML = '';
2918
- for (const m of models) {
2919
- const opt = document.createElement('option');
2920
- opt.value = m.id;
2921
- opt.textContent = m.label || m.id;
2922
- els.settingsModel.appendChild(opt);
2923
- }
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
+ });
2924
3276
  }
2925
- if (cfg.model) els.settingsModel.value = cfg.model;
3277
+ void fetchModels();
2926
3278
  }
2927
3279
 
2928
3280
  if (els.settingsMaxTurns) {
@@ -2964,12 +3316,39 @@ function fillSettingsForm(cfg) {
2964
3316
  }
2965
3317
 
2966
3318
  /**
2967
- * 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
3321
+ */
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.
2968
3349
  */
2969
- function updateGithubRuleLabelVisibility() {
2970
- if (!els.settingsGithubRuleLabelField || !els.settingsGithubRuleAction) return;
2971
- const show = els.settingsGithubRuleAction.value === 'add_label';
2972
- els.settingsGithubRuleLabelField.classList.toggle('hidden', !show);
3350
+ function updateGithubRuleFieldsVisibility() {
3351
+ updateRuleFieldsVisibility('github');
2973
3352
  }
2974
3353
 
2975
3354
  /**
@@ -2989,12 +3368,8 @@ function updateTicketSourceUI(source) {
2989
3368
  els.jiraConnectPanel.classList.toggle('hidden', ticketSource !== 'jira');
2990
3369
  }
2991
3370
 
2992
- if (els.jiraRulesPanel) {
2993
- els.jiraRulesPanel.classList.toggle('hidden', ticketSource !== 'jira');
2994
- }
2995
- if (els.githubRulesPanel) {
2996
- els.githubRulesPanel.classList.toggle('hidden', ticketSource !== 'github');
2997
- }
3371
+ // Rules tab always shows both GitHub and Jira post-PR options
3372
+ // (ticket source only gates the Jira connect panel above).
2998
3373
 
2999
3374
  if (els.ticketSourceMeta) {
3000
3375
  els.ticketSourceMeta.textContent =
@@ -3044,7 +3419,7 @@ function updateTimeoutMsHint(minutes) {
3044
3419
  function applyConfigSnapshot(data) {
3045
3420
  appConfig = data || {};
3046
3421
  if (data?.model) currentModel = data.model;
3047
- updateModelButtons();
3422
+ updateModelLabel();
3048
3423
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
3049
3424
  if (els.repoName) {
3050
3425
  els.repoName.textContent = data?.repoName || 'local repo';
@@ -3068,35 +3443,6 @@ async function fetchConfig() {
3068
3443
  }
3069
3444
  }
3070
3445
 
3071
- async function setModel(model) {
3072
- if (modelSaving || model === currentModel) return;
3073
- modelSaving = true;
3074
- const prev = currentModel;
3075
- currentModel = model;
3076
- updateModelButtons();
3077
- try {
3078
- const res = await fetch('/api/config', {
3079
- method: 'PATCH',
3080
- headers: { 'Content-Type': 'application/json' },
3081
- body: JSON.stringify({ model }),
3082
- });
3083
- const data = await readJson(res);
3084
- if (!res.ok) {
3085
- currentModel = prev;
3086
- updateModelButtons();
3087
- alert(`Model update failed: ${data.error || `HTTP ${res.status}`}`);
3088
- return;
3089
- }
3090
- applyConfigSnapshot({ ...appConfig, ...data });
3091
- } catch (err) {
3092
- currentModel = prev;
3093
- updateModelButtons();
3094
- alert(`Model update failed: ${err.message}`);
3095
- } finally {
3096
- modelSaving = false;
3097
- }
3098
- }
3099
-
3100
3446
  function readSettingsForm() {
3101
3447
  const baseBranch = els.settingsBaseBranch?.value?.trim() || '';
3102
3448
  const model = els.settingsModel?.value || currentModel;
@@ -3120,6 +3466,13 @@ function readSettingsForm() {
3120
3466
  ticketSource,
3121
3467
  };
3122
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
+
3123
3476
  if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
3124
3477
  patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
3125
3478
  patch.jiraPrLinkPhrase =
@@ -3130,28 +3483,31 @@ function readSettingsForm() {
3130
3483
  if (token) patch.jiraApiToken = token;
3131
3484
  }
3132
3485
 
3133
- // Only PATCH the visible source's rules so saving on one source
3134
- // never resets the other source's saved config (updateConfig merges).
3135
- if (ticketSource === 'jira') {
3136
- patch.jiraRules = {
3137
- afterPrOpened: {
3138
- enabled: Boolean(els.settingsJiraRuleEnabled?.checked),
3139
- targetStatus:
3140
- els.settingsJiraRuleStatus?.value?.trim() || 'In Review',
3141
- },
3142
- };
3143
- } else {
3144
- const ghAction = els.settingsGithubRuleAction?.value || 'none';
3145
- patch.githubRules = {
3146
- afterPrOpened: {
3147
- enabled: Boolean(els.settingsGithubRuleEnabled?.checked),
3148
- action: ['none', 'add_label', 'close_issue'].includes(ghAction)
3149
- ? ghAction
3150
- : 'none',
3151
- label: els.settingsGithubRuleLabel?.value?.trim() || '',
3152
- },
3153
- };
3154
- }
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
+ };
3155
3511
 
3156
3512
  return patch;
3157
3513
  }
@@ -3190,6 +3546,7 @@ async function saveSettings(event) {
3190
3546
  }
3191
3547
  applyConfigSnapshot(data);
3192
3548
  fillSettingsForm(data);
3549
+ void fetchModels({ refresh: true });
3193
3550
  setSettingsFeedback('Settings saved. Next jobs will use these values.', 'ok');
3194
3551
  } catch (err) {
3195
3552
  setSettingsFeedback(err.message || 'Save failed', 'error');
@@ -3209,15 +3566,79 @@ els.themeToggle.addEventListener('click', () => {
3209
3566
  applyTheme(next);
3210
3567
  });
3211
3568
 
3212
- els.modelToggle.querySelectorAll('.model-btn').forEach((btn) => {
3213
- btn.addEventListener('click', () => setModel(btn.dataset.model));
3214
- });
3215
-
3216
3569
  els.settingsForm?.addEventListener('submit', saveSettings);
3217
3570
  els.settingsTimeout?.addEventListener('input', () => {
3218
3571
  updateTimeoutMsHint(els.settingsTimeout.value);
3219
3572
  });
3220
- 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
+ );
3221
3642
 
3222
3643
  els.addBtn.addEventListener('click', async () => {
3223
3644
  els.enqueueFeedback.classList.add('hidden');
@@ -3538,5 +3959,6 @@ els.logJobFilter.addEventListener('change', () => {
3538
3959
 
3539
3960
  applyTheme(loadTheme());
3540
3961
  fetchConfig();
3962
+ fetchModels();
3541
3963
  fetchJobs();
3542
3964
  setInterval(fetchJobs, 3000);