@floless/app 0.23.2 → 0.24.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.
@@ -97,10 +97,12 @@ curl -s http://127.0.0.1:4317/api/health
97
97
  ## Schedule presets (structured, NOT cron)
98
98
 
99
99
  Pick exactly one shape. Times are **local machine time**, `HH:MM` 24-hour. The finest granularity
100
- is **hourly** by design (no sub-hour schedules).
100
+ is **every minute** (`minutes`), for near-real-time monitoring (e.g. polling a log folder); the
101
+ 1-minute floor + the serialized run queue keep a routine from flooding.
101
102
 
102
103
  | User intent | ScheduleSpec |
103
104
  |---|---|
105
+ | "every 5 minutes" / "every 15 min" | `{"kind":"minutes","everyMinutes":5}` (must divide 60 and stay sub-hourly: 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30) |
104
106
  | "every 2 hours" | `{"kind":"hourly","everyHours":2}` (must divide 24: 1, 2, 3, 4, 6, 8, 12, 24) |
105
107
  | "every day at 7am" | `{"kind":"daily","time":"07:00"}` |
106
108
  | "every weekday at 7am" | `{"kind":"weekdays","time":"07:00"}` (Mon–Fri) |
@@ -48,13 +48,15 @@ in 3D (`steel-from-drawings`) → bake it out (an IFC file and/or native Tekla p
48
48
  to `demos/steel-from-drawings/` if you keep the editable copy in sync.
49
49
  6. **Bake the SAME scene into `steel-to-ifc` (the IFC bake-out), if it's installed.** Check
50
50
  `~/.aware/apps/steel-to-ifc/`. If present, replace the `scene:` mapping under that app's `bake-ifc`
51
- node `config.args` with the IDENTICAL scene (the installed copy and `demos/steel-to-ifc/` if
52
- present), then `aware app compile ~/.aware/apps/steel-to-ifc`. This keeps the exported IFC matching
51
+ node `config` (the `ifc` agent's `scene` input — NOT `config.args`; the app now calls the host-free
52
+ `ifc` AWARE agent, not an exec) with the IDENTICAL scene (the installed copy and `demos/steel-to-ifc/`
53
+ if present), then `aware app compile ~/.aware/apps/steel-to-ifc`. This keeps the exported IFC matching
53
54
  the 3D the user confirmed. (`steel-to-ifc` writes a real IFC file — IfcColumn/IfcBeam on the grid —
54
55
  openable in Tekla, SDS2, Revit or Navisworks; it never touches a Tekla model, so no Tekla needed.)
55
56
  7. **Bake the SAME scene into `steel-to-tekla` (native Tekla parts), if it's installed.** Check
56
57
  `~/.aware/apps/steel-to-tekla/`. If present, replace the `scene:` under that app's `bake-tekla` node
57
- `config.args` with the IDENTICAL scene (installed + `demos/steel-to-tekla/`), set `config.version`
58
+ `config` (the `tekla` `bake-scene` verb's `scene` input — NOT `config.args`; the app now calls the
59
+ first-class `bake-scene` verb, not an exec) with the IDENTICAL scene (installed + `demos/steel-to-tekla/`), set `config.version`
58
60
  to the user's RUNNING Tekla version (e.g. "2026.0" — the Open API connects to a version-specific
59
61
  channel, so a wrong version silently finds no model), then `aware app compile
60
62
  ~/.aware/apps/steel-to-tekla`. It creates a real Tekla member per element via the Open API (mode:
@@ -123,9 +123,9 @@ app that hasn't run yet is fine.
123
123
 
124
124
  - **Never write raw HTML/markup into descriptor strings** — it renders as literal text
125
125
  by design. Describe; don't mark up.
126
- - Every write is snapshotted to `~/.floless/ui/history/` (newest 20), and the user has
127
- one-click **Undo** and **Reset to default** in the app — so be bold composing, but
128
- never destructive: don't delete panels the user didn't ask to remove, and prefer
129
- editing the existing document over regenerating it.
126
+ - Every write is recorded in the app's undo timeline (`~/.floless/ui/timeline.json`,
127
+ newest 20 states), and the user has multi-level **Undo / Redo** and **Reset to default**
128
+ in the app — so be bold composing, but never destructive: don't delete panels the user
129
+ didn't ask to remove, and prefer editing the existing document over regenerating it.
130
130
  - The UI renders; AWARE runs; you compose. Don't put computed values in `stat` blocks
131
131
  that a `source`-bound block could keep live — a stale hand-written number misleads.
package/dist/web/app.css CHANGED
@@ -2813,8 +2813,14 @@ body {
2813
2813
  display: flex; justify-content: space-between; gap: 10px;
2814
2814
  padding: 4px 8px; border-radius: 4px; font-size: 12px; color: var(--text-dim);
2815
2815
  }
