@bridge_gpt/mcp-server 0.2.19 → 0.2.21

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.
Files changed (35) hide show
  1. package/README.md +6 -3
  2. package/build/agents.generated.js +1 -1
  3. package/build/commands.generated.js +4 -3
  4. package/build/conductor/local-merge.js +458 -95
  5. package/build/estimate-epic.js +84 -0
  6. package/build/executor/job-runner.js +151 -17
  7. package/build/executor/merge-job.js +84 -10
  8. package/build/executor/worker-finalization.js +98 -18
  9. package/build/index.js +1843 -401
  10. package/build/pipelines.generated.js +16 -20
  11. package/build/readme.generated.js +1 -1
  12. package/build/review-tickets.js +15 -5
  13. package/build/sfcc/client.js +192 -50
  14. package/build/sfcc/ocapi-write-faults.js +94 -0
  15. package/build/sfcc/permissions.js +7 -22
  16. package/build/sfcc/register.js +9 -0
  17. package/build/sfcc/write-grants.js +80 -0
  18. package/build/sfcc/write-guard.js +39 -0
  19. package/build/sfcc/write-result.js +47 -0
  20. package/build/sfcc/write-tool-common.js +85 -0
  21. package/build/sfcc/writes-custom-object-def.js +141 -0
  22. package/build/sfcc/writes-object-attribute-payloads.js +97 -0
  23. package/build/sfcc/writes-site-preference-payloads.js +59 -0
  24. package/build/sfcc/writes-site-preference.js +96 -0
  25. package/build/sfcc/writes-system-object-payloads.js +213 -0
  26. package/build/sfcc/writes-system-object.js +348 -0
  27. package/build/sfcc/writes.js +66 -0
  28. package/build/version.generated.js +1 -1
  29. package/package.json +3 -3
  30. package/pipelines/idea-to-ticket.json +7 -0
  31. package/pipelines/review-ticket.json +5 -18
  32. package/public/css/main.min.css +1583 -117
  33. package/public/css/main.min.css.map +1 -1
  34. package/public/js/main.min.js +2792 -449
  35. package/public/js/main.min.js.map +1 -1
@@ -12,6 +12,18 @@ __webpack_require__.r(__webpack_exports__);
12
12
  // extracted by mini-css-extract-plugin
13
13
 
14
14
 
15
+ /***/ },
16
+
17
+ /***/ "./src/css/get-started.css"
18
+ /*!*********************************!*\
19
+ !*** ./src/css/get-started.css ***!
20
+ \*********************************/
21
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
22
+
23
+ __webpack_require__.r(__webpack_exports__);
24
+ // extracted by mini-css-extract-plugin
25
+
26
+
15
27
  /***/ },
16
28
 
17
29
  /***/ "./src/css/globals.css"
@@ -72,6 +84,18 @@ __webpack_require__.r(__webpack_exports__);
72
84
  // extracted by mini-css-extract-plugin
73
85
 
74
86
 
87
+ /***/ },
88
+
89
+ /***/ "./src/css/run-dashboard.css"
90
+ /*!***********************************!*\
91
+ !*** ./src/css/run-dashboard.css ***!
92
+ \***********************************/
93
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
94
+
95
+ __webpack_require__.r(__webpack_exports__);
96
+ // extracted by mini-css-extract-plugin
97
+
98
+
75
99
  /***/ },
76
100
 
77
101
  /***/ "./src/css/setup-detail.css"
@@ -1780,249 +1804,1270 @@ function showDeleteModal(projectName, onConfirm, onCancel) {
1780
1804
 
1781
1805
  /***/ },
1782
1806
 
1783
- /***/ "./src/js/learn_repository.js"
1784
- /*!************************************!*\
1785
- !*** ./src/js/learn_repository.js ***!
1786
- \************************************/
1807
+ /***/ "./src/js/get-started.js"
1808
+ /*!*******************************!*\
1809
+ !*** ./src/js/get-started.js ***!
1810
+ \*******************************/
1787
1811
  (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
1788
1812
 
1789
1813
  __webpack_require__.r(__webpack_exports__);
1790
1814
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
1791
- /* harmony export */ initLearnRepository: () => (/* binding */ initLearnRepository),
1792
- /* harmony export */ renderUI: () => (/* binding */ renderUI)
1815
+ /* harmony export */ init: () => (/* binding */ init)
1793
1816
  /* harmony export */ });
1794
- /* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils.js */ "./src/js/utils.js");
1795
- /* harmony import */ var _modal_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./modal.js */ "./src/js/modal.js");
1796
- /* harmony import */ var _optimize_context_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./optimize/context.js */ "./src/js/optimize/context.js");
1797
- /* harmony import */ var _optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./optimize/status_adapters.js */ "./src/js/optimize/status_adapters.js");
1798
- /* harmony import */ var _optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./optimize/polling.js */ "./src/js/optimize/polling.js");
1799
- /* harmony import */ var _optimize_feedback_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./optimize/feedback.js */ "./src/js/optimize/feedback.js");
1800
- /* harmony import */ var _optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./optimize/learn_repository_renderer.js */ "./src/js/optimize/learn_repository_renderer.js");
1801
- /**
1802
- * Learn Repository feature for the Optimize page.
1803
- *
1804
- * Routed through the shared Optimize plumbing (BAPI-307): canonical identity from
1805
- * getOptimizeContext, status normalization via the pure adapters, declarative
1806
- * per-section rendering via the renderer, polling via pollUntil, and inline
1807
- * feedback via setInlineStatus. Initialization is driven by the single Optimize
1808
- * page entrypoint (optimize.js) — this module no longer self-registers a
1809
- * DOMContentLoaded listener. Phase 2 removed the global Begin/Resume CTA in
1810
- * favor of the per-section "Run Analysis" model.
1811
- */
1812
-
1817
+ /* harmony import */ var _modal_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./modal.js */ "./src/js/modal.js");
1818
+ /* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils.js */ "./src/js/utils.js");
1813
1819
 
1814
1820
 
1815
1821
 
1822
+ // ============================================================
1823
+ // Install-key constants
1824
+ // ============================================================
1825
+
1826
+ // Onboarding-generated keys are always stamped as admin "Easy Install" keys so
1827
+ // they are recognizable on the Security page and authorized for /install-bridge.
1828
+ const INSTALL_KEY_NAME = 'Easy Install';
1829
+ const INSTALL_KEY_ROLE = 'admin';
1830
+ const INSTALL_KEY_EMAIL_DOMAIN = 'bridgegpt-api.com';
1831
+
1832
+ // User-facing copy for the GitHub App connect flow (BAPI-506). Kept sanitized —
1833
+ // no backend detail, connection code, or install URL is ever surfaced or logged.
1834
+ const GITHUB_CONNECT_MESSAGES = {
1835
+ connecting: 'Connecting to GitHub…',
1836
+ connectButton: 'Connect GitHub',
1837
+ connectionError: 'We could not start the GitHub connection. Please try again.',
1838
+ missingRepo: "We could not determine this project's repository. Reload the page and try again.",
1839
+ 'verification-failed': 'GitHub could not verify this installation. Please try connecting again.',
1840
+ 'invalid': 'This GitHub connection link is no longer valid. Please start the connection again.',
1841
+ 'expired': 'This GitHub connection link has expired. Please start the connection again.',
1842
+ 'already-consumed': 'This GitHub connection link has already been used. Please start the connection again.',
1843
+ 'unresolved': 'GitHub returned without completing the connection. Please try again.',
1844
+ 'no-repositories': 'The GitHub App installation did not include any repositories Bridge can access.',
1845
+ 'already-linked': 'This GitHub installation is already connected to a different Bridge account. If this is unexpected, contact support.',
1846
+ 'binding-failed': 'GitHub was verified, but Bridge could not finish linking the repository. Use the manual fallback below.',
1847
+ pickerLoading: 'Loading the repositories from your GitHub installation…',
1848
+ pickerEmpty: 'No repositories were available to select. Use the manual fallback below.',
1849
+ pickerPrompt: 'Your GitHub installation includes multiple repositories. Choose the one to connect to this project:',
1850
+ connectedTitle: 'GitHub connected',
1851
+ };
1816
1852
 
