@floless/app 0.11.0 → 0.12.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.
@@ -0,0 +1,660 @@
1
+ /* ============================================================================
2
+ * panels.js — Custom Panels: renders ~/.floless/ui/extensions.json (the
3
+ * declarative descriptor the terminal AI composes) into the Dashboard view and
4
+ * extra Inspect tabs. See docs/superpowers/specs/2026-06-10-declarative-ui-
5
+ * extensions-design.md.
6
+ *
7
+ * Pure renderer, by contract: blocks come from the shipped catalog, data payloads
8
+ * arrive pre-resolved from GET /api/extensions, and EVERY interpolated string is
9
+ * escaped — no AI/user-authored HTML ever reaches the DOM (text renders as text).
10
+ * Loaded AFTER app.js + aware.js: reuses their globals (state, renderInspect,
11
+ * escapeHtml, escapeAttr, showToast) and the minimal window.flolessBridge seams
12
+ * (api, showHtmlReport, …) instead of duplicating run/report plumbing.
13
+ * ========================================================================== */
14
+ (() => {
15
+ const bridge = window.flolessBridge || {};
16
+ // aware.js always loads first and provides api; this fallback only guards a
17
+ // pathological load order so the file can't throw at parse time.
18
+ const api = bridge.api || (async (path, opts) => {
19
+ const res = await fetch(path, { headers: { 'content-type': 'application/json' }, ...opts });
20
+ const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
21
+ if (!res.ok || body.ok === false) {
22
+ const err = new Error(body.error || `HTTP ${res.status}`);
23
+ err.body = body;
24
+ throw err;
25
+ }
26
+ return body;
27
+ });
28
+
29
+ const $canvasMain = document.getElementById('canvas-main');
30
+ const $dashboard = document.getElementById('dashboard');
31
+ const $viewToggle = document.getElementById('view-toggle');
32
+ const $dashDot = document.getElementById('dash-dot');
33
+ const $extBadge = document.getElementById('ext-badge');
34
+ const $extMenu = document.getElementById('ext-menu');
35
+ const $extHistory = document.getElementById('ext-history');
36
+ const $centerName = document.getElementById('center-panel-name');
37
+ const $centerRole = document.getElementById('center-panel-role');
38
+ const $resetModal = document.getElementById('ext-reset-modal');
39
+ if (!$dashboard || !$viewToggle) return; // markup absent — nothing to wire
40
+
41
+ const LS_VIEW = 'floless:view';
42
+ let view = 'canvas';
43
+ try { if (localStorage.getItem(LS_VIEW) === 'dashboard') view = 'dashboard'; } catch { /* private mode */ }
44
+
45
+ let booted = false; // flips in boot() — called by aware.js once licensed + AWARE-ready
46
+ let ext = { descriptor: null, validation: null, data: {} };
47
+ let pendingCustomize = 0; // queued ui-customize requests (the composer's "waiting" line)
48
+ // run-app gate cache: appId -> { state, runnable, runState, missing }. Cleared on
49
+ // every refetch so a Compile elsewhere re-arms the buttons.
50
+ const appGate = new Map();
51
+ const RUN_APP_RE = /^run-app:([A-Za-z0-9._-]+)$/;
52
+
53
+ // ── descriptor accessors (defensive — the store is loose by design) ─────────
54
+ function allPanels() {
55
+ const d = ext.descriptor;
56
+ if (!d || !Array.isArray(d.panels)) return [];
57
+ return d.panels.filter((p) => p && typeof p === 'object' && !Array.isArray(p));
58
+ }
59
+ // v1 honors two slots. Anything else lands on the Dashboard rather than
60
+ // vanishing — a panel the user asked for must never silently disappear.
61
+ const inspectPanels = () => allPanels().filter((p) => p.slot === 'inspect-tab');
62
+ const dashboardPanels = () => allPanels().filter((p) => p.slot !== 'inspect-tab');
63
+ const descriptorInvalid = () => !!(ext.validation && ext.validation.valid === false);
64
+
65
+ // ── view toggle (Canvas | Dashboard) ─────────────────────────────────────────
66
+ function applyView() {
67
+ const dash = view === 'dashboard';
68
+ if ($canvasMain) $canvasMain.classList.toggle('view-dashboard', dash);
69
+ $dashboard.hidden = !dash;
70
+ $viewToggle.querySelectorAll('.view-btn').forEach((b) => {
71
+ const on = b.dataset.view === view;
72
+ b.classList.toggle('active', on);
73
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
74
+ });
75
+ if ($centerName) $centerName.textContent = dash ? 'Dashboard' : 'Canvas';
76
+ if ($centerRole) $centerRole.textContent = dash ? 'your panels · composed by your terminal AI' : 'transparency layer · read-mostly';
77
+ if (dash && $dashDot) $dashDot.hidden = true; // seen — clear the "updated" dot
78
+ }
79
+ function setView(v) {
80
+ view = v === 'dashboard' ? 'dashboard' : 'canvas';
81
+ try { localStorage.setItem(LS_VIEW, view); } catch { /* private mode */ }
82
+ applyView();
83
+ if (view === 'dashboard') renderDashboard();
84
+ }
85
+ $viewToggle.querySelectorAll('.view-btn').forEach((b) => { b.onclick = () => setView(b.dataset.view); });
86
+
87
+ // ── block renderers (all output escaped; placeholders over errors) ──────────
88
+ const payloadFor = (source) => (typeof source === 'string' ? ext.data[source] : undefined);
89
+ function rowsFrom(payload) {
90
+ if (Array.isArray(payload)) return payload;
91
+ if (payload && typeof payload === 'object' && Array.isArray(payload.rows)) return payload.rows;
92
+ return null;
93
+ }
94
+ const fmtVal = (v) =>
95
+ typeof v === 'number' || typeof v === 'string' ? String(v) : v == null ? '—' : JSON.stringify(v);
96
+
97
+ function placeholderHtml(msg) {
98
+ return `<div class="ext-placeholder">${escapeHtml(msg)}</div>`;
99
+ }
100
+
101
+ function statHtml(b) {
102
+ return `<div class="ext-stat">` +
103
+ `<div class="ext-stat-label">${escapeHtml(String(b.label ?? ''))}</div>` +
104
+ `<div class="ext-stat-value">${escapeHtml(fmtVal(b.value))}</div>` +
105
+ (b.hint != null ? `<div class="ext-stat-hint">${escapeHtml(String(b.hint))}</div>` : '') +
106
+ `</div>`;
107
+ }
108
+
109
+ // Columns: the declared list wins; otherwise the union of keys from a row sample.
110
+ function columnsFor(rows, declared) {
111
+ if (Array.isArray(declared) && declared.length && declared.every((c) => typeof c === 'string')) return declared;
112
+ const cols = [];
113
+ for (const r of rows.slice(0, 20)) for (const k of Object.keys(r)) if (!cols.includes(k)) cols.push(k);
114
+ return cols;
115
+ }
116
+ // Sort mirroring html-report's #210 semantics: numbers compare numerically,
117
+ // everything else as strings, and rows MISSING the key sort last — under BOTH
118
+ // directions (the 0.62 fix).
119
+ function sortRows(rows, key, desc) {
120
+ const missing = (v) => v === undefined || v === null || v === '';
121
+ return rows.slice().sort((a, b) => {
122
+ const va = a ? a[key] : undefined;
123
+ const vb = b ? b[key] : undefined;
124
+ if (missing(va) && missing(vb)) return 0;
125
+ if (missing(va)) return 1;
126
+ if (missing(vb)) return -1;
127
+ const na = typeof va === 'number' ? va : Number(va);
128
+ const nb = typeof vb === 'number' ? vb : Number(vb);
129
+ const cmp = !Number.isNaN(na) && !Number.isNaN(nb) ? na - nb : String(va).localeCompare(String(vb));
130
+ return desc ? -cmp : cmp;
131
+ });
132
+ }
133
+ const MAX_ROWS = 200; // defensive cap; a panel is a glance, not a data dump
134
+
135
+ // The last run's trace exists but the server couldn't parse it (corrupt/truncated):
136
+ // distinct from "no run yet" — don't tell the user to run something that DID run.
137
+ const traceUnreadable = (payload) =>
138
+ !!(payload && typeof payload === 'object' && payload.traceUnreadable === true);
139
+
140
+ function tableHtml(b) {
141
+ const payload = payloadFor(b.source);
142
+ if (traceUnreadable(payload)) {
143
+ return placeholderHtml(`The last run of “${String(b.source ?? '')}” finished, but its trace couldn’t be read — re-run to refresh this table.`);
144
+ }
145
+ const rows = rowsFrom(payload);
146
+ if (!rows || !rows.length) {
147
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
148
+ // A run-output envelope with no row data: the run happened, it just isn't
149
+ // tabular — say so (and point at the block that fits) instead of "run it".
150
+ if ('rows' in payload || 'html' in payload) {
151
+ return placeholderHtml(`The last run of “${String(b.source ?? '')}” produced no table rows${typeof payload.html === 'string' ? ' — use a report block for this source instead' : ''}.`);
152
+ }
153
+ // A plain object payload (last-run-status / routine-status) → a field/value
154
+ // table, mirroring html-report's object auto-render.
155
+ const entries = Object.entries(payload).filter(([, v]) => typeof v !== 'object' || v === null);
156
+ if (entries.length) {
157
+ const body = entries.map(([k, v]) =>
158
+ `<tr><td>${escapeHtml(k)}</td><td>${escapeHtml(v == null ? '—' : String(v))}</td></tr>`,
159
+ ).join('');
160
+ return `<div class="ext-table-wrap"><table class="ext-table"><tbody>${body}</tbody></table></div>`;
161
+ }
162
+ }
163
+ return placeholderHtml(`No data for “${String(b.source ?? '')}” yet — run the workflow to fill this table.`);
164
+ }
165
+ let shown = rows.filter((r) => r && typeof r === 'object' && !Array.isArray(r));
166
+ const cols = columnsFor(shown, b.columns);
167
+ if (typeof b.sort === 'string' && b.sort) shown = sortRows(shown, b.sort, b['sort-desc'] === true);
168
+ const more = Math.max(0, shown.length - MAX_ROWS);
169
+ if (more) shown = shown.slice(0, MAX_ROWS);
170
+ const isNum = (v) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v)));
171
+ const head = cols.map((c) => `<th>${escapeHtml(c)}</th>`).join('');
172
+ const body = shown.map((r) =>
173
+ `<tr>${cols.map((c) => {
174
+ const v = r[c];
175
+ const txt = v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
176
+ return `<td${isNum(v) ? ' class="num"' : ''}>${escapeHtml(txt)}</td>`;
177
+ }).join('')}</tr>`,
178
+ ).join('');
179
+ return `<div class="ext-table-wrap"><table class="ext-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>` +
180
+ (more ? `<div class="ext-table-more">…and ${more} more row${more === 1 ? '' : 's'}</div>` : '');
181
+ }
182
+
183
+ function textHtml(b) {
184
+ const content = typeof b.content === 'string' ? b.content : '';
185
+ const paras = content.split(/\r?\n\s*\r?\n/).map((p) => p.trim()).filter(Boolean);
186
+ if (!paras.length) return '';
187
+ // Escaped FIRST, then single newlines become <br> — markup in content stays text.
188
+ return `<div class="ext-text">${paras.map((p) => `<p>${escapeHtml(p).replace(/\r?\n/g, '<br>')}</p>`).join('')}</div>`;
189
+ }
190
+
191
+ function reportHtml(b) {
192
+ const payload = payloadFor(b.source);
193
+ const title = typeof b.title === 'string' && b.title ? b.title : String(b.source ?? 'Report');
194
+ if (traceUnreadable(payload)) {
195
+ return placeholderHtml(`The last run of “${String(b.source ?? '')}” finished, but its trace couldn’t be read — re-run to refresh this report.`);
196
+ }
197
+ const html = payload && typeof payload === 'object' && typeof payload.html === 'string' ? payload.html : null;
198
+ if (!html) return placeholderHtml(`No report for “${String(b.source ?? '')}” yet — run the workflow to generate one.`);
199
+ return `<div class="ext-report-row">` +
200
+ `<span class="ext-report-title">${escapeHtml(title)}</span>` +
201
+ `<button type="button" class="ext-report-open" data-source="${escapeAttr(String(b.source))}" data-title="${escapeAttr(title)}" data-tip="Open in the HTML Viewer — the report the run produced, never composed by the UI">View report ▸</button>` +
202
+ `</div>`;
203
+ }
204
+
205
+ function actionHtml(b) {
206
+ const label = escapeHtml(String(b.label ?? 'Action'));
207
+ const actionId = typeof b['action-id'] === 'string' ? b['action-id'] : '';
208
+ const m = RUN_APP_RE.exec(actionId);
209
+ if (!m) {
210
+ // Declared intent the host doesn't wire yet — inert, honestly labeled.
211
+ return `<button type="button" class="ext-action" disabled data-tip="${escapeAttr(`This action (“${actionId || 'unnamed'}”) isn't wired in this FloLess version.`)}">${label}</button>`;
212
+ }
213
+ const appId = m[1];
214
+ const gate = appGate.get(appId);
215
+ let disabled = ' disabled';
216
+ let tip;
217
+ if (!gate || gate.state === 'loading') tip = 'Checking workflow state…';
218
+ else if (gate.missing) tip = `Blocked: workflow “${appId}” is not installed.`;
219
+ else if (!gate.runnable) {
220
+ // The same stale-lock language the header Run button teaches.
221
+ tip = gate.runState === 'drift'
222
+ ? 'Blocked: the .flo changed since the last approve — Compile first'
223
+ : 'Blocked: not compiled — Compile first';
224
+ } else {
225
+ disabled = '';
226
+ tip = `Run ${appId} for real against the live host`;
227
+ }
228
+ const inputs = b.inputs && typeof b.inputs === 'object' && !Array.isArray(b.inputs) ? b.inputs : {};
229
+ return `<button type="button" class="ext-action ext-action-run" data-app="${escapeAttr(appId)}" data-inputs="${escapeAttr(JSON.stringify(inputs))}"${disabled} data-tip="${escapeAttr(tip)}">▶ ${label}</button>`;
230
+ }
231
+
232
+ function unknownHtml(b) {
233
+ return `<div class="ext-placeholder ext-unknown">This “${escapeHtml(String(b.type ?? '?'))}” block needs a newer FloLess — it appears once you update.</div>`;
234
+ }
235
+
236
+ function blocksHtml(blocks) {
237
+ let html = '';
238
+ let statRun = []; // consecutive stats render as ONE KPI strip (mirrors AWARE's fallback renderer)
239
+ const flush = () => { if (statRun.length) { html += `<div class="ext-stat-row">${statRun.join('')}</div>`; statRun = []; } };
240
+ for (const block of blocks) {
241
+ if (!block || typeof block !== 'object') continue;
242
+ if (block.type === 'stat') { statRun.push(statHtml(block)); continue; }
243
+ flush();
244
+ switch (block.type) {
245
+ case 'table': html += tableHtml(block); break;
246
+ case 'text': html += textHtml(block); break;
247
+ case 'report': html += reportHtml(block); break;
248
+ case 'action': html += actionHtml(block); break;
249
+ default: html += unknownHtml(block);
250
+ }
251
+ }
252
+ flush();
253
+ return html;
254
+ }
255
+
256
+ function panelHtml(p) {
257
+ const blocks = Array.isArray(p.blocks) ? p.blocks : [];
258
+ return `<section class="ext-panel" data-panel="${escapeAttr(String(p.id ?? ''))}">` +
259
+ `<div class="ext-panel-title">${escapeHtml(String(p.title ?? p.id ?? 'Panel'))}</div>` +
260
+ blocksHtml(blocks) +
261
+ `</section>`;
262
+ }
263
+
264
+ // ── Dashboard render ─────────────────────────────────────────────────────────
265
+ function composerHtml() {
266
+ return `<div class="ext-composer-wrap">` +
267
+ `<div class="ext-pending" id="ext-pending" hidden></div>` +
268
+ `<div class="ext-composer">` +
269
+ `<input type="text" id="ext-composer-input" placeholder="Describe what to add or change — your terminal AI applies it" autocomplete="off" aria-label="Describe a dashboard change">` +
270
+ `<button type="button" id="ext-composer-send">Queue for terminal AI</button>` +
271
+ `</div>` +
272
+ `<div class="ext-composer-hint">Try: “Show the last BOM run as a table with a re-run button”.</div>` +
273
+ `</div>`;
274
+ }
275
+
276
+ function renderDashboard() {
277
+ const v = ext.validation;
278
+ const panels = descriptorInvalid() ? [] : dashboardPanels();
279
+ let html = '';
280
+ if (descriptorInvalid()) {
281
+ // Server-validated invalid descriptor ⇒ shipped default + a visible warning —
282
+ // a user file can never brick the app (design hard requirement).
283
+ const errs = Array.isArray(v.errors) ? v.errors : [];
284
+ html += `<div class="ext-invalid">` +
285
+ `<div class="ext-invalid-title">⚠ Your customization file has errors — showing the default view</div>` +
286
+ (errs.length ? `<ul>${errs.slice(0, 6).map((e) => `<li>${escapeHtml(String(e))}</li>`).join('')}</ul>` : '') +
287
+ `<div class="ext-invalid-hint">Ask your terminal AI to fix it, or use Undo (Customized, top left).</div>` +
288
+ `</div>`;
289
+ } else {
290
+ if (v && v.valid == null && v.note && panels.length) {
291
+ html += `<div class="ext-note">${escapeHtml(String(v.note))}</div>`;
292
+ }
293
+ if (v && v.valid === true && Array.isArray(v.warnings) && v.warnings.length) {
294
+ html += `<div class="ext-note">⚠ ${escapeHtml(String(v.warnings[0]))}${v.warnings.length > 1 ? ` (+${v.warnings.length - 1} more)` : ''}</div>`;
295
+ }
296
+ }
297
+ if (panels.length) {
298
+ html += `<div class="ext-grid">${panels.map(panelHtml).join('')}</div>`;
299
+ } else if (!descriptorInvalid()) {
300
+ // Quiet empty state: one sentence + the composer below. Nothing disabled.
301
+ html += `<div class="ext-empty">No custom panels yet — describe what you want below and your terminal AI builds it.</div>`;
302
+ }
303
+ html += composerHtml();
304
+ // Post-reset escape hatch: with no panels there is no Customized pill — and so
305
+ // no Undo control — which would strand a mistaken Reset (its dialog promises
306
+ // recoverability). The empty state offers the newest snapshot back, one click.
307
+ const offerRestore = !panels.length && !descriptorInvalid();
308
+ if (offerRestore) html += `<div class="ext-restore" id="ext-restore" hidden></div>`;
309
+ $dashboard.innerHTML = html;
310
+ renderPending();
311
+ ensureGates(collectRunAppIds(panels));
312
+ if (offerRestore) maybeOfferRestore();
313
+ }
314
+
315
+ // Fill the empty state's restore line iff history holds a snapshot. Disappears
316
+ // naturally when panels exist (only rendered in the empty branch) or when
317
+ // history is empty (this leaves it hidden). Re-checked on every re-render.
318
+ async function maybeOfferRestore() {
319
+ let history = [];
320
+ try { ({ history } = await api('/api/extensions/history')); } catch { return; }
321
+ const el = document.getElementById('ext-restore'); // may be gone if a re-render raced this fetch
322
+ if (!el || !Array.isArray(history) || !history.length) return;
323
+ const when = relAgo(String(history[0].timestamp || ''));
324
+ el.innerHTML = `Previous layout saved${when ? ' ' + escapeHtml(when) : ''} — ` +
325
+ `<button type="button" class="ext-restore-btn">Restore it</button>`;
326
+ el.hidden = false;
327
+ }
328
+
329
+ // ── run-app gating (the same runnable gate the header Run button enforces) ──
330
+ function collectRunAppIds(panels) {
331
+ const ids = new Set();
332
+ for (const p of panels) {
333
+ for (const b of Array.isArray(p.blocks) ? p.blocks : []) {
334
+ if (b && typeof b === 'object' && b.type === 'action' && typeof b['action-id'] === 'string') {
335
+ const m = RUN_APP_RE.exec(b['action-id']);
336
+ if (m) ids.add(m[1]);
337
+ }
338
+ }
339
+ }
340
+ return [...ids];
341
+ }
342
+ function ensureGates(ids) {
343
+ for (const id of ids) {
344
+ if (appGate.has(id)) continue;
345
+ appGate.set(id, { state: 'loading' });
346
+ api(`/api/app/${encodeURIComponent(id)}`)
347
+ .then(({ app }) => appGate.set(id, { state: 'ready', runnable: !!app.runnable, runState: app.runState, missing: false }))
348
+ .catch(() => appGate.set(id, { state: 'ready', runnable: false, runState: 'uncompiled', missing: true }))
349
+ .then(() => renderActiveSurfaces());
350
+ }
351
+ }
352
+
353
+ // ── delegated interactions (innerHTML rebuilds; listeners must survive) ─────
354
+ function onSurfaceClick(e) {
355
+ if (e.target.closest('#ext-composer-send')) { submitCustomize(); return; }
356
+ const restore = e.target.closest('.ext-restore-btn');
357
+ if (restore) { restoreFromHistory(restore); return; }
358
+ const rep = e.target.closest('.ext-report-open');
359
+ if (rep) { openReport(rep.dataset.source, rep.dataset.title); return; }
360
+ const run = e.target.closest('.ext-action-run');
361
+ if (run && !run.disabled) runAction(run);
362
+ }
363
+
364
+ // The empty state's "Restore it" — the same undo the Customized menu offers,
365
+ // reachable when the pill (and with it the menu) is gone after a reset.
366
+ async function restoreFromHistory(btn) {
367
+ btn.disabled = true;
368
+ const ok = await undoNow();
369
+ if (!ok) btn.disabled = false; // success re-renders the dashboard; the line goes away
370
+ }
371
+ $dashboard.addEventListener('click', onSurfaceClick);
372
+ const $inspectBody = document.getElementById('inspect-body');
373
+ if ($inspectBody) $inspectBody.addEventListener('click', onSurfaceClick);
374
+ $dashboard.addEventListener('keydown', (e) => {
375
+ if (e.key === 'Enter' && e.target && e.target.id === 'ext-composer-input') {
376
+ e.preventDefault();
377
+ submitCustomize();
378
+ }
379
+ });
380
+
381
+ function openReport(source, title) {
382
+ const payload = payloadFor(source);
383
+ const html = payload && typeof payload === 'object' && typeof payload.html === 'string' ? payload.html : null;
384
+ if (!html) { showToast('No report yet — run the workflow first.', 'info'); return; }
385
+ if (bridge.showHtmlReport) bridge.showHtmlReport(title || 'Report', html);
386
+ }
387
+
388
+ async function runAction(btn) {
389
+ const appId = btn.dataset.app;
390
+ let inputs = {};
391
+ try { inputs = JSON.parse(btn.dataset.inputs || '{}'); } catch { /* inert inputs */ }
392
+ const prev = btn.textContent;
393
+ btn.disabled = true;
394
+ btn.textContent = '◆ Running…';
395
+ try {
396
+ const res = await api('/api/run', { method: 'POST', body: JSON.stringify({ id: appId, simulate: false, inputs }) });
397
+ if (res.credentialIssue) {
398
+ showToast(`Session expired — reconnect ${res.credentialIssue.label} (≡ menu → Integrations), then run again.`, 'warn');
399
+ return;
400
+ }
401
+ const html = res.report && res.report.html;
402
+ if (html && bridge.showHtmlReport) bridge.showHtmlReport(`${appId} · report`, html);
403
+ else showToast(`Run complete · ${appId}`, 'ok');
404
+ } catch (err) {
405
+ const cred = err && err.body && err.body.credentialIssue;
406
+ if (cred) showToast(`Session expired — reconnect ${cred.label} (≡ menu → Integrations), then run again.`, 'warn');
407
+ else showToast('Run failed: ' + (err && err.message ? err.message : err), 'warn');
408
+ } finally {
409
+ btn.disabled = false;
410
+ btn.textContent = prev;
411
+ fetchExtensions(); // last-run bindings just changed (SSE run-ended also fires; refetch is idempotent)
412
+ }
413
+ }
414
+
415
+ // ── Customize composer (the Tweak reverse channel, dashboard-scoped) ────────
416
+ async function submitCustomize() {
417
+ const input = document.getElementById('ext-composer-input');
418
+ const btn = document.getElementById('ext-composer-send');
419
+ const instruction = input ? input.value.trim() : '';
420
+ if (!instruction) { if (input) input.focus(); return; }
421
+ if (btn) { btn.disabled = true; btn.textContent = 'Queueing…'; }
422
+ try {
423
+ const { request } = await api('/api/extensions/customize', { method: 'POST', body: JSON.stringify({ instruction }) });
424
+ const line = bridge.instructionFor ? bridge.instructionFor(request) : instruction;
425
+ const copied = bridge.copyToClipboard ? await bridge.copyToClipboard(line) : false;
426
+ showToast(copied ? 'Queued for your terminal AI · copied to clipboard' : 'Queued for your terminal AI', 'ok');
427
+ if (input) input.value = '';
428
+ if (bridge.loadRequests) bridge.loadRequests();
429
+ refreshPending();
430
+ } catch (err) {
431
+ showToast('Couldn’t queue the change: ' + (err && err.message ? err.message : err), 'warn');
432
+ } finally {
433
+ if (btn) { btn.disabled = false; btn.textContent = 'Queue for terminal AI'; }
434
+ }
435
+ }
436
+
437
+ // Between "queued" and "applied" the dashboard looks unchanged — say so inline,
438
+ // not just in a transient toast (UX review: kill the dead air for non-technical users).
439
+ async function refreshPending() {
440
+ if (!booted) return;
441
+ try {
442
+ const { requests } = await api('/api/requests');
443
+ pendingCustomize = (requests || []).filter((r) => r.type === 'ui-customize').length;
444
+ } catch { pendingCustomize = 0; }
445
+ renderPending();
446
+ }
447
+ function renderPending() {
448
+ const el = document.getElementById('ext-pending');
449
+ if (!el) return;
450
+ el.hidden = pendingCustomize === 0;
451
+ el.textContent = pendingCustomize === 0 ? '' :
452
+ `${pendingCustomize} change${pendingCustomize === 1 ? '' : 's'} queued for your terminal AI — paste the copied instruction there, or say “apply my floless request”.`;
453
+ }
454
+
455
+ // ── Customized badge + Undo / History / Reset menu ───────────────────────────
456
+ function renderBadge() {
457
+ if (!$extBadge) return;
458
+ const has = allPanels().length > 0;
459
+ $extBadge.hidden = !has;
460
+ if (!has) closeExtMenu();
461
+ }
462
+ let extMenuOpen = false;
463
+ function openExtMenu() {
464
+ renderHistoryList();
465
+ const r = $extBadge.getBoundingClientRect();
466
+ $extMenu.style.left = Math.round(Math.max(8, Math.min(r.left, window.innerWidth - 300))) + 'px';
467
+ $extMenu.hidden = false;
468
+ $extMenu.classList.add('show');
469
+ $extBadge.setAttribute('aria-expanded', 'true');
470
+ extMenuOpen = true;
471
+ }
472
+ function closeExtMenu() {
473
+ if (!extMenuOpen) return;
474
+ $extMenu.classList.remove('show');
475
+ $extMenu.hidden = true;
476
+ $extBadge.setAttribute('aria-expanded', 'false');
477
+ extMenuOpen = false;
478
+ }
479
+ if ($extBadge) {
480
+ $extBadge.addEventListener('click', (e) => {
481
+ e.stopPropagation();
482
+ if (extMenuOpen) closeExtMenu(); else openExtMenu();
483
+ });
484
+ document.addEventListener('click', (e) => {
485
+ if (extMenuOpen && !$extMenu.contains(e.target) && e.target !== $extBadge) closeExtMenu();
486
+ });
487
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && extMenuOpen) closeExtMenu(); });
488
+ }
489
+
490
+ function relAgo(iso) {
491
+ const ms = Date.now() - new Date(iso).getTime();
492
+ if (isNaN(ms) || ms < 0) return '';
493
+ const min = Math.round(ms / 60000);
494
+ if (min < 1) return 'just now';
495
+ if (min < 60) return `${min} min ago`;
496
+ const h = Math.round(min / 60);
497
+ if (h < 48) return `${h} h ago`;
498
+ return `${Math.round(h / 24)} d ago`;
499
+ }
500
+ async function renderHistoryList() {
501
+ if (!$extHistory) return;
502
+ $extHistory.innerHTML = '<div class="ext-history-empty">loading…</div>';
503
+ try {
504
+ const { history } = await api('/api/extensions/history');
505
+ if (!history || !history.length) {
506
+ $extHistory.innerHTML = '<div class="ext-history-empty">No snapshots yet — every change lands here automatically.</div>';
507
+ return;
508
+ }
509
+ $extHistory.innerHTML = history.slice(0, 8).map((h) => {
510
+ const when = relAgo(h.timestamp) || (h.timestamp ? new Date(h.timestamp).toLocaleString() : '—');
511
+ return `<div class="ext-history-item"><span class="ext-history-when" data-tip="${escapeAttr(h.timestamp)}">${escapeHtml(when)}</span>` +
512
+ `<span class="ext-history-count">${Number(h.panelsCount) || 0} panel${h.panelsCount === 1 ? '' : 's'}</span></div>`;
513
+ }).join('');
514
+ } catch {
515
+ $extHistory.innerHTML = '<div class="ext-history-empty">history unavailable</div>';
516
+ }
517
+ }
518
+
519
+ // The one undo body — shared by the Customized menu's "Undo last change" and
520
+ // the empty state's "Restore it". Returns whether the restore happened.
521
+ async function undoNow() {
522
+ try {
523
+ await api('/api/extensions/undo', { method: 'POST' });
524
+ showToast('Restored the previous layout', 'ok');
525
+ fetchExtensions();
526
+ return true;
527
+ } catch (err) {
528
+ const msg = err && err.body && err.body.error;
529
+ if (msg === 'nothing to undo') showToast('Nothing to undo yet', 'info');
530
+ else showToast('Undo failed: ' + (err && err.message ? err.message : err), 'warn');
531
+ return false;
532
+ }
533
+ }
534
+ const $extUndo = document.getElementById('ext-undo');
535
+ if ($extUndo) $extUndo.onclick = () => { closeExtMenu(); undoNow(); };
536
+
537
+ // Reset is recoverable (archived to history first) but still asks — mirrors the
538
+ // routine-delete confirm: Cancel holds focus; Esc/backdrop cancel.
539
+ function confirmReset() {
540
+ return new Promise((resolve) => {
541
+ const $confirm = document.getElementById('ext-reset-confirm');
542
+ const $cancel = document.getElementById('ext-reset-cancel');
543
+ $resetModal.classList.add('show');
544
+ setTimeout(() => $cancel.focus(), 0);
545
+ const done = (result) => {
546
+ $resetModal.classList.remove('show');
547
+ $confirm.onclick = $cancel.onclick = $resetModal.onclick = null;
548
+ document.removeEventListener('keydown', onKey, true);
549
+ resolve(result);
550
+ };
551
+ const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } };
552
+ $confirm.onclick = () => done(true);
553
+ $cancel.onclick = () => done(false);
554
+ $resetModal.onclick = (e) => { if (e.target === $resetModal) done(false); };
555
+ document.addEventListener('keydown', onKey, true);
556
+ });
557
+ }
558
+ const $extReset = document.getElementById('ext-reset');
559
+ if ($extReset) $extReset.onclick = async () => {
560
+ closeExtMenu();
561
+ if (!(await confirmReset())) return;
562
+ try {
563
+ await api('/api/extensions/reset', { method: 'POST' });
564
+ showToast('Dashboard reset — your panels were saved to history', 'ok');
565
+ fetchExtensions();
566
+ } catch (err) {
567
+ const msg = err && err.body && err.body.error;
568
+ if (msg && /nothing to reset/i.test(msg)) showToast('Already the default dashboard', 'info');
569
+ else showToast('Reset failed: ' + (err && err.message ? err.message : err), 'warn');
570
+ }
571
+ };
572
+
573
+ // ── extra Inspect tabs (slot: inspect-tab) ───────────────────────────────────
574
+ function renderInspectTabs() {
575
+ const tabs = document.getElementById('tabs');
576
+ if (!tabs) return;
577
+ tabs.querySelectorAll('button.ext-tab').forEach((b) => b.remove());
578
+ const list = descriptorInvalid() ? [] : inspectPanels();
579
+ for (const p of list) {
580
+ const b = document.createElement('button');
581
+ b.className = 'ext-tab';
582
+ b.dataset.tab = 'ext:' + String(p.id ?? '');
583
+ b.textContent = String(p.title ?? p.id ?? 'Panel');
584
+ tabs.appendChild(b); // app.js's delegated $tabs.onclick handles activation
585
+ }
586
+ if (typeof state.currentTab === 'string' && state.currentTab.startsWith('ext:')) {
587
+ const alive = list.some((p) => 'ext:' + String(p.id ?? '') === state.currentTab);
588
+ if (!alive) state.currentTab = 'description'; // panel renamed/removed — heal to a real tab
589
+ tabs.querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.tab === state.currentTab));
590
+ renderInspect();
591
+ }
592
+ }
593
+
594
+ function renderExtInspect(panelId) {
595
+ const $bd = document.getElementById('inspect-body');
596
+ if (!$bd) return;
597
+ const p = inspectPanels().find((x) => String(x.id ?? '') === panelId);
598
+ if (!p) {
599
+ $bd.innerHTML = '<div class="empty-state">This panel was removed from your customization.</div>';
600
+ return;
601
+ }
602
+ $bd.innerHTML = `<div class="ext-inspect">` +
603
+ `<div class="inspect-head-row"><div class="inspect-title">${escapeHtml(String(p.title ?? p.id))}</div></div>` +
604
+ `<div class="inspect-meta">custom panel · composed by your terminal AI</div>` +
605
+ blocksHtml(Array.isArray(p.blocks) ? p.blocks : []) +
606
+ `</div>`;
607
+ ensureGates(collectRunAppIds([p]));
608
+ }
609
+
610
+ // Route ext:* tabs through our renderer; everything else stays app.js's.
611
+ // (Same reassignment pattern aware.js uses for renderChat/selectAgent.)
612
+ const _renderInspect = renderInspect;
613
+ renderInspect = function renderInspectWithPanels() {
614
+ if (typeof state.currentTab === 'string' && state.currentTab.startsWith('ext:')) {
615
+ renderExtInspect(state.currentTab.slice(4));
616
+ return;
617
+ }
618
+ _renderInspect();
619
+ };
620
+
621
+ function renderActiveSurfaces() {
622
+ renderDashboard();
623
+ if (typeof state.currentTab === 'string' && state.currentTab.startsWith('ext:')) renderInspect();
624
+ }
625
+
626
+ // ── fetch + public surface ───────────────────────────────────────────────────
627
+ async function fetchExtensions(opts = {}) {
628
+ if (!booted) return;
629
+ let res;
630
+ try {
631
+ res = await api('/api/extensions');
632
+ } catch {
633
+ return; // gated/offline — keep the current render; the next signal retries
634
+ }
635
+ ext = { descriptor: res.descriptor ?? null, validation: res.validation ?? null, data: res.data || {} };
636
+ appGate.clear(); // compiles/installs elsewhere may have changed the gates
637
+ renderBadge();
638
+ renderInspectTabs();
639
+ renderDashboard();
640
+ // Content changed while the user is on Canvas → light the Dashboard dot
641
+ // (never auto-switch — don't yank their context).
642
+ if (opts.fromChange && view === 'canvas' && allPanels().length && $dashDot) $dashDot.hidden = false;
643
+ refreshPending();
644
+ }
645
+
646
+ function boot() {
647
+ if (booted) return;
648
+ booted = true;
649
+ fetchExtensions();
650
+ }
651
+
652
+ window.flolessPanels = {
653
+ boot,
654
+ refresh: (opts) => fetchExtensions(opts || {}),
655
+ refreshData: () => fetchExtensions(),
656
+ refreshPending,
657
+ };
658
+
659
+ applyView(); // restore the persisted view immediately (no fetch until boot)
660
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {