@veolab/discoverylab 1.1.0 → 1.2.0

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 (64) hide show
  1. package/dist/chunk-2OGZX6C4.js +588 -0
  2. package/dist/chunk-43U6UYV7.js +590 -0
  3. package/dist/chunk-4H2E3K2G.js +7638 -0
  4. package/dist/chunk-4KLG6DDE.js +334 -0
  5. package/dist/chunk-4NNTRJOI.js +7791 -0
  6. package/dist/chunk-5F76VWME.js +6397 -0
  7. package/dist/chunk-5NEFN42O.js +7791 -0
  8. package/dist/chunk-63MEQ6UH.js +7673 -0
  9. package/dist/chunk-C7QUR7XX.js +6397 -0
  10. package/dist/chunk-GGJJUCFK.js +7160 -0
  11. package/dist/chunk-GLHOY3NN.js +7805 -0
  12. package/dist/chunk-GSWHWEYC.js +1346 -0
  13. package/dist/chunk-HDKEQOF5.js +7788 -0
  14. package/dist/chunk-HZGSWVVS.js +7111 -0
  15. package/dist/chunk-I6YD3QFM.js +500 -0
  16. package/dist/chunk-IRKQG33A.js +7054 -0
  17. package/dist/chunk-KV7KDJ43.js +7639 -0
  18. package/dist/chunk-L4SA5F5W.js +6397 -0
  19. package/dist/chunk-MFFPQLU4.js +7102 -0
  20. package/dist/chunk-MJS2YKNR.js +6397 -0
  21. package/dist/chunk-NDBW6ELQ.js +7638 -0
  22. package/dist/chunk-P4S7ZY6G.js +7638 -0
  23. package/dist/chunk-PMTGGZ7R.js +6397 -0
  24. package/dist/chunk-PYUCY3U6.js +1340 -0
  25. package/dist/chunk-RDZDSOAL.js +7750 -0
  26. package/dist/chunk-SLNJEF32.js +91 -0
  27. package/dist/chunk-SR67SRIT.js +1336 -0
  28. package/dist/chunk-TAODYZ52.js +1393 -0
  29. package/dist/chunk-TBG76CYG.js +6395 -0
  30. package/dist/chunk-TJ3H23LL.js +362 -0
  31. package/dist/chunk-XIBF5LBD.js +6395 -0
  32. package/dist/chunk-XUKWS2CE.js +7805 -0
  33. package/dist/cli.js +6 -6
  34. package/dist/db-ADBEBNH6.js +35 -0
  35. package/dist/index.d.ts +170 -1
  36. package/dist/index.html +1168 -106
  37. package/dist/index.js +9 -7
  38. package/dist/playwright-ATDC4NYW.js +38 -0
  39. package/dist/playwright-E6EUFIJG.js +38 -0
  40. package/dist/playwright-R7Y5HREH.js +39 -0
  41. package/dist/server-2VKO76UK.js +14 -0
  42. package/dist/server-3BK2VFU7.js +13 -0
  43. package/dist/server-6IPHVUYT.js +14 -0
  44. package/dist/server-73P7M3QB.js +14 -0
  45. package/dist/server-BPVRW5LJ.js +14 -0
  46. package/dist/server-F3YPX6ET.js +13 -0
  47. package/dist/server-IOOZK4NP.js +14 -0
  48. package/dist/server-J52LMTBT.js +13 -0
  49. package/dist/server-NPZN3FWO.js +14 -0
  50. package/dist/server-O5FIAHSY.js +14 -0
  51. package/dist/server-P27BZXBL.js +14 -0
  52. package/dist/server-S6B5WUBT.js +14 -0
  53. package/dist/server-SRYNSGSP.js +14 -0
  54. package/dist/server-X3TLP6DX.js +14 -0
  55. package/dist/server-ZBPQ33V6.js +14 -0
  56. package/dist/setup-AQX4JQVR.js +17 -0
  57. package/dist/tools-2KPB37GK.js +178 -0
  58. package/dist/tools-3H6IOWXV.js +178 -0
  59. package/dist/tools-BUVCUCRL.js +178 -0
  60. package/dist/tools-HDNODRS6.js +178 -0
  61. package/dist/tools-L6PKKQPY.js +179 -0
  62. package/dist/tools-N5N2IO7V.js +178 -0
  63. package/dist/tools-TLCKABUW.js +178 -0
  64. package/package.json +1 -1
package/dist/index.html CHANGED
@@ -1824,12 +1824,22 @@
1824
1824
  justify-content: center;
1825
1825
  padding-top: 15vh;
1826
1826
  z-index: 1000;
1827
+ overscroll-behavior: contain;
1827
1828
  }
1828
1829
 
1829
1830
  .modal-overlay.active {
1830
1831
  display: flex;
1831
1832
  }
1832
1833
 
1834
+ .modal-overlay.code-modal-overlay {
1835
+ align-items: center;
1836
+ justify-content: center;
1837
+ padding: clamp(10px, 3vh, 24px);
1838
+ padding-top: clamp(10px, 3vh, 24px);
1839
+ background: rgba(4, 6, 10, 0.78);
1840
+ backdrop-filter: blur(2px);
1841
+ }
1842
+
1833
1843
  .modal {
1834
1844
  background: var(--bg-surface);
1835
1845
  border: 1px solid var(--border);
@@ -2014,10 +2024,12 @@
2014
2024
  max-width: 900px;
2015
2025
  width: min(92vw, 900px);
2016
2026
  max-height: 82vh;
2027
+ height: min(86vh, 860px);
2017
2028
  display: flex;
2018
2029
  flex-direction: column;
2019
2030
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.02)), var(--bg-surface);
2020
2031
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
2032
+ overscroll-behavior: contain;
2021
2033
  }
2022
2034
 
2023
2035
  .code-modal .modal-header {
@@ -2053,12 +2065,21 @@
2053
2065
  }
2054
2066
 
2055
2067
  .code-modal-body {
2056
- padding: 14px 18px 0;
2068
+ padding: 12px 16px 0;
2057
2069
  flex: 1;
2058
2070
  overflow: hidden;
2059
2071
  display: flex;
2060
2072
  flex-direction: column;
2061
- gap: 12px;
2073
+ gap: 10px;
2074
+ min-height: 0;
2075
+ }
2076
+
2077
+ .code-modal-panel {
2078
+ display: flex;
2079
+ flex-direction: column;
2080
+ flex: 1;
2081
+ min-height: 0;
2082
+ min-width: 0;
2062
2083
  }
2063
2084
 
