@floless/app 0.5.1

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.
@@ -0,0 +1,1334 @@
1
+ /* ====================== DATA ====================== */
2
+
3
+ const PROMPTS = {}; // populated live from /api by aware.js - no demo seed
4
+ const AGENTS = {}; // ditto (a dead server must not bleed fake nodes onto the canvas)
5
+
6
+ /* ====================== STATE ====================== */
7
+
8
+ const COLLAPSE_KEY = 'floless-demo-collapse-v1';
9
+ // Code tab line-wrap preference (persisted, default off → no-wrap + horizontal scroll).
10
+ const CODE_WRAP_KEY = 'floless:codeWrap';
11
+ let codeWrap = (() => { try { return localStorage.getItem(CODE_WRAP_KEY) === 'true'; } catch { return false; } })();
12
+
13
+ const state = {
14
+ promptKey: null,
15
+ selectedAgentId: null,
16
+ currentTab: 'description',
17
+ hasRun: false,
18
+ running: false,
19
+ favorites: [],
20
+ collapse: { left: false, right: false },
21
+ pendingFavAgentId: null,
22
+ traceFilter: null, // Execution tab: node id to filter rows to, or null = all
23
+ };
24
+
25
+ function loadState() {
26
+ // Templates are server-backed (loaded via /api/templates in aware.js); only the
27
+ // panel-collapse UI state is persisted locally.
28
+ try {
29
+ const c = JSON.parse(localStorage.getItem(COLLAPSE_KEY) || '{}');
30
+ if (c.left !== undefined) state.collapse.left = c.left;
31
+ if (c.right !== undefined) state.collapse.right = c.right;
32
+ } catch {}
33
+ }
34
+ function saveFavs() {
35
+ document.getElementById('fav-count').textContent = state.favorites.length;
36
+ }
37
+ function saveCollapse() {
38
+ localStorage.setItem(COLLAPSE_KEY, JSON.stringify(state.collapse));
39
+ }
40
+
41
+ /* ====================== ELEMENTS ====================== */
42
+ const $app = document.getElementById('app');
43
+ const $promptSel = document.getElementById('prompt-select');
44
+ const $runBtn = document.getElementById('run-btn');
45
+ const $browseBtn = document.getElementById('browse-btn');
46
+ const $messages = document.getElementById('messages');
47
+ const $topology = document.getElementById('topology');
48
+ const $tabs = document.getElementById('tabs');
49
+ const $inspectBd = document.getElementById('inspect-body');
50
+ const $inspectRole = document.getElementById('inspect-role');
51
+ const $favBar = document.getElementById('fav-bar');
52
+ const $favChipRow = document.getElementById('fav-chip-row');
53
+ const $favBarEmpty = document.getElementById('fav-bar-empty');
54
+ const $favCount = document.getElementById('fav-count');
55
+ const $addFavModal = document.getElementById('add-fav-modal');
56
+ const $libModal = document.getElementById('lib-modal');
57
+ const $libList = document.getElementById('lib-list');
58
+ const $libSearch = document.getElementById('lib-search');
59
+ const $favName = document.getElementById('fav-name');
60
+ const $favCat = document.getElementById('fav-cat');
61
+ const $favCatSug = document.getElementById('fav-cat-suggest');
62
+ const $addFavSub = document.getElementById('add-fav-sub');
63
+
64
+ let dagCardMap = {};
65
+ let dagResizeObserver = null;
66
+
67
+ /* ====================== RENDER ====================== */
68
+
69
+ // The active workflow's renderable shape, or null when none is open / the server
70
+ // is unreachable. Every PROMPTS[state.promptKey] dereference goes through these so
71
+ // a missing workflow renders an honest placeholder instead of throwing.
72
+ function currentPrompt() { return (state.promptKey != null && PROMPTS[state.promptKey]) || null; }
73
+ function isDag() { const p = currentPrompt(); return !!p && p.layout === 'dag'; }
74
+
75
+ // Honest canvas state when there's no workflow to draw. `kind` is one of
76
+ // 'offline' (server unreachable), 'empty' (no workflow open / none installed),
77
+ // or 'loading'. NEVER renders fake nodes — that bleed-through is the bug this fixes.
78
+ function renderCanvasPlaceholder(kind) {
79
+ if (dagResizeObserver) { dagResizeObserver.disconnect(); dagResizeObserver = null; }
80
+ $topology.className = 'topology placeholder';
81
+ $topology.style.cssText = '';
82
+ // 'empty' is the brand-new-user surface (auto-open means installed users never
83
+ // see it — so it's effectively "nothing installed yet"). Render a guided panel
84
+ // that orients them: what this is, the run flow, and how to add a workflow.
85
+ if (kind === 'empty') { $topology.innerHTML = onboardingHtml(); wireOnboardingCopy(); return; }
86
+ const views = {
87
+ offline: { icon: '⚠', title: 'Server offline', body: 'The local FloLess server isn’t responding. Start it to load your workflows.' },
88
+ loading: { icon: '…', title: 'Loading…', body: 'Reading your installed workflows.' },
89
+ };
90
+ const v = views[kind] || views.offline;
91
+ $topology.innerHTML =
92
+ `<div class="canvas-empty" data-kind="${kind}">` +
93
+ `<div class="canvas-empty-icon">${v.icon}</div>` +
94
+ `<div class="canvas-empty-title">${v.title}</div>` +
95
+ `<div class="canvas-empty-body">${v.body}</div>` +
96
+ `<div class="canvas-empty-actions" id="canvas-empty-actions"></div>` +
97
+ `</div>`;
98
+ }
99
+
100
+ // First-run onboarding card (the 'empty' placeholder). Purely informational —
101
+ // the only action is copying the install command (the steps' real controls live
102
+ // in the header and are already disabled). No repeated brand mark (not a splash).
103
+ const ONB_INSTALL_CMD = 'aware app install <folder>';
104
+ function onboardingHtml() {
105
+ const step = (n, label, note) =>
106
+ `<li><span class="onb-num">${n}</span><span class="onb-step"><span class="onb-step-label">${label}</span> <span class="onb-step-note">${note}</span></span></li>`;
107
+ return (
108
+ `<div class="canvas-empty onboarding" data-kind="empty">` +
109
+ `<div class="onb-headline">Your terminal AI builds the workflows.<br>You run and inspect them here.</div>` +
110
+ `<div class="onb-sub">floless.app shows the canvas, triggers runs, and streams results. Compose in Claude Code or Codex; the browser is the window.</div>` +
111
+ `<div class="onb-divider"></div>` +
112
+ `<ol class="onb-steps">` +
113
+ step(1, 'Pick a workflow', '— the picker, top right') +
114
+ step(2, 'Set inputs', '— on the input node') +
115
+ step(3, 'Compile', '— freeze the approved lock') +
116
+ step(4, 'Run', '— against the live host; results render here') +
117
+ `</ol>` +
118
+ `<div class="onb-cmd-label">Add a workflow from your terminal:</div>` +
119
+ `<div class="onb-cmd"><code>${ONB_INSTALL_CMD.replace('<', '&lt;').replace('>', '&gt;')}</code>` +
120
+ `<button class="onb-copy" type="button" data-tip="Copy command" aria-label="Copy command">⎘</button></div>` +
121
+ `</div>`
122
+ );
123
+ }
124
+ function wireOnboardingCopy() {
125
+ const btn = $topology.querySelector('.onb-copy');
126
+ if (!btn) return;
127
+ btn.onclick = async () => {
128
+ try {
129
+ await navigator.clipboard.writeText(ONB_INSTALL_CMD);
130
+ const prev = btn.textContent;
131
+ btn.textContent = '✓';
132
+ showToast('Copied: ' + ONB_INSTALL_CMD, 'ok');
133
+ setTimeout(() => { btn.textContent = prev; }, 1200);
134
+ } catch {
135
+ showToast('Could not copy — select the command and copy manually.', 'warn');
136
+ }
137
+ };
138
+ }
139
+
140
+ function nodeIds(p) {
141
+ return p.layout === 'dag' ? p.nodes.map(n => n.id) : p.nodes;
142
+ }
143
+
144
+ function computePorts(p) {
145
+ const ports = {};
146
+ if (p.layout === 'dag') {
147
+ p.nodes.forEach(n => { ports[n.id] = { in: 0, out: 0 }; });
148
+ p.connections.forEach(c => {
149
+ if (ports[c.from]) ports[c.from].out++;
150
+ if (ports[c.to]) ports[c.to].in++;
151
+ });
152
+ } else {
153
+ p.nodes.forEach((id, i) => {
154
+ ports[id] = {
155
+ in: i === 0 ? 0 : 1,
156
+ out: i === p.nodes.length - 1 ? 0 : 1
157
+ };
158
+ });
159
+ }
160
+ return ports;
161
+ }
162
+
163
+ function renderChat() {
164
+ const p = currentPrompt();
165
+ $messages.innerHTML = '';
166
+ if (!p) return;
167
+ $messages.appendChild(msgEl('user', '$ you', p.userText));
168
+ p.narration.forEach((line, i) => {
169
+ setTimeout(() => {
170
+ $messages.appendChild(msgEl('aware', 'claude', mdInline(line)));
171
+ $messages.scrollTop = $messages.scrollHeight;
172
+ }, 180 * (i + 1));
173
+ });
174
+ }
175
+ // Local timestamp, stamped when a chat block is created: YYYY-MM-DD HH:MM:SS.
176
+ function nowStamp(d = new Date()) {
177
+ const p = (n) => String(n).padStart(2, '0');
178
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
179
+ }
180
+ function msgEl(cls, who, html) {
181
+ const div = document.createElement('div');
182
+ div.className = 'msg ' + cls;
183
+ div.innerHTML = `<div class="who"><span class="who-name">${who}</span><span class="who-time">${nowStamp()}</span></div><div class="body">${html}</div>`;
184
+ return div;
185
+ }
186
+ function mdInline(s) {
187
+ return s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`(.+?)`/g, '<code>$1</code>');
188
+ }
189
+
190
+ function renderTopology() {
191
+ const p = currentPrompt();
192
+ if (!p) { renderCanvasPlaceholder('empty'); return; }
193
+ $topology.innerHTML = '';
194
+ $topology.className = 'topology';
195
+ $topology.style.cssText = '';
196
+ if (dagResizeObserver) { dagResizeObserver.disconnect(); dagResizeObserver = null; }
197
+
198
+ if (p.layout === 'dag') {
199
+ renderDagTopology(p);
200
+ } else {
201
+ renderLinearTopology(p);
202
+ }
203
+ const firstId = nodeIds(p)[0];
204
+ if (!state.selectedAgentId || !nodeIds(p).includes(state.selectedAgentId)) {
205
+ selectAgent(firstId);
206
+ } else {
207
+ selectAgent(state.selectedAgentId);
208
+ }
209
+ resetView(); // fresh 100% + centred view per render; also re-syncs the zoom % label
210
+ }
211
+
212
+ function renderLinearTopology(p) {
213
+ const ports = computePorts(p);
214
+ p.nodes.forEach((id, i) => {
215
+ if (i > 0) $topology.appendChild(wireEl(i - 1, p.wires[i - 1]));
216
+ $topology.appendChild(cardEl(id, ports[id]));
217
+ });
218
+ }
219
+
220
+ function renderDagTopology(p) {
221
+ $topology.classList.add('dag');
222
+ const maxRow = Math.max(...p.nodes.map(n => n.row));
223
+ const maxCol = Math.max(...p.nodes.map(n => n.col));
224
+ $topology.style.gridTemplateRows = `repeat(${maxRow}, auto)`;
225
+ $topology.style.gridTemplateColumns = `repeat(${maxCol}, auto)`;
226
+
227
+ const ports = computePorts(p);
228
+ dagCardMap = {};
229
+ p.nodes.forEach(n => {
230
+ const card = cardEl(n.id, ports[n.id]);
231
+ card.style.gridRow = n.row;
232
+ card.style.gridColumn = n.col;
233
+ $topology.appendChild(card);
234
+ dagCardMap[n.id] = card;
235
+ });
236
+
237
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
238
+ svg.classList.add('wires-svg');
239
+ svg.id = 'wires-svg';
240
+ $topology.appendChild(svg);
241
+
242
+ requestAnimationFrame(() => drawDagWires(svg, p.connections));
243
+ dagResizeObserver = new ResizeObserver(() => drawDagWires(svg, p.connections));
244
+ dagResizeObserver.observe($topology);
245
+ }
246
+
247
+ function drawDagWires(svg, connections) {
248
+ svg.innerHTML = '';
249
+ connections.forEach((c, idx) => {
250
+ const fromEl = dagCardMap[c.from];
251
+ const toEl = dagCardMap[c.to];
252
+ if (!fromEl || !toEl) return;
253
+ // Use offsetLeft/offsetTop (unscaled, relative to .topology) so SVG aligns under any zoom transform.
254
+ const x1 = fromEl.offsetLeft + fromEl.offsetWidth;
255
+ const y1 = fromEl.offsetTop + fromEl.offsetHeight / 2;
256
+ const x2 = toEl.offsetLeft;
257
+ const y2 = toEl.offsetTop + toEl.offsetHeight / 2;
258
+ const dx = Math.max(28, (x2 - x1) * 0.45);
259
+
260
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
261
+ path.setAttribute('d', `M ${x1} ${y1} C ${x1+dx} ${y1}, ${x2-dx} ${y2}, ${x2} ${y2}`);
262
+ path.classList.add('dag-wire');
263
+ path.dataset.connIdx = idx;
264
+ svg.appendChild(path);
265
+
266
+ // Arrow head — rotated to follow the curve's tangent at the endpoint.
267
+ // Cubic Bezier control points share the start/end Y, so the analytic
268
+ // tangent is always horizontal; instead, sample a point near t=1 and use
269
+ // the chord from there to the endpoint as the visual approach direction.
270
+ const t = 0.88, u = 1 - t;
271
+ const bx = u*u*u*x1 + 3*u*u*t*(x1+dx) + 3*u*t*t*(x2-dx) + t*t*t*x2;
272
+ const by = u*u*u*y1 + 3*u*u*t*y1 + 3*u*t*t*y2 + t*t*t*y2;
273
+ const ang = Math.atan2(y2 - by, x2 - bx);
274
+ const cos = Math.cos(ang), sin = Math.sin(ang);
275
+ const h = 9, w = 5; // arrow length (along tangent) and half-width
276
+ const tipX = x2 - 1 * cos, tipY = y2 - 1 * sin;
277
+ const blX = tipX + (-h) * cos - (-w) * sin, blY = tipY + (-h) * sin + (-w) * cos;
278
+ const brX = tipX + (-h) * cos - ( w) * sin, brY = tipY + (-h) * sin + ( w) * cos;
279
+ const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
280
+ arrow.setAttribute('points', `${blX},${blY} ${brX},${brY} ${tipX},${tipY}`);
281
+ arrow.classList.add('dag-arrow');
282
+ arrow.dataset.connIdx = idx;
283
+ svg.appendChild(arrow);
284
+
285
+ if (c.label) {
286
+ const lx = (x1 + x2) / 2;
287
+ const ly = (y1 + y2) / 2;
288
+ const charW = 5.6;
289
+ const w = c.label.length * charW + 12;
290
+ const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
291
+ bg.setAttribute('x', lx - w/2);
292
+ bg.setAttribute('y', ly - 8);
293
+ bg.setAttribute('width', w);
294
+ bg.setAttribute('height', 14);
295
+ bg.setAttribute('rx', 2);
296
+ bg.classList.add('dag-label-bg');
297
+ svg.appendChild(bg);
298
+
299
+ const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
300
+ label.setAttribute('x', lx);
301
+ label.setAttribute('y', ly + 3);
302
+ label.setAttribute('text-anchor', 'middle');
303
+ label.classList.add('dag-label');
304
+ label.textContent = c.label;
305
+ svg.appendChild(label);
306
+ }
307
+ });
308
+ }
309
+
310
+ function cardEl(id, ports) {
311
+ const a = AGENTS[id];
312
+ ports = ports || { in: 0, out: 0 };
313
+ const div = document.createElement('div');
314
+ div.className = 'agent-card';
315
+ div.dataset.agentId = id;
316
+ div.draggable = true;
317
+ const faved = state.favorites.some(f => f.id === id);
318
+ div.innerHTML = `
319
+ <button class="fav-btn ${faved ? 'faved' : ''}" data-fav="${id}" data-tip="${faved ? 'Saved as Template' : 'Save as Template'}">★</button>
320
+ <div class="head">
321
+ <div class="head-left">
322
+ <div class="kind">${a.kind}</div>
323
+ <div class="ver">${a.version}</div>
324
+ </div>
325
+ <div class="icon">${a.icon}</div>
326
+ </div>
327
+ <div class="title">${a.title}</div>
328
+ <div class="subtitle">${a.subtitle}</div>
329
+ <div class="blurb">${a.blurb}</div>
330
+ <div class="footer-row">
331
+ <span class="ports"><span class="ports-num">${ports.in}</span> in <span class="ports-arrow">·</span> <span class="ports-num">${ports.out}</span> out</span>
332
+ <span class="inspect-hint">inspect ▸</span>
333
+ </div>
334
+ `;
335
+ div.onclick = (e) => {
336
+ if (e.target.closest('.fav-btn')) return;
337
+ selectAgent(id);
338
+ };
339
+ div.querySelector('.fav-btn').onclick = (e) => {
340
+ e.stopPropagation();
341
+ toggleFav(id);
342
+ };
343
+ div.addEventListener('dragstart', (e) => {
344
+ e.dataTransfer.setData('text/plain', id);
345
+ e.dataTransfer.effectAllowed = 'copy';
346
+ div.classList.add('dragging');
347
+ });
348
+ div.addEventListener('dragend', () => div.classList.remove('dragging'));
349
+ return div;
350
+ }
351
+
352
+ function wireEl(idx, label) {
353
+ const wrap = document.createElement('div');
354
+ wrap.className = 'wire-wrap';
355
+ wrap.innerHTML = `
356
+ <div class="wire-label">${label || ''}</div>
357
+ <div class="wire" data-wire-idx="${idx}"><div class="pulse"></div></div>
358
+ `;
359
+ return wrap;
360
+ }
361
+
362
+ function selectAgent(id) {
363
+ state.selectedAgentId = id;
364
+ document.querySelectorAll('.agent-card').forEach(c => {
365
+ c.classList.toggle('selected', c.dataset.agentId === id);
366
+ });
367
+ const a = AGENTS[id];
368
+ if (a) $inspectRole.textContent = a.title.toLowerCase();
369
+ renderInspect();
370
+ }
371
+
372
+ function renderInspect() {
373
+ const a = AGENTS[state.selectedAgentId];
374
+ if (!a) {
375
+ $inspectBd.innerHTML = '<div class="empty-state">Click an agent in the canvas to inspect.</div>';
376
+ return;
377
+ }
378
+ // Stale find-highlights reference text nodes from a now-discarded <pre>; clear
379
+ // them on every re-render so they can't linger over the wrong tab/node.
380
+ clearCodeFind();
381
+ const nid = state.selectedAgentId;
382
+ const header = `
383
+ <div class="inspect-head-row">
384
+ <div class="inspect-title">${a.title}</div>
385
+ <button class="inspect-id" type="button" data-copy-id="${escapeHtml(nid)}" data-tip="Copy node id">${escapeHtml(nid)} ⎘</button>
386
+ </div>
387
+ <div class="inspect-meta">${a.kind} · ${a.version} · ${a.subtitle}</div>
388
+ `;
389
+ let body = '';
390
+ switch (state.currentTab) {
391
+ case 'description': body = `<div class="inspect-content">${a.description}</div>`; break;
392
+ case 'skill': body = `<div class="inspect-content">${a.skill}</div>`; break;
393
+ case 'code':
394
+ body = `<div class="code-toolbar">` +
395
+ `<div class="code-find"><input type="text" id="code-find-input" class="code-find-input" placeholder="Find in code…" aria-label="Find in code" autocomplete="off" spellcheck="false"><span class="code-find-count" id="code-find-count"></span></div>` +
396
+ `<button class="code-wrap-btn${codeWrap ? ' active' : ''}" id="code-wrap-btn" type="button" aria-pressed="${codeWrap}" data-tip="Wrap long lines to the panel width — no horizontal scroll">↵ Wrap</button>` +
397
+ `</div>` +
398
+ `<pre class="code${codeWrap ? ' wrap' : ''}" id="code-pre">${a.code}</pre>`;
399
+ break;
400
+ case 'execution':
401
+ if (!state.hasRun) {
402
+ body = '<div class="empty-state">No execution yet. Hit <strong>▶ Run workflow</strong>.</div>';
403
+ } else {
404
+ const rows = a.execution || [];
405
+ const nodes = [...new Set(rows.map(r => r.node).filter(Boolean))];
406
+ // Auto-heal a stale filter (node no longer in this run's trace) → show all.
407
+ const filter = (state.traceFilter && nodes.includes(state.traceFilter)) ? state.traceFilter : null;
408
+ const chips = nodes.length > 1
409
+ ? `<div class="trace-filter"><button class="tf-chip${!filter ? ' active' : ''}" type="button" data-tf="">all</button>` +
410
+ nodes.map(n => `<button class="tf-chip${filter === n ? ' active' : ''}" type="button" data-tf="${escapeHtml(n)}">${escapeHtml(n)}</button>`).join('') +
411
+ `</div>`
412
+ : '';
413
+ const shown = filter ? rows.filter(r => r.node === filter) : rows;
414
+ body = chips + `<div class="trace">${
415
+ shown.map(r => `
416
+ <div class="row">
417
+ <span class="ts">${r.ts}</span>
418
+ <span class="lvl ${r.lvl}">${r.lvl}</span>
419
+ <span class="msg">${r.msg}</span>
420
+ </div>
421
+ `).join('')
422
+ }</div>`;
423
+ }
424
+ break;
425
+ }
426
+ $inspectBd.innerHTML = header + body;
427
+ }
428
+
429
+ // Code tab "Wrap" toggle: flip word-wrap on the <pre>, persist, update in place
430
+ // (delegated so it survives every renderInspect rebuild).
431
+ document.addEventListener('click', (e) => {
432
+ const btn = e.target.closest('#code-wrap-btn');
433
+ if (!btn) return;
434
+ codeWrap = !codeWrap;
435
+ try { localStorage.setItem(CODE_WRAP_KEY, codeWrap); } catch {}
436
+ btn.classList.toggle('active', codeWrap);
437
+ btn.setAttribute('aria-pressed', String(codeWrap));
438
+ const pre = $inspectBd.querySelector('pre.code');
439
+ if (pre) pre.classList.toggle('wrap', codeWrap);
440
+ });
441
+
442
+ // ── Small dev affordances ────────────────────────────────────────────────────
443
+ // Copy a node's id from the inspect header (delegated; survives re-render).
444
+ document.addEventListener('click', (e) => {
445
+ const idBtn = e.target.closest('.inspect-id');
446
+ if (!idBtn) return;
447
+ const id = idBtn.dataset.copyId || '';
448
+ if (!navigator.clipboard) { showToast('Could not copy', 'warn'); return; }
449
+ navigator.clipboard.writeText(id)
450
+ .then(() => showToast('Copied node id: ' + id, 'ok'))
451
+ .catch(() => showToast('Could not copy', 'warn'));
452
+ });
453
+
454
+ // Execution-trace node filter: clicking a chip narrows the trace to one node.
455
+ document.addEventListener('click', (e) => {
456
+ const chip = e.target.closest('.tf-chip');
457
+ if (!chip) return;
458
+ state.traceFilter = chip.dataset.tf || null;
459
+ renderInspect();
460
+ });
461
+
462
+ // Find-in-code: highlight matches in the syntax-highlighted <pre> WITHOUT
463
+ // mutating its DOM, via the CSS Custom Highlight API. Enter cycles forward,
464
+ // Shift+Enter back, Esc clears. Degrades gracefully where the API is absent.
465
+ let codeFindRanges = [];
466
+ let codeFindIdx = -1;
467
+ function clearCodeFind() {
468
+ codeFindRanges = [];
469
+ codeFindIdx = -1;
470
+ if (window.CSS && CSS.highlights) {
471
+ CSS.highlights.delete('code-find');
472
+ CSS.highlights.delete('code-find-active');
473
+ }
474
+ }
475
+ function runCodeFind(query) {
476
+ const pre = document.getElementById('code-pre');
477
+ const count = document.getElementById('code-find-count');
478
+ clearCodeFind();
479
+ if (count) count.textContent = '';
480
+ if (!pre || !(window.CSS && CSS.highlights && window.Highlight)) return;
481
+ const q = (query || '').trim();
482
+ if (!q) return;
483
+ const ql = q.toLowerCase();
484
+ const walker = document.createTreeWalker(pre, NodeFilter.SHOW_TEXT);
485
+ let node;
486
+ while ((node = walker.nextNode())) {
487
+ const text = node.nodeValue.toLowerCase();
488
+ let i = 0;
489
+ while ((i = text.indexOf(ql, i)) >= 0) {
490
+ const r = document.createRange();
491
+ r.setStart(node, i);
492
+ r.setEnd(node, i + q.length);
493
+ codeFindRanges.push(r);
494
+ i += q.length;
495
+ }
496
+ }
497
+ if (!codeFindRanges.length) { if (count) count.textContent = '0/0'; return; }
498
+ CSS.highlights.set('code-find', new Highlight(...codeFindRanges));
499
+ codeFindIdx = 0;
500
+ showActiveFind();
501
+ }
502
+ function showActiveFind() {
503
+ const count = document.getElementById('code-find-count');
504
+ if (!codeFindRanges.length || !(window.CSS && CSS.highlights && window.Highlight)) return;
505
+ const active = codeFindRanges[codeFindIdx];
506
+ CSS.highlights.set('code-find-active', new Highlight(active));
507
+ if (count) count.textContent = `${codeFindIdx + 1}/${codeFindRanges.length}`;
508
+ // Scroll the active match into view within the <pre> when it's off-screen.
509
+ const pre = document.getElementById('code-pre');
510
+ const rect = active.getBoundingClientRect && active.getBoundingClientRect();
511
+ if (pre && rect) {
512
+ const pr = pre.getBoundingClientRect();
513
+ if (rect.top < pr.top || rect.bottom > pr.bottom) {
514
+ pre.scrollTop += (rect.top - pr.top) - pre.clientHeight / 2;
515
+ }
516
+ }
517
+ }
518
+ document.addEventListener('input', (e) => {
519
+ if (e.target.id === 'code-find-input') runCodeFind(e.target.value);
520
+ });
521
+ document.addEventListener('keydown', (e) => {
522
+ if (e.target.id !== 'code-find-input') return;
523
+ if (e.key === 'Enter') {
524
+ e.preventDefault();
525
+ if (!codeFindRanges.length) return;
526
+ codeFindIdx = (codeFindIdx + (e.shiftKey ? -1 : 1) + codeFindRanges.length) % codeFindRanges.length;
527
+ showActiveFind();
528
+ } else if (e.key === 'Escape') {
529
+ e.preventDefault();
530
+ e.target.value = '';
531
+ runCodeFind('');
532
+ }
533
+ });
534
+
535
+ /* ============= FAVORITES ============= */
536
+
537
+ function toggleFav(agentId) {
538
+ const i = state.favorites.findIndex(f => f.id === agentId);
539
+ if (i >= 0) {
540
+ state.favorites.splice(i, 1);
541
+ saveFavs();
542
+ renderFavBar();
543
+ refreshFavMarkers();
544
+ return;
545
+ }
546
+ openAddFavModal(agentId);
547
+ }
548
+
549
+ function openAddFavModal(agentId) {
550
+ state.pendingFavAgentId = agentId;
551
+ const a = AGENTS[agentId];
552
+ $addFavSub.textContent = `Pin "${a.title}" for quick reuse across apps.`;
553
+ $favName.value = a.title;
554
+ $favCat.value = '';
555
+ renderCategorySuggestions();
556
+ $addFavModal.classList.add('show');
557
+ setTimeout(() => $favCat.focus(), 50);
558
+ }
559
+
560
+ function renderCategorySuggestions() {
561
+ const cats = [...new Set(state.favorites.map(f => f.category).filter(Boolean))];
562
+ const defaults = ['Tekla', 'MEP', 'Fab Pipeline', 'Coordination', 'QA'];
563
+ const all = [...new Set([...cats, ...defaults])];
564
+ $favCatSug.innerHTML = all.slice(0, 6).map(c => `<button data-cat="${escapeAttr(c)}">${escapeHtml(c)}</button>`).join('');
565
+ $favCatSug.querySelectorAll('button').forEach(b => {
566
+ b.onclick = () => { $favCat.value = b.dataset.cat; };
567
+ });
568
+ }
569
+
570
+ function closeAddFavModal() {
571
+ $addFavModal.classList.remove('show');
572
+ state.pendingFavAgentId = null;
573
+ }
574
+
575
+ function commitFav() {
576
+ if (!state.pendingFavAgentId) return;
577
+ const name = $favName.value.trim() || AGENTS[state.pendingFavAgentId].title;
578
+ const category = $favCat.value.trim() || 'Uncategorized';
579
+ state.favorites.push({ id: state.pendingFavAgentId, name, category });
580
+ saveFavs();
581
+ renderFavBar();
582
+ refreshFavMarkers();
583
+ closeAddFavModal();
584
+ }
585
+
586
+ function renderFavBar() {
587
+ $favCount.textContent = state.favorites.length;
588
+ if (state.favorites.length === 0) {
589
+ $favChipRow.innerHTML = '';
590
+ $favBarEmpty.style.display = 'block';
591
+ return;
592
+ }
593
+ $favBarEmpty.style.display = 'none';
594
+ $favChipRow.innerHTML = state.favorites.map((f, idx) => `
595
+ <div class="fav-chip" data-fav-idx="${idx}" data-fav-agent="${f.id}" data-tip="Click to inspect · ${escapeAttr(f.category)}">
596
+ <span class="cat">${escapeHtml(f.category)}</span>
597
+ <span class="name">${escapeHtml(f.name)}</span>
598
+ <span class="del" data-del="${idx}" data-tip="Remove">×</span>
599
+ </div>
600
+ `).join('');
601
+ $favChipRow.querySelectorAll('.fav-chip').forEach(chip => {
602
+ chip.onclick = (e) => {
603
+ if (e.target.closest('.del')) return;
604
+ const id = chip.dataset.favAgent;
605
+ const p = PROMPTS[state.promptKey];
606
+ if (nodeIds(p).includes(id)) {
607
+ selectAgent(id);
608
+ } else {
609
+ chip.style.borderColor = 'var(--warn)';
610
+ setTimeout(() => chip.style.borderColor = '', 600);
611
+ appendNarration(`<em>${AGENTS[id].title}</em> is favorited but isn't in the current app. Switch prompts or compose a new app to use it.`);
612
+ }
613
+ };
614
+ chip.querySelector('.del').onclick = (e) => {
615
+ e.stopPropagation();
616
+ const idx = parseInt(chip.dataset.favIdx, 10);
617
+ state.favorites.splice(idx, 1);
618
+ saveFavs();
619
+ renderFavBar();
620
+ refreshFavMarkers();
621
+ };
622
+ });
623
+ }
624
+
625
+ function refreshFavMarkers() {
626
+ document.querySelectorAll('.agent-card .fav-btn').forEach(btn => {
627
+ const id = btn.dataset.fav;
628
+ btn.classList.toggle('faved', state.favorites.some(f => f.id === id));
629
+ });
630
+ if ($libModal.classList.contains('show')) renderLibrary();
631
+ }
632
+
633
+ function setupFavDropZone() {
634
+ $favBar.addEventListener('dragover', (e) => {
635
+ e.preventDefault();
636
+ e.dataTransfer.dropEffect = 'copy';
637
+ $favBar.classList.add('drag-over');
638
+ });
639
+ $favBar.addEventListener('dragleave', (e) => {
640
+ if (e.target === $favBar) $favBar.classList.remove('drag-over');
641
+ });
642
+ $favBar.addEventListener('drop', (e) => {
643
+ e.preventDefault();
644
+ $favBar.classList.remove('drag-over');
645
+ const id = e.dataTransfer.getData('text/plain');
646
+ if (!id || !AGENTS[id]) return;
647
+ if (state.favorites.some(f => f.id === id)) {
648
+ $favBar.style.background = 'var(--accent-soft)';
649
+ setTimeout(() => $favBar.style.background = '', 300);
650
+ return;
651
+ }
652
+ openAddFavModal(id);
653
+ });
654
+ }
655
+
656
+ /* ============= LIBRARY ============= */
657
+
658
+ function openLibrary() {
659
+ $libSearch.value = '';
660
+ renderLibrary();
661
+ $libModal.classList.add('show');
662
+ }
663
+
664
+ function renderLibrary() {
665
+ const q = ($libSearch.value || '').toLowerCase().trim();
666
+ const items = Object.entries(AGENTS).filter(([id, a]) => {
667
+ if (!q) return true;
668
+ return id.includes(q) ||
669
+ a.title.toLowerCase().includes(q) ||
670
+ a.kind.toLowerCase().includes(q) ||
671
+ (a.blurb && a.blurb.toLowerCase().includes(q));
672
+ });
673
+ $libList.innerHTML = items.map(([id, a]) => {
674
+ const faved = state.favorites.some(f => f.id === id);
675
+ return `
676
+ <div class="lib-item">
677
+ <div class="info">
678
+ <div class="name">${a.title}</div>
679
+ <div class="meta">${a.kind} · ${a.version}</div>
680
+ <div class="blurb">${a.blurb}</div>
681
+ </div>
682
+ <button class="lib-fav ${faved ? 'faved' : ''}" data-lib-fav="${id}" data-tip="${faved ? 'In favorites' : 'Add to favorites'}">★</button>
683
+ </div>
684
+ `;
685
+ }).join('') || '<div class="empty-state">No agents match.</div>';
686
+ $libList.querySelectorAll('.lib-fav').forEach(btn => {
687
+ btn.onclick = () => toggleFav(btn.dataset.libFav);
688
+ });
689
+ }
690
+
691
+ /* ============= COLLAPSE ============= */
692
+
693
+ function applyCollapse() {
694
+ $app.classList.toggle('left-collapsed', state.collapse.left);
695
+ $app.classList.toggle('right-collapsed', state.collapse.right);
696
+ // redraw DAG wires after layout changes
697
+ if (isDag()) {
698
+ const svg = document.getElementById('wires-svg');
699
+ if (svg) {
700
+ setTimeout(() => drawDagWires(svg, PROMPTS[state.promptKey].connections), 280);
701
+ }
702
+ }
703
+ }
704
+
705
+ function toggleCollapse(side) {
706
+ state.collapse[side] = !state.collapse[side];
707
+ applyCollapse();
708
+ saveCollapse();
709
+ }
710
+
711
+ /* ============= RESIZE ============= */
712
+
713
+ const WIDTHS_KEY = 'floless-demo-widths-v1';
714
+ const MIN_LEFT = 220, MAX_LEFT = 600;
715
+ const MIN_RIGHT = 260, MAX_RIGHT = 700;
716
+
717
+ function loadWidths() {
718
+ try {
719
+ const w = JSON.parse(localStorage.getItem(WIDTHS_KEY) || '{}');
720
+ if (w.left != null) $app.style.setProperty('--left-width', w.left + 'px');
721
+ if (w.right != null) $app.style.setProperty('--right-width', w.right + 'px');
722
+ } catch {}
723
+ }
724
+
725
+ function saveWidths() {
726
+ const w = {};
727
+ const l = $app.style.getPropertyValue('--left-width');
728
+ const r = $app.style.getPropertyValue('--right-width');
729
+ if (l) w.left = parseInt(l, 10);
730
+ if (r) w.right = parseInt(r, 10);
731
+ localStorage.setItem(WIDTHS_KEY, JSON.stringify(w));
732
+ }
733
+
734
+ function setupResize() {
735
+ document.querySelectorAll('.resize-handle').forEach(handle => {
736
+ const side = handle.dataset.resize; // 'left' or 'right'
737
+
738
+ handle.addEventListener('mousedown', (e) => {
739
+ e.preventDefault();
740
+ const startX = e.clientX;
741
+ const panel = side === 'left'
742
+ ? document.querySelector('.chat')
743
+ : document.querySelector('.inspect');
744
+ const startW = panel.getBoundingClientRect().width;
745
+
746
+ handle.classList.add('dragging');
747
+ $app.classList.add('resizing');
748
+ document.body.classList.add('resizing');
749
+
750
+ function onMove(ev) {
751
+ const dx = ev.clientX - startX;
752
+ let newW;
753
+ if (side === 'left') {
754
+ newW = Math.max(MIN_LEFT, Math.min(MAX_LEFT, startW + dx));
755
+ $app.style.setProperty('--left-width', newW + 'px');
756
+ } else {
757
+ newW = Math.max(MIN_RIGHT, Math.min(MAX_RIGHT, startW - dx));
758
+ $app.style.setProperty('--right-width', newW + 'px');
759
+ }
760
+ if (isDag()) {
761
+ const svg = document.getElementById('wires-svg');
762
+ if (svg) drawDagWires(svg, PROMPTS[state.promptKey].connections);
763
+ }
764
+ }
765
+ function onUp() {
766
+ handle.classList.remove('dragging');
767
+ $app.classList.remove('resizing');
768
+ document.body.classList.remove('resizing');
769
+ document.removeEventListener('mousemove', onMove);
770
+ document.removeEventListener('mouseup', onUp);
771
+ saveWidths();
772
+ }
773
+ document.addEventListener('mousemove', onMove);
774
+ document.addEventListener('mouseup', onUp);
775
+ });
776
+
777
+ handle.addEventListener('dblclick', () => {
778
+ if (side === 'left') $app.style.removeProperty('--left-width');
779
+ else $app.style.removeProperty('--right-width');
780
+ saveWidths();
781
+ if (isDag()) {
782
+ const svg = document.getElementById('wires-svg');
783
+ if (svg) drawDagWires(svg, PROMPTS[state.promptKey].connections);
784
+ }
785
+ });
786
+ });
787
+ }
788
+
789
+ /* ============= RUN ============= */
790
+
791
+ async function runLinearAnimation() {
792
+ const cards = [...document.querySelectorAll('.agent-card')];
793
+ const wires = [...document.querySelectorAll('.wire')];
794
+ for (let i = 0; i < cards.length; i++) {
795
+ cards[i].classList.add('pulsing');
796
+ await sleep(450);
797
+ cards[i].classList.remove('pulsing');
798
+ if (i < wires.length) {
799
+ wires[i].classList.add('active', 'firing');
800
+ await sleep(700);
801
+ wires[i].classList.remove('firing');
802
+ }
803
+ }
804
+ }
805
+
806
+ async function runDagAnimation(p) {
807
+ const cols = {};
808
+ p.nodes.forEach(n => {
809
+ if (!cols[n.col]) cols[n.col] = [];
810
+ cols[n.col].push(n.id);
811
+ });
812
+ const sortedCols = Object.keys(cols).map(Number).sort((a, b) => a - b);
813
+
814
+ for (const col of sortedCols) {
815
+ cols[col].forEach(id => dagCardMap[id]?.classList.add('pulsing'));
816
+ await sleep(500);
817
+ cols[col].forEach(id => dagCardMap[id]?.classList.remove('pulsing'));
818
+
819
+ const out = p.connections
820
+ .map((c, idx) => ({ c, idx }))
821
+ .filter(({ c }) => cols[col].includes(c.from));
822
+ out.forEach(({ idx }) => {
823
+ const w = document.querySelector(`.dag-wire[data-conn-idx="${idx}"]`);
824
+ const a = document.querySelector(`.dag-arrow[data-conn-idx="${idx}"]`);
825
+ w?.classList.add('active', 'firing');
826
+ a?.classList.add('active');
827
+ });
828
+ await sleep(700);
829
+ document.querySelectorAll('.dag-wire.firing').forEach(w => w.classList.remove('firing'));
830
+ }
831
+ }
832
+
833
+ /* ============= EVENTS ============= */
834
+
835
+ $promptSel.onchange = () => {
836
+ state.promptKey = $promptSel.value;
837
+ state.hasRun = false;
838
+ state.selectedAgentId = null;
839
+ renderChat();
840
+ renderTopology();
841
+ };
842
+
843
+ $tabs.onclick = (e) => {
844
+ const btn = e.target.closest('button');
845
+ if (!btn) return;
846
+ state.currentTab = btn.dataset.tab;
847
+ document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b === btn));
848
+ renderInspect();
849
+ };
850
+
851
+ $runBtn.onclick = async () => {
852
+ const p = currentPrompt();
853
+ if (state.running || !p) return; // aware.js overrides this handler; guard the null/no-workflow case
854
+ state.running = true;
855
+ $runBtn.disabled = true;
856
+ $runBtn.textContent = '◆ Running…';
857
+
858
+ if (p.layout === 'dag') await runDagAnimation(p);
859
+ else await runLinearAnimation();
860
+
861
+ state.hasRun = true;
862
+ state.running = false;
863
+ $runBtn.disabled = false;
864
+ $runBtn.textContent = '▶ Run workflow';
865
+
866
+ state.currentTab = 'execution';
867
+ document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === 'execution'));
868
+ renderInspect();
869
+ appendNarration(`Execution complete · ${nodeIds(p).length} steps fired · check the <strong>Execution</strong> tab on each agent for the full trace.`);
870
+ };
871
+
872
+ $browseBtn.onclick = openLibrary;
873
+
874
+ document.querySelectorAll('.panel-toggle').forEach(btn => {
875
+ btn.onclick = () => toggleCollapse(btn.dataset.side);
876
+ });
877
+
878
+ document.getElementById('fav-cancel').onclick = closeAddFavModal;
879
+ document.getElementById('fav-save').onclick = commitFav;
880
+ document.getElementById('lib-close').onclick = () => $libModal.classList.remove('show');
881
+ $addFavModal.onclick = (e) => { if (e.target === $addFavModal) closeAddFavModal(); };
882
+ $libModal.onclick = (e) => { if (e.target === $libModal) $libModal.classList.remove('show'); };
883
+
884
+ document.addEventListener('keydown', (e) => {
885
+ if (e.key === 'Escape') {
886
+ if ($addFavModal.classList.contains('show')) closeAddFavModal();
887
+ if ($libModal.classList.contains('show')) $libModal.classList.remove('show');
888
+ }
889
+ if (e.key === 'Enter' && $addFavModal.classList.contains('show')) commitFav();
890
+ });
891
+
892
+ $libSearch.oninput = renderLibrary;
893
+
894
+ window.addEventListener('resize', () => {
895
+ if (isDag()) {
896
+ const svg = document.getElementById('wires-svg');
897
+ if (svg) drawDagWires(svg, PROMPTS[state.promptKey].connections);
898
+ }
899
+ });
900
+
901
+ function appendNarration(html) {
902
+ $messages.appendChild(msgEl('aware', 'claude', html));
903
+ $messages.scrollTop = $messages.scrollHeight;
904
+ }
905
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
906
+ function escapeHtml(s) {
907
+ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
908
+ }
909
+ function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '&quot;'); }
910
+
911
+ /* ============= MENU ============= */
912
+
913
+ const $menuBtn = document.getElementById('menu-btn');
914
+ const $menu = document.getElementById('menu');
915
+
916
+ function toggleMenu(force) {
917
+ const willOpen = (force === undefined) ? !$menu.classList.contains('show') : force;
918
+ $menu.classList.toggle('show', willOpen);
919
+ $menuBtn.classList.toggle('open', willOpen);
920
+ }
921
+
922
+ $menuBtn.addEventListener('click', (e) => {
923
+ e.stopPropagation();
924
+ toggleMenu();
925
+ // Re-sync the start-on-login toggle whenever the menu opens — the task can be
926
+ // changed out-of-band (Windows Settings → Startup apps). refreshStartup is a
927
+ // hoisted function declaration defined later in this file.
928
+ if ($menu.classList.contains('show')) { try { refreshStartup(); } catch {} }
929
+ });
930
+
931
+ document.addEventListener('click', (e) => {
932
+ if ($menu.classList.contains('show') &&
933
+ !$menu.contains(e.target) &&
934
+ e.target !== $menuBtn) {
935
+ toggleMenu(false);
936
+ }
937
+ });
938
+
939
+ $menu.addEventListener('click', (e) => {
940
+ const item = e.target.closest('.menu-item');
941
+ if (!item) return;
942
+ const action = item.dataset.action;
943
+ toggleMenu(false);
944
+ handleMenuAction(action);
945
+ });
946
+
947
+ function handleMenuAction(action) {
948
+ switch (action) {
949
+ case 'open': doOpen(); break;
950
+ case 'save': doSave(); break;
951
+ case 'save-as': doSaveAs(); break;
952
+ case 'find': openFind(); break;
953
+ case 'integrations': openIntegrations(); break;
954
+ case 'routines': openRoutines(); break;
955
+ }
956
+ }
957
+
958
+ /* ============= ACTIONS ============= */
959
+ // "New" and local-file "Open" were removed: a workflow is an installed AWARE app,
960
+ // not a file the UI authors. doOpen is reassigned in aware.js to focus the workflow
961
+ // dropdown; doSave/doSaveAs are reassigned there to persist the open workflow's inputs.
962
+
963
+ function doOpen() { $promptSel.focus(); }
964
+
965
+ function doSave() {
966
+ const p = currentPrompt();
967
+ if (!p) return;
968
+ showToast(`Saved · ${p.name}`, 'ok');
969
+ }
970
+
971
+ function doSaveAs() {
972
+ const p = currentPrompt();
973
+ if (!p) return;
974
+ const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' });
975
+ const url = URL.createObjectURL(blob);
976
+ const a = document.createElement('a');
977
+ a.href = url;
978
+ a.download = p.name;
979
+ document.body.appendChild(a);
980
+ a.click();
981
+ document.body.removeChild(a);
982
+ setTimeout(() => URL.revokeObjectURL(url), 100);
983
+ showToast(`Downloaded ${p.name}`, 'ok');
984
+ }
985
+
986
+ /* ============= FIND ============= */
987
+
988
+ const $findOverlay = document.getElementById('find-overlay');
989
+ const $findInput = document.getElementById('find-input');
990
+ const $findCount = document.getElementById('find-count');
991
+
992
+ function openFind() {
993
+ $findOverlay.classList.add('show');
994
+ $findInput.value = '';
995
+ $findCount.textContent = '';
996
+ setTimeout(() => $findInput.focus(), 50);
997
+ }
998
+ function closeFind() {
999
+ $findOverlay.classList.remove('show');
1000
+ document.querySelectorAll('.agent-card').forEach(c => {
1001
+ c.classList.remove('find-dim', 'find-match');
1002
+ });
1003
+ }
1004
+
1005
+ $findInput.addEventListener('input', () => {
1006
+ const q = $findInput.value.toLowerCase().trim();
1007
+ let matches = 0;
1008
+ document.querySelectorAll('.agent-card').forEach(card => {
1009
+ if (!q) {
1010
+ card.classList.remove('find-dim', 'find-match');
1011
+ return;
1012
+ }
1013
+ const a = AGENTS[card.dataset.agentId];
1014
+ const hit = a && (
1015
+ a.title.toLowerCase().includes(q) ||
1016
+ a.kind.toLowerCase().includes(q) ||
1017
+ (a.blurb || '').toLowerCase().includes(q)
1018
+ );
1019
+ card.classList.toggle('find-match', !!hit);
1020
+ card.classList.toggle('find-dim', !hit);
1021
+ if (hit) matches++;
1022
+ });
1023
+ $findCount.textContent = q ? `${matches} match${matches === 1 ? '' : 'es'}` : '';
1024
+ });
1025
+
1026
+ document.getElementById('find-close').onclick = closeFind;
1027
+
1028
+ /* ============= THEME ============= */
1029
+
1030
+ const THEME_KEY = 'floless-demo-theme-v1';
1031
+
1032
+ function setTheme(theme) {
1033
+ document.documentElement.dataset.theme = theme;
1034
+ localStorage.setItem(THEME_KEY, theme);
1035
+ document.querySelectorAll('.theme-btn').forEach(b => {
1036
+ b.classList.toggle('active', b.dataset.theme === theme);
1037
+ });
1038
+ if (isDag()) {
1039
+ const svg = document.getElementById('wires-svg');
1040
+ if (svg) setTimeout(() => drawDagWires(svg, PROMPTS[state.promptKey].connections), 30);
1041
+ }
1042
+ }
1043
+
1044
+ function initTheme() {
1045
+ const t = localStorage.getItem(THEME_KEY) || 'dark';
1046
+ setTheme(t);
1047
+ }
1048
+
1049
+ document.querySelectorAll('.theme-btn').forEach(b => {
1050
+ b.addEventListener('click', () => setTheme(b.dataset.theme));
1051
+ });
1052
+
1053
+ /* ============= START ON LOGIN (autostart) ============= */
1054
+ // Toggles the per-user logon Scheduled Task (server/autostart.mjs) via /api/autostart.
1055
+ // The row is HIDDEN unless the server reports `supported` (Windows packaged build) —
1056
+ // the app's rule is hide, never show a dead control.
1057
+ const $startupRow = document.getElementById('menu-startup');
1058
+ const $startupToggle = document.getElementById('startup-toggle');
1059
+
1060
+ async function refreshStartup() {
1061
+ try {
1062
+ const r = await fetch('/api/autostart');
1063
+ if (!r.ok) { if ($startupRow) $startupRow.hidden = true; return; }
1064
+ const d = await r.json();
1065
+ const supported = !!d.supported;
1066
+ if ($startupRow) $startupRow.hidden = !supported;
1067
+ if (supported && $startupToggle) $startupToggle.checked = !!d.enabled;
1068
+ } catch { if ($startupRow) $startupRow.hidden = true; }
1069
+ }
1070
+
1071
+ if ($startupToggle) {
1072
+ $startupToggle.addEventListener('change', async () => {
1073
+ const want = $startupToggle.checked;
1074
+ $startupToggle.disabled = true;
1075
+ try {
1076
+ const r = await fetch('/api/autostart', {
1077
+ method: 'PUT',
1078
+ headers: { 'Content-Type': 'application/json' },
1079
+ body: JSON.stringify({ enabled: want }),
1080
+ });
1081
+ const d = await r.json().catch(() => ({}));
1082
+ if (!r.ok || !d.ok) throw new Error(d.error || 'request failed');
1083
+ $startupToggle.checked = !!d.enabled; // trust server truth (schtasks re-queried)
1084
+ // Success is silent — the switch position IS the confirmation (matches Theme).
1085
+ // UX review 2026-05-31: a preference write doesn't rise to a toast.
1086
+ } catch (e) {
1087
+ $startupToggle.checked = !want; // revert the optimistic flip
1088
+ showToast('Couldn’t update startup setting — check the server log', 'err');
1089
+ } finally {
1090
+ $startupToggle.disabled = false;
1091
+ }
1092
+ });
1093
+ }
1094
+
1095
+ // Initial read + a refresh each time the menu opens (state can change out-of-band,
1096
+ // e.g. the user disabling the task in Windows Settings).
1097
+ refreshStartup();
1098
+
1099
+ /* ============= TOAST ============= */
1100
+
1101
+ function showToast(msg, type) {
1102
+ const c = document.getElementById('toast-container');
1103
+ const t = document.createElement('div');
1104
+ t.className = `toast ${type || 'info'}`;
1105
+ const span = document.createElement('span');
1106
+ span.className = 'toast-msg';
1107
+ span.textContent = msg; // textContent — never innerHTML (msg can be a raw server error)
1108
+ t.appendChild(span);
1109
+ const dismiss = () => {
1110
+ t.style.transition = 'opacity 0.3s, transform 0.3s';
1111
+ t.style.opacity = '0';
1112
+ t.style.transform = 'translateY(8px)';
1113
+ setTimeout(() => t.remove(), 300);
1114
+ };
1115
+ // Errors stay until dismissed (they carry an action to read); warnings linger
1116
+ // longer; info/ok auto-dismiss. warn/err get an × so they can be closed early.
1117
+ const ms = { ok: 2500, info: 2500, warn: 5000, err: 0 }[type] ?? 2500;
1118
+ if (type === 'warn' || type === 'err') {
1119
+ const x = document.createElement('button');
1120
+ x.className = 'toast-close';
1121
+ x.type = 'button';
1122
+ x.setAttribute('aria-label', 'Dismiss');
1123
+ x.textContent = '×';
1124
+ x.onclick = dismiss;
1125
+ t.appendChild(x);
1126
+ }
1127
+ c.appendChild(t);
1128
+ if (ms > 0) setTimeout(dismiss, ms);
1129
+ }
1130
+
1131
+ /* ============= ZOOM + PAN ============= */
1132
+
1133
+ const ZOOM_LEVELS = [0.5, 0.6, 0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0];
1134
+ const MIN_ZOOM = ZOOM_LEVELS[0];
1135
+ const MAX_ZOOM = ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
1136
+ let zoom = 1; // continuous scale (fit produces arbitrary values, not just presets)
1137
+ let panX = 0, panY = 0; // pan offset in screen px (transform-origin is the canvas centre)
1138
+
1139
+ function applyTransform() {
1140
+ $topology.style.transformOrigin = '50% 50%';
1141
+ $topology.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
1142
+ document.getElementById('zoom-reset').textContent = Math.round(zoom * 100) + '%';
1143
+ document.getElementById('zoom-out').disabled = zoom <= MIN_ZOOM + 1e-3;
1144
+ document.getElementById('zoom-in').disabled = zoom >= MAX_ZOOM - 1e-3;
1145
+ if (isDag()) {
1146
+ const svg = document.getElementById('wires-svg');
1147
+ if (svg) requestAnimationFrame(() => drawDagWires(svg, PROMPTS[state.promptKey].connections));
1148
+ }
1149
+ }
1150
+
1151
+ // Step to the next preset zoom above (+1) or below (-1) the current continuous zoom.
1152
+ function zoomBy(delta) {
1153
+ const next = delta > 0
1154
+ ? (ZOOM_LEVELS.find((z) => z > zoom + 1e-3) ?? MAX_ZOOM)
1155
+ : ([...ZOOM_LEVELS].reverse().find((z) => z < zoom - 1e-3) ?? MIN_ZOOM);
1156
+ if (Math.abs(next - zoom) < 1e-3) return;
1157
+ zoom = next;
1158
+ applyTransform();
1159
+ }
1160
+
1161
+ // Reset to 100% + recentre. Also called by renderTopology so the zoom/pan state
1162
+ // matches the transform it clears (otherwise the % label would go stale on switch).
1163
+ function resetView() { zoom = 1; panX = 0; panY = 0; applyTransform(); }
1164
+
1165
+ // Fit the whole workflow into the visible canvas (zooms in OR out), then recentre.
1166
+ // Measures the real node cluster — offset* are unscaled layout coords, so they're
1167
+ // unaffected by the current transform.
1168
+ function fitToScreen() {
1169
+ const cards = [...$topology.querySelectorAll('.agent-card')];
1170
+ if (!cards.length) { resetView(); return; }
1171
+ let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;
1172
+ cards.forEach((c) => {
1173
+ minL = Math.min(minL, c.offsetLeft);
1174
+ minT = Math.min(minT, c.offsetTop);
1175
+ maxR = Math.max(maxR, c.offsetLeft + c.offsetWidth);
1176
+ maxB = Math.max(maxB, c.offsetTop + c.offsetHeight);
1177
+ });
1178
+ const contentW = maxR - minL, contentH = maxB - minT;
1179
+ if (contentW <= 0 || contentH <= 0) { resetView(); return; }
1180
+ const pad = 40;
1181
+ const fit = Math.min(($topology.clientWidth - pad * 2) / contentW, ($topology.clientHeight - pad * 2) / contentH);
1182
+ zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, fit));
1183
+ panX = 0; panY = 0; // origin 50% 50% keeps the cluster centred
1184
+ applyTransform();
1185
+ }
1186
+
1187
+ document.getElementById('zoom-out').onclick = () => zoomBy(-1);
1188
+ document.getElementById('zoom-in').onclick = () => zoomBy(+1);
1189
+ document.getElementById('zoom-reset').onclick = () => resetView();
1190
+ const $zoomFit = document.getElementById('zoom-fit');
1191
+ if ($zoomFit) $zoomFit.onclick = () => fitToScreen();
1192
+
1193
+ // Ctrl/Cmd + scroll wheel = zoom on canvas (skips OS-level browser zoom)
1194
+ let zoomAccum = 0;
1195
+ document.querySelector('.canvas').addEventListener('wheel', (e) => {
1196
+ if (!e.ctrlKey && !e.metaKey) return;
1197
+ e.preventDefault();
1198
+ zoomAccum += e.deltaY;
1199
+ if (Math.abs(zoomAccum) >= 40) {
1200
+ zoomBy(zoomAccum < 0 ? +1 : -1);
1201
+ zoomAccum = 0;
1202
+ }
1203
+ }, { passive: false });
1204
+
1205
+ // Middle-mouse drag = pan the (possibly zoomed) canvas. preventDefault on the middle
1206
+ // button suppresses Windows' autoscroll bubble; the offset is screen-px (1:1 with the
1207
+ // cursor) because translate sits OUTSIDE the scale in the transform list.
1208
+ const $canvasEl = document.querySelector('.canvas');
1209
+ let panning = false, panFromX = 0, panFromY = 0, panBaseX = 0, panBaseY = 0;
1210
+ $canvasEl.addEventListener('mousedown', (e) => {
1211
+ if (e.button !== 1) return;
1212
+ e.preventDefault();
1213
+ panning = true;
1214
+ panFromX = e.clientX; panFromY = e.clientY;
1215
+ panBaseX = panX; panBaseY = panY;
1216
+ $canvasEl.classList.add('panning');
1217
+ });
1218
+ window.addEventListener('mousemove', (e) => {
1219
+ if (!panning) return;
1220
+ panX = panBaseX + (e.clientX - panFromX);
1221
+ panY = panBaseY + (e.clientY - panFromY);
1222
+ applyTransform();
1223
+ });
1224
+ window.addEventListener('mouseup', () => {
1225
+ if (!panning) return;
1226
+ panning = false;
1227
+ $canvasEl.classList.remove('panning');
1228
+ });
1229
+ $canvasEl.addEventListener('auxclick', (e) => { if (e.button === 1) e.preventDefault(); });
1230
+
1231
+ /* ============= INTEGRATIONS ============= */
1232
+ // The real, server-backed Integrations window lives in aware.js (openIntegrations/
1233
+ // renderIntegrations are reassigned there from /api/integrations). app.js only owns
1234
+ // the element refs + close wiring the live version reuses.
1235
+
1236
+ const $integrationsModal = document.getElementById('integrations-modal');
1237
+ const $integrationsList = document.getElementById('integrations-list');
1238
+
1239
+ document.getElementById('integrations-close').onclick = () => $integrationsModal.classList.remove('show');
1240
+ $integrationsModal.onclick = (e) => { if (e.target === $integrationsModal) $integrationsModal.classList.remove('show'); };
1241
+
1242
+ /* ============= KEYBOARD SHORTCUTS ============= */
1243
+
1244
+ document.addEventListener('keydown', (e) => {
1245
+ const tag = e.target.tagName;
1246
+ const inField = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
1247
+
1248
+ if (inField) {
1249
+ if (e.key === 'Escape' && e.target === $findInput) closeFind();
1250
+ return;
1251
+ }
1252
+
1253
+ const cmd = e.ctrlKey || e.metaKey;
1254
+ if (cmd && !e.shiftKey && e.key.toLowerCase() === 'o') { e.preventDefault(); doOpen(); return; }
1255
+ if (cmd && e.shiftKey && e.key.toLowerCase() === 's') { e.preventDefault(); doSaveAs(); return; }
1256
+ if (cmd && !e.shiftKey && e.key.toLowerCase() === 's') { e.preventDefault(); doSave(); return; }
1257
+ if (cmd && e.key.toLowerCase() === 'f') { e.preventDefault(); openFind(); return; }
1258
+ if (cmd && e.key.toLowerCase() === 'i') { e.preventDefault(); openIntegrations(); return; }
1259
+ if (cmd && (e.key === '=' || e.key === '+')) { e.preventDefault(); zoomBy(+1); return; }
1260
+ if (cmd && e.key === '-') { e.preventDefault(); zoomBy(-1); return; }
1261
+ if (cmd && e.key === '0') { e.preventDefault(); resetView(); return; }
1262
+ if (e.key === 'Home') { e.preventDefault(); fitToScreen(); return; }
1263
+ if (e.key === 'Escape') {
1264
+ if ($findOverlay.classList.contains('show')) closeFind();
1265
+ if ($integrationsModal.classList.contains('show')) $integrationsModal.classList.remove('show');
1266
+ if ($menu.classList.contains('show')) toggleMenu(false);
1267
+ }
1268
+ });
1269
+
1270
+ /* ============= TOOLTIPS ============= */
1271
+ // App-styled tooltips for any [data-tip] element — one reused box, event-delegated
1272
+ // (so JS-generated cards/chips/buttons get them for free), shown on hover AND
1273
+ // keyboard focus, and clamped to the viewport. Replaces native data-tip="" which can't
1274
+ // be themed to match the dark skin. Hand-rolled; the thin UI ships no tooltip lib.
1275
+ (function initTooltips() {
1276
+ const tip = document.createElement('div');
1277
+ tip.id = 'tooltip';
1278
+ tip.setAttribute('role', 'tooltip');
1279
+ tip.hidden = true;
1280
+ document.body.appendChild(tip);
1281
+ let anchor = null;
1282
+
1283
+ function place() {
1284
+ if (!anchor) return;
1285
+ const r = anchor.getBoundingClientRect();
1286
+ const tw = tip.offsetWidth, th = tip.offsetHeight, gap = 8;
1287
+ let top = r.bottom + gap;
1288
+ if (top + th > window.innerHeight - 6) top = r.top - gap - th; // flip above near the viewport bottom
1289
+ let left = r.left + r.width / 2 - tw / 2;
1290
+ left = Math.max(6, Math.min(left, window.innerWidth - tw - 6)); // clamp to viewport
1291
+ tip.style.left = Math.round(left) + 'px';
1292
+ tip.style.top = Math.round(Math.max(6, top)) + 'px';
1293
+ }
1294
+ function show(el) {
1295
+ const text = el.getAttribute('data-tip');
1296
+ if (!text) return; // empty data-tip = nothing to show
1297
+ anchor = el;
1298
+ tip.textContent = text; // textContent, not innerHTML — never renders markup
1299
+ tip.hidden = false;
1300
+ place();
1301
+ }
1302
+ function hide() { anchor = null; tip.hidden = true; }
1303
+
1304
+ document.addEventListener('mouseover', (e) => {
1305
+ const el = e.target.closest('[data-tip]');
1306
+ if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return; // ignore moves within the same element
1307
+ show(el);
1308
+ });
1309
+ document.addEventListener('mouseout', (e) => {
1310
+ const el = e.target.closest('[data-tip]');
1311
+ if (!el || (e.relatedTarget && el.contains(e.relatedTarget))) return;
1312
+ if (el === anchor) hide();
1313
+ });
1314
+ document.addEventListener('focusin', (e) => {
1315
+ const el = e.target.closest && e.target.closest('[data-tip]');
1316
+ if (el) show(el);
1317
+ });
1318
+ document.addEventListener('focusout', hide);
1319
+ document.addEventListener('click', hide, true); // a click acts on the control; don't strand the tip
1320
+ window.addEventListener('scroll', hide, true);
1321
+ window.addEventListener('resize', hide);
1322
+ })();
1323
+
1324
+ /* ============= INIT ============= */
1325
+ loadState();
1326
+ initTheme();
1327
+ loadWidths();
1328
+ applyCollapse();
1329
+ setupResize();
1330
+ saveFavs();
1331
+ renderFavBar();
1332
+ setupFavDropZone();
1333
+ renderChat();
1334
+ renderTopology();