synabun 1.3.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 (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,3573 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — Automation Studio
3
+ // Browse, configure, launch, and track loop automations.
4
+ // Powered by SynaBun memory + browser integration.
5
+ // ═══════════════════════════════════════════
6
+
7
+ import { emit, on } from './state.js';
8
+ import { storage } from './storage.js';
9
+ import { isGuest, hasPermission, showGuestToast } from './ui-sync.js';
10
+ // sendToPanel removed — all launches use floating browser only
11
+ import {
12
+ fetchLoopTemplates,
13
+ createLoopTemplate,
14
+ updateLoopTemplate,
15
+ deleteLoopTemplate,
16
+ importLoopTemplates,
17
+ fetchActiveLoop,
18
+ launchLoop,
19
+ stopLoop,
20
+ launchAgent,
21
+ fetchAgents,
22
+ fetchAgent,
23
+ stopAgent,
24
+ removeAgent,
25
+ fetchSchedules,
26
+ createSchedule,
27
+ updateSchedule,
28
+ deleteSchedule,
29
+ testSchedule,
30
+ startScheduleTimer,
31
+ cancelScheduleTimer,
32
+ fetchScheduleTimers,
33
+ createQuickTimer,
34
+ fetchQuickTimers,
35
+ cancelQuickTimer,
36
+ triggerQuickTimerNow,
37
+ } from './api.js';
38
+
39
+ const $ = (id) => document.getElementById(id);
40
+
41
+ // ── Module-local state ──
42
+ let _panel = null;
43
+ let _backdrop = null;
44
+ let _templates = []; // user-created templates from API
45
+ let _activeLoop = null; // current active loop status
46
+ let _view = 'welcome'; // 'welcome' | 'picker' | 'detail' | 'wizard' | 'running'
47
+ let _selected = null; // currently open template in detail view
48
+ let _filterCategory = 'all';
49
+ let _searchQuery = '';
50
+ let _pollTimer = null;
51
+ let _prevLoopActive = false;
52
+
53
+ // Cleanup: stored references for document-level listeners so closePanel() can remove them
54
+ let _docListeners = [];
55
+
56
+ // Detail/editor state
57
+ let _metaDirty = false;
58
+ let _detailDirty = false;
59
+ let _editorContent = '';
60
+ let _originalContent = '';
61
+ let _previewMode = false;
62
+ let _fetchSeq = 0;
63
+
64
+ // Wizard state
65
+ let _wizardState = null;
66
+ let _wizardStep = 0;
67
+ let _wizardPreset = null;
68
+
69
+ // Agent state
70
+ let _agents = []; // live agent list from server
71
+ let _agentOutputs = {}; // agentId → accumulated text chunks for live display
72
+ let _agentRecentTools = {}; // agentId → last N tool names for live feed
73
+ let _agentJournals = {}; // agentId → journal entries from iteration-complete events
74
+ let _launchMode = 'loop'; // 'loop' | 'agent' — which mode the launch dialog uses
75
+ const _launchDestination = 'cli'; // always floating — side panel launch removed
76
+
77
+ // Schedule state
78
+ let _schedules = []; // all schedules from API
79
+ let _editingSchedule = null; // schedule being edited, or null for new
80
+ let _scheduleTimerData = {}; // scheduleId → { firesAt, minutes }
81
+ let _quickTimers = []; // active quick timers from API
82
+ let _showAdvancedSchedules = false;
83
+ let _selectedQtMinutes = null; // currently selected quick timer preset
84
+ let _qtUsesBrowser = null; // null = use template default, true/false = user override
85
+
86
+ // ── Constants ──
87
+ const PANEL_KEY = 'neural-panel-automation-studio';
88
+ const POLL_INTERVAL = 5000;
89
+
90
+ const CATEGORY_LABELS = {
91
+ social: 'Social',
92
+ productivity: 'Productivity',
93
+ monitoring: 'Monitoring',
94
+ custom: 'Custom',
95
+ };
96
+
97
+ const CATEGORY_COLORS = {
98
+ social: 'hsla(200, 40%, 55%, 0.9)',
99
+ productivity: 'hsla(160, 40%, 55%, 0.9)',
100
+ monitoring: 'hsla(35, 50%, 58%, 0.9)',
101
+ custom: 'hsla(270, 30%, 60%, 0.9)',
102
+ };
103
+
104
+ // ── SVG Icons (stroke-based, 16x16, matches Skills Studio pattern) ──
105
+ const _s = (d) => `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
106
+
107
+ const AS_ICONS = {
108
+ // Social media icons
109
+ twitter: _s('<path d="M2 13c2.5 0 4.5-1 5.5-3 1 2 3.5 3 6.5 1"/><path d="M14 3c-1 .5-2 .8-3 .8C10 3 8.5 2.5 7 3.5c-1.5 1-1.5 3-.5 4"/>'),
110
+ x: `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M9.47 6.77L14.18 1.5h-1.11L8.97 6.03 5.82 1.5H1.5l4.95 7.2L1.5 14.5h1.11l4.33-5.03 3.46 5.03h4.32L9.47 6.77zm-1.53 1.78l-.5-.72L3.11 2.36h1.72l3.23 4.62.5.72 4.2 6.01h-1.72L7.94 8.55z"/></svg>`,
111
+ instagram: `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="12" height="12" rx="3"/><circle cx="8" cy="8" r="3"/><circle cx="11.5" cy="4.5" r="0.5" fill="currentColor" stroke="none"/></svg>`,
112
+ facebook: `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M14 8a6 6 0 10-6.94 5.93v-4.2H5.63V8h1.43V6.56c0-1.41.84-2.19 2.13-2.19.62 0 1.26.11 1.26.11v1.39h-.71c-.7 0-.92.43-.92.88V8h1.56l-.25 1.73H8.82v4.2A6 6 0 0014 8z"/></svg>`,
113
+ linkedin: `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M2 3.47c0-.8.66-1.47 1.47-1.47s1.47.66 1.47 1.47S4.28 4.93 3.47 4.93 2 4.28 2 3.47zM2.2 6h2.53v8H2.2V6zm4.27 0h2.43v1.09c.34-.64 1.16-1.3 2.4-1.3 2.56 0 3.03 1.69 3.03 3.88V14h-2.53V10.1c0-.93-.02-2.13-1.3-2.13-1.3 0-1.5 1.01-1.5 2.06V14H6.47V6z"/></svg>`,
114
+ tiktok: `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M11.73 3.32A3.05 3.05 0 0110.67 1h-2.2v9.48a1.82 1.82 0 01-1.82 1.67 1.82 1.82 0 01-.83-3.44V6.46a4.06 4.06 0 00-.83-.08A4.07 4.07 0 001 10.44 4.07 4.07 0 005 14.5a4.07 4.07 0 004.06-4.06V5.6a5.28 5.28 0 003.08 1v-2.2a3.05 3.05 0 01-.41-1.08z"/></svg>`,
115
+ youtube: `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M14.06 4.88a1.78 1.78 0 00-1.25-1.26C11.73 3.33 8 3.33 8 3.33s-3.73 0-4.81.29A1.78 1.78 0 001.94 4.88 18.7 18.7 0 001.65 8c-.02 1.06.08 2.12.29 3.12a1.78 1.78 0 001.25 1.26c1.08.29 4.81.29 4.81.29s3.73 0 4.81-.29a1.78 1.78 0 001.25-1.26c.21-1 .31-2.06.29-3.12.02-1.06-.08-2.12-.29-3.12zM6.6 10.15V5.85L10.24 8 6.6 10.15z"/></svg>`,
116
+ whatsapp: `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8.02 1.5A6.14 6.14 0 001.5 7.5a6.1 6.1 0 00.94 3.26L1.5 14.5l3.88-.94A6.14 6.14 0 0014.5 7.5 6.14 6.14 0 008.02 1.5zm3.57 8.68c-.15.43-.9.82-1.24.87-.34.05-.66.17-2.22-.46s-2.52-2.3-2.6-2.4c-.07-.1-.6-.8-.6-1.53s.38-1.08.52-1.23c.13-.15.3-.18.39-.18h.28c.09 0 .22-.03.34.26s.42 1.02.46 1.1c.04.07.06.15.01.24-.05.1-.07.15-.15.23-.07.08-.15.18-.22.24-.07.07-.15.15-.07.3.09.15.38.63.82 1.02.56.5 1.04.66 1.19.73.15.07.23.06.32-.04.09-.1.38-.43.48-.58.1-.15.2-.13.34-.07.13.05.85.4.99.47.15.07.24.11.28.17.04.07.04.4-.11.82z"/></svg>`,
117
+ discord: `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M12.36 3.8A11.5 11.5 0 009.5 2.93a.04.04 0 00-.05.02c-.12.22-.26.52-.36.75a10.6 10.6 0 00-3.18 0A7.4 7.4 0 005.55 2.95a.04.04 0 00-.05-.02 11.5 11.5 0 00-2.86.87.04.04 0 00-.02.01A11.7 11.7 0 001 10.98a.05.05 0 00.02.03 11.6 11.6 0 003.5 1.77.05.05 0 00.05-.01c.27-.37.51-.76.72-1.17a.04.04 0 00-.02-.06 7.6 7.6 0 01-1.1-.52.04.04 0 01-.004-.07l.22-.17a.04.04 0 01.05-.01 8.3 8.3 0 007.12 0 .04.04 0 01.05.01l.22.17a.04.04 0 01-.003.07c-.35.2-.72.37-1.1.52a.04.04 0 00-.02.06c.21.41.45.8.72 1.17a.05.05 0 00.05.02 11.56 11.56 0 003.5-1.78.05.05 0 00.02-.03c.29-2.97-.48-5.55-2.04-7.84a.04.04 0 00-.02-.02zM5.68 9.5c-.68 0-1.24-.63-1.24-1.4s.55-1.4 1.24-1.4c.7 0 1.25.63 1.24 1.4 0 .77-.55 1.4-1.24 1.4zm4.58 0c-.68 0-1.24-.63-1.24-1.4s.55-1.4 1.24-1.4c.7 0 1.25.63 1.24 1.4 0 .77-.54 1.4-1.24 1.4z"/></svg>`,
118
+
119
+ // General purpose icons
120
+ search: _s('<circle cx="7" cy="7" r="4"/><path d="M14 14l-3.5-3.5"/>'),
121
+ research: _s('<path d="M3 2h10M3 6h10M3 10h7"/><circle cx="13" cy="12" r="2"/><path d="M11.5 10.5l-1-1"/>'),
122
+ chart: _s('<path d="M3 14V8M7 14V4M11 14V9M15 14V6"/><path d="M1 14h14"/>'),
123
+ mail: _s('<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M2 5l6 4 6-4"/>'),
124
+ globe: _s('<circle cx="8" cy="8" r="6"/><path d="M2 8h12M8 2c-2 2-2 10 0 12M8 2c2 2 2 10 0 12"/>'),
125
+ monitor: _s('<rect x="2" y="2" width="12" height="9" rx="1.5"/><path d="M5 14h6M8 11v3"/>'),
126
+ code: _s('<polyline points="5 4.5 2 8 5 11.5"/><polyline points="11 4.5 14 8 11 11.5"/>'),
127
+ pencil: _s('<path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z"/>'),
128
+ chat: _s('<path d="M3 3h10a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7l-3 3v-3H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/>'),
129
+ refresh: _s('<path d="M3 8a5 5 0 0 1 9-3M13 8a5 5 0 0 1-9 3"/><polyline points="3 3 3 6 6 6"/><polyline points="13 13 13 10 10 10"/>'),
130
+ brain: _s('<path d="M8 2C6 2 4.5 3.5 4.5 5c-1.5.5-2.5 2-2 3.5.5 1.5 2 2.5 3.5 2.5M8 2c2 0 3.5 1.5 3.5 3 1.5.5 2.5 2 2 3.5-.5 1.5-2 2.5-3.5 2.5M8 2v12"/><path d="M5 8h6"/>'),
131
+ plus: _s('<path d="M8 3v10M3 8h10"/>'),
132
+ bolt: _s('<path d="M9 2L4 9h4l-1 5 5-7H8l1-5z"/>'),
133
+ blank: _s('<rect x="3" y="2" width="10" height="12" rx="1.5"/><path d="M6 6h4M6 9h2"/>'),
134
+
135
+ clock: _s('<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 2.5"/>'),
136
+
137
+ // External SVG logos (fill-based, from public/ directory)
138
+ claude: `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" fill-rule="evenodd"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>`,
139
+ openai: `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" fill-rule="evenodd"><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"/></svg>`,
140
+ gemini: `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" fill-rule="evenodd"><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"/></svg>`,
141
+ leonardo: `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M9.5 2l1.5 3.5L14.5 7l-3.5 1.5L9.5 12l-1.5-3.5L4.5 7l3.5-1.5L9.5 2zM19 10l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2zM5 17l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2z"/></svg>`,
142
+ pin: _s('<path d="M8 14v-3"/><path d="M4 11h8"/><path d="M10.5 2.5l3 3-2 2 .5 2-4.5.5.5-4.5 2-.5z"/>'),
143
+
144
+ // UI chrome
145
+ back: _s('<path d="M10 3L5 8l5 5"/>'),
146
+ forward: _s('<path d="M6 3l5 5-5 5"/>'),
147
+ down: _s('<path d="M3 6l5 5 5-5"/>'),
148
+ close: _s('<path d="M4 4l8 8M12 4l-8 8"/>'),
149
+ export: _s('<path d="M8 2v8M4 6l4-4 4 4"/><path d="M2 11v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2"/>'),
150
+ };
151
+
152
+ /** Resolve icon key → SVG HTML. Falls back to refresh icon for unknown keys. */
153
+ function icon(key) { return AS_ICONS[key] || AS_ICONS.refresh; }
154
+
155
+ /** Icon keys available for template selection (excludes UI-only icons). */
156
+ const ICON_KEYS = Object.keys(AS_ICONS).filter(k => !['back','forward','down','close','export','plus','bolt','blank'].includes(k));
157
+
158
+ // ── CLI profiles and model options ──
159
+ const CLI_PROFILES = [
160
+ { id: 'claude-code', label: 'Claude Code', desc: 'Anthropic' },
161
+ { id: 'codex', label: 'Codex CLI', desc: 'OpenAI' },
162
+ { id: 'gemini', label: 'Gemini CLI', desc: 'Google' },
163
+ ];
164
+
165
+ const CLI_MODELS = {
166
+ 'claude-code': [
167
+ { id: 'claude-opus-4-6', label: 'Opus 4.6', desc: 'Most capable', tier: 'top' },
168
+ { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', desc: 'Balanced', tier: 'default' },
169
+ { id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Fastest' },
170
+ ],
171
+ 'codex': [
172
+ { id: 'o3', label: 'o3', desc: 'Deep reasoning', tier: 'top' },
173
+ { id: 'o4-mini', label: 'o4-mini', desc: 'Fast reasoning', tier: 'default' },
174
+ ],
175
+ 'gemini': [
176
+ { id: 'gemini-2.5-pro', label: '2.5 Pro', desc: 'Most capable', tier: 'default' },
177
+ { id: 'gemini-2.5-flash', label: '2.5 Flash', desc: 'Lightweight' },
178
+ ],
179
+ };
180
+
181
+ // Persistent launch preferences (remembered across launches in session)
182
+ let _launchProfile = storage.getItem('as-launch-profile') || 'claude-code';
183
+ let _launchModel = storage.getItem('as-launch-model') || null;
184
+
185
+ // ── Preset Templates with Wizard Steps ──
186
+
187
+ const PRESET_TEMPLATES = [
188
+ {
189
+ id: '__preset_social',
190
+ name: 'Social Engagement',
191
+ description: 'Browse a social platform, find relevant threads, and interact with posts.',
192
+ task: 'Navigate to a social platform and engage with posts about a topic.',
193
+ context: 'Use SynaBun browser tools only.',
194
+ iterations: 50, maxMinutes: 120,
195
+ icon: 'chat', category: 'social', usesBrowser: true,
196
+ steps: [
197
+ {
198
+ title: 'Platform & Topic', subtitle: 'Where and what to engage with',
199
+ buildContent(state) {
200
+ const platform = state.platform || '';
201
+ return `
202
+ <div class="awiz-field">
203
+ <label>Platform</label>
204
+ <div class="awiz-chips" data-key="platform" data-mode="single">
205
+ ${chip('Twitter/X', platform, 'twitter')} ${chip('LinkedIn', platform, 'linkedin')}
206
+ ${chip('Facebook', platform, 'facebook')} ${chip('TikTok', platform, 'tiktok')}
207
+ ${chip('Instagram', platform, 'instagram')} ${chip('Reddit', platform, 'reddit')}
208
+ </div>
209
+ </div>
210
+ <div class="awiz-field">
211
+ <label>Topics / Hashtags</label>
212
+ <textarea data-key="topics" placeholder="e.g. #vibecoding, AI automation, building in public" rows="3">${esc(state.topics || '')}</textarea>
213
+ </div>
214
+ <div class="awiz-field">
215
+ <label>Language</label>
216
+ <input type="text" data-key="language" placeholder="e.g. English, Portuguese, match thread language" value="${esc(state.language || '')}" />
217
+ </div>`;
218
+ },
219
+ validate(state) {
220
+ if (!state.platform?.trim()) return 'Pick a platform';
221
+ if (!state.topics?.trim()) return 'Enter at least one topic or hashtag';
222
+ return null;
223
+ },
224
+ },
225
+ {
226
+ title: 'Persona & Tone', subtitle: 'How should the agent sound?',
227
+ buildContent(state) {
228
+ const tone = state.tone || 'casual';
229
+ return `
230
+ <div class="awiz-field">
231
+ <label>Tone</label>
232
+ <div class="awiz-chips" data-key="tone" data-mode="single">
233
+ ${chip('Casual', tone, 'casual')} ${chip('Professional', tone, 'professional')}
234
+ ${chip('Witty', tone, 'witty')} ${chip('Technical', tone, 'technical')}
235
+ </div>
236
+ </div>
237
+ <div class="awiz-field">
238
+ <label>Reply Length</label>
239
+ <div class="awiz-chips" data-key="replyLength" data-mode="single">
240
+ ${chip('Short (1-2 sentences)', state.replyLength || 'short', 'short')}
241
+ ${chip('Medium (3-4 sentences)', state.replyLength, 'medium')}
242
+ </div>
243
+ </div>
244
+ <div class="awiz-field">
245
+ <label>Voice Notes <span class="awiz-optional">(optional)</span></label>
246
+ <textarea data-key="voiceNotes" placeholder="e.g. Sound like a fellow developer. Be genuine, not salesy." rows="3">${esc(state.voiceNotes || '')}</textarea>
247
+ </div>`;
248
+ },
249
+ },
250
+ {
251
+ title: 'Rules', subtitle: 'Behavioral constraints \u2014 order = priority',
252
+ buildContent(state) {
253
+ if (!Array.isArray(state.rules)) state.rules = [];
254
+ const items = state.rules.map((rule, i) => `
255
+ <div class="awiz-rule-item" draggable="true" data-rule-idx="${i}">
256
+ <span class="awiz-rule-grip" data-tooltip="Drag to reorder">\u2261</span>
257
+ <span class="awiz-rule-num">${i + 1}</span>
258
+ <span class="awiz-rule-text">${esc(rule)}</span>
259
+ <button class="awiz-rule-remove" data-remove-idx="${i}" data-tooltip="Remove">&times;</button>
260
+ </div>`).join('');
261
+ return `
262
+ <div class="awiz-field">
263
+ <div class="awiz-rule-add-row">
264
+ <input type="text" id="awiz-rule-input" placeholder="e.g. Do NOT double post on the same thread" />
265
+ <button class="awiz-rule-add-btn" id="awiz-rule-add">Add</button>
266
+ </div>
267
+ <div class="awiz-rules-list" id="awiz-rules-list">${items || '<div class="awiz-rules-empty">No rules yet.</div>'}</div>
268
+ <span class="awiz-hint">Drag to reorder. Top rules have highest priority.</span>
269
+ </div>`;
270
+ },
271
+ afterRender(container, state) { wireRulesListEvents(container, state); },
272
+ },
273
+ ],
274
+ buildCommand(state) {
275
+ const platformNames = { twitter: 'Twitter/X', linkedin: 'LinkedIn', facebook: 'Facebook', tiktok: 'TikTok', instagram: 'Instagram', reddit: 'Reddit' };
276
+ const toneMap = { casual: 'casual and conversational', professional: 'professional and polished', witty: 'witty and engaging', technical: 'technical and informed' };
277
+ const lengthMap = { short: '1-2 sentences max', medium: '3-4 sentences' };
278
+ const rules = Array.isArray(state.rules) && state.rules.length > 0
279
+ ? state.rules.map((r, i) => ` ${i + 1}. ${r}`).join('\n') : null;
280
+ const lines = [
281
+ `Navigate to ${platformNames[state.platform] || state.platform || '[PLATFORM]'} and find recent posts about:`,
282
+ state.topics || '[TOPICS]', '',
283
+ state.language ? `Language: ${state.language}.` : '',
284
+ `Tone: ${toneMap[state.tone] || 'casual'}.`,
285
+ `Reply length: ${lengthMap[state.replyLength] || '1-2 sentences max'}.`,
286
+ state.voiceNotes ? `Voice: ${state.voiceNotes}` : '', '',
287
+ rules ? `RULES (ordered by priority):\n${rules}` : '',
288
+ '', 'Like posts you interact with.',
289
+ 'Be authentic. Add genuine value. Never be spammy.',
290
+ ];
291
+ return lines.filter(Boolean).join('\n');
292
+ },
293
+ },
294
+ {
295
+ id: '__preset_datacollect',
296
+ name: 'Data Collection',
297
+ description: 'Navigate websites and collect structured data into memory.',
298
+ task: 'Visit websites and extract specific data points.',
299
+ context: 'Use SynaBun browser tools to navigate. Store findings in memory.',
300
+ iterations: 20, maxMinutes: 60,
301
+ icon: 'folder', category: 'productivity', usesBrowser: true,
302
+ steps: [
303
+ {
304
+ title: 'Source', subtitle: 'Where to collect data from',
305
+ buildContent(state) {
306
+ return `
307
+ <div class="awiz-field">
308
+ <label>Starting URL</label>
309
+ <input type="text" data-key="startUrl" placeholder="https://example.com/listings" value="${esc(state.startUrl || '')}" />
310
+ </div>
311
+ <div class="awiz-field">
312
+ <label>What to Collect</label>
313
+ <textarea data-key="collectWhat" placeholder="e.g. Product names, prices, and URLs from each listing" rows="3">${esc(state.collectWhat || '')}</textarea>
314
+ </div>`;
315
+ },
316
+ validate(state) {
317
+ if (!state.startUrl?.trim()) return 'Enter a starting URL';
318
+ if (!state.collectWhat?.trim()) return 'Describe what data to collect';
319
+ return null;
320
+ },
321
+ },
322
+ {
323
+ title: 'Memory Storage', subtitle: 'Where to save collected data in SynaBun',
324
+ buildContent(state) {
325
+ return `
326
+ <div class="awiz-field">
327
+ <label>Category</label>
328
+ <input type="text" data-key="storageCategory" placeholder="e.g. price-tracking" value="${esc(state.storageCategory || '')}" />
329
+ <span class="awiz-hint">Child category under <strong>research</strong></span>
330
+ </div>
331
+ <div class="awiz-field">
332
+ <label>Tags</label>
333
+ <input type="text" data-key="storageTags" placeholder="e.g. data-collection, prices" value="${esc(state.storageTags || '')}" />
334
+ </div>`;
335
+ },
336
+ validate(state) {
337
+ if (!state.storageCategory?.trim()) return 'Choose a category for the data';
338
+ return null;
339
+ },
340
+ },
341
+ ],
342
+ buildCommand(state) {
343
+ const cat = (state.storageCategory || 'collected-data').trim().toLowerCase().replace(/\s+/g, '-');
344
+ const tags = state.storageTags ? state.storageTags.split(',').map(t => `"${t.trim()}"`).join(', ') : '"data-collection"';
345
+ const lines = [
346
+ `Navigate to: ${state.startUrl || '[URL]'}`,
347
+ `Collect: ${state.collectWhat || '[DESCRIBE DATA]'}`, '',
348
+ 'For each page, extract the requested data points.',
349
+ 'Paginate or scroll to load more results as needed.', '',
350
+ 'MEMORY STORAGE (MANDATORY):',
351
+ 'After each iteration, store collected data using the SynaBun `remember` tool:',
352
+ `- category: "${cat}"`,
353
+ `- tags: [${tags}]`,
354
+ '- importance: 6',
355
+ '- Content: Structured list of collected items with all requested fields.',
356
+ `If category "${cat}" does not exist, first call \`category\` with action "create", name "${cat}", parent "research".`,
357
+ 'Do NOT skip the memory storage step.',
358
+ ];
359
+ return lines.filter(Boolean).join('\n');
360
+ },
361
+ },
362
+ {
363
+ id: '__preset_codereview',
364
+ name: 'Code Review',
365
+ description: 'Scan codebase for issues, code smells, and improvement opportunities.',
366
+ task: 'Review code for bugs, security issues, and performance problems.',
367
+ context: 'Prioritize high-impact issues. Be constructive.',
368
+ iterations: 10, maxMinutes: 45,
369
+ icon: 'search', category: 'productivity', usesBrowser: false,
370
+ steps: [
371
+ {
372
+ title: 'Target', subtitle: 'What code should Claude review?',
373
+ buildContent(state) {
374
+ const areas = state.focusAreas || ['bugs', 'security'];
375
+ return `
376
+ <div class="awiz-field">
377
+ <label>Directory or File Path</label>
378
+ <input type="text" data-key="targetPath" placeholder="src/components/ or src/lib/services/auth.ts" value="${esc(state.targetPath || '')}" />
379
+ <span class="awiz-hint">Leave empty to scan the entire project</span>
380
+ </div>
381
+ <div class="awiz-field">
382
+ <label>Language / Framework</label>
383
+ <input type="text" data-key="techStack" placeholder="TypeScript, React, Next.js" value="${esc(state.techStack || '')}" />
384
+ </div>
385
+ <div class="awiz-field">
386
+ <label>Focus Areas</label>
387
+ <div class="awiz-chips" data-key="focusAreas" data-mode="multi">
388
+ ${mchip('Bugs', areas, 'bugs')} ${mchip('Security', areas, 'security')}
389
+ ${mchip('Performance', areas, 'performance')} ${mchip('Readability', areas, 'readability')}
390
+ </div>
391
+ </div>`;
392
+ },
393
+ },
394
+ {
395
+ title: 'Standards', subtitle: 'How thorough should the review be?',
396
+ buildContent(state) {
397
+ const severity = state.severity || 'all';
398
+ return `
399
+ <div class="awiz-field">
400
+ <label>Severity Threshold</label>
401
+ <div class="awiz-chips" data-key="severity" data-mode="single">
402
+ ${chip('Critical Only', severity, 'critical')} ${chip('All Issues', severity, 'all')}
403
+ </div>
404
+ </div>
405
+ <div class="awiz-field">
406
+ <label>Style Guide Notes <span class="awiz-optional">(optional)</span></label>
407
+ <textarea data-key="styleNotes" placeholder="e.g. Follow ESLint config, prefer named exports" rows="3">${esc(state.styleNotes || '')}</textarea>
408
+ </div>
409
+ <div class="awiz-field">
410
+ <label>Skip / Ignore <span class="awiz-optional">(optional)</span></label>
411
+ <input type="text" data-key="skipPatterns" placeholder="test files, generated code" value="${esc(state.skipPatterns || '')}" />
412
+ </div>`;
413
+ },
414
+ },
415
+ ],
416
+ buildCommand(state) {
417
+ const lines = [
418
+ state.targetPath ? `Review code in: ${state.targetPath}.` : 'Scan the codebase for issues.',
419
+ state.techStack ? `Tech stack: ${state.techStack}.` : '',
420
+ `Focus on: ${(state.focusAreas || ['bugs', 'security']).join(', ')}.`,
421
+ `Severity: ${state.severity === 'critical' ? 'critical issues only' : 'all issues'}.`,
422
+ state.styleNotes ? `Style guidelines: ${state.styleNotes}` : '',
423
+ state.skipPatterns ? `Skip: ${state.skipPatterns}.` : '',
424
+ 'Document findings with file paths and line numbers. Suggest specific fixes.',
425
+ ];
426
+ return lines.filter(Boolean).join('\n');
427
+ },
428
+ },
429
+ {
430
+ id: '__preset_research',
431
+ name: 'Content Research',
432
+ description: 'Search topics, summarize findings, and collect useful references.',
433
+ task: 'Research topics, summarize findings, collect references.',
434
+ context: 'Focus on authoritative sources. Note conflicting viewpoints.',
435
+ iterations: 8, maxMinutes: 30,
436
+ icon: 'research', category: 'productivity', usesBrowser: true,
437
+ steps: [
438
+ {
439
+ title: 'Topic', subtitle: 'Describe what Claude should research',
440
+ buildContent(state) {
441
+ const sources = state.sources || ['blogs', 'docs'];
442
+ return `
443
+ <div class="awiz-field">
444
+ <label>Research Description</label>
445
+ <textarea data-key="subject" placeholder="e.g. Find the latest WebSocket scaling patterns for Node.js." rows="4">${esc(state.subject || '')}</textarea>
446
+ </div>
447
+ <div class="awiz-field">
448
+ <label>Source Preferences</label>
449
+ <div class="awiz-chips" data-key="sources" data-mode="multi">
450
+ ${mchip('Academic', sources, 'academic')} ${mchip('Blogs', sources, 'blogs')}
451
+ ${mchip('Forums', sources, 'forums')} ${mchip('Docs', sources, 'docs')}
452
+ </div>
453
+ </div>`;
454
+ },
455
+ validate(state) { if (!state.subject?.trim()) return 'Describe what to research'; return null; },
456
+ },
457
+ {
458
+ title: 'Rules', subtitle: 'Define rules for the research \u2014 order = priority',
459
+ buildContent(state) {
460
+ if (!Array.isArray(state.rules)) state.rules = [];
461
+ const items = state.rules.map((rule, i) => `
462
+ <div class="awiz-rule-item" draggable="true" data-rule-idx="${i}">
463
+ <span class="awiz-rule-grip" data-tooltip="Drag to reorder">\u2261</span>
464
+ <span class="awiz-rule-num">${i + 1}</span>
465
+ <span class="awiz-rule-text">${esc(rule)}</span>
466
+ <button class="awiz-rule-remove" data-remove-idx="${i}" data-tooltip="Remove">&times;</button>
467
+ </div>`).join('');
468
+ return `
469
+ <div class="awiz-field">
470
+ <div class="awiz-rule-add-row">
471
+ <input type="text" id="awiz-rule-input" placeholder="e.g. Only use sources from the last 2 years" />
472
+ <button class="awiz-rule-add-btn" id="awiz-rule-add">Add</button>
473
+ </div>
474
+ <div class="awiz-rules-list" id="awiz-rules-list">${items || '<div class="awiz-rules-empty">No rules yet.</div>'}</div>
475
+ <span class="awiz-hint">Drag to reorder. Top rules have highest priority.</span>
476
+ </div>`;
477
+ },
478
+ afterRender(container, state) { wireRulesListEvents(container, state); },
479
+ },
480
+ {
481
+ title: 'Output', subtitle: 'How should findings be formatted?',
482
+ buildContent(state) {
483
+ const format = state.outputFormat || 'bullets';
484
+ const depth = state.depth || 'moderate';
485
+ return `
486
+ <div class="awiz-field">
487
+ <label>Format</label>
488
+ <div class="awiz-chips" data-key="outputFormat" data-mode="single">
489
+ ${chip('Bullet Summary', format, 'bullets')} ${chip('Detailed Notes', format, 'detailed')} ${chip('Annotated Links', format, 'links')}
490
+ </div>
491
+ </div>
492
+ <div class="awiz-field">
493
+ <label>Depth</label>
494
+ <div class="awiz-chips" data-key="depth" data-mode="single">
495
+ ${chip('Quick Scan', depth, 'quick')} ${chip('Moderate', depth, 'moderate')} ${chip('Deep Dive', depth, 'deep')}
496
+ </div>
497
+ </div>`;
498
+ },
499
+ },
500
+ {
501
+ title: 'Memory Storage', subtitle: 'Where should findings be saved in SynaBun?',
502
+ buildContent(state) {
503
+ return `
504
+ <div class="awiz-field">
505
+ <label>Research Name</label>
506
+ <input type="text" data-key="researchName" placeholder="e.g. WebSocket Scaling Patterns" value="${esc(state.researchName || '')}" />
507
+ <span class="awiz-hint">A short name to identify this research batch</span>
508
+ </div>
509
+ <div class="awiz-field">
510
+ <label>Category</label>
511
+ <input type="text" data-key="researchCategory" placeholder="e.g. websocket-scaling" value="${esc(state.researchCategory || '')}" />
512
+ <span class="awiz-hint">Child category under <strong>research</strong></span>
513
+ </div>`;
514
+ },
515
+ validate(state) {
516
+ if (!state.researchName?.trim()) return 'Give this research a name';
517
+ if (!state.researchCategory?.trim()) return 'Choose a category for the findings';
518
+ return null;
519
+ },
520
+ },
521
+ ],
522
+ buildCommand(state) {
523
+ const cat = (state.researchCategory || 'general').trim().toLowerCase().replace(/\s+/g, '-');
524
+ const rules = Array.isArray(state.rules) && state.rules.length > 0
525
+ ? state.rules.map((r, i) => ` ${i + 1}. ${r}`).join('\n') : null;
526
+ const lines = [
527
+ `Research: ${state.subject || 'the assigned topic'}.`,
528
+ rules ? `RULES (ordered by priority \u2014 #1 is most important):\n${rules}` : '',
529
+ `Preferred sources: ${(state.sources || ['blogs', 'docs']).join(', ')}.`,
530
+ `Output format: ${state.outputFormat === 'detailed' ? 'detailed notes' : state.outputFormat === 'links' ? 'annotated links' : 'bullet summaries'}.`,
531
+ `Depth: ${state.depth === 'deep' ? 'deep dive' : state.depth === 'quick' ? 'quick scan' : 'moderate'}.`,
532
+ 'Use browser tools to search the web. Prioritize authoritative sources.', '',
533
+ 'MEMORY STORAGE (MANDATORY):',
534
+ `After each iteration, store your findings using the SynaBun \`remember\` tool with these EXACT parameters:`,
535
+ `- category: "${cat}"`,
536
+ `- tags: ["research", "${esc(state.researchName || 'unnamed').toLowerCase().replace(/\s+/g, '-')}"]`,
537
+ `- importance: 7`,
538
+ `- Content: Include the research name "${esc(state.researchName || '')}" as a header, then your findings for that iteration.`,
539
+ `If category "${cat}" does not exist, first call the \`category\` tool with action "create", name "${cat}", parent "research".`,
540
+ 'Do NOT skip the memory storage step. Every iteration MUST produce at least one memory entry.',
541
+ ];
542
+ return lines.filter(Boolean).join('\n');
543
+ },
544
+ },
545
+ {
546
+ id: '__preset_monitoring',
547
+ name: 'Site Monitoring',
548
+ description: 'Check URLs for changes, errors, and performance issues.',
549
+ task: 'Check sites for uptime, errors, and visual changes.',
550
+ context: 'Record response times. Screenshot regressions.',
551
+ iterations: 5, maxMinutes: 15,
552
+ icon: 'chart', category: 'monitoring', usesBrowser: true,
553
+ steps: [
554
+ {
555
+ title: 'Targets', subtitle: 'What URLs should Claude monitor?',
556
+ buildContent(state) {
557
+ const checks = state.checks || ['uptime', 'performance'];
558
+ return `
559
+ <div class="awiz-field">
560
+ <label>URLs to Monitor</label>
561
+ <textarea data-key="urls" placeholder="https://example.com\nhttps://api.example.com/health" rows="4">${esc(state.urls || '')}</textarea>
562
+ <span class="awiz-hint">One URL per line</span>
563
+ </div>
564
+ <div class="awiz-field">
565
+ <label>What to Check</label>
566
+ <div class="awiz-chips" data-key="checks" data-mode="multi">
567
+ ${mchip('Uptime', checks, 'uptime')} ${mchip('Visual Changes', checks, 'visual')}
568
+ ${mchip('Performance', checks, 'performance')} ${mchip('SSL', checks, 'ssl')}
569
+ </div>
570
+ </div>`;
571
+ },
572
+ validate(state) { if (!state.urls?.trim()) return 'Enter at least one URL'; return null; },
573
+ },
574
+ {
575
+ title: 'Alerts', subtitle: 'What thresholds and behaviors to use?',
576
+ buildContent(state) {
577
+ return `
578
+ <div class="awiz-field">
579
+ <label>Response Time Threshold (ms)</label>
580
+ <input type="number" data-key="threshold" placeholder="3000" value="${state.threshold || ''}" />
581
+ <span class="awiz-hint">Flag pages slower than this (default: 3000ms)</span>
582
+ </div>
583
+ <div class="awiz-field">
584
+ <label>Screenshot on Change</label>
585
+ <div class="awiz-chips" data-key="screenshotOnChange" data-mode="single">
586
+ ${chip('Yes', state.screenshotOnChange ?? 'yes', 'yes')} ${chip('No', state.screenshotOnChange, 'no')}
587
+ </div>
588
+ </div>`;
589
+ },
590
+ },
591
+ ],
592
+ buildCommand(state) {
593
+ const lines = [
594
+ `Monitor these URLs:\n${state.urls || '(none specified)'}`,
595
+ `Check for: ${(state.checks || ['uptime', 'performance']).join(', ')}.`,
596
+ state.threshold ? `Flag responses slower than ${state.threshold}ms.` : '',
597
+ (state.screenshotOnChange ?? 'yes') === 'yes' ? 'Take screenshots on visual changes.' : '',
598
+ 'Use browser tools to visit each URL. Report issues with evidence.',
599
+ ];
600
+ return lines.filter(Boolean).join('\n');
601
+ },
602
+ },
603
+ {
604
+ id: '__preset_inbox',
605
+ name: 'Inbox Triage',
606
+ description: 'Process messages, categorize by priority, and draft replies.',
607
+ task: 'Triage inbox: categorize by urgency, draft replies.',
608
+ context: 'Be concise. Prioritize time-sensitive items.',
609
+ iterations: 10, maxMinutes: 30,
610
+ icon: 'mail', category: 'productivity', usesBrowser: false,
611
+ steps: [
612
+ {
613
+ title: 'Source', subtitle: 'Where are the messages coming from?',
614
+ buildContent(state) {
615
+ const platform = state.inboxPlatform || 'email';
616
+ return `
617
+ <div class="awiz-field">
618
+ <label>Platform</label>
619
+ <div class="awiz-chips" data-key="inboxPlatform" data-mode="single">
620
+ ${chip('Email', platform, 'email')} ${chip('Slack', platform, 'slack')}
621
+ ${chip('Discord', platform, 'discord')} ${chip('GitHub', platform, 'github')}
622
+ </div>
623
+ </div>
624
+ <div class="awiz-field">
625
+ <label>Priority Rules <span class="awiz-optional">(optional)</span></label>
626
+ <textarea data-key="priorityRules" placeholder="e.g. Messages from @boss are always urgent" rows="3">${esc(state.priorityRules || '')}</textarea>
627
+ </div>
628
+ <div class="awiz-field">
629
+ <label>Auto-Reply</label>
630
+ <div class="awiz-chips" data-key="autoReply" data-mode="single">
631
+ ${chip('Draft Only', state.autoReply || 'draft', 'draft')}
632
+ ${chip('Auto-Send Low Priority', state.autoReply, 'auto-low')}
633
+ </div>
634
+ </div>`;
635
+ },
636
+ },
637
+ {
638
+ title: 'Actions', subtitle: 'What should Claude do with each message?',
639
+ buildContent(state) {
640
+ const draftStyle = state.draftStyle || 'concise';
641
+ return `
642
+ <div class="awiz-field">
643
+ <label>Draft Style</label>
644
+ <div class="awiz-chips" data-key="draftStyle" data-mode="single">
645
+ ${chip('Concise', draftStyle, 'concise')} ${chip('Detailed', draftStyle, 'detailed')} ${chip('Friendly', draftStyle, 'friendly')}
646
+ </div>
647
+ </div>
648
+ <div class="awiz-field">
649
+ <label>Escalation Rules <span class="awiz-optional">(optional)</span></label>
650
+ <textarea data-key="escalation" placeholder="e.g. Escalate billing issues to finance" rows="3">${esc(state.escalation || '')}</textarea>
651
+ </div>`;
652
+ },
653
+ },
654
+ ],
655
+ buildCommand(state) {
656
+ const lines = [
657
+ `Triage ${state.inboxPlatform || 'email'} inbox.`,
658
+ 'Categorize each message: urgent, normal, low.',
659
+ state.priorityRules ? `Priority rules:\n${state.priorityRules}` : '',
660
+ `Reply mode: ${state.autoReply === 'auto-low' ? 'auto-send low-priority, draft rest' : 'draft only'}.`,
661
+ `Draft style: ${state.draftStyle || 'concise'}.`,
662
+ state.escalation ? `Escalation:\n${state.escalation}` : '',
663
+ 'Flag items requiring human decision.',
664
+ ];
665
+ return lines.filter(Boolean).join('\n');
666
+ },
667
+ },
668
+ ];
669
+
670
+ // ═══════════════════════════════════════════
671
+ // HELPERS
672
+ // ═══════════════════════════════════════════
673
+
674
+ function esc(str) {
675
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
676
+ }
677
+
678
+ function chip(label, currentVal, value) {
679
+ const v = value ?? label;
680
+ const active = (typeof currentVal === 'string' && currentVal.toLowerCase() === v.toLowerCase()) ? 'active' : '';
681
+ return `<button class="awiz-chip ${active}" data-value="${esc(v)}">${esc(label)}</button>`;
682
+ }
683
+
684
+ function mchip(label, currentArr, value) {
685
+ const active = Array.isArray(currentArr) && currentArr.includes(value) ? 'active' : '';
686
+ return `<button class="awiz-chip ${active}" data-value="${esc(value)}">${esc(label)}</button>`;
687
+ }
688
+
689
+ function showToast(msg) {
690
+ const existing = document.querySelector('.as-toast');
691
+ if (existing) existing.remove();
692
+ const toast = document.createElement('div');
693
+ toast.className = 'as-toast';
694
+ toast.textContent = msg;
695
+ document.body.appendChild(toast);
696
+ requestAnimationFrame(() => toast.classList.add('show'));
697
+ setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 3500);
698
+ }
699
+
700
+ function formatLoopCommand(taskText, state) {
701
+ const lines = ['Start a loop with these settings:', `Task: ${taskText}`];
702
+ if (state.usesBrowser) {
703
+ lines.push('', 'BROWSER: This automation REQUIRES the SynaBun internal browser.',
704
+ 'Use ONLY SynaBun browser tools (browser_navigate, browser_go_back, browser_go_forward, browser_reload, browser_click, browser_fill, browser_type, browser_hover, browser_select, browser_press, browser_scroll, browser_upload, browser_snapshot, browser_content, browser_screenshot, browser_evaluate, browser_wait, browser_session, browser_extract_tweets, browser_extract_fb_posts, browser_extract_tiktok_videos, browser_extract_tiktok_search, browser_extract_tiktok_studio, browser_extract_tiktok_profile, browser_extract_wa_chats, browser_extract_wa_messages, browser_extract_ig_feed, browser_extract_ig_profile, browser_extract_ig_post, browser_extract_ig_reels, browser_extract_ig_search, browser_extract_li_feed, browser_extract_li_profile, browser_extract_li_post, browser_extract_li_notifications, browser_extract_li_messages, browser_extract_li_search_people, browser_extract_li_network, browser_extract_li_jobs).',
705
+ 'Start by calling browser_navigate with your target URL — it auto-creates a session.',
706
+ 'NEVER use Playwright plugin tools or WebFetch — they bypass the visible browser.');
707
+ }
708
+ lines.push(`Iterations: ${state.iterations}`);
709
+ lines.push(`Time cap: ${state.maxMinutes} minutes`);
710
+ return lines.join('\n');
711
+ }
712
+
713
+ const BROWSER_CONTEXT = 'BROWSER REQUIRED: Use ONLY SynaBun browser tools: browser_navigate, browser_go_back, browser_go_forward, browser_reload, browser_click, browser_fill, browser_type, browser_hover, browser_select, browser_press, browser_scroll, browser_upload, browser_snapshot, browser_content, browser_screenshot, browser_evaluate, browser_wait, browser_session, browser_extract_tweets, browser_extract_fb_posts, browser_extract_tiktok_videos, browser_extract_tiktok_search, browser_extract_tiktok_studio, browser_extract_tiktok_profile, browser_extract_wa_chats, browser_extract_wa_messages, browser_extract_ig_feed, browser_extract_ig_profile, browser_extract_ig_post, browser_extract_ig_reels, browser_extract_ig_search, browser_extract_li_feed, browser_extract_li_profile, browser_extract_li_post, browser_extract_li_notifications, browser_extract_li_messages, browser_extract_li_search_people, browser_extract_li_network, browser_extract_li_jobs. Start by calling browser_navigate with your target URL — it auto-creates a session. NEVER use Playwright plugin tools or WebFetch.';
714
+
715
+ // ── Inline launch panel ──
716
+ // Instead of an overlay modal, swap the detail content area with launch configuration
717
+
718
+ let _pendingLaunchParams = null; // stashed params while launch panel is open
719
+ let _launchPanelActive = false; // whether the inline launch panel is showing
720
+ // Browser mode removed — automations use saved browser settings directly
721
+
722
+ function showLaunchInline(params) {
723
+ // Enforce browser context before stashing
724
+ if (params.usesBrowser && (!params.context || !params.context.includes('BROWSER REQUIRED'))) {
725
+ params.context = BROWSER_CONTEXT;
726
+ }
727
+ _pendingLaunchParams = params;
728
+ _launchPanelActive = true;
729
+
730
+ // Render into detail content area if available, otherwise take over main area
731
+ let contentEl = _panel?.querySelector('.as-detail-content');
732
+ if (!contentEl) {
733
+ // Non-detail context (wizard, welcome) — switch to a standalone launch view
734
+ _view = 'launch';
735
+ const main = $('as-main');
736
+ if (!main) return;
737
+ main.innerHTML = `<div class="as-detail-content as-launch-standalone"></div>`;
738
+ contentEl = main.querySelector('.as-detail-content');
739
+ }
740
+
741
+ const profile = _launchProfile || 'claude-code';
742
+ const models = CLI_MODELS[profile] || [];
743
+ const defaultModel = models.find(m => m.tier === 'default') || models[0];
744
+ const currentModel = _launchModel && models.some(m => m.id === _launchModel) ? _launchModel : defaultModel?.id;
745
+
746
+ const iterCount = params.iterations || 10;
747
+ const timeCount = params.maxMinutes || 30;
748
+
749
+ contentEl.innerHTML = `
750
+ <div class="as-launch-inline">
751
+ <div class="as-launch-inline-header">
752
+ <button class="as-launch-inline-back" data-action="launch-inline-close">
753
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M9 2L4 7l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
754
+ </button>
755
+ <span class="as-launch-inline-title">Launch Configuration</span>
756
+ </div>
757
+ <div class="as-launch-inline-body">
758
+ <div class="as-launch-section">
759
+ <label class="as-launch-label">Mode</label>
760
+ <div class="as-launch-mode-toggle">
761
+ <button class="as-launch-mode${_launchMode === 'loop' ? ' active' : ''}" data-mode="loop">
762
+ <span class="as-launch-mode-icon"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 8A6 6 0 1 1 8 2"/><polyline points="14 2 14 8 8 8"/></svg></span>
763
+ Loop
764
+ </button>
765
+ <button class="as-launch-mode${_launchMode === 'agent' ? ' active' : ''}" data-mode="agent">
766
+ <span class="as-launch-mode-icon"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="5" r="3"/><path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6"/></svg></span>
767
+ Agent
768
+ </button>
769
+ </div>
770
+ </div>
771
+ <div class="as-launch-section">
772
+ <label class="as-launch-label">CLI</label>
773
+ <div class="as-launch-profiles" id="as-launch-profiles">
774
+ ${CLI_PROFILES.map(p => `
775
+ <button class="as-launch-profile${p.id === profile ? ' active' : ''}" data-profile="${p.id}">
776
+ <span class="as-launch-profile-name">${p.label}</span>
777
+ <span class="as-launch-profile-org">${p.desc}</span>
778
+ </button>
779
+ `).join('')}
780
+ </div>
781
+ </div>
782
+ <div class="as-launch-section" id="as-launch-model-section">
783
+ <label class="as-launch-label">Model</label>
784
+ <div class="as-launch-models" id="as-launch-models">
785
+ ${renderModelChips(profile, currentModel)}
786
+ </div>
787
+ </div>
788
+ <div class="as-launch-section as-launch-summary">
789
+ <div class="as-launch-summary-row">
790
+ <span class="as-launch-summary-key">Task</span>
791
+ <span class="as-launch-summary-val as-launch-summary-task">${esc((params.task || '').slice(0, 100))}${(params.task || '').length > 100 ? '\u2026' : ''}</span>
792
+ </div>
793
+ <div class="as-launch-loop-fields" ${_launchMode === 'agent' ? 'style="display:none"' : ''}>
794
+ <div class="as-launch-summary-row">
795
+ <span class="as-launch-summary-key">Iterations</span>
796
+ <span class="as-launch-summary-val">${iterCount}</span>
797
+ </div>
798
+ <div class="as-launch-summary-row">
799
+ <span class="as-launch-summary-key">Time cap</span>
800
+ <span class="as-launch-summary-val">${timeCount} min</span>
801
+ </div>
802
+ ${params.usesBrowser ? `<div class="as-launch-summary-row"><span class="as-launch-summary-key">Browser</span><span class="as-launch-summary-val">Yes &mdash; uses your Browser settings</span></div>` : ''}
803
+ </div>
804
+ <div class="as-launch-agent-fields" ${_launchMode === 'loop' ? 'style="display:none"' : ''}>
805
+ <div class="as-launch-summary-row">
806
+ <span class="as-launch-summary-key">SynaBun</span>
807
+ <label class="as-launch-toggle">
808
+ <input type="checkbox" id="as-agent-synabun" checked>
809
+ <span class="as-launch-toggle-label">Memory + Browser tools</span>
810
+ </label>
811
+ </div>
812
+ <div class="as-launch-summary-row">
813
+ <span class="as-launch-summary-key">Mode</span>
814
+ <div class="as-launch-agent-mode-row">
815
+ <button class="as-launch-agent-submode active" data-submode="single">Single</button>
816
+ <button class="as-launch-agent-submode" data-submode="loop">Loop</button>
817
+ </div>
818
+ </div>
819
+ <div class="as-launch-agent-loop-cfg" style="display:none">
820
+ <div class="as-launch-summary-row">
821
+ <span class="as-launch-summary-key">Iterations</span>
822
+ <input type="number" id="as-agent-iterations" value="${iterCount}" min="1" max="50" class="as-launch-inline-input">
823
+ </div>
824
+ <div class="as-launch-summary-row">
825
+ <span class="as-launch-summary-key">Time cap</span>
826
+ <input type="number" id="as-agent-maxminutes" value="${timeCount}" min="1" max="480" class="as-launch-inline-input"> <span class="as-launch-unit">min</span>
827
+ </div>
828
+ </div>
829
+ <div class="as-launch-summary-row">
830
+ <span class="as-launch-summary-key">Isolation</span>
831
+ <span class="as-launch-summary-val as-launch-isolation-badge">Process isolated &mdash; unique session per run</span>
832
+ </div>
833
+ </div>
834
+ </div>
835
+ </div>
836
+ <div class="as-launch-inline-footer">
837
+ <button class="as-launch-cancel" data-action="launch-inline-close">Cancel</button>
838
+ <button class="as-launch-go" data-action="launch-inline-confirm" id="as-launch-confirm-btn">
839
+ ${_launchMode === 'agent' ? 'Launch Agent' : 'Launch'}
840
+ </button>
841
+ </div>
842
+ </div>
843
+ `;
844
+
845
+ wireLaunchInline(contentEl);
846
+ }
847
+
848
+ function renderModelChips(profileId, selectedModel) {
849
+ const models = CLI_MODELS[profileId] || [];
850
+ if (!models.length) return '<span class="as-launch-no-models">No model selection available</span>';
851
+ return models.map(m => `
852
+ <button class="as-launch-model${m.id === selectedModel ? ' active' : ''}${m.tier ? ` as-launch-model--${m.tier}` : ''}" data-model="${m.id}">
853
+ <span class="as-launch-model-name">${m.label}</span>
854
+ <span class="as-launch-model-desc">${m.desc}</span>
855
+ </button>
856
+ `).join('');
857
+ }
858
+
859
+ function wireLaunchInline(container) {
860
+ const root = container.querySelector('.as-launch-inline');
861
+ if (!root) return;
862
+
863
+ // Mode toggle (Loop vs Agent)
864
+ root.querySelectorAll('.as-launch-mode').forEach(btn => {
865
+ btn.addEventListener('click', () => {
866
+ root.querySelectorAll('.as-launch-mode').forEach(b => b.classList.remove('active'));
867
+ btn.classList.add('active');
868
+ _launchMode = btn.dataset.mode;
869
+ const loopFields = root.querySelector('.as-launch-loop-fields');
870
+ const agentFields = root.querySelector('.as-launch-agent-fields');
871
+ if (loopFields) loopFields.style.display = _launchMode === 'loop' ? '' : 'none';
872
+ if (agentFields) agentFields.style.display = _launchMode === 'agent' ? '' : 'none';
873
+ const confirmBtn = root.querySelector('#as-launch-confirm-btn');
874
+ if (confirmBtn) confirmBtn.textContent = _launchMode === 'agent' ? 'Launch Agent' : 'Launch';
875
+ });
876
+ });
877
+
878
+ // Profile selection
879
+ root.querySelectorAll('.as-launch-profile').forEach(btn => {
880
+ btn.addEventListener('click', () => {
881
+ root.querySelectorAll('.as-launch-profile').forEach(b => b.classList.remove('active'));
882
+ btn.classList.add('active');
883
+ const profileId = btn.dataset.profile;
884
+ _launchProfile = profileId;
885
+ storage.setItem('as-launch-profile', profileId);
886
+ const models = CLI_MODELS[profileId] || [];
887
+ const defaultModel = models.find(m => m.tier === 'default') || models[0];
888
+ _launchModel = defaultModel?.id || null;
889
+ storage.setItem('as-launch-model', _launchModel);
890
+ const modelsContainer = root.querySelector('#as-launch-models');
891
+ if (modelsContainer) {
892
+ modelsContainer.innerHTML = renderModelChips(profileId, _launchModel);
893
+ wireModelChips(root);
894
+ }
895
+ });
896
+ });
897
+
898
+ wireModelChips(root);
899
+
900
+ // Agent submode toggle (Single vs Loop)
901
+ root.querySelectorAll('.as-launch-agent-submode').forEach(btn => {
902
+ btn.addEventListener('click', () => {
903
+ root.querySelectorAll('.as-launch-agent-submode').forEach(b => b.classList.remove('active'));
904
+ btn.classList.add('active');
905
+ const loopCfg = root.querySelector('.as-launch-agent-loop-cfg');
906
+ if (loopCfg) loopCfg.style.display = btn.dataset.submode === 'loop' ? '' : 'none';
907
+ });
908
+ });
909
+
910
+ // Escape key to close
911
+ const escHandler = (e) => {
912
+ if (e.key === 'Escape') { closeLaunchInline(); document.removeEventListener('keydown', escHandler); }
913
+ };
914
+ document.addEventListener('keydown', escHandler);
915
+ _docListeners.push(['keydown', escHandler]);
916
+ }
917
+
918
+ function wireModelChips(container) {
919
+ container.querySelectorAll('.as-launch-model').forEach(btn => {
920
+ btn.addEventListener('click', () => {
921
+ container.querySelectorAll('.as-launch-model').forEach(b => b.classList.remove('active'));
922
+ btn.classList.add('active');
923
+ _launchModel = btn.dataset.model;
924
+ storage.setItem('as-launch-model', _launchModel);
925
+ });
926
+ });
927
+ }
928
+
929
+ function closeLaunchInline() {
930
+ _pendingLaunchParams = null;
931
+ _launchPanelActive = false;
932
+ // Restore previous view
933
+ if (_view === 'launch') {
934
+ _view = 'welcome';
935
+ renderView();
936
+ } else if (_view === 'detail' && _selected) {
937
+ renderDetailMain();
938
+ }
939
+ }
940
+
941
+ async function confirmLaunch() {
942
+ if (!_pendingLaunchParams) return;
943
+ const params = { ..._pendingLaunchParams, profile: _launchProfile, model: _launchModel };
944
+
945
+ // Read agent values BEFORE closing (closeLaunchInline re-renders detail view)
946
+ const withSynabun = !!document.getElementById('as-agent-synabun')?.checked;
947
+ const submodeBtn = document.querySelector('.as-launch-agent-submode.active');
948
+ const agentSubmode = submodeBtn?.dataset?.submode || 'single';
949
+ const agentIterations = parseInt(document.getElementById('as-agent-iterations')?.value || '1', 10);
950
+ const agentMaxMinutes = parseInt(document.getElementById('as-agent-maxminutes')?.value || '30', 10);
951
+
952
+ // Clear launch state (don't re-render detail — we're about to navigate away)
953
+ _pendingLaunchParams = null;
954
+ _launchPanelActive = false;
955
+
956
+ // Agent mode — isolated spawn, optional SynaBun integration
957
+ if (_launchMode === 'agent') {
958
+ try {
959
+ showToast(`Launching ${agentSubmode === 'loop' ? 'loop ' : ''}agent${withSynabun ? ' with SynaBun' : ''}...`);
960
+ const result = await launchAgent({
961
+ task: params.task,
962
+ model: params.model || undefined,
963
+ cwd: params.cwd || undefined,
964
+ maxTurns: params.usesBrowser ? 100 : 75,
965
+ withSynabun,
966
+ browserProfile: (withSynabun && params.usesBrowser) ? true : undefined,
967
+ mode: agentSubmode,
968
+ iterations: agentSubmode === 'loop' ? agentIterations : 1,
969
+ maxMinutes: agentSubmode === 'loop' ? agentMaxMinutes : 30,
970
+ context: params.context || undefined,
971
+ });
972
+ if (!result?.ok) { showToast(result?.error || 'Failed to launch agent'); return; }
973
+ _agentOutputs[result.agentId] = '';
974
+ showToast('Agent launched');
975
+ closePanel();
976
+ } catch (err) { showToast('Agent launch failed: ' + (err.message || 'unknown error')); }
977
+ return;
978
+ }
979
+
980
+ // Loop mode — route based on destination
981
+ try {
982
+ // Each automation gets its own DEDICATED browser session for isolation.
983
+ // Always create a new floating browser window — never embed in side panel.
984
+ let dedicatedBrowserSessionId = null;
985
+ if (params.usesBrowser) {
986
+ // Check if screencast stream is disabled — if so, skip the client-side floating
987
+ // browser window and let the server create a headful browser via Strategy 3.
988
+ let streamDisabled = false;
989
+ try {
990
+ const cfgRes = await fetch('/api/browser/config');
991
+ const cfgData = await cfgRes.json();
992
+ streamDisabled = !!cfgData.config?.screencast?.disabled;
993
+ } catch {}
994
+
995
+ if (!streamDisabled) {
996
+ showToast('Opening browser...');
997
+ const browserReady = new Promise((resolve) => {
998
+ const unsub = on('sync:browser:created', (data) => { unsub(); resolve(data?.sessionId || null); });
999
+ setTimeout(() => { unsub(); resolve(null); }, 12000);
1000
+ });
1001
+ emit('browser:open', { url: 'about:blank', force: true });
1002
+ dedicatedBrowserSessionId = await browserReady;
1003
+ if (dedicatedBrowserSessionId) {
1004
+ console.log('[AS] confirmLaunch: created dedicated browser session', dedicatedBrowserSessionId);
1005
+ }
1006
+ } else {
1007
+ console.log('[AS] confirmLaunch: stream disabled — server will create headful browser');
1008
+ }
1009
+ }
1010
+
1011
+ // Pass the dedicated browser session ID to the server so it pins to this loop
1012
+ if (dedicatedBrowserSessionId) {
1013
+ params.browserSessionId = dedicatedBrowserSessionId;
1014
+ }
1015
+
1016
+ // Launch as floating terminal + floating browser
1017
+ showToast('Launching loop...');
1018
+ emit('terminal:expect-managed');
1019
+ console.log('[AS] confirmLaunch: calling launchLoop, profile =', params.profile, ', usesBrowser =', params.usesBrowser);
1020
+ const result = await launchLoop(params);
1021
+ console.log('[AS] confirmLaunch: launchLoop result =', JSON.stringify(result));
1022
+ if (!result?.ok) {
1023
+ emit('terminal:attach-floating', {});
1024
+ showToast(result?.error || 'Failed to launch loop');
1025
+ return;
1026
+ }
1027
+ console.log('[AS] confirmLaunch: emitting terminal:attach-floating, terminalSessionId =', result.terminalSessionId);
1028
+ emit('terminal:attach-floating', {
1029
+ terminalSessionId: result.terminalSessionId,
1030
+ profile: params.profile || 'claude-code',
1031
+ initialMessage: '[SynaBun Loop] Begin task.',
1032
+ autoSubmit: true,
1033
+ });
1034
+ const profileLabel = CLI_PROFILES.find(p => p.id === params.profile)?.label || params.profile;
1035
+ showToast(`Loop started — ${profileLabel} launching...`);
1036
+ closePanel();
1037
+ } catch (err) {
1038
+ // Clear the managed-terminal flag since we won't emit terminal:attach-floating
1039
+ emit('terminal:attach-floating', {});
1040
+ console.error('[AS] confirmLaunch: caught error:', err);
1041
+ showToast('Launch failed: ' + (err.message || 'unknown error'));
1042
+ }
1043
+ }
1044
+
1045
+ // Backward-compatible wrapper — all call sites use this, it now shows inline launch panel
1046
+ async function serverLaunchLoop(params) {
1047
+ showLaunchInline(params);
1048
+ }
1049
+
1050
+ function relativeTime(dateStr) {
1051
+ const d = new Date(dateStr);
1052
+ const diff = Date.now() - d.getTime();
1053
+ const mins = Math.floor(diff / 60000);
1054
+ if (mins < 1) return 'just now';
1055
+ if (mins < 60) return `${mins}m ago`;
1056
+ const hours = Math.floor(mins / 60);
1057
+ if (hours < 24) return `${hours}h ago`;
1058
+ const days = Math.floor(hours / 24);
1059
+ if (days < 30) return `${days}d ago`;
1060
+ return d.toLocaleDateString();
1061
+ }
1062
+
1063
+ function downloadJson(data, filename) {
1064
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
1065
+ const url = URL.createObjectURL(blob);
1066
+ const a = document.createElement('a');
1067
+ a.href = url; a.download = filename; a.click();
1068
+ URL.revokeObjectURL(url);
1069
+ }
1070
+
1071
+ function debounce(fn, ms) {
1072
+ let timer;
1073
+ return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
1074
+ }
1075
+
1076
+ function buildTemplateCommand(template) {
1077
+ const isPreset = template.id?.startsWith('__preset_');
1078
+ if (isPreset && template.buildCommand) {
1079
+ return template.buildCommand(template._lastState || {});
1080
+ }
1081
+ let cmd = template.task || '';
1082
+ if (template.context) cmd += '\n\n' + template.context;
1083
+ return cmd;
1084
+ }
1085
+
1086
+ // ═══════════════════════════════════════════
1087
+ // PANEL LIFECYCLE
1088
+ // ═══════════════════════════════════════════
1089
+
1090
+ export function initAutomationStudio() {
1091
+ on('automations:open', () => openPanel());
1092
+ on('automations:open-wizard', () => { openPanel(); });
1093
+ on('automations:open-schedules', () => { openPanel().then(() => { _view = 'schedules'; _selected = null; loadScheduleData().then(() => renderView()); }); });
1094
+ on('automations:import', () => { openPanel().then(() => triggerImport()); });
1095
+ setupAgentWebSocket();
1096
+ setupScheduleWebSocket();
1097
+ }
1098
+
1099
+ async function openPanel() {
1100
+ if (isGuest() && !hasPermission('automations')) {
1101
+ showGuestToast('Automation Studio is disabled by the host');
1102
+ return;
1103
+ }
1104
+ if (_panel) { _panel.focus(); return; }
1105
+
1106
+ // Backdrop
1107
+ _backdrop = document.createElement('div');
1108
+ _backdrop.className = 'studio-backdrop';
1109
+ // Backdrop click disabled — close only via ESC or close button
1110
+ document.body.appendChild(_backdrop);
1111
+
1112
+ _panel = document.createElement('div');
1113
+ _panel.className = 'automation-studio-panel glass resizable';
1114
+ _panel.id = 'automation-studio-panel';
1115
+ _panel.innerHTML = buildPanelHTML();
1116
+ document.body.appendChild(_panel);
1117
+
1118
+ // Always open centered at default size
1119
+ _panel.style.left = Math.max(20, (window.innerWidth - 900) / 2) + 'px';
1120
+ _panel.style.top = Math.max(48, (window.innerHeight - 520) / 2) + 'px';
1121
+
1122
+ wirePanel();
1123
+ await loadData();
1124
+ renderView();
1125
+ requestAnimationFrame(() => { _backdrop.classList.add('open'); _panel.classList.add('open'); });
1126
+ startPolling();
1127
+ }
1128
+
1129
+ function closePanel() {
1130
+ if (!_panel) return;
1131
+ stopPolling();
1132
+ // Remove all document-level listeners added during this panel's lifetime
1133
+ for (const [evt, fn] of _docListeners) document.removeEventListener(evt, fn);
1134
+ _docListeners = [];
1135
+ if (_backdrop) { _backdrop.remove(); _backdrop = null; }
1136
+ _panel.remove(); _panel = null;
1137
+ _templates = []; _activeLoop = null;
1138
+ _view = 'welcome'; _selected = null;
1139
+ resetDirty(); _editorContent = ''; _originalContent = '';
1140
+ _previewMode = false;
1141
+ _wizardState = null; _wizardStep = 0; _wizardPreset = null;
1142
+ _prevLoopActive = false; _fetchSeq = 0;
1143
+ }
1144
+
1145
+ function buildPanelHTML() {
1146
+ return `
1147
+ <div class="resize-handle resize-handle-t" data-resize="t"></div>
1148
+ <div class="resize-handle resize-handle-r" data-resize="r"></div>
1149
+ <div class="resize-handle resize-handle-b" data-resize="b"></div>
1150
+ <div class="resize-handle resize-handle-l" data-resize="l"></div>
1151
+ <div class="resize-handle resize-handle-tl" data-resize="tl"></div>
1152
+ <div class="resize-handle resize-handle-tr" data-resize="tr"></div>
1153
+ <div class="resize-handle resize-handle-bl" data-resize="bl"></div>
1154
+ <div class="resize-handle resize-handle-br" data-resize="br"></div>
1155
+
1156
+ <div class="as-header drag-handle" data-drag="automation-studio-panel">
1157
+ <div class="as-header-left">
1158
+ <button class="as-sidebar-toggle" id="as-sidebar-toggle" data-tooltip="Toggle sidebar">
1159
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 4h12M2 8h12M2 12h12"/></svg>
1160
+ </button>
1161
+ <h3>Automation Studio</h3>
1162
+ <span class="as-count" id="as-total-count"></span>
1163
+ </div>
1164
+ <div class="as-header-actions">
1165
+ <button class="as-header-btn" id="as-new-btn">+ New</button>
1166
+ <button class="as-header-btn" id="as-schedules-btn">${AS_ICONS.clock} Schedules</button>
1167
+ <button class="as-header-btn" id="as-import-btn">Import</button>
1168
+ </div>
1169
+ <button class="backdrop-toggle-btn" id="as-backdrop-toggle" data-tooltip="Toggle backdrop">
1170
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
1171
+ </button>
1172
+ <button class="as-close" id="as-close">&times;</button>
1173
+ </div>
1174
+
1175
+ <div class="as-body">
1176
+ <aside class="as-sidebar" id="as-sidebar">
1177
+ <div class="as-search-wrap">
1178
+ <input type="text" class="as-search" id="as-search" placeholder="Filter..." autocomplete="off" spellcheck="false">
1179
+ </div>
1180
+ <div class="as-filters">
1181
+ <button class="as-filter active" data-cat="all">All</button>
1182
+ <button class="as-filter" data-cat="social">Social</button>
1183
+ <button class="as-filter" data-cat="productivity">Productivity</button>
1184
+ <button class="as-filter" data-cat="monitoring">Monitoring</button>
1185
+ <button class="as-filter" data-cat="custom">Custom</button>
1186
+ </div>
1187
+ <div class="as-template-list" id="as-template-list"></div>
1188
+ <div class="as-sidebar-footer" id="as-sidebar-footer" style="display:none">
1189
+ <div class="as-loop-indicator" data-action="view-running">
1190
+ <span class="as-running-pulse"></span>
1191
+ <div class="as-loop-indicator-info">
1192
+ <span class="as-loop-indicator-name" id="as-loop-indicator-name">Loop Active</span>
1193
+ <span class="as-loop-indicator-detail" id="as-loop-indicator-detail"></span>
1194
+ </div>
1195
+ <button class="as-loop-stop-mini" data-action="force-stop" data-tooltip="Stop">\u25A0</button>
1196
+ </div>
1197
+ </div>
1198
+ </aside>
1199
+ <main class="as-main" id="as-main"></main>
1200
+ </div>
1201
+ `;
1202
+ }
1203
+
1204
+ function wirePanel() {
1205
+ $('as-close')?.addEventListener('click', closePanel);
1206
+ $('as-new-btn')?.addEventListener('click', () => { _view = 'picker'; _selected = null; renderView(); });
1207
+ $('as-schedules-btn')?.addEventListener('click', () => { _view = 'schedules'; _selected = null; loadScheduleData().then(() => renderView()); });
1208
+ $('as-import-btn')?.addEventListener('click', () => triggerImport());
1209
+
1210
+ $('as-sidebar-toggle')?.addEventListener('click', () => {
1211
+ const sidebar = $('as-sidebar');
1212
+ if (sidebar) {
1213
+ sidebar.classList.toggle('as-sidebar--force-show');
1214
+ $('as-sidebar-toggle')?.classList.toggle('active', sidebar.classList.contains('as-sidebar--force-show'));
1215
+ }
1216
+ });
1217
+
1218
+ $('as-backdrop-toggle')?.addEventListener('click', () => {
1219
+ if (_backdrop) {
1220
+ _backdrop.classList.toggle('backdrop-hidden');
1221
+ $('as-backdrop-toggle')?.classList.toggle('active', _backdrop.classList.contains('backdrop-hidden'));
1222
+ }
1223
+ });
1224
+
1225
+ $('as-search')?.addEventListener('input', (e) => {
1226
+ _searchQuery = e.target.value.toLowerCase();
1227
+ renderSidebar();
1228
+ });
1229
+
1230
+ _panel.querySelectorAll('.as-filter[data-cat]').forEach(btn => {
1231
+ btn.addEventListener('click', () => {
1232
+ _filterCategory = btn.dataset.cat;
1233
+ _panel.querySelectorAll('.as-filter[data-cat]').forEach(b => b.classList.toggle('active', b === btn));
1234
+ renderSidebar();
1235
+ });
1236
+ });
1237
+
1238
+ // Delegated click handler for all data-action buttons
1239
+ _panel.addEventListener('click', (e) => handlePanelClick(e));
1240
+
1241
+ // Escape key
1242
+ const onEsc = (e) => {
1243
+ if (e.key === 'Escape' && _panel) {
1244
+ if (_view === 'launch') { closeLaunchInline(); return; }
1245
+ if (_view === 'schedule-editor') { _view = 'schedules'; _editingSchedule = null; renderView(); return; }
1246
+ if (_view === 'schedules') { _view = 'welcome'; renderView(); return; }
1247
+ if (_view === 'wizard') { _view = _selected ? 'detail' : 'welcome'; renderView(); return; }
1248
+ if (_view === 'detail' || _view === 'running' || _view === 'picker') {
1249
+ if (_detailDirty && !confirm('Discard unsaved changes?')) return;
1250
+ _view = 'welcome'; _selected = null; resetDirty(); renderView(); return;
1251
+ }
1252
+ closePanel();
1253
+ document.removeEventListener('keydown', onEsc);
1254
+ }
1255
+ };
1256
+ document.addEventListener('keydown', onEsc);
1257
+ _docListeners.push(['keydown', onEsc]);
1258
+
1259
+ initDrag();
1260
+ }
1261
+
1262
+ // ═══════════════════════════════════════════
1263
+ // DRAG SUPPORT
1264
+ // ═══════════════════════════════════════════
1265
+
1266
+ function initDrag() {
1267
+ const handle = _panel.querySelector('.drag-handle');
1268
+ if (!handle) return;
1269
+ let dragging = false, startX, startY, startLeft, startTop;
1270
+ handle.addEventListener('mousedown', (e) => {
1271
+ if (e.target.closest('button') || e.target.closest('input')) return;
1272
+ dragging = true;
1273
+ startX = e.clientX; startY = e.clientY;
1274
+ const rect = _panel.getBoundingClientRect();
1275
+ startLeft = rect.left; startTop = rect.top;
1276
+ e.preventDefault();
1277
+ });
1278
+ const onMove = (e) => {
1279
+ if (!dragging) return;
1280
+ _panel.style.left = (startLeft + e.clientX - startX) + 'px';
1281
+ _panel.style.top = (startTop + e.clientY - startY) + 'px';
1282
+ };
1283
+ const onUp = () => { dragging = false; };
1284
+ document.addEventListener('mousemove', onMove);
1285
+ document.addEventListener('mouseup', onUp);
1286
+ _docListeners.push(['mousemove', onMove], ['mouseup', onUp]);
1287
+ }
1288
+
1289
+ // ═══════════════════════════════════════════
1290
+ // DATA LOADING & POLLING
1291
+ // ═══════════════════════════════════════════
1292
+
1293
+ async function loadData() {
1294
+ try { _templates = await fetchLoopTemplates(); } catch { _templates = []; }
1295
+ try { _activeLoop = await fetchActiveLoop(); } catch { _activeLoop = null; }
1296
+ await refreshAgents();
1297
+ updateSidebarFooter();
1298
+ }
1299
+
1300
+ function startPolling() {
1301
+ stopPolling();
1302
+ _pollTimer = setInterval(async () => {
1303
+ if (!_panel) { stopPolling(); return; }
1304
+ try {
1305
+ const prev = _activeLoop?.active;
1306
+ _activeLoop = await fetchActiveLoop();
1307
+ await refreshAgents();
1308
+ updateSidebarFooter();
1309
+
1310
+ if (prev && !_activeLoop?.active) {
1311
+ _prevLoopActive = false;
1312
+ }
1313
+ if (_view === 'running') renderRunningMain();
1314
+ // Refresh quick timer countdowns
1315
+ if (_view === 'schedules' && _quickTimers.length > 0) {
1316
+ document.querySelectorAll('.as-qt-active-countdown').forEach(el => {
1317
+ const card = el.closest('.as-qt-active-card');
1318
+ const cancelBtn = card?.querySelector('[data-action="qt-cancel"]');
1319
+ const timerId = cancelBtn?.dataset?.id;
1320
+ const qt = timerId && _quickTimers.find(t => t.id === timerId);
1321
+ if (qt) el.textContent = formatTimerCountdown(qt.firesAt);
1322
+ });
1323
+ }
1324
+ } catch {}
1325
+ }, POLL_INTERVAL);
1326
+ }
1327
+
1328
+ function stopPolling() {
1329
+ if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
1330
+ }
1331
+
1332
+ function updateSidebarFooter() {
1333
+ const footer = $('as-sidebar-footer');
1334
+ const active = _activeLoop?.active;
1335
+ if (footer) footer.style.display = active ? '' : 'none';
1336
+ if (active && _activeLoop) {
1337
+ const nameEl = $('as-loop-indicator-name');
1338
+ const detailEl = $('as-loop-indicator-detail');
1339
+ if (nameEl) nameEl.textContent = (_activeLoop.task || 'Loop Active').slice(0, 40);
1340
+ if (detailEl) detailEl.textContent = `${_activeLoop.currentIteration || 0}/${_activeLoop.totalIterations} iterations`;
1341
+ }
1342
+ }
1343
+
1344
+ // ═══════════════════════════════════════════
1345
+ // VIEW MACHINE
1346
+ // ═══════════════════════════════════════════
1347
+
1348
+ function renderView() {
1349
+ renderSidebar();
1350
+ const count = $('as-total-count');
1351
+ if (count) count.textContent = `${PRESET_TEMPLATES.length + _templates.length}`;
1352
+
1353
+ switch (_view) {
1354
+ case 'welcome': renderWelcomeMain(); break;
1355
+ case 'picker': renderPickerMain(); break;
1356
+ case 'detail': renderDetailMain(); break;
1357
+ case 'wizard': renderWizardMain(); break;
1358
+ case 'running': renderRunningMain(); break;
1359
+ case 'schedules': renderSchedulesMain(); break;
1360
+ case 'schedule-editor': renderScheduleEditorMain(); break;
1361
+ case 'launch': break; // launch panel is rendered by showLaunchInline
1362
+ }
1363
+ }
1364
+
1365
+ function filterTemplates(all) {
1366
+ return all.filter(t => {
1367
+ if (_filterCategory !== 'all' && t.category !== _filterCategory) return false;
1368
+ if (_searchQuery && !t.name.toLowerCase().includes(_searchQuery) && !t.description?.toLowerCase().includes(_searchQuery)) return false;
1369
+ return true;
1370
+ });
1371
+ }
1372
+
1373
+ // ── Sidebar ──
1374
+
1375
+ function renderSidebar() {
1376
+ const list = $('as-template-list');
1377
+ if (!list) return;
1378
+
1379
+ const all = [...PRESET_TEMPLATES, ..._templates];
1380
+ const filtered = filterTemplates(all);
1381
+
1382
+ const groups = {};
1383
+ for (const t of filtered) {
1384
+ const cat = t.category || 'custom';
1385
+ if (!groups[cat]) groups[cat] = [];
1386
+ groups[cat].push(t);
1387
+ }
1388
+
1389
+ let html = '';
1390
+ for (const [cat, items] of Object.entries(groups)) {
1391
+ html += `<div class="as-sidebar-group">
1392
+ <div class="as-sidebar-group-header">
1393
+ ${CATEGORY_LABELS[cat] || cat}
1394
+ <span class="as-sidebar-group-count">${items.length}</span>
1395
+ </div>`;
1396
+ for (const t of items) {
1397
+ const active = _selected?.id === t.id ? ' active' : '';
1398
+ const isPreset = t.id.startsWith('__preset_');
1399
+ const dot = isPreset ? '<span class="as-item-preset-dot"></span>' : '<span class="as-item-custom-dot"></span>';
1400
+ html += `<div class="as-sidebar-item${active}" data-action="open-detail" data-id="${t.id}">
1401
+ <div class="as-sidebar-icon">${icon(t.icon)}</div>
1402
+ <div class="as-sidebar-info">
1403
+ <div class="as-sidebar-name">${dot}${esc(t.name)}</div>
1404
+ <div class="as-sidebar-desc">${t.iterations}x \u00B7 ${t.maxMinutes}m${t.usesBrowser ? ' \u00B7 <span class="as-browser-dot"></span>' : ''}</div>
1405
+ </div>
1406
+ </div>`;
1407
+ }
1408
+ html += '</div>';
1409
+ }
1410
+
1411
+ if (!filtered.length) html = '<div class="as-empty">No templates match your filter.</div>';
1412
+ const scrollTop = list.scrollTop;
1413
+ list.innerHTML = html;
1414
+ list.scrollTop = scrollTop;
1415
+ }
1416
+
1417
+ // ── Welcome ──
1418
+
1419
+ function renderWelcomeMain() {
1420
+ const main = $('as-main');
1421
+ if (!main) return;
1422
+ const presetCount = PRESET_TEMPLATES.length;
1423
+ const customCount = _templates.length;
1424
+
1425
+ main.innerHTML = `
1426
+ <div class="as-welcome">
1427
+ <div class="as-welcome-hero">
1428
+ <img src="synabun.png?v=2" alt="SynaBun" class="as-welcome-logo">
1429
+ <h2>Automation Studio</h2>
1430
+ </div>
1431
+ <p class="as-welcome-sub">Launch, edit, and track loop automations.</p>
1432
+ <div class="as-stats-row">
1433
+ <div class="as-stat">
1434
+ <span class="as-stat-num">${presetCount}</span>
1435
+ <span class="as-stat-label">Presets</span>
1436
+ </div>
1437
+ <div class="as-stat">
1438
+ <span class="as-stat-num">${customCount}</span>
1439
+ <span class="as-stat-label">Custom</span>
1440
+ </div>
1441
+ </div>
1442
+ <p class="as-welcome-hint">Select a template from the sidebar, or click + New to create one.</p>
1443
+ </div>
1444
+ `;
1445
+ }
1446
+
1447
+ // ── Preset Picker (+ New) ──
1448
+
1449
+ function renderPickerMain() {
1450
+ const main = $('as-main');
1451
+ if (!main) return;
1452
+
1453
+ const cards = PRESET_TEMPLATES.map(t => `
1454
+ <div class="as-picker-card" data-action="pick-preset" data-id="${t.id}">
1455
+ <div class="as-picker-icon">${icon(t.icon)}</div>
1456
+ <div class="as-picker-name">${esc(t.name)}</div>
1457
+ <div class="as-picker-desc">${esc(t.description)}</div>
1458
+ <div class="as-picker-meta">
1459
+ <span class="as-badge">${t.iterations}x</span>
1460
+ <span class="as-badge">${t.maxMinutes}m</span>
1461
+ ${t.usesBrowser ? '<span class="as-badge as-badge--browser">Browser</span>' : ''}
1462
+ </div>
1463
+ </div>
1464
+ `).join('');
1465
+
1466
+ main.innerHTML = `
1467
+ <div class="as-picker">
1468
+ <div class="as-picker-header">
1469
+ <button class="as-back-btn" data-action="go-welcome">\u2190 Back</button>
1470
+ <h3>Choose a Starting Point</h3>
1471
+ </div>
1472
+ <p class="as-picker-sub">Pick a preset to configure with a guided wizard, or start from scratch.</p>
1473
+ <div class="as-picker-grid">
1474
+ ${cards}
1475
+ <div class="as-picker-card as-picker-card--blank" data-action="pick-blank">
1476
+ <div class="as-picker-icon as-picker-icon--blank">${AS_ICONS.plus}</div>
1477
+ <div class="as-picker-name">Blank</div>
1478
+ <div class="as-picker-desc">Start from scratch with an empty template.</div>
1479
+ </div>
1480
+ </div>
1481
+ </div>
1482
+ `;
1483
+ }
1484
+
1485
+ // ── Detail/Editor View ──
1486
+
1487
+ function switchToDetail(template) {
1488
+ if (_detailDirty && _selected && _selected.id !== template.id) {
1489
+ if (!confirm('Discard unsaved changes?')) return;
1490
+ }
1491
+ const seq = ++_fetchSeq;
1492
+ _selected = template;
1493
+ _editorContent = buildTemplateCommand(template);
1494
+ _originalContent = _editorContent;
1495
+ resetDirty();
1496
+ _previewMode = false;
1497
+ _view = 'detail';
1498
+ if (seq !== _fetchSeq) return;
1499
+ renderView();
1500
+ }
1501
+
1502
+ function renderDetailMain() {
1503
+ const main = $('as-main');
1504
+ if (!main || !_selected) return;
1505
+ const t = _selected;
1506
+ const isPreset = t.id.startsWith('__preset_');
1507
+
1508
+ main.innerHTML = `
1509
+ <div class="as-detail-header">
1510
+ <button class="as-back-btn" data-action="go-welcome">\u2190 Templates</button>
1511
+ <span class="as-detail-icon">${icon(t.icon)}</span>
1512
+ <span class="as-detail-name">${esc(t.name)}</span>
1513
+ <span class="as-badge as-badge--cat" style="--cat-color:${CATEGORY_COLORS[t.category] || 'var(--t-muted)'}">${esc(CATEGORY_LABELS[t.category] || t.category)}</span>
1514
+ ${isPreset ? '<span class="as-badge as-badge--preset">Preset</span>' : ''}
1515
+ <span class="as-detail-spacer"></span>
1516
+ ${isPreset ? `<button class="as-header-btn as-customize-btn" data-action="customize">Customize</button>` : ''}
1517
+ ${!isPreset ? `
1518
+ <button class="as-header-btn" data-action="export-one" data-id="${t.id}">\u2193 Export</button>
1519
+ <button class="as-header-btn as-discard-btn" id="as-discard" disabled>Discard</button>
1520
+ <button class="as-header-btn as-save-btn" id="as-save" disabled>Save</button>
1521
+ <button class="as-header-btn as-delete-btn" data-action="delete-template" data-id="${t.id}">\u2715</button>
1522
+ ` : ''}
1523
+ </div>
1524
+
1525
+ <div class="as-detail-body">
1526
+ <div class="as-detail-meta" id="as-detail-meta">
1527
+ ${buildDetailMeta(t, isPreset)}
1528
+ </div>
1529
+ <div class="as-detail-content">
1530
+ <div class="as-md-toolbar" id="as-md-toolbar">
1531
+ <button class="ss-md-tool" data-cmd="heading" data-tooltip="Heading"><svg viewBox="0 0 24 24"><path d="M4 4v16M20 4v16M4 12h16"/></svg></button>
1532
+ <button class="ss-md-tool" data-cmd="bold" data-tooltip="Bold (Ctrl+B)"><b style="font-size:13px">B</b></button>
1533
+ <button class="ss-md-tool" data-cmd="italic" data-tooltip="Italic (Ctrl+I)"><i style="font-size:13px">I</i></button>
1534
+ <button class="ss-md-tool" data-cmd="strikethrough" data-tooltip="Strikethrough"><s style="font-size:12px">S</s></button>
1535
+ <div class="ss-md-toolbar-sep"></div>
1536
+ <button class="ss-md-tool" data-cmd="ul" data-tooltip="Bullet list"><svg viewBox="0 0 24 24"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="5" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="5" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="5" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg></button>
1537
+ <button class="ss-md-tool" data-cmd="ol" data-tooltip="Numbered list"><svg viewBox="0 0 24 24"><line x1="10" y1="6" x2="20" y2="6"/><line x1="10" y1="12" x2="20" y2="12"/><line x1="10" y1="18" x2="20" y2="18"/><text x="4" y="8" font-size="7" fill="currentColor" stroke="none" font-weight="600">1</text><text x="4" y="14" font-size="7" fill="currentColor" stroke="none" font-weight="600">2</text><text x="4" y="20" font-size="7" fill="currentColor" stroke="none" font-weight="600">3</text></svg></button>
1538
+ <button class="ss-md-tool" data-cmd="quote" data-tooltip="Blockquote"><svg viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h12"/><rect x="1" y="5" width="2" height="14" rx="1" fill="currentColor" stroke="none" opacity="0.4"/></svg></button>
1539
+ <div class="ss-md-toolbar-sep"></div>
1540
+ <button class="ss-md-tool" data-cmd="code" data-tooltip="Inline code"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button>
1541
+ <button class="ss-md-tool" data-cmd="codeblock" data-tooltip="Code block"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="9 8 5 12 9 16" stroke-width="1.5"/><polyline points="15 8 19 12 15 16" stroke-width="1.5"/></svg></button>
1542
+ <button class="ss-md-tool" data-cmd="link" data-tooltip="Link (Ctrl+K)"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
1543
+ <button class="ss-md-tool" data-cmd="hr" data-tooltip="Horizontal rule">&mdash;</button>
1544
+ <div class="ss-view-toggle">
1545
+ <button class="ss-view-toggle-btn${_previewMode ? '' : ' active'}" data-mode="raw">Raw</button>
1546
+ <button class="ss-view-toggle-btn${_previewMode ? ' active' : ''}" data-mode="preview">Preview</button>
1547
+ </div>
1548
+ </div>
1549
+ <textarea class="as-cmd-editor${_previewMode ? ' as-hidden' : ''}" id="as-cmd-editor"
1550
+ spellcheck="false" autocorrect="off" autocapitalize="off"
1551
+ ${isPreset ? 'readonly' : ''}>${esc(_editorContent)}</textarea>
1552
+ <div class="as-cmd-preview${_previewMode ? ' visible' : ''}" id="as-cmd-preview"></div>
1553
+ <div class="as-detail-actions">
1554
+ <button class="as-action-btn" data-action="copy-cmd">
1555
+ <svg viewBox="0 0 24 24" width="14" height="14"><rect x="9" y="9" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
1556
+ Copy
1557
+ </button>
1558
+ ${isPreset ? `<button class="as-action-btn" data-action="reconfigure">Wizard</button>` : ''}
1559
+ <div class="as-action-spacer"></div>
1560
+ <button class="as-action-btn as-action-btn--test" data-action="test-run">Test (1 iter)</button>
1561
+ <button class="as-action-btn as-action-btn--launch" data-action="launch-loop">
1562
+ <svg viewBox="0 0 24 24" width="14" height="14"><polyline points="4 17 10 11 4 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="19" x2="20" y2="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
1563
+ Launch
1564
+ </button>
1565
+ </div>
1566
+ </div>
1567
+ </div>
1568
+ `;
1569
+
1570
+ wireDetailView(isPreset);
1571
+ }
1572
+
1573
+ function buildDetailMeta(t, isPreset) {
1574
+ const disabled = isPreset ? 'disabled readonly' : '';
1575
+ return `
1576
+ <div class="as-meta-section">
1577
+ <div class="as-field">
1578
+ <label>Name</label>
1579
+ <input type="text" class="as-input" id="as-f-name" value="${esc(t.name)}" ${disabled} />
1580
+ </div>
1581
+ <div class="as-field">
1582
+ <label>Category</label>
1583
+ ${isPreset
1584
+ ? `<span class="as-value-text">${esc(CATEGORY_LABELS[t.category] || t.category)}</span>`
1585
+ : `<div class="as-cat-picker" id="as-cat-picker">
1586
+ <input type="hidden" id="as-f-category" value="${esc(t.category || 'custom')}">
1587
+ <button type="button" class="as-cat-picker-trigger" id="as-cat-trigger">
1588
+ <span class="as-cat-picker-label">${esc(CATEGORY_LABELS[t.category] || t.category || 'Custom')}</span>
1589
+ <span class="as-icon-picker-chevron">${AS_ICONS.down}</span>
1590
+ </button>
1591
+ <div class="as-cat-picker-menu" id="as-cat-menu">
1592
+ ${Object.entries(CATEGORY_LABELS).map(([k, v]) =>
1593
+ `<button type="button" class="as-cat-picker-item${k === (t.category || 'custom') ? ' selected' : ''}" data-cat="${k}">${esc(v)}</button>`
1594
+ ).join('')}
1595
+ </div>
1596
+ </div>`
1597
+ }
1598
+ </div>
1599
+ <div class="as-field">
1600
+ <label>Description</label>
1601
+ <textarea class="as-textarea" id="as-f-desc" rows="3" ${disabled}>${esc(t.description || '')}</textarea>
1602
+ </div>
1603
+ <div class="as-field">
1604
+ <label>Iterations <span class="as-range-val" id="as-f-iter-val">${t.iterations}</span></label>
1605
+ <input type="range" class="as-range" id="as-f-iterations" min="1" max="200" value="${t.iterations}" />
1606
+ </div>
1607
+ <div class="as-field">
1608
+ <label>Time cap <span class="as-range-val" id="as-f-min-val">${t.maxMinutes}m</span></label>
1609
+ <input type="range" class="as-range" id="as-f-maxminutes" min="1" max="480" value="${t.maxMinutes}" />
1610
+ </div>
1611
+ <div class="as-field as-field--toggle">
1612
+ <label>Uses Browser</label>
1613
+ <label class="as-toggle">
1614
+ <input type="checkbox" id="as-f-browser" ${t.usesBrowser ? 'checked' : ''} ${isPreset ? 'disabled' : ''}>
1615
+ <span class="as-toggle-track"></span>
1616
+ </label>
1617
+ </div>
1618
+ ${isPreset ? `
1619
+ <div class="as-meta-divider"></div>
1620
+ <div class="as-preset-hint">
1621
+ Built-in preset.
1622
+ <button class="as-inline-link" data-action="reconfigure">Re-run Wizard</button> to change parameters, or
1623
+ <button class="as-inline-link" data-action="customize">Customize</button> to create an editable copy.
1624
+ </div>
1625
+ ` : `
1626
+ <div class="as-meta-divider"></div>
1627
+ <div class="as-field">
1628
+ <label>Icon</label>
1629
+ <div class="as-icon-picker" id="as-icon-picker">
1630
+ <input type="hidden" id="as-f-icon" value="${esc(t.icon || 'refresh')}">
1631
+ <button type="button" class="as-icon-picker-trigger" id="as-icon-trigger">
1632
+ <span class="as-icon-picker-preview">${icon(t.icon || 'refresh')}</span>
1633
+ <span class="as-icon-picker-label">${esc(t.icon || 'refresh')}</span>
1634
+ <span class="as-icon-picker-chevron">${AS_ICONS.down}</span>
1635
+ </button>
1636
+ <div class="as-icon-picker-menu" id="as-icon-menu">
1637
+ ${ICON_KEYS.map(k =>
1638
+ `<button type="button" class="as-icon-picker-item${k === (t.icon || 'refresh') ? ' selected' : ''}" data-icon="${k}" data-tooltip="${k}">${icon(k)}</button>`
1639
+ ).join('')}
1640
+ </div>
1641
+ </div>
1642
+ </div>
1643
+ `}
1644
+ </div>
1645
+ `;
1646
+ }
1647
+
1648
+ function wireDetailView(isPreset) {
1649
+ // Sliders
1650
+ const iterSlider = $('as-f-iterations');
1651
+ const iterVal = $('as-f-iter-val');
1652
+ if (iterSlider && iterVal) iterSlider.addEventListener('input', () => { iterVal.textContent = iterSlider.value; if (!isPreset) markDirty(); });
1653
+ const minSlider = $('as-f-maxminutes');
1654
+ const minVal = $('as-f-min-val');
1655
+ if (minSlider && minVal) minSlider.addEventListener('input', () => { minVal.textContent = minSlider.value + 'm'; if (!isPreset) markDirty(); });
1656
+
1657
+ // Browser toggle
1658
+ const browserToggle = $('as-f-browser');
1659
+ if (browserToggle && !isPreset) browserToggle.addEventListener('change', () => markDirty());
1660
+
1661
+ // Editor textarea
1662
+ const editor = $('as-cmd-editor');
1663
+ if (editor && !isPreset) {
1664
+ editor.addEventListener('input', () => {
1665
+ _editorContent = editor.value;
1666
+ _detailDirty = _metaDirty || (_editorContent !== _originalContent);
1667
+ updateDetailDirtyState();
1668
+ });
1669
+ editor.addEventListener('keydown', (e) => {
1670
+ if (e.key === 'Tab') {
1671
+ e.preventDefault();
1672
+ const start = editor.selectionStart, end = editor.selectionEnd;
1673
+ editor.value = editor.value.substring(0, start) + ' ' + editor.value.substring(end);
1674
+ editor.selectionStart = editor.selectionEnd = start + 2;
1675
+ _editorContent = editor.value;
1676
+ }
1677
+ // Keyboard shortcuts
1678
+ if (e.ctrlKey || e.metaKey) {
1679
+ if (e.key === 'b') { e.preventDefault(); mdWrap(editor, '**', '**'); }
1680
+ else if (e.key === 'i') { e.preventDefault(); mdWrap(editor, '*', '*'); }
1681
+ else if (e.key === 'k') { e.preventDefault(); mdLink(editor); }
1682
+ }
1683
+ });
1684
+ }
1685
+
1686
+ // Metadata field dirty tracking
1687
+ if (!isPreset) {
1688
+ _panel?.querySelectorAll('#as-detail-meta input:not([type="range"]):not([type="checkbox"]), #as-detail-meta textarea, #as-detail-meta select').forEach(el => {
1689
+ el.addEventListener('input', () => markDirty());
1690
+ el.addEventListener('change', () => markDirty());
1691
+ });
1692
+ }
1693
+
1694
+ // Icon picker & category picker
1695
+ wireIconPicker(isPreset);
1696
+ wireCategoryPicker(isPreset);
1697
+
1698
+ // Markdown toolbar
1699
+ wireMdToolbar();
1700
+
1701
+ // Preview toggle
1702
+ _panel?.querySelectorAll('.ss-view-toggle-btn').forEach(btn => {
1703
+ btn.addEventListener('click', () => {
1704
+ const mode = btn.dataset.mode;
1705
+ _previewMode = mode === 'preview';
1706
+ _panel.querySelectorAll('.ss-view-toggle-btn').forEach(b => b.classList.toggle('active', b === btn));
1707
+ const ed = $('as-cmd-editor');
1708
+ const prev = $('as-cmd-preview');
1709
+ const toolbar = $('as-md-toolbar');
1710
+ if (ed) ed.classList.toggle('as-hidden', _previewMode);
1711
+ if (prev) {
1712
+ prev.classList.toggle('visible', _previewMode);
1713
+ if (_previewMode) prev.innerHTML = `<pre style="white-space:pre-wrap;margin:0;font-size:12px;line-height:1.65;color:var(--t-primary)">${esc(ed?.value || '')}</pre>`;
1714
+ }
1715
+ if (toolbar) toolbar.querySelectorAll('.ss-md-tool').forEach(t => t.style.visibility = _previewMode ? 'hidden' : '');
1716
+ });
1717
+ });
1718
+
1719
+ // Save/Discard
1720
+ $('as-save')?.addEventListener('click', () => saveCurrentTemplate());
1721
+ $('as-discard')?.addEventListener('click', () => {
1722
+ _editorContent = _originalContent;
1723
+ resetDirty();
1724
+ switchToDetail(_selected); // re-render with original data
1725
+ });
1726
+ }
1727
+
1728
+ function wireIconPicker(isPreset) {
1729
+ if (isPreset) return;
1730
+ const picker = $('as-icon-picker');
1731
+ const trigger = $('as-icon-trigger');
1732
+ const menu = $('as-icon-menu');
1733
+ const hidden = $('as-f-icon');
1734
+ if (!picker || !trigger || !menu || !hidden) return;
1735
+
1736
+ trigger.addEventListener('click', (e) => {
1737
+ e.stopPropagation();
1738
+ const isOpen = menu.classList.contains('open');
1739
+ menu.classList.toggle('open', !isOpen);
1740
+ trigger.classList.toggle('open', !isOpen);
1741
+ });
1742
+
1743
+ menu.addEventListener('click', (e) => {
1744
+ const item = e.target.closest('.as-icon-picker-item');
1745
+ if (!item) return;
1746
+ const key = item.dataset.icon;
1747
+ hidden.value = key;
1748
+ trigger.querySelector('.as-icon-picker-preview').innerHTML = icon(key);
1749
+ trigger.querySelector('.as-icon-picker-label').textContent = key;
1750
+ menu.querySelectorAll('.as-icon-picker-item').forEach(i => i.classList.toggle('selected', i === item));
1751
+ menu.classList.remove('open');
1752
+ trigger.classList.remove('open');
1753
+ // Update detail header icon
1754
+ const headerIcon = _panel?.querySelector('.as-detail-icon');
1755
+ if (headerIcon) headerIcon.innerHTML = icon(key);
1756
+ markDirty();
1757
+ });
1758
+
1759
+ // Close on click outside
1760
+ const closeIconPicker = (e) => {
1761
+ if (!picker.contains(e.target)) {
1762
+ menu.classList.remove('open');
1763
+ trigger.classList.remove('open');
1764
+ }
1765
+ };
1766
+ document.addEventListener('click', closeIconPicker);
1767
+ _docListeners.push(['click', closeIconPicker]);
1768
+ }
1769
+
1770
+ function wireCategoryPicker(isPreset) {
1771
+ if (isPreset) return;
1772
+ const picker = $('as-cat-picker');
1773
+ const trigger = $('as-cat-trigger');
1774
+ const menu = $('as-cat-menu');
1775
+ const hidden = $('as-f-category');
1776
+ if (!picker || !trigger || !menu || !hidden) return;
1777
+
1778
+ trigger.addEventListener('click', (e) => {
1779
+ e.stopPropagation();
1780
+ const isOpen = menu.classList.contains('open');
1781
+ menu.classList.toggle('open', !isOpen);
1782
+ trigger.classList.toggle('open', !isOpen);
1783
+ });
1784
+
1785
+ menu.addEventListener('click', (e) => {
1786
+ const item = e.target.closest('.as-cat-picker-item');
1787
+ if (!item) return;
1788
+ const key = item.dataset.cat;
1789
+ hidden.value = key;
1790
+ trigger.querySelector('.as-cat-picker-label').textContent = CATEGORY_LABELS[key] || key;
1791
+ menu.querySelectorAll('.as-cat-picker-item').forEach(i => i.classList.toggle('selected', i === item));
1792
+ menu.classList.remove('open');
1793
+ trigger.classList.remove('open');
1794
+ markDirty();
1795
+ });
1796
+
1797
+ const closeCatPicker = (e) => {
1798
+ if (!picker.contains(e.target)) {
1799
+ menu.classList.remove('open');
1800
+ trigger.classList.remove('open');
1801
+ }
1802
+ };
1803
+ document.addEventListener('click', closeCatPicker);
1804
+ _docListeners.push(['click', closeCatPicker]);
1805
+ }
1806
+
1807
+ function resetDirty() {
1808
+ _metaDirty = false;
1809
+ _detailDirty = false;
1810
+ }
1811
+
1812
+ function markDirty() {
1813
+ _metaDirty = true;
1814
+ _detailDirty = true;
1815
+ updateDetailDirtyState();
1816
+ }
1817
+
1818
+ function updateDetailDirtyState() {
1819
+ const saveBtn = $('as-save');
1820
+ const discardBtn = $('as-discard');
1821
+ if (saveBtn) { saveBtn.disabled = !_detailDirty; saveBtn.classList.toggle('dirty', _detailDirty); }
1822
+ if (discardBtn) discardBtn.disabled = !_detailDirty;
1823
+ }
1824
+
1825
+ async function saveCurrentTemplate() {
1826
+ if (!_selected || _selected.id.startsWith('__preset_')) return;
1827
+ const name = $('as-f-name')?.value?.trim() || _selected.name;
1828
+ const category = $('as-f-category')?.value || _selected.category;
1829
+ const description = $('as-f-desc')?.value?.trim() || '';
1830
+ const iconKey = $('as-f-icon')?.value || _selected.icon || 'refresh';
1831
+ const iterations = parseInt($('as-f-iterations')?.value || '10', 10);
1832
+ const maxMinutes = parseInt($('as-f-maxminutes')?.value || '30', 10);
1833
+ const usesBrowser = $('as-f-browser')?.checked || false;
1834
+ const task = $('as-cmd-editor')?.value?.trim() || '';
1835
+
1836
+ if (!name) { showToast('Name is required'); return; }
1837
+ if (!task) { showToast('Task description is required'); return; }
1838
+
1839
+ const payload = { name, icon: iconKey, category, task, context: '', description: description || task.slice(0, 120), iterations, maxMinutes, usesBrowser };
1840
+
1841
+ try {
1842
+ if (_selected.id) { await updateLoopTemplate(_selected.id, payload); }
1843
+ else { const result = await createLoopTemplate(payload); if (result?.id) _selected.id = result.id; }
1844
+ _templates = await fetchLoopTemplates();
1845
+ const updated = _templates.find(t => t.id === _selected.id);
1846
+ if (updated) _selected = updated;
1847
+ _originalContent = task;
1848
+ _editorContent = task;
1849
+ resetDirty();
1850
+ renderView();
1851
+ showToast('Saved');
1852
+ } catch (err) { showToast('Save failed: ' + err.message); }
1853
+ }
1854
+
1855
+ // ── Markdown toolbar ──
1856
+
1857
+ function wireMdToolbar() {
1858
+ const toolbar = $('as-md-toolbar');
1859
+ if (!toolbar) return;
1860
+ toolbar.querySelectorAll('.ss-md-tool[data-cmd]').forEach(btn => {
1861
+ btn.addEventListener('click', () => {
1862
+ const editor = $('as-cmd-editor');
1863
+ if (!editor || editor.readOnly) return;
1864
+ const cmd = btn.dataset.cmd;
1865
+ switch (cmd) {
1866
+ case 'bold': mdWrap(editor, '**', '**'); break;
1867
+ case 'italic': mdWrap(editor, '*', '*'); break;
1868
+ case 'strikethrough': mdWrap(editor, '~~', '~~'); break;
1869
+ case 'code': mdWrap(editor, '`', '`'); break;
1870
+ case 'codeblock': mdWrap(editor, '\n```\n', '\n```\n'); break;
1871
+ case 'heading': mdPrefix(editor, '## '); break;
1872
+ case 'ul': mdPrefix(editor, '- '); break;
1873
+ case 'ol': mdPrefix(editor, '1. '); break;
1874
+ case 'quote': mdPrefix(editor, '> '); break;
1875
+ case 'link': mdLink(editor); break;
1876
+ case 'hr': {
1877
+ const s = editor.selectionStart;
1878
+ editor.value = editor.value.substring(0, s) + '\n---\n' + editor.value.substring(s);
1879
+ editor.selectionStart = editor.selectionEnd = s + 5;
1880
+ editor.focus();
1881
+ break;
1882
+ }
1883
+ }
1884
+ _editorContent = editor.value;
1885
+ if (_editorContent !== _originalContent) { _detailDirty = true; updateDetailDirtyState(); }
1886
+ });
1887
+ });
1888
+ }
1889
+
1890
+ function mdWrap(editor, before, after) {
1891
+ const s = editor.selectionStart, e = editor.selectionEnd;
1892
+ const sel = editor.value.substring(s, e) || 'text';
1893
+ editor.value = editor.value.substring(0, s) + before + sel + after + editor.value.substring(e);
1894
+ editor.selectionStart = s + before.length;
1895
+ editor.selectionEnd = s + before.length + sel.length;
1896
+ editor.focus();
1897
+ }
1898
+
1899
+ function mdPrefix(editor, prefix) {
1900
+ const s = editor.selectionStart, e = editor.selectionEnd;
1901
+ const val = editor.value;
1902
+ const lineStart = val.lastIndexOf('\n', s - 1) + 1;
1903
+ const lineEnd = val.indexOf('\n', e);
1904
+ const end = lineEnd < 0 ? val.length : lineEnd;
1905
+ const lines = val.substring(lineStart, end).split('\n');
1906
+ const toggled = lines.map(line => {
1907
+ if (line.startsWith(prefix)) return line.substring(prefix.length);
1908
+ // Strip other prefixes
1909
+ return prefix + line.replace(/^(#{1,6}\s|[-*]\s|\d+\.\s|>\s)/, '');
1910
+ });
1911
+ editor.value = val.substring(0, lineStart) + toggled.join('\n') + val.substring(end);
1912
+ editor.selectionStart = lineStart;
1913
+ editor.selectionEnd = lineStart + toggled.join('\n').length;
1914
+ editor.focus();
1915
+ }
1916
+
1917
+ function mdLink(editor) {
1918
+ const s = editor.selectionStart, e = editor.selectionEnd;
1919
+ const sel = editor.value.substring(s, e) || 'text';
1920
+ const inserted = `[${sel}](url)`;
1921
+ editor.value = editor.value.substring(0, s) + inserted + editor.value.substring(e);
1922
+ editor.selectionStart = s + sel.length + 3;
1923
+ editor.selectionEnd = s + sel.length + 6;
1924
+ editor.focus();
1925
+ }
1926
+
1927
+ // ── Customize (clone preset → custom) ──
1928
+
1929
+ async function customizePreset(preset) {
1930
+ try {
1931
+ const cmd = buildTemplateCommand(preset);
1932
+ const payload = {
1933
+ name: preset.name + ' (Custom)',
1934
+ icon: preset.icon,
1935
+ category: preset.category,
1936
+ task: cmd,
1937
+ context: '',
1938
+ iterations: parseInt($('as-f-iterations')?.value || String(preset.iterations), 10),
1939
+ maxMinutes: parseInt($('as-f-maxminutes')?.value || String(preset.maxMinutes), 10),
1940
+ usesBrowser: preset.usesBrowser || false,
1941
+ description: preset.description,
1942
+ };
1943
+ const result = await createLoopTemplate(payload);
1944
+ if (result?.id) {
1945
+ _templates = await fetchLoopTemplates();
1946
+ const newTpl = _templates.find(t => t.id === result.id);
1947
+ if (newTpl) {
1948
+ showToast('Customized \u2014 edit and save your copy');
1949
+ switchToDetail(newTpl);
1950
+ }
1951
+ }
1952
+ } catch (err) { showToast('Customize failed: ' + err.message); }
1953
+ }
1954
+
1955
+ // ── Test Run ──
1956
+
1957
+ function handleTestRun() {
1958
+ if (!_selected) return;
1959
+ const cmd = $('as-cmd-editor')?.value || _editorContent;
1960
+ const iterations = parseInt($('as-f-iterations')?.value || '1', 10);
1961
+ const maxMinutes = parseInt($('as-f-maxminutes')?.value || '10', 10);
1962
+ const browserEl = $('as-f-browser');
1963
+ const usesBrowser = browserEl ? browserEl.checked : (_selected.usesBrowser || false);
1964
+ serverLaunchLoop({
1965
+ task: cmd,
1966
+ context: null,
1967
+ iterations: 1, maxMinutes: 10,
1968
+ usesBrowser,
1969
+ });
1970
+ }
1971
+
1972
+ // ── Launch ──
1973
+
1974
+ function handleLaunch() {
1975
+ if (!_selected) return;
1976
+
1977
+ // If dirty and saveable (not a preset), show unsaved-changes modal
1978
+ const isPreset = _selected.id?.startsWith('__preset_');
1979
+ if (_detailDirty && !isPreset) {
1980
+ showUnsavedLaunchModal();
1981
+ return;
1982
+ }
1983
+
1984
+ proceedWithLaunch();
1985
+ }
1986
+
1987
+ function proceedWithLaunch() {
1988
+ const cmd = $('as-cmd-editor')?.value || _editorContent;
1989
+ const iterations = parseInt($('as-f-iterations')?.value || String(_selected.iterations), 10);
1990
+ const maxMinutes = parseInt($('as-f-maxminutes')?.value || String(_selected.maxMinutes), 10);
1991
+ const browserEl = $('as-f-browser');
1992
+ const usesBrowser = browserEl ? browserEl.checked : (_selected.usesBrowser || false);
1993
+ serverLaunchLoop({
1994
+ task: cmd,
1995
+ context: null,
1996
+ iterations, maxMinutes, usesBrowser,
1997
+ });
1998
+ }
1999
+
2000
+ function showUnsavedLaunchModal() {
2001
+ _panel?.querySelector('.as-unsaved-dialog')?.remove();
2002
+
2003
+ const html = `
2004
+ <div class="as-unsaved-dialog">
2005
+ <div class="as-unsaved-dialog-inner">
2006
+ <div class="as-unsaved-dialog-header">
2007
+ <span class="as-unsaved-dialog-title">Unsaved Changes</span>
2008
+ <button class="as-launch-dialog-close" data-action="unsaved-dialog-close">
2009
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M11 3L3 11M3 3l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
2010
+ </button>
2011
+ </div>
2012
+ <div class="as-unsaved-dialog-body">
2013
+ <p class="as-unsaved-dialog-msg">You have unsaved changes to this automation. Save before launching?</p>
2014
+ </div>
2015
+ <div class="as-unsaved-dialog-footer">
2016
+ <button class="as-launch-cancel" data-action="unsaved-dialog-close">Cancel</button>
2017
+ <button class="as-launch-go" data-action="unsaved-save-launch">Save &amp; Continue</button>
2018
+ </div>
2019
+ </div>
2020
+ </div>
2021
+ `;
2022
+
2023
+ _panel.insertAdjacentHTML('beforeend', html);
2024
+ }
2025
+
2026
+ async function handleSaveAndLaunch() {
2027
+ const dialog = _panel?.querySelector('.as-unsaved-dialog');
2028
+ const btn = dialog?.querySelector('[data-action="unsaved-save-launch"]');
2029
+ if (btn) { btn.textContent = 'Saving…'; btn.disabled = true; }
2030
+
2031
+ await saveCurrentTemplate();
2032
+
2033
+ // saveCurrentTemplate resets _detailDirty on success
2034
+ if (!_detailDirty) {
2035
+ dialog?.remove();
2036
+ proceedWithLaunch();
2037
+ } else {
2038
+ // Save failed — restore button
2039
+ if (btn) { btn.textContent = 'Save & Continue'; btn.disabled = false; }
2040
+ }
2041
+ }
2042
+
2043
+ // ═══════════════════════════════════════════
2044
+ // WIZARD (rendered in main area)
2045
+ // ═══════════════════════════════════════════
2046
+
2047
+ function openWizard(preset) {
2048
+ _wizardPreset = preset;
2049
+ _wizardStep = 0;
2050
+ _wizardState = {
2051
+ iterations: preset.iterations,
2052
+ maxMinutes: preset.maxMinutes,
2053
+ usesBrowser: preset.usesBrowser || false,
2054
+ };
2055
+ _view = 'wizard';
2056
+ renderView();
2057
+ }
2058
+
2059
+ function renderWizardMain() {
2060
+ const main = $('as-main');
2061
+ if (!main || !_wizardPreset) return;
2062
+ const totalSteps = _wizardPreset.steps.length + 1;
2063
+ const stepNames = [..._wizardPreset.steps.map(s => s.title), 'Review'];
2064
+
2065
+ main.innerHTML = `
2066
+ <div class="as-wizard">
2067
+ <div class="as-wizard-header">
2068
+ <button class="as-back-btn" data-action="cancel-wizard">\u2190 Cancel</button>
2069
+ <span class="as-wizard-icon">${icon(_wizardPreset.icon)}</span>
2070
+ <span class="as-wizard-title">${esc(_wizardPreset.name)}</span>
2071
+ </div>
2072
+ <div class="as-wizard-steps">
2073
+ ${stepNames.map((s, i) => `
2074
+ <div class="as-wiz-step${i === _wizardStep ? ' active' : ''}${i < _wizardStep ? ' done' : ''}">
2075
+ <span class="as-wiz-step-num">${i + 1}</span>
2076
+ <span class="as-wiz-step-label">${esc(s)}</span>
2077
+ </div>
2078
+ `).join('')}
2079
+ </div>
2080
+ <div class="as-wizard-body" id="as-wizard-body"></div>
2081
+ <div class="as-wizard-nav">
2082
+ <button class="as-header-btn" data-action="wizard-back" style="visibility:${_wizardStep === 0 ? 'hidden' : 'visible'}">\u2190 Back</button>
2083
+ <button class="as-header-btn as-save-btn" data-action="wizard-next">${_wizardStep === totalSteps - 2 ? 'Review' : _wizardStep >= totalSteps - 1 ? '' : 'Next \u2192'}</button>
2084
+ </div>
2085
+ </div>
2086
+ `;
2087
+
2088
+ renderWizardStep();
2089
+ }
2090
+
2091
+ function renderWizardStep() {
2092
+ const body = $('as-wizard-body');
2093
+ if (!body || !_wizardPreset) return;
2094
+ const totalSteps = _wizardPreset.steps.length + 1;
2095
+ const isLastStep = _wizardStep === totalSteps - 1;
2096
+
2097
+ // Update step indicators
2098
+ _panel?.querySelectorAll('.as-wiz-step').forEach((el, i) => {
2099
+ el.classList.toggle('active', i === _wizardStep);
2100
+ el.classList.toggle('done', i < _wizardStep);
2101
+ });
2102
+
2103
+ const backBtn = _panel?.querySelector('[data-action="wizard-back"]');
2104
+ const nextBtn = _panel?.querySelector('[data-action="wizard-next"]');
2105
+ if (backBtn) backBtn.style.visibility = _wizardStep === 0 ? 'hidden' : 'visible';
2106
+
2107
+ if (isLastStep) {
2108
+ body.innerHTML = buildReviewStep();
2109
+ if (nextBtn) nextBtn.style.display = 'none';
2110
+ wireReviewEvents();
2111
+ } else {
2112
+ const step = _wizardPreset.steps[_wizardStep];
2113
+ body.innerHTML = `
2114
+ <div class="awiz-step-title">${esc(step.title)}</div>
2115
+ <div class="awiz-step-subtitle">${esc(step.subtitle)}</div>
2116
+ <div class="awiz-step-content">${step.buildContent(_wizardState)}</div>`;
2117
+ if (nextBtn) {
2118
+ nextBtn.style.display = '';
2119
+ nextBtn.textContent = _wizardStep === totalSteps - 2 ? 'Review' : 'Next \u2192';
2120
+ }
2121
+ }
2122
+
2123
+ wireChipEvents(_panel);
2124
+ wireInputSync(_panel);
2125
+ if (!isLastStep) {
2126
+ const step = _wizardPreset.steps[_wizardStep];
2127
+ if (step?.afterRender) step.afterRender(_panel, _wizardState);
2128
+ }
2129
+ }
2130
+
2131
+ function buildReviewStep() {
2132
+ const state = _wizardState;
2133
+ const preset = _wizardPreset;
2134
+ const command = preset.buildCommand(state);
2135
+
2136
+ const summaryPairs = [];
2137
+ for (const step of preset.steps) {
2138
+ const tmp = document.createElement('div');
2139
+ tmp.innerHTML = step.buildContent(state);
2140
+ tmp.querySelectorAll('.awiz-field').forEach(field => {
2141
+ const label = field.querySelector('label')?.textContent?.replace(/\(optional\)/i, '').trim();
2142
+ if (!label) return;
2143
+ const input = field.querySelector('input, textarea');
2144
+ const chips = field.querySelector('.awiz-chips');
2145
+ let value = '';
2146
+ if (input && input.value) value = input.value;
2147
+ else if (chips) {
2148
+ value = Array.from(chips.querySelectorAll('.awiz-chip.active')).map(c => c.textContent).join(', ');
2149
+ }
2150
+ if (value) summaryPairs.push({ label, value });
2151
+ });
2152
+ }
2153
+
2154
+ const summaryHtml = summaryPairs.map(p =>
2155
+ `<div class="awiz-summary-row"><span class="awiz-summary-label">${esc(p.label)}</span><span class="awiz-summary-value">${esc(p.value)}</span></div>`
2156
+ ).join('');
2157
+
2158
+ return `
2159
+ <div class="awiz-step-title">Review & Launch</div>
2160
+ <div class="awiz-step-subtitle">Confirm your settings and go</div>
2161
+ <div class="awiz-summary">${summaryHtml}</div>
2162
+ <div class="awiz-controls">
2163
+ <div class="awiz-control-row">
2164
+ <label>Iterations</label>
2165
+ <input type="range" id="awiz-iterations" min="1" max="50" value="${state.iterations}" />
2166
+ <span class="awiz-range-val" id="awiz-iter-val">${state.iterations}</span>
2167
+ </div>
2168
+ <div class="awiz-control-row">
2169
+ <label>Time cap</label>
2170
+ <input type="range" id="awiz-minutes" min="1" max="480" value="${state.maxMinutes}" />
2171
+ <span class="awiz-range-val" id="awiz-min-val">${state.maxMinutes}m</span>
2172
+ </div>
2173
+ </div>
2174
+ ${state.usesBrowser ? '<div style="font-size:11px;color:hsla(210,40%,65%,0.9);margin-bottom:12px;display:flex;align-items:center;gap:6px"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg> This automation uses browser tools</div>' : ''}
2175
+ <div class="awiz-command-preview">
2176
+ <div class="awiz-preview-label">Command Preview</div>
2177
+ <pre class="awiz-preview-text">${esc(formatLoopCommand(command, state))}</pre>
2178
+ </div>
2179
+ <div class="awiz-launch-actions">
2180
+ <button class="awiz-btn-copy" data-action="wizard-copy">
2181
+ <svg viewBox="0 0 24 24" width="16" height="16"><rect x="9" y="9" width="13" height="13" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" stroke-width="2"/></svg>
2182
+ Copy Command
2183
+ </button>
2184
+ <button class="awiz-btn-launch" data-action="wizard-open-editor">
2185
+ <svg viewBox="0 0 24 24" width="16" height="16"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
2186
+ Open in Editor
2187
+ </button>
2188
+ <button class="awiz-btn-launch" data-action="wizard-launch">
2189
+ <svg viewBox="0 0 24 24" width="16" height="16"><polyline points="4 17 10 11 4 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="19" x2="20" y2="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
2190
+ Launch Terminal
2191
+ </button>
2192
+ </div>`;
2193
+ }
2194
+
2195
+ function wireReviewEvents() {
2196
+ const iterSlider = $('awiz-iterations');
2197
+ const iterVal = $('awiz-iter-val');
2198
+ const minSlider = $('awiz-minutes');
2199
+ const minVal = $('awiz-min-val');
2200
+
2201
+ if (iterSlider && iterVal) {
2202
+ iterSlider.addEventListener('input', () => {
2203
+ _wizardState.iterations = parseInt(iterSlider.value, 10);
2204
+ iterVal.textContent = iterSlider.value;
2205
+ updateWizardPreview();
2206
+ });
2207
+ }
2208
+ if (minSlider && minVal) {
2209
+ minSlider.addEventListener('input', () => {
2210
+ _wizardState.maxMinutes = parseInt(minSlider.value, 10);
2211
+ minVal.textContent = minSlider.value + 'm';
2212
+ updateWizardPreview();
2213
+ });
2214
+ }
2215
+ }
2216
+
2217
+ function updateWizardPreview() {
2218
+ const pre = _panel?.querySelector('.awiz-preview-text');
2219
+ if (pre) pre.textContent = formatLoopCommand(_wizardPreset.buildCommand(_wizardState), _wizardState);
2220
+ }
2221
+
2222
+ function collectInputValues() {
2223
+ if (!_panel) return;
2224
+ _panel.querySelectorAll('.awiz-step-content input[data-key], .awiz-step-content textarea[data-key]').forEach(input => {
2225
+ _wizardState[input.dataset.key] = input.type === 'number' ? Number(input.value) : input.value;
2226
+ });
2227
+ _panel.querySelectorAll('.awiz-chips').forEach(group => {
2228
+ const key = group.dataset.key;
2229
+ const mode = group.dataset.mode;
2230
+ if (mode === 'single') {
2231
+ const active = group.querySelector('.awiz-chip.active');
2232
+ if (active) _wizardState[key] = active.dataset.value;
2233
+ } else {
2234
+ _wizardState[key] = Array.from(group.querySelectorAll('.awiz-chip.active')).map(c => c.dataset.value);
2235
+ }
2236
+ });
2237
+ }
2238
+
2239
+ // ═══════════════════════════════════════════
2240
+ // RUNNING VIEW
2241
+ // ═══════════════════════════════════════════
2242
+
2243
+ function renderRunningMain() {
2244
+ const main = $('as-main');
2245
+ if (!main) return;
2246
+
2247
+ const hasLoop = _activeLoop?.active;
2248
+ const hasAgents = _agents.length > 0;
2249
+
2250
+ if (!hasLoop && !hasAgents) {
2251
+ main.innerHTML = `
2252
+ <div class="as-detail-header">
2253
+ <button class="as-back-btn" data-action="go-welcome">\u2190 Back</button>
2254
+ <h3 style="font-size:14px;font-weight:600;color:var(--t-bright)">Running</h3>
2255
+ </div>
2256
+ <div class="as-empty">
2257
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
2258
+ <div>No automations running</div>
2259
+ <div style="margin-top:8px;font-size:11px;color:var(--t-faint)">Launch an automation from a template to see it here.</div>
2260
+ </div>`;
2261
+ return;
2262
+ }
2263
+
2264
+ let html = `
2265
+ <div class="as-detail-header">
2266
+ <button class="as-back-btn" data-action="go-welcome">\u2190 Back</button>
2267
+ <h3 style="font-size:14px;font-weight:600;color:var(--t-bright)">Running</h3>
2268
+ </div>
2269
+ <div style="padding:16px;display:flex;flex-direction:column;gap:12px;overflow-y:auto;max-height:calc(100% - 52px)">`;
2270
+
2271
+ // Active loop card
2272
+ if (hasLoop) {
2273
+ const loop = _activeLoop;
2274
+ const pct = loop.totalIterations > 0 ? Math.round((loop.currentIteration / loop.totalIterations) * 100) : 0;
2275
+ html += `
2276
+ <div class="as-running-card">
2277
+ <div class="as-running-header">
2278
+ <div class="as-running-label"><span class="as-running-pulse"></span> Loop Active</div>
2279
+ ${loop.usesBrowser ? '<span class="as-browser-indicator"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg> Browser Active</span>' : ''}
2280
+ </div>
2281
+ <div class="as-running-task">${esc(loop.task)}</div>
2282
+ <div class="as-running-progress"><div class="as-running-progress-fill" style="width:${pct}%"></div></div>
2283
+ <div class="as-running-stats">
2284
+ <span>Iteration ${loop.currentIteration}/${loop.totalIterations}</span>
2285
+ <span>${loop.remainingMinutes ?? '?'}min remaining</span>
2286
+ <span>${loop.elapsedMinutes ?? '?'}min elapsed</span>
2287
+ </div>
2288
+ <div class="as-running-controls">
2289
+ <button class="as-btn-stop" data-action="force-stop">Stop</button>
2290
+ </div>
2291
+ </div>`;
2292
+ }
2293
+
2294
+ // Agent cards
2295
+ for (const agent of _agents) {
2296
+ const statusClass = `as-agent-status--${agent.status}`;
2297
+ const statusLabel = agent.status === 'running' ? 'Running' : agent.status === 'completed' ? 'Completed' : agent.status === 'stopped' ? 'Stopped' : 'Failed';
2298
+ const elapsed = agentElapsed(agent);
2299
+ const preview = (_agentOutputs[agent.id] || agent.textOutput || '').slice(-300);
2300
+ const isLoop = agent.mode === 'loop' && agent.totalIterations > 1;
2301
+ const iterPct = isLoop && agent.totalIterations > 0 ? Math.round((agent.currentIteration / agent.totalIterations) * 100) : 0;
2302
+ const journal = _agentJournals[agent.id] || agent.journal || [];
2303
+ const recentTools = _agentRecentTools[agent.id] || agent.recentTools || [];
2304
+ const lastJournal = journal.length > 0 ? journal[journal.length - 1] : null;
2305
+
2306
+ html += `
2307
+ <div class="as-agent-card ${statusClass}" data-agent-id="${agent.id}">
2308
+ <div class="as-agent-header">
2309
+ <div class="as-agent-label">
2310
+ ${agent.status === 'running' ? '<span class="as-running-pulse"></span>' : ''}
2311
+ <span class="as-agent-status-badge ${statusClass}">${statusLabel}</span>
2312
+ ${isLoop ? `<span class="as-agent-iter-badge">${agent.currentIteration}/${agent.totalIterations}</span>` : ''}
2313
+ <span class="as-agent-model">${esc(agent.model)}</span>
2314
+ ${agent.withSynabun ? '<span class="as-agent-synabun-badge">SB</span>' : ''}
2315
+ </div>
2316
+ <div class="as-agent-actions">
2317
+ ${agent.status === 'running' ? `<button class="as-agent-stop-btn" data-action="agent-stop" data-id="${agent.id}" data-tooltip="Stop">&#9632;</button>` : ''}
2318
+ <button class="as-agent-remove-btn" data-action="agent-remove" data-id="${agent.id}" data-tooltip="Remove">&times;</button>
2319
+ </div>
2320
+ </div>
2321
+ <div class="as-agent-task">${esc(agent.task.slice(0, 150))}${agent.task.length > 150 ? '\u2026' : ''}</div>
2322
+ ${isLoop ? `<div class="as-running-progress"><div class="as-running-progress-fill" style="width:${iterPct}%"></div></div>` : ''}`;
2323
+
2324
+ // Live tool feed
2325
+ if (recentTools.length > 0) {
2326
+ html += `<div class="as-agent-tool-feed" id="as-agent-tools-${agent.id}">${recentTools.map(t => t.replace('mcp__SynaBun__', '')).join(' > ')}</div>`;
2327
+ }
2328
+
2329
+ // Structured progress from last handoff
2330
+ if (lastJournal?.done || lastJournal?.next) {
2331
+ html += `<div class="as-agent-handoff">`;
2332
+ if (lastJournal.done) html += `<div class="as-handoff-done"><span class="as-handoff-label">Done:</span> ${esc(lastJournal.done.slice(0, 200))}</div>`;
2333
+ if (lastJournal.next) html += `<div class="as-handoff-next"><span class="as-handoff-label">Next:</span> ${esc(lastJournal.next.slice(0, 200))}</div>`;
2334
+ if (lastJournal.state) html += `<div class="as-handoff-state"><span class="as-handoff-label">State:</span> ${esc(lastJournal.state.slice(0, 150))}</div>`;
2335
+ html += `</div>`;
2336
+ }
2337
+
2338
+ // Iteration journal timeline (collapsed by default, last 5 entries)
2339
+ if (isLoop && journal.length > 0) {
2340
+ const shownJournal = journal.slice(-5);
2341
+ html += `<div class="as-agent-journal">`;
2342
+ for (const entry of shownJournal) {
2343
+ const toolBadge = entry.toolCount ? ` <span class="as-journal-tools">${entry.toolCount}t</span>` : '';
2344
+ html += `<div class="as-journal-entry"><span class="as-journal-iter">#${entry.iteration}</span>${toolBadge} ${esc((entry.done || entry.summary || '').slice(0, 120))}</div>`;
2345
+ }
2346
+ html += `</div>`;
2347
+ }
2348
+
2349
+ html += `
2350
+ <div class="as-agent-output" id="as-agent-output-${agent.id}">${preview ? esc(preview) : '<span class="as-agent-waiting">Waiting for output...</span>'}</div>
2351
+ <div class="as-agent-footer">
2352
+ <span class="as-agent-elapsed">${elapsed}</span>
2353
+ ${isLoop ? `<span class="as-agent-iter">iter ${agent.currentIteration}/${agent.totalIterations}</span>` : ''}
2354
+ ${agent.toolUseCount ? `<span class="as-agent-tools">${agent.toolUseCount} tools</span>` : ''}
2355
+ ${agent.costUsd != null ? `<span class="as-agent-cost">$${agent.costUsd.toFixed(4)}</span>` : ''}
2356
+ ${agent.browserSessionId ? '<span class="as-agent-browser-tag">browser</span>' : ''}
2357
+ </div>
2358
+ </div>`;
2359
+ }
2360
+
2361
+ html += '</div>';
2362
+ main.innerHTML = html;
2363
+
2364
+ // Auto-scroll agent output areas to bottom
2365
+ for (const agent of _agents) {
2366
+ const outputEl = $(`as-agent-output-${agent.id}`);
2367
+ if (outputEl) outputEl.scrollTop = outputEl.scrollHeight;
2368
+ }
2369
+ }
2370
+
2371
+ // ── Agent helpers ──
2372
+
2373
+ function agentElapsed(agent) {
2374
+ const start = new Date(agent.startedAt).getTime();
2375
+ const end = agent.endedAt ? new Date(agent.endedAt).getTime() : Date.now();
2376
+ const secs = Math.floor((end - start) / 1000);
2377
+ if (secs < 60) return `${secs}s`;
2378
+ const mins = Math.floor(secs / 60);
2379
+ const remainSecs = secs % 60;
2380
+ return `${mins}m ${remainSecs}s`;
2381
+ }
2382
+
2383
+ async function refreshAgents() {
2384
+ try {
2385
+ const data = await fetchAgents();
2386
+ _agents = data?.agents || [];
2387
+ // Update local output buffers with any text we haven't captured via WebSocket
2388
+ for (const a of _agents) {
2389
+ if (!_agentOutputs[a.id] && a.textLength > 0) {
2390
+ // Fetch full details to get textOutput
2391
+ try {
2392
+ const full = await fetchAgent(a.id);
2393
+ if (full?.textOutput) _agentOutputs[a.id] = full.textOutput;
2394
+ } catch {}
2395
+ }
2396
+ }
2397
+ } catch {}
2398
+ }
2399
+
2400
+ function setupAgentWebSocket() {
2401
+ // Listen for agent events on the sync WebSocket
2402
+ on('sync:agent:output', (data) => {
2403
+ if (!data?.agentId || !data?.event) return;
2404
+ const event = data.event;
2405
+ // Extract text and tool uses from assistant messages
2406
+ if (event.type === 'assistant' && event.message?.content) {
2407
+ for (const block of event.message.content) {
2408
+ if (block.type === 'text') {
2409
+ _agentOutputs[data.agentId] = (_agentOutputs[data.agentId] || '') + block.text;
2410
+ // Live-update the output element if visible
2411
+ const outputEl = $(`as-agent-output-${data.agentId}`);
2412
+ if (outputEl) {
2413
+ const preview = (_agentOutputs[data.agentId] || '').slice(-300);
2414
+ outputEl.textContent = preview;
2415
+ outputEl.scrollTop = outputEl.scrollHeight;
2416
+ }
2417
+ } else if (block.type === 'tool_use') {
2418
+ // Track recent tools for live feed
2419
+ if (!_agentRecentTools[data.agentId]) _agentRecentTools[data.agentId] = [];
2420
+ _agentRecentTools[data.agentId].push(block.name);
2421
+ if (_agentRecentTools[data.agentId].length > 8) _agentRecentTools[data.agentId].shift();
2422
+ // Increment tool count
2423
+ const idx = _agents.findIndex(a => a.id === data.agentId);
2424
+ if (idx >= 0) _agents[idx].toolUseCount = (_agents[idx].toolUseCount || 0) + 1;
2425
+ // Live-update the tool feed element if visible
2426
+ const toolEl = $(`as-agent-tools-${data.agentId}`);
2427
+ if (toolEl) {
2428
+ const tools = _agentRecentTools[data.agentId] || [];
2429
+ toolEl.textContent = tools.map(t => t.replace('mcp__SynaBun__', '')).join(' > ');
2430
+ }
2431
+ }
2432
+ }
2433
+ }
2434
+ });
2435
+
2436
+ on('sync:agent:launched', async () => {
2437
+ await refreshAgents();
2438
+ if (_view === 'running') renderRunningMain();
2439
+ });
2440
+
2441
+ on('sync:agent:status', async (data) => {
2442
+ // Update local agent status
2443
+ const idx = _agents.findIndex(a => a.id === data?.agentId);
2444
+ if (idx >= 0) _agents[idx].status = data.status;
2445
+ if (_view === 'running') renderRunningMain();
2446
+ });
2447
+
2448
+ on('sync:agent:removed', (data) => {
2449
+ _agents = _agents.filter(a => a.id !== data?.agentId);
2450
+ delete _agentOutputs[data?.agentId];
2451
+ delete _agentRecentTools[data?.agentId];
2452
+ delete _agentJournals[data?.agentId];
2453
+ if (_view === 'running') renderRunningMain();
2454
+ });
2455
+
2456
+ on('sync:agent:iteration', (data) => {
2457
+ const idx = _agents.findIndex(a => a.id === data?.agentId);
2458
+ if (idx >= 0) {
2459
+ _agents[idx].currentIteration = data.iteration;
2460
+ _agents[idx].totalIterations = data.total;
2461
+ }
2462
+ if (_view === 'running') renderRunningMain();
2463
+ });
2464
+
2465
+ on('sync:agent:iteration-complete', (data) => {
2466
+ if (!data?.agentId) return;
2467
+ if (!_agentJournals[data.agentId]) _agentJournals[data.agentId] = [];
2468
+ _agentJournals[data.agentId].push({
2469
+ iteration: data.iteration,
2470
+ summary: data.summary,
2471
+ done: data.done,
2472
+ next: data.next,
2473
+ state: data.state,
2474
+ toolCount: data.toolCount,
2475
+ });
2476
+ // Keep bounded
2477
+ if (_agentJournals[data.agentId].length > 20) _agentJournals[data.agentId] = _agentJournals[data.agentId].slice(-15);
2478
+ // Update journal on the local agent object too
2479
+ const idx = _agents.findIndex(a => a.id === data.agentId);
2480
+ if (idx >= 0) _agents[idx].journal = _agentJournals[data.agentId];
2481
+ if (_view === 'running') renderRunningMain();
2482
+ });
2483
+ }
2484
+
2485
+ function setupScheduleWebSocket() {
2486
+ on('sync:schedule:created', (data) => {
2487
+ if (data?.schedule) {
2488
+ _schedules.push(data.schedule);
2489
+ if (_view === 'schedules') renderSchedulesMain();
2490
+ }
2491
+ });
2492
+
2493
+ on('sync:schedule:updated', (data) => {
2494
+ if (data?.schedule) {
2495
+ const idx = _schedules.findIndex(s => s.id === data.schedule.id);
2496
+ if (idx >= 0) _schedules[idx] = data.schedule;
2497
+ else _schedules.push(data.schedule);
2498
+ if (_view === 'schedules') renderSchedulesMain();
2499
+ }
2500
+ });
2501
+
2502
+ on('sync:schedule:deleted', (data) => {
2503
+ if (data?.scheduleId) {
2504
+ _schedules = _schedules.filter(s => s.id !== data.scheduleId);
2505
+ if (_view === 'schedules') renderSchedulesMain();
2506
+ }
2507
+ });
2508
+
2509
+ on('sync:schedule:fired', (data) => {
2510
+ if (data?.scheduleName) showToast(`Schedule fired: ${data.scheduleName}`);
2511
+ });
2512
+
2513
+ on('sync:schedule:completed', (data) => {
2514
+ loadScheduleData().then(() => { if (_view === 'schedules') renderSchedulesMain(); });
2515
+ // Auto-attach the floating terminal so the user can see the scheduled loop running.
2516
+ // The server-side loop driver handles the initial message and auto-confirm —
2517
+ // this just provides UI visibility.
2518
+ if (data?.terminalSessionId) {
2519
+ emit('terminal:attach-floating', {
2520
+ terminalSessionId: data.terminalSessionId,
2521
+ profile: data.profile || 'claude-code',
2522
+ snapToPanel: false,
2523
+ });
2524
+ }
2525
+ });
2526
+
2527
+ on('sync:schedule:failed', (data) => {
2528
+ if (data?.reason) showToast(`Schedule failed: ${data.reason}`);
2529
+ loadScheduleData().then(() => { if (_view === 'schedules') renderSchedulesMain(); });
2530
+ });
2531
+
2532
+ on('sync:schedule:timer-set', (data) => {
2533
+ if (data?.scheduleId && data?.firesAt) {
2534
+ _scheduleTimerData[data.scheduleId] = { firesAt: data.firesAt, minutes: data.minutes };
2535
+ if (_view === 'schedules') renderSchedulesMain();
2536
+ }
2537
+ });
2538
+
2539
+ on('sync:schedule:timer-fired', (data) => {
2540
+ if (data?.scheduleName) showToast(`Timer fired: ${data.scheduleName}`);
2541
+ if (data?.scheduleId) delete _scheduleTimerData[data.scheduleId];
2542
+ loadScheduleData().then(() => { if (_view === 'schedules') renderSchedulesMain(); });
2543
+ });
2544
+
2545
+ on('sync:schedule:timer-cancelled', (data) => {
2546
+ if (data?.scheduleId) delete _scheduleTimerData[data.scheduleId];
2547
+ if (_view === 'schedules') renderSchedulesMain();
2548
+ });
2549
+
2550
+ // Quick timer events
2551
+ on('sync:quick-timer:set', (data) => {
2552
+ if (data?.timerId && !_quickTimers.some(t => t.id === data.timerId)) {
2553
+ _quickTimers.push({ id: data.timerId, templateName: data.templateName, firesAt: data.firesAt, minutes: data.minutes, profile: data.profile, model: data.model, usesBrowser: data.usesBrowser });
2554
+ if (_view === 'schedules') renderSchedulesMain();
2555
+ }
2556
+ });
2557
+
2558
+ on('sync:quick-timer:fired', (data) => {
2559
+ if (data?.templateName) showToast(`Timer fired: ${data.templateName}`);
2560
+ if (data?.timerId) _quickTimers = _quickTimers.filter(t => t.id !== data.timerId);
2561
+ if (_view === 'schedules') renderSchedulesMain();
2562
+ });
2563
+
2564
+ on('sync:quick-timer:fired-now', (data) => {
2565
+ if (data?.templateName) showToast(`Running now: ${data.templateName}`);
2566
+ if (_view === 'schedules') renderSchedulesMain();
2567
+ });
2568
+
2569
+ on('sync:quick-timer:cancelled', (data) => {
2570
+ if (data?.timerId) _quickTimers = _quickTimers.filter(t => t.id !== data.timerId);
2571
+ if (_view === 'schedules') renderSchedulesMain();
2572
+ });
2573
+
2574
+ on('sync:quick-timer:failed', (data) => {
2575
+ if (data?.reason) showToast(`Timer failed: ${data.reason}`);
2576
+ });
2577
+ }
2578
+
2579
+ // ═══════════════════════════════════════════
2580
+ // ═══════════════════════════════════════════
2581
+ // EVENT HANDLERS (delegated)
2582
+ // ═══════════════════════════════════════════
2583
+
2584
+ async function handlePanelClick(e) {
2585
+ const btn = e.target.closest('[data-action]');
2586
+ if (!btn) return;
2587
+ const action = btn.dataset.action;
2588
+ const id = btn.dataset.id;
2589
+
2590
+ switch (action) {
2591
+ case 'go-welcome':
2592
+ if (_detailDirty && !confirm('Discard unsaved changes?')) return;
2593
+ _view = 'welcome'; _selected = null; resetDirty();
2594
+ renderView();
2595
+ break;
2596
+
2597
+ case 'go-picker':
2598
+ _view = 'picker'; renderView();
2599
+ break;
2600
+
2601
+ case 'launch-inline-close':
2602
+ closeLaunchInline();
2603
+ break;
2604
+
2605
+ case 'launch-inline-confirm':
2606
+ confirmLaunch();
2607
+ break;
2608
+
2609
+ case 'unsaved-dialog-close':
2610
+ _panel?.querySelector('.as-unsaved-dialog')?.remove();
2611
+ break;
2612
+
2613
+ case 'unsaved-save-launch':
2614
+ handleSaveAndLaunch();
2615
+ break;
2616
+
2617
+ case 'open-detail': {
2618
+ const all = [...PRESET_TEMPLATES, ..._templates];
2619
+ const tpl = all.find(t => t.id === id);
2620
+ if (tpl) switchToDetail(tpl);
2621
+ break;
2622
+ }
2623
+
2624
+ case 'pick-preset': {
2625
+ const preset = PRESET_TEMPLATES.find(t => t.id === id);
2626
+ if (preset) openWizard(preset);
2627
+ break;
2628
+ }
2629
+
2630
+ case 'pick-blank': {
2631
+ const blank = { id: '', name: '', description: '', task: '', context: '', iterations: 10, maxMinutes: 30, icon: 'refresh', category: 'custom', usesBrowser: false };
2632
+ _selected = blank;
2633
+ _editorContent = ''; _originalContent = '';
2634
+ resetDirty(); _previewMode = false;
2635
+ _view = 'detail';
2636
+ renderView();
2637
+ break;
2638
+ }
2639
+
2640
+ case 'configure':
2641
+ case 'reconfigure': {
2642
+ const all = [...PRESET_TEMPLATES, ..._templates];
2643
+ const preset = all.find(t => t.id === (_selected?.id || id));
2644
+ if (preset && preset.steps) openWizard(preset);
2645
+ break;
2646
+ }
2647
+
2648
+ case 'customize': {
2649
+ if (_selected) customizePreset(_selected);
2650
+ break;
2651
+ }
2652
+
2653
+ case 'copy-cmd': {
2654
+ const text = $('as-cmd-editor')?.value || _editorContent;
2655
+ const full = formatLoopCommand(text, {
2656
+ iterations: parseInt($('as-f-iterations')?.value || String(_selected?.iterations || 10), 10),
2657
+ maxMinutes: parseInt($('as-f-maxminutes')?.value || String(_selected?.maxMinutes || 30), 10),
2658
+ usesBrowser: $('as-f-browser')?.checked || false,
2659
+ });
2660
+ navigator.clipboard.writeText(full).then(() => showToast('Copied to clipboard')).catch(() => showToast('Copy failed'));
2661
+ break;
2662
+ }
2663
+
2664
+ case 'test-run':
2665
+ handleTestRun();
2666
+ break;
2667
+
2668
+ case 'launch-loop':
2669
+ handleLaunch();
2670
+ break;
2671
+
2672
+ case 'force-stop': {
2673
+ try {
2674
+ const result = await stopLoop();
2675
+ if (result?.ok) {
2676
+ showToast(result.stopped > 0 ? 'Loop stopped' : 'No active loops');
2677
+ _activeLoop = null;
2678
+ updateSidebarFooter();
2679
+ if (_view === 'running') renderRunningMain();
2680
+ } else { showToast(result?.error || 'Stop failed'); }
2681
+ } catch (err) { showToast('Stop failed: ' + err.message); }
2682
+ break;
2683
+ }
2684
+
2685
+ case 'view-running':
2686
+ _view = 'running';
2687
+ refreshAgents().then(() => renderView());
2688
+ break;
2689
+
2690
+ case 'agent-stop': {
2691
+ try {
2692
+ const result = await stopAgent(id);
2693
+ if (result?.ok) {
2694
+ showToast('Agent stopped');
2695
+ const idx = _agents.findIndex(a => a.id === id);
2696
+ if (idx >= 0) _agents[idx].status = 'stopped';
2697
+ if (_view === 'running') renderRunningMain();
2698
+ } else { showToast(result?.error || 'Stop failed'); }
2699
+ } catch (err) { showToast('Stop failed: ' + err.message); }
2700
+ break;
2701
+ }
2702
+
2703
+ case 'agent-remove': {
2704
+ try {
2705
+ const result = await removeAgent(id);
2706
+ if (result?.ok) {
2707
+ _agents = _agents.filter(a => a.id !== id);
2708
+ delete _agentOutputs[id];
2709
+ showToast('Agent removed');
2710
+ if (_view === 'running') renderRunningMain();
2711
+ } else { showToast(result?.error || 'Remove failed'); }
2712
+ } catch (err) { showToast('Remove failed: ' + err.message); }
2713
+ break;
2714
+ }
2715
+
2716
+ case 'delete-template': {
2717
+ if (!confirm('Delete this automation?')) break;
2718
+ try {
2719
+ await deleteLoopTemplate(id);
2720
+ _templates = _templates.filter(t => t.id !== id);
2721
+ if (_selected?.id === id) { _selected = null; _view = 'welcome'; }
2722
+ renderView();
2723
+ showToast('Deleted');
2724
+ } catch (err) { showToast('Delete failed: ' + err.message); }
2725
+ break;
2726
+ }
2727
+
2728
+ case 'export-one': {
2729
+ const tpl = _templates.find(t => t.id === id);
2730
+ if (tpl) downloadJson({ version: 1, type: 'synabun-loop-template', template: tpl }, `loop-${tpl.name.toLowerCase().replace(/\s+/g, '-')}.json`);
2731
+ break;
2732
+ }
2733
+
2734
+ case 'cancel-wizard':
2735
+ _wizardState = null; _wizardStep = 0; _wizardPreset = null;
2736
+ _view = _selected ? 'detail' : 'welcome';
2737
+ renderView();
2738
+ break;
2739
+
2740
+ case 'wizard-back':
2741
+ if (_wizardStep > 0) { collectInputValues(); _wizardStep--; renderWizardStep(); }
2742
+ break;
2743
+
2744
+ case 'wizard-next': {
2745
+ collectInputValues();
2746
+ const step = _wizardPreset.steps[_wizardStep];
2747
+ if (step?.validate) { const err = step.validate(_wizardState); if (err) { showToast(err); return; } }
2748
+ _wizardStep++;
2749
+ renderWizardStep();
2750
+ break;
2751
+ }
2752
+
2753
+ case 'wizard-copy': {
2754
+ collectInputValues();
2755
+ const cmd = formatLoopCommand(_wizardPreset.buildCommand(_wizardState), _wizardState);
2756
+ navigator.clipboard.writeText(cmd).then(() => showToast('Copied to clipboard')).catch(() => showToast('Copy failed'));
2757
+ break;
2758
+ }
2759
+
2760
+ case 'wizard-open-editor': {
2761
+ collectInputValues();
2762
+ const cmd = _wizardPreset.buildCommand(_wizardState);
2763
+ _wizardPreset._lastState = { ..._wizardState };
2764
+ _selected = _wizardPreset;
2765
+ _editorContent = cmd;
2766
+ _originalContent = cmd;
2767
+ resetDirty(); _previewMode = false;
2768
+ _wizardState = null; _wizardStep = 0; _wizardPreset = null;
2769
+ _view = 'detail';
2770
+ renderView();
2771
+ break;
2772
+ }
2773
+
2774
+ case 'wizard-launch': {
2775
+ collectInputValues();
2776
+ const taskText = _wizardPreset.buildCommand(_wizardState);
2777
+ const wizIterations = _wizardState.iterations;
2778
+ const wizMaxMinutes = _wizardState.maxMinutes;
2779
+ const wizUsesBrowser = _wizardState.usesBrowser;
2780
+ _wizardState = null; _wizardStep = 0; _wizardPreset = null;
2781
+ serverLaunchLoop({
2782
+ task: taskText,
2783
+ context: null,
2784
+ iterations: wizIterations,
2785
+ maxMinutes: wizMaxMinutes,
2786
+ usesBrowser: wizUsesBrowser,
2787
+ });
2788
+ break;
2789
+ }
2790
+
2791
+ // ── Schedule actions ──
2792
+
2793
+ case 'schedule-new':
2794
+ _editingSchedule = null;
2795
+ _view = 'schedule-editor';
2796
+ renderView();
2797
+ break;
2798
+
2799
+ case 'schedule-edit': {
2800
+ const sched = _schedules.find(s => s.id === id);
2801
+ if (sched) { _editingSchedule = sched; _view = 'schedule-editor'; renderView(); }
2802
+ break;
2803
+ }
2804
+
2805
+ case 'schedule-toggle': {
2806
+ const sched = _schedules.find(s => s.id === id);
2807
+ if (sched) {
2808
+ try {
2809
+ const updated = await updateSchedule(id, { enabled: !sched.enabled });
2810
+ const idx = _schedules.findIndex(s => s.id === id);
2811
+ if (idx >= 0) _schedules[idx] = updated;
2812
+ renderSchedulesMain();
2813
+ showToast(updated.enabled ? 'Schedule enabled' : 'Schedule paused');
2814
+ } catch (err) { showToast('Toggle failed: ' + err.message); }
2815
+ }
2816
+ break;
2817
+ }
2818
+
2819
+ case 'schedule-test': {
2820
+ try {
2821
+ await testSchedule(id);
2822
+ showToast('Schedule queued for immediate fire');
2823
+ } catch (err) { showToast('Test failed: ' + err.message); }
2824
+ break;
2825
+ }
2826
+
2827
+ case 'schedule-delete': {
2828
+ if (!confirm('Delete this schedule?')) break;
2829
+ try {
2830
+ await deleteSchedule(id);
2831
+ _schedules = _schedules.filter(s => s.id !== id);
2832
+ renderSchedulesMain();
2833
+ showToast('Schedule deleted');
2834
+ } catch (err) { showToast('Delete failed: ' + err.message); }
2835
+ break;
2836
+ }
2837
+
2838
+ case 'schedule-timer-toggle': {
2839
+ const row = document.getElementById(`as-timer-row-${id}`);
2840
+ if (row) row.style.display = row.style.display === 'none' ? 'flex' : 'none';
2841
+ break;
2842
+ }
2843
+
2844
+ case 'schedule-timer-fire': {
2845
+ const mins = Number(el.dataset.minutes);
2846
+ if (!mins) break;
2847
+ try {
2848
+ const result = await startScheduleTimer(id, mins);
2849
+ _scheduleTimerData[id] = { firesAt: result.firesAt, minutes: result.minutes };
2850
+ renderSchedulesMain();
2851
+ showToast(`Timer set: fires in ${mins}m`);
2852
+ } catch (err) { showToast('Timer failed: ' + err.message); }
2853
+ break;
2854
+ }
2855
+
2856
+ case 'schedule-timer-custom': {
2857
+ const input = document.querySelector(`.as-sched-timer-input[data-id="${id}"]`);
2858
+ const mins = Number(input?.value);
2859
+ if (!mins || mins < 1) { showToast('Enter minutes (1+)'); break; }
2860
+ try {
2861
+ const result = await startScheduleTimer(id, mins);
2862
+ _scheduleTimerData[id] = { firesAt: result.firesAt, minutes: result.minutes };
2863
+ renderSchedulesMain();
2864
+ showToast(`Timer set: fires in ${mins}m`);
2865
+ } catch (err) { showToast('Timer failed: ' + err.message); }
2866
+ break;
2867
+ }
2868
+
2869
+ case 'schedule-timer-cancel': {
2870
+ try {
2871
+ await cancelScheduleTimer(id);
2872
+ delete _scheduleTimerData[id];
2873
+ renderSchedulesMain();
2874
+ showToast('Timer cancelled');
2875
+ } catch (err) { showToast('Cancel failed: ' + err.message); }
2876
+ break;
2877
+ }
2878
+
2879
+ case 'qt-profile': {
2880
+ const profileId = btn.dataset.profile;
2881
+ if (!profileId) break;
2882
+ _launchProfile = profileId;
2883
+ storage.setItem('as-launch-profile', profileId);
2884
+ // Update model to default for new profile
2885
+ const models = CLI_MODELS[profileId] || [];
2886
+ const defaultModel = models.find(m => m.tier === 'default') || models[0];
2887
+ _launchModel = defaultModel?.id || null;
2888
+ storage.setItem('as-launch-model', _launchModel);
2889
+ renderSchedulesMain();
2890
+ break;
2891
+ }
2892
+
2893
+ case 'qt-model': {
2894
+ const modelId = btn.dataset.model;
2895
+ if (!modelId) break;
2896
+ _launchModel = modelId;
2897
+ storage.setItem('as-launch-model', _launchModel);
2898
+ // Update visual selection inline (avoid full re-render)
2899
+ $('as-qt-models')?.querySelectorAll('.as-launch-model').forEach(b => b.classList.remove('active'));
2900
+ btn.classList.add('active');
2901
+ break;
2902
+ }
2903
+
2904
+ case 'qt-select': {
2905
+ const mins = Number(btn.dataset.minutes);
2906
+ if (!mins) break;
2907
+ _selectedQtMinutes = mins;
2908
+ // Clear custom input when preset selected
2909
+ const customInput = $('as-qt-custom-min');
2910
+ if (customInput) customInput.value = '';
2911
+ // Update visual selection
2912
+ btn.closest('.as-qt-presets')?.querySelectorAll('.as-qt-preset[data-action="qt-select"]').forEach(b => b.classList.remove('as-qt-preset--selected'));
2913
+ btn.classList.add('as-qt-preset--selected');
2914
+ break;
2915
+ }
2916
+
2917
+ case 'qt-go': {
2918
+ const templateId = $('as-qt-template')?.value;
2919
+ if (!templateId) { showToast('Select a template first'); break; }
2920
+ // Custom input takes priority if filled, otherwise use selected preset
2921
+ const customVal = Number($('as-qt-custom-min')?.value);
2922
+ const mins = (customVal && customVal >= 1) ? customVal : _selectedQtMinutes;
2923
+ if (!mins) { showToast('Select a time or enter minutes'); break; }
2924
+ const qtProfile = _launchProfile || 'claude-code';
2925
+ const qtModel = _launchModel || null;
2926
+ const qtBrowser = !!$('as-qt-browser')?.checked;
2927
+ try {
2928
+ const result = await createQuickTimer(templateId, mins, { profile: qtProfile, model: qtModel, usesBrowser: qtBrowser });
2929
+ // Guard: WebSocket sync:quick-timer:set may arrive before this HTTP response
2930
+ if (!_quickTimers.some(t => t.id === result.timerId)) {
2931
+ _quickTimers.push({ id: result.timerId, templateId, templateName: result.templateName, firesAt: result.firesAt, minutes: result.minutes, profile: result.profile, model: result.model, usesBrowser: result.usesBrowser });
2932
+ }
2933
+ _selectedQtMinutes = null;
2934
+ _qtUsesBrowser = null;
2935
+ renderSchedulesMain();
2936
+ const label = mins >= 60 ? `${mins / 60}h` : `${mins}m`;
2937
+ showToast(`Timer set: ${result.templateName} in ${label}`);
2938
+ } catch (err) { showToast('Timer failed: ' + err.message); }
2939
+ break;
2940
+ }
2941
+
2942
+ case 'qt-now': {
2943
+ const templateId = $('as-qt-template')?.value;
2944
+ if (!templateId) { showToast('Select a template first'); break; }
2945
+ const qtProfile = _launchProfile || 'claude-code';
2946
+ const qtModel = _launchModel || null;
2947
+ const qtBrowser = !!$('as-qt-browser')?.checked;
2948
+ try {
2949
+ const result = await triggerQuickTimerNow(templateId, { profile: qtProfile, model: qtModel, usesBrowser: qtBrowser });
2950
+ showToast(`Firing now: ${result.templateName}`);
2951
+ } catch (err) { showToast('Run Now failed: ' + err.message); }
2952
+ break;
2953
+ }
2954
+
2955
+ case 'qt-cancel': {
2956
+ try {
2957
+ await cancelQuickTimer(id);
2958
+ _quickTimers = _quickTimers.filter(t => t.id !== id);
2959
+ renderSchedulesMain();
2960
+ showToast('Timer cancelled');
2961
+ } catch (err) { showToast('Cancel failed: ' + err.message); }
2962
+ break;
2963
+ }
2964
+
2965
+ case 'toggle-advanced-schedules':
2966
+ _showAdvancedSchedules = !_showAdvancedSchedules;
2967
+ renderSchedulesMain();
2968
+ break;
2969
+
2970
+ case 'schedule-save':
2971
+ await saveScheduleFromEditor();
2972
+ break;
2973
+
2974
+ case 'schedule-cancel':
2975
+ _editingSchedule = null;
2976
+ _view = 'schedules';
2977
+ renderView();
2978
+ break;
2979
+
2980
+ case 'schedule-back':
2981
+ _view = 'schedules';
2982
+ _editingSchedule = null;
2983
+ renderView();
2984
+ break;
2985
+ }
2986
+ }
2987
+
2988
+ // ═══════════════════════════════════════════
2989
+ // WIZARD INFRASTRUCTURE (chip/input/rules)
2990
+ // ═══════════════════════════════════════════
2991
+
2992
+ function wireRulesListEvents(container, state) {
2993
+ if (!Array.isArray(state.rules)) state.rules = [];
2994
+ const list = container.querySelector('#awiz-rules-list');
2995
+ const input = container.querySelector('#awiz-rule-input');
2996
+ const addBtn = container.querySelector('#awiz-rule-add');
2997
+ if (!list || !input || !addBtn) return;
2998
+
2999
+ function rerender() {
3000
+ if (state.rules.length === 0) {
3001
+ list.innerHTML = '<div class="awiz-rules-empty">No rules yet.</div>';
3002
+ } else {
3003
+ list.innerHTML = state.rules.map((rule, i) => `
3004
+ <div class="awiz-rule-item" draggable="true" data-rule-idx="${i}">
3005
+ <span class="awiz-rule-grip" data-tooltip="Drag to reorder">\u2261</span>
3006
+ <span class="awiz-rule-num">${i + 1}</span>
3007
+ <span class="awiz-rule-text">${esc(rule)}</span>
3008
+ <button class="awiz-rule-remove" data-remove-idx="${i}" data-tooltip="Remove">&times;</button>
3009
+ </div>`).join('');
3010
+ wireDragDrop();
3011
+ }
3012
+ }
3013
+
3014
+ function addRule() {
3015
+ const val = input.value.trim();
3016
+ if (!val) return;
3017
+ state.rules.push(val);
3018
+ input.value = ''; input.focus();
3019
+ rerender();
3020
+ }
3021
+
3022
+ addBtn.addEventListener('click', (e) => { e.preventDefault(); addRule(); });
3023
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addRule(); } });
3024
+
3025
+ list.addEventListener('click', (e) => {
3026
+ const removeBtn = e.target.closest('.awiz-rule-remove');
3027
+ if (!removeBtn) return;
3028
+ const idx = parseInt(removeBtn.dataset.removeIdx, 10);
3029
+ if (!isNaN(idx) && idx >= 0 && idx < state.rules.length) {
3030
+ state.rules.splice(idx, 1);
3031
+ rerender();
3032
+ }
3033
+ });
3034
+
3035
+ let _dragIdx = null;
3036
+ function wireDragDrop() {
3037
+ list.querySelectorAll('.awiz-rule-item').forEach(item => {
3038
+ item.addEventListener('dragstart', (e) => {
3039
+ _dragIdx = parseInt(item.dataset.ruleIdx, 10);
3040
+ item.classList.add('awiz-rule-dragging');
3041
+ e.dataTransfer.effectAllowed = 'move';
3042
+ });
3043
+ item.addEventListener('dragend', () => {
3044
+ item.classList.remove('awiz-rule-dragging'); _dragIdx = null;
3045
+ list.querySelectorAll('.awiz-rule-item').forEach(el => el.classList.remove('awiz-rule-over'));
3046
+ });
3047
+ item.addEventListener('dragover', (e) => {
3048
+ e.preventDefault(); e.dataTransfer.dropEffect = 'move';
3049
+ list.querySelectorAll('.awiz-rule-item').forEach(el => el.classList.remove('awiz-rule-over'));
3050
+ item.classList.add('awiz-rule-over');
3051
+ });
3052
+ item.addEventListener('dragleave', () => { item.classList.remove('awiz-rule-over'); });
3053
+ item.addEventListener('drop', (e) => {
3054
+ e.preventDefault();
3055
+ const dropIdx = parseInt(item.dataset.ruleIdx, 10);
3056
+ if (_dragIdx !== null && _dragIdx !== dropIdx) {
3057
+ const [moved] = state.rules.splice(_dragIdx, 1);
3058
+ state.rules.splice(dropIdx, 0, moved);
3059
+ rerender();
3060
+ }
3061
+ });
3062
+ });
3063
+ }
3064
+ if (state.rules.length > 0) wireDragDrop();
3065
+ }
3066
+
3067
+ function wireChipEvents(container) {
3068
+ container.querySelectorAll('.awiz-chips').forEach(group => {
3069
+ group.querySelectorAll('.awiz-chip').forEach(btn => {
3070
+ const fresh = btn.cloneNode(true);
3071
+ btn.parentNode.replaceChild(fresh, btn);
3072
+ fresh.addEventListener('click', (e) => {
3073
+ e.preventDefault();
3074
+ const c = fresh.closest('.awiz-chips');
3075
+ const mode = c.dataset.mode;
3076
+ const key = c.dataset.key;
3077
+ const value = fresh.dataset.value;
3078
+ if (mode === 'single') {
3079
+ c.querySelectorAll('.awiz-chip').forEach(ch => ch.classList.remove('active'));
3080
+ fresh.classList.add('active');
3081
+ _wizardState[key] = value;
3082
+ } else {
3083
+ fresh.classList.toggle('active');
3084
+ _wizardState[key] = Array.from(c.querySelectorAll('.awiz-chip.active')).map(ch => ch.dataset.value);
3085
+ }
3086
+ });
3087
+ });
3088
+ });
3089
+ }
3090
+
3091
+ function wireInputSync(container) {
3092
+ container.querySelectorAll('.awiz-step-content input[data-key], .awiz-step-content textarea[data-key]').forEach(input => {
3093
+ input.addEventListener('input', () => {
3094
+ _wizardState[input.dataset.key] = input.type === 'number' ? Number(input.value) : input.value;
3095
+ });
3096
+ });
3097
+ }
3098
+
3099
+ // ═══════════════════════════════════════════
3100
+ // SCHEDULES
3101
+ // ═══════════════════════════════════════════
3102
+
3103
+ async function loadScheduleData() {
3104
+ try { _schedules = await fetchSchedules(); } catch { _schedules = []; }
3105
+ try { _scheduleTimerData = await fetchScheduleTimers(); } catch { _scheduleTimerData = {}; }
3106
+ try { _quickTimers = await fetchQuickTimers(); } catch { _quickTimers = []; }
3107
+ }
3108
+
3109
+ const CRON_PRESET_GROUPS = [
3110
+ { group: 'Frequency', presets: [
3111
+ { label: 'Every 15 Min', cron: '*/15 * * * *', desc: 'Every 15 minutes' },
3112
+ { label: 'Every 30 Min', cron: '*/30 * * * *', desc: 'Every 30 minutes' },
3113
+ { label: 'Every Hour', cron: '0 * * * *', desc: 'Every hour at :00' },
3114
+ { label: 'Every 2 Hours', cron: '0 */2 * * *', desc: 'Every 2 hours at :00' },
3115
+ { label: 'Every 3 Hours', cron: '0 */3 * * *', desc: 'Every 3 hours at :00' },
3116
+ { label: 'Every 6 Hours', cron: '0 */6 * * *', desc: 'Every 6 hours at :00' },
3117
+ ]},
3118
+ { group: 'Daily', presets: [
3119
+ { label: 'Morning 9am', cron: '0 9 * * *', desc: 'Every day at 9:00' },
3120
+ { label: 'Midday 12pm', cron: '0 12 * * *', desc: 'Every day at 12:00' },
3121
+ { label: 'Evening 6pm', cron: '0 18 * * *', desc: 'Every day at 18:00' },
3122
+ { label: '2x (10am, 6pm)', cron: '0 10,18 * * *', desc: 'Every day at 10:00, 18:00' },
3123
+ { label: '3x (9am, 2pm, 7pm)', cron: '0 9,14,19 * * *', desc: 'Every day at 9:00, 14:00, 19:00' },
3124
+ { label: '4x (8a, 12p, 4p, 8p)', cron: '0 8,12,16,20 * * *', desc: 'Every day at 8:00, 12:00, 16:00, 20:00' },
3125
+ ]},
3126
+ { group: 'Weekly', presets: [
3127
+ { label: 'Weekdays 9am', cron: '0 9 * * 1-5', desc: 'Mon–Fri at 9:00' },
3128
+ { label: 'Weekdays 2x', cron: '0 10,18 * * 1-5', desc: 'Mon–Fri at 10:00, 18:00' },
3129
+ { label: 'Weekdays 3x', cron: '0 9,14,19 * * 1-5', desc: 'Mon–Fri at 9:00, 14:00, 19:00' },
3130
+ { label: 'Weekends 10am', cron: '0 10 * * 0,6', desc: 'Sat & Sun at 10:00' },
3131
+ { label: 'Mon/Wed/Fri', cron: '0 9 * * 1,3,5', desc: 'Mon, Wed, Fri at 9:00' },
3132
+ { label: 'Tue/Thu', cron: '0 9 * * 2,4', desc: 'Tue, Thu at 9:00' },
3133
+ ]},
3134
+ ];
3135
+ // Flat list for backward compat
3136
+ const CRON_PRESETS = CRON_PRESET_GROUPS.flatMap(g => g.presets);
3137
+
3138
+ const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
3139
+
3140
+ function describeCronClient(cronStr) {
3141
+ const fields = cronStr.trim().split(/\s+/);
3142
+ if (fields.length !== 5) return cronStr;
3143
+ const [minute, hour, , , dayOfWeek] = fields;
3144
+ let dayPart = '';
3145
+ if (dayOfWeek === '*') dayPart = 'Every day';
3146
+ else if (dayOfWeek === '1-5') dayPart = 'Weekdays';
3147
+ else if (dayOfWeek === '0,6') dayPart = 'Weekends';
3148
+ else dayPart = dayOfWeek.split(',').map(d => DAY_NAMES[+d] || d).join(', ');
3149
+ const pad = (n) => String(n).padStart(2, '0');
3150
+ let timePart;
3151
+ // Handle */N minute patterns (e.g. */15, */30)
3152
+ const minStep = minute.match(/^\*\/(\d+)$/);
3153
+ if (minStep && hour === '*') {
3154
+ return `${dayPart} — every ${minStep[1]} minutes`;
3155
+ }
3156
+ // Handle */N hour patterns (e.g. */2, */3, */6)
3157
+ const hourStep = hour.match(/^\*\/(\d+)$/);
3158
+ if (hourStep) {
3159
+ timePart = `every ${hourStep[1]} hours at :${pad(+minute)}`;
3160
+ } else if (hour.includes(',')) {
3161
+ timePart = hour.split(',').map(h => `${pad(+h)}:${pad(+minute)}`).join(', ');
3162
+ } else if (hour === '*') {
3163
+ timePart = `every hour at :${pad(+minute)}`;
3164
+ } else {
3165
+ timePart = `${pad(+hour)}:${pad(+minute)}`;
3166
+ }
3167
+ return `${dayPart} at ${timePart}`;
3168
+ }
3169
+
3170
+ function formatNextRun(nextRun) {
3171
+ if (!nextRun) return 'N/A';
3172
+ const d = new Date(nextRun);
3173
+ const now = new Date();
3174
+ const diffMs = d - now;
3175
+ if (diffMs < 0) return 'Overdue';
3176
+ if (diffMs < 60_000) return 'Under a minute';
3177
+ if (diffMs < 3600_000) return `${Math.round(diffMs / 60_000)}m`;
3178
+ if (diffMs < 86400_000) return `${Math.round(diffMs / 3600_000)}h`;
3179
+ return `${Math.round(diffMs / 86400_000)}d`;
3180
+ }
3181
+
3182
+ function scheduleStatusBadge(schedule) {
3183
+ if (!schedule.enabled) return '<span class="as-sched-badge as-sched-badge--paused">Paused</span>';
3184
+ if (schedule.lastRunResult === 'template_missing') return '<span class="as-sched-badge as-sched-badge--error">Missing Template</span>';
3185
+ if (schedule.lastRunResult?.startsWith('error:')) return '<span class="as-sched-badge as-sched-badge--error">Error</span>';
3186
+ if (schedule.lastRunResult === 'launched') return '<span class="as-sched-badge as-sched-badge--ok">Active</span>';
3187
+ return '<span class="as-sched-badge as-sched-badge--ok">Active</span>';
3188
+ }
3189
+
3190
+ function formatTimerCountdown(firesAtISO) {
3191
+ const diff = new Date(firesAtISO) - Date.now();
3192
+ if (diff <= 0) return 'firing...';
3193
+ const mins = Math.ceil(diff / 60_000);
3194
+ if (mins >= 60) {
3195
+ const h = Math.floor(mins / 60);
3196
+ const m = mins % 60;
3197
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
3198
+ }
3199
+ return `${mins}m`;
3200
+ }
3201
+
3202
+ function renderSchedulesMain() {
3203
+ const main = $('as-main');
3204
+ if (!main) return;
3205
+
3206
+ const allTemplates = [...PRESET_TEMPLATES, ..._templates];
3207
+ const templateMap = {};
3208
+ for (const t of allTemplates) templateMap[t.id] = t;
3209
+
3210
+ // ── Active Quick Timers ──
3211
+ let timersHTML = '';
3212
+ if (_quickTimers.length > 0) {
3213
+ let timerCards = '';
3214
+ for (const qt of _quickTimers) {
3215
+ const profileLabel = CLI_PROFILES.find(p => p.id === qt.profile)?.label || qt.profile || 'Claude Code';
3216
+ const modelObj = qt.model ? (CLI_MODELS[qt.profile || 'claude-code'] || []).find(m => m.id === qt.model) : null;
3217
+ const modelLabel = modelObj?.label || '';
3218
+ const metaParts = [profileLabel, modelLabel, qt.usesBrowser ? 'Browser' : ''].filter(Boolean);
3219
+ timerCards += `
3220
+ <div class="as-qt-active-card">
3221
+ <div class="as-qt-active-pulse"></div>
3222
+ <div class="as-qt-active-info">
3223
+ <span class="as-qt-active-name">${esc(qt.templateName)}</span>
3224
+ <span class="as-qt-active-meta">${esc(metaParts.join(' \u00b7 '))}</span>
3225
+ </div>
3226
+ <span class="as-qt-active-countdown">${formatTimerCountdown(qt.firesAt)}</span>
3227
+ <button class="as-qt-cancel" data-action="qt-cancel" data-id="${qt.id}" data-tooltip="Cancel timer">${AS_ICONS.close}</button>
3228
+ </div>`;
3229
+ }
3230
+ timersHTML = `<div class="as-qt-active-timers">${timerCards}</div>`;
3231
+ }
3232
+
3233
+ // ── Quick Timer Creator ──
3234
+ const templateOptions = allTemplates.map(t =>
3235
+ `<option value="${t.id}">${esc(t.name)}</option>`
3236
+ ).join('');
3237
+
3238
+ const qtPresetButtons = [
3239
+ { minutes: 5, label: '5m' }, { minutes: 15, label: '15m' }, { minutes: 30, label: '30m' },
3240
+ { minutes: 60, label: '1h' }, { minutes: 120, label: '2h' }, { minutes: 240, label: '4h' },
3241
+ ].map(p => `<button class="as-qt-preset${_selectedQtMinutes === p.minutes ? ' as-qt-preset--selected' : ''}" data-action="qt-select" data-minutes="${p.minutes}">${p.label}</button>`).join('');
3242
+
3243
+ // CLI/Model/Browser settings for quick timer
3244
+ const qtProfile = _launchProfile || 'claude-code';
3245
+ const qtModels = CLI_MODELS[qtProfile] || [];
3246
+ const qtDefaultModel = qtModels.find(m => m.tier === 'default') || qtModels[0];
3247
+ const qtCurrentModel = _launchModel && qtModels.some(m => m.id === _launchModel) ? _launchModel : qtDefaultModel?.id;
3248
+
3249
+ const qtProfileChips = CLI_PROFILES.map(p => `
3250
+ <button class="as-launch-profile${p.id === qtProfile ? ' active' : ''}" data-action="qt-profile" data-profile="${p.id}">
3251
+ <span class="as-launch-profile-name">${p.label}</span>
3252
+ <span class="as-launch-profile-org">${p.desc}</span>
3253
+ </button>
3254
+ `).join('');
3255
+
3256
+ const qtModelChips = qtModels.map(m => `
3257
+ <button class="as-launch-model${m.id === qtCurrentModel ? ' active' : ''}${m.tier ? ` as-launch-model--${m.tier}` : ''}" data-action="qt-model" data-model="${m.id}">
3258
+ <span class="as-launch-model-name">${m.label}</span>
3259
+ <span class="as-launch-model-desc">${m.desc}</span>
3260
+ </button>
3261
+ `).join('');
3262
+
3263
+ // Determine browser toggle default from selected template
3264
+ const selectedTpl = $('as-qt-template')?.value;
3265
+ const selectedTemplate = selectedTpl ? allTemplates.find(t => t.id === selectedTpl) : null;
3266
+ const qtBrowserDefault = _qtUsesBrowser !== null ? _qtUsesBrowser : (selectedTemplate ? !!selectedTemplate.usesBrowser : false);
3267
+
3268
+ const qtCreatorHTML = `
3269
+ <div class="as-qt-creator">
3270
+ <select class="as-qt-select" id="as-qt-template">
3271
+ <option value="">Select template...</option>
3272
+ ${templateOptions}
3273
+ </select>
3274
+ <div class="as-qt-group">
3275
+ <span class="as-launch-label">CLI</span>
3276
+ <div class="as-launch-profiles" id="as-qt-profiles">${qtProfileChips}</div>
3277
+ </div>
3278
+ <div class="as-qt-group">
3279
+ <span class="as-launch-label">Model</span>
3280
+ <div class="as-launch-models" id="as-qt-models">${qtModelChips}</div>
3281
+ </div>
3282
+ <div class="as-qt-group">
3283
+ <span class="as-launch-label">Browser</span>
3284
+ <label class="as-qt-browser-toggle">
3285
+ <input type="checkbox" id="as-qt-browser" ${qtBrowserDefault ? 'checked' : ''}>
3286
+ <span class="as-qt-switch"></span>
3287
+ <span class="as-qt-browser-label">Uses browser</span>
3288
+ </label>
3289
+ </div>
3290
+ <div class="as-qt-group">
3291
+ <span class="as-launch-label">Timer</span>
3292
+ <div class="as-qt-presets">${qtPresetButtons}
3293
+ <input class="as-qt-custom-input" id="as-qt-custom-min" type="number" min="1" max="1440" placeholder="min" />
3294
+ </div>
3295
+ </div>
3296
+ <div class="as-qt-actions">
3297
+ <button class="as-launch-go" data-action="qt-go">${_s('<path d="M5 3l8 5-8 5V3z"/>')} Go</button>
3298
+ <button class="as-launch-cancel" data-action="qt-now" data-tooltip="Fire immediately">Run Now</button>
3299
+ </div>
3300
+ </div>`;
3301
+
3302
+ // ── Recurring Cron Schedules ──
3303
+ let cronHTML = '';
3304
+ let cronListHTML = '';
3305
+ if (_showAdvancedSchedules) {
3306
+ if (_schedules.length === 0) {
3307
+ cronListHTML = '<div class="as-qt-empty">No recurring schedules.</div>';
3308
+ } else {
3309
+ for (const s of _schedules) {
3310
+ const tpl = templateMap[s.templateId];
3311
+ const tplName = tpl ? esc(tpl.name) : '<em>Missing</em>';
3312
+ cronListHTML += `
3313
+ <div class="as-sched-card${s.enabled ? '' : ' as-sched-card--disabled'}">
3314
+ <div class="as-sched-card-left">
3315
+ <div class="as-sched-card-info">
3316
+ <div class="as-sched-card-name">${esc(s.name)}</div>
3317
+ <div class="as-sched-card-meta">${tplName} &middot; ${esc(describeCronClient(s.cron))}</div>
3318
+ <div class="as-sched-card-next">Next: ${formatNextRun(s.nextRun)} &middot; Runs: ${s.runCount || 0}</div>
3319
+ </div>
3320
+ </div>
3321
+ <div class="as-sched-card-right">
3322
+ ${scheduleStatusBadge(s)}
3323
+ <button class="as-sched-action" data-action="schedule-toggle" data-id="${s.id}" data-tooltip="${s.enabled ? 'Pause' : 'Enable'}">
3324
+ ${s.enabled ? _s('<path d="M5 3v10M11 3v10"/>') : _s('<path d="M5 3l8 5-8 5V3z"/>')}
3325
+ </button>
3326
+ <button class="as-sched-action" data-action="schedule-test" data-id="${s.id}" data-tooltip="Test fire now">${AS_ICONS.bolt}</button>
3327
+ <button class="as-sched-action" data-action="schedule-edit" data-id="${s.id}" data-tooltip="Edit">${AS_ICONS.pencil}</button>
3328
+ <button class="as-sched-action as-sched-action--danger" data-action="schedule-delete" data-id="${s.id}" data-tooltip="Delete">${AS_ICONS.close}</button>
3329
+ </div>
3330
+ </div>`;
3331
+ }
3332
+ }
3333
+ cronHTML = `
3334
+ <div class="as-qt-cron-section">
3335
+ <div class="as-qt-cron-header" data-action="toggle-advanced-schedules">
3336
+ <div class="as-qt-cron-header-left">
3337
+ <div class="as-qt-cron-icon">${_s('<circle cx="7" cy="7" r="5"/><path d="M7 4v3l2 2"/>')}</div>
3338
+ <span>Recurring Schedules</span>
3339
+ ${_schedules.length ? `<span class="as-qt-cron-count">${_schedules.length}</span>` : ''}
3340
+ </div>
3341
+ <span class="as-qt-chevron as-qt-chevron--open">${_s('<path d="M4 6l4 4 4-4"/>')}</span>
3342
+ </div>
3343
+ <div class="as-qt-cron-body">
3344
+ <div class="as-sched-list">${cronListHTML}</div>
3345
+ <button class="as-qt-btn as-qt-btn--secondary" data-action="schedule-new">+ New Cron Schedule</button>
3346
+ </div>
3347
+ </div>`;
3348
+ } else {
3349
+ cronHTML = `
3350
+ <div class="as-qt-cron-section">
3351
+ <div class="as-qt-cron-header" data-action="toggle-advanced-schedules">
3352
+ <div class="as-qt-cron-header-left">
3353
+ <div class="as-qt-cron-icon">${_s('<circle cx="7" cy="7" r="5"/><path d="M7 4v3l2 2"/>')}</div>
3354
+ <span>Recurring Schedules</span>
3355
+ ${_schedules.length ? `<span class="as-qt-cron-count">${_schedules.length}</span>` : ''}
3356
+ </div>
3357
+ <span class="as-qt-chevron">${_s('<path d="M4 6l4 4 4-4"/>')}</span>
3358
+ </div>
3359
+ </div>`;
3360
+ }
3361
+
3362
+ main.innerHTML = `
3363
+ <div class="as-schedules">
3364
+ <div class="as-sched-header">
3365
+ <button class="as-back-btn" data-action="go-welcome">${AS_ICONS.back}</button>
3366
+ <h3>Schedules</h3>
3367
+ </div>
3368
+ ${timersHTML}
3369
+ ${qtCreatorHTML}
3370
+ ${cronHTML}
3371
+ </div>
3372
+ `;
3373
+
3374
+ // Wire template select → auto-set browser toggle from template's usesBrowser
3375
+ const tplSelect = $('as-qt-template');
3376
+ if (tplSelect) {
3377
+ tplSelect.addEventListener('change', () => {
3378
+ const tpl = allTemplates.find(t => t.id === tplSelect.value);
3379
+ const browserCheckbox = $('as-qt-browser');
3380
+ if (browserCheckbox && tpl) {
3381
+ browserCheckbox.checked = !!tpl.usesBrowser;
3382
+ _qtUsesBrowser = null; // reset to template default
3383
+ }
3384
+ });
3385
+ }
3386
+
3387
+ // Wire browser toggle → track user override
3388
+ const browserCheckbox = $('as-qt-browser');
3389
+ if (browserCheckbox) {
3390
+ browserCheckbox.addEventListener('change', () => {
3391
+ _qtUsesBrowser = browserCheckbox.checked;
3392
+ });
3393
+ }
3394
+ }
3395
+
3396
+ function renderScheduleEditorMain() {
3397
+ const main = $('as-main');
3398
+ if (!main) return;
3399
+
3400
+ const s = _editingSchedule;
3401
+ const isEdit = !!s;
3402
+ const allTemplates = [...PRESET_TEMPLATES, ..._templates];
3403
+
3404
+ const presetsHTML = CRON_PRESET_GROUPS.map(g => `
3405
+ <div class="as-sched-preset-group">
3406
+ <div class="as-sched-preset-group-label">${esc(g.group)}</div>
3407
+ <div class="as-sched-preset-grid">
3408
+ ${g.presets.map(p => `<button class="as-sched-preset" data-cron="${esc(p.cron)}" data-tooltip="${esc(p.desc)}">${esc(p.label)}</button>`).join('')}
3409
+ </div>
3410
+ </div>`).join('');
3411
+
3412
+ const templateOptions = allTemplates.map(t =>
3413
+ `<option value="${t.id}"${(s?.templateId === t.id) ? ' selected' : ''}>${esc(t.name)} (${t.category})</option>`
3414
+ ).join('');
3415
+
3416
+ // Reorder: Mon(1)–Sat(6) then Sun(0) so 2-column grid pairs weekdays nicely
3417
+ const dayOrder = [1, 2, 3, 4, 5, 6, 0];
3418
+ const dayThemesHTML = dayOrder.map(idx => {
3419
+ const name = DAY_NAMES[idx];
3420
+ const val = s?.dayThemes?.[String(idx)]?.contextOverride || '';
3421
+ return `
3422
+ <div class="as-sched-daytheme">
3423
+ <label>${name}</label>
3424
+ <input type="text" data-day="${idx}" placeholder="Context override for ${name}..." value="${esc(val)}" />
3425
+ </div>`;
3426
+ }).join('');
3427
+
3428
+ const tz = s?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
3429
+
3430
+ main.innerHTML = `
3431
+ <div class="as-sched-editor">
3432
+ <div class="as-sched-header">
3433
+ <button class="as-back-btn" data-action="schedule-back">${AS_ICONS.back}</button>
3434
+ <h3>${isEdit ? 'Edit Schedule' : 'New Schedule'}</h3>
3435
+ </div>
3436
+
3437
+ <div class="as-sched-form">
3438
+ <div class="as-sched-field-row">
3439
+ <div class="as-sched-field as-sched-field--flex">
3440
+ <label>Name</label>
3441
+ <input type="text" id="as-sched-name" placeholder="e.g. Morning Social Post" value="${esc(s?.name || '')}" />
3442
+ </div>
3443
+ <div class="as-sched-field as-sched-field--flex">
3444
+ <label>Template</label>
3445
+ <select id="as-sched-template">${templateOptions}</select>
3446
+ </div>
3447
+ </div>
3448
+
3449
+ <div class="as-sched-field">
3450
+ <label>Schedule (Cron)</label>
3451
+ <div class="as-sched-presets-grouped">${presetsHTML}</div>
3452
+ <div class="as-sched-cron-row">
3453
+ <input type="text" id="as-sched-cron" placeholder="0 9,14,19 * * 1-5" value="${esc(s?.cron || '0 9,14,19 * * 1-5')}" />
3454
+ <span class="as-sched-cron-desc" id="as-sched-cron-desc">${esc(describeCronClient(s?.cron || '0 9,14,19 * * 1-5'))}</span>
3455
+ </div>
3456
+ </div>
3457
+
3458
+ <div class="as-sched-field-row">
3459
+ <div class="as-sched-field as-sched-field--flex">
3460
+ <label>Timezone</label>
3461
+ <input type="text" id="as-sched-tz" value="${esc(tz)}" />
3462
+ </div>
3463
+ <div class="as-sched-field as-sched-field--flex as-sched-field--toggle-wrap">
3464
+ <label>Status</label>
3465
+ <label class="as-sched-toggle-label">
3466
+ <input type="checkbox" id="as-sched-enabled" ${s?.enabled !== false ? 'checked' : ''} />
3467
+ Enabled
3468
+ </label>
3469
+ </div>
3470
+ </div>
3471
+
3472
+ <div class="as-sched-field">
3473
+ <label>Day Themes <span class="as-sched-hint">(optional context overrides per day of week)</span></label>
3474
+ <div class="as-sched-daythemes-grid" id="as-sched-daythemes">${dayThemesHTML}</div>
3475
+ </div>
3476
+
3477
+ <div class="as-sched-actions">
3478
+ <button class="as-header-btn" data-action="schedule-save">${isEdit ? 'Save Changes' : 'Create Schedule'}</button>
3479
+ <button class="as-header-btn as-header-btn--secondary" data-action="schedule-cancel">Cancel</button>
3480
+ </div>
3481
+ </div>
3482
+ </div>
3483
+ `;
3484
+
3485
+ // Wire cron preset clicks
3486
+ main.querySelectorAll('.as-sched-preset').forEach(btn => {
3487
+ btn.addEventListener('click', () => {
3488
+ const cronInput = $('as-sched-cron');
3489
+ if (cronInput) {
3490
+ cronInput.value = btn.dataset.cron;
3491
+ updateCronDesc();
3492
+ }
3493
+ });
3494
+ });
3495
+
3496
+ // Wire cron input live description
3497
+ $('as-sched-cron')?.addEventListener('input', updateCronDesc);
3498
+ }
3499
+
3500
+ function updateCronDesc() {
3501
+ const cronInput = $('as-sched-cron');
3502
+ const descEl = $('as-sched-cron-desc');
3503
+ if (cronInput && descEl) {
3504
+ descEl.textContent = describeCronClient(cronInput.value);
3505
+ }
3506
+ }
3507
+
3508
+ async function saveScheduleFromEditor() {
3509
+ const name = $('as-sched-name')?.value?.trim();
3510
+ const templateId = $('as-sched-template')?.value;
3511
+ const cron = $('as-sched-cron')?.value?.trim();
3512
+ const timezone = $('as-sched-tz')?.value?.trim();
3513
+ const enabled = $('as-sched-enabled')?.checked ?? true;
3514
+
3515
+ if (!name) { showToast('Name is required'); return; }
3516
+ if (!templateId) { showToast('Select a template'); return; }
3517
+ if (!cron || cron.split(/\s+/).length !== 5) { showToast('Valid 5-field cron expression required'); return; }
3518
+
3519
+ // Collect day themes
3520
+ const dayThemes = {};
3521
+ const dayInputs = document.querySelectorAll('#as-sched-daythemes input[data-day]');
3522
+ dayInputs.forEach(input => {
3523
+ const val = input.value.trim();
3524
+ if (val) dayThemes[input.dataset.day] = { contextOverride: val };
3525
+ });
3526
+
3527
+ const params = { name, templateId, cron, timezone, enabled, dayThemes };
3528
+
3529
+ try {
3530
+ if (_editingSchedule) {
3531
+ const updated = await updateSchedule(_editingSchedule.id, params);
3532
+ const idx = _schedules.findIndex(s => s.id === _editingSchedule.id);
3533
+ if (idx >= 0) _schedules[idx] = updated;
3534
+ showToast('Schedule updated');
3535
+ } else {
3536
+ const created = await createSchedule(params);
3537
+ _schedules.push(created);
3538
+ showToast('Schedule created');
3539
+ }
3540
+ _editingSchedule = null;
3541
+ _view = 'schedules';
3542
+ renderView();
3543
+ } catch (err) {
3544
+ showToast('Save failed: ' + err.message);
3545
+ }
3546
+ }
3547
+
3548
+ // ═══════════════════════════════════════════
3549
+ // IMPORT / EXPORT
3550
+ // ═══════════════════════════════════════════
3551
+
3552
+ function triggerImport() {
3553
+ const input = document.createElement('input');
3554
+ input.type = 'file'; input.accept = '.json'; input.style.display = 'none';
3555
+ input.addEventListener('change', async () => {
3556
+ const file = input.files?.[0];
3557
+ if (!file) return;
3558
+ try {
3559
+ const text = await file.text();
3560
+ const data = JSON.parse(text);
3561
+ if (data.type !== 'synabun-loop-templates' && data.type !== 'synabun-loop-template') {
3562
+ showToast('Invalid file format'); return;
3563
+ }
3564
+ const result = await importLoopTemplates(data);
3565
+ _templates = await fetchLoopTemplates();
3566
+ renderView();
3567
+ showToast(`Imported: ${result.added} added, ${result.updated} updated`);
3568
+ } catch (err) { showToast('Import failed: ' + err.message); }
3569
+ input.remove();
3570
+ });
3571
+ document.body.appendChild(input);
3572
+ input.click();
3573
+ }