@floless/app 0.14.0 → 0.15.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.
@@ -52609,7 +52609,7 @@ function appVersion() {
52609
52609
  return resolveVersion({
52610
52610
  isSea: isSea2(),
52611
52611
  sqVersionXml: readSqVersionXml(),
52612
- define: true ? "0.14.0" : void 0,
52612
+ define: true ? "0.15.0" : void 0,
52613
52613
  pkgVersion: readPkgVersion()
52614
52614
  });
52615
52615
  }
@@ -52619,7 +52619,7 @@ function resolveChannel(s) {
52619
52619
  return "dev";
52620
52620
  }
52621
52621
  function appChannel() {
52622
- return resolveChannel({ isSea: isSea2(), define: true ? "0.14.0" : void 0 });
52622
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.15.0" : void 0 });
52623
52623
  }
52624
52624
 
52625
52625
  // oauth-presets.ts
@@ -53211,6 +53211,9 @@ function hostRunBlocked(agent, agentsDir) {
53211
53211
  if (!hostWatcherActive()) return null;
53212
53212
  return "a host watcher is live for this host \u2014 stop it before running manually (one live model = one consumer)";
53213
53213
  }
53214
+ function foregroundSessionId(appId) {
53215
+ return `__run__:${appId}`;
53216
+ }
53214
53217
  function startSession(spec, deps = defaultDeps) {
53215
53218
  if (spec.hostBacked && hostWatcherActive(spec.id)) {
53216
53219
  return { ok: false, reason: "another host watcher is live \u2014 stop it first (one live model = one watcher)" };
@@ -53883,6 +53886,8 @@ function isGatedAwareRoute(url, method) {
53883
53886
  if (path === "/api/routines" || path.startsWith("/api/routines/")) {
53884
53887
  return m === "POST" || m === "PATCH";
53885
53888
  }
53889
+ if (path.startsWith("/api/trigger-run/")) return false;
53890
+ if (path === "/api/trigger-run") return m === "POST";
53886
53891
  return AWARE_ROUTES.some((p) => path.startsWith(p));
53887
53892
  }
53888
53893
 
@@ -57363,6 +57368,42 @@ async function startServer() {
57363
57368
  const running = cancelActiveRun();
57364
57369
  return { ok: true, running };
57365
57370
  });
57371
+ app.post(
57372
+ "/api/trigger-run",
57373
+ async (req, reply) => {
57374
+ const { id, inputs } = req.body ?? {};
57375
+ if (!id) return reply.status(400).send({ ok: false, error: "id required" });
57376
+ let appSpec;
57377
+ try {
57378
+ appSpec = readApp(id);
57379
+ } catch (err) {
57380
+ if (err instanceof AppNotFoundError) return reply.status(404).send({ ok: false, error: "app not found" });
57381
+ throw err;
57382
+ }
57383
+ if (!appSpec.triggerSource) {
57384
+ return reply.status(400).send({ ok: false, error: "not a streaming workflow \u2014 use \u25B6 Run for a one-shot" });
57385
+ }
57386
+ if (!appSpec.runnable) {
57387
+ return reply.send({ ok: false, error: "needs Compile \u2014 Run is gated on a fresh lock", blocked: true });
57388
+ }
57389
+ const sessionId = foregroundSessionId(id);
57390
+ const res = startSession({
57391
+ id: sessionId,
57392
+ workflow: id,
57393
+ sourceNodeId: appSpec.triggerSource.nodeId,
57394
+ hostBacked: isHostBacked(appSpec.triggerSource.agent),
57395
+ inputs
57396
+ });
57397
+ if (!res.ok) return reply.send({ ok: false, error: res.reason, blocked: true });
57398
+ return { ok: true, sessionId };
57399
+ }
57400
+ );
57401
+ app.post("/api/trigger-run/:id/stop", async (req, reply) => {
57402
+ const id = req.params.id;
57403
+ if (!/^[A-Za-z0-9._-]+$/.test(id)) return reply.status(400).send({ ok: false, error: "invalid app id" });
57404
+ await stopSession(foregroundSessionId(id));
57405
+ return { ok: true };
57406
+ });
57366
57407
  app.get("/api/latest-run/:id", async (req, reply) => {
57367
57408
  const id = req.params.id;
57368
57409
  if (!/^[A-Za-z0-9._-]+$/.test(id)) {
@@ -258,6 +258,26 @@ manifest edit + recompile, no engine. See the design doc for the Stage-1 plan.
258
258
  it renders the returned `html` in the HTML Viewer and caches it; otherwise it fills the Execution
259
259
  trace. The secondary **`Simulate`** header button is `simulate:true` (every node stubbed from its
260
260
  output-schema, no host) — a composition check.
261
+ - **▶ Run on a STREAMING/watch app = a LIVE FOREGROUND session** (not the finite run). When the
262
+ open app's source node is a `lifecycle:start` watch (`app.triggerSource != null`), the finite
263
+ `/api/run` never completes (a stream doesn't end → 60 s timeout). Instead Run starts an
264
+ **ephemeral** trigger session — `POST /api/trigger-run {id,inputs}` / `POST
265
+ /api/trigger-run/:id/stop` — that re-renders the report in the HTML Viewer on **every** fired
266
+ event (e.g. each Tekla selection change) until Stop, reusing the background-routine machinery
267
+ (`server/trigger-sessions.ts`: long-lived `aware app run` OUTSIDE the run lock, host-exclusivity
268
+ guard, per-event report HTML on the `trigger-report` SSE). The session id is namespaced
269
+ `foregroundSessionId()` → `__run__:<app>` so it can't collide with a routine id in the shared SSE
270
+ id space; `route-gate` gates the spawning POST and exempts the `/stop` teardown. Per-event repaint
271
+ reuses the existing `applyTriggerReport`→`paintReport` path (gated on `workflow === currentId`) —
272
+ **zero** new render plumbing. The watch node's `once:true` (AWARE `tekla.watch` input) makes the
273
+ same session fire once then exit ("Run complete"); `once:false` (default) streams continuously.
274
+ **Two UI gotchas a real Playwright pass caught (inference missed both):** (1) the always-on header
275
+ **■ Stop run is OCCLUDED by the report-modal backdrop**, so a live session shown in the Viewer
276
+ needs its OWN reachable Stop — the in-modal `#report-stop` (`web/index.html` action row,
277
+ warn-toned); (2) `paintGate()` recomputes Run-disabled / Stop-hidden on **every** gate repaint
278
+ (an fs-change → `loadApp` mid-session repaints), so it MUST include the live-session flag
279
+ (`foregroundTrigger`) exactly like `syncRunControls`, or a repaint re-arms Run and hides Stop
280
+ while the watcher is still live.
261
281
  - **Per-node run status on the canvas + a Stop button.** During a run each node card paints
262
282
  running → ✓ done / ✗ failed from the trace (`pushTrace`), and the report-run overlay shows a
263
283
  **Stop** button. See `references/dev-server-and-run-trace.md` for the trace event kinds, the live
package/dist/web/app.css CHANGED
@@ -2056,6 +2056,10 @@ body {
2056
2056
  .report-actions button { padding: 6px 12px; }
2057
2057
  .report-actions button.ghost { background: var(--surface-3); color: var(--text-muted); }
2058
2058
  .report-actions button.ghost:hover { color: var(--text); border-color: var(--accent-dim); }
2059
+ /* The in-modal live-session Stop reads as a stop (warn-toned), matching the header
2060
+ ■ Stop run — not a neutral ghost link like Share/Open. Existing --warn token only. */
2061
+ .report-actions button.report-stop { background: transparent; color: var(--warn); border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent); font-weight: 600; letter-spacing: 0.04em; }
2062
+ .report-actions button.report-stop:hover { background: color-mix(in srgb, var(--warn) 16%, transparent); border-color: var(--warn); color: var(--text); }
2059
2063
  #report-close { font-size: 18px; line-height: 1; padding: 4px 10px; }
2060
2064
  .report-stage { position: relative; flex: 1; background: #0f172a; }
2061
2065
  #report-frame { width: 100%; height: 100%; border: 0; display: block; background: #0f172a; }
package/dist/web/aware.js CHANGED
@@ -24,6 +24,7 @@
24
24
  const $reportOpen = document.getElementById('report-open');
25
25
  const $reportShare = document.getElementById('report-share');
26
26
  const $reportClose = document.getElementById('report-close');
27
+ const $reportStop = document.getElementById('report-stop');
27
28
  const $stopRunBtn = document.getElementById('stop-run-btn');
28
29
 
29
30
  // One persistent array the inspect Execution tab reads; we mutate in place so
@@ -330,8 +331,13 @@
330
331
 
331
332
  // Keep Run disabled (and the header Stop shown) if a gate re-paint lands mid-run (#39) —
332
333
  // never let a re-render re-arm Run while a run is still in flight.
333
- $runBtn.disabled = !app.runnable || reportRunning || state.running;
334
- if ($stopRunBtn) $stopRunBtn.hidden = !(reportRunning || state.running);
334
+ // …including a live foreground trigger session (▶ Run on a streaming app): a
335
+ // gate repaint mid-session (e.g. an fs-change → loadApp) must not re-arm Run or
336
+ // hide the Stop while the watcher is still live. The in-modal Stop tracks the
337
+ // same state (it's the reachable Stop while the Viewer covers the header one).
338
+ $runBtn.disabled = !app.runnable || reportRunning || state.running || !!foregroundTrigger;
339
+ if ($stopRunBtn) $stopRunBtn.hidden = !(reportRunning || state.running || foregroundTrigger);
340
+ if ($reportStop) $reportStop.hidden = !foregroundTrigger;
335
341
  $runBtn.dataset.tip = app.runnable
336
342
  ? 'Run the approved workflow'
337
343
  : app.runState === 'drift'
@@ -911,6 +917,16 @@
911
917
  // double-click LOAD the last result instantly instead of re-running.
912
918
  const lastReportByApp = new Map();
913
919
 
920
+ // A live FOREGROUND trigger session — ▶ Run on a streaming/watch app (one whose
921
+ // source is a lifecycle:start watch). The finite run can't complete for a stream,
922
+ // so Run hosts a live session that re-renders the report in the Viewer on every
923
+ // fired event until Stop. `{ appId, sessionId, firedCount }` while live, else null.
924
+ // Distinct from a background routine (⏱ Routines) and the finite report run
925
+ // (reportRunning). Its session id is server-namespaced with this prefix so its SSE
926
+ // snapshots route here, never to the Routines list.
927
+ const FG_SESSION_PREFIX = '__run__:';
928
+ let foregroundTrigger = null;
929
+
914
930
  // Reflect run-in-flight state in the ALWAYS-VISIBLE header so a run is stoppable even
915
931
  // when the HTML Viewer modal is closed (#39). The header ■ Stop run appears for the whole
916
932
  // duration of either run path (the modal run `reportRunning` and the inline run
@@ -919,7 +935,10 @@
919
935
  // the user, on the modal's × tooltip, that closing leaves the run going (Stop is in the
920
936
  // header) — the canvas keeps showing progress (markCanvasRunning), by design.
921
937
  function syncRunControls() {
922
- const running = reportRunning || state.running;
938
+ const running = reportRunning || state.running || !!foregroundTrigger;
939
+ // The in-modal Stop is reachable only for a live foreground session (the header
940
+ // Stop is occluded by the report-modal backdrop). Show it while one is live.
941
+ if ($reportStop) $reportStop.hidden = !foregroundTrigger;
923
942
  if ($stopRunBtn) {
924
943
  $stopRunBtn.hidden = !running;
925
944
  // Reset to a fresh, clickable Stop each run; stopRun() flips it to "Cancelling…".
@@ -945,7 +964,22 @@
945
964
  // pulse, and asks the server to kill the `aware app run` child. The in-flight
946
965
  // /api/run then rejects via api() and the catch renders the cancelled state.
947
966
  async function stopRun() {
948
- if (!reportRunning && !state.running) return;
967
+ if (!reportRunning && !state.running && !foregroundTrigger) return;
968
+ // A live foreground session ends via its own stop route (it runs outside the
969
+ // run lock, so /api/run/stop would no-op). Clear the flag first so the late
970
+ // `stopped` SSE snapshot is a harmless no-op rather than a double-handle.
971
+ if (foregroundTrigger) {
972
+ const ft = foregroundTrigger;
973
+ foregroundTrigger = null;
974
+ if ($stopRunBtn) { $stopRunBtn.disabled = true; $stopRunBtn.textContent = 'Stopping…'; }
975
+ const ovStop = document.querySelector('.overlay-stop');
976
+ if (ovStop) { ovStop.disabled = true; ovStop.textContent = 'Stopping…'; }
977
+ try { await api(`/api/trigger-run/${encodeURIComponent(ft.appId)}/stop`, { method: 'POST' }); } catch { /* best effort — the session may already be gone */ }
978
+ // A deliberate stop needs no toast — the subtitle change is the confirmation.
979
+ paintForegroundStopped('stopped');
980
+ syncRunControls();
981
+ return;
982
+ }
949
983
  cancelRequested = true;
950
984
  clearNodeStatus();
951
985
  // Reflect "cancelling" on BOTH Stop surfaces — the in-modal overlay and the header.
@@ -1308,6 +1342,10 @@
1308
1342
  });
1309
1343
 
1310
1344
  $reportClose.onclick = () => hideModal($reportModal);
1345
+ // The in-modal Stop for a live foreground session: the always-on header Stop sits
1346
+ // BEHIND the report modal's backdrop, so a session shown in the Viewer needs its
1347
+ // own reachable Stop. Visibility is driven by syncRunControls (foregroundTrigger).
1348
+ if ($reportStop) $reportStop.onclick = () => stopRun();
1311
1349
  $reportModal.addEventListener('click', (e) => { if (e.target === $reportModal) hideModal($reportModal); });
1312
1350
  // The Stop button is rebuilt into the overlay each run — delegate so one
1313
1351
  // listener survives every innerHTML swap.
@@ -2127,7 +2165,12 @@
2127
2165
  const app = currentId && apps.get(currentId);
2128
2166
  if (!app) return;
2129
2167
  if (!app.runnable) { showToast($runBtn.dataset.tip, 'warn'); return; }
2130
- if (state.running || reportRunning) return;
2168
+ if (state.running || reportRunning || foregroundTrigger) return;
2169
+
2170
+ // Streaming/watch app → a LIVE FOREGROUND session, not a finite run (which would
2171
+ // never complete and time out at 60s). It re-renders the report in the Viewer on
2172
+ // every fired event until Stop. (Simulate still takes the normal stubbed path.)
2173
+ if (!simulate && app.triggerSource) { startForegroundTrigger(app); return; }
2131
2174
 
2132
2175
  markCanvasRunning(); // immediate during-run feedback; the trace refines per node
2133
2176
 
@@ -2782,7 +2825,11 @@
2782
2825
  loadRoutinesData();
2783
2826
  if (window.flolessPanels) window.flolessPanels.refreshData(); // routine runs also refresh bindings
2784
2827
  } else if (m.type === 'trigger-session-changed') {
2785
- applyTriggerSnapshot(m.id, m.snapshot);
2828
+ // A foreground live-run session ( Run on a streaming app) shares this SSE
2829
+ // event with background routines; its server-namespaced id routes it to the
2830
+ // Viewer handler instead of the Routines list.
2831
+ if (typeof m.id === 'string' && m.id.startsWith(FG_SESSION_PREFIX)) applyForegroundSnapshot(m.id, m.snapshot);
2832
+ else applyTriggerSnapshot(m.id, m.snapshot);
2786
2833
  } else if (m.type === 'trigger-report') {
2787
2834
  applyTriggerReport(m);
2788
2835
  } else if (m.type === 'connect-result') {
@@ -3489,6 +3536,144 @@
3489
3536
  $reportOverlay.append(mkEl('div', null, 'Listening for the next selection…'));
3490
3537
  }
3491
3538
 
3539
+ // ── Foreground live run (▶ Run on a streaming/watch app) ──────────────────────
3540
+ // Start a live session: the Viewer re-renders the report on every fired event
3541
+ // (e.g. each Tekla selection change) until Stop. Reuses the exact trigger-report /
3542
+ // trigger-session-changed SSE path the background routine already drives — the
3543
+ // server hosts the long-lived `aware app run`; here we open the Viewer, surface
3544
+ // Stop, and reflect the live state. We never compose HTML — the Viewer relays
3545
+ // exactly what the node returns (applyTriggerReport paints each fresh report).
3546
+ async function startForegroundTrigger(app) {
3547
+ if (foregroundTrigger) return;
3548
+ const id = currentId;
3549
+ $reportTitle.textContent = `HTML Viewer · ${app.displayName}`;
3550
+ $reportModal.dataset.nodeId = reportNodeId() || '';
3551
+ const cached = lastReportByApp.get(id);
3552
+ if (cached && cached.html) paintReport(cached.html);
3553
+ else { $reportFrame.srcdoc = ''; $reportShare.hidden = true; }
3554
+ $reportOverlay.hidden = false;
3555
+ $reportOverlay.replaceChildren();
3556
+ $reportOverlay.append(mkEl('div', 'spinner'), mkEl('div', null, 'Starting live session…'));
3557
+ $reportSub.innerHTML = `Live run of <code>${escapeHtml(id)}</code> — the report re-renders on every event.`;
3558
+ showModal($reportModal);
3559
+ try {
3560
+ const res = await api('/api/trigger-run', { method: 'POST', body: JSON.stringify({ id, inputs: currentInputs() }) });
3561
+ foregroundTrigger = { appId: id, sessionId: res.sessionId, firedCount: 0 };
3562
+ syncRunControls(); // header ■ Stop run becomes reachable; Run disabled while live
3563
+ paintForegroundListening();
3564
+ appendNarration(`Live session started for <strong>${escapeHtml(id)}</strong> — the report updates on every event. Click <strong>■ Stop run</strong> (top right) to end it.`);
3565
+ } catch (e) {
3566
+ const msg = (e && e.message) ? e.message : String(e);
3567
+ // A refusal (a watcher already holds this host) names the concrete path back —
3568
+ // never a dead end. Other failures are recoverable; the server returns them
3569
+ // in-band, so a failed start is a viewer message + toast, not a console fault.
3570
+ const blocked = !!(e.body && e.body.blocked);
3571
+ $reportShare.hidden = true; // the overlay is the primary content — nothing to share
3572
+ $reportOverlay.hidden = false;
3573
+ $reportOverlay.replaceChildren();
3574
+ if (blocked) {
3575
+ $reportOverlay.append(
3576
+ mkEl('div', null, 'One live model, one watcher — a session is already watching this host.'),
3577
+ mkEl('div', null, 'To run here, stop it first: open ⏱ Routines and disable the watcher, then click ▶ Run workflow.'),
3578
+ );
3579
+ $reportSub.textContent = 'Live run blocked — a watcher already holds this host.';
3580
+ showToast('Blocked — stop the active watcher in ⏱ Routines first', 'warn');
3581
+ } else {
3582
+ $reportOverlay.append(mkEl('div', null, `Couldn't start the live session: ${msg}. Check the host is attached and the lock is fresh, then click ▶ Run workflow.`));
3583
+ $reportSub.textContent = 'Live session failed to start.';
3584
+ showToast('Live run failed: ' + msg, 'warn');
3585
+ }
3586
+ syncRunControls();
3587
+ }
3588
+ }
3589
+
3590
+ // SSE: a foreground live session's snapshot changed. Drives the Viewer's live
3591
+ // state (collecting on each fire; ended on stop/error). Ignores a stale snapshot
3592
+ // for a superseded/stopped session (foregroundTrigger already cleared).
3593
+ function applyForegroundSnapshot(id, snapshot) {
3594
+ if (!foregroundTrigger || id !== foregroundTrigger.sessionId || !snapshot) return;
3595
+ const prevFired = foregroundTrigger.firedCount || 0;
3596
+ foregroundTrigger.firedCount = snapshot.firedCount || 0;
3597
+ const fired = foregroundTrigger.firedCount > prevFired;
3598
+ const viewerOpen = currentId === foregroundTrigger.appId && $reportModal.classList.contains('show');
3599
+ if (snapshot.state === 'error') {
3600
+ foregroundTrigger = null;
3601
+ if (viewerOpen) {
3602
+ $reportShare.hidden = true; // the error overlay is the primary content now
3603
+ $reportOverlay.hidden = false;
3604
+ $reportOverlay.replaceChildren();
3605
+ $reportOverlay.append(mkEl('div', null, `Live session ended with an error: ${snapshot.error || 'the host stopped responding'}. Check Tekla is open and the workflow is compiled, then click ▶ Run workflow to try again.`));
3606
+ $reportSub.textContent = 'Live session error.';
3607
+ }
3608
+ showToast('Live session ended: ' + (snapshot.error || 'host error'), 'warn');
3609
+ syncRunControls();
3610
+ return;
3611
+ }
3612
+ if (snapshot.state === 'stopped') {
3613
+ // The stream ended on its own — a one-shot (`once`) snapshot fired then exited,
3614
+ // or the host closed cleanly. A clean self-exit reads as "Run complete".
3615
+ foregroundTrigger = null;
3616
+ if (viewerOpen) paintForegroundStopped('complete');
3617
+ syncRunControls();
3618
+ return;
3619
+ }
3620
+ // listening: a new fire → "collecting" spinner until applyTriggerReport paints it.
3621
+ if (fired && viewerOpen && !reportRunning) showTriggerCollecting();
3622
+ }
3623
+
3624
+ // The live-but-idle state: the session is listening and no event has fired yet.
3625
+ // A watch app shows nothing until the user next acts in the host, so the copy
3626
+ // DIRECTS that action. If a previous report is cached, keep it visible behind a
3627
+ // live pip (NOT a spinner — nothing is computing); else an instructional prompt
3628
+ // with a Stop. Never a dead end, never a spinner that won't resolve on its own.
3629
+ function paintForegroundListening() {
3630
+ if (!$reportModal.classList.contains('show')) return;
3631
+ const cached = currentId && lastReportByApp.get(currentId);
3632
+ if (cached && cached.html) {
3633
+ $reportOverlay.hidden = true;
3634
+ $reportOverlay.replaceChildren();
3635
+ $reportShare.hidden = false; // the cached report is the primary content
3636
+ $reportSub.innerHTML = `<span class="live-pip" aria-hidden="true"></span>Live · waiting for the next change — change your selection in Tekla to refresh the report.`;
3637
+ } else {
3638
+ $reportFrame.srcdoc = '';
3639
+ $reportShare.hidden = true;
3640
+ $reportOverlay.hidden = false;
3641
+ $reportOverlay.replaceChildren();
3642
+ $reportOverlay.append(
3643
+ mkEl('div', null, 'Listening for events — change your selection in Tekla to generate the first report.'),
3644
+ Object.assign(mkEl('button', 'overlay-stop', '■ Stop run'), { type: 'button' }),
3645
+ );
3646
+ $reportSub.innerHTML = `<span class="live-pip" aria-hidden="true"></span>Live · listening…`;
3647
+ }
3648
+ }
3649
+
3650
+ // The session ended. `reason` distinguishes a clean self-exit ('complete' — e.g. a
3651
+ // one-shot `once` snapshot) from a deliberate Stop ('stopped'). Either way keep the
3652
+ // last report visible; the path back is ▶ Run workflow.
3653
+ function paintForegroundStopped(reason) {
3654
+ if (!$reportModal.classList.contains('show')) return;
3655
+ const complete = reason === 'complete';
3656
+ const cached = currentId && lastReportByApp.get(currentId);
3657
+ if (complete) showToast('Run complete', 'ok');
3658
+ if (cached && cached.html) {
3659
+ $reportOverlay.hidden = true;
3660
+ $reportOverlay.replaceChildren();
3661
+ $reportShare.hidden = false;
3662
+ $reportSub.innerHTML = complete
3663
+ ? `Run complete · last report above — click <strong>▶ Run workflow</strong> to run again.`
3664
+ : `Live session stopped · last report above — click <strong>▶ Run workflow</strong> to start again.`;
3665
+ } else {
3666
+ $reportFrame.srcdoc = '';
3667
+ $reportShare.hidden = true;
3668
+ $reportOverlay.hidden = false;
3669
+ $reportOverlay.replaceChildren();
3670
+ $reportOverlay.append(mkEl('div', null, complete
3671
+ ? 'Run complete. Click ▶ Run workflow to run again.'
3672
+ : 'Live session stopped. Click ▶ Run workflow to start again.'));
3673
+ $reportSub.textContent = complete ? 'Run complete.' : 'Live session stopped.';
3674
+ }
3675
+ }
3676
+
3492
3677
  function renderRoutinesList() {
3493
3678
  const $list = document.getElementById('routines-list');
3494
3679
  const $quota = document.getElementById('rtn-quota');
@@ -289,6 +289,7 @@
289
289
  <div class="modal-sub" id="report-sub">Rendered from the live run — never composed by the UI.</div>
290
290
  </div>
291
291
  <div class="report-actions">
292
+ <button id="report-stop" class="ghost report-stop" hidden data-tip="Stop the live session — the report stops updating on each change">■ Stop run</button>
292
293
  <button id="report-share" class="ghost" hidden data-tip="Share this report as a link anyone can view — hosted on floless.io">↗ Share</button>
293
294
  <button id="report-open" class="ghost" data-tip="Open the report in a new browser tab">↗ Open</button>
294
295
  <button id="report-close" data-tip="Close">×</button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.14.0",
3
+ "version": "0.15.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": {