1853
+ // Holds the plaintext minted key for the current page lifetime ONLY. Never
1854
+ // persisted to storage, cookies, URLs, datasets, or sent back to the server.
1855
+ let generatedInstallKey = null;
1856
+
1857
+ // ============================================================
1858
+ // Clipboard helper
1859
+ // ============================================================
1860
+
1861
+ // Centralized, keyboard-accessible clipboard write used by snippet copy,
1862
+ // /install-bridge handoff copy, and the generated-key copy. Falls back to a
1863
+ // hidden textarea + execCommand when the async clipboard API is unavailable.
1864
+ // Does NOT log the copied text — it may be a plaintext API key.
1865
+ async function copyTextToClipboard(text) {
1866
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1867
+ await navigator.clipboard.writeText(text);
1868
+ return;
1869
+ }
1870
+ const ta = document.createElement('textarea');
1871
+ ta.value = text;
1872
+ ta.style.position = 'fixed';
1873
+ ta.style.opacity = '0';
1874
+ document.body.appendChild(ta);
1875
+ ta.focus();
1876
+ ta.select();
1877
+ try {
1878
+ document.execCommand('copy');
1879
+ } finally {
1880
+ document.body.removeChild(ta);
1881
+ }
1882
+ }
1883
+
1884
+ // Derive a stable, non-empty synthetic email for the minted key. Setup sessions
1885
+ // expose client_id, not a user email, but the key-creation route only requires
1886
+ // a non-empty email — this address is repo-derived, stable, and recognizable in
1887
+ // the Security page key list.
1888
+ function deriveInstallKeyEmail(repoName) {
1889
+ let sanitized = String(repoName == null ? '' : repoName)
1890
+ .trim()
1891
+ .toLowerCase()
1892
+ .replace(/[^a-z0-9._+-]/g, '-')
1893
+ // Trim leading/trailing punctuation that would make the local part awkward.
1894
+ .replace(/^[._+-]+/, '')
1895
+ .replace(/[._+-]+$/, '');
1896
+ if (!sanitized) sanitized = 'project';
1897
+ return `install+${sanitized}@${INSTALL_KEY_EMAIL_DOMAIN}`;
1898
+ }
1899
+
1900
+ // ============================================================
1901
+ // Snippet tabs
1902
+ // ============================================================
1903
+
1904
+ function renderSnippetTabs(snippets) {
1905
+ const tabList = document.getElementById('editor-tabs');
1906
+ const panelContainer = document.getElementById('editor-tab-panels');
1907
+ if (!tabList || !panelContainer) return;
1908
+
1909
+ tabList.innerHTML = '';
1910
+ panelContainer.innerHTML = '';
1911
+
1912
+ snippets.forEach((snippet, idx) => {
1913
+ const panelId = `snippet-panel-${snippet.id}`;
1914
+ const tabId = `snippet-tab-${snippet.id}`;
1915
+ const isFirst = idx === 0;
1916
+
1917
+ // Tab button
1918
+ const btn = document.createElement('button');
1919
+ btn.type = 'button';
1920
+ btn.id = tabId;
1921
+ btn.className = 'snippet-tab-btn' + (isFirst ? ' active' : '');
1922
+ btn.setAttribute('role', 'tab');
1923
+ btn.setAttribute('aria-selected', isFirst ? 'true' : 'false');
1924
+ btn.setAttribute('aria-controls', panelId);
1925
+ btn.setAttribute('tabindex', isFirst ? '0' : '-1');
1926
+ btn.textContent = snippet.label;
1927
+ btn.addEventListener('click', () => activateTab(idx, snippets));
1928
+ tabList.appendChild(btn);
1929
+
1930
+ // Panel
1931
+ const panel = document.createElement('div');
1932
+ panel.id = panelId;
1933
+ panel.className = 'snippet-panel' + (isFirst ? '' : ' hidden');
1934
+ panel.setAttribute('role', 'tabpanel');
1935
+ panel.setAttribute('aria-labelledby', tabId);
1936
+ if (!isFirst) panel.hidden = true;
1937
+
1938
+ // Metadata row
1939
+ const meta = document.createElement('div');
1940
+ meta.className = 'snippet-meta';
1941
+ meta.textContent = `${snippet.target_path} · ${snippet.language}`;
1942
+ panel.appendChild(meta);
1943
+
1944
+ if (snippet.notes) {
1945
+ const notes = document.createElement('p');
1946
+ notes.className = 'snippet-notes';
1947
+ notes.textContent = snippet.notes;
1948
+ panel.appendChild(notes);
1949
+ }
1950
+
1951
+ // Code block built from parts
1952
+ const pre = document.createElement('pre');
1953
+ const code = document.createElement('code');
1954
+ code.className = `language-${snippet.language}`;
1955
+
1956
+ (snippet.parts || []).forEach(part => {
1957
+ if (part.kind === 'api_key') {
1958
+ const span = document.createElement('span');
1959
+ span.setAttribute('data-bapi-api-key', '');
1960
+ span.className = 'bapi-api-key-slot';
1961
+ span.textContent = part.text;
1962
+ code.appendChild(span);
1963
+ } else {
1964
+ code.appendChild(document.createTextNode(part.text));
1965
+ }
1966
+ });
1967
+ pre.appendChild(code);
1968
+ panel.appendChild(pre);
1817
1969
 
1970
+ // Copy row
1971
+ const copyRow = document.createElement('div');
1972
+ copyRow.className = 'snippet-copy-row';
1818
1973
 
1974
+ const copyBtn = document.createElement('button');
1975
+ copyBtn.type = 'button';
1976
+ copyBtn.className = 'btn btn-secondary snippet-copy-btn';
1977
+ copyBtn.textContent = 'Copy';
1819
1978
 
1979
+ const copyStatus = document.createElement('span');
1980
+ copyStatus.className = 'copy-status';
1981
+ copyStatus.setAttribute('aria-live', 'polite');
1820
1982
 
1821
- // Active polls keyed by analysis type (per-section) plus the global learning
1822
- // poll. Each value is an AbortController so a new poll can cancel the prior one.
1823
- const activePolls = new Map();
1983
+ copyBtn.addEventListener('click', async () => {
1984
+ const text = code.textContent;
1985
+ try {
1986
+ await copyTextToClipboard(text);
1987
+ copyStatus.textContent = 'Copied!';
1988
+ setTimeout(() => { copyStatus.textContent = ''; }, 2000);
1989
+ } catch (_) {
1990
+ copyStatus.textContent = 'Copy failed';
1991
+ }
1992
+ });
1824
1993
 
1825
- let latestLearnRepositoryStatus = null;
1826
- const GLOBAL_LEARNING_POLL_KEY = 'global-learning';
1827
- const LEARNING_POLL_INTERVAL_MS = 30000;
1828
- const LEARNING_POLL_TIMEOUT_MS = 600000;
1994
+ copyRow.appendChild(copyBtn);
1995
+ copyRow.appendChild(copyStatus);
1996
+ panel.appendChild(copyRow);
1829
1997
 
1830
- // Canonical repository name, sourced once from the shared Optimize context. This
1831
- // is the machine-readable identifier used in API URLs and POST payloads (never
1832
- // the human-facing display name).
1833
- let currentRepoName = null;
1998
+ panelContainer.appendChild(panel);
1999
+ });
1834
2000
 
1835
- /**
1836
- * Initialize the Learn Repository feature.
1837
- * @param {{repoName: (string|null)}} [context] - Shared Optimize context.
1838
- */
1839
- async function initLearnRepository(context = (0,_optimize_context_js__WEBPACK_IMPORTED_MODULE_2__.getOptimizeContext)()) {
1840
- currentRepoName = context && context.repoName;
1841
- if (!currentRepoName) {
1842
- console.error('Repository name not available');
1843
- return;
2001
+ // If a key was already minted during this page lifetime, re-inject it so the
2002
+ // snippet contract stays intact across re-renders (e.g. a retry).
2003
+ if (generatedInstallKey) {
2004
+ injectInstallKeyIntoSnippets(generatedInstallKey);
1844
2005
  }
2006
+ }
1845
2007
 
1846
- try {
1847
- // Fetch initial status
1848
- const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.apiCall)(
1849
- `/optimize/learn-repository/${(0,_optimize_context_js__WEBPACK_IMPORTED_MODULE_2__.encodePathSegment)(currentRepoName)}/status`,
1850
- 'GET'
1851
- );
1852
- if (data) {
1853
- renderUI(data);
1854
- } else {
1855
- showStatusMessage('Failed to load repository learning status', 'error');
1856
- }
1857
- } catch (error) {
1858
- console.error('Error fetching learn repository status:', error);
1859
- showStatusMessage('Error loading repository learning status', 'error');
1860
- }
2008
+ function activateTab(idx, snippets) {
2009
+ const tabList = document.getElementById('editor-tabs');
2010
+ const panelContainer = document.getElementById('editor-tab-panels');
2011
+ if (!tabList || !panelContainer) return;
1861
2012
 
1862
- // Set up event listeners
1863
- setupEventListeners();
1864
- }
2013
+ const tabs = tabList.querySelectorAll('[role="tab"]');
2014
+ const panels = panelContainer.querySelectorAll('[role="tabpanel"]');
1865
2015
 
1866
- function updateLatestLearnRepositoryStatus(data) {
1867
- if (!data) return;
1868
- latestLearnRepositoryStatus = data;
2016
+ tabs.forEach((tab, i) => {
2017
+ const active = i === idx;
2018
+ tab.setAttribute('aria-selected', active ? 'true' : 'false');
2019
+ tab.setAttribute('tabindex', active ? '0' : '-1');
2020
+ tab.classList.toggle('active', active);
2021
+ });
2022
+
2023
+ panels.forEach((panel, i) => {
2024
+ const active = i === idx;
2025
+ panel.hidden = !active;
2026
+ panel.classList.toggle('hidden', !active);
2027
+ });
1869
2028
  }
1870
2029
 
1871
- /**
1872
- * Render every Learn Repository section from a status payload. Each section is
1873
- * normalized by the pure adapter and rendered declaratively by the renderer.
1874
- * When the server reports a run in progress and no poll is active, a global poll
1875
- * is started (idempotent — safe to call from re-renders inside the poll itself).
1876
- * @param {Object} data
1877
- */
1878
- function renderUI(data) {
1879
- updateLatestLearnRepositoryStatus(data);
2030
+ // ============================================================
2031
+ // /install-bridge hand-off
2032
+ // ============================================================
1880
2033
 
1881
- const normalized = (0,_optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__.adaptLearnRepositoryStatus)(data);
2034
+ function initializeHandoffCopy() {
2035
+ const btn = document.getElementById('handoff-copy-btn');
2036
+ const status = document.getElementById('handoff-copy-status');
2037
+ if (!btn || !status) return;
1882
2038
 
1883
- _optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__.ANALYSIS_TYPES.forEach((type) => {
1884
- (0,_optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__.renderLearnRepositorySection)(type, data[type] || {});
2039
+ btn.addEventListener('click', async () => {
2040
+ try {
2041
+ await copyTextToClipboard('/install-bridge');
2042
+ status.textContent = 'Copied!';
2043
+ setTimeout(() => { status.textContent = ''; }, 2000);
2044
+ } catch (_) {
2045
+ status.textContent = 'Copy failed';
2046
+ }
1885
2047
  });
2048
+ }
1886
2049
 
1887
- if (normalized.learningInProgress && !activePolls.get(GLOBAL_LEARNING_POLL_KEY)) {
1888
- pollLearningStatus();
2050
+ // ============================================================
2051
+ // Config-status readout
2052
+ // ============================================================
2053
+
2054
+ // Whitelist mapping known integration ids to the setup-detail field anchors
2055
+ // their credential is configured under. Only ids present here contribute a URL
2056
+ // fragment — arbitrary server-provided ids never become a fragment.
2057
+ const INTEGRATION_SETUP_ANCHORS = {
2058
+ jira: 'jira_api_token',
2059
+ github_app: 'git_app_installation_id',
2060
+ vcs_access_token: 'version_control_access_token',
2061
+ vcs_webhook: 'vcs_webhook_secret',
2062
+ };
2063
+
2064
+ // Read the projectId query param from the current URL. Returns null when absent.
2065
+ function getProjectIdFromUrl() {
2066
+ return new URLSearchParams(window.location.search).get('projectId');
2067
+ }
2068
+
2069
+ // Build a setup-detail href, preserving the projectId param and (optionally)
2070
+ // deep-linking to a specific field anchor. Both projectId and fragment are
2071
+ // URL-encoded; an absent projectId yields the bare path, an empty fragment is
2072
+ // omitted.
2073
+ function buildSetupDetailHref(projectId, fragmentId) {
2074
+ let href = projectId
2075
+ ? `/setup/setup-detail?projectId=${encodeURIComponent(projectId)}`
2076
+ : '/setup/setup-detail';
2077
+ if (fragmentId) {
2078
+ href += `#${encodeURIComponent(fragmentId)}`;
2079
+ }
2080
+ return href;
2081
+ }
2082
+
2083
+ // Resolve the setup-detail field anchor for an integration item from the
2084
+ // whitelist. Returns '' for missing/unknown ids so arbitrary server data is
2085
+ // never used as a URL fragment.
2086
+ function getIntegrationSetupFragment(integration) {
2087
+ const id = integration && integration.id;
2088
+ if (!id) return '';
2089
+ return INTEGRATION_SETUP_ANCHORS[id] || '';
2090
+ }
2091
+
2092
+ // Normalize an integration's required_for descriptions into display text.
2093
+ // Accepts an array (stringified, trimmed, empties dropped, comma-joined) or a
2094
+ // plain string (trimmed). Returns '' for missing/null/unsupported values.
2095
+ function formatRequiredForText(requiredFor) {
2096
+ if (Array.isArray(requiredFor)) {
2097
+ return requiredFor
2098
+ .map(item => (item == null ? '' : String(item).trim()))
2099
+ .filter(item => item !== '')
2100
+ .join(', ');
2101
+ }
2102
+ if (typeof requiredFor === 'string') {
2103
+ return requiredFor.trim();
1889
2104
  }
2105
+ return '';
1890
2106
  }
1891
2107
 
1892
- function setupEventListeners() {
1893
- // Approve buttons for each analysis type
1894
- _optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__.ANALYSIS_TYPES.forEach((type) => {
1895
- const sectionId = (0,_optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__.getSectionId)(type);
1896
- const approveBtn = document.getElementById(`lr-${sectionId}-approve`);
1897
- if (approveBtn) {
1898
- approveBtn.addEventListener('click', () => handleApprove(type));
1899
- }
2108
+ // True only when the item carries a non-null setup_instructions object. Used
2109
+ // only to choose link copy — get-started never renders instruction prose.
2110
+ function hasSetupInstructions(integration) {
2111
+ return !!(
2112
+ integration &&
2113
+ typeof integration.setup_instructions === 'object' &&
2114
+ integration.setup_instructions !== null
2115
+ );
2116
+ }
1900
2117
 
1901
- // Save buttons
1902
- const saveBtn = document.getElementById(`lr-${sectionId}-save`);
1903
- if (saveBtn) {
1904
- saveBtn.addEventListener('click', () => handleSaveInstructions(type));
1905
- }
1906
- });
2118
+ // Build a single display-only checklist row for one integration using safe DOM
2119
+ // APIs (createElement + textContent) — never innerHTML with server data. Shows
2120
+ // the label, a configured/unconfigured chip, required_for text, and a
2121
+ // setup-detail deep link. Renders no buttons, credential values, or inputs.
2122
+ function renderIntegrationChecklistRow(integration, projectId) {
2123
+ const row = document.createElement('div');
2124
+ row.className = 'status-field-row install-integration-row';
2125
+ row.setAttribute('role', 'listitem');
2126
+ if (integration.id) {
2127
+ row.setAttribute('data-integration-id', integration.id);
2128
+ }
2129
+
2130
+ const configured = !!integration.is_configured;
2131
+
2132
+ const chip = document.createElement('span');
2133
+ chip.className = `status-chip ${configured ? 'status-set' : 'status-unset'}`;
2134
+ chip.textContent = configured ? 'Configured' : 'Unconfigured';
2135
+ row.appendChild(chip);
2136
+
2137
+ const label = document.createElement('span');
2138
+ label.className = 'status-field-name install-integration-label';
2139
+ label.textContent = integration.label || integration.id || 'Integration';
2140
+ row.appendChild(label);
2141
+
2142
+ const requiredForText = formatRequiredForText(integration.required_for);
2143
+ if (requiredForText) {
2144
+ const required = document.createElement('span');
2145
+ required.className = 'status-field-value install-integration-required';
2146
+ required.textContent = `Required for: ${requiredForText}`;
2147
+ row.appendChild(required);
2148
+ }
2149
+
2150
+ const link = document.createElement('a');
2151
+ link.className = 'install-integration-link';
2152
+ link.href = buildSetupDetailHref(projectId, getIntegrationSetupFragment(integration));
2153
+ const hasInstructions = hasSetupInstructions(integration);
2154
+ link.textContent = hasInstructions ? 'View setup instructions' : 'Open Setup Detail';
2155
+ const labelText = integration.label || integration.id || 'this integration';
2156
+ link.setAttribute(
2157
+ 'aria-label',
2158
+ `${hasInstructions ? 'View setup instructions' : 'Open Setup Detail'} for ${labelText}`
2159
+ );
2160
+ row.appendChild(link);
1907
2161
 
1908
- // Reanalyze controls — now <button>s; reveal options instead of direct-fire.
1909
- document.querySelectorAll('.lr-reanalyze-link').forEach((link) => {
1910
- link.addEventListener('click', () => {
1911
- link.classList.add('hidden');
1912
- const container = link.closest('.reanalyze-container');
1913
- const optionsWrapper = container?.querySelector('.lr-reanalyze-options');
1914
- optionsWrapper?.classList.remove('hidden');
1915
- const defaultRadio = optionsWrapper?.querySelector('input[value="auto_complete"]');
1916
- defaultRadio?.focus();
1917
- });
1918
- });
2162
+ return row;
2163
+ }
1919
2164
 
1920
- // Confirm buttons invoke handleReanalyze
1921
- document.querySelectorAll('.lr-reanalyze-confirm-btn').forEach((btn) => {
1922
- btn.addEventListener('click', (e) => {
1923
- e.preventDefault();
1924
- handleReanalyze(btn.dataset.analysisType);
1925
- });
1926
- });
2165
+ // Render the full integrations checklist into container. Absent-safe: no-ops on
2166
+ // a missing container, a non-array/empty integrations value, or when no valid
2167
+ // object items remain. Clears the container first so refreshes never duplicate.
2168
+ function renderIntegrationChecklist(integrations, container, projectId) {
2169
+ if (!container) return;
1927
2170
 
1928
- // Cancel buttons — collapse options back to initial state
1929
- document.querySelectorAll('.lr-reanalyze-cancel-btn').forEach((btn) => {
1930
- btn.addEventListener('click', (e) => {
1931
- e.preventDefault();
1932
- const container = btn.closest('.reanalyze-container');
1933
- const optionsWrapper = container?.querySelector('.lr-reanalyze-options');
1934
- const reanalyzeLink = container?.querySelector('.lr-reanalyze-link');
1935
- optionsWrapper?.classList.add('hidden');
1936
- reanalyzeLink?.classList.remove('hidden');
1937
- // Reset radio selection to auto_complete
1938
- const defaultRadio = optionsWrapper?.querySelector('input[value="auto_complete"]');
1939
- if (defaultRadio) defaultRadio.checked = true;
1940
- reanalyzeLink?.focus();
1941
- });
1942
- });
2171
+ container.innerHTML = '';
1943
2172
 
1944
- // Accept/Discard draft buttons
1945
- document.querySelectorAll('.lr-accept-draft-btn').forEach((btn) => {
1946
- btn.addEventListener('click', () => handleAcceptDraft(btn.dataset.analysisType));
1947
- });
1948
- document.querySelectorAll('.lr-discard-draft-btn').forEach((btn) => {
1949
- btn.addEventListener('click', () => handleDiscardDraft(btn.dataset.analysisType));
1950
- });
2173
+ if (!Array.isArray(integrations) || integrations.length === 0) return;
1951
2174
 
1952
- // Per-section "Run Analysis" buttons for empty sections (the single start path)
1953
- document.querySelectorAll('.lr-initial-analyze-btn').forEach((btn) => {
1954
- btn.addEventListener('click', () => handleInitialAnalyze(btn.dataset.analysisType));
2175
+ const validItems = integrations.filter(
2176
+ item => item && typeof item === 'object'
2177
+ );
2178
+ if (validItems.length === 0) return;
2179
+
2180
+ const wrapper = document.createElement('div');
2181
+ wrapper.className = 'install-integrations-wrapper';
2182
+
2183
+ const title = document.createElement('h3');
2184
+ title.className = 'install-integrations-title';
2185
+ title.textContent = 'Integration checklist';
2186
+ wrapper.appendChild(title);
2187
+
2188
+ const note = document.createElement('p');
2189
+ note.className = 'install-integrations-note';
2190
+ note.textContent =
2191
+ 'Presence-only status — no credential values are shown here. ' +
2192
+ 'Use Setup Detail to add or update integration credentials.';
2193
+ wrapper.appendChild(note);
2194
+
2195
+ const list = document.createElement('div');
2196
+ list.className = 'install-integrations-list';
2197
+ list.setAttribute('role', 'list');
2198
+
2199
+ validItems.forEach(item => {
2200
+ list.appendChild(renderIntegrationChecklistRow(item, projectId));
1955
2201
  });
2202
+
2203
+ wrapper.appendChild(list);
2204
+ container.appendChild(wrapper);
1956
2205
  }
1957
2206
 
1958
- function stopGlobalLearningPoll() {
1959
- const controller = activePolls.get(GLOBAL_LEARNING_POLL_KEY);
1960
- if (controller) {
1961
- controller.abort();
1962
- activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
2207
+ function renderInstallStatus(installStatus, projectId) {
2208
+ const summaryEl = document.getElementById('install-status-summary');
2209
+ const groupsEl = document.getElementById('install-status-groups');
2210
+ if (!summaryEl || !groupsEl) return;
2211
+
2212
+ // The integrations container is optional so the function stays defensive if
2213
+ // the template does not (yet) include it.
2214
+ const integrationsEl = document.getElementById('install-integrations');
2215
+
2216
+ // Clear all render targets up front so a refresh (e.g. after a GitHub
2217
+ // connection completes) never duplicates prior content.
2218
+ summaryEl.innerHTML = '';
2219
+ groupsEl.innerHTML = '';
2220
+ if (integrationsEl) integrationsEl.innerHTML = '';
2221
+
2222
+ if (!installStatus || !installStatus.available) {
2223
+ summaryEl.innerHTML = '<p class="status-empty">No configuration found for this project.</p>';
2224
+ return;
1963
2225
  }
1964
- }
1965
2226
 
1966
- function pollLearningStatus() {
1967
- stopGlobalLearningPoll();
2227
+ const { summary, groups, integrations } = installStatus;
2228
+ if (summary) {
2229
+ const row = document.createElement('div');
2230
+ row.className = 'status-summary';
1968
2231
 
1969
- const controller = new AbortController();
1970
- activePolls.set(GLOBAL_LEARNING_POLL_KEY, controller);
1971
- const statusUrl = `/optimize/learn-repository/${(0,_optimize_context_js__WEBPACK_IMPORTED_MODULE_2__.encodePathSegment)(currentRepoName)}/status`;
2232
+ const setChip = document.createElement('span');
2233
+ setChip.className = 'status-chip status-set';
2234
+ setChip.textContent = `${summary.set} Set`;
1972
2235
 
1973
- // Note: a full 'all' run can take ~30 minutes, longer than
1974
- // LEARNING_POLL_TIMEOUT_MS once the timeout fires the user sees a refresh
1975
- // prompt; on refresh, renderUI's learning_in_progress branch resumes polling.
1976
- (0,_optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__.pollUntil)(statusUrl, (data) => data != null && data.learning_in_progress !== true, {
1977
- intervalMs: LEARNING_POLL_INTERVAL_MS,
1978
- timeoutMs: LEARNING_POLL_TIMEOUT_MS,
1979
- continueOnError: true,
1980
- signal: controller.signal,
1981
- onTick: (data) => {
1982
- updateLatestLearnRepositoryStatus(data);
1983
- renderUI(data);
1984
- },
1985
- })
1986
- .then(() => {
1987
- activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
1988
- showStatusMessage('Learning analysis complete. Review any generated clarifications below.', 'success');
1989
- })
1990
- .catch((error) => {
1991
- if (error && error.name === 'AbortError') return;
1992
- activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
1993
- if (error instanceof _optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__.PollTimeoutError) {
1994
- showStatusMessage(
1995
- 'Learning is taking longer than expected. Please refresh the page to see the latest status.',
1996
- 'error'
1997
- );
1998
- } else {
1999
- console.error('Global learning poll error:', error);
2000
- }
2001
- });
2002
- }
2236
+ const unsetChip = document.createElement('span');
2237
+ unsetChip.className = 'status-chip status-unset';
2238
+ unsetChip.textContent = `${summary.unset} Unset`;
2003
2239
 
2004
- async function handleApprove(analysisType) {
2005
- const sectionId = (0,_optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__.getSectionId)(analysisType);
2240
+ const total = document.createElement('span');
2241
+ total.className = 'status-total';
2242
+ total.textContent = `of ${summary.total} fields`;
2006
2243
 
2007
- const confirmText = 'Are you sure you want to approve these clarifications? (You will still be able to edit this later in the configuration panel if needed)';
2244
+ row.appendChild(setChip);
2245
+ row.appendChild(unsetChip);
2246
+ row.appendChild(total);
2247
+ summaryEl.appendChild(row);
2248
+ }
2008
2249
 
2009
- const confirmed = await showConfirmationModal('Confirm Approval', confirmText);
2250
+ // Render the integrations checklist before the config-field groups so it
2251
+ // still appears when integration status is present but groups are empty.
2252
+ renderIntegrationChecklist(integrations, integrationsEl, projectId);
2010
2253
 
2011
- if (!confirmed) {
2254
+ if (!groups || groups.length === 0) {
2255
+ groupsEl.innerHTML = '<p class="status-empty">No configuration fields found.</p>';
2012
2256
  return;
2013
2257
  }
2014
2258
 
2015
- const button = document.getElementById(`lr-${sectionId}-approve`);
2016
- const textarea = document.getElementById(`lr-${sectionId}-textarea`);
2017
- const originalText = button ? button.textContent : null;
2259
+ groups.forEach(group => {
2260
+ if (!group.fields || group.fields.length === 0) return;
2261
+ const section = document.createElement('div');
2262
+ section.className = 'status-group';
2018
2263
 
2019
- // Get the edited clarifications text from the textarea
2020
- const editedClarifications = textarea ? textarea.value : '';
2264
+ const heading = document.createElement('h3');
2265
+ heading.className = 'status-group-title';
2266
+ heading.textContent = group.title || group.key;
2267
+ section.appendChild(heading);
2021
2268
 
2022
- try {
2023
- // Disable button and show loading state
2024
- if (button) {
2025
- button.disabled = true;
2269
+ group.fields.forEach(field => {
2270
+ const row = document.createElement('div');
2271
+ row.className = 'status-field-row';
2272
+
2273
+ const chip = document.createElement('span');
2274
+ chip.className = `status-chip ${field.is_set ? 'status-set' : 'status-unset'}`;
2275
+ chip.textContent = field.is_set ? 'Set' : 'Unset';
2276
+
2277
+ const label = document.createElement('span');
2278
+ label.className = 'status-field-name';
2279
+ label.textContent = field.field_name;
2280
+
2281
+ row.appendChild(chip);
2282
+ row.appendChild(label);
2283
+
2284
+ if (field.current_value !== null && field.current_value !== undefined) {
2285
+ const val = document.createElement('span');
2286
+ val.className = 'status-field-value';
2287
+ const display =
2288
+ typeof field.current_value === 'object'
2289
+ ? JSON.stringify(field.current_value)
2290
+ : String(field.current_value);
2291
+ val.textContent = display;
2292
+ row.appendChild(val);
2293
+ }
2294
+
2295
+ section.appendChild(row);
2296
+ });
2297
+
2298
+ groupsEl.appendChild(section);
2299
+ });
2300
+ }
2301
+
2302
+ // ============================================================
2303
+ // Data loading
2304
+ // ============================================================
2305
+
2306
+ async function loadInstallInstructions(projectId) {
2307
+ const loadingEl = document.getElementById('get-started-loading');
2308
+ const errorEl = document.getElementById('get-started-error');
2309
+ const contentEl = document.getElementById('get-started-content');
2310
+
2311
+ if (loadingEl) loadingEl.classList.remove('hidden');
2312
+ if (errorEl) errorEl.classList.add('hidden');
2313
+ if (contentEl) contentEl.classList.add('hidden');
2314
+
2315
+ try {
2316
+ const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.apiCall)(
2317
+ `/setup/install-instructions?projectId=${encodeURIComponent(projectId)}`,
2318
+ 'GET'
2319
+ );
2320
+
2321
+ // apiCall returns null on centralized 401 redirect
2322
+ if (!data) return;
2323
+
2324
+ if (loadingEl) loadingEl.classList.add('hidden');
2325
+
2326
+ // Defensive sync: keep the container's repo dataset authoritative from
2327
+ // the install-instructions payload so the install-key click handler
2328
+ // reads the correct repo_name even if the server attribute was empty.
2329
+ const containerEl = document.getElementById('get-started-container');
2330
+ if (containerEl && data.repo_name) {
2331
+ containerEl.dataset.repoName = data.repo_name;
2332
+ }
2333
+
2334
+ const repoLabel = document.getElementById('get-started-repo-label');
2335
+ if (repoLabel) {
2336
+ repoLabel.textContent = `Project: ${data.repo_name}`;
2337
+ }
2338
+
2339
+ renderSnippetTabs(data.snippets || []);
2340
+ initializeHandoffCopy();
2341
+ renderInstallStatus(data.install_status, projectId);
2342
+
2343
+ if (contentEl) contentEl.classList.remove('hidden');
2344
+
2345
+ // Render any GitHub connection return-state now that the repo dataset is
2346
+ // synchronized from the install-instructions payload (BAPI-506).
2347
+ renderGitHubConnectionReturnState();
2348
+ } catch (error) {
2349
+ if (loadingEl) loadingEl.classList.add('hidden');
2350
+ if (contentEl) contentEl.classList.add('hidden');
2351
+
2352
+ const msgEl = document.getElementById('get-started-error-msg');
2353
+ if (msgEl) {
2354
+ msgEl.textContent = (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.getApiErrorMessage)(error, 'Failed to load install instructions.');
2355
+ }
2356
+ if (errorEl) errorEl.classList.remove('hidden');
2357
+ }
2358
+ }
2359
+
2360
+ // ============================================================
2361
+ // Install-key generation
2362
+ // ============================================================
2363
+
2364
+ // Replace every onboarding snippet's data-bapi-api-key placeholder with the
2365
+ // minted key, scoped strictly to the onboarding container. Returns the number
2366
+ // of updated elements so callers can detect an unexpected empty-snippet state.
2367
+ function injectInstallKeyIntoSnippets(apiKey) {
2368
+ const slots = document.querySelectorAll('#get-started-container [data-bapi-api-key]');
2369
+ slots.forEach(el => {
2370
+ el.textContent = apiKey;
2371
+ el.classList.add('bapi-api-key-slot-filled');
2372
+ });
2373
+ return slots.length;
2374
+ }
2375
+
2376
+ // Toggle the loading state on the generate button. When loading ends without
2377
+ // success the caller passes loading=false to restore the idle affordance.
2378
+ function setInstallKeyLoading(loading) {
2379
+ const btn = document.getElementById('generate-install-key-btn');
2380
+ const panel = document.getElementById('install-key-panel');
2381
+ const errorEl = document.getElementById('install-key-error');
2382
+ if (loading) {
2383
+ if (errorEl) {
2384
+ errorEl.textContent = '';
2385
+ errorEl.classList.add('hidden');
2386
+ }
2387
+ if (btn) {
2388
+ btn.disabled = true;
2389
+ btn.textContent = 'Generating…';
2390
+ }
2391
+ if (panel) panel.setAttribute('aria-busy', 'true');
2392
+ } else {
2393
+ if (btn) {
2394
+ btn.disabled = false;
2395
+ btn.textContent = 'Generate install key';
2396
+ }
2397
+ if (panel) panel.removeAttribute('aria-busy');
2398
+ }
2399
+ }
2400
+
2401
+ // Write a brief, non-leaking failure message near the action. Never surface raw
2402
+ // server error details, request body fields, or any plaintext key.
2403
+ function showInstallKeyError(message) {
2404
+ const errorEl = document.getElementById('install-key-error');
2405
+ if (!errorEl) return;
2406
+ errorEl.textContent = message || 'Could not generate the install key. Please try again.';
2407
+ errorEl.classList.remove('hidden');
2408
+ }
2409
+
2410
+ // Mint an admin install key on explicit click via the existing POST /setup/keys
2411
+ // route, then reveal it once and inject it into every snippet.
2412
+ async function handleGenerateInstallKeyClick() {
2413
+ const container = document.getElementById('get-started-container');
2414
+ if (!container) return;
2415
+
2416
+ // Prevent silent repeat minting within the same page lifetime. By design
2417
+ // this guard is per-page-load only: a reload intentionally allows minting a
2418
+ // fresh on-demand key (BAPI-383). Unused keys are revocable from the
2419
+ // Security page, so we accept this over reusing/auto-revoking prior keys.
2420
+ if (generatedInstallKey) return;
2421
+
2422
+ const repoName = container.dataset.repoName;
2423
+ if (!repoName) {
2424
+ showInstallKeyError('Could not generate the install key. Please try again.');
2425
+ return;
2426
+ }
2427
+
2428
+ setInstallKeyLoading(true);
2429
+
2430
+ const payload = {
2431
+ email: deriveInstallKeyEmail(repoName),
2432
+ repo_name: repoName,
2433
+ name: INSTALL_KEY_NAME,
2434
+ role: INSTALL_KEY_ROLE,
2435
+ };
2436
+
2437
+ let response;
2438
+ try {
2439
+ response = await fetch('/setup/keys', {
2440
+ method: 'POST',
2441
+ headers: { 'Content-Type': 'application/json' },
2442
+ body: JSON.stringify(payload),
2443
+ });
2444
+ } catch (_) {
2445
+ setInstallKeyLoading(false);
2446
+ showInstallKeyError('Could not generate the install key. Please try again.');
2447
+ return;
2448
+ }
2449
+
2450
+ // Only a 201 Created is success. Do not parse/display raw server details.
2451
+ if (response.status !== 201) {
2452
+ setInstallKeyLoading(false);
2453
+ showInstallKeyError('Could not generate the install key. Please try again.');
2454
+ return;
2455
+ }
2456
+
2457
+ let data;
2458
+ try {
2459
+ data = await response.json();
2460
+ } catch (_) {
2461
+ setInstallKeyLoading(false);
2462
+ showInstallKeyError('Could not generate the install key. Please try again.');
2463
+ return;
2464
+ }
2465
+
2466
+ const apiKey = data && data.api_key;
2467
+ if (!apiKey) {
2468
+ setInstallKeyLoading(false);
2469
+ showInstallKeyError('Could not generate the install key. Please try again.');
2470
+ return;
2471
+ }
2472
+
2473
+ generatedInstallKey = apiKey;
2474
+
2475
+ const keyInput = document.getElementById('generated-install-key');
2476
+ if (keyInput) keyInput.value = apiKey;
2477
+
2478
+ const resultEl = document.getElementById('install-key-result');
2479
+ if (resultEl) resultEl.classList.remove('hidden');
2480
+
2481
+ injectInstallKeyIntoSnippets(apiKey);
2482
+
2483
+ const statusEl = document.getElementById('install-key-status');
2484
+ if (statusEl) statusEl.textContent = 'Admin install key generated and inserted into the snippets.';
2485
+
2486
+ // Permanently disable the button after success to avoid repeated minting.
2487
+ const btn = document.getElementById('generate-install-key-btn');
2488
+ const panel = document.getElementById('install-key-panel');
2489
+ if (btn) {
2490
+ btn.disabled = true;
2491
+ btn.textContent = 'Install key generated';
2492
+ }
2493
+ if (panel) panel.removeAttribute('aria-busy');
2494
+
2495
+ // Move focus to the revealed credential controls for keyboard/screen-reader users.
2496
+ const copyBtn = document.getElementById('copy-install-key-btn');
2497
+ if (copyBtn) copyBtn.focus();
2498
+ else if (keyInput) keyInput.focus();
2499
+ }
2500
+
2501
+ // Page-scoped, click-driven initializer. Binds the generate and copy buttons.
2502
+ // Returns without side effects if the panel is absent. Does NOT mint on load.
2503
+ function initializeInstallKeyGeneration(container) {
2504
+ if (!container) return;
2505
+
2506
+ const generateBtn = document.getElementById('generate-install-key-btn');
2507
+ if (!generateBtn) return;
2508
+
2509
+ generateBtn.addEventListener('click', handleGenerateInstallKeyClick);
2510
+
2511
+ const copyBtn = document.getElementById('copy-install-key-btn');
2512
+ const keyInput = document.getElementById('generated-install-key');
2513
+ const copyStatus = document.getElementById('copy-install-key-status');
2514
+ if (copyBtn && keyInput) {
2515
+ copyBtn.addEventListener('click', async () => {
2516
+ const value = keyInput.value;
2517
+ if (!value) return;
2518
+ try {
2519
+ await copyTextToClipboard(value);
2520
+ if (copyStatus) copyStatus.textContent = 'Copied!';
2521
+ copyBtn.textContent = 'Copied!';
2522
+ setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
2523
+ } catch (_) {
2524
+ if (copyStatus) copyStatus.textContent = 'Copy failed';
2525
+ }
2526
+ });
2527
+ }
2528
+ }
2529
+
2530
+ // ============================================================
2531
+ // Entry point
2532
+ // ============================================================
2533
+
2534
+ // ============================================================
2535
+ // GitHub App connect flow (BAPI-506)
2536
+ // ============================================================
2537
+
2538
+ function getGitHubRepoName() {
2539
+ const container = document.getElementById('get-started-container');
2540
+ const repoName = container && container.dataset ? container.dataset.repoName : '';
2541
+ return (repoName || '').trim();
2542
+ }
2543
+
2544
+ function setGitHubConnectLoading(loading) {
2545
+ const btn = document.getElementById('connect-github-btn');
2546
+ const section = document.getElementById('github-connect-section');
2547
+ const status = document.getElementById('github-connect-status');
2548
+ if (btn) {
2549
+ btn.disabled = loading;
2550
+ btn.textContent = loading
2551
+ ? GITHUB_CONNECT_MESSAGES.connecting
2552
+ : GITHUB_CONNECT_MESSAGES.connectButton;
2553
+ }
2554
+ if (section) section.setAttribute('aria-busy', loading ? 'true' : 'false');
2555
+ if (status) status.textContent = loading ? GITHUB_CONNECT_MESSAGES.connecting : '';
2556
+ }
2557
+
2558
+ function showGitHubConnectError(message) {
2559
+ const errorEl = document.getElementById('github-connect-error');
2560
+ if (errorEl) {
2561
+ errorEl.textContent = message;
2562
+ errorEl.classList.remove('hidden');
2563
+ }
2564
+ }
2565
+
2566
+ function clearGitHubConnectionPanels() {
2567
+ ['github-connect-error', 'github-connect-result', 'github-repo-picker-panel'].forEach((id) => {
2568
+ const el = document.getElementById(id);
2569
+ if (el) {
2570
+ el.classList.add('hidden');
2571
+ if (id !== 'github-connect-error') el.innerHTML = '';
2572
+ }
2573
+ });
2574
+ }
2575
+
2576
+ function renderGitHubConnectedState(repoDisplayName) {
2577
+ clearGitHubConnectionPanels();
2578
+ const resultEl = document.getElementById('github-connect-result');
2579
+ if (!resultEl) return;
2580
+ const title = document.createElement('p');
2581
+ title.className = 'github-connect-result-title';
2582
+ title.textContent = GITHUB_CONNECT_MESSAGES.connectedTitle || 'GitHub connected';
2583
+ const body = document.createElement('p');
2584
+ body.textContent = repoDisplayName
2585
+ ? `Bridge verified the installation and linked ${repoDisplayName} automatically.`
2586
+ : 'Bridge verified the installation and linked your repository automatically.';
2587
+ resultEl.classList.remove('github-connect-result--warning', 'github-connect-result--error');
2588
+ resultEl.classList.add('github-connect-result--success');
2589
+ resultEl.appendChild(title);
2590
+ resultEl.appendChild(body);
2591
+ resultEl.classList.remove('hidden');
2592
+ }
2593
+
2594
+ function renderGitHubFailureState(status) {
2595
+ // The manual fallback link is always present in the card markup; failure
2596
+ // states just surface sanitized inline copy alongside it.
2597
+ const message = GITHUB_CONNECT_MESSAGES[status] || GITHUB_CONNECT_MESSAGES.connectionError;
2598
+ showGitHubConnectError(message);
2599
+ }
2600
+
2601
+ async function handleConnectGitHubClick() {
2602
+ clearGitHubConnectionPanels();
2603
+ const repoName = getGitHubRepoName();
2604
+ if (!repoName) {
2605
+ showGitHubConnectError(GITHUB_CONNECT_MESSAGES.missingRepo);
2606
+ return;
2607
+ }
2608
+
2609
+ setGitHubConnectLoading(true);
2610
+ try {
2611
+ const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.apiCall)('/setup/github/connection-code', 'POST', { repo_name: repoName });
2612
+ // apiCall returns null on centralized 401 redirect (navigating to login).
2613
+ if (!data) return;
2614
+ if (!data.install_url) {
2615
+ showGitHubConnectError(GITHUB_CONNECT_MESSAGES.connectionError);
2616
+ setGitHubConnectLoading(false);
2617
+ return;
2618
+ }
2619
+ // Navigate the browser to the GitHub App install URL.
2620
+ window.location.assign(data.install_url);
2621
+ } catch (error) {
2622
+ showGitHubConnectError(GITHUB_CONNECT_MESSAGES.connectionError);
2623
+ setGitHubConnectLoading(false);
2624
+ }
2625
+ }
2626
+
2627
+ function renderGitHubRepoPicker(candidates) {
2628
+ const panel = document.getElementById('github-repo-picker-panel');
2629
+ if (!panel) return;
2630
+ panel.innerHTML = '';
2631
+
2632
+ const prompt = document.createElement('p');
2633
+ prompt.textContent = GITHUB_CONNECT_MESSAGES.pickerPrompt;
2634
+ panel.appendChild(prompt);
2635
+
2636
+ const list = document.createElement('div');
2637
+ list.className = 'github-repo-picker-list';
2638
+
2639
+ candidates.forEach((candidate) => {
2640
+ const option = document.createElement('button');
2641
+ option.type = 'button';
2642
+ option.className = 'github-repo-picker-option btn btn-secondary';
2643
+ option.setAttribute('data-testid', 'github-repo-picker-option');
2644
+ option.setAttribute('data-github-repository-id', candidate.github_repository_id);
2645
+
2646
+ const nameSpan = document.createElement('span');
2647
+ nameSpan.className = 'github-repo-picker-option-name';
2648
+ nameSpan.textContent = candidate.github_repo_full_name || candidate.github_repo_name;
2649
+ option.appendChild(nameSpan);
2650
+
2651
+ if (candidate.owner) {
2652
+ const ownerSpan = document.createElement('span');
2653
+ ownerSpan.className = 'github-repo-picker-option-owner';
2654
+ ownerSpan.textContent = candidate.owner;
2655
+ option.appendChild(ownerSpan);
2656
+ }
2657
+
2658
+ option.addEventListener('click', () => handleGitHubRepoSelection(candidate));
2659
+ list.appendChild(option);
2660
+ });
2661
+
2662
+ panel.appendChild(list);
2663
+ panel.classList.remove('hidden');
2664
+ }
2665
+
2666
+ async function loadGitHubPickerCandidates() {
2667
+ const panel = document.getElementById('github-repo-picker-panel');
2668
+ const params = new URLSearchParams(window.location.search);
2669
+ const state = params.get('state');
2670
+ const installationId = params.get('installationId');
2671
+ const repoName = getGitHubRepoName();
2672
+
2673
+ if (!state || !installationId || !repoName) {
2674
+ renderGitHubFailureState('unresolved');
2675
+ return;
2676
+ }
2677
+
2678
+ if (panel) {
2679
+ panel.innerHTML = `<p>${GITHUB_CONNECT_MESSAGES.pickerLoading}</p>`;
2680
+ panel.classList.remove('hidden');
2681
+ }
2682
+
2683
+ try {
2684
+ const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.apiCall)('/setup/github/repository-candidates', 'POST', {
2685
+ repo_name: repoName,
2686
+ state,
2687
+ installation_id: installationId,
2688
+ });
2689
+ if (!data) return;
2690
+ const candidates = (data && data.candidates) || [];
2691
+ if (!candidates.length) {
2692
+ if (panel) panel.innerHTML = `<p>${GITHUB_CONNECT_MESSAGES.pickerEmpty}</p>`;
2693
+ return;
2694
+ }
2695
+ renderGitHubRepoPicker(candidates);
2696
+ } catch (error) {
2697
+ if (panel) panel.innerHTML = `<p>${GITHUB_CONNECT_MESSAGES.pickerEmpty}</p>`;
2698
+ }
2699
+ }
2700
+
2701
+ async function handleGitHubRepoSelection(candidate) {
2702
+ const params = new URLSearchParams(window.location.search);
2703
+ const state = params.get('state');
2704
+ const installationId = params.get('installationId');
2705
+ const repoName = getGitHubRepoName();
2706
+ if (!state || !installationId || !repoName) {
2707
+ renderGitHubFailureState('unresolved');
2708
+ return;
2709
+ }
2710
+
2711
+ try {
2712
+ const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.apiCall)('/setup/github/select-repository', 'POST', {
2713
+ repo_name: repoName,
2714
+ state,
2715
+ installation_id: installationId,
2716
+ github_repository_id: candidate.github_repository_id,
2717
+ });
2718
+ if (!data) return;
2719
+ if (data.status === 'connected') {
2720
+ renderGitHubConnectedState(candidate.github_repo_full_name || candidate.github_repo_name);
2721
+ // Refresh the live config-status readout now that the repo is bound.
2722
+ const projectId = new URLSearchParams(window.location.search).get('projectId');
2723
+ if (projectId) loadInstallInstructions(projectId);
2724
+ return;
2725
+ }
2726
+ renderGitHubFailureState('binding-failed');
2727
+ } catch (error) {
2728
+ renderGitHubFailureState('binding-failed');
2729
+ }
2730
+ }
2731
+
2732
+ function renderGitHubConnectionReturnState() {
2733
+ const params = new URLSearchParams(window.location.search);
2734
+ const status = params.get('githubConnection');
2735
+ if (!status) return;
2736
+
2737
+ if (status === 'connected') {
2738
+ renderGitHubConnectedState(params.get('githubRepo'));
2739
+ return;
2740
+ }
2741
+ if (status === 'ambiguous') {
2742
+ loadGitHubPickerCandidates();
2743
+ return;
2744
+ }
2745
+ renderGitHubFailureState(status);
2746
+ }
2747
+
2748
+ function initializeGitHubConnect() {
2749
+ const btn = document.getElementById('connect-github-btn');
2750
+ if (btn) {
2751
+ btn.addEventListener('click', handleConnectGitHubClick);
2752
+ }
2753
+ }
2754
+
2755
+ function init() {
2756
+ const container = document.getElementById('get-started-container');
2757
+ if (!container) return;
2758
+
2759
+ (0,_modal_js__WEBPACK_IMPORTED_MODULE_0__.initModal)();
2760
+
2761
+ // Click-driven install-key generation; does not mint a key on page load.
2762
+ initializeInstallKeyGeneration(container);
2763
+
2764
+ // Wire the "Connect GitHub" launch button (BAPI-506).
2765
+ initializeGitHubConnect();
2766
+
2767
+ // Bind keyboard navigation once — renderSnippetTabs may run multiple times (retry),
2768
+ // so binding here prevents the listener from stacking on the tabList element.
2769
+ const tabList = document.getElementById('editor-tabs');
2770
+ if (tabList) {
2771
+ tabList.addEventListener('keydown', (e) => {
2772
+ const tabs = Array.from(tabList.querySelectorAll('[role="tab"]'));
2773
+ if (!tabs.length) return;
2774
+ const current = tabs.findIndex(t => t.getAttribute('aria-selected') === 'true');
2775
+ let next = current;
2776
+ if (e.key === 'ArrowRight') next = (current + 1) % tabs.length;
2777
+ else if (e.key === 'ArrowLeft') next = (current - 1 + tabs.length) % tabs.length;
2778
+ else if (e.key === 'Home') next = 0;
2779
+ else if (e.key === 'End') next = tabs.length - 1;
2780
+ else return;
2781
+ e.preventDefault();
2782
+ activateTab(next);
2783
+ tabs[next].focus();
2784
+ });
2785
+ }
2786
+
2787
+ const projectId = getProjectIdFromUrl();
2788
+
2789
+ // Wire the setup-detail link (FAQ drawer) and footer CTA separately
2790
+ const setupDetailLink = document.getElementById('setup-detail-link');
2791
+ if (setupDetailLink && projectId) {
2792
+ setupDetailLink.href = buildSetupDetailHref(projectId);
2793
+ }
2794
+ const continueSetupLink = document.getElementById('continue-setup-link');
2795
+ if (continueSetupLink && projectId) {
2796
+ continueSetupLink.href = buildSetupDetailHref(projectId);
2797
+ }
2798
+
2799
+ // Initialize FAQ drawers scoped to this page
2800
+ (0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.initDrawers)({ root: document, drawerSelector: '#get-started-container .drawer' });
2801
+
2802
+ // Wire retry button
2803
+ const retryBtn = document.getElementById('get-started-retry-btn');
2804
+ if (retryBtn) {
2805
+ retryBtn.addEventListener('click', () => {
2806
+ if (projectId) loadInstallInstructions(projectId);
2807
+ });
2808
+ }
2809
+
2810
+ if (!projectId) {
2811
+ const loadingEl = document.getElementById('get-started-loading');
2812
+ const errorEl = document.getElementById('get-started-error');
2813
+ const msgEl = document.getElementById('get-started-error-msg');
2814
+ if (loadingEl) loadingEl.classList.add('hidden');
2815
+ if (msgEl) msgEl.textContent = 'Missing projectId — please navigate here from your project dashboard.';
2816
+ if (errorEl) errorEl.classList.remove('hidden');
2817
+ return;
2818
+ }
2819
+
2820
+ loadInstallInstructions(projectId);
2821
+ }
2822
+
2823
+ init();
2824
+
2825
+
2826
+ /***/ },
2827
+
2828
+ /***/ "./src/js/learn_repository.js"
2829
+ /*!************************************!*\
2830
+ !*** ./src/js/learn_repository.js ***!
2831
+ \************************************/
2832
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
2833
+
2834
+ __webpack_require__.r(__webpack_exports__);
2835
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
2836
+ /* harmony export */ initLearnRepository: () => (/* binding */ initLearnRepository),
2837
+ /* harmony export */ renderUI: () => (/* binding */ renderUI)
2838
+ /* harmony export */ });
2839
+ /* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils.js */ "./src/js/utils.js");
2840
+ /* harmony import */ var _modal_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./modal.js */ "./src/js/modal.js");
2841
+ /* harmony import */ var _optimize_context_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./optimize/context.js */ "./src/js/optimize/context.js");
2842
+ /* harmony import */ var _optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./optimize/status_adapters.js */ "./src/js/optimize/status_adapters.js");
2843
+ /* harmony import */ var _optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./optimize/polling.js */ "./src/js/optimize/polling.js");
2844
+ /* harmony import */ var _optimize_feedback_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./optimize/feedback.js */ "./src/js/optimize/feedback.js");
2845
+ /* harmony import */ var _optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./optimize/learn_repository_renderer.js */ "./src/js/optimize/learn_repository_renderer.js");
2846
+ /**
2847
+ * Learn Repository feature for the Optimize page.
2848
+ *
2849
+ * Routed through the shared Optimize plumbing (BAPI-307): canonical identity from
2850
+ * getOptimizeContext, status normalization via the pure adapters, declarative
2851
+ * per-section rendering via the renderer, polling via pollUntil, and inline
2852
+ * feedback via setInlineStatus. Initialization is driven by the single Optimize
2853
+ * page entrypoint (optimize.js) — this module no longer self-registers a
2854
+ * DOMContentLoaded listener. Phase 2 removed the global Begin/Resume CTA in
2855
+ * favor of the per-section "Run Analysis" model.
2856
+ */
2857
+
2858
+
2859
+
2860
+
2861
+
2862
+
2863
+
2864
+
2865
+
2866
+ // Active polls keyed by analysis type (per-section) plus the global learning
2867
+ // poll. Each value is an AbortController so a new poll can cancel the prior one.
2868
+ const activePolls = new Map();
2869
+
2870
+ let latestLearnRepositoryStatus = null;
2871
+ const GLOBAL_LEARNING_POLL_KEY = 'global-learning';
2872
+ const LEARNING_POLL_INTERVAL_MS = 30000;
2873
+ const LEARNING_POLL_TIMEOUT_MS = 600000;
2874
+
2875
+ // Canonical repository name, sourced once from the shared Optimize context. This
2876
+ // is the machine-readable identifier used in API URLs and POST payloads (never
2877
+ // the human-facing display name).
2878
+ let currentRepoName = null;
2879
+
2880
+ /**
2881
+ * Initialize the Learn Repository feature.
2882
+ * @param {{repoName: (string|null)}} [context] - Shared Optimize context.
2883
+ */
2884
+ async function initLearnRepository(context = (0,_optimize_context_js__WEBPACK_IMPORTED_MODULE_2__.getOptimizeContext)()) {
2885
+ currentRepoName = context && context.repoName;
2886
+ if (!currentRepoName) {
2887
+ console.error('Repository name not available');
2888
+ return;
2889
+ }
2890
+
2891
+ try {
2892
+ // Fetch initial status
2893
+ const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.apiCall)(
2894
+ `/optimize/learn-repository/${(0,_optimize_context_js__WEBPACK_IMPORTED_MODULE_2__.encodePathSegment)(currentRepoName)}/status`,
2895
+ 'GET'
2896
+ );
2897
+ if (data) {
2898
+ renderUI(data);
2899
+ } else {
2900
+ showStatusMessage('Failed to load repository learning status', 'error');
2901
+ }
2902
+ } catch (error) {
2903
+ console.error('Error fetching learn repository status:', error);
2904
+ showStatusMessage('Error loading repository learning status', 'error');
2905
+ }
2906
+
2907
+ // Set up event listeners
2908
+ setupEventListeners();
2909
+ }
2910
+
2911
+ function updateLatestLearnRepositoryStatus(data) {
2912
+ if (!data) return;
2913
+ latestLearnRepositoryStatus = data;
2914
+ }
2915
+
2916
+ /**
2917
+ * Render every Learn Repository section from a status payload. Each section is
2918
+ * normalized by the pure adapter and rendered declaratively by the renderer.
2919
+ * When the server reports a run in progress and no poll is active, a global poll
2920
+ * is started (idempotent — safe to call from re-renders inside the poll itself).
2921
+ * @param {Object} data
2922
+ */
2923
+ function renderUI(data) {
2924
+ updateLatestLearnRepositoryStatus(data);
2925
+
2926
+ const normalized = (0,_optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__.adaptLearnRepositoryStatus)(data);
2927
+
2928
+ _optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__.ANALYSIS_TYPES.forEach((type) => {
2929
+ (0,_optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__.renderLearnRepositorySection)(type, data[type] || {});
2930
+ });
2931
+
2932
+ if (normalized.learningInProgress && !activePolls.get(GLOBAL_LEARNING_POLL_KEY)) {
2933
+ pollLearningStatus();
2934
+ }
2935
+ }
2936
+
2937
+ function setupEventListeners() {
2938
+ // Approve buttons for each analysis type
2939
+ _optimize_status_adapters_js__WEBPACK_IMPORTED_MODULE_3__.ANALYSIS_TYPES.forEach((type) => {
2940
+ const sectionId = (0,_optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__.getSectionId)(type);
2941
+ const approveBtn = document.getElementById(`lr-${sectionId}-approve`);
2942
+ if (approveBtn) {
2943
+ approveBtn.addEventListener('click', () => handleApprove(type));
2944
+ }
2945
+
2946
+ // Save buttons
2947
+ const saveBtn = document.getElementById(`lr-${sectionId}-save`);
2948
+ if (saveBtn) {
2949
+ saveBtn.addEventListener('click', () => handleSaveInstructions(type));
2950
+ }
2951
+ });
2952
+
2953
+ // Reanalyze controls — now <button>s; reveal options instead of direct-fire.
2954
+ document.querySelectorAll('.lr-reanalyze-link').forEach((link) => {
2955
+ link.addEventListener('click', () => {
2956
+ link.classList.add('hidden');
2957
+ const container = link.closest('.reanalyze-container');
2958
+ const optionsWrapper = container?.querySelector('.lr-reanalyze-options');
2959
+ optionsWrapper?.classList.remove('hidden');
2960
+ const defaultRadio = optionsWrapper?.querySelector('input[value="auto_complete"]');
2961
+ defaultRadio?.focus();
2962
+ });
2963
+ });
2964
+
2965
+ // Confirm buttons — invoke handleReanalyze
2966
+ document.querySelectorAll('.lr-reanalyze-confirm-btn').forEach((btn) => {
2967
+ btn.addEventListener('click', (e) => {
2968
+ e.preventDefault();
2969
+ handleReanalyze(btn.dataset.analysisType);
2970
+ });
2971
+ });
2972
+
2973
+ // Cancel buttons — collapse options back to initial state
2974
+ document.querySelectorAll('.lr-reanalyze-cancel-btn').forEach((btn) => {
2975
+ btn.addEventListener('click', (e) => {
2976
+ e.preventDefault();
2977
+ const container = btn.closest('.reanalyze-container');
2978
+ const optionsWrapper = container?.querySelector('.lr-reanalyze-options');
2979
+ const reanalyzeLink = container?.querySelector('.lr-reanalyze-link');
2980
+ optionsWrapper?.classList.add('hidden');
2981
+ reanalyzeLink?.classList.remove('hidden');
2982
+ // Reset radio selection to auto_complete
2983
+ const defaultRadio = optionsWrapper?.querySelector('input[value="auto_complete"]');
2984
+ if (defaultRadio) defaultRadio.checked = true;
2985
+ reanalyzeLink?.focus();
2986
+ });
2987
+ });
2988
+
2989
+ // Accept/Discard draft buttons
2990
+ document.querySelectorAll('.lr-accept-draft-btn').forEach((btn) => {
2991
+ btn.addEventListener('click', () => handleAcceptDraft(btn.dataset.analysisType));
2992
+ });
2993
+ document.querySelectorAll('.lr-discard-draft-btn').forEach((btn) => {
2994
+ btn.addEventListener('click', () => handleDiscardDraft(btn.dataset.analysisType));
2995
+ });
2996
+
2997
+ // Per-section "Run Analysis" buttons for empty sections (the single start path)
2998
+ document.querySelectorAll('.lr-initial-analyze-btn').forEach((btn) => {
2999
+ btn.addEventListener('click', () => handleInitialAnalyze(btn.dataset.analysisType));
3000
+ });
3001
+ }
3002
+
3003
+ function stopGlobalLearningPoll() {
3004
+ const controller = activePolls.get(GLOBAL_LEARNING_POLL_KEY);
3005
+ if (controller) {
3006
+ controller.abort();
3007
+ activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
3008
+ }
3009
+ }
3010
+
3011
+ function pollLearningStatus() {
3012
+ stopGlobalLearningPoll();
3013
+
3014
+ const controller = new AbortController();
3015
+ activePolls.set(GLOBAL_LEARNING_POLL_KEY, controller);
3016
+ const statusUrl = `/optimize/learn-repository/${(0,_optimize_context_js__WEBPACK_IMPORTED_MODULE_2__.encodePathSegment)(currentRepoName)}/status`;
3017
+
3018
+ // Note: a full 'all' run can take ~30 minutes, longer than
3019
+ // LEARNING_POLL_TIMEOUT_MS — once the timeout fires the user sees a refresh
3020
+ // prompt; on refresh, renderUI's learning_in_progress branch resumes polling.
3021
+ (0,_optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__.pollUntil)(statusUrl, (data) => data != null && data.learning_in_progress !== true, {
3022
+ intervalMs: LEARNING_POLL_INTERVAL_MS,
3023
+ timeoutMs: LEARNING_POLL_TIMEOUT_MS,
3024
+ continueOnError: true,
3025
+ signal: controller.signal,
3026
+ onTick: (data) => {
3027
+ updateLatestLearnRepositoryStatus(data);
3028
+ renderUI(data);
3029
+ },
3030
+ })
3031
+ .then(() => {
3032
+ activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
3033
+ showStatusMessage('Learning analysis complete. Review any generated clarifications below.', 'success');
3034
+ })
3035
+ .catch((error) => {
3036
+ if (error && error.name === 'AbortError') return;
3037
+ activePolls.delete(GLOBAL_LEARNING_POLL_KEY);
3038
+ if (error instanceof _optimize_polling_js__WEBPACK_IMPORTED_MODULE_4__.PollTimeoutError) {
3039
+ showStatusMessage(
3040
+ 'Learning is taking longer than expected. Please refresh the page to see the latest status.',
3041
+ 'error'
3042
+ );
3043
+ } else {
3044
+ console.error('Global learning poll error:', error);
3045
+ }
3046
+ });
3047
+ }
3048
+
3049
+ async function handleApprove(analysisType) {
3050
+ const sectionId = (0,_optimize_learn_repository_renderer_js__WEBPACK_IMPORTED_MODULE_6__.getSectionId)(analysisType);
3051
+
3052
+ const confirmText = 'Are you sure you want to approve these clarifications? (You will still be able to edit this later in the configuration panel if needed)';
3053
+
3054
+ const confirmed = await showConfirmationModal('Confirm Approval', confirmText);
3055
+
3056
+ if (!confirmed) {
3057
+ return;
3058
+ }
3059
+
3060
+ const button = document.getElementById(`lr-${sectionId}-approve`);
3061
+ const textarea = document.getElementById(`lr-${sectionId}-textarea`);
3062
+ const originalText = button ? button.textContent : null;
3063
+
3064
+ // Get the edited clarifications text from the textarea
3065
+ const editedClarifications = textarea ? textarea.value : '';
3066
+
3067
+ try {
3068
+ // Disable button and show loading state
3069
+ if (button) {
3070
+ button.disabled = true;
2026
3071
  button.textContent = 'Processing...';
2027
3072
  }
2028
3073
 
@@ -4000,16 +5045,20 @@ __webpack_require__.r(__webpack_exports__);
4000
5045
  /* harmony export */ getChangedFieldEntries: () => (/* binding */ getChangedFieldEntries),
4001
5046
  /* harmony export */ getCiFollowupConfigCanonicalString: () => (/* binding */ getCiFollowupConfigCanonicalString),
4002
5047
  /* harmony export */ getPayloadValueForField: () => (/* binding */ getPayloadValueForField),
5048
+ /* harmony export */ getReviewPolicyOverridesCanonicalString: () => (/* binding */ getReviewPolicyOverridesCanonicalString),
5049
+ /* harmony export */ getReviewPolicyOverridesObject: () => (/* binding */ getReviewPolicyOverridesObject),
4003
5050
  /* harmony export */ getSelectedCiFollowupStrategy: () => (/* binding */ getSelectedCiFollowupStrategy),
4004
5051
  /* harmony export */ getSelectedMcpSlugs: () => (/* binding */ getSelectedMcpSlugs),
4005
5052
  /* harmony export */ getUtf8ByteLength: () => (/* binding */ getUtf8ByteLength),
4006
5053
  /* harmony export */ handleBlurValidation: () => (/* binding */ handleBlurValidation),
4007
5054
  /* harmony export */ handleInputClearError: () => (/* binding */ handleInputClearError),
5055
+ /* harmony export */ handleReviewPolicyResetAll: () => (/* binding */ handleReviewPolicyResetAll),
4008
5056
  /* harmony export */ handleSaveProject: () => (/* binding */ handleSaveProject),
4009
5057
  /* harmony export */ normalizeStringArrayValue: () => (/* binding */ normalizeStringArrayValue),
4010
5058
  /* harmony export */ populateCiFollowupConfig: () => (/* binding */ populateCiFollowupConfig),
4011
5059
  /* harmony export */ populateMcpDocSelector: () => (/* binding */ populateMcpDocSelector),
4012
5060
  /* harmony export */ populateProjectForms: () => (/* binding */ populateProjectForms),
5061
+ /* harmony export */ populateReviewPolicyOverrides: () => (/* binding */ populateReviewPolicyOverrides),
4013
5062
  /* harmony export */ rebuildSecondOpinionProviderOptions: () => (/* binding */ rebuildSecondOpinionProviderOptions),
4014
5063
  /* harmony export */ resetProjectDetailBaseline: () => (/* binding */ resetProjectDetailBaseline),
4015
5064
  /* harmony export */ setupConditionalVisibilityListeners: () => (/* binding */ setupConditionalVisibilityListeners),
@@ -4018,7 +5067,8 @@ __webpack_require__.r(__webpack_exports__);
4018
5067
  /* harmony export */ toggleDeepResearchTimeoutVisibility: () => (/* binding */ toggleDeepResearchTimeoutVisibility),
4019
5068
  /* harmony export */ toggleStoryPointsFieldVisibility: () => (/* binding */ toggleStoryPointsFieldVisibility),
4020
5069
  /* harmony export */ updateCiFollowupInstructionsByteCounter: () => (/* binding */ updateCiFollowupInstructionsByteCounter),
4021
- /* harmony export */ validateInput: () => (/* binding */ validateInput)
5070
+ /* harmony export */ validateInput: () => (/* binding */ validateInput),
5071
+ /* harmony export */ validateReviewPolicyOverrides: () => (/* binding */ validateReviewPolicyOverrides)
4022
5072
  /* harmony export */ });
