@floless/app 0.74.0 → 0.76.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.
- package/dist/floless-server.cjs +746 -270
- package/dist/schemas/steel.takeoff.v1.schema.json +26 -7
- package/dist/web/app.css +21 -2
- package/dist/web/app.js +13 -1
- package/dist/web/aware.js +16 -3
- package/dist/web/index.html +9 -0
- package/dist/web/steel-3d-view.js +17 -1
- package/dist/web/steel-editor.html +247 -14
- package/dist/web/workspaces.js +196 -9
- package/package.json +1 -1
package/dist/web/workspaces.js
CHANGED
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
model: document.getElementById('ws-frame-model'),
|
|
37
37
|
drawings: document.getElementById('ws-frame-drawings'),
|
|
38
38
|
};
|
|
39
|
+
const $drawingsBar = document.getElementById('ws-drawings-bar');
|
|
40
|
+
const $revisionCard = document.getElementById('ws-revision-card');
|
|
41
|
+
const $revisionFile = document.getElementById('ws-revision-file');
|
|
42
|
+
let currentStep = 'model';
|
|
39
43
|
|
|
40
44
|
// The Exports step's cards. `file:true` = writes a file on disk (shown with a "✓ exported HH:MM"
|
|
41
45
|
// line + ⧉ Open / ▤ Reveal); `open:false` (IFC) = reveal-only (no OS default app for a .ifc).
|
|
@@ -171,6 +175,7 @@
|
|
|
171
175
|
if (!p) { try { p = (await api('/api/projects')).projects.find((x) => x.id === id); } catch { /* fall through */ } }
|
|
172
176
|
if (!p) { showToast('Project not found — it may have been archived.', 'warn'); return loadProjects(); }
|
|
173
177
|
current = p;
|
|
178
|
+
lastRevState = 'none'; stopRevisionPoll(); // fresh project — drop any prior revision-poll state
|
|
174
179
|
$projName.textContent = p.name;
|
|
175
180
|
$crumbName.textContent = p.name;
|
|
176
181
|
$status.textContent = p.app;
|
|
@@ -184,10 +189,11 @@
|
|
|
184
189
|
setStep('model');
|
|
185
190
|
applyMode();
|
|
186
191
|
}
|
|
187
|
-
function closeProject() { current = null; closeProjMenu(); applyMode(); }
|
|
192
|
+
function closeProject() { current = null; stopRevisionPoll(); lastRevState = 'none'; $revisionCard.hidden = true; closeProjMenu(); applyMode(); }
|
|
188
193
|
document.getElementById('ws-back').addEventListener('click', closeProject);
|
|
189
194
|
|
|
190
195
|
function setStep(s) {
|
|
196
|
+
currentStep = s;
|
|
191
197
|
$stepTabs.querySelectorAll('button').forEach((b) => {
|
|
192
198
|
const on = b.dataset.step === s;
|
|
193
199
|
b.classList.toggle('active', on);
|
|
@@ -198,6 +204,8 @@
|
|
|
198
204
|
f.hidden = !show;
|
|
199
205
|
if (show && !f.dataset.loaded) { f.src = f.dataset.want; f.dataset.loaded = '1'; }
|
|
200
206
|
}
|
|
207
|
+
$drawingsBar.hidden = s !== 'drawings';
|
|
208
|
+
renderRevisionCard(); // reflect any pending/failed revision read (card shows only on the Drawings step)
|
|
201
209
|
// Exports is a shell-rendered pane (not an iframe): show/hide + (re)paint on open.
|
|
202
210
|
const showExports = s === 'exports';
|
|
203
211
|
$wsExports.hidden = !showExports;
|
|
@@ -240,6 +248,43 @@
|
|
|
240
248
|
}
|
|
241
249
|
document.getElementById('ws-approve').addEventListener('click', approveProject);
|
|
242
250
|
|
|
251
|
+
// ── Find in model bridge (app.js Ctrl+F delegates here in Workspaces mode) ──────
|
|
252
|
+
// Active only when a project is open on the Model step, the editor iframe is loaded and exposes
|
|
253
|
+
// findMember(), and there's no pending "AI updated — reload" banner (searching a stale in-memory
|
|
254
|
+
// model would mislead). Same-origin contentWindow call, like the Approve flush above.
|
|
255
|
+
function wsFindActive() {
|
|
256
|
+
if (!$app.classList.contains('mode-workspaces')) return false;
|
|
257
|
+
if (!current || frames.model.hidden) return false; // Model step must be showing
|
|
258
|
+
const w = frames.model.contentWindow;
|
|
259
|
+
if (!w || typeof w.findMember !== 'function') return false;
|
|
260
|
+
try { if (w.document.getElementById('aiUpdateBanner')) return false; } catch { return false; }
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
function wsFind(q) {
|
|
264
|
+
try { const w = frames.model.contentWindow; if (w && typeof w.findMember === 'function') return w.findMember(q); } catch { /* iframe reloaded between findActive() and here */ }
|
|
265
|
+
return { count: 0 };
|
|
266
|
+
}
|
|
267
|
+
function wsClearFind() {
|
|
268
|
+
const w = frames.model && frames.model.contentWindow;
|
|
269
|
+
if (w && typeof w.clearFind === 'function') { try { w.clearFind(); } catch { /* iframe gone */ } }
|
|
270
|
+
}
|
|
271
|
+
// While focus is inside the editor iframe (the usual case on the Model step), the parent's Ctrl+F
|
|
272
|
+
// handler never sees the key — so the shell forwards Cmd/Ctrl+F from the same-origin editor up to
|
|
273
|
+
// the global openFind. Re-wired on every iframe (re)load; each load is a fresh contentWindow, so
|
|
274
|
+
// the listeners don't stack.
|
|
275
|
+
frames.model.addEventListener('load', () => {
|
|
276
|
+
try {
|
|
277
|
+
frames.model.contentWindow.addEventListener('keydown', (e) => {
|
|
278
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
if (typeof window.openFind === 'function') window.openFind();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
} catch { /* not-yet-loaded / cross-origin */ }
|
|
284
|
+
});
|
|
285
|
+
// (find methods are exposed on the canonical window.flolessWorkspaces export below — a separate
|
|
286
|
+
// assignment here would be clobbered by that later `= { setMode, … }`.)
|
|
287
|
+
|
|
243
288
|
// ── Exports step ──────────────────────────────────────────────────────────────
|
|
244
289
|
const hhmm = (iso) => {
|
|
245
290
|
const d = new Date(iso);
|
|
@@ -367,6 +412,141 @@
|
|
|
367
412
|
if (rev) { fileAction('/api/reveal', rev.dataset.reveal, 'Couldn’t reveal the file'); return; }
|
|
368
413
|
});
|
|
369
414
|
|
|
415
|
+
// ── Revisions: attach a revised drawing set → queue a compose-time read (Slice 4b) ──
|
|
416
|
+
// The browser records intent + files and QUEUES the read; the terminal AI reads and commits a
|
|
417
|
+
// `revision-read` version. The pending/failed card is Drawings-scoped (proximity to the action; the
|
|
418
|
+
// footer Requests popover surfaces it globally). A self-contained poll resolves it — the shell has
|
|
419
|
+
// no SSE hook — and refreshes History on completion. Cards are DOM-built (textContent, never
|
|
420
|
+
// innerHTML) so an AI-supplied error string can never reach the DOM as markup.
|
|
421
|
+
let lastRevState = 'none';
|
|
422
|
+
let revBaseVersion = 0; // the ledger head captured at attach — success = the head advances past it
|
|
423
|
+
let revCardReq = null;
|
|
424
|
+
let revisionPoll = null;
|
|
425
|
+
function stopRevisionPoll() { if (revisionPoll) { clearInterval(revisionPoll); revisionPoll = null; } }
|
|
426
|
+
function startRevisionPoll() {
|
|
427
|
+
stopRevisionPoll();
|
|
428
|
+
revisionPoll = setInterval(() => { if (current) renderRevisionCard(); else stopRevisionPoll(); }, 3000);
|
|
429
|
+
}
|
|
430
|
+
const revEl = (tag, cls, text) => {
|
|
431
|
+
const e = document.createElement(tag);
|
|
432
|
+
if (cls) e.className = cls;
|
|
433
|
+
if (text != null) e.textContent = text;
|
|
434
|
+
return e;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
function readFileAsSnapshot(file) {
|
|
438
|
+
return new Promise((resolve, reject) => {
|
|
439
|
+
const r = new FileReader();
|
|
440
|
+
r.onload = () => resolve({ name: file.name, dataUrl: String(r.result) });
|
|
441
|
+
r.onerror = () => reject(new Error('read failed'));
|
|
442
|
+
r.readAsDataURL(file);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function attachRevision(files) {
|
|
447
|
+
const proj = current; // capture — the user may switch projects during the async file reads / POST
|
|
448
|
+
if (!proj || !files.length) return;
|
|
449
|
+
let snapshots;
|
|
450
|
+
try { snapshots = await Promise.all(files.map(readFileAsSnapshot)); }
|
|
451
|
+
catch { showToast('Could not read the selected files', 'warn'); return; }
|
|
452
|
+
if (current !== proj) return; // switched projects mid-read — abandon rather than queue against the wrong one
|
|
453
|
+
let res;
|
|
454
|
+
try {
|
|
455
|
+
res = await api(`/api/projects/${encodeURIComponent(proj.id)}/revision-requests`, {
|
|
456
|
+
method: 'POST', body: JSON.stringify({ appId: proj.app, snapshots }),
|
|
457
|
+
});
|
|
458
|
+
} catch (err) { showToast('Attach failed: ' + ((err && err.message) || err), 'warn'); return; }
|
|
459
|
+
const req = res && res.request;
|
|
460
|
+
// Copy the paste-ready instruction so the user can hand it to their terminal AI (best-effort).
|
|
461
|
+
if (req && bridge.markedInstruction && bridge.copyToClipboard) {
|
|
462
|
+
try { await bridge.copyToClipboard(bridge.markedInstruction(req)); } catch { /* clipboard blocked */ }
|
|
463
|
+
}
|
|
464
|
+
if (bridge.loadRequests) bridge.loadRequests().catch(() => {}); // refresh the footer Requests popover
|
|
465
|
+
if (current !== proj) return; // switched away while posting — don't drive the wrong project's card/poll
|
|
466
|
+
showToast(`Revised set queued — ${files.length} file${files.length > 1 ? 's' : ''} for your terminal AI`, 'ok');
|
|
467
|
+
revBaseVersion = (req && typeof req.baseVersion === 'number') ? req.baseVersion : 0;
|
|
468
|
+
lastRevState = 'pending';
|
|
469
|
+
startRevisionPoll();
|
|
470
|
+
renderRevisionCard();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
document.getElementById('ws-attach-revision').addEventListener('click', () => $revisionFile.click());
|
|
474
|
+
$revisionFile.addEventListener('change', () => {
|
|
475
|
+
const files = [...($revisionFile.files || [])];
|
|
476
|
+
$revisionFile.value = ''; // clear so re-picking the same file re-fires change
|
|
477
|
+
attachRevision(files);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
function buildPendingCard(req) {
|
|
481
|
+
const n = (req.sourceRefs && req.sourceRefs.length) || (req.snapshots && req.snapshots.length) || 1;
|
|
482
|
+
const spin = revEl('span', 'wrc-spin'); spin.setAttribute('aria-hidden', 'true');
|
|
483
|
+
const txt = revEl('span', 'wrc-text');
|
|
484
|
+
txt.appendChild(revEl('b', null, 'Reading the revised set…'));
|
|
485
|
+
txt.appendChild(document.createTextNode(` ${n} file${n > 1 ? 's' : ''} queued for your terminal AI — paste the copied instruction there, or say “apply my floless request”.`));
|
|
486
|
+
const copy = revEl('button', 'btn-mini', '⧉ Copy');
|
|
487
|
+
copy.type = 'button'; copy.dataset.revcopy = ''; copy.setAttribute('data-tip', 'Copy the instruction again');
|
|
488
|
+
return [spin, txt, copy];
|
|
489
|
+
}
|
|
490
|
+
function buildFailedCard(req) {
|
|
491
|
+
const ico = revEl('span', 'wrc-err-ico', '⚠'); ico.setAttribute('aria-hidden', 'true');
|
|
492
|
+
const txt = revEl('span', 'wrc-text');
|
|
493
|
+
txt.appendChild(revEl('b', null, 'The read failed.'));
|
|
494
|
+
txt.appendChild(document.createTextNode(' '));
|
|
495
|
+
txt.appendChild(revEl('span', 'wrc-err-detail', req.error || 'The read could not be completed.'));
|
|
496
|
+
txt.appendChild(document.createTextNode(' Attach the set again to retry.'));
|
|
497
|
+
const actions = revEl('span', 'wrc-actions');
|
|
498
|
+
const dismiss = revEl('button', 'btn-mini', 'Dismiss'); dismiss.type = 'button'; dismiss.dataset.revdismiss = '';
|
|
499
|
+
actions.appendChild(dismiss);
|
|
500
|
+
return [ico, txt, actions];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function renderRevisionCard() {
|
|
504
|
+
if (!current) { $revisionCard.hidden = true; return; }
|
|
505
|
+
let reqs = [];
|
|
506
|
+
try { const b = await api('/api/requests'); reqs = Array.isArray(b) ? b : (b.requests || []); } catch { return; }
|
|
507
|
+
const mine = reqs.filter((r) => r && r.type === 'revision-read' && r.project === current.id);
|
|
508
|
+
const failed = mine.find((r) => r.status === 'failed');
|
|
509
|
+
const pending = mine.find((r) => r.status === 'pending');
|
|
510
|
+
const state = failed ? 'failed' : pending ? 'pending' : 'none';
|
|
511
|
+
// Was pending, now nothing in flight. Confirm a NEW version actually committed (the head advanced
|
|
512
|
+
// past the attach baseVersion) before celebrating — a bare request DELETE (Dismiss / footer-clear)
|
|
513
|
+
// must NOT show a false "complete" toast or re-lock exports.
|
|
514
|
+
if (lastRevState === 'pending' && state === 'none') {
|
|
515
|
+
let vr;
|
|
516
|
+
try { vr = await api(`/api/projects/${encodeURIComponent(current.id)}/versions`); }
|
|
517
|
+
catch { return; } // transient — leave lastRevState 'pending'; the poll retries next tick
|
|
518
|
+
const tip = (vr.versions || [])[0];
|
|
519
|
+
if (tip && tip.kind === 'revision-read' && tip.n > revBaseVersion) {
|
|
520
|
+
current.approvedAt = vr.approvedAt || undefined; // the AI read cleared the sign-off (exports re-lock)
|
|
521
|
+
if (!$wsHistory.hidden) renderHistory();
|
|
522
|
+
showToast('Revision read complete — a new version is on History', 'ok');
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
lastRevState = state;
|
|
526
|
+
if (state === 'none') { stopRevisionPoll(); $revisionCard.hidden = true; $revisionCard.replaceChildren(); return; }
|
|
527
|
+
if (state === 'failed') stopRevisionPoll();
|
|
528
|
+
else if (!revisionPoll) startRevisionPoll(); // a pending read seen on load → keep it fresh
|
|
529
|
+
if (currentStep !== 'drawings') { $revisionCard.hidden = true; return; } // card is Drawings-scoped
|
|
530
|
+
revCardReq = failed || pending;
|
|
531
|
+
$revisionCard.className = 'ws-revision-card ' + (state === 'failed' ? 'state-failed' : 'state-pending');
|
|
532
|
+
$revisionCard.replaceChildren(...(state === 'failed' ? buildFailedCard(failed) : buildPendingCard(pending)));
|
|
533
|
+
$revisionCard.hidden = false;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
$revisionCard.addEventListener('click', async (e) => {
|
|
537
|
+
const req = revCardReq;
|
|
538
|
+
if (!req) return;
|
|
539
|
+
if (e.target.closest('[data-revcopy]') && bridge.markedInstruction && bridge.copyToClipboard) {
|
|
540
|
+
const ok = await bridge.copyToClipboard(bridge.markedInstruction(req));
|
|
541
|
+
showToast(ok ? 'Instruction copied' : 'Copy blocked — use the Requests panel', ok ? 'ok' : 'warn');
|
|
542
|
+
} else if (e.target.closest('[data-revdismiss]')) {
|
|
543
|
+
try { await api(`/api/requests/${encodeURIComponent(req.id)}`, { method: 'DELETE' }); } catch { /* already gone */ }
|
|
544
|
+
lastRevState = 'none';
|
|
545
|
+
if (bridge.loadRequests) bridge.loadRequests().catch(() => {});
|
|
546
|
+
renderRevisionCard();
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
370
550
|
// ── History step ────────────────────────────────────────────────────────────────
|
|
371
551
|
// The project's version ledger. A version = a commit (Approve = Model gate, Rollback = unsigned).
|
|
372
552
|
// The draft contract is the working tree; an unapproved draft shows the working-copy banner.
|
|
@@ -380,10 +560,13 @@
|
|
|
380
560
|
author === 'You'
|
|
381
561
|
? '<span class="avatar you">YOU</span>'
|
|
382
562
|
: `<span class="avatar ai">${escapeHtml((author || '?').replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase() || 'AI')}</span>`;
|
|
383
|
-
const gateBadge = (gate) =>
|
|
384
|
-
gate === 'model'
|
|
385
|
-
|
|
386
|
-
|
|
563
|
+
const gateBadge = (gate, kind) => {
|
|
564
|
+
if (gate === 'model')
|
|
565
|
+
return '<span class="gate-badge model" data-tip="Model gate — the geometry was signed off">Model</span>';
|
|
566
|
+
if (kind === 'revision-read')
|
|
567
|
+
return '<span class="gate-badge ai-read" data-tip="AI read — the terminal AI composed this version from a revised drawing set; Approve to sign it off">AI read</span>';
|
|
568
|
+
return '<span class="gate-badge" data-tip="Unsigned — a working-tree restore, not a sign-off">unsigned</span>';
|
|
569
|
+
};
|
|
387
570
|
|
|
388
571
|
let versions = []; // last-rendered ledger (newest first)
|
|
389
572
|
|
|
@@ -396,7 +579,7 @@
|
|
|
396
579
|
`<td><span class="ver${v.current ? ' cur' : ''}">v${escapeHtml(String(v.n))}</span></td>` +
|
|
397
580
|
`<td class="htime">${escapeHtml(histWhen(v.ts))}</td>` +
|
|
398
581
|
`<td><span class="hby">${avatarFor(v.author)}${escapeHtml(v.author)}</span></td>` +
|
|
399
|
-
`<td>${gateBadge(v.gate)}</td>` +
|
|
582
|
+
`<td>${gateBadge(v.gate, v.kind)}</td>` +
|
|
400
583
|
`<td class="change">${escapeHtml(v.message)}</td>` +
|
|
401
584
|
`<td>${action}</td></tr>`;
|
|
402
585
|
}
|
|
@@ -430,9 +613,13 @@
|
|
|
430
613
|
}
|
|
431
614
|
// Working copy = the draft; when it isn't signed off (approvedAt cleared by an edit or a
|
|
432
615
|
// rollback) surface it honestly ABOVE the table (not as a fake row) and name the Exports lock.
|
|
616
|
+
const headKind = rows.length && rows[0].current ? rows[0].kind : undefined;
|
|
433
617
|
const working = !approvedAt
|
|
434
|
-
?
|
|
435
|
-
|
|
618
|
+
? (headKind === 'revision-read'
|
|
619
|
+
? '<div class="hist-working">● <b>The AI read a revised drawing set — review it before signing.</b> ' +
|
|
620
|
+
'Open <b>Model</b> to check the change, then <button type="button" id="hist-approve">Approve</button> to sign off. Exports stay locked until you do.</div>'
|
|
621
|
+
: '<div class="hist-working">● <b>Working copy isn’t signed off.</b> ' +
|
|
622
|
+
'<button type="button" id="hist-approve">Approve</button> to sign off this version. Exports stay locked until you do.</div>')
|
|
436
623
|
: '';
|
|
437
624
|
$wsHistory.innerHTML =
|
|
438
625
|
'<div class="hist-note">Every <b>Approve</b> and <b>Rollback</b> records a version. Rollback is non-destructive — it restores a version as a <i>new</i> one.</div>' +
|
|
@@ -553,6 +740,6 @@
|
|
|
553
740
|
loadProjects();
|
|
554
741
|
});
|
|
555
742
|
|
|
556
|
-
window.flolessWorkspaces = { setMode, refresh: loadProjects };
|
|
743
|
+
window.flolessWorkspaces = { setMode, refresh: loadProjects, findActive: wsFindActive, find: wsFind, clearFind: wsClearFind };
|
|
557
744
|
applyMode(); // restore persisted mode immediately (matches panels.js applyView timing)
|
|
558
745
|
})();
|