2816
+ .ext-history-item.is-current .ext-history-when { color: var(--accent); }
2816
2817
  .ext-history-when { color: var(--text); }
2817
2818
  .ext-history-count { font-family: var(--mono); font-size: 11px; color: var(--text-muted); }
2819
+ /* "you are here" marker on the cursor's history entry (multi-level undo/redo, #102). */
2820
+ .ext-history-current {
2821
+ font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em;
2822
+ color: var(--accent); margin-right: 6px; vertical-align: 1px;
2823
+ }
2818
2824
  .ext-history-empty { padding: 6px 8px 8px; font-size: 12px; color: var(--text-muted); }
2819
2825
  /* Reset is destructive-looking (though recoverable) — danger on hover, never accent. */
2820
2826
  #ext-reset-confirm:hover { color: var(--err); border-color: var(--err); background: color-mix(in srgb, var(--err) 10%, transparent); }
package/dist/web/aware.js CHANGED
@@ -2778,7 +2778,12 @@
2778
2778
  if (cancelRequested) return;
2779
2779
  if (ev.kind === 'run-start') clearNodeStatus();
2780
2780
  else if (ev.node && ev.kind === 'node-start') setNodeStatus(ev.node, 'running');
2781
- else if (ev.node && /^(node-output|output|write|node-end|node-ok)$/.test(ev.kind)) setNodeStatus(ev.node, 'done');
2781
+ // `would-write` is the simulate-mode counterpart of `write`: a write-mode node,
2782
+ // stubbed during Simulate, emits would-write (with proposed_inputs) instead of
2783
+ // actually writing. Treat it as a completed node so it leaves "running" like its
2784
+ // read-mode peers' node-output does — otherwise write nodes hang on "running"
2785
+ // after a Simulate completes (#104).
2786
+ else if (ev.node && /^(node-output|output|write|would-write|node-end|node-ok)$/.test(ev.kind)) setNodeStatus(ev.node, 'done');
2782
2787
  else if (ev.node && /error/i.test(ev.kind)) setNodeStatus(ev.node, 'error');