4023
5073
  /* harmony import */ var _modal_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./modal.js */ "./src/js/modal.js");
4024
5074
  /* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils.js */ "./src/js/utils.js");
@@ -4155,113 +5205,617 @@ function setCiFollowupControlValues(config) {
4155
5205
  document.querySelectorAll('input[name="ci_followup_strategy"]').forEach(radio => {
4156
5206
  radio.checked = radio.value === config.strategy;
4157
5207
  });
4158
- const maxIterationsEl = document.getElementById('ci_followup_max_iterations');
4159
- if (maxIterationsEl) maxIterationsEl.value = config.max_iterations;
4160
- const maxMinutesEl = document.getElementById('ci_followup_max_minutes');
4161
- if (maxMinutesEl) maxMinutesEl.value = config.max_minutes;
4162
- const instructionsEl = document.getElementById('ci_followup_instructions');
4163
- if (instructionsEl) instructionsEl.value = config.instructions;
5208
+ const maxIterationsEl = document.getElementById('ci_followup_max_iterations');
5209
+ if (maxIterationsEl) maxIterationsEl.value = config.max_iterations;
5210
+ const maxMinutesEl = document.getElementById('ci_followup_max_minutes');
5211
+ if (maxMinutesEl) maxMinutesEl.value = config.max_minutes;
5212
+ const instructionsEl = document.getElementById('ci_followup_instructions');
5213
+ if (instructionsEl) instructionsEl.value = config.instructions;
5214
+ }
5215
+
5216
+ /**
5217
+ * Update the byte counter text and over-limit class for the ci_followup
5218
+ * instructions textarea.
5219
+ */
5220
+ function updateCiFollowupInstructionsByteCounter() {
5221
+ const counter = document.getElementById('ci_followup_instructions_byte_count');
5222
+ if (!counter) return;
5223
+ const instructionsEl = document.getElementById('ci_followup_instructions');
5224
+ const byteLength = getUtf8ByteLength(instructionsEl ? instructionsEl.value : '');
5225
+ counter.textContent = `${byteLength}/${CI_FOLLOWUP_INSTRUCTIONS_MAX_BYTES} bytes`;
5226
+ counter.classList.toggle('over-limit', byteLength > CI_FOLLOWUP_INSTRUCTIONS_MAX_BYTES);
5227
+ }
5228
+
5229
+ /**
5230
+ * Apply strategy-specific state to the instructions textarea: poll_only disables
5231
+ * (and, for a user-initiated change, clears) instructions; custom makes them
5232
+ * required; fix_and_iterate makes them optional. Initial population passes
5233
+ * `clearInstructionsOnPollOnly: false` so MCP-authored poll_only instructions
5234
+ * survive page load.
5235
+ * @param {{clearInstructionsOnPollOnly?: boolean}} [options]
5236
+ */
5237
+ function applyCiFollowupStrategyState(options = {}) {
5238
+ const { clearInstructionsOnPollOnly = true } = options;
5239
+ const strategy = getSelectedCiFollowupStrategy();
5240
+ const instructionsEl = document.getElementById('ci_followup_instructions');
5241
+ if (instructionsEl) {
5242
+ if (strategy === 'poll_only') {
5243
+ if (clearInstructionsOnPollOnly) instructionsEl.value = '';
5244
+ instructionsEl.disabled = true;
5245
+ instructionsEl.required = false;
5246
+ } else if (strategy === 'custom') {
5247
+ instructionsEl.disabled = false;
5248
+ instructionsEl.required = true;
5249
+ } else { // fix_and_iterate
5250
+ instructionsEl.disabled = false;
5251
+ instructionsEl.required = false;
5252
+ }
5253
+ }
5254
+ updateCiFollowupInstructionsByteCounter();
5255
+ }
5256
+
5257
+ /**
5258
+ * Show/hide and enable/disable the CI Follow-up inner controls based on the enable
5259
+ * checkbox. Off: hide via `.hidden` and disable every inner input. On: reveal and
5260
+ * re-enable, then apply strategy-specific instruction state (without clearing).
5261
+ */
5262
+ function toggleCiFollowupControlsVisibility() {
5263
+ const enabled = document.getElementById('ci_followup_enabled');
5264
+ const controls = document.getElementById('ci_followup_controls');
5265
+ if (!controls) return;
5266
+ const isOn = !!(enabled && enabled.checked);
5267
+ const innerInputs = controls.querySelectorAll('input, textarea, select');
5268
+ if (isOn) {
5269
+ controls.classList.remove('hidden');
5270
+ innerInputs.forEach(el => { el.disabled = false; });
5271
+ applyCiFollowupStrategyState({ clearInstructionsOnPollOnly: false });
5272
+ } else {
5273
+ controls.classList.add('hidden');
5274
+ innerInputs.forEach(el => { el.disabled = true; });
5275
+ }
5276
+ }
5277
+
5278
+ /**
5279
+ * Initialize the CI Follow-up sub-section from the GET value. Accepts the raw DB
5280
+ * value (canonical JSON string), an already-parsed object, or null/empty/invalid.
5281
+ * Invalid or empty values disable the feature and reset inner controls to defaults;
5282
+ * a valid config enables it and populates every control, preserving stored
5283
+ * instructions even for poll_only so MCP-authored values are not silently altered.
5284
+ * @param {string|Object|null} rawValue
5285
+ */
5286
+ function populateCiFollowupConfig(rawValue) {
5287
+ const enabledEl = document.getElementById('ci_followup_enabled');
5288
+ let config = null;
5289
+ if (rawValue && typeof rawValue === 'object') {
5290
+ config = rawValue;
5291
+ } else if (typeof rawValue === 'string' && rawValue.trim() !== '') {
5292
+ try {
5293
+ config = JSON.parse(rawValue);
5294
+ } catch (e) {
5295
+ config = null;
5296
+ }
5297
+ }
5298
+
5299
+ if (!config || Array.isArray(config) || typeof config !== 'object') {
5300
+ if (enabledEl) enabledEl.checked = false;
5301
+ setCiFollowupControlValues(CI_FOLLOWUP_DEFAULTS);
5302
+ toggleCiFollowupControlsVisibility();
5303
+ return;
5304
+ }
5305
+
5306
+ if (enabledEl) enabledEl.checked = true;
5307
+ setCiFollowupControlValues({
5308
+ strategy: config.strategy || CI_FOLLOWUP_DEFAULTS.strategy,
5309
+ max_iterations: config.max_iterations != null ? config.max_iterations : CI_FOLLOWUP_DEFAULTS.max_iterations,
5310
+ max_minutes: config.max_minutes != null ? config.max_minutes : CI_FOLLOWUP_DEFAULTS.max_minutes,
5311
+ instructions: typeof config.instructions === 'string' ? config.instructions : CI_FOLLOWUP_DEFAULTS.instructions,
5312
+ });
5313
+ // Reveals the controls and applies strategy state without clearing instructions.
5314
+ toggleCiFollowupControlsVisibility();
5315
+ }
5316
+
5317
+ // --- Difficulty-adaptive review policy overrides (BAPI-579; admin-only) ---
5318
+ // Six fixed difficulty buckets over the 1-10 scale. Each `slug` is the lowercased,
5319
+ // hyphenated bucket label used for the stable data-testid selectors. `range` is
5320
+ // display-only (boundaries are NOT editable). The bucket labels and the default
5321
+ // ladder below are kept byte-identical to the backend
5322
+ // (api/library/review_ticket_policy.py) so a stored override round-trips exactly.
5323
+ const REVIEW_POLICY_BUCKETS = Object.freeze([
5324
+ { name: 'Very Easy', slug: 'very-easy', range: '1' },
5325
+ { name: 'Easy', slug: 'easy', range: '2' },
5326
+ { name: 'Easy-Moderate', slug: 'easy-moderate', range: '3-4' },
5327
+ { name: 'Moderate', slug: 'moderate', range: '5' },
5328
+ { name: 'Moderate-Hard', slug: 'moderate-hard', range: '6' },
5329
+ { name: 'Hard', slug: 'hard', range: '7-10' },
5330
+ ]);
5331
+
5332
+ // The two per-bucket automations and their coarse per-round tiers. `clarify` must
5333
+ // keep at least one round; `critique` may be empty (skipped). Max two rounds each.
5334
+ const REVIEW_POLICY_AUTOMATIONS = Object.freeze(['clarify', 'critique']);
5335
+ const REVIEW_POLICY_AUTOMATION_LABELS = Object.freeze({ clarify: 'Clarify', critique: 'Critique' });
5336
+ const REVIEW_POLICY_TIERS = Object.freeze(['cheap', 'basic', 'premium']);
5337
+ const REVIEW_POLICY_TIER_LABELS = Object.freeze({ cheap: 'Cheap', basic: 'Basic', premium: 'Premium' });
5338
+ const REVIEW_POLICY_MAX_ROUNDS = 2;
5339
+ // Tier a freshly-added round starts on before the admin chooses a value.
5340
+ const REVIEW_POLICY_DEFAULT_NEW_TIER = 'basic';
5341
+
5342
+ // Built-in default ladder (mirrors DEFAULT_REVIEW_POLICY_LADDER on the backend).
5343
+ // Each bucket is the same {clarify:[tier...], critique:[tier...]} shape a stored
5344
+ // override uses; an empty critique list means that automation is skipped.
5345
+ const DEFAULT_REVIEW_POLICY_LADDER = Object.freeze({
5346
+ 'Very Easy': { clarify: ['basic'], critique: [] },
5347
+ 'Easy': { clarify: ['basic', 'basic'], critique: [] },
5348
+ 'Easy-Moderate': { clarify: ['premium', 'basic'], critique: [] },
5349
+ 'Moderate': { clarify: ['premium', 'premium'], critique: ['basic'] },
5350
+ 'Moderate-Hard': { clarify: ['premium'], critique: ['premium'] },
5351
+ 'Hard': { clarify: ['premium', 'premium'], critique: ['premium', 'premium'] },
5352
+ });
5353
+
5354
+ // In-memory editor state: { bucketName: { clarify: [tier...], critique: [tier...] } }
5355
+ // covering all six buckets. `null` until the editor is populated. Both the hidden
5356
+ // carrier input and the sparse-save descriptor serialize from this single source.
5357
+ let reviewPolicyState = null;
5358
+
5359
+ /**
5360
+ * Deep copy of one bucket's built-in default policy.
5361
+ * @param {string} name - bucket display name
5362
+ * @returns {{clarify:Array<string>, critique:Array<string>}}
5363
+ */
5364
+ function getReviewPolicyBucketDefault(name) {
5365
+ const def = DEFAULT_REVIEW_POLICY_LADDER[name];
5366
+ return { clarify: def.clarify.slice(), critique: def.critique.slice() };
5367
+ }
5368
+
5369
+ /**
5370
+ * Fresh full-ladder state deep-copied from the built-in defaults.
5371
+ * @returns {Object<string, {clarify:Array<string>, critique:Array<string>}>}
5372
+ */
5373
+ function cloneReviewPolicyLadder() {
5374
+ const ladder = {};
5375
+ REVIEW_POLICY_BUCKETS.forEach(bucket => {
5376
+ ladder[bucket.name] = getReviewPolicyBucketDefault(bucket.name);
5377
+ });
5378
+ return ladder;
5379
+ }
5380
+
5381
+ /**
5382
+ * Whether a bucket's current state equals its built-in default (order-sensitive).
5383
+ * @param {string} name - bucket display name
5384
+ * @returns {boolean}
5385
+ */
5386
+ function reviewPolicyBucketMatchesDefault(name) {
5387
+ if (!reviewPolicyState || !reviewPolicyState[name]) return true;
5388
+ const current = reviewPolicyState[name];
5389
+ const def = DEFAULT_REVIEW_POLICY_LADDER[name];
5390
+ return (
5391
+ JSON.stringify(current.clarify) === JSON.stringify(def.clarify) &&
5392
+ JSON.stringify(current.critique) === JSON.stringify(def.critique)
5393
+ );
5394
+ }
5395
+
5396
+ /**
5397
+ * The sparse overrides object: only buckets that DIFFER from their default,
5398
+ * inserted in the fixed bucket order with clarify before critique so a
5399
+ * JSON.stringify of the result is deterministic. `{}` when everything is default.
5400
+ * @returns {Object<string, {clarify:Array<string>, critique:Array<string>}>}
5401
+ */
5402
+ function getReviewPolicyOverridesObject() {
5403
+ const overrides = {};
5404
+ if (!reviewPolicyState) return overrides;
5405
+ REVIEW_POLICY_BUCKETS.forEach(bucket => {
5406
+ if (!reviewPolicyBucketMatchesDefault(bucket.name)) {
5407
+ const current = reviewPolicyState[bucket.name];
5408
+ overrides[bucket.name] = {
5409
+ clarify: current.clarify.slice(),
5410
+ critique: current.critique.slice(),
5411
+ };
5412
+ }
5413
+ });
5414
+ return overrides;
5415
+ }
5416
+
5417
+ /**
5418
+ * Canonical serialized overrides value for the sparse-save text descriptor and the
5419
+ * hidden carrier input. `'{}'` when every bucket matches its default.
5420
+ * @returns {string}
5421
+ */
5422
+ function getReviewPolicyOverridesCanonicalString() {
5423
+ return JSON.stringify(getReviewPolicyOverridesObject());
5424
+ }
5425
+
5426
+ /**
5427
+ * Push the canonical overrides value onto the hidden carrier input (form-submission
5428
+ * contract). The sparse-save flow reads from the descriptor, so this only keeps the
5429
+ * DOM carrier in sync; it is a no-op when the editor is absent.
5430
+ */
5431
+ function syncReviewPolicyHiddenInput() {
5432
+ const input = document.getElementById('review_policy_overrides_input');
5433
+ if (input) input.value = getReviewPolicyOverridesCanonicalString();
5434
+ }
5435
+
5436
+ /**
5437
+ * Local validation for one bucket's state. Returns an error message string, or ''
5438
+ * when the bucket is valid. Mirrors the backend contract: at most two rounds per
5439
+ * automation, every tier known, and at least one clarify round.
5440
+ * @param {string} name - bucket display name
5441
+ * @returns {string}
5442
+ */
5443
+ function validateReviewPolicyBucket(name) {
5444
+ if (!reviewPolicyState || !reviewPolicyState[name]) return '';
5445
+ const current = reviewPolicyState[name];
5446
+ for (const automation of REVIEW_POLICY_AUTOMATIONS) {
5447
+ const rounds = current[automation];
5448
+ const label = REVIEW_POLICY_AUTOMATION_LABELS[automation];
5449
+ if (rounds.length > REVIEW_POLICY_MAX_ROUNDS) {
5450
+ return `${label} allows at most ${REVIEW_POLICY_MAX_ROUNDS} rounds.`;
5451
+ }
5452
+ for (const tier of rounds) {
5453
+ if (!REVIEW_POLICY_TIERS.includes(tier)) {
5454
+ return `Invalid ${label.toLowerCase()} tier selected.`;
5455
+ }
5456
+ }
5457
+ }
5458
+ if (current.clarify.length < 1) {
5459
+ return 'Clarify must have at least one round.';
5460
+ }
5461
+ return '';
5462
+ }
5463
+
5464
+ /**
5465
+ * Set (or clear) a bucket's error container. Uses the shared `.visible` toggle so
5466
+ * no inline display is mutated.
5467
+ * @param {string} slug - bucket slug
5468
+ * @param {string} message - error text ('' clears it)
5469
+ */
5470
+ function setReviewPolicyBucketError(slug, message) {
5471
+ const errorEl = document.querySelector(`[data-testid="review-policy-bucket-${slug}-error"]`);
5472
+ if (!errorEl) return;
5473
+ errorEl.textContent = message || '';
5474
+ errorEl.classList.toggle('visible', !!message);
5475
+ }
5476
+
5477
+ /**
5478
+ * Validate every bucket, painting per-bucket errors. Returns false if any bucket is
5479
+ * invalid. Safe to call when the editor is absent (returns true).
5480
+ * @returns {boolean}
5481
+ */
5482
+ function validateReviewPolicyOverrides() {
5483
+ if (!reviewPolicyState) return true;
5484
+ let allValid = true;
5485
+ REVIEW_POLICY_BUCKETS.forEach(bucket => {
5486
+ const message = validateReviewPolicyBucket(bucket.name);
5487
+ setReviewPolicyBucketError(bucket.slug, message);
5488
+ const card = document.querySelector(`[data-testid="review-policy-bucket-${bucket.slug}"]`);
5489
+ if (card) card.classList.toggle('review-policy-bucket-card--invalid', !!message);
5490
+ if (message) allValid = false;
5491
+ });
5492
+ return allValid;
5493
+ }
5494
+
5495
+ /**
5496
+ * Refresh a bucket's default/override state label plus its error/invalid painting
5497
+ * from the current state. Called after every edit.
5498
+ * @param {{name:string, slug:string}} bucket
5499
+ */
5500
+ function updateReviewPolicyBucketStatus(bucket) {
5501
+ const stateEl = document.querySelector(`[data-testid="review-policy-bucket-${bucket.slug}-state"]`);
5502
+ const isDefault = reviewPolicyBucketMatchesDefault(bucket.name);
5503
+ if (stateEl) {
5504
+ stateEl.textContent = isDefault ? 'Using built-in defaults' : 'Override active';
5505
+ stateEl.classList.toggle('review-policy-state--default', isDefault);
5506
+ stateEl.classList.toggle('review-policy-state--override', !isDefault);
5507
+ }
5508
+ const message = validateReviewPolicyBucket(bucket.name);
5509
+ setReviewPolicyBucketError(bucket.slug, message);
5510
+ const card = document.querySelector(`[data-testid="review-policy-bucket-${bucket.slug}"]`);
5511
+ if (card) card.classList.toggle('review-policy-bucket-card--invalid', !!message);
5512
+ }
5513
+
5514
+ /**
5515
+ * Common post-edit refresh: repaint the bucket status and re-sync the hidden input.
5516
+ * @param {{name:string, slug:string}} bucket
5517
+ */
5518
+ function afterReviewPolicyChange(bucket) {
5519
+ updateReviewPolicyBucketStatus(bucket);
5520
+ syncReviewPolicyHiddenInput();
5521
+ }
5522
+
5523
+ /**
5524
+ * Build one per-round row: a tier <select> plus a remove-round button. The remove
5525
+ * button is disabled for a clarify group's last remaining round (clarify >= 1).
5526
+ * @param {{name:string, slug:string}} bucket
5527
+ * @param {string} automation - 'clarify' | 'critique'
5528
+ * @param {number} idx - zero-based round index
5529
+ * @param {string} tier - current tier value
5530
+ * @param {number} roundCount - total rounds currently in this automation
5531
+ * @returns {HTMLElement}
5532
+ */
5533
+ function renderReviewPolicyRoundRow(bucket, automation, idx, tier, roundCount) {
5534
+ const row = document.createElement('div');
5535
+ row.className = 'review-policy-round';
5536
+
5537
+ const label = document.createElement('label');
5538
+ label.className = 'review-policy-round-label';
5539
+
5540
+ const labelText = document.createElement('span');
5541
+ labelText.className = 'review-policy-round-label-text';
5542
+ labelText.textContent = `Round ${idx + 1}`;
5543
+ label.appendChild(labelText);
5544
+
5545
+ const select = document.createElement('select');
5546
+ select.className = 'review-policy-tier-select';
5547
+ select.setAttribute('data-testid', `review-policy-${bucket.slug}-${automation}-round-${idx + 1}-tier`);
5548
+ REVIEW_POLICY_TIERS.forEach(tierValue => {
5549
+ const option = document.createElement('option');
5550
+ option.value = tierValue;
5551
+ option.textContent = REVIEW_POLICY_TIER_LABELS[tierValue];
5552
+ if (tierValue === tier) option.selected = true;
5553
+ select.appendChild(option);
5554
+ });
5555
+ select.addEventListener('change', () => {
5556
+ reviewPolicyState[bucket.name][automation][idx] = select.value;
5557
+ afterReviewPolicyChange(bucket);
5558
+ });
5559
+ label.appendChild(select);
5560
+ row.appendChild(label);
5561
+
5562
+ const removeBtn = document.createElement('button');
5563
+ removeBtn.type = 'button';
5564
+ removeBtn.className = 'review-policy-remove-round';
5565
+ removeBtn.setAttribute('data-testid', `review-policy-${bucket.slug}-${automation}-remove-round-${idx + 1}`);
5566
+ removeBtn.textContent = 'Remove round';
5567
+ if (automation === 'clarify' && roundCount <= 1) {
5568
+ removeBtn.disabled = true;
5569
+ }
5570
+ removeBtn.addEventListener('click', () => handleReviewPolicyRemoveRound(bucket, automation, idx));
5571
+ row.appendChild(removeBtn);
5572
+
5573
+ return row;
5574
+ }
5575
+
5576
+ /**
5577
+ * Build one automation group (clarify or critique) with its round rows and an
5578
+ * add-round button (disabled at the two-round cap).
5579
+ * @param {{name:string, slug:string}} bucket
5580
+ * @param {string} automation - 'clarify' | 'critique'
5581
+ * @returns {HTMLElement}
5582
+ */
5583
+ function renderReviewPolicyGroup(bucket, automation) {
5584
+ const rounds = reviewPolicyState[bucket.name][automation];
5585
+ const group = document.createElement('div');
5586
+ group.className = 'review-policy-group';
5587
+ group.setAttribute('data-testid', `review-policy-${bucket.slug}-${automation}-group`);
5588
+
5589
+ const title = document.createElement('div');
5590
+ title.className = 'review-policy-group-title';
5591
+ title.textContent = REVIEW_POLICY_AUTOMATION_LABELS[automation];
5592
+ group.appendChild(title);
5593
+
5594
+ const roundsWrap = document.createElement('div');
5595
+ roundsWrap.className = 'review-policy-rounds';
5596
+ if (rounds.length === 0) {
5597
+ const empty = document.createElement('p');
5598
+ empty.className = 'review-policy-empty';
5599
+ empty.textContent = 'Skipped (no rounds)';
5600
+ roundsWrap.appendChild(empty);
5601
+ } else {
5602
+ rounds.forEach((tier, idx) => {
5603
+ roundsWrap.appendChild(renderReviewPolicyRoundRow(bucket, automation, idx, tier, rounds.length));
5604
+ });
5605
+ }
5606
+ group.appendChild(roundsWrap);
5607
+
5608
+ const addBtn = document.createElement('button');
5609
+ addBtn.type = 'button';
5610
+ addBtn.className = 'review-policy-add-round';
5611
+ addBtn.setAttribute('data-testid', `review-policy-${bucket.slug}-${automation}-add-round`);
5612
+ addBtn.textContent = 'Add round';
5613
+ if (rounds.length >= REVIEW_POLICY_MAX_ROUNDS) {
5614
+ addBtn.disabled = true;
5615
+ }
5616
+ addBtn.addEventListener('click', () => handleReviewPolicyAddRound(bucket, automation));
5617
+ group.appendChild(addBtn);
5618
+
5619
+ return group;
5620
+ }
5621
+
5622
+ /**
5623
+ * Build one bucket card: header (name, fixed range, state label, reset button),
5624
+ * error container, and the clarify/critique groups. All text is set via
5625
+ * textContent, so no unescaped values reach the DOM.
5626
+ * @param {{name:string, slug:string, range:string}} bucket
5627
+ * @returns {HTMLElement}
5628
+ */
5629
+ function renderReviewPolicyBucketCard(bucket) {
5630
+ const card = document.createElement('div');
5631
+ card.className = 'review-policy-bucket-card';
5632
+ card.setAttribute('data-testid', `review-policy-bucket-${bucket.slug}`);
5633
+
5634
+ const header = document.createElement('div');
5635
+ header.className = 'review-policy-bucket-header';
5636
+
5637
+ const heading = document.createElement('div');
5638
+ heading.className = 'review-policy-bucket-heading';
5639
+ const nameEl = document.createElement('span');
5640
+ nameEl.className = 'review-policy-bucket-name';
5641
+ nameEl.textContent = bucket.name;
5642
+ const rangeEl = document.createElement('span');
5643
+ rangeEl.className = 'review-policy-bucket-range';
5644
+ rangeEl.textContent = `Difficulty ${bucket.range}`;
5645
+ heading.appendChild(nameEl);
5646
+ heading.appendChild(rangeEl);
5647
+ header.appendChild(heading);
5648
+
5649
+ const stateEl = document.createElement('span');
5650
+ stateEl.className = 'review-policy-state';
5651
+ stateEl.setAttribute('data-testid', `review-policy-bucket-${bucket.slug}-state`);
5652
+ header.appendChild(stateEl);
5653
+
5654
+ const resetBtn = document.createElement('button');
5655
+ resetBtn.type = 'button';
5656
+ resetBtn.className = 'review-policy-reset-bucket';
5657
+ resetBtn.setAttribute('data-testid', `review-policy-${bucket.slug}-reset`);
5658
+ resetBtn.textContent = 'Reset bucket';
5659
+ resetBtn.addEventListener('click', () => handleReviewPolicyResetBucket(bucket));
5660
+ header.appendChild(resetBtn);
5661
+
5662
+ card.appendChild(header);
5663
+
5664
+ const errorEl = document.createElement('div');
5665
+ errorEl.className = 'form-error review-policy-bucket-error';
5666
+ errorEl.setAttribute('data-testid', `review-policy-bucket-${bucket.slug}-error`);
5667
+ card.appendChild(errorEl);
5668
+
5669
+ const groups = document.createElement('div');
5670
+ groups.className = 'review-policy-groups';
5671
+ REVIEW_POLICY_AUTOMATIONS.forEach(automation => {
5672
+ groups.appendChild(renderReviewPolicyGroup(bucket, automation));
5673
+ });
5674
+ card.appendChild(groups);
5675
+
5676
+ return card;
5677
+ }
5678
+
5679
+ /**
5680
+ * Replace one bucket card in place (used after a structural add/remove/reset).
5681
+ * @param {{name:string, slug:string, range:string}} bucket
5682
+ */
5683
+ function refreshReviewPolicyBucketCard(bucket) {
5684
+ const existing = document.querySelector(`[data-testid="review-policy-bucket-${bucket.slug}"]`);
5685
+ if (!existing || !existing.parentNode) return;
5686
+ existing.parentNode.replaceChild(renderReviewPolicyBucketCard(bucket), existing);
5687
+ updateReviewPolicyBucketStatus(bucket);
5688
+ }
5689
+
5690
+ /**
5691
+ * Build the compact 1-10 boundary reference strip (display-only, not editable).
5692
+ */
5693
+ function renderReviewPolicyBoundaryStrip() {
5694
+ const strip = document.getElementById('review_policy_boundary_strip');
5695
+ if (!strip) return;
5696
+ strip.textContent = '';
5697
+ REVIEW_POLICY_BUCKETS.forEach(bucket => {
5698
+ const item = document.createElement('span');
5699
+ item.className = 'review-policy-boundary-item';
5700
+ const label = document.createElement('span');
5701
+ label.className = 'review-policy-boundary-label';
5702
+ label.textContent = bucket.name;
5703
+ const range = document.createElement('span');
5704
+ range.className = 'review-policy-boundary-range';
5705
+ range.textContent = bucket.range;
5706
+ item.appendChild(label);
5707
+ item.appendChild(range);
5708
+ strip.appendChild(item);
5709
+ });
5710
+ }
5711
+
5712
+ /**
5713
+ * Render the full editor (boundary strip + all six bucket cards) from state, then
5714
+ * paint each bucket's status. No-op when the editor root is absent.
5715
+ */
5716
+ function renderReviewPolicyEditor() {
5717
+ const container = document.getElementById('review_policy_buckets');
5718
+ if (!container || !reviewPolicyState) return;
5719
+ renderReviewPolicyBoundaryStrip();
5720
+ container.textContent = '';
5721
+ REVIEW_POLICY_BUCKETS.forEach(bucket => {
5722
+ container.appendChild(renderReviewPolicyBucketCard(bucket));
5723
+ });
5724
+ REVIEW_POLICY_BUCKETS.forEach(bucket => updateReviewPolicyBucketStatus(bucket));
4164
5725
  }