2064
2085
  .code-editor {
@@ -2081,17 +2102,20 @@
2081
2102
  .code-editor-container {
2082
2103
  display: flex;
2083
2104
  flex: 1;
2084
- background: var(--bg-primary);
2105
+ background: #070b11;
2085
2106
  border: 1px solid var(--border);
2086
2107
  border-radius: 10px;
2087
2108
  overflow: hidden;
2088
2109
  min-height: 320px;
2110
+ height: 100%;
2111
+ min-width: 0;
2112
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
2089
2113
  }
2090
2114
 
2091
2115
  .code-line-numbers {
2092
- background: var(--bg-tertiary);
2116
+ background: #0b1017;
2093
2117
  color: var(--text-muted);
2094
- padding: 14px 12px 14px 14px;
2118
+ padding: 14px 12px calc(14px + 4.8em) 14px;
2095
2119
  font-family: 'SF Mono', Monaco, Consolas, monospace;
2096
2120
  font-size: 12px;
2097
2121
  line-height: 1.6;
@@ -2099,16 +2123,16 @@
2099
2123
  user-select: none;
2100
2124
  border-right: 1px solid var(--border);
2101
2125
  min-width: 45px;
2102
- overflow: hidden;
2126
+ overflow-y: hidden;
2103
2127
  white-space: pre;
2104
2128
  }
2105
2129
 
2106
2130
  .code-editor-textarea {
2107
2131
  flex: 1;
2108
- background: transparent;
2132
+ background: #070b11;
2109
2133
  color: var(--text-primary);
2110
2134
  border: none;
2111
- padding: 14px 16px;
2135
+ padding: 14px 16px calc(14px + 4.8em) 16px;
2112
2136
  font-family: 'SF Mono', Monaco, Consolas, monospace;
2113
2137
  font-size: 12px;
2114
2138
  line-height: 1.6;
@@ -2116,12 +2140,22 @@
2116
2140
  tab-size: 2;
2117
2141
  outline: none;
2118
2142
  overflow-y: auto;
2143
+ height: 100%;
2144
+ min-height: 0;
2145
+ box-sizing: border-box;
2146
+ scroll-padding-bottom: 4.8em;
2147
+ overscroll-behavior: contain;
2148
+ -webkit-overflow-scrolling: touch;
2119
2149
  }
2120
2150
 
2121
2151
  .code-editor-textarea:focus {
2122
2152
  outline: none;
2123
2153
  }
2124
2154
 
2155
+ .code-editor-textarea::selection {
2156
+ background: rgba(96, 165, 250, 0.28);
2157
+ }
2158
+
2125
2159
  /* YAML Syntax Highlighting (via overlay) */
2126
2160
  .yaml-key { color: #7dd3fc; }
2127
2161
  .yaml-value { color: #fde68a; }
@@ -2140,6 +2174,16 @@
2140
2174
  max-height: 180px;
2141
2175
  overflow-y: auto;
2142
2176
  color: #8b949e;
2177
+ overscroll-behavior: contain;
2178
+ -webkit-overflow-scrolling: touch;
2179
+ }
2180
+
2181
+ .code-vars-table-wrap {
2182
+ overflow: auto;
2183
+ border: 1px solid var(--border);
2184
+ border-radius: 8px;
2185
+ overscroll-behavior: contain;
2186
+ -webkit-overflow-scrolling: touch;
2143
2187
  }
2144
2188
 
2145
2189
  .code-modal-output .output-success { color: #4ade80; }
@@ -2179,6 +2223,7 @@
2179
2223
  @media (max-width: 720px) {
2180
2224
  .code-modal {
2181
2225
  max-height: 88vh;
2226
+ height: min(92vh, 920px);
2182
2227
  }
2183
2228
 
2184
2229
  .code-modal-footer {
@@ -7047,12 +7092,24 @@
7047
7092
  </button>
7048
7093
  </div>
7049
7094
  <div style="font-size: 10px; color: var(--text-muted); margin-top: 4px;">
7050
- <a href="https://ollama.ai" target="_blank" style="color: var(--accent);">Install Ollama</a> - Only used when provider is set to <strong>Ollama</strong>. Install any model (example: <code style="background: var(--bg-tertiary); padding: 1px 4px; border-radius: 3px;">ollama pull llama3.2</code>).
7095
+ <a href="https://ollama.ai" target="_blank" style="color: var(--accent);">Install Ollama</a> - Used for local text/coding and optional visual fallback. Recommended starting points:
7096
+ <a href="https://ollama.com/library/qwen2.5-coder" target="_blank" style="color: var(--accent);">qwen2.5-coder</a> (scripts/text) and
7097
+ <a href="https://ollama.com/library/qwen2.5vl" target="_blank" style="color: var(--accent);">qwen2.5vl</a> or
7098
+ <a href="https://ollama.com/library/gemma3" target="_blank" style="color: var(--accent);">gemma3</a> (for example <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">gemma3:4b</code>) for screenshots/vision. You must download and test on your machine.
7099
+ </div>
7100
+ </div>
7101
+ <div class="setting-item">
7102
+ <label class="setting-label" style="display: flex; align-items: center; justify-content: space-between; gap: 12px;">
7103
+ <span>Prefer Ollama Vision for screenshot action detection</span>
7104
+ <input type="checkbox" id="preferOllamaVisionForActionDetection" style="cursor: pointer;">
7105
+ </label>
7106
+ <div style="font-size: 10px; color: var(--text-muted); margin-top: 4px;">
7107
+ When enabled, visual action detection (screenshots → Maestro YAML) tries your Ollama vision model before Claude CLI Vision.
7051
7108
  </div>
7052
7109
  </div>
7053
7110
  <div class="setting-item" id="ollamaModelContainer">
7054
7111
  <label class="setting-label" style="display: flex; align-items: center; justify-content: space-between;">
7055
- <span>Ollama Model</span>
7112
+ <span>Ollama Models (Text + Vision)</span>
7056
7113
  <span id="ollamaStatusBadge" style="font-size: 10px; padding: 2px 6px; border-radius: 4px; background: var(--bg-tertiary); color: var(--text-muted);">Checking...</span>
7057
7114
  </label>
7058
7115
  <div id="ollamaModelSelector">
@@ -8074,7 +8131,8 @@
8074
8131
  }
8075
8132
 
8076
8133
  // LLM Settings
8077
- let currentOllamaModel = 'llama3.2';
8134
+ let currentOllamaTextModel = 'qwen2.5-coder:7b';
8135
+ let currentOllamaVisionModel = 'qwen2.5vl:7b';
8078
8136
  const LLM_AUTO_PROVIDER_PRIORITY = ['anthropic', 'openai', 'ollama', 'claude-cli'];
8079
8137
 
8080
8138
  function normalizeOllamaModelName(value) {
@@ -8089,6 +8147,12 @@
8089
8147
  return installed.split(':')[0] === selected.split(':')[0];
8090
8148
  }
8091
8149
 
8150
+ function isLikelyVisionCapableOllamaModelName(modelName) {
8151
+ const normalized = normalizeOllamaModelName(modelName);
8152
+ if (!normalized) return false;
8153
+ return /(vision|vl\b|llava|bakllava|moondream|gemma3|minicpm-v|qwen2\.5vl|qwen2-vl)/.test(normalized);
8154
+ }
8155
+
8092
8156
  function resolveLLMProviderStatus(data) {
8093
8157
  const providers = Array.isArray(data?.providers) ? data.providers : [];
8094
8158
  const preferredProvider = data?.preferredProvider || 'auto';
@@ -8125,7 +8189,12 @@
8125
8189
  document.getElementById('openaiModel').value = data.openaiModel || 'gpt-5.2';
8126
8190
  document.getElementById('ollamaUrl').value = data.ollamaUrl || 'http://localhost:11434';
8127
8191
  document.getElementById('claudeCliModel').value = data.claudeCliModel || 'haiku';
8128
- currentOllamaModel = data.ollamaModel || 'llama3.2';
8192
+ currentOllamaTextModel = data.ollamaModel || 'qwen2.5-coder:7b';
8193
+ currentOllamaVisionModel = data.ollamaVisionModel || 'qwen2.5vl:7b';
8194
+ const preferOllamaVisionCheckbox = document.getElementById('preferOllamaVisionForActionDetection');
8195
+ if (preferOllamaVisionCheckbox) {
8196
+ preferOllamaVisionCheckbox.checked = data.preferOllamaVisionForActionDetection === true;
8197
+ }
8129
8198
 
8130
8199
  // Update preferred provider dropdown
8131
8200
  await updatePreferredProviderDropdown(data.preferredProvider || 'auto');
@@ -8171,6 +8240,11 @@
8171
8240
  const refreshIcon = document.getElementById('ollamaRefreshIcon');
8172
8241
  const ollamaUrlInput = document.getElementById('ollamaUrl');
8173
8242
  const typedOllamaUrl = (ollamaUrlInput?.value || '').trim();
8243
+ const ollamaTextModelInput = document.getElementById('ollamaTextModel');
8244
+ const ollamaVisionModelInput = document.getElementById('ollamaVisionModel');
8245
+ const preferOllamaVisionForActionDetection = document.getElementById('preferOllamaVisionForActionDetection')?.checked === true;
8246
+ const typedOllamaTextModel = (ollamaTextModelInput?.value || currentOllamaTextModel || 'qwen2.5-coder:7b').trim();
8247
+ const typedOllamaVisionModel = (ollamaVisionModelInput?.value || currentOllamaVisionModel || 'qwen2.5vl:7b').trim();
8174
8248
 
8175
8249
  // Show loading state
8176
8250
  if (refreshIcon) {
@@ -8194,7 +8268,11 @@
8194
8268
  }
8195
8269
 
8196
8270
  try {
8197
- const query = typedOllamaUrl ? `?url=${encodeURIComponent(typedOllamaUrl)}` : '';
8271
+ const params = new URLSearchParams();
8272
+ if (typedOllamaUrl) params.set('url', typedOllamaUrl);
8273
+ if (typedOllamaTextModel) params.set('textModel', typedOllamaTextModel);
8274
+ if (typedOllamaVisionModel) params.set('visionModel', typedOllamaVisionModel);
8275
+ const query = params.toString() ? `?${params.toString()}` : '';
8198
8276
  const response = await fetch(`/api/ollama/status${query}`);
8199
8277
  const data = await response.json();
8200
8278
 
@@ -8203,39 +8281,117 @@
8203
8281
  }
8204
8282
 
8205
8283
  if (data.running && data.models && data.models.length > 0) {
8206
- const selectedModelAvailable = data.selectedModelAvailable !== false;
8207
- const selectedModel = data.currentModel || currentOllamaModel || 'llama3.2';
8284
+ const selectedTextModelAvailable = data.selectedTextModelAvailable !== false;
8285
+ const selectedVisionModelAvailable = data.selectedVisionModelAvailable !== false;
8286
+ const selectedTextModel = data.currentTextModel || currentOllamaTextModel || 'qwen2.5-coder:7b';
8287
+ const selectedVisionModel = data.currentVisionModel || currentOllamaVisionModel || 'qwen2.5vl:7b';
8288
+ const visionModelLooksCapable = data.selectedVisionModelLooksCapable !== false;
8208
8289
 
8209
8290
  // Ollama is running and has models
8210
8291
  if (badge) {
8211
- if (selectedModelAvailable) {
8292
+ if (selectedTextModelAvailable && selectedVisionModelAvailable) {
8212
8293
  badge.textContent = `${data.models.length} model${data.models.length > 1 ? 's' : ''}`;
8213
8294
  badge.style.background = 'rgba(34, 197, 94, 0.15)';
8214
8295
  badge.style.color = 'var(--success)';
8215
8296
  } else {
8216
- badge.textContent = 'Model missing';
8297
+ const missing = [
8298
+ selectedTextModelAvailable ? null : 'Text missing',
8299
+ selectedVisionModelAvailable ? null : 'Vision missing'
8300
+ ].filter(Boolean).join(' • ') || 'Model missing';
8301
+ badge.textContent = missing;
8217
8302
  badge.style.background = 'rgba(245, 158, 11, 0.15)';
8218
8303
  badge.style.color = 'var(--warning)';
8219
8304
  }
8220
8305
  }
8221
8306
  if (container) {
8222
- const modelOptions = data.models.map(m => {
8223
- const sizeMB = Math.round(m.size / 1024 / 1024);
8224
- const sizeStr = sizeMB > 1024 ? `${(sizeMB / 1024).toFixed(1)}GB` : `${sizeMB}MB`;
8225
- const selected = ollamaModelNamesMatch(m.name, selectedModel) ? 'selected' : '';
8226
- return `<option value="${m.name}" ${selected}>${m.name} (${sizeStr})</option>`;
8227
- }).join('');
8307
+ const allModels = Array.isArray(data.models) ? data.models : [];
8308
+ const visionCapableModels = allModels.filter(m => isLikelyVisionCapableOllamaModelName(m.name));
8309
+ const nonVisionModels = allModels.filter(m => !isLikelyVisionCapableOllamaModelName(m.name));
8310
+ const visionOptionsModels = preferOllamaVisionForActionDetection
8311
+ ? [...visionCapableModels, ...nonVisionModels]
8312
+ : allModels;
8313
+
8314
+ const renderModelOptions = (selectedModelName, modelsList, options = {}) => {
8315
+ const {
8316
+ preserveMissing = true,
8317
+ emptyLabel = 'No compatible models installed',
8318
+ markNonVision = false
8319
+ } = options;
8320
+ const safeModels = Array.isArray(modelsList) ? modelsList : [];
8321
+ if (safeModels.length === 0) {
8322
+ const missingOption = (preserveMissing && selectedModelName)
8323
+ ? `<option value="${selectedModelName}" selected>${selectedModelName} (saved, not installed)</option>`
8324
+ : '';
8325
+ return `${missingOption}<option value="" disabled>${emptyLabel}</option>`;
8326
+ }
8327
+ const optionsHtml = safeModels.map(m => {
8328
+ const sizeMB = Math.round(m.size / 1024 / 1024);
8329
+ const sizeStr = sizeMB > 1024 ? `${(sizeMB / 1024).toFixed(1)}GB` : `${sizeMB}MB`;
8330
+ const selected = ollamaModelNamesMatch(m.name, selectedModelName) ? 'selected' : '';
8331
+ const nonVisionSuffix = markNonVision && !isLikelyVisionCapableOllamaModelName(m.name) ? ' • non-vision' : '';
8332
+ return `<option value="${m.name}" ${selected}>${m.name} (${sizeStr})${nonVisionSuffix}</option>`;
8333
+ }).join('');
8334
+ const hasSelected = safeModels.some(m => ollamaModelNamesMatch(m.name, selectedModelName));
8335
+ if (!hasSelected && selectedModelName && preserveMissing) {
8336
+ return `<option value="${selectedModelName}" selected>${selectedModelName} (saved, not installed)</option>${optionsHtml}`;
8337
+ }
8338
+ return optionsHtml;
8339
+ };
8228
8340
 
8229
8341
  container.innerHTML = `
8230
8342
  <div style="display: grid; gap: 8px;">
8231
- <select class="setting-input" id="ollamaModel" style="cursor: pointer;">
8232
- ${modelOptions}
8233
- </select>
8234
- ${selectedModelAvailable ? '' : `
8343
+ <div style="display: grid; gap: 4px;">
8344
+ <label style="font-size: 11px; color: var(--text-secondary);">Text / Coding Model (chat, summary, scripts)</label>
8345
+ <select class="setting-input" id="ollamaTextModel" style="cursor: pointer;">
8346
+ ${renderModelOptions(selectedTextModel, allModels, { emptyLabel: 'No models installed' })}
8347
+ </select>
8348
+ </div>
8349
+ <div style="display: grid; gap: 4px;">
8350
+ <label style="font-size: 11px; color: var(--text-secondary);">Vision Model (screenshots → actions/YAML)</label>
8351
+ <select class="setting-input" id="ollamaVisionModel" style="cursor: pointer;">
8352
+ ${renderModelOptions(selectedVisionModel, visionOptionsModels, {
8353
+ emptyLabel: preferOllamaVisionForActionDetection
8354
+ ? 'No vision-capable models installed'
8355
+ : 'No models installed',
8356
+ markNonVision: preferOllamaVisionForActionDetection
8357
+ })}
8358
+ </select>
8359
+ </div>
8360
+ <div style="display: grid; gap: 4px;">
8361
+ <label style="font-size: 11px; color: var(--text-secondary);">Available Vision Models (detected)</label>
8362
+ <select class="setting-input" id="ollamaAvailableVisionModelQuickPick" style="cursor: pointer;">
8363
+ ${visionCapableModels.length > 0
8364
+ ? renderModelOptions(selectedVisionModel, visionCapableModels, {
8365
+ preserveMissing: false,
8366
+ emptyLabel: 'No vision-capable models installed'
8367
+ })
8368
+ : '<option value=\"\" disabled selected>No vision-capable models installed</option>'}
8369
+ </select>
8370
+ </div>
8371
+ <div style="font-size: 10px; color: var(--text-muted); line-height: 1.4;">
8372
+ Recommended: <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5-coder:7b</code> for scripts/text and
8373
+ <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5vl:7b</code> or <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">gemma3:4b</code> for screenshots/vision.
8374
+ </div>
8375
+ ${selectedTextModelAvailable ? '' : `
8235
8376
  <div style="padding: 8px; background: rgba(245, 158, 11, 0.08); border: 1px solid rgba(245, 158, 11, 0.2); border-radius: 6px; color: var(--warning); font-size: 11px;">
8236
- Selected model "${selectedModel}" is not installed. Choose an available model and save.
8377
+ Text model "${selectedTextModel}" is not installed. Choose an available model and save.
8237
8378
  </div>
8238
8379
  `}
8380
+ ${selectedVisionModelAvailable ? '' : `
8381
+ <div style="padding: 8px; background: rgba(245, 158, 11, 0.08); border: 1px solid rgba(245, 158, 11, 0.2); border-radius: 6px; color: var(--warning); font-size: 11px;">
8382
+ Vision model "${selectedVisionModel}" is not installed. Choose an available model and save.
8383
+ </div>
8384
+ `}
8385
+ ${selectedVisionModelAvailable && !visionModelLooksCapable ? `
8386
+ <div style="padding: 8px; background: rgba(245, 158, 11, 0.08); border: 1px solid rgba(245, 158, 11, 0.2); border-radius: 6px; color: var(--warning); font-size: 11px;">
8387
+ Vision model "${selectedVisionModel}" may not support images well. A multimodal model like <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5vl:7b</code> or <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">gemma3:4b</code> is recommended.
8388
+ </div>
8389
+ ` : ''}
8390
+ ${preferOllamaVisionForActionDetection && visionCapableModels.length === 0 ? `
8391
+ <div style="padding: 8px; background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.18); border-radius: 6px; color: var(--error); font-size: 11px;">
8392
+ "Prefer Ollama Vision" is enabled, but no installed Ollama models look multimodal/vision-capable. Install <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5vl:7b</code> or <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">gemma3:4b</code> (or another vision model).
8393
+ </div>
8394
+ ` : ''}
8239
8395
  </div>
8240
8396
  `;
8241
8397
  }
@@ -8251,7 +8407,9 @@
8251
8407
  <div style="padding: 10px; background: var(--bg-tertiary); border-radius: 6px; font-size: 12px;">
8252
8408
  <div style="color: var(--warning); margin-bottom: 6px;">No models installed</div>
8253
8409
  <div style="color: var(--text-muted);">
8254
- Install any model (example): <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">ollama pull llama3.2</code>
8410
+ Install and test models (examples):
8411
+ <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">ollama pull qwen2.5-coder:7b</code> and
8412
+ <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">ollama pull qwen2.5vl:7b</code> (or <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">ollama pull gemma3:4b</code>)
8255
8413
  </div>
8256
8414
  </div>
8257
8415
  `;
@@ -8297,9 +8455,12 @@
8297
8455
  async function saveLLMSettings(options = {}) {
8298
8456
  const { silent = false } = options;
8299
8457
  try {
8300
- // Get Ollama model from select or use current value
8301
- const ollamaModelSelect = document.getElementById('ollamaModel');
8302
- const ollamaModel = ollamaModelSelect?.value || currentOllamaModel;
8458
+ // Get Ollama models from selects or use current values
8459
+ const ollamaTextModelSelect = document.getElementById('ollamaTextModel');
8460
+ const ollamaVisionModelSelect = document.getElementById('ollamaVisionModel');
8461
+ const ollamaModel = ollamaTextModelSelect?.value || currentOllamaTextModel;
8462
+ const ollamaVisionModel = ollamaVisionModelSelect?.value || currentOllamaVisionModel;
8463
+ const preferOllamaVisionForActionDetection = document.getElementById('preferOllamaVisionForActionDetection')?.checked === true;
8303
8464
 
8304
8465
  const settings = {
8305
8466
  anthropicApiKey: document.getElementById('anthropicApiKey')?.value || '',
@@ -8309,6 +8470,8 @@
8309
8470
  claudeCliModel: document.getElementById('claudeCliModel')?.value || 'haiku',
8310
8471
  ollamaUrl: document.getElementById('ollamaUrl')?.value || 'http://localhost:11434',
8311
8472
  ollamaModel: ollamaModel,
8473
+ ollamaVisionModel: ollamaVisionModel,
8474
+ preferOllamaVisionForActionDetection,
8312
8475
  preferredProvider: document.getElementById('preferredProvider')?.value || 'auto'
8313
8476
  };
8314
8477
 
@@ -8327,7 +8490,8 @@
8327
8490
  }
8328
8491
 
8329
8492
  // Update current model
8330
- currentOllamaModel = ollamaModel;
8493
+ currentOllamaTextModel = ollamaModel;
8494
+ currentOllamaVisionModel = ollamaVisionModel;
8331
8495
 
8332
8496
  await checkOllamaStatus();
8333
8497
  await updatePreferredProviderDropdown(settings.preferredProvider || 'auto');
@@ -8421,13 +8585,22 @@
8421
8585
  document.getElementById('closeSettings').addEventListener('click', closeAllModals);
8422
8586
  document.getElementById('refreshSetup').addEventListener('click', loadSetupStatus);
8423
8587
  document.getElementById('saveSettings')?.addEventListener('click', saveSettingsFromForm);
8424
- ['preferredProvider', 'anthropicModel', 'openaiModel', 'claudeCliModel', 'ollamaUrl', 'anthropicApiKey', 'openaiApiKey'].forEach((id) => {
8588
+ ['preferredProvider', 'anthropicModel', 'openaiModel', 'claudeCliModel', 'ollamaUrl', 'anthropicApiKey', 'openaiApiKey', 'preferOllamaVisionForActionDetection'].forEach((id) => {
8425
8589
  document.getElementById(id)?.addEventListener('change', () => {
8426
8590
  void saveLLMSettings({ silent: true });
8427
8591
  });
8428
8592
  });
8429
8593
  document.getElementById('ollamaModelSelector')?.addEventListener('change', (event) => {
8430
- if (event.target && event.target.id === 'ollamaModel') {
8594
+ if (event.target && event.target.id === 'ollamaAvailableVisionModelQuickPick') {
8595
+ const quickPickValue = event.target.value;
8596
+ const visionSelect = document.getElementById('ollamaVisionModel');
8597
+ if (visionSelect && quickPickValue) {
8598
+ visionSelect.value = quickPickValue;
8599
+ }
8600
+ void saveLLMSettings({ silent: true });
8601
+ return;
8602
+ }
8603
+ if (event.target && (event.target.id === 'ollamaTextModel' || event.target.id === 'ollamaVisionModel')) {
8431
8604
  void saveLLMSettings({ silent: true });
8432
8605
  }
8433
8606
  });
@@ -14733,7 +14906,7 @@
14733
14906
 
14734
14907
  // If we have the session locally, generate code directly
14735
14908
  if (recorderSession.actions && recorderSession.actions.length > 0) {
14736
- showCodeModal(generateSpecCode(recorderSession));
14909
+ showCodeModal(generateSpecCode(recorderSession), 'typescript', recorderSession.id, 'web');
14737
14910
  return;
14738
14911
  }
14739
14912
 
@@ -14752,7 +14925,7 @@
14752
14925
  }
14753
14926
 
14754
14927
  // Show code in a modal
14755
- showCodeModal(data.specCode || generateSpecCode(data.recording));
14928
+ showCodeModal(data.specCode || generateSpecCode(data.recording), 'typescript', recorderSession.id, 'web');
14756
14929
 
14757
14930
  } catch (error) {
14758
14931
  showToast('Failed to load spec code', 'error');
@@ -16294,20 +16467,557 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16294
16467
  }
16295
16468
  }
16296
16469
 
16470
+ let codeModalState = null;
16471
+ let codeModalScrollLock = null;
16472
+ const CODE_MODAL_MOBILE_DEVICE_STORAGE_KEY = 'discoverylab.codeModal.mobileRunDeviceId';
16473
+
16474
+ function lockPageScrollForCodeModal() {
16475
+ if (codeModalScrollLock) return;
16476
+ const body = document.body;
16477
+ const docEl = document.documentElement;
16478
+ const scrollX = window.scrollX || docEl.scrollLeft || 0;
16479
+ const scrollY = window.scrollY || docEl.scrollTop || 0;
16480
+
16481
+ codeModalScrollLock = {
16482
+ scrollX,
16483
+ scrollY,
16484
+ body: {
16485
+ position: body.style.position,
16486
+ top: body.style.top,
16487
+ left: body.style.left,
16488
+ right: body.style.right,
16489
+ width: body.style.width,
16490
+ overflow: body.style.overflow
16491
+ },
16492
+ htmlOverflow: docEl.style.overflow
16493
+ };
16494
+
16495
+ docEl.style.overflow = 'hidden';
16496
+ body.style.overflow = 'hidden';
16497
+ body.style.position = 'fixed';
16498
+ body.style.top = `-${scrollY}px`;
16499
+ body.style.left = `-${scrollX}px`;
16500
+ body.style.right = '0';
16501
+ body.style.width = '100%';
16502
+ }
16503
+
16504
+ function unlockPageScrollForCodeModal() {
16505
+ if (!codeModalScrollLock) return;
16506
+ const body = document.body;
16507
+ const docEl = document.documentElement;
16508
+ const lock = codeModalScrollLock;
16509
+
16510
+ docEl.style.overflow = lock.htmlOverflow || '';
16511
+ body.style.position = lock.body.position || '';
16512
+ body.style.top = lock.body.top || '';
16513
+ body.style.left = lock.body.left || '';
16514
+ body.style.right = lock.body.right || '';
16515
+ body.style.width = lock.body.width || '';
16516
+ body.style.overflow = lock.body.overflow || '';
16517
+
16518
+ window.scrollTo(lock.scrollX || 0, lock.scrollY || 0);
16519
+ codeModalScrollLock = null;
16520
+ }
16521
+
16522
+ function getPreferredCodeModalMobileDeviceId() {
16523
+ if (codeModalState?.mobileRunSelectedDeviceId) return codeModalState.mobileRunSelectedDeviceId;
16524
+ try {
16525
+ const fromStorage = localStorage.getItem(CODE_MODAL_MOBILE_DEVICE_STORAGE_KEY);
16526
+ if (fromStorage) return fromStorage;
16527
+ } catch {}
16528
+ const deviceDropdown = document.getElementById('deviceDropdown');
16529
+ if (deviceDropdown && deviceDropdown.value) return deviceDropdown.value;
16530
+ return '';
16531
+ }
16532
+
16533
+ function formatCodeModalMobileDeviceOptionLabel(device) {
16534
+ const platform = String(device?.platform || '').toUpperCase() || 'MOBILE';
16535
+ const name = String(device?.name || device?.id || 'Unknown device');
16536
+ const status = String(device?.status || '').toLowerCase();
16537
+ return status ? `${platform} · ${name} (${status})` : `${platform} · ${name}`;
16538
+ }
16539
+
16540
+ function renderCodeModalMobileDevicePicker() {
16541
+ const selectEl = document.getElementById('codeModalMobileDeviceSelect');
16542
+ const statusEl = document.getElementById('codeModalMobileDeviceStatus');
16543
+ if (!selectEl || !statusEl || !codeModalState) return;
16544
+
16545
+ const devices = Array.isArray(codeModalState.mobileRunDevices) ? codeModalState.mobileRunDevices : [];
16546
+ const loading = codeModalState.mobileRunDevicesLoading === true;
16547
+ const selectedId = codeModalState.mobileRunSelectedDeviceId || '';
16548
+
16549
+ const options = ['<option value="">Auto-detect device (Maestro default)</option>'];
16550
+ for (const device of devices) {
16551
+ const id = String(device?.id || '').trim();
16552
+ if (!id) continue;
16553
+ const selectedAttr = selectedId === id ? ' selected' : '';
16554
+ options.push(`<option value="${escapeAttr(id)}"${selectedAttr}>${escapeHtml(formatCodeModalMobileDeviceOptionLabel(device))}</option>`);
16555
+ }
16556
+ selectEl.innerHTML = options.join('');
16557
+
16558
+ if (loading) {
16559
+ statusEl.textContent = 'Loading devices...';
16560
+ statusEl.style.color = 'var(--text-muted)';
16561
+ } else if (devices.length === 0) {
16562
+ statusEl.textContent = 'No connected/booted devices found';
16563
+ statusEl.style.color = 'var(--warning)';
16564
+ } else if (selectedId) {
16565
+ const selected = devices.find(d => String(d.id || '') === selectedId);
16566
+ statusEl.textContent = selected ? `Selected: ${selected.name}` : 'Selected device unavailable (using auto)';
16567
+ statusEl.style.color = selected ? 'var(--success)' : 'var(--warning)';
16568
+ } else {
16569
+ statusEl.textContent = 'Using auto-detect unless a device is selected';
16570
+ statusEl.style.color = 'var(--text-muted)';
16571
+ }
16572
+ }
16573
+
16574
+ async function loadCodeModalMobileRunDevices(forceRefresh = false) {
16575
+ if (!codeModalState || codeModalState.type !== 'mobile') return;
16576
+ codeModalState.mobileRunDevicesLoading = true;
16577
+ renderCodeModalMobileDevicePicker();
16578
+ try {
16579
+ const url = forceRefresh
16580
+ ? '/api/testing/mobile/maestro-devices?refresh=1'
16581
+ : '/api/testing/mobile/maestro-devices';
16582
+ const response = await fetch(url);
16583
+ const data = await response.json();
16584
+ if (!response.ok) throw new Error(data.error || 'Failed to load devices');
16585
+ const devices = Array.isArray(data.devices) ? data.devices : [];
16586
+ codeModalState.mobileRunDevices = devices;
16587
+
16588
+ const preferred = codeModalState.mobileRunSelectedDeviceId || getPreferredCodeModalMobileDeviceId();
16589
+ if (preferred && devices.some(d => String(d.id || '') === preferred)) {
16590
+ codeModalState.mobileRunSelectedDeviceId = preferred;
16591
+ } else {
16592
+ codeModalState.mobileRunSelectedDeviceId = '';
16593
+ }
16594
+ renderCodeModalMobileDevicePicker();
16595
+ } catch (error) {
16596
+ console.error('Failed to load Maestro devices for code modal:', error);
16597
+ codeModalState.mobileRunDevices = [];
16598
+ codeModalState.mobileRunSelectedDeviceId = '';
16599
+ renderCodeModalMobileDevicePicker();
16600
+ const statusEl = document.getElementById('codeModalMobileDeviceStatus');
16601
+ if (statusEl) {
16602
+ statusEl.textContent = `Failed to load devices: ${error.message || 'Unknown error'}`;
16603
+ statusEl.style.color = 'var(--error)';
16604
+ }
16605
+ } finally {
16606
+ if (codeModalState) codeModalState.mobileRunDevicesLoading = false;
16607
+ renderCodeModalMobileDevicePicker();
16608
+ }
16609
+ }
16610
+
16611
+ function onCodeModalMobileDeviceChanged(value) {
16612
+ if (!codeModalState) return;
16613
+ const normalized = String(value || '').trim();
16614
+ codeModalState.mobileRunSelectedDeviceId = normalized;
16615
+ try {
16616
+ if (normalized) localStorage.setItem(CODE_MODAL_MOBILE_DEVICE_STORAGE_KEY, normalized);
16617
+ else localStorage.removeItem(CODE_MODAL_MOBILE_DEVICE_STORAGE_KEY);
16618
+ } catch {}
16619
+ renderCodeModalMobileDevicePicker();
16620
+ }
16621
+
16622
+ function findScrollableAncestorInModal(target, modalRoot) {
16623
+ let el = target instanceof Element ? target : null;
16624
+ while (el && el !== modalRoot) {
16625
+ const style = window.getComputedStyle(el);
16626
+ const overflowY = style.overflowY || style.overflow;
16627
+ const overflowX = style.overflowX || style.overflow;
16628
+ const canScrollY = /(auto|scroll)/.test(overflowY) && el.scrollHeight > (el.clientHeight + 1);
16629
+ const canScrollX = /(auto|scroll)/.test(overflowX) && el.scrollWidth > (el.clientWidth + 1);
16630
+ if (canScrollY || canScrollX) return el;
16631
+ el = el.parentElement;
16632
+ }
16633
+ return null;
16634
+ }
16635
+
16636
+ function attachCodeModalScrollTrap(modalEl) {
16637
+ if (!modalEl) return;
16638
+ const wheelHandler = (e) => {
16639
+ if (!modalEl.contains(e.target)) return;
16640
+ const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
16641
+ const firstElementInPath = path.find((node) => node instanceof Element);
16642
+ const targetEl = (e.target instanceof Element ? e.target : null) || (firstElementInPath instanceof Element ? firstElementInPath : null);
16643
+
16644
+ // Only intercept wheel when cursor is on the line-number gutter.
16645
+ // Everything else should use native scrolling in Chrome (textarea/vars/output).
16646
+ if (targetEl?.closest('#codeLineNumbers')) {
16647
+ const textarea = modalEl.querySelector('#codeEditorTextarea');
16648
+ if (textarea) {
16649
+ textarea.scrollBy({
16650
+ top: e.deltaY || 0,
16651
+ left: e.deltaX || 0,
16652
+ behavior: 'auto'
16653
+ });
16654
+ }
16655
+ e.preventDefault();
16656
+ e.stopPropagation();
16657
+ return;
16658
+ }
16659
+ };
16660
+
16661
+ modalEl.addEventListener('wheel', wheelHandler, { passive: false, capture: true });
16662
+ modalEl._wheelTrapHandler = wheelHandler;
16663
+ }
16664
+
16665
+ function getCodeModalOwnerType(type) {
16666
+ return type === 'mobile' ? 'mobile-recording' : 'web-recording';
16667
+ }
16668
+
16669
+ function extractCodeModalPlaceholders(code) {
16670
+ const matches = new Set();
16671
+ const regex = /\$\{([A-Z][A-Z0-9_]*)\}/g;
16672
+ let match;
16673
+ const text = String(code || '');
16674
+ while ((match = regex.exec(text)) !== null) {
16675
+ if (match[1]) matches.add(match[1]);
16676
+ }
16677
+ return Array.from(matches).sort();
16678
+ }
16679
+
16680
+ function escapeAttr(value) {
16681
+ return String(value ?? '')
16682
+ .replace(/&/g, '&amp;')
16683
+ .replace(/"/g, '&quot;')
16684
+ .replace(/</g, '&lt;')
16685
+ .replace(/>/g, '&gt;');
16686
+ }
16687
+
16688
+ function parseEnvTestTextClient(rawText) {
16689
+ const rows = [];
16690
+ const lines = String(rawText || '').split(/\r?\n/);
16691
+ let pendingNotes = [];
16692
+ let pendingPlatform = 'both';
16693
+ for (const line of lines) {
16694
+ const trimmed = line.trim();
16695
+ if (!trimmed) {
16696
+ pendingNotes = [];
16697
+ pendingPlatform = 'both';
16698
+ continue;
16699
+ }
16700
+ if (trimmed.startsWith('#')) {
16701
+ const comment = trimmed.slice(1).trim();
16702
+ const m = comment.match(/^platform:\s*(mobile|web|both)$/i);
16703
+ if (m) {
16704
+ pendingPlatform = m[1].toLowerCase();
16705
+ } else if (comment) {
16706
+ pendingNotes.push(comment);
16707
+ }
16708
+ continue;
16709
+ }
16710
+ const eqIndex = line.indexOf('=');
16711
+ if (eqIndex <= 0) continue;
16712
+ const key = line.slice(0, eqIndex).trim().toUpperCase();
16713
+ if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(key)) continue;
16714
+ rows.push({
16715
+ id: 'var_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
16716
+ key,
16717
+ value: line.slice(eqIndex + 1).replace(/\\n/g, '\n'),
16718
+ isSecret: /PASSWORD|SECRET|TOKEN|KEY|OTP|PIN/.test(key),
16719
+ platform: ['mobile', 'web', 'both'].includes(pendingPlatform) ? pendingPlatform : 'both',
16720
+ notes: pendingNotes.join(' ') || ''
16721
+ });
16722
+ pendingNotes = [];
16723
+ pendingPlatform = 'both';
16724
+ }
16725
+ return rows;
16726
+ }
16727
+
16728
+ function renderEnvTestTextClient(rows) {
16729
+ const lines = [];
16730
+ for (const row of (rows || [])) {
16731
+ const key = String(row.key || '').trim().toUpperCase();
16732
+ if (!key) continue;
16733
+ if (row.notes) lines.push(`# ${String(row.notes).trim()}`);
16734
+ if (row.platform && row.platform !== 'both') lines.push(`# platform: ${row.platform}`);
16735
+ lines.push(`${key}=${String(row.value || '').replace(/\n/g, '\\n')}`);
16736
+ lines.push('');
16737
+ }
16738
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
16739
+ }
16740
+
16741
+ function collectCodeModalVariablesFromTable() {
16742
+ const rows = Array.from(document.querySelectorAll('#codeModalVarsTableBody tr[data-var-row]'));
16743
+ return rows.map((tr) => ({
16744
+ id: tr.dataset.id || '',
16745
+ key: (tr.querySelector('[data-field="key"]')?.value || '').trim().toUpperCase(),
16746
+ value: tr.querySelector('[data-field="value"]')?.value || '',
16747
+ isSecret: tr.querySelector('[data-field="isSecret"]')?.checked === true,
16748
+ platform: tr.querySelector('[data-field="platform"]')?.value || 'both',
16749
+ notes: (tr.querySelector('[data-field="notes"]')?.value || '').trim(),
16750
+ })).filter((row) => row.key);
16751
+ }
16752
+
16753
+ function getCodeModalVariablesSignature(rows) {
16754
+ return JSON.stringify((rows || []).map((row) => ({
16755
+ key: String(row.key || '').trim().toUpperCase(),
16756
+ value: String(row.value || ''),
16757
+ isSecret: row.isSecret === true,
16758
+ platform: ['mobile', 'web', 'both'].includes(row.platform) ? row.platform : 'both',
16759
+ notes: String(row.notes || '').trim(),
16760
+ })));
16761
+ }
16762
+
16763
+ function syncCodeModalEnvFromTable() {
16764
+ const envTextarea = document.getElementById('codeModalEnvTextarea');
16765
+ if (!envTextarea) return;
16766
+ const rows = collectCodeModalVariablesFromTable();
16767
+ envTextarea.value = renderEnvTestTextClient(rows);
16768
+ if (codeModalState) codeModalState.variables = rows;
16769
+ updateCodeModalVariablesStatus();
16770
+ }
16771
+
16772
+ function updateCodeModalVariablesStatus() {
16773
+ if (!codeModalState) return;
16774
+ const codeText = document.getElementById('codeEditorTextarea')?.value || codeModalState.code || '';
16775
+ const placeholders = extractCodeModalPlaceholders(codeText);
16776
+ codeModalState.placeholders = placeholders;
16777
+ const rows = collectCodeModalVariablesFromTable();
16778
+ const activePlatform = codeModalState.type === 'mobile' ? 'mobile' : 'web';
16779
+ const scoped = rows.filter((row) => (row.platform || 'both') === 'both' || row.platform === activePlatform);
16780
+ const rowKeys = new Set(scoped.map((row) => String(row.key || '').trim().toUpperCase()).filter(Boolean));
16781
+ const missing = placeholders.filter((key) => !rowKeys.has(key));
16782
+ const unused = scoped.map((row) => row.key).filter((key) => key && !placeholders.includes(key));
16783
+ codeModalState.missingPlaceholders = missing;
16784
+
16785
+ const summaryEl = document.getElementById('codeModalVarSummary');
16786
+ if (summaryEl) {
16787
+ summaryEl.innerHTML = `
16788
+ <span>${placeholders.length} placeholder${placeholders.length === 1 ? '' : 's'} in script</span>
16789
+ <span style="color:${missing.length ? 'var(--warning)' : 'var(--success)'};">${missing.length} missing</span>
16790
+ <span>${unused.length} unused</span>
16791
+ `;
16792
+ }
16793
+
16794
+ const badgeEl = document.getElementById('codeModalVarsTabBadge');
16795
+ if (badgeEl) {
16796
+ badgeEl.textContent = missing.length > 0 ? `${missing.length} missing` : 'Ready';
16797
+ badgeEl.style.color = missing.length > 0 ? 'var(--warning)' : 'var(--success)';
16798
+ }
16799
+
16800
+ const missingEl = document.getElementById('codeModalMissingList');
16801
+ if (missingEl) {
16802
+ missingEl.textContent = missing.length > 0 ? `Missing: ${missing.join(', ')}` : 'All placeholders have values for this platform.';
16803
+ missingEl.style.color = missing.length > 0 ? 'var(--warning)' : 'var(--text-muted)';
16804
+ }
16805
+ }
16806
+
16807
+ function renderCodeModalVariablesTable() {
16808
+ if (!codeModalState) return;
16809
+ const body = document.getElementById('codeModalVarsTableBody');
16810
+ if (!body) return;
16811
+ const rows = Array.isArray(codeModalState.variables) ? codeModalState.variables : [];
16812
+ body.innerHTML = rows.length > 0 ? rows.map((row, index) => `
16813
+ <tr data-var-row data-id="${escapeAttr(row.id || '')}">
16814
+ <td><input data-field="key" class="setting-input" style="min-width: 120px;" value="${escapeAttr(row.key || '')}" placeholder="PASSWORD"></td>
16815
+ <td><input data-field="value" type="${row.isSecret ? 'password' : 'text'}" class="setting-input" style="min-width: 180px;" value="${escapeAttr(row.value || '')}" placeholder="value"></td>
16816
+ <td>
16817
+ <select data-field="platform" class="setting-input" style="min-width: 90px;">
16818
+ <option value="both" ${row.platform === 'both' ? 'selected' : ''}>both</option>
16819
+ <option value="mobile" ${row.platform === 'mobile' ? 'selected' : ''}>mobile</option>
16820
+ <option value="web" ${row.platform === 'web' ? 'selected' : ''}>web</option>
16821
+ </select>
16822
+ </td>
16823
+ <td style="text-align: center;">
16824
+ <input data-field="isSecret" type="checkbox" ${row.isSecret ? 'checked' : ''} onchange="toggleCodeModalVarSecret(${index}, this.checked)">
16825
+ </td>
16826
+ <td><input data-field="notes" class="setting-input" style="min-width: 160px;" value="${escapeAttr(row.notes || '')}" placeholder="optional note"></td>
16827
+ <td style="text-align: right;">
16828
+ <button class="btn btn-secondary" style="padding: 6px 8px;" onclick="removeCodeModalVariableRow(${index})">Remove</button>
16829
+ </td>
16830
+ </tr>
16831
+ `).join('') : `<tr><td colspan="6" style="color: var(--text-muted); padding: 12px; text-align: center;">No variables saved yet</td></tr>`;
16832
+
16833
+ body.querySelectorAll('input, select').forEach((el) => {
16834
+ el.addEventListener('input', () => syncCodeModalEnvFromTable());
16835
+ el.addEventListener('change', () => syncCodeModalEnvFromTable());
16836
+ });
16837
+ updateCodeModalVariablesStatus();
16838
+ }
16839
+
16840
+ function addCodeModalVariableRow(defaults = {}) {
16841
+ if (!codeModalState) return;
16842
+ codeModalState.variables = Array.isArray(codeModalState.variables) ? codeModalState.variables : [];
16843
+ codeModalState.variables.push({
16844
+ id: defaults.id || ('var_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7)),
16845
+ key: (defaults.key || '').toUpperCase(),
16846
+ value: defaults.value || '',
16847
+ isSecret: defaults.isSecret !== false,
16848
+ platform: defaults.platform || 'both',
16849
+ notes: defaults.notes || ''
16850
+ });
16851
+ renderCodeModalVariablesTable();
16852
+ syncCodeModalEnvFromTable();
16853
+ }
16854
+
16855
+ function removeCodeModalVariableRow(index) {
16856
+ if (!codeModalState || !Array.isArray(codeModalState.variables)) return;
16857
+ codeModalState.variables.splice(index, 1);
16858
+ renderCodeModalVariablesTable();
16859
+ syncCodeModalEnvFromTable();
16860
+ }
16861
+
16862
+ function toggleCodeModalVarSecret(index, checked) {
16863
+ if (!codeModalState || !Array.isArray(codeModalState.variables) || !codeModalState.variables[index]) return;
16864
+ codeModalState.variables[index].isSecret = !!checked;
16865
+ renderCodeModalVariablesTable();
16866
+ }
16867
+
16868
+ function addMissingCodeModalVariablesFromScript() {
16869
+ if (!codeModalState) return;
16870
+ const codeText = document.getElementById('codeEditorTextarea')?.value || '';
16871
+ const placeholders = extractCodeModalPlaceholders(codeText);
16872
+ const existing = new Set((codeModalState.variables || []).map((row) => String(row.key || '').toUpperCase()));
16873
+ let added = 0;
16874
+ placeholders.forEach((key) => {
16875
+ if (existing.has(key)) return;
16876
+ addCodeModalVariableRow({
16877
+ key,
16878
+ value: '',
16879
+ isSecret: /PASSWORD|SECRET|TOKEN|KEY|OTP|PIN/.test(key),
16880
+ platform: 'both',
16881
+ notes: ''
16882
+ });
16883
+ existing.add(key);
16884
+ added += 1;
16885
+ });
16886
+ if (added === 0) {
16887
+ showToast('No new placeholders to add', 'info');
16888
+ } else {
16889
+ showToast(`${added} variable${added === 1 ? '' : 's'} added from script`, 'success');
16890
+ }
16891
+ }
16892
+
16893
+ function applyEnvTestToCodeModalTable() {
16894
+ const envTextarea = document.getElementById('codeModalEnvTextarea');
16895
+ if (!envTextarea || !codeModalState) return;
16896
+ const parsed = parseEnvTestTextClient(envTextarea.value);
16897
+ const previousByKey = new Map((codeModalState.variables || []).map((row) => [String(row.key || '').toUpperCase(), row]));
16898
+ codeModalState.variables = parsed.map((row) => ({
16899
+ ...row,
16900
+ isSecret: previousByKey.get(row.key)?.isSecret ?? row.isSecret,
16901
+ }));
16902
+ renderCodeModalVariablesTable();
16903
+ syncCodeModalEnvFromTable();
16904
+ showToast('Applied .env.test to variables table', 'success');
16905
+ }
16906
+
16907
+ async function loadCodeModalVariables() {
16908
+ if (!codeModalState?.recordingId || !codeModalState?.ownerType) return;
16909
+ try {
16910
+ const response = await fetch(`/api/test-variables/${codeModalState.ownerType}/${codeModalState.recordingId}`);
16911
+ const data = await response.json();
16912
+ if (!response.ok) throw new Error(data.error || 'Failed to load variables');
16913
+ codeModalState.variables = Array.isArray(data.variables) ? data.variables : [];
16914
+ codeModalState.variablesLoaded = true;
16915
+ codeModalState.lastSavedVariablesSignature = getCodeModalVariablesSignature(codeModalState.variables);
16916
+ renderCodeModalVariablesTable();
16917
+ const envTextarea = document.getElementById('codeModalEnvTextarea');
16918
+ if (envTextarea) {
16919
+ envTextarea.value = typeof data.envTest === 'string' ? data.envTest : renderEnvTestTextClient(codeModalState.variables);
16920
+ }
16921
+ updateCodeModalVariablesStatus();
16922
+ return true;
16923
+ } catch (error) {
16924
+ console.error('Failed to load code modal variables:', error);
16925
+ const missingEl = document.getElementById('codeModalMissingList');
16926
+ if (missingEl) {
16927
+ missingEl.textContent = `Failed to load variables: ${error.message || 'Unknown error'}`;
16928
+ missingEl.style.color = 'var(--error)';
16929
+ }
16930
+ return false;
16931
+ }
16932
+ }
16933
+
16934
+ async function saveCodeModalVariables(options = {}) {
16935
+ if (!codeModalState?.recordingId || !codeModalState?.ownerType) return false;
16936
+ const { silent = false } = options;
16937
+ try {
16938
+ if (!codeModalState.variablesLoaded) {
16939
+ const loaded = await loadCodeModalVariables();
16940
+ if (!loaded) {
16941
+ throw new Error('Unable to load existing variables');
16942
+ }
16943
+ }
16944
+ const rows = collectCodeModalVariablesFromTable().map((row) => ({
16945
+ ...row,
16946
+ key: String(row.key || '').trim().toUpperCase(),
16947
+ platform: ['mobile', 'web', 'both'].includes(row.platform) ? row.platform : 'both'
16948
+ })).filter((row) => row.key);
16949
+ const response = await fetch(`/api/test-variables/${codeModalState.ownerType}/${codeModalState.recordingId}`, {
16950
+ method: 'PUT',
16951
+ headers: { 'Content-Type': 'application/json' },
16952
+ body: JSON.stringify({ variables: rows })
16953
+ });
16954
+ const data = await response.json();
16955
+ if (!response.ok) throw new Error(data.error || 'Failed to save variables');
16956
+ codeModalState.variables = Array.isArray(data.variables) ? data.variables : rows;
16957
+ codeModalState.lastSavedVariablesSignature = getCodeModalVariablesSignature(codeModalState.variables);
16958
+ renderCodeModalVariablesTable();
16959
+ const envTextarea = document.getElementById('codeModalEnvTextarea');
16960
+ if (envTextarea) envTextarea.value = typeof data.envTest === 'string' ? data.envTest : renderEnvTestTextClient(codeModalState.variables);
16961
+ if (!silent) showToast('Test variables saved', 'success');
16962
+ return true;
16963
+ } catch (error) {
16964
+ if (!silent) showToast(`Failed to save variables: ${error.message || 'Unknown error'}`, 'error');
16965
+ return false;
16966
+ }
16967
+ }
16968
+
16969
+ function switchCodeModalTab(tab) {
16970
+ const codePanel = document.getElementById('codeModalCodePanel');
16971
+ const varsPanel = document.getElementById('codeModalVarsPanel');
16972
+ const codeBtn = document.getElementById('codeModalTabCode');
16973
+ const varsBtn = document.getElementById('codeModalTabVars');
16974
+ if (codePanel) codePanel.style.display = tab === 'code' ? 'flex' : 'none';
16975
+ if (varsPanel) varsPanel.style.display = tab === 'vars' ? 'flex' : 'none';
16976
+ if (codeBtn) codeBtn.style.opacity = tab === 'code' ? '1' : '0.7';
16977
+ if (varsBtn) varsBtn.style.opacity = tab === 'vars' ? '1' : '0.7';
16978
+ if (codeModalState) codeModalState.activeTab = tab;
16979
+ if (tab === 'vars') {
16980
+ updateCodeModalVariablesStatus();
16981
+ }
16982
+ }
16983
+
16297
16984
  // Generic code modal for both Web (TypeScript) and Mobile (YAML)
16298
16985
  function showCodeModal(code, language, recordingId, type) {
16299
- const isYaml = language === 'yaml';
16986
+ const resolvedLanguage = language || 'typescript';
16987
+ const resolvedType = type || (resolvedLanguage === 'yaml' ? 'mobile' : 'web');
16988
+ const resolvedRecordingId = recordingId || (resolvedType === 'web' ? recorderSession?.id : currentProject?.id || lastMobileRecording?.projectId);
16989
+ const isYaml = resolvedLanguage === 'yaml';
16300
16990
  const title = isYaml ? 'Maestro Flow (YAML)' : 'Playwright Test (TypeScript)';
16301
16991
  const lineCount = (code || '').split('\n').length;
16302
16992
  const lineNumbers = Array.from({length: Math.max(lineCount, 20)}, (_, i) => i + 1).join('\n');
16993
+ const hasVarsTab = !!resolvedRecordingId && (resolvedType === 'mobile' || resolvedType === 'web');
16994
+
16995
+ codeModalState = {
16996
+ code: code || '',
16997
+ language: resolvedLanguage,
16998
+ type: resolvedType,
16999
+ recordingId: resolvedRecordingId || null,
17000
+ ownerType: hasVarsTab ? getCodeModalOwnerType(resolvedType) : null,
17001
+ variables: [],
17002
+ variablesLoaded: false,
17003
+ lastSavedVariablesSignature: '',
17004
+ placeholders: extractCodeModalPlaceholders(code || ''),
17005
+ missingPlaceholders: [],
17006
+ activeTab: 'code',
17007
+ mobileRunDevices: [],
17008
+ mobileRunDevicesLoading: resolvedType === 'mobile',
17009
+ mobileRunSelectedDeviceId: resolvedType === 'mobile' ? getPreferredCodeModalMobileDeviceId() : '',
17010
+ lastSavedCode: code || ''
17011
+ };
16303
17012
 
16304
17013
  const existing = document.getElementById('codeModal');
16305
17014
  if (existing) {
16306
- existing.remove();
17015
+ closeCodeModal();
16307
17016
  }
16308
17017
 
16309
17018
  const modal = document.createElement('div');
16310
17019
  modal.className = 'modal-overlay';
17020
+ modal.classList.add('code-modal-overlay');
16311
17021
  modal.id = 'codeModal';
16312
17022
  modal.innerHTML = `
16313
17023
  <div class="modal code-modal">
@@ -16315,21 +17025,95 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16315
17025
  <h3>${title}</h3>
16316
17026
  <button class="modal-close" onclick="closeCodeModal()">&times;</button>
16317
17027
  </div>
17028
+ <div style="display:flex; gap:8px; padding: 10px 16px 0 16px; border-bottom: 1px solid var(--border);">
17029
+ <button id="codeModalTabCode" class="btn btn-secondary" style="padding: 6px 10px; min-width: auto;" onclick="switchCodeModalTab('code')">Script</button>
17030
+ ${hasVarsTab ? `
17031
+ <button id="codeModalTabVars" class="btn btn-secondary" style="padding: 6px 10px; min-width: auto; opacity: .7;" onclick="switchCodeModalTab('vars')">
17032
+ Test Variables
17033
+ <span id="codeModalVarsTabBadge" style="margin-left:6px; font-size: 10px; color: var(--text-muted);">...</span>
17034
+ </button>
17035
+ ` : ''}
17036
+ </div>
17037
+ ${resolvedType === 'mobile' ? `
17038
+ <div style="display:flex; align-items:center; justify-content:space-between; gap:10px; padding: 10px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; background: rgba(255,255,255,0.01);">
17039
+ <div style="display:flex; align-items:center; gap:8px; flex-wrap: wrap; min-width: 0;">
17040
+ <label for="codeModalMobileDeviceSelect" style="font-size: 12px; color: var(--text-secondary);">Run on device</label>
17041
+ <select id="codeModalMobileDeviceSelect" class="setting-input" style="min-width: 280px; max-width: 100%; padding: 6px 10px; height: auto;" onchange="onCodeModalMobileDeviceChanged(this.value)">
17042
+ <option value="">Auto-detect device (Maestro default)</option>
17043
+ </select>
17044
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="loadCodeModalMobileRunDevices(true)">Refresh</button>
17045
+ </div>
17046
+ <div id="codeModalMobileDeviceStatus" style="font-size: 11px; color: var(--text-muted);">Loading devices...</div>
17047
+ </div>
17048
+ ` : ''}
16318
17049
  <div class="code-modal-body">
16319
- <div class="code-editor-container">
16320
- <div id="codeLineNumbers" class="code-line-numbers">${lineNumbers}</div>
16321
- <textarea id="codeEditorTextarea" class="code-editor-textarea" spellcheck="false">${escapeHtml(code)}</textarea>
17050
+ <div id="codeModalCodePanel" class="code-modal-panel">
17051
+ <div class="code-editor-container">
17052
+ <div id="codeLineNumbers" class="code-line-numbers">${lineNumbers}</div>
17053
+ <textarea id="codeEditorTextarea" class="code-editor-textarea" spellcheck="false">${escapeHtml(code)}</textarea>
17054
+ </div>
17055
+ <div id="codeModalOutput" class="code-modal-output" style="display: none;"></div>
16322
17056
  </div>
16323
- <div id="codeModalOutput" class="code-modal-output" style="display: none;"></div>
17057
+ ${hasVarsTab ? `
17058
+ <div id="codeModalVarsPanel" class="code-modal-panel" style="display:none; padding: 12px 0 0 0; overflow:auto;">
17059
+ <div style="display:grid; gap:10px;">
17060
+ <div style="display:flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap;">
17061
+ <div id="codeModalVarSummary" style="font-size: 12px; color: var(--text-secondary); display:flex; gap:10px; flex-wrap:wrap;">
17062
+ <span>Loading variables...</span>
17063
+ </div>
17064
+ <div style="display:flex; gap:8px; flex-wrap:wrap;">
17065
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="addMissingCodeModalVariablesFromScript()">Add Missing From Script</button>
17066
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="addCodeModalVariableRow()">Add Variable</button>
17067
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="loadCodeModalVariables()">Reload</button>
17068
+ <button class="btn btn-primary" style="padding:6px 10px; min-width:auto;" onclick="saveCodeModalVariables()">Save Variables</button>
17069
+ </div>
17070
+ </div>
17071
+ <div id="codeModalMissingList" style="font-size:11px; color: var(--text-muted);">Checking placeholders...</div>
17072
+ <div class="code-vars-table-wrap">
17073
+ <table style="width:100%; border-collapse: collapse; min-width: 860px; background: var(--bg-secondary);">
17074
+ <thead>
17075
+ <tr style="text-align:left; font-size: 11px; color: var(--text-secondary); background: var(--bg-tertiary);">
17076
+ <th style="padding:8px;">Key</th>
17077
+ <th style="padding:8px;">Value</th>
17078
+ <th style="padding:8px;">Platform</th>
17079
+ <th style="padding:8px;">Secret</th>
17080
+ <th style="padding:8px;">Notes</th>
17081
+ <th style="padding:8px;"></th>
17082
+ </tr>
17083
+ </thead>
17084
+ <tbody id="codeModalVarsTableBody"></tbody>
17085
+ </table>
17086
+ </div>
17087
+ <div style="display:grid; gap:6px;">
17088
+ <div style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
17089
+ <label style="font-size: 12px; color: var(--text-secondary);">.env.test (saved in DB as structured variables)</label>
17090
+ <div style="display:flex; gap:8px;">
17091
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="syncCodeModalEnvFromTable()">Regenerate</button>
17092
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="applyEnvTestToCodeModalTable()">Apply .env</button>
17093
+ </div>
17094
+ </div>
17095
+ <textarea id="codeModalEnvTextarea" class="code-editor-textarea" spellcheck="false" style="min-height: 140px; height: 140px;"></textarea>
17096
+ </div>
17097
+ </div>
17098
+ </div>
17099
+ ` : ''}
16324
17100
  </div>
16325
17101
  <div class="code-modal-footer">
16326
- ${isYaml && recordingId ? `
16327
- <button id="runCodeBtn" class="btn btn-run" onclick="runMaestroFromEditor('${recordingId}')">
17102
+ ${resolvedRecordingId ? `
17103
+ <button id="runCodeBtn" class="btn btn-run" onclick="${isYaml ? `runMaestroFromEditor('${resolvedRecordingId}')` : `runPlaywrightFromEditor('${resolvedRecordingId}')`}">
16328
17104
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px;">
16329
17105
  <polygon points="5 3 19 12 5 21 5 3"/>
16330
17106
  </svg>
16331
- Run Test
17107
+ ${isYaml ? 'Run on Device' : 'Run Test'}
16332
17108
  </button>
17109
+ ${isYaml ? `
17110
+ <button id="stopCodeBtn" class="btn btn-secondary" onclick="stopMaestroRunFromEditor()" style="display:none; min-width: 120px; border-color: rgba(239,68,68,0.25); color: #fca5a5;">
17111
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="margin-right: 4px;">
17112
+ <rect x="6" y="6" width="12" height="12" rx="1"/>
17113
+ </svg>
17114
+ Stop Run
17115
+ </button>
17116
+ ` : ''}
16333
17117
  ` : ''}
16334
17118
  <button class="btn btn-secondary" onclick="copyCodeToClipboard()">
16335
17119
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px;">
@@ -16338,8 +17122,8 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16338
17122
  </svg>
16339
17123
  Copy
16340
17124
  </button>
16341
- ${recordingId ? `
16342
- <button class="btn btn-primary" onclick="saveCodeChanges('${recordingId}', '${type}')">
17125
+ ${resolvedRecordingId ? `
17126
+ <button class="btn btn-primary" onclick="saveCodeChanges('${resolvedRecordingId}', '${resolvedType}')">
16343
17127
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px;">
16344
17128
  <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
16345
17129
  <polyline points="17 21 17 13 7 13 7 21"/>
@@ -16352,6 +17136,8 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16352
17136
  </div>
16353
17137
  `;
16354
17138
  document.body.appendChild(modal);
17139
+ lockPageScrollForCodeModal();
17140
+ attachCodeModalScrollTrap(modal);
16355
17141
  requestAnimationFrame(() => modal.classList.add('active'));
16356
17142
 
16357
17143
  // Sync line numbers with textarea scroll and content
@@ -16364,9 +17150,17 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16364
17150
  lineNumbersEl.scrollTop = textarea.scrollTop;
16365
17151
  });
16366
17152
 
17153
+ // If the pointer is over line numbers, scroll the editor instead of the page behind
17154
+ lineNumbersEl.addEventListener('wheel', (e) => {
17155
+ e.preventDefault();
17156
+ textarea.scrollTop += e.deltaY;
17157
+ textarea.scrollLeft += e.deltaX;
17158
+ }, { passive: false });
17159
+
16367
17160
  // Update line numbers when content changes
16368
17161
  textarea.addEventListener('input', () => {
16369
17162
  updateLineNumbers();
17163
+ updateCodeModalVariablesStatus();
16370
17164
  });
16371
17165
  }
16372
17166
 
@@ -16380,6 +17174,14 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16380
17174
  };
16381
17175
  document.addEventListener('keydown', escHandler);
16382
17176
  modal._escHandler = escHandler;
17177
+
17178
+ if (hasVarsTab) {
17179
+ loadCodeModalVariables();
17180
+ }
17181
+ if (resolvedType === 'mobile') {
17182
+ loadCodeModalMobileRunDevices();
17183
+ }
17184
+ switchCodeModalTab('code');
16383
17185
  }
16384
17186
 
16385
17187
  function updateLineNumbers() {
@@ -16392,17 +17194,279 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16392
17194
  lineNumbersEl.textContent = Array.from({length: maxLines}, (_, i) => i + 1).join('\n');
16393
17195
  }
16394
17196
 
17197
+ function setCodeModalRunButtonBusy(runBtn, label) {
17198
+ if (!runBtn) return;
17199
+ runBtn.disabled = true;
17200
+ runBtn.innerHTML = `
17201
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px; animation: spin 1s linear infinite;">
17202
+ <circle cx="12" cy="12" r="10"/>
17203
+ <path d="M12 6v6l4 2"/>
17204
+ </svg>
17205
+ ${escapeHtml(label || 'Running...')}
17206
+ `;
17207
+ }
17208
+
17209
+ function setCodeModalStopButtonState(stopBtn, options = {}) {
17210
+ if (!stopBtn) return;
17211
+ const {
17212
+ visible = false,
17213
+ disabled = false,
17214
+ label = 'Stop Run'
17215
+ } = options;
17216
+ stopBtn.style.display = visible ? '' : 'none';
17217
+ stopBtn.disabled = !!disabled;
17218
+ stopBtn.innerHTML = `
17219
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="margin-right: 4px;">
17220
+ <rect x="6" y="6" width="12" height="12" rx="1"/>
17221
+ </svg>
17222
+ ${escapeHtml(label)}
17223
+ `;
17224
+ }
17225
+
17226
+ function resetCodeModalRunButton(runBtn, type = 'mobile') {
17227
+ if (!runBtn) return;
17228
+ runBtn.disabled = false;
17229
+ runBtn.innerHTML = `
17230
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px;">
17231
+ <polygon points="5 3 19 12 5 21 5 3"/>
17232
+ </svg>
17233
+ ${type === 'mobile' ? 'Run on Device' : 'Run Test'}
17234
+ `;
17235
+ }
17236
+
17237
+ function appendCodeModalOutputLine(outputEl, htmlLine) {
17238
+ if (!outputEl) return;
17239
+ outputEl.innerHTML += '\n' + htmlLine;
17240
+ outputEl.scrollTop = outputEl.scrollHeight;
17241
+ }
17242
+
17243
+ async function pollMaestroRunStatus(runId, options = {}) {
17244
+ const {
17245
+ outputEl = null,
17246
+ runBtn = null,
17247
+ pollMs = 1200,
17248
+ timeoutMs = 360000
17249
+ } = options;
17250
+ const startedAt = Date.now();
17251
+ let runningAnnounced = false;
17252
+
17253
+ while ((Date.now() - startedAt) < timeoutMs) {
17254
+ const response = await fetch(`/api/testing/mobile/replays/${encodeURIComponent(runId)}`);
17255
+ const data = await response.json().catch(() => ({}));
17256
+ if (!response.ok) {
17257
+ throw new Error(data.error || 'Failed to read Maestro run status');
17258
+ }
17259
+
17260
+ const elapsedSec = Math.max(0, Math.round((Number(data.elapsedMs || 0) / 1000)));
17261
+ if (data.status === 'running') {
17262
+ if (!runningAnnounced && outputEl) {
17263
+ appendCodeModalOutputLine(outputEl, '<span class="output-running">⏳ Maestro is running on device...</span>');
17264
+ runningAnnounced = true;
17265
+ }
17266
+ if (runBtn) {
17267
+ setCodeModalRunButtonBusy(runBtn, elapsedSec > 0 ? `Running... ${elapsedSec}s` : 'Running...');
17268
+ }
17269
+ await new Promise(resolve => setTimeout(resolve, pollMs));
17270
+ continue;
17271
+ }
17272
+
17273
+ return data;
17274
+ }
17275
+
17276
+ throw new Error('Timed out waiting for Maestro run to finish');
17277
+ }
17278
+
16395
17279
  // Run Maestro test from editor
16396
17280
  let codeModalRunning = false;
17281
+ async function stopMaestroRunFromEditor() {
17282
+ const stopBtn = document.getElementById('stopCodeBtn');
17283
+ const outputEl = document.getElementById('codeModalOutput');
17284
+ const runId = String(codeModalState?.activeMaestroRunId || '').trim();
17285
+ if (!runId) return;
17286
+
17287
+ try {
17288
+ setCodeModalStopButtonState(stopBtn, { visible: true, disabled: true, label: 'Stopping...' });
17289
+ appendCodeModalOutputLine(outputEl, '<span class="output-running">⏹ Requesting stop...</span>');
17290
+ const response = await fetch(`/api/testing/mobile/replays/${encodeURIComponent(runId)}/stop`, {
17291
+ method: 'POST'
17292
+ });
17293
+ const data = await response.json().catch(() => ({}));
17294
+ if (!response.ok && response.status !== 409) {
17295
+ throw new Error(data.error || 'Failed to stop run');
17296
+ }
17297
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">⏳ Stop requested. Waiting for runner status...</span>');
17298
+ showToast('Stop requested', 'info');
17299
+ } catch (error) {
17300
+ appendCodeModalOutputLine(outputEl, '<span class="output-error">✗ Stop failed: ' + escapeHtml(error.message || 'Unknown error') + '</span>');
17301
+ setCodeModalStopButtonState(stopBtn, { visible: true, disabled: false, label: 'Stop Run' });
17302
+ showToast('Failed to stop run', 'error');
17303
+ }
17304
+ }
17305
+
16397
17306
  async function runMaestroFromEditor(recordingId) {
16398
17307
  if (codeModalRunning) return;
16399
17308
 
16400
17309
  const textarea = document.getElementById('codeEditorTextarea');
16401
17310
  const outputEl = document.getElementById('codeModalOutput');
16402
17311
  const runBtn = document.getElementById('runCodeBtn');
17312
+ const stopBtn = document.getElementById('stopCodeBtn');
16403
17313
 
16404
17314
  if (!textarea || !outputEl) return;
16405
17315
 
17316
+ codeModalRunning = true;
17317
+ if (codeModalState) codeModalState.activeMaestroRunId = null;
17318
+ if (runBtn) {
17319
+ setCodeModalRunButtonBusy(runBtn, 'Loading...');
17320
+ }
17321
+ setCodeModalStopButtonState(stopBtn, { visible: false });
17322
+
17323
+ // Show output area
17324
+ outputEl.style.display = 'block';
17325
+ outputEl.innerHTML = '<span class="output-running">▶ Saving and running Maestro test on device...</span>';
17326
+
17327
+ const selectedDeviceId = String(codeModalState?.mobileRunSelectedDeviceId || '').trim();
17328
+ const selectedDevice = Array.isArray(codeModalState?.mobileRunDevices)
17329
+ ? codeModalState.mobileRunDevices.find((d) => String(d?.id || '').trim() === selectedDeviceId)
17330
+ : null;
17331
+
17332
+ try {
17333
+ const currentVars = collectCodeModalVariablesFromTable().map((row) => ({
17334
+ ...row,
17335
+ key: String(row.key || '').trim().toUpperCase(),
17336
+ platform: ['mobile', 'web', 'both'].includes(row.platform) ? row.platform : 'both'
17337
+ })).filter((row) => row.key);
17338
+ const currentVarsSignature = getCodeModalVariablesSignature(currentVars);
17339
+ const shouldSaveVars = !codeModalState?.variablesLoaded
17340
+ || currentVarsSignature !== String(codeModalState?.lastSavedVariablesSignature || '');
17341
+ if (shouldSaveVars) {
17342
+ const varsSaved = await saveCodeModalVariables({ silent: true });
17343
+ if (!varsSaved) throw new Error('Failed to save test variables');
17344
+ appendCodeModalOutputLine(outputEl, '<span class="output-success">✓ Test variables saved</span>');
17345
+ } else {
17346
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">↺ Test variables unchanged (skipped save)</span>');
17347
+ }
17348
+
17349
+ // First, save the code
17350
+ const currentCode = textarea.value;
17351
+ const shouldSaveCode = currentCode !== String(codeModalState?.lastSavedCode ?? codeModalState?.code ?? '');
17352
+ if (shouldSaveCode) {
17353
+ const saveResponse = await fetch(`/api/testing/mobile/recordings/${recordingId}/flow`, {
17354
+ method: 'PUT',
17355
+ headers: { 'Content-Type': 'application/json' },
17356
+ body: JSON.stringify({ flowCode: currentCode })
17357
+ });
17358
+
17359
+ if (!saveResponse.ok) {
17360
+ throw new Error('Failed to save flow code');
17361
+ }
17362
+ if (codeModalState) {
17363
+ codeModalState.code = currentCode;
17364
+ codeModalState.lastSavedCode = currentCode;
17365
+ }
17366
+ outputEl.innerHTML += '\n<span class="output-success">✓ Flow saved</span>';
17367
+ } else {
17368
+ outputEl.innerHTML += '\n<span class="output-step">↺ Flow unchanged (skipped save)</span>';
17369
+ }
17370
+ outputEl.innerHTML += '\n<span class="output-running">▶ Starting Maestro test on device...</span>';
17371
+
17372
+ // Now run the test
17373
+ const runRequestBody = {};
17374
+ if (selectedDeviceId) {
17375
+ runRequestBody.deviceId = selectedDeviceId;
17376
+ if (selectedDevice?.platform === 'ios' || selectedDevice?.platform === 'android') {
17377
+ runRequestBody.devicePlatform = selectedDevice.platform;
17378
+ runRequestBody.deviceName = String(selectedDevice?.name || selectedDeviceId);
17379
+ runRequestBody.skipDeviceValidation = true;
17380
+ }
17381
+ }
17382
+ const runResponse = await fetch(`/api/testing/mobile/recordings/${recordingId}/replay`, {
17383
+ method: 'POST',
17384
+ headers: { 'Content-Type': 'application/json' },
17385
+ body: JSON.stringify(runRequestBody)
17386
+ });
17387
+
17388
+ const runData = await runResponse.json();
17389
+
17390
+ if (!runResponse.ok) {
17391
+ if (Array.isArray(runData?.missingKeys) && runData.missingKeys.length > 0) {
17392
+ outputEl.innerHTML += '\n<span class="output-error">✗ Missing variables: ' + runData.missingKeys.join(', ') + '</span>';
17393
+ switchCodeModalTab('vars');
17394
+ }
17395
+ throw new Error(runData.error || 'Failed to start test');
17396
+ }
17397
+
17398
+ appendCodeModalOutputLine(outputEl, '<span class="output-success">✓ Maestro test started!</span>');
17399
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">📍 Flow: ' + escapeHtml(runData.flowPath || recordingId) + '</span>');
17400
+ if (runData?.deviceName || runData?.deviceId) {
17401
+ const deviceLabel = runData.deviceName || runData.deviceId;
17402
+ const platformLabel = runData.devicePlatform ? ` (${String(runData.devicePlatform).toUpperCase()})` : '';
17403
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">📱 Device: ' + escapeHtml(deviceLabel + platformLabel) + '</span>');
17404
+ if (runData?.deviceSelectionSource === 'trusted-client') {
17405
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">⚡ Fast start: reused selected device from modal</span>');
17406
+ }
17407
+ } else {
17408
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">📱 Device: Auto-detect (Maestro default)</span>');
17409
+ }
17410
+ if (Array.isArray(runData.usedKeys) && runData.usedKeys.length > 0) {
17411
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">🔐 Vars: ' + escapeHtml(runData.usedKeys.join(', ')) + '</span>');
17412
+ }
17413
+ appendCodeModalOutputLine(outputEl, '<span style="color: #fbbf24;">💡 Watch the selected device for test execution.</span>');
17414
+
17415
+ const runId = typeof runData?.runId === 'string' ? runData.runId : '';
17416
+ if (runId) {
17417
+ if (codeModalState) codeModalState.activeMaestroRunId = runId;
17418
+ setCodeModalRunButtonBusy(runBtn, 'Running...');
17419
+ setCodeModalStopButtonState(stopBtn, { visible: true, disabled: false, label: 'Stop Run' });
17420
+ const finalStatus = await pollMaestroRunStatus(runId, { outputEl, runBtn });
17421
+ const durationMs = Number(finalStatus?.durationMs);
17422
+ const durationLabel = Number.isFinite(durationMs) && durationMs > 0
17423
+ ? ` (${Math.round(durationMs / 1000)}s)`
17424
+ : '';
17425
+
17426
+ if (finalStatus.status === 'completed') {
17427
+ appendCodeModalOutputLine(outputEl, '<span class="output-success">✓ Maestro test completed' + escapeHtml(durationLabel) + '</span>');
17428
+ if (finalStatus.output) {
17429
+ const preview = String(finalStatus.output).slice(-2500);
17430
+ appendCodeModalOutputLine(outputEl, '<pre style="white-space: pre-wrap; margin: 8px 0 0 0; color: var(--text-secondary);">' + escapeHtml(preview) + '</pre>');
17431
+ }
17432
+ showToast('Maestro test completed', 'success');
17433
+ } else if (finalStatus.status === 'failed') {
17434
+ appendCodeModalOutputLine(outputEl, '<span class="output-error">✗ Maestro test failed' + escapeHtml(durationLabel) + '</span>');
17435
+ if (finalStatus.error) {
17436
+ appendCodeModalOutputLine(outputEl, '<span class="output-error">' + escapeHtml(String(finalStatus.error)) + '</span>');
17437
+ }
17438
+ if (finalStatus.output) {
17439
+ const preview = String(finalStatus.output).slice(-2500);
17440
+ appendCodeModalOutputLine(outputEl, '<pre style="white-space: pre-wrap; margin: 8px 0 0 0; color: var(--text-secondary);">' + escapeHtml(preview) + '</pre>');
17441
+ }
17442
+ showToast('Maestro test failed', 'error');
17443
+ } else if (finalStatus.status === 'canceled') {
17444
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">⏹ Maestro run canceled' + escapeHtml(durationLabel) + '</span>');
17445
+ showToast('Maestro run canceled', 'info');
17446
+ }
17447
+ } else {
17448
+ showToast('Maestro test started on device', 'success');
17449
+ }
17450
+
17451
+ } catch (error) {
17452
+ outputEl.innerHTML += '\n<span class="output-error">✗ Error: ' + (error.message || 'Unknown error') + '</span>';
17453
+ showToast('Failed to run test', 'error');
17454
+ } finally {
17455
+ codeModalRunning = false;
17456
+ if (codeModalState) codeModalState.activeMaestroRunId = null;
17457
+ setCodeModalStopButtonState(stopBtn, { visible: false });
17458
+ resetCodeModalRunButton(runBtn, 'mobile');
17459
+ }
17460
+ }
17461
+
17462
+ async function runPlaywrightFromEditor(recordingId) {
17463
+ if (codeModalRunning) return;
17464
+
17465
+ const textarea = document.getElementById('codeEditorTextarea');
17466
+ const outputEl = document.getElementById('codeModalOutput');
17467
+ const runBtn = document.getElementById('runCodeBtn');
17468
+ if (!textarea || !outputEl) return;
17469
+
16406
17470
  codeModalRunning = true;
16407
17471
  if (runBtn) {
16408
17472
  runBtn.disabled = true;
@@ -16415,84 +17479,66 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16415
17479
  `;
16416
17480
  }
16417
17481
 
16418
- // Show output area
16419
17482
  outputEl.style.display = 'block';
16420
- outputEl.innerHTML = '<span class="output-running">▶ Saving and running Maestro test...</span>';
16421
-
16422
- // Detect platform from YAML appId
16423
- const yamlContent = textarea.value;
16424
- const appIdMatch = yamlContent.match(/appId:\s*([^\s\n]+)/);
16425
- const appId = appIdMatch ? appIdMatch[1] : '';
16426
- const platform = appId.includes('com.apple') || appId.startsWith('com.') && !appId.includes('android') ? 'ios' : 'android';
17483
+ outputEl.innerHTML = '<span class="output-running">▶ Saving Playwright script and running test...</span>';
16427
17484
 
16428
17485
  try {
16429
- // First, save the code
16430
- const saveResponse = await fetch(`/api/testing/mobile/recordings/${recordingId}/flow`, {
17486
+ const varsSaved = await saveCodeModalVariables({ silent: true });
17487
+ if (varsSaved === false) {
17488
+ throw new Error('Failed to save test variables');
17489
+ }
17490
+
17491
+ const saveResponse = await fetch(`/api/recorder/recordings/${recordingId}/spec`, {
16431
17492
  method: 'PUT',
16432
17493
  headers: { 'Content-Type': 'application/json' },
16433
- body: JSON.stringify({ flowCode: textarea.value })
17494
+ body: JSON.stringify({ specCode: textarea.value })
16434
17495
  });
16435
-
17496
+ const saveData = await saveResponse.json().catch(() => ({}));
16436
17497
  if (!saveResponse.ok) {
16437
- throw new Error('Failed to save flow code');
17498
+ throw new Error(saveData.error || 'Failed to save spec');
16438
17499
  }
16439
17500
 
16440
- outputEl.innerHTML += '\n<span class="output-success">✓ Flow saved</span>';
16441
- outputEl.innerHTML += '\n<span class="output-running">▶ Starting auto-capture and Maestro test...</span>';
17501
+ outputEl.innerHTML += '\n<span class="output-success">✓ Spec saved</span>';
16442
17502
 
16443
- // Start auto-capture before running the test
16444
- try {
16445
- await fetch('/api/testing/mobile/auto-capture/start', {
16446
- method: 'POST',
16447
- headers: { 'Content-Type': 'application/json' },
16448
- body: JSON.stringify({ recordingId, platform })
16449
- });
16450
- outputEl.innerHTML += '\n<span class="output-success">✓ Auto-capture started</span>';
16451
- } catch (captureErr) {
16452
- console.log('Auto-capture not available:', captureErr);
16453
- }
16454
-
16455
- // Now run the test
16456
- const runResponse = await fetch(`/api/testing/mobile/recordings/${recordingId}/replay`, {
17503
+ const runResponse = await fetch(`/api/recorder/recordings/${recordingId}/run`, {
16457
17504
  method: 'POST',
16458
- headers: { 'Content-Type': 'application/json' }
17505
+ headers: { 'Content-Type': 'application/json' },
17506
+ body: JSON.stringify({
17507
+ headless: false,
17508
+ browser: 'chromium',
17509
+ video: 'retain-on-failure',
17510
+ screenshot: 'only-on-failure',
17511
+ trace: 'retain-on-failure'
17512
+ })
16459
17513
  });
16460
-
16461
17514
  const runData = await runResponse.json();
16462
17515
 
16463
17516
  if (!runResponse.ok) {
16464
- throw new Error(runData.error || 'Failed to start test');
17517
+ if (Array.isArray(runData?.missingKeys) && runData.missingKeys.length > 0) {
17518
+ outputEl.innerHTML += '\n<span class="output-error">✗ Missing variables: ' + runData.missingKeys.join(', ') + '</span>';
17519
+ switchCodeModalTab('vars');
17520
+ }
17521
+ throw new Error(runData.error || runData.result?.error || 'Failed to run Playwright test');
16465
17522
  }
16466
17523
 
16467
- outputEl.innerHTML += '\n<span class="output-success">✓ Maestro test started!</span>';
16468
- outputEl.innerHTML += '\n<span class="output-step">📍 Flow: ' + (runData.flowPath || recordingId) + '</span>';
16469
- outputEl.innerHTML += '\n\n<span style="color: #4ade80;">📸 Screenshots are being captured automatically.</span>';
16470
- outputEl.innerHTML += '\n<span style="color: #fbbf24;">💡 Watch the device screen for test execution.</span>';
16471
-
16472
- showToast('Maestro test started with auto-capture!', 'success');
16473
-
16474
- // Schedule auto-capture stop after estimated test duration (30s default)
16475
- setTimeout(async () => {
16476
- try {
16477
- await fetch('/api/testing/mobile/auto-capture/stop', {
16478
- method: 'POST',
16479
- headers: { 'Content-Type': 'application/json' },
16480
- body: JSON.stringify({ recordingId })
16481
- });
16482
- } catch {}
16483
- }, 30000);
17524
+ const result = runData.result || {};
17525
+ outputEl.innerHTML += '\n<span class="output-success">✓ Playwright run completed</span>';
17526
+ outputEl.innerHTML += '\n<span class="output-step">✅ Passed: ' + (result.passed ?? 0) + ' | ❌ Failed: ' + (result.failed ?? 0) + ' | ⏭️ Skipped: ' + (result.skipped ?? 0) + '</span>';
17527
+ if (Array.isArray(runData.usedKeys) && runData.usedKeys.length > 0) {
17528
+ outputEl.innerHTML += '\n<span class="output-step">🔐 Vars: ' + runData.usedKeys.join(', ') + '</span>';
17529
+ }
17530
+ if (result.reportPath) {
17531
+ outputEl.innerHTML += '\n<span class="output-step">📄 Report: ' + result.reportPath + '</span>';
17532
+ }
17533
+ if (result.output) {
17534
+ const preview = String(result.output).slice(0, 2500);
17535
+ outputEl.innerHTML += '\n\n<pre style="white-space: pre-wrap; margin: 8px 0 0 0; color: var(--text-secondary);">' + escapeHtml(preview) + '</pre>';
17536
+ }
16484
17537
 
17538
+ showToast(result.success === false ? 'Playwright test failed' : 'Playwright test finished', result.success === false ? 'warning' : 'success');
16485
17539
  } catch (error) {
16486
17540
  outputEl.innerHTML += '\n<span class="output-error">✗ Error: ' + (error.message || 'Unknown error') + '</span>';
16487
- showToast('Failed to run test', 'error');
16488
- // Stop auto-capture on error
16489
- try {
16490
- await fetch('/api/testing/mobile/auto-capture/stop', {
16491
- method: 'POST',
16492
- headers: { 'Content-Type': 'application/json' },
16493
- body: JSON.stringify({ recordingId })
16494
- });
16495
- } catch {}
17541
+ showToast('Failed to run Playwright test', 'error');
16496
17542
  } finally {
16497
17543
  codeModalRunning = false;
16498
17544
  if (runBtn) {
@@ -16513,8 +17559,13 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16513
17559
  if (modal._escHandler) {
16514
17560
  document.removeEventListener('keydown', modal._escHandler);
16515
17561
  }
17562
+ if (modal._wheelTrapHandler) {
17563
+ modal.removeEventListener('wheel', modal._wheelTrapHandler, { capture: true });
17564
+ }
16516
17565
  modal.remove();
16517
17566
  }
17567
+ unlockPageScrollForCodeModal();
17568
+ codeModalState = null;
16518
17569
  }
16519
17570
 
16520
17571
  function copyCodeToClipboard() {
@@ -16532,6 +17583,11 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16532
17583
  const code = textarea.value;
16533
17584
 
16534
17585
  try {
17586
+ const varsSaved = await saveCodeModalVariables({ silent: true });
17587
+ if (varsSaved === false) {
17588
+ throw new Error('Failed to save test variables');
17589
+ }
17590
+
16535
17591
  if (type === 'mobile') {
16536
17592
  const response = await fetch(`/api/testing/mobile/recordings/${recordingId}/flow`, {
16537
17593
  method: 'PUT',
@@ -16549,6 +17605,12 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16549
17605
  if (!response.ok) throw new Error('Failed to save');
16550
17606
  }
16551
17607
 
17608
+ if (codeModalState) {
17609
+ codeModalState.code = code;
17610
+ codeModalState.lastSavedCode = code;
17611
+ codeModalState.placeholders = extractCodeModalPlaceholders(code);
17612
+ }
17613
+ updateCodeModalVariablesStatus();
16552
17614
  showToast('Code saved!', 'success');
16553
17615
  } catch (error) {
16554
17616
  showToast('Failed to save code', 'error');