2783
2788
  else if (ev.kind === 'run-end' && ev.status && ev.status !== 'ok') {
2784
2789
  // run failed — any node still "running" (started, never produced output) failed
@@ -3902,6 +3907,7 @@
3902
3907
 
3903
3908
  function scheduleText(s) {
3904
3909
  if (!s || !s.kind) return '—';
3910
+ if (s.kind === 'minutes') return s.everyMinutes === 1 ? 'Every minute' : `Every ${s.everyMinutes} minutes`;
3905
3911
  if (s.kind === 'hourly') return s.everyHours === 1 ? 'Every hour' : `Every ${s.everyHours} hours`;
3906
3912
  if (s.kind === 'daily') return `Daily · ${s.time}`;
3907
3913
  if (s.kind === 'weekdays') return `Weekdays · ${s.time}`;
@@ -4481,6 +4487,7 @@
4481
4487
  const sched = (r && r.schedule) ? r.schedule : { kind: 'daily', time: '07:00' };
4482
4488
  fillTimeSelects();
4483
4489
  document.getElementById('rtn-kind').value = sched.kind;
4490
+ document.getElementById('rtn-everyminutes').value = sched.kind === 'minutes' ? String(sched.everyMinutes) : '5';
4484
4491
  document.getElementById('rtn-everyhours').value = sched.kind === 'hourly' ? String(sched.everyHours) : '2';
4485
4492
  setTimeSelects(sched.time || '07:00');
4486
4493
  document.getElementById('rtn-cron').value = sched.kind === 'cron' ? sched.expr : '';
@@ -4503,7 +4510,10 @@
4503
4510
  let schedule;
4504
4511
  if (!asTrigger) {
4505
4512
  const kind = document.getElementById('rtn-kind').value;
4506
- if (kind === 'hourly') {
4513
+ if (kind === 'minutes') {
4514
+ const n = Math.max(1, Math.min(30, parseInt(document.getElementById('rtn-everyminutes').value, 10) || 5));
4515
+ schedule = { kind: 'minutes', everyMinutes: n };
4516
+ } else if (kind === 'hourly') {
4507
4517
  const n = Math.max(1, Math.min(24, parseInt(document.getElementById('rtn-everyhours').value, 10) || 1));
4508
4518
  schedule = { kind: 'hourly', everyHours: n };
4509
4519
  } else if (kind === 'weekly') {
@@ -216,14 +216,18 @@
216
216
  <div class="menu ext-menu" id="ext-menu" role="menu" hidden>
217
217
  <button class="menu-item" id="ext-undo" role="menuitem">
218
218
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/></svg></span>
219
- <span class="menu-label">Undo last change</span>
219
+ <span class="menu-label">Undo</span>
220
+ </button>
221
+ <button class="menu-item" id="ext-redo" role="menuitem">
222
+ <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M15 14l5-5-5-5"/><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"/></svg></span>
223
+ <span class="menu-label">Redo</span>
220
224
  </button>
221
225
  <button class="menu-item" id="ext-reset" role="menuitem">
222
226
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg></span>
223
227
  <span class="menu-label">Reset to default</span>
224
228
  </button>
225
229
  <div class="menu-divider"></div>
226
- <div class="ext-history-head">History · restored by Undo, newest first</div>
230
+ <div class="ext-history-head">History newest first</div>
227
231
  <div class="ext-history" id="ext-history"></div>
228
232
  </div>
229
233
 
@@ -491,11 +495,23 @@
491
495
  <option value="daily">Every day</option>
492
496
  <option value="weekdays">Every weekday (Mon–Fri)</option>
493
497
  <option value="weekly">Specific days</option>
498
+ <option value="minutes">Every N minutes</option>
494
499
  <option value="hourly">Every N hours</option>
495
500
  <option value="cron">Custom (cron)</option>
496
501
  </select>
497
502
  </div>
498
503
  <div id="rtn-sched-detail">
504
+ <div class="modal-field" data-sched="minutes" hidden>
505
+ <label for="rtn-everyminutes">Run every</label>
506
+ <select id="rtn-everyminutes" class="rtn-kind-select" aria-describedby="rtn-minutes-hint">
507
+ <option value="5" selected>Every 5 minutes</option>
508
+ <option value="10">Every 10 minutes</option>
509
+ <option value="15">Every 15 minutes</option>
510
+ <option value="30">Every 30 minutes</option>
511
+ <option value="1">Every minute</option>
512
+ </select>
513
+ <div class="rtn-cron-hint" id="rtn-minutes-hint">Clock-aligned to the top of the hour (e.g. :00, :15, :30, :45) · actual precision is the ~1-minute scheduler tick.</div>
514
+ </div>
499
515
  <div class="modal-field" data-sched="hourly" hidden>
500
516
  <label for="rtn-everyhours">Run every</label>
501
517
  <select id="rtn-everyhours" class="rtn-kind-select">
@@ -32,6 +32,8 @@
32
32
  const $dashDot = document.getElementById('dash-dot');
33
33
  const $extBadge = document.getElementById('ext-badge');
34
34
  const $extMenu = document.getElementById('ext-menu');
35
+ const $extUndo = document.getElementById('ext-undo');
36
+ const $extRedo = document.getElementById('ext-redo');
35
37
  const $extHistory = document.getElementById('ext-history');
36
38
  const $centerName = document.getElementById('center-panel-name');
37
39
  const $centerRole = document.getElementById('center-panel-role');
@@ -499,42 +501,82 @@
499
501
  if (h < 48) return `${h} h ago`;
500
502
  return `${Math.round(h / 24)} d ago`;
501
503
  }
504
+ // Reflect undo/redo availability on the menu buttons (disabled, not hidden — they're
505
+ // persistent navigation controls; the native :disabled state communicates "you're at
506
+ // the end of the stack"). Called on menu open and after every undo/redo/reset.
507
+ function setUndoRedoEnabled(canUndo, canRedo) {
508
+ if ($extUndo) $extUndo.disabled = !canUndo;
509
+ if ($extRedo) $extRedo.disabled = !canRedo;
510
+ }
502
511
  async function renderHistoryList() {
503
512
  if (!$extHistory) return;
504
513
  $extHistory.innerHTML = '<div class="ext-history-empty">loading…</div>';
505
514
  try {
506
- const { history } = await api('/api/extensions/history');
515
+ const { history, canUndo, canRedo } = await api('/api/extensions/history');
516
+ setUndoRedoEnabled(canUndo, canRedo);
507
517
  if (!history || !history.length) {
508
518
  $extHistory.innerHTML = '<div class="ext-history-empty">No snapshots yet — every change lands here automatically.</div>';
509
519
  return;
510
520
  }
511
521
  $extHistory.innerHTML = history.slice(0, 8).map((h) => {
512
522
  const when = relAgo(h.timestamp) || (h.timestamp ? new Date(h.timestamp).toLocaleString() : '—');
513
- return `<div class="ext-history-item"><span class="ext-history-when" data-tip="${escapeAttr(h.timestamp)}">${escapeHtml(when)}</span>` +
514
- `<span class="ext-history-count">${Number(h.panelsCount) || 0} panel${h.panelsCount === 1 ? '' : 's'}</span></div>`;
523
+ const now = h.current ? '<span class="ext-history-current">now</span>' : '';
524
+ return `<div class="ext-history-item${h.current ? ' is-current' : ''}"><span class="ext-history-when" data-tip="${escapeAttr(h.timestamp)}">${escapeHtml(when)}</span>` +
525
+ `<span class="ext-history-count">${now}${Number(h.panelsCount) || 0} panel${h.panelsCount === 1 ? '' : 's'}</span></div>`;
515
526
  }).join('');
516
527
  } catch {
517
528
  $extHistory.innerHTML = '<div class="ext-history-empty">history unavailable</div>';
518
529
  }
519
530
  }
520
531
 
521
- // The one undo body — shared by the Customized menu's "Undo last change" and
522
- // the empty state's "Restore it". Returns whether the restore happened.
532
+ // The one undo body — shared by the Customized menu's "Undo" and the empty state's
533
+ // "Restore it". Walks one state back per call (multi-level). Returns whether it moved.
523
534
  async function undoNow() {
524
535
  try {
525
- await api('/api/extensions/undo', { method: 'POST' });
536
+ const res = await api('/api/extensions/undo', { method: 'POST' });
537
+ setUndoRedoEnabled(res.canUndo, res.canRedo);
526
538
  showToast('Restored the previous layout', 'ok');
527
539
  fetchExtensions();
528
540
  return true;
529
541
  } catch (err) {
530
- const msg = err && err.body && err.body.error;
531
- if (msg === 'nothing to undo') showToast('Nothing to undo yet', 'info');
542
+ const b = err && err.body;
543
+ if (b) setUndoRedoEnabled(b.canUndo, b.canRedo);
544
+ if (b && b.error === 'nothing to undo') showToast('Nothing to undo', 'info');
532
545
  else showToast('Undo failed: ' + (err && err.message ? err.message : err), 'warn');
533
546
  return false;
534
547
  }
535
548
  }
536
- const $extUndo = document.getElementById('ext-undo');
549
+ // Redo walks one state forward through the redo branch (a new edit truncates it).
550
+ async function redoNow() {
551
+ try {
552
+ const res = await api('/api/extensions/redo', { method: 'POST' });
553
+ setUndoRedoEnabled(res.canUndo, res.canRedo);
554
+ showToast('Restored the next layout', 'ok');
555
+ fetchExtensions();
556
+ return true;
557
+ } catch (err) {
558
+ const b = err && err.body;
559
+ if (b) setUndoRedoEnabled(b.canUndo, b.canRedo);
560
+ if (b && b.error === 'nothing to redo') showToast('Nothing to redo', 'info');
561
+ else showToast('Redo failed: ' + (err && err.message ? err.message : err), 'warn');
562
+ return false;
563
+ }
564
+ }
537
565
  if ($extUndo) $extUndo.onclick = () => { closeExtMenu(); undoNow(); };
566
+ if ($extRedo) $extRedo.onclick = () => { closeExtMenu(); redoNow(); };
567
+
568
+ // Ctrl/Cmd+Z = undo, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z = redo — multi-level, matching the
569
+ // menu. Only while the Dashboard view is active and focus isn't in a text field, so it
570
+ // never steals a real input's (or the terminal's) own undo.
571
+ document.addEventListener('keydown', (e) => {
572
+ if (view !== 'dashboard') return;
573
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
574
+ const t = e.target;
575
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
576
+ const k = e.key.toLowerCase();
577
+ if (k === 'z' && !e.shiftKey) { e.preventDefault(); undoNow(); }
578
+ else if (k === 'y' || (k === 'z' && e.shiftKey)) { e.preventDefault(); redoNow(); }
579
+ });
538
580
 
539
581
  // Reset is recoverable (archived to history first) but still asks — mirrors the
540
582
  // routine-delete confirm: Cancel holds focus; Esc/backdrop cancel.
@@ -562,12 +604,14 @@
562
604
  closeExtMenu();
563
605
  if (!(await confirmReset())) return;
564
606
  try {
565
- await api('/api/extensions/reset', { method: 'POST' });
607
+ const res = await api('/api/extensions/reset', { method: 'POST' });
608
+ setUndoRedoEnabled(res.canUndo, res.canRedo); // symmetric with undo/redo (Undo recovers it)
566
609
  showToast('Dashboard reset — your panels were saved to history', 'ok');
567
610
  fetchExtensions();
568
611
  } catch (err) {
569
- const msg = err && err.body && err.body.error;
570
- if (msg && /nothing to reset/i.test(msg)) showToast('Already the default dashboard', 'info');
612
+ const b = err && err.body;
613
+ if (b) setUndoRedoEnabled(b.canUndo, b.canRedo);
614
+ if (b && b.error && /nothing to reset/i.test(b.error)) showToast('Already the default dashboard', 'info');
571
615
  else showToast('Reset failed: ' + (err && err.message ? err.message : err), 'warn');
572
616
  }
573
617
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.23.2",
3
+ "version": "0.24.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": {