4165
5726
 
4166
5727
  /**
4167
- * Update the byte counter text and over-limit class for the ci_followup
4168
- * instructions textarea.
5728
+ * Add a round (at the built-in new-round tier) to a bucket's automation, capped at
5729
+ * two rounds, then refresh the card.
5730
+ * @param {{name:string, slug:string, range:string}} bucket
5731
+ * @param {string} automation - 'clarify' | 'critique'
4169
5732
  */
4170
- function updateCiFollowupInstructionsByteCounter() {
4171
- const counter = document.getElementById('ci_followup_instructions_byte_count');
4172
- if (!counter) return;
4173
- const instructionsEl = document.getElementById('ci_followup_instructions');
4174
- const byteLength = getUtf8ByteLength(instructionsEl ? instructionsEl.value : '');
4175
- counter.textContent = `${byteLength}/${CI_FOLLOWUP_INSTRUCTIONS_MAX_BYTES} bytes`;
4176
- counter.classList.toggle('over-limit', byteLength > CI_FOLLOWUP_INSTRUCTIONS_MAX_BYTES);
5733
+ function handleReviewPolicyAddRound(bucket, automation) {
5734
+ const rounds = reviewPolicyState[bucket.name][automation];
5735
+ if (rounds.length >= REVIEW_POLICY_MAX_ROUNDS) return;
5736
+ rounds.push(REVIEW_POLICY_DEFAULT_NEW_TIER);
5737
+ refreshReviewPolicyBucketCard(bucket);
5738
+ afterReviewPolicyChange(bucket);
4177
5739
  }
4178
5740
 
4179
5741
  /**
4180
- * Apply strategy-specific state to the instructions textarea: poll_only disables
4181
- * (and, for a user-initiated change, clears) instructions; custom makes them
4182
- * required; fix_and_iterate makes them optional. Initial population passes
4183
- * `clearInstructionsOnPollOnly: false` so MCP-authored poll_only instructions
4184
- * survive page load.
4185
- * @param {{clearInstructionsOnPollOnly?: boolean}} [options]
5742
+ * Remove a round from a bucket's automation. Clarify can never drop below one round.
5743
+ * @param {{name:string, slug:string, range:string}} bucket
5744
+ * @param {string} automation - 'clarify' | 'critique'
5745
+ * @param {number} idx - zero-based round index to remove
4186
5746
  */
4187
- function applyCiFollowupStrategyState(options = {}) {
4188
- const { clearInstructionsOnPollOnly = true } = options;
4189
- const strategy = getSelectedCiFollowupStrategy();
4190
- const instructionsEl = document.getElementById('ci_followup_instructions');
4191
- if (instructionsEl) {
4192
- if (strategy === 'poll_only') {
4193
- if (clearInstructionsOnPollOnly) instructionsEl.value = '';
4194
- instructionsEl.disabled = true;
4195
- instructionsEl.required = false;
4196
- } else if (strategy === 'custom') {
4197
- instructionsEl.disabled = false;
4198
- instructionsEl.required = true;
4199
- } else { // fix_and_iterate
4200
- instructionsEl.disabled = false;
4201
- instructionsEl.required = false;
4202
- }
4203
- }
4204
- updateCiFollowupInstructionsByteCounter();
5747
+ function handleReviewPolicyRemoveRound(bucket, automation, idx) {
5748
+ const rounds = reviewPolicyState[bucket.name][automation];
5749
+ if (automation === 'clarify' && rounds.length <= 1) return;
5750
+ rounds.splice(idx, 1);
5751
+ refreshReviewPolicyBucketCard(bucket);
5752
+ afterReviewPolicyChange(bucket);
4205
5753
  }
4206
5754
 
4207
5755
  /**
4208
- * Show/hide and enable/disable the CI Follow-up inner controls based on the enable
4209
- * checkbox. Off: hide via `.hidden` and disable every inner input. On: reveal and
4210
- * re-enable, then apply strategy-specific instruction state (without clearing).
5756
+ * Reset one bucket to its built-in default and refresh the card.
5757
+ * @param {{name:string, slug:string, range:string}} bucket
4211
5758
  */
4212
- function toggleCiFollowupControlsVisibility() {
4213
- const enabled = document.getElementById('ci_followup_enabled');
4214
- const controls = document.getElementById('ci_followup_controls');
4215
- if (!controls) return;
4216
- const isOn = !!(enabled && enabled.checked);
4217
- const innerInputs = controls.querySelectorAll('input, textarea, select');
4218
- if (isOn) {
4219
- controls.classList.remove('hidden');
4220
- innerInputs.forEach(el => { el.disabled = false; });
4221
- applyCiFollowupStrategyState({ clearInstructionsOnPollOnly: false });
4222
- } else {
4223
- controls.classList.add('hidden');
4224
- innerInputs.forEach(el => { el.disabled = true; });
4225
- }
5759
+ function handleReviewPolicyResetBucket(bucket) {
5760
+ if (!reviewPolicyState) return;
5761
+ reviewPolicyState[bucket.name] = getReviewPolicyBucketDefault(bucket.name);
5762
+ refreshReviewPolicyBucketCard(bucket);
5763
+ afterReviewPolicyChange(bucket);
4226
5764
  }
4227
5765
 
4228
5766
  /**
4229
- * Initialize the CI Follow-up sub-section from the GET value. Accepts the raw DB
4230
- * value (canonical JSON string), an already-parsed object, or null/empty/invalid.
4231
- * Invalid or empty values disable the feature and reset inner controls to defaults;
4232
- * a valid config enables it and populates every control, preserving stored
4233
- * instructions even for poll_only so MCP-authored values are not silently altered.
4234
- * @param {string|Object|null} rawValue
5767
+ * Reset every bucket to its built-in default and re-render the whole editor.
4235
5768
  */
4236
- function populateCiFollowupConfig(rawValue) {
4237
- const enabledEl = document.getElementById('ci_followup_enabled');
4238
- let config = null;
4239
- if (rawValue && typeof rawValue === 'object') {
4240
- config = rawValue;
5769
+ function handleReviewPolicyResetAll() {
5770
+ if (!reviewPolicyState) return;
5771
+ reviewPolicyState = cloneReviewPolicyLadder();
5772
+ renderReviewPolicyEditor();
5773
+ syncReviewPolicyHiddenInput();
5774
+ }
5775
+
5776
+ /**
5777
+ * Initialize the review-policy editor from the stored GET value. Accepts a parsed
5778
+ * object, a JSON string, or null/invalid (all treated as no overrides). Only known
5779
+ * buckets with a well-formed clarify list are merged over the defaults; anything
5780
+ * malformed falls back to that bucket's default (defensive — the backend validates
5781
+ * on write). No-op when the editor root is absent.
5782
+ * @param {Object|string|null} rawValue
5783
+ */
5784
+ function populateReviewPolicyOverrides(rawValue) {
5785
+ const editor = document.getElementById('review_policy_overrides_editor');
5786
+ if (!editor) return;
5787
+
5788
+ let overrides = {};
5789
+ if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) {
5790
+ overrides = rawValue;
4241
5791
  } else if (typeof rawValue === 'string' && rawValue.trim() !== '') {
4242
5792
  try {
4243
- config = JSON.parse(rawValue);
5793
+ const parsed = JSON.parse(rawValue);
5794
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
5795
+ overrides = parsed;
5796
+ }
4244
5797
  } catch (e) {
4245
- config = null;
5798
+ overrides = {};
4246
5799
  }
4247
5800
  }
4248
5801
 
4249
- if (!config || Array.isArray(config) || typeof config !== 'object') {
4250
- if (enabledEl) enabledEl.checked = false;
4251
- setCiFollowupControlValues(CI_FOLLOWUP_DEFAULTS);
4252
- toggleCiFollowupControlsVisibility();
4253
- return;
4254
- }
4255
-
4256
- if (enabledEl) enabledEl.checked = true;
4257
- setCiFollowupControlValues({
4258
- strategy: config.strategy || CI_FOLLOWUP_DEFAULTS.strategy,
4259
- max_iterations: config.max_iterations != null ? config.max_iterations : CI_FOLLOWUP_DEFAULTS.max_iterations,
4260
- max_minutes: config.max_minutes != null ? config.max_minutes : CI_FOLLOWUP_DEFAULTS.max_minutes,
4261
- instructions: typeof config.instructions === 'string' ? config.instructions : CI_FOLLOWUP_DEFAULTS.instructions,
5802
+ reviewPolicyState = cloneReviewPolicyLadder();
5803
+ REVIEW_POLICY_BUCKETS.forEach(bucket => {
5804
+ const stored = overrides[bucket.name];
5805
+ if (!stored || typeof stored !== 'object' || !Array.isArray(stored.clarify)) return;
5806
+ const clarify = stored.clarify
5807
+ .filter(tier => REVIEW_POLICY_TIERS.includes(tier))
5808
+ .slice(0, REVIEW_POLICY_MAX_ROUNDS);
5809
+ const critique = Array.isArray(stored.critique)
5810
+ ? stored.critique.filter(tier => REVIEW_POLICY_TIERS.includes(tier)).slice(0, REVIEW_POLICY_MAX_ROUNDS)
5811
+ : [];
5812
+ if (clarify.length >= 1) {
5813
+ reviewPolicyState[bucket.name] = { clarify, critique };
5814
+ }
4262
5815
  });
4263
- // Reveals the controls and applies strategy state without clearing instructions.
4264
- toggleCiFollowupControlsVisibility();
5816
+
5817
+ renderReviewPolicyEditor();
5818
+ syncReviewPolicyHiddenInput();
4265
5819
  }
4266
5820
 
4267
5821
  /**
@@ -4346,6 +5900,12 @@ function setupConditionalVisibilityListeners() {
4346
5900
  if (ciInstructions) {
4347
5901
  ciInstructions.addEventListener('input', updateCiFollowupInstructionsByteCounter);
4348
5902
  }
5903
+ // BAPI-579: the review-policy "reset all" button. Per-bucket controls (round
5904
+ // selects, add/remove, per-bucket reset) are wired at render time.
5905
+ const reviewPolicyResetAll = document.getElementById('review_policy_reset_all');
5906
+ if (reviewPolicyResetAll) {
5907
+ reviewPolicyResetAll.addEventListener('click', handleReviewPolicyResetAll);
5908
+ }
4349
5909
 
4350
5910
  // Apply initial state for all three dependent regions before baseline capture.
4351
5911
  toggleDeepResearchTimeoutVisibility();
@@ -4388,10 +5948,15 @@ const PROJECT_DETAIL_FIELD_DESCRIPTORS = [
4388
5948
  { field: 'review_instructions', type: 'text', id: 'review_instructions' },
4389
5949
  { field: 'documentation_instructions', type: 'text', id: 'documentation_instructions' },
4390
5950
  { field: 'allow_mutating_smoke_ops', type: 'boolean', id: 'allow_mutating_smoke_ops' },
5951
+ { field: 'difficulty_adaptive_review_enabled', type: 'boolean', id: 'difficulty_adaptive_review_enabled' },
4391
5952
  { field: 'selected_mcp_slugs', type: 'array', getValue: getSelectedMcpSlugs, orderInsensitive: true },
4392
5953
  // CI Follow-up: a structured sub-section that diffs as a stable canonical JSON
4393
5954
  // string ('' when disabled). getPayloadValueForField parses it back to an object.
4394
5955
  { field: 'ci_followup_config', type: 'text', getValue: getCiFollowupConfigCanonicalString },
5956
+ // BAPI-579: difficulty-adaptive review policy overrides. Diffs as a stable
5957
+ // canonical JSON string ('{}' when every bucket matches the built-in default).
5958
+ // getPayloadValueForField parses it back to the object the backend expects.
5959
+ { field: 'difficulty_adaptive_review_policy_overrides', type: 'text', getValue: getReviewPolicyOverridesCanonicalString },
4395
5960
 
4396
5961
  // Code Reviewer (the four *_correctness_standards render in the Code Writer
4397
5962
  // drawer but persist via the code_reviewer payload — see FIELD_TO_SECTION)
@@ -4448,7 +6013,9 @@ const FIELD_TO_SECTION = {
4448
6013
  review_instructions: 'code_writer',
4449
6014
  documentation_instructions: 'code_writer',
4450
6015
  ci_followup_config: 'code_writer',
6016
+ difficulty_adaptive_review_policy_overrides: 'code_writer',
4451
6017
  allow_mutating_smoke_ops: 'code_writer',
6018
+ difficulty_adaptive_review_enabled: 'code_writer',
4452
6019
  selected_mcp_slugs: 'code_writer',
4453
6020
  // code_reviewer
4454
6021
  frontend_correctness_standards: 'code_reviewer',
@@ -4526,6 +6093,16 @@ function getPayloadValueForField(fieldName, changedValue, estimateScaleValue = n
4526
6093
  // when off; otherwise an object the backend's shared validator canonicalizes.
4527
6094
  return changedValue === '' ? null : JSON.parse(changedValue);
4528
6095
  }
6096
+ if (fieldName === 'difficulty_adaptive_review_policy_overrides') {
6097
+ // changedValue is the canonical JSON string ('{}' when all buckets are
6098
+ // default). Send it as a parsed object; the backend validates and persists
6099
+ // it (an empty {} clears any prior override back to the built-in ladder).
6100
+ try {
6101
+ return JSON.parse(changedValue);
6102
+ } catch (e) {
6103
+ return {};
6104
+ }
6105
+ }
4529
6106
  return changedValue;
4530
6107
  }
4531
6108
 
@@ -4580,6 +6157,7 @@ const EXPLANATION_TEXTS = {
4580
6157
  'alternate_version_directories': 'Which directories hold the alternate SFCC version (the one that isn\'t your base version)? Input partial directory paths that can identify them. Enter one directory path per line.',
4581
6158
  'write_tests': 'If enabled, the Code Writer will attempt to generate unit tests alongside the code draft. Optional, defaults to true.',
4582
6159
  'allow_mutating_smoke_ops': 'If selected, the AI will work to genuinely test functionality through real operations, executed against the local environment. This will provide greater quality assurance, but requires greater trust. Only runs on the sandbox.',
6160
+ 'difficulty_adaptive_review_enabled': 'OFF by default. When enabled, easy/medium tickets may run one lower-cost review round instead of two. Hard, unknown, or error/failure cases always keep the full premium two-round review, so review quality never degrades silently.',
4583
6161
  'review_instructions': 'Instructions that guide how the AI validates that a plan was implemented correctly. Use this to define the specific verification steps the AI should follow during its review — such as which tools to invoke, what checks to run, and how to confirm correctness.',
4584
6162
  'selected_mcp_slugs': 'MCP Validation Manuals: choose which canonical MCP manuals are supplied to the final plan reviewer when it generates review, smoke, integration, and documentation steps. The available choices are loaded from the backend MCP docs catalog.',
4585
6163
  'pwa_overrides_directories': 'Specify the directories that contain your PWA Kit template overrides. Enter one directory path per line (e.g., `overrides`, `app/templates`, `custom-overrides`). These directories will be given special consideration when the AI generates or modifies code.',
@@ -4871,9 +6449,15 @@ function populateProjectForms(data) {
4871
6449
  if (codeWriterForm.elements.allow_mutating_smoke_ops) {
4872
6450
  codeWriterForm.elements.allow_mutating_smoke_ops.checked = !!writer.allow_mutating_smoke_ops;
4873
6451
  }
6452
+ if (codeWriterForm.elements.difficulty_adaptive_review_enabled) {
6453
+ codeWriterForm.elements.difficulty_adaptive_review_enabled.checked = !!writer.difficulty_adaptive_review_enabled;
6454
+ }
4874
6455
  populateMcpDocSelector(data.available_mcp_docs || [], writer.selected_mcp_slugs || []);
4875
6456
  // CI Follow-up: parse the raw DB text into the structured controls (OFF when null).
4876
6457
  populateCiFollowupConfig(writer.ci_followup_config);
6458
+ // BAPI-579: render the difficulty-adaptive review policy editor from the
6459
+ // stored overrides object ({} = built-in defaults).
6460
+ populateReviewPolicyOverrides(writer.difficulty_adaptive_review_policy_overrides);
4877
6461
  }
4878
6462
 
4879
6463
  // Correctness standards (moved to Code Writer drawer, still saved via code_reviewer payload)
@@ -5132,6 +6716,13 @@ function validateAllForms() {
5132
6716
  }
5133
6717
  });
5134
6718
 
6719
+ // BAPI-579: the review-policy editor's per-bucket rules (>=1 clarify round,
6720
+ // <=2 rounds, known tiers) are validated separately from the native inputs,
6721
+ // painting errors on the affected bucket's error container.
6722
+ if (!validateReviewPolicyOverrides()) {
6723
+ allValid = false;
6724
+ }
6725
+
5135
6726
  return allValid;
5136
6727
  }
5137
6728
 
@@ -5215,76 +6806,449 @@ async function init() {
5215
6806
  const urlParams = new URLSearchParams(window.location.search);
5216
6807
  const projectId = urlParams.get('projectId');
5217
6808
 
5218
- if (!projectId) {
5219
- (0,_modal_js__WEBPACK_IMPORTED_MODULE_0__.showModal)('Error', 'Project ID not found in URL.');
5220
- return;
5221
- }
6809
+ if (!projectId) {
6810
+ (0,_modal_js__WEBPACK_IMPORTED_MODULE_0__.showModal)('Error', 'Project ID not found in URL.');
6811
+ return;
6812
+ }
6813
+
6814
+ await loadProjectDetail(projectId);
6815
+
6816
+ if (saveChangesBtn) {
6817
+ saveChangesBtn.addEventListener('click', handleSaveProject);
6818
+ }
6819
+
6820
+ if (saveChangesBtn) {
6821
+ saveChangesBtn.textContent = 'Save Configuration';
6822
+ saveChangesBtn.style.position = 'fixed';
6823
+ saveChangesBtn.style.bottom = '20px';
6824
+ saveChangesBtn.style.left = '50%';
6825
+ saveChangesBtn.style.transform = 'translateX(-50%)';
6826
+ saveChangesBtn.style.zIndex = '1000';
6827
+ saveChangesBtn.style.padding = '10px 20px';
6828
+ saveChangesBtn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
6829
+ }
6830
+
6831
+ initializeExplainLinkFunctionality();
6832
+
6833
+ const allSaveButtons = document.querySelectorAll('button[type="submit"]');
6834
+ allSaveButtons.forEach(button => {
6835
+ if (button !== saveChangesBtn) {
6836
+ button.style.display = 'none';
6837
+ }
6838
+ });
6839
+
6840
+ if (estimateScaleJsonTextarea) {
6841
+ estimateScaleJsonTextarea.addEventListener('input', () => {
6842
+ try {
6843
+ if (estimateScaleJsonTextarea.value.trim()) {
6844
+ JSON.parse(estimateScaleJsonTextarea.value);
6845
+ }
6846
+ estimateScaleJsonErrorDiv.classList.remove('visible');
6847
+ estimateScaleJsonTextarea.style.borderColor = '';
6848
+ } catch (e) {
6849
+ estimateScaleJsonErrorDiv.textContent = 'Invalid JSON format.';
6850
+ estimateScaleJsonErrorDiv.classList.add('visible');
6851
+ estimateScaleJsonTextarea.style.borderColor = 'var(--error-color)';
6852
+ }
6853
+ });
6854
+ }
6855
+
6856
+ setupValidationListeners();
6857
+
6858
+ // Wire conditional visibility (deep-research timeout, story points field, CI
6859
+ // follow-up controls) and apply initial hidden/disabled state BEFORE the
6860
+ // baseline snapshot so dependent fields are not flagged permanently dirty.
6861
+ setupConditionalVisibilityListeners();
6862
+
6863
+ // Capture the baseline only after the forms are populated, the dynamic
6864
+ // MCP checkboxes are rendered (in loadProjectDetail -> populateProjectForms),
6865
+ // the explain links are wired, the estimate-scale live-validation listener
6866
+ // is attached, the validation listeners are set up, and conditional
6867
+ // visibility has been applied — otherwise async-rendered or dependent fields
6868
+ // would appear permanently dirty.
6869
+ captureProjectDetailBaseline();
6870
+ }
6871
+ }
6872
+
6873
+ document.addEventListener('DOMContentLoaded', init);
6874
+
6875
+ // Named exports for unit testing (DOM-free where possible). Exporting does not
6876
+ // change browser behavior — the page still runs via the DOMContentLoaded hook.
6877
+
6878
+
6879
+
6880
+ /***/ },
6881
+
6882
+ /***/ "./src/js/run-dashboard.js"
6883
+ /*!*********************************!*\
6884
+ !*** ./src/js/run-dashboard.js ***!
6885
+ \*********************************/
6886
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
6887
+
6888
+ __webpack_require__.r(__webpack_exports__);
6889
+ /* harmony import */ var _utils_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils.js */ "./src/js/utils.js");
6890
+ /* harmony import */ var _modal_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./modal.js */ "./src/js/modal.js");
6891
+ // src/js/run-dashboard.js
6892
+ /**
6893
+ * Run Dashboard page module (BAPI-539, Epic Conductor v2, T8).
6894
+ *
6895
+ * Pure projection over truth: polls/refreshes the read-only dashboard data
6896
+ * endpoint and re-renders the ticket x gate matrix, active jobs, and
6897
+ * needs_human cards. The ONLY mutation this module triggers is the unpark
6898
+ * POST, owned by T5 (api/routes/epic_runs.py::unpark_epic_ticket_endpoint) —
6899
+ * the frontend only wires the button to it and re-renders on success.
6900
+ */
6901
+
6902
+
6903
+
6904
+ const REFRESH_INTERVAL_MS = 30000;
6905
+
6906
+ let pollTimer = null;
6907
+
6908
+ function init() {
6909
+ const container = document.getElementById('run-dashboard-container');
6910
+ if (!container) {
6911
+ return;
6912
+ }
6913
+
6914
+ (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.initDrawers)({ root: document, drawerSelector: '#run-dashboard-container .drawer' });
6915
+
6916
+ const refreshButton = container.querySelector('[data-testid="run-dashboard-refresh"]');
6917
+ if (refreshButton) {
6918
+ refreshButton.addEventListener('click', () => {
6919
+ refreshDashboard({ showScopedLoading: true });
6920
+ });
6921
+ }
6922
+
6923
+ container.addEventListener('click', (event) => {
6924
+ const button = event.target.closest('[data-testid="unpark-ticket-button"]');
6925
+ if (button) {
6926
+ handleUnparkClick(button);
6927
+ }
6928
+ });
6929
+
6930
+ const intervalMs = Number(container.dataset.refreshIntervalMs) || REFRESH_INTERVAL_MS;
6931
+ if (pollTimer) {
6932
+ clearInterval(pollTimer);
6933
+ }
6934
+ pollTimer = setInterval(() => {
6935
+ refreshDashboard({ showScopedLoading: false });
6936
+ }, intervalMs);
6937
+ }
6938
+
6939
+ function setScopedError(selector, message) {
6940
+ const container = document.getElementById('run-dashboard-container');
6941
+ if (!container) return;
6942
+ const panel = container.querySelector(selector);
6943
+ if (!panel) return;
6944
+ if (message) {
6945
+ panel.textContent = message;
6946
+ panel.classList.remove('hidden');
6947
+ panel.classList.add('visible');
6948
+ } else {
6949
+ panel.textContent = '';
6950
+ panel.classList.add('hidden');
6951
+ panel.classList.remove('visible');
6952
+ }
6953
+ }
6954
+
6955
+ async function refreshDashboard({ showScopedLoading = false } = {}) {
6956
+ const container = document.getElementById('run-dashboard-container');
6957
+ if (!container) return;
6958
+ const refreshUrl = container.dataset.refreshUrl;
6959
+ const refreshButton = container.querySelector('[data-testid="run-dashboard-refresh"]');
6960
+
6961
+ if (showScopedLoading) {
6962
+ (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.showLoading)();
6963
+ if (refreshButton) refreshButton.disabled = true;
6964
+ }
6965
+
6966
+ try {
6967
+ const data = await (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.apiCall)(refreshUrl, 'GET');
6968
+ if (data === null) {
6969
+ // Centralized 401 handling in apiCall already redirected.
6970
+ return;
6971
+ }
6972
+ setScopedError('[data-testid="run-dashboard-error"]', null);
6973
+ renderDashboard(data);
6974
+ } catch (error) {
6975
+ setScopedError(
6976
+ '[data-testid="run-dashboard-error"]',
6977
+ (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.getApiErrorMessage)(error, 'Failed to refresh dashboard.')
6978
+ );
6979
+ } finally {
6980
+ if (showScopedLoading) {
6981
+ (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.hideLoading)();
6982
+ if (refreshButton) refreshButton.disabled = false;
6983
+ }
6984
+ }
6985
+ }
6986
+
6987
+ function renderDashboard(data) {
6988
+ renderRunSummary(data.summary);
6989
+ renderGateMatrix(data.gate_columns, data.ticket_rows);
6990
+ renderActiveJobs(data.active_jobs);
6991
+ renderNeedsHumanCards(data.needs_human_cards, data.summary);
6992
+ }
6993
+
6994
+ function renderRunSummary(summary) {
6995
+ const summaryEl = document.querySelector('[data-testid="run-dashboard-summary"]');
6996
+ if (!summaryEl || !summary) return;
6997
+ const fieldMap = {
6998
+ run_status: summary.run_status,
6999
+ last_tick_at: summary.last_tick_at || 'N/A',
7000
+ active_job_count: summary.active_job_count,
7001
+ needs_human_count: summary.needs_human_count,
7002
+ lease_owner: summary.lease_owner || 'N/A',
7003
+ };
7004
+ Object.keys(fieldMap).forEach((field) => {
7005
+ const el = summaryEl.querySelector(`[data-field="${field}"]`);
7006
+ if (el) el.textContent = String(fieldMap[field]);
7007
+ });
7008
+ }
7009
+
7010
+ function renderGateMatrix(gateColumns, ticketRows) {
7011
+ const tbody = document.getElementById('gate-matrix-body');
7012
+ const emptyState = document.querySelector('[data-testid="gate-matrix-empty"]');
7013
+ if (!tbody) return;
7014
+
7015
+ while (tbody.firstChild) {
7016
+ tbody.removeChild(tbody.firstChild);
7017
+ }
7018
+
7019
+ const rows = ticketRows || [];
7020
+ if (rows.length === 0) {
7021
+ if (emptyState) emptyState.classList.remove('hidden');
7022
+ return;
7023
+ }
7024
+ if (emptyState) emptyState.classList.add('hidden');
7025
+
7026
+ rows.forEach((row) => {
7027
+ const tr = document.createElement('tr');
7028
+ tr.dataset.testid = 'gate-matrix-row';
7029
+ tr.dataset.ticketKey = row.ticket_key;
7030
+
7031
+ const ticketTd = document.createElement('td');
7032
+ ticketTd.className = 'monospace';
7033
+ ticketTd.textContent = row.ticket_key;
7034
+ tr.appendChild(ticketTd);
7035
+
7036
+ (row.cells || []).forEach((cell) => {
7037
+ const td = document.createElement('td');
7038
+ td.className = 'gate-cell';
7039
+ td.dataset.testid = 'gate-cell';
7040
+ td.dataset.ticketKey = row.ticket_key;
7041
+ td.dataset.gateKey = cell.gate_key;
7042
+
7043
+ const badge = document.createElement('span');
7044
+ const status = cell.status || 'pending';
7045
+ badge.className = `gate-status-badge gate-status-badge--${status}`;
7046
+ badge.textContent = status;
7047
+ td.appendChild(badge);
7048
+
7049
+ if (cell.observation_detail) {
7050
+ const details = document.createElement('details');
7051
+ details.className = 'gate-observation-detail';
7052
+ details.dataset.testid = 'gate-observation-detail';
7053
+ details.dataset.ticketKey = row.ticket_key;
7054
+ details.dataset.gateKey = cell.gate_key;
7055
+
7056
+ const summary = document.createElement('summary');
7057
+ summary.textContent = cell.observation_summary || '';
7058
+ details.appendChild(summary);
7059
+
7060
+ const body = document.createElement('div');
7061
+ body.className = 'observation-detail-body';
7062
+ body.textContent = cell.observation_detail;
7063
+ details.appendChild(body);
7064
+
7065
+ td.appendChild(details);
7066
+ }
7067
+
7068
+ tr.appendChild(td);
7069
+ });
7070
+
7071
+ tbody.appendChild(tr);
7072
+ });
7073
+ }
7074
+
7075
+ function renderActiveJobs(activeJobs) {
7076
+ const list = document.getElementById('active-jobs-list');
7077
+ const emptyState = document.querySelector('[data-testid="active-jobs-empty"]');
7078
+ if (!list) return;
7079
+
7080
+ while (list.firstChild) {
7081
+ list.removeChild(list.firstChild);
7082
+ }
7083
+
7084
+ const jobs = activeJobs || [];
7085
+ if (jobs.length === 0) {
7086
+ if (emptyState) emptyState.classList.remove('hidden');
7087
+ return;
7088
+ }
7089
+ if (emptyState) emptyState.classList.add('hidden');
7090
+
7091
+ jobs.forEach((job) => {
7092
+ const row = document.createElement('div');
7093
+ row.className = 'active-job-row';
7094
+ row.dataset.testid = 'active-job-row';
7095
+ row.dataset.jobId = String(job.id);
7096
+
7097
+ const statusBadge = document.createElement('span');
7098
+ statusBadge.className = `gate-status-badge gate-status-badge--${job.status}`;
7099
+ statusBadge.textContent = job.status;
7100
+ row.appendChild(statusBadge);
7101
+
7102
+ const ticketSpan = document.createElement('span');
7103
+ ticketSpan.className = 'monospace';
7104
+ ticketSpan.textContent = job.ticket_key || '';
7105
+ row.appendChild(ticketSpan);
7106
+
7107
+ const jobTypeSpan = document.createElement('span');
7108
+ jobTypeSpan.textContent = job.job_type || '';
7109
+ row.appendChild(jobTypeSpan);
7110
+
7111
+ if (job.phase_hint) {
7112
+ const phaseSpan = document.createElement('span');
7113
+ phaseSpan.className = 'phase-hint';
7114
+ phaseSpan.textContent = job.phase_hint;
7115
+ row.appendChild(phaseSpan);
7116
+ }
7117
+
7118
+ const activitySpan = document.createElement('span');
7119
+ activitySpan.className = 'job-activity';
7120
+ activitySpan.textContent = job.last_activity_at || 'N/A';
7121
+ row.appendChild(activitySpan);
7122
+
7123
+ const commitCountSpan = document.createElement('span');
7124
+ commitCountSpan.className = 'job-commit-count';
7125
+ const commitCount = job.local_commit_count != null ? job.local_commit_count : 0;
7126
+ commitCountSpan.textContent = `${commitCount} commits`;
7127
+ row.appendChild(commitCountSpan);
7128
+
7129
+ const shaSpan = document.createElement('span');
7130
+ shaSpan.className = 'monospace job-commit-sha';
7131
+ shaSpan.textContent = job.last_commit_sha || 'N/A';
7132
+ row.appendChild(shaSpan);
7133
+
7134
+ list.appendChild(row);
7135
+ });
7136
+ }
5222
7137
 
5223
- await loadProjectDetail(projectId);
7138
+ function renderNeedsHumanCards(cards, summary) {
7139
+ const list = document.getElementById('needs-human-list');
7140
+ const emptyState = document.querySelector('[data-testid="needs-human-empty"]');
7141
+ if (!list) return;
5224
7142
 
5225
- if (saveChangesBtn) {
5226
- saveChangesBtn.addEventListener('click', handleSaveProject);
5227
- }
7143
+ while (list.firstChild) {
7144
+ list.removeChild(list.firstChild);
7145
+ }
5228
7146
 
5229
- if (saveChangesBtn) {
5230
- saveChangesBtn.textContent = 'Save Configuration';
5231
- saveChangesBtn.style.position = 'fixed';
5232
- saveChangesBtn.style.bottom = '20px';
5233
- saveChangesBtn.style.left = '50%';
5234
- saveChangesBtn.style.transform = 'translateX(-50%)';
5235
- saveChangesBtn.style.zIndex = '1000';
5236
- saveChangesBtn.style.padding = '10px 20px';
5237
- saveChangesBtn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
5238
- }
7147
+ const items = cards || [];
7148
+ if (items.length === 0) {
7149
+ if (emptyState) emptyState.classList.remove('hidden');
7150
+ return;
7151
+ }
7152
+ if (emptyState) emptyState.classList.add('hidden');
5239
7153
 
5240
- initializeExplainLinkFunctionality();
5241
-
5242
- const allSaveButtons = document.querySelectorAll('button[type="submit"]');
5243
- allSaveButtons.forEach(button => {
5244
- if (button !== saveChangesBtn) {
5245
- button.style.display = 'none';
5246
- }
7154
+ items.forEach((card) => {
7155
+ const cardEl = document.createElement('div');
7156
+ cardEl.className = 'needs-human-card';
7157
+ cardEl.dataset.testid = 'needs-human-card';
7158
+ cardEl.dataset.ticketKey = card.ticket_key;
7159
+
7160
+ const heading = document.createElement('h3');
7161
+ heading.className = 'monospace';
7162
+ heading.textContent = card.ticket_key;
7163
+ cardEl.appendChild(heading);
7164
+
7165
+ const fields = [
7166
+ ['Parked gate', card.parked_gate_label || 'N/A'],
7167
+ ['Reason', card.needs_human_reason || 'N/A'],
7168
+ ['Parked at', card.parked_at || 'N/A'],
7169
+ ['What happened', card.observation_summary || 'N/A'],
7170
+ ['Remediation attempts', card.remediation_attempts],
7171
+ ['No-progress attempts', card.remediation_no_progress_attempts],
7172
+ ['Gate attempts', card.gate_attempts != null ? card.gate_attempts : 'N/A'],
7173
+ ];
7174
+ fields.forEach(([label, value]) => {
7175
+ const fieldEl = document.createElement('div');
7176
+ fieldEl.className = 'needs-human-field';
7177
+ const strong = document.createElement('strong');
7178
+ strong.textContent = `${label}: `;
7179
+ fieldEl.appendChild(strong);
7180
+ fieldEl.appendChild(document.createTextNode(String(value)));
7181
+ cardEl.appendChild(fieldEl);
5247
7182
  });
5248
7183
 
5249
- if (estimateScaleJsonTextarea) {
5250
- estimateScaleJsonTextarea.addEventListener('input', () => {
5251
- try {
5252
- if (estimateScaleJsonTextarea.value.trim()) {
5253
- JSON.parse(estimateScaleJsonTextarea.value);
5254
- }
5255
- estimateScaleJsonErrorDiv.classList.remove('visible');
5256
- estimateScaleJsonTextarea.style.borderColor = '';
5257
- } catch (e) {
5258
- estimateScaleJsonErrorDiv.textContent = 'Invalid JSON format.';
5259
- estimateScaleJsonErrorDiv.classList.add('visible');
5260
- estimateScaleJsonTextarea.style.borderColor = 'var(--error-color)';
5261
- }
5262
- });
5263
- }
7184
+ if (card.observation_detail) {
7185
+ const details = document.createElement('details');
7186
+ details.className = 'gate-observation-detail';
7187
+ const detailsSummary = document.createElement('summary');
7188
+ detailsSummary.textContent = 'Observation detail';
7189
+ details.appendChild(detailsSummary);
7190
+ const body = document.createElement('div');
7191
+ body.className = 'observation-detail-body';
7192
+ body.textContent = card.observation_detail;
7193
+ details.appendChild(body);
7194
+ cardEl.appendChild(details);
7195
+ }
7196
+
7197
+ const actions = document.createElement('div');
7198
+ actions.className = 'needs-human-actions';
7199
+
7200
+ const unparkButton = document.createElement('button');
7201
+ unparkButton.type = 'button';
7202
+ unparkButton.className = 'btn btn-primary';
7203
+ unparkButton.dataset.testid = 'unpark-ticket-button';
7204
+ unparkButton.dataset.ticketKey = card.ticket_key;
7205
+ unparkButton.dataset.rowVersion = String(card.row_version);
7206
+ unparkButton.textContent = 'Unpark';
7207
+ actions.appendChild(unparkButton);
7208
+
7209
+ const secondaryAction = document.createElement('span');
7210
+ secondaryAction.className = 'needs-human-secondary-action';
7211
+ secondaryAction.setAttribute('aria-disabled', 'true');
7212
+ secondaryAction.textContent = 'Abandon (not available from this view)';
7213
+ actions.appendChild(secondaryAction);
7214
+
7215
+ cardEl.appendChild(actions);
7216
+ list.appendChild(cardEl);
7217
+ });
7218
+ }
5264
7219
 
5265
- setupValidationListeners();
7220
+ async function handleUnparkClick(button) {
7221
+ const container = document.getElementById('run-dashboard-container');
7222
+ if (!container) return;
5266
7223
 
5267
- // Wire conditional visibility (deep-research timeout, story points field, CI
5268
- // follow-up controls) and apply initial hidden/disabled state BEFORE the
5269
- // baseline snapshot so dependent fields are not flagged permanently dirty.
5270
- setupConditionalVisibilityListeners();
7224
+ const ticketKey = button.dataset.ticketKey;
7225
+ const rowVersion = button.dataset.rowVersion;
7226
+ const repoName = container.dataset.repoName;
7227
+ const epicRunId = container.dataset.epicRunId;
7228
+ const unparkUrlTemplate = container.dataset.unparkUrlTemplate;
5271
7229
 
5272
- // Capture the baseline only after the forms are populated, the dynamic
5273
- // MCP checkboxes are rendered (in loadProjectDetail -> populateProjectForms),
5274
- // the explain links are wired, the estimate-scale live-validation listener
5275
- // is attached, the validation listeners are set up, and conditional
5276
- // visibility has been applied — otherwise async-rendered or dependent fields
5277
- // would appear permanently dirty.
5278
- captureProjectDetailBaseline();
7230
+ button.disabled = true;
7231
+ (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.showLoading)();
7232
+
7233
+ try {
7234
+ const url = unparkUrlTemplate.replace('__ticket_key__', encodeURIComponent(ticketKey));
7235
+ await (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.apiCall)(url, 'POST', {
7236
+ repo_name: repoName,
7237
+ expected_row_version: Number(rowVersion),
7238
+ idempotency_key: `run-dashboard-unpark:${epicRunId}:${ticketKey}:${rowVersion}`,
7239
+ reason: 'Unparked from the run dashboard.',
7240
+ });
7241
+ await refreshDashboard({ showScopedLoading: false });
7242
+ } catch (error) {
7243
+ (0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Unpark Failed', (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.getApiErrorMessage)(error, 'Failed to unpark ticket.'));
7244
+ button.disabled = false;
7245
+ } finally {
7246
+ (0,_utils_js__WEBPACK_IMPORTED_MODULE_0__.hideLoading)();
5279
7247
  }
5280
7248
  }
5281
7249
 
5282
7250
  document.addEventListener('DOMContentLoaded', init);
5283
7251
 
5284
- // Named exports for unit testing (DOM-free where possible). Exporting does not
5285
- // change browser behavior — the page still runs via the DOMContentLoaded hook.
5286
-
5287
-
5288
7252
 
5289
7253
  /***/ },
5290
7254
 
@@ -6806,8 +8770,480 @@ let projectsContainer, createProjectContainer;
6806
8770
  // Project Management (Setup Page Only)
6807
8771
  // ==========================================
6808
8772
 
8773
+ // ------------------------------------------------------------------
8774
+ // BAPI-548: attention-first, XSS-safe setup dashboard renderer.
8775
+ //
8776
+ // SECURITY: every project-supplied string (display name, repo name,
8777
+ // description, working_in, provider, version, branch, signal labels and
8778
+ // messages) is assigned via textContent / safeText / createTextElement — never
8779
+ // interpolated into innerHTML. Static structural markup is built as DOM nodes.
8780
+ // ------------------------------------------------------------------
8781
+
8782
+ // Visible launcher routes (order matters — matches the comp's launcher row).
8783
+ const LAUNCHER_ACTIONS = [
8784
+ { label: 'Setup', testid: 'project-action-setup', href: (id) => `/setup/setup-detail?projectId=${id}` },
8785
+ { label: 'Configure', testid: 'project-action-configure', href: (id) => `/setup/project-detail?projectId=${id}` },
8786
+ { label: 'Optimize', testid: 'project-action-optimize', href: (id) => `/optimize/${id}` },
8787
+ { label: 'Data', testid: 'project-action-data', href: (id) => `/setup/data/${id}` },
8788
+ { label: 'Monitoring', testid: 'project-action-monitoring', href: (id) => `/setup/monitoring/${id}` },
8789
+ ];
8790
+
8791
+ // Overflow (⋯) routes, kept separate from the visible launcher row.
8792
+ const OVERFLOW_ACTIONS = [
8793
+ { label: 'Security', testid: 'project-action-security', href: (id) => `/setup/security/${id}` },
8794
+ { label: 'Connect editor', testid: 'project-action-connect-editor', href: (id) => `/setup/get-started?projectId=${id}` },
8795
+ ];
8796
+
8797
+ const PROVIDER_LABELS = { openai: 'OpenAI', anthropic: 'Anthropic', gemini: 'Gemini' };
8798
+ const STATE_ICONS = { ok: '✓', warning: '⚠', critical: '⛔', unknown: '–' };
8799
+
8800
+ /**
8801
+ * Coerce a possibly-nullish value to a safe display string. Never returns HTML.
8802
+ * @param {*} value
8803
+ * @param {string} fallback
8804
+ * @returns {string}
8805
+ */
8806
+ function safeText(value, fallback) {
8807
+ if (value === null || value === undefined) {
8808
+ return fallback || '';
8809
+ }
8810
+ const text = String(value).trim();
8811
+ return text === '' ? (fallback || '') : text;
8812
+ }
8813
+
8814
+ /**
8815
+ * Create an element, set an optional class, and assign text via textContent.
8816
+ * @param {string} tag
8817
+ * @param {string} className
8818
+ * @param {string} text
8819
+ * @returns {HTMLElement}
8820
+ */
8821
+ function createTextElement(tag, className, text) {
8822
+ const el = document.createElement(tag);
8823
+ if (className) {
8824
+ el.className = className;
8825
+ }
8826
+ if (text !== undefined && text !== null) {
8827
+ el.textContent = String(text);
8828
+ }
8829
+ return el;
8830
+ }
8831
+
8832
+ /**
8833
+ * Format an ISO timestamp into a short relative string (e.g. "2d ago").
8834
+ * @param {string} iso
8835
+ * @returns {string}
8836
+ */
8837
+ function formatRelativeTime(iso) {
8838
+ if (!iso) {
8839
+ return 'never';
8840
+ }
8841
+ const then = new Date(iso);
8842
+ if (Number.isNaN(then.getTime())) {
8843
+ return 'unknown';
8844
+ }
8845
+ const diffMs = Date.now() - then.getTime();
8846
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
8847
+ if (diffDays <= 0) {
8848
+ return 'today';
8849
+ }
8850
+ if (diffDays === 1) {
8851
+ return '1d ago';
8852
+ }
8853
+ if (diffDays < 30) {
8854
+ return `${diffDays}d ago`;
8855
+ }
8856
+ const diffMonths = Math.floor(diffDays / 30);
8857
+ if (diffMonths < 12) {
8858
+ return `${diffMonths}mo ago`;
8859
+ }
8860
+ return `${Math.floor(diffMonths / 12)}y ago`;
8861
+ }
8862
+
8863
+ /**
8864
+ * Tolerate a missing/older API `status` block by returning an unknown-safe default.
8865
+ * @param {Object} project
8866
+ * @returns {Object}
8867
+ */
8868
+ function getProjectStatus(project) {
8869
+ const status = project && project.status ? project.status : {};
8870
+ return {
8871
+ working_in: status.working_in || null,
8872
+ provider: status.provider || null,
8873
+ version: status.version || null,
8874
+ last_index: status.last_index || {},
8875
+ signals: Array.isArray(status.signals) ? status.signals : [],
8876
+ summary: status.summary || { attention_count: 0, overall_state: 'unknown' },
8877
+ };
8878
+ }
8879
+
8880
+ /**
8881
+ * Return only the signals that need attention (warning or critical).
8882
+ * @param {Object} status
8883
+ * @returns {Array}
8884
+ */
8885
+ function getAttentionSignals(status) {
8886
+ if (!status || !Array.isArray(status.signals)) {
8887
+ return [];
8888
+ }
8889
+ return status.signals.filter((signal) => signal && (signal.state === 'warning' || signal.state === 'critical'));
8890
+ }
8891
+
8892
+ /**
8893
+ * Update the dashboard header counters from the fetched projects.
8894
+ * @param {Array} projects
8895
+ */
8896
+ function updateProjectsDashboardSummary(projects) {
8897
+ const list = Array.isArray(projects) ? projects : [];
8898
+ const countEl = document.getElementById('projects-count');
8899
+ const attentionEl = document.getElementById('projects-attention-count');
8900
+
8901
+ if (countEl) {
8902
+ const noun = list.length === 1 ? 'project' : 'projects';
8903
+ countEl.textContent = `${list.length} ${noun}`;
8904
+ }
8905
+
8906
+ if (attentionEl) {
8907
+ // Header counts PROJECTS that need attention (≥1 warning/critical signal),
8908
+ // not total signals — the per-card pill carries the per-project signal count.
8909
+ const projectsNeedingAttention = list.reduce((sum, project) => {
8910
+ const status = getProjectStatus(project);
8911
+ return sum + (getAttentionSignals(status).length > 0 ? 1 : 0);
8912
+ }, 0);
8913
+ if (projectsNeedingAttention > 0) {
8914
+ attentionEl.textContent = ` · ${projectsNeedingAttention} need attention`;
8915
+ attentionEl.classList.remove('hidden');
8916
+ } else {
8917
+ attentionEl.textContent = '';
8918
+ attentionEl.classList.add('hidden');
8919
+ }
8920
+ }
8921
+ }
8922
+
8923
+ /**
8924
+ * Build the compact one-line summary row: dot, display name, repo tag, status pill.
8925
+ * @param {Object} project
8926
+ * @param {Object} status
8927
+ * @returns {HTMLElement}
8928
+ */
8929
+ function buildProjectSummaryRow(project, status) {
8930
+ const overallState = (status.summary && status.summary.overall_state) || 'unknown';
8931
+ const attentionCount = getAttentionSignals(status).length;
8932
+
8933
+ const row = createTextElement('div', 'project-card-summary');
8934
+
8935
+ const identity = createTextElement('div', 'project-identity');
8936
+ identity.appendChild(createTextElement('span', `project-state-dot project-state-dot--${overallState}`, ''));
8937
+ const title = createTextElement('span', 'project-title', safeText(project.repo_display_name || project.repo_name, 'Untitled project'));
8938
+ title.setAttribute('data-testid', 'project-display-name');
8939
+ identity.appendChild(title);
8940
+ const repoTag = createTextElement('span', 'project-repo-tag', safeText(project.repo_name, ''));
8941
+ repoTag.setAttribute('data-testid', 'project-repo-tag');
8942
+ identity.appendChild(repoTag);
8943
+ row.appendChild(identity);
8944
+
8945
+ let pill;
8946
+ if (attentionCount > 0) {
8947
+ pill = createTextElement('span', 'project-status-pill project-status-pill--attention', `⚠ ${attentionCount} need attention`);
8948
+ } else {
8949
+ pill = createTextElement('span', 'project-status-pill project-status-pill--ok', '✓ All monitored checks green');
8950
+ }
8951
+ pill.setAttribute('data-testid', 'project-status-pill');
8952
+ row.appendChild(pill);
8953
+ return row;
8954
+ }
8955
+
8956
+ /**
8957
+ * Build the muted secondary meta line. Absent values are skipped or dashed.
8958
+ * @param {Object} status
8959
+ * @returns {HTMLElement}
8960
+ */
8961
+ function buildProjectMetaLine(status) {
8962
+ const parts = [];
8963
+ if (status.working_in) {
8964
+ parts.push(`works in ${safeText(status.working_in, '')}`);
8965
+ }
8966
+ if (status.version) {
8967
+ parts.push(`v${safeText(status.version, '')}`);
8968
+ }
8969
+ if (status.provider) {
8970
+ parts.push(`AI ${PROVIDER_LABELS[status.provider] || safeText(status.provider, '')}`);
8971
+ }
8972
+ const lastIndex = status.last_index || {};
8973
+ if (lastIndex.last_ran) {
8974
+ parts.push(`indexed ${safeText(lastIndex.branch, 'default')}`);
8975
+ parts.push(formatRelativeTime(lastIndex.last_ran));
8976
+ } else {
8977
+ parts.push('never indexed');
8978
+ }
8979
+ const meta = createTextElement('div', 'project-meta-line', parts.join(' · '));
8980
+ meta.setAttribute('data-testid', 'project-meta-line');
8981
+ return meta;
8982
+ }
8983
+
8984
+ /**
8985
+ * Build the 2-line-clamped description text node.
8986
+ * @param {Object} project
8987
+ * @returns {HTMLElement}
8988
+ */
8989
+ function buildProjectDescription(project) {
8990
+ const description = createTextElement('p', 'project-description', safeText(project.project_description, 'No description provided.'));
8991
+ description.setAttribute('data-testid', 'project-description');
8992
+ return description;
8993
+ }
8994
+
8995
+ /**
8996
+ * Build a safe internal fix link for an attention panel.
8997
+ * Only internal routes beginning with "/" become navigable links.
8998
+ * @param {Object} fix
8999
+ * @returns {HTMLElement}
9000
+ */
9001
+ function buildAttentionFixLink(fix) {
9002
+ const label = fix && fix.label ? `${safeText(fix.label, 'go')} →` : '→ go';
9003
+ const href = fix && typeof fix.href === 'string' ? fix.href : '';
9004
+ if (href && href.charAt(0) === '/' && href.charAt(1) !== '/') {
9005
+ const link = createTextElement('a', 'attention-fix-link', label);
9006
+ link.setAttribute('href', href);
9007
+ link.setAttribute('data-testid', 'attention-fix-link');
9008
+ return link;
9009
+ }
9010
+ // Non-internal / missing href: render as inert text, never a navigable link.
9011
+ const inert = createTextElement('span', 'attention-fix-link attention-fix-link--inert', label);
9012
+ inert.setAttribute('data-testid', 'attention-fix-link');
9013
+ return inert;
9014
+ }
9015
+
9016
+ /**
9017
+ * Build tinted attention panels for warning/critical signals.
9018
+ * @param {Array} signals - attention signals only
9019
+ * @returns {HTMLElement|null}
9020
+ */
9021
+ function buildAttentionPanels(signals) {
9022
+ if (!signals || signals.length === 0) {
9023
+ return null;
9024
+ }
9025
+ const list = createTextElement('div', 'project-attention-list', '');
9026
+ signals.forEach((signal) => {
9027
+ const state = signal.state === 'critical' ? 'critical' : 'warning';
9028
+ const panel = createTextElement('div', `attention-panel attention-panel--${state}`, '');
9029
+ panel.setAttribute('data-testid', 'attention-panel');
9030
+ panel.setAttribute('data-signal-id', safeText(signal.id, ''));
9031
+
9032
+ const icon = createTextElement('span', 'attention-icon', STATE_ICONS[state] || '⚠');
9033
+ icon.setAttribute('aria-hidden', 'true');
9034
+ panel.appendChild(icon);
9035
+
9036
+ const content = createTextElement('div', 'attention-content', '');
9037
+ content.appendChild(createTextElement('span', 'attention-label', safeText(signal.label, 'Check')));
9038
+ content.appendChild(createTextElement('span', 'attention-message', safeText(signal.message, '')));
9039
+ panel.appendChild(content);
9040
+
9041
+ panel.appendChild(buildAttentionFixLink(signal.fix));
9042
+ list.appendChild(panel);
9043
+ });
9044
+ return list;
9045
+ }
9046
+
9047
+ /**
9048
+ * Build the native, keyboard-operable "Show all checks" disclosure listing every signal.
9049
+ * @param {Array} signals - all signals
9050
+ * @returns {HTMLElement|null}
9051
+ */
9052
+ function buildChecksDetails(signals) {
9053
+ if (!signals || signals.length === 0) {
9054
+ return null;
9055
+ }
9056
+ const details = createTextElement('details', 'project-checks-details', '');
9057
+ details.setAttribute('data-testid', 'checks-details');
9058
+ const summary = createTextElement('summary', 'project-checks-summary', 'Show all checks');
9059
+ summary.setAttribute('data-testid', 'checks-summary');
9060
+ details.appendChild(summary);
9061
+
9062
+ const rows = createTextElement('div', 'project-checks-list', '');
9063
+ rows.setAttribute('data-testid', 'checks-list');
9064
+ signals.forEach((signal) => {
9065
+ const state = signal.state || 'unknown';
9066
+ const row = createTextElement('div', `project-check-row project-check-row--${state}`, '');
9067
+ row.setAttribute('data-testid', 'check-row');
9068
+ row.setAttribute('data-signal-id', safeText(signal.id, ''));
9069
+ const icon = createTextElement('span', 'project-check-icon', STATE_ICONS[state] || '–');
9070
+ icon.setAttribute('aria-hidden', 'true');
9071
+ row.appendChild(icon);
9072
+ row.appendChild(createTextElement('span', 'project-check-label', safeText(signal.label, 'Check')));
9073
+ row.appendChild(createTextElement('span', 'project-check-message', safeText(signal.message, '')));
9074
+ rows.appendChild(row);
9075
+ });
9076
+ details.appendChild(rows);
9077
+ return details;
9078
+ }
9079
+
9080
+ /**
9081
+ * Wire the delete button to the existing type-to-confirm modal flow.
9082
+ * @param {HTMLElement} button
9083
+ * @param {Object} project
9084
+ */
9085
+ function bindDeleteProjectAction(button, project) {
9086
+ button.addEventListener('click', () => {
9087
+ const projectName = project.repo_name;
9088
+ if (projectName && typeof projectName === 'string' && projectName.trim() !== '') {
9089
+ (0,_delete_modal_js__WEBPACK_IMPORTED_MODULE_3__.showDeleteModal)(projectName, async () => {
9090
+ const result = await (0,_delete_modal_js__WEBPACK_IMPORTED_MODULE_3__.deleteProjectById)(project.id);
9091
+ if (result) {
9092
+ const card = document.querySelector(`.project-card[data-project-id="${project.id}"]`);
9093
+ if (card) {
9094
+ card.remove();
9095
+ }
9096
+ refreshDashboardAfterDelete();
9097
+ } else {
9098
+ (0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Error', 'Failed to delete project.');
9099
+ }
9100
+ }, () => {
9101
+ // Cancelled — no-op.
9102
+ });
9103
+ } else {
9104
+ (0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Error', 'Could not delete your project. Try deleting from the database directly.');
9105
+ }
9106
+ });
9107
+ }
9108
+
9109
+ /**
9110
+ * After a card is removed, refresh counters from the remaining cards and fall back
9111
+ * to the empty state when the last project is deleted.
9112
+ */
9113
+ function refreshDashboardAfterDelete() {
9114
+ const projectsList = document.getElementById('projects-list');
9115
+ if (!projectsList) {
9116
+ return;
9117
+ }
9118
+ const remaining = projectsList.querySelectorAll('.project-card');
9119
+ const countEl = document.getElementById('projects-count');
9120
+ const attentionEl = document.getElementById('projects-attention-count');
9121
+ if (countEl) {
9122
+ const noun = remaining.length === 1 ? 'project' : 'projects';
9123
+ countEl.textContent = `${remaining.length} ${noun}`;
9124
+ }
9125
+ if (attentionEl) {
9126
+ // Recompute the header attention count from the REMAINING cards rather than
9127
+ // blindly clearing it — a card needs attention when its root carries the
9128
+ // warning/critical state class (same basis as updateProjectsDashboardSummary).
9129
+ const attentionProjects = projectsList.querySelectorAll(
9130
+ '.project-card--warning, .project-card--critical').length;
9131
+ if (attentionProjects > 0) {
9132
+ attentionEl.textContent = ` · ${attentionProjects} need attention`;
9133
+ attentionEl.classList.remove('hidden');
9134
+ } else {
9135
+ attentionEl.textContent = '';
9136
+ attentionEl.classList.add('hidden');
9137
+ }
9138
+ }
9139
+ if (remaining.length === 0) {
9140
+ projectsList.textContent = '';
9141
+ projectsList.appendChild(buildEmptyDashboardState());
9142
+ }
9143
+ }
9144
+
9145
+ /**
9146
+ * Build the bottom launcher row plus the keyboard-operable ⋯ overflow.
9147
+ * @param {Object} project
9148
+ * @returns {HTMLElement}
9149
+ */
9150
+ function buildLauncherRow(project) {
9151
+ const footer = createTextElement('div', 'project-actions-footer', '');
9152
+
9153
+ const launcher = createTextElement('div', 'project-launcher', '');
9154
+ LAUNCHER_ACTIONS.forEach((action) => {
9155
+ const link = createTextElement('a', 'project-launcher-link', action.label);
9156
+ link.setAttribute('href', action.href(project.id));
9157
+ link.setAttribute('data-testid', action.testid);
9158
+ launcher.appendChild(link);
9159
+ });
9160
+ footer.appendChild(launcher);
9161
+
9162
+ const overflow = createTextElement('details', 'project-overflow', '');
9163
+ overflow.setAttribute('data-testid', 'project-overflow');
9164
+ const overflowSummary = createTextElement('summary', 'project-overflow-summary', '⋯');
9165
+ overflowSummary.setAttribute('aria-label', 'More actions');
9166
+ overflowSummary.setAttribute('data-testid', 'project-overflow-summary');
9167
+ overflow.appendChild(overflowSummary);
9168
+
9169
+ const menu = createTextElement('div', 'project-overflow-menu', '');
9170
+ OVERFLOW_ACTIONS.forEach((action) => {
9171
+ const link = createTextElement('a', 'project-overflow-action', action.label);
9172
+ link.setAttribute('href', action.href(project.id));
9173
+ link.setAttribute('data-testid', action.testid);
9174
+ menu.appendChild(link);
9175
+ });
9176
+ const deleteButton = createTextElement('button', 'project-overflow-action project-overflow-action--delete', 'Delete');
9177
+ deleteButton.setAttribute('type', 'button');
9178
+ deleteButton.setAttribute('data-testid', 'project-action-delete');
9179
+ bindDeleteProjectAction(deleteButton, project);
9180
+ menu.appendChild(deleteButton);
9181
+
9182
+ overflow.appendChild(menu);
9183
+ footer.appendChild(overflow);
9184
+ return footer;
9185
+ }
9186
+
9187
+ /**
9188
+ * Build a single attention-first project card as DOM nodes.
9189
+ * @param {Object} project
9190
+ * @returns {HTMLElement}
9191
+ */
9192
+ function buildProjectCard(project) {
9193
+ const status = getProjectStatus(project);
9194
+ const overallState = (status.summary && status.summary.overall_state) || 'unknown';
9195
+ const attentionSignals = getAttentionSignals(status);
9196
+
9197
+ const card = createTextElement('div', `project-card project-card--${overallState}`, '');
9198
+ card.setAttribute('data-testid', 'project-card');
9199
+ card.setAttribute('data-project-id', project.id);
9200
+ card.setAttribute('data-project-name', safeText(project.repo_name, ''));
9201
+
9202
+ card.appendChild(buildProjectSummaryRow(project, status));
9203
+ card.appendChild(buildProjectMetaLine(status));
9204
+ card.appendChild(buildProjectDescription(project));
9205
+
9206
+ const panels = buildAttentionPanels(attentionSignals);
9207
+ if (panels) {
9208
+ card.appendChild(panels);
9209
+ }
9210
+
9211
+ const checks = buildChecksDetails(status.signals);
9212
+ if (checks) {
9213
+ card.appendChild(checks);
9214
+ }
9215
+
9216
+ card.appendChild(buildLauncherRow(project));
9217
+ return card;
9218
+ }
9219
+
9220
+ /**
9221
+ * Build the calm empty-dashboard state as DOM nodes.
9222
+ * @returns {HTMLElement}
9223
+ */
9224
+ function buildEmptyDashboardState() {
9225
+ const wrap = createTextElement('div', 'setup-empty-state', '');
9226
+ wrap.setAttribute('data-testid', 'setup-empty-state');
9227
+ wrap.appendChild(createTextElement('p', 'setup-empty-title', 'No projects yet'));
9228
+ wrap.appendChild(createTextElement('p', 'setup-empty-message', 'Create your first project to start monitoring its health here.'));
9229
+ return wrap;
9230
+ }
9231
+
9232
+ /**
9233
+ * Build the inline error state for a failed project-list fetch.
9234
+ * @param {string} message
9235
+ * @returns {HTMLElement}
9236
+ */
9237
+ function buildDashboardErrorState(message) {
9238
+ const wrap = createTextElement('div', 'setup-error-state', '');
9239
+ wrap.setAttribute('data-testid', 'setup-error-state');
9240
+ wrap.appendChild(createTextElement('p', 'setup-error-title', 'Could not load your projects'));
9241
+ wrap.appendChild(createTextElement('p', 'setup-error-message', safeText(message, 'Please try again.')));
9242
+ return wrap;
9243
+ }
9244
+
6809
9245
  /**
6810
- * Loads the list of projects
9246
+ * Loads the list of projects and renders the dashboard.
6811
9247
  */
6812
9248
  async function loadProjects() {
6813
9249
  (0,_utils_js__WEBPACK_IMPORTED_MODULE_2__.showLoading)();
@@ -6820,135 +9256,38 @@ async function loadProjects() {
6820
9256
  }
6821
9257
  } catch (error) {
6822
9258
  console.error('Error loading projects:', error);
6823
- (0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Error', (0,_utils_js__WEBPACK_IMPORTED_MODULE_2__.getApiErrorMessage)(error, 'Failed to load projects.'));
9259
+ const projectsList = document.getElementById('projects-list');
9260
+ if (projectsList) {
9261
+ projectsList.textContent = '';
9262
+ projectsList.appendChild(buildDashboardErrorState((0,_utils_js__WEBPACK_IMPORTED_MODULE_2__.getApiErrorMessage)(error, 'Failed to load projects.')));
9263
+ }
9264
+ updateProjectsDashboardSummary([]);
6824
9265
  } finally {
6825
9266
  (0,_utils_js__WEBPACK_IMPORTED_MODULE_2__.hideLoading)();
6826
9267
  }
6827
9268
  }
6828
9269
 
6829
9270
  /**
6830
- * Renders the list of projects in the projects grid
9271
+ * Renders the list of projects in the projects grid using safe DOM construction.
6831
9272
  * @param {Array} projects - List of projects
6832
9273
  */
6833
9274
  function renderProjectsList(projects) {
6834
9275
  const projectsList = document.getElementById('projects-list');
6835
- projectsList.innerHTML = ''; // Clear existing projects
6836
-
6837
- if (projects.length === 0) {
6838
- projectsList.innerHTML = '<p>No projects found. Create one!</p>';
9276
+ if (!projectsList) {
6839
9277
  return;
6840
9278
  }
6841
-
6842
- projects.forEach(project => {
6843
- const displayName = project.repo_display_name || project.repo_name;
6844
- const projectCard = document.createElement('div');
6845
- projectCard.className = 'project-card';
6846
- projectCard.setAttribute('data-project-id', project.id);
6847
- projectCard.setAttribute('data-project-name', project.repo_name);
6848
- projectCard.innerHTML = `
6849
- <h3>${displayName}</h3>
6850
- <p>${project.project_description || 'No description provided.'}</p>
6851
- <div class="project-card-actions">
6852
- <div class="project-card-actions-row">
6853
- <button class="btn btn-secondary btn-setup" data-project-id="${project.id}">Setup</button>
6854
- <button class="btn btn-secondary btn-config" data-project-id="${project.id}">Configure</button>
6855
- <button class="btn btn-secondary btn-optimize" data-project-id="${project.id}">Optimize</button>
6856
- <button class="btn btn-secondary btn-health">Data</button>
6857
- <button class="btn btn-secondary btn-monitoring">Monitoring</button>
6858
- <button class="btn btn-secondary btn-security" data-project-id="${project.id}">Security</button>
6859
- <button class="btn btn-secondary btn-connect-editor" data-project-id="${project.id}">Connect editor</button>
6860
- </div>
6861
- <div class="project-card-actions-row project-card-actions-secondary">
6862
- <a href="#" class="delete-link" data-project-id="${project.id}">Delete</a>
6863
- </div>
6864
- </div>
6865
- `;
6866
-
6867
- // Add event listener for the setup button
6868
- const setupButton = projectCard.querySelector('.btn-setup');
6869
- if (setupButton) {
6870
- setupButton.addEventListener('click', () => {
6871
- window.location.href = `/setup/setup-detail?projectId=${project.id}`;
6872
- });
6873
- }
6874
-
6875
- // Add event listener for the config button
6876
- const configButton = projectCard.querySelector('.btn-config');
6877
- if (configButton) {
6878
- configButton.addEventListener('click', () => {
6879
- window.location.href = `/setup/project-detail?projectId=${project.id}`;
6880
- });
6881
- }
6882
-
6883
- // Add event listener for the optimize button
6884
- const optimizeButton = projectCard.querySelector('.btn-optimize');
6885
- if (optimizeButton) {
6886
- optimizeButton.addEventListener('click', () => {
6887
- window.location.href = `/optimize/${project.id}`;
6888
- });
6889
- }
6890
-
6891
- // Add event listener for the data health button
6892
- const healthButton = projectCard.querySelector('.btn-health');
6893
- if (healthButton) {
6894
- healthButton.addEventListener('click', () => {
6895
- window.location.href = `/setup/data/${project.id}`;
6896
- });
6897
- }
6898
-
6899
- // Add event listener for the monitoring button
6900
- const monitoringButton = projectCard.querySelector('.btn-monitoring');
6901
- if (monitoringButton) {
6902
- monitoringButton.addEventListener('click', () => {
6903
- window.location.href = `/setup/monitoring/${project.id}`;
6904
- });
6905
- }
6906
-
6907
- // Add event listener for the security button
6908
- const securityButton = projectCard.querySelector('.btn-security');
6909
- if (securityButton) {
6910
- securityButton.addEventListener('click', () => {
6911
- window.location.href = `/setup/security/${project.id}`;
6912
- });
6913
- }
9279
+ projectsList.textContent = ''; // Clear existing projects safely (no innerHTML).
6914
9280
 
6915
- // Add event listener for the connect-editor button
6916
- const connectEditorButton = projectCard.querySelector('.btn-connect-editor');
6917
- if (connectEditorButton) {
6918
- connectEditorButton.addEventListener('click', () => {
6919
- window.location.href = `/setup/get-started?projectId=${project.id}`;
6920
- });
6921
- }
9281
+ const list = Array.isArray(projects) ? projects : [];
9282
+ updateProjectsDashboardSummary(list);
6922
9283
 
6923
- // Add event listener for the delete link
6924
- const deleteLink = projectCard.querySelector('.delete-link');
6925
- if (deleteLink) {
6926
- deleteLink.addEventListener('click', (e) => {
6927
- e.preventDefault();
6928
- const projectName = project.repo_name;
6929
- if (projectName && typeof projectName === 'string' && projectName.trim() !== '') {
6930
- (0,_delete_modal_js__WEBPACK_IMPORTED_MODULE_3__.showDeleteModal)(projectName, async () => {
6931
- // Call API to delete the project
6932
- const result = await (0,_delete_modal_js__WEBPACK_IMPORTED_MODULE_3__.deleteProjectById)(project.id);
6933
- if (result) {
6934
- // Remove the project card from the DOM
6935
- const card = document.querySelector(`.project-card[data-project-id="${project.id}"]`);
6936
- if (card) {
6937
- card.remove();
6938
- }
6939
- } else {
6940
- (0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Error', 'Failed to delete project.');
6941
- }
6942
- }, () => {
6943
- // Cancelled
6944
- });
6945
- } else {
6946
- (0,_modal_js__WEBPACK_IMPORTED_MODULE_1__.showModal)('Error', 'Could not delete your project. Try deleting from the database directly.');
6947
- }
6948
- });
6949
- }
6950
-
6951
- projectsList.appendChild(projectCard);
9284
+ if (list.length === 0) {
9285
+ projectsList.appendChild(buildEmptyDashboardState());
9286
+ return;
9287
+ }
9288
+
9289
+ list.forEach((project) => {
9290
+ projectsList.appendChild(buildProjectCard(project));
6952
9291
  });
6953
9292
  }
6954
9293
 
@@ -7896,6 +10235,7 @@ function initDrawers(options = {}) {
7896
10235
  /******/ __webpack_require__("./src/js/setup.js");
7897
10236
  /******/ __webpack_require__("./src/js/setup-detail.js");
7898
10237
  /******/ __webpack_require__("./src/js/security.js");
10238
+ /******/ __webpack_require__("./src/js/run-dashboard.js");
7899
10239
  /******/ __webpack_require__("./src/js/project-detail.js");
7900
10240
  /******/ __webpack_require__("./src/js/optimize_analysis.js");
7901
10241
  /******/ __webpack_require__("./src/js/optimize.js");
@@ -7903,6 +10243,7 @@ function initDrawers(options = {}) {
7903
10243
  /******/ __webpack_require__("./src/js/modal.js");
7904
10244
  /******/ __webpack_require__("./src/js/login.js");
7905
10245
  /******/ __webpack_require__("./src/js/learn_repository.js");
10246
+ /******/ __webpack_require__("./src/js/get-started.js");
7906
10247
  /******/ __webpack_require__("./src/js/delete-modal.js");
7907
10248
  /******/ __webpack_require__("./src/js/data_health.js");
7908
10249
  /******/ __webpack_require__("./src/js/auth.js");
@@ -7913,11 +10254,13 @@ function initDrawers(options = {}) {
7913
10254
  /******/ __webpack_require__("./src/js/optimize/context.js");
7914
10255
  /******/ __webpack_require__("./src/css/setup.css");
7915
10256
  /******/ __webpack_require__("./src/css/setup-detail.css");
10257
+ /******/ __webpack_require__("./src/css/run-dashboard.css");
7916
10258
  /******/ __webpack_require__("./src/css/public-pages.css");
7917
10259
  /******/ __webpack_require__("./src/css/project-detail.css");
7918
10260
  /******/ __webpack_require__("./src/css/optimize_analysis.css");
7919
10261
  /******/ __webpack_require__("./src/css/optimize.css");
7920
10262
  /******/ __webpack_require__("./src/css/globals.css");
10263
+ /******/ __webpack_require__("./src/css/get-started.css");
7921
10264
  /******/ var __webpack_exports__ = __webpack_require__("./src/css/data_health.css");
7922
10265
  /******/
7923
10266
  /******/ })()