@floless/app 0.75.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 +380 -211
- 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-editor.html +76 -19
- package/dist/web/workspaces.js +196 -9
- package/package.json +1 -1
package/dist/web/app.css
CHANGED
|
@@ -3344,6 +3344,8 @@ body {
|
|
|
3344
3344
|
.app.mode-workspaces .notes-strip, .app.mode-workspaces .find-overlay,
|
|
3345
3345
|
/* the ws surfaces carry their own headings — hide the canvas's "Canvas · transparency layer" label */
|
|
3346
3346
|
.app.mode-workspaces .canvas > .panel-label { display:none !important; }
|
|
3347
|
+
/* …but Find in model reuses that overlay — un-hide it while open (Ctrl+F searches editor members). */
|
|
3348
|
+
.app.mode-workspaces .find-overlay.show { display: flex !important; }
|
|
3347
3349
|
/* the editor owns center+right in a project — collapse the inspect column entirely */
|
|
3348
3350
|
.app.mode-workspaces { --right-width:0px !important; }
|
|
3349
3351
|
.app.mode-workspaces .inspect { display:none; }
|
|
@@ -3473,9 +3475,26 @@ table.hist tr.current td { background:var(--accent-soft); }
|
|
|
3473
3475
|
.hist .gate-badge { display:inline-block; padding:2px 7px; border-radius:4px; font-size:9.5px; font-weight:600;
|
|
3474
3476
|
text-transform:uppercase; letter-spacing:.05em; background:transparent; border:1px solid var(--border-strong); color:var(--text-dim); }
|
|
3475
3477
|
.hist .gate-badge.model { background:var(--accent-soft); border-color:var(--accent-dim); color:var(--accent-bright); }
|
|
3476
|
-
.hist .btn-mini { background:none; border:1px solid var(--border-strong); color:var(--text-muted); border-radius:6px;
|
|
3478
|
+
.hist .btn-mini, .ws-drawings-bar .btn-mini, .ws-revision-card .btn-mini { background:none; border:1px solid var(--border-strong); color:var(--text-muted); border-radius:6px;
|
|
3477
3479
|
padding:4px 10px; font-size:11px; cursor:pointer; white-space:nowrap; }
|
|
3478
|
-
.hist .btn-mini:hover { color:var(--text); border-color:var(--accent); }
|
|
3480
|
+
.hist .btn-mini:hover, .ws-drawings-bar .btn-mini:hover, .ws-revision-card .btn-mini:hover { color:var(--text); border-color:var(--accent); }
|
|
3481
|
+
|
|
3482
|
+
/* ── Workspaces: revision re-read (Slice 4b) — attach band + pending/failed card ── */
|
|
3483
|
+
.ws-drawings-bar { flex:none; display:flex; align-items:center; gap:10px; padding:8px 14px; border-bottom:1px solid var(--border); background:var(--surface); }
|
|
3484
|
+
.ws-drawings-bar[hidden] { display:none !important; }
|
|
3485
|
+
.ws-drawings-bar-label { font-size:10px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.1em; }
|
|
3486
|
+
.ws-drawings-bar-sep { flex:1; }
|
|
3487
|
+
.ws-revision-card { flex:none; display:flex; align-items:center; gap:10px; padding:9px 14px; border-bottom:1px solid var(--border); background:var(--surface-2); font-size:12px; color:var(--text-muted); }
|
|
3488
|
+
.ws-revision-card[hidden] { display:none !important; }
|
|
3489
|
+
.ws-revision-card b { color:var(--text); font-weight:600; }
|
|
3490
|
+
.ws-revision-card .wrc-text { flex:1; min-width:0; }
|
|
3491
|
+
.ws-revision-card .wrc-spin { flex:none; width:14px; height:14px; border:2px solid var(--border-strong); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; }
|
|
3492
|
+
.ws-revision-card.state-failed { border-left:3px solid var(--err); }
|
|
3493
|
+
.ws-revision-card.state-failed .wrc-err-ico { color:var(--err); flex:none; }
|
|
3494
|
+
.ws-revision-card.state-failed .wrc-err-detail { color:var(--text-dim); }
|
|
3495
|
+
.ws-revision-card .wrc-actions { display:flex; gap:6px; flex:none; }
|
|
3496
|
+
@media (prefers-reduced-motion: reduce) { .ws-revision-card .wrc-spin { animation:none; } }
|
|
3497
|
+
.hist .gate-badge.ai-read { background:var(--accent-soft); border-color:var(--accent-dim); color:var(--accent-bright); }
|
|
3479
3498
|
|
|
3480
3499
|
/* Rollback confirm — a styled anchored popover (NEVER a native dialog); names the Exports re-lock. */
|
|
3481
3500
|
.hist-confirm { position:absolute; z-index:20; width:264px; padding:12px 13px; background:var(--surface-3);
|
package/dist/web/app.js
CHANGED
|
@@ -1094,6 +1094,10 @@ const $findInput = document.getElementById('find-input');
|
|
|
1094
1094
|
const $findCount = document.getElementById('find-count');
|
|
1095
1095
|
|
|
1096
1096
|
function openFind() {
|
|
1097
|
+
const ws = window.flolessWorkspaces && window.flolessWorkspaces.findActive();
|
|
1098
|
+
// In Workspaces mode there's nothing to find off the Model step — don't pop a dead overlay.
|
|
1099
|
+
if (document.getElementById('app').classList.contains('mode-workspaces') && !ws) return;
|
|
1100
|
+
$findInput.placeholder = ws ? 'Find in model…' : 'Find agent…';
|
|
1097
1101
|
$findOverlay.classList.add('show');
|
|
1098
1102
|
$findInput.value = '';
|
|
1099
1103
|
$findCount.textContent = '';
|
|
@@ -1101,13 +1105,21 @@ function openFind() {
|
|
|
1101
1105
|
}
|
|
1102
1106
|
function closeFind() {
|
|
1103
1107
|
$findOverlay.classList.remove('show');
|
|
1108
|
+
if (window.flolessWorkspaces && window.flolessWorkspaces.clearFind) window.flolessWorkspaces.clearFind();
|
|
1104
1109
|
document.querySelectorAll('.agent-card').forEach(c => {
|
|
1105
1110
|
c.classList.remove('find-dim', 'find-match');
|
|
1106
1111
|
});
|
|
1107
1112
|
}
|
|
1108
1113
|
|
|
1109
1114
|
$findInput.addEventListener('input', () => {
|
|
1110
|
-
const
|
|
1115
|
+
const raw = $findInput.value;
|
|
1116
|
+
const q = raw.toLowerCase().trim();
|
|
1117
|
+
// Workspaces + Model step: search members in the embedded editor, not the canvas.
|
|
1118
|
+
if (window.flolessWorkspaces && window.flolessWorkspaces.findActive()) {
|
|
1119
|
+
const r = window.flolessWorkspaces.find(raw) || { count: 0 };
|
|
1120
|
+
$findCount.textContent = q ? `${r.count} match${r.count === 1 ? '' : 'es'}` : '';
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1111
1123
|
let matches = 0;
|
|
1112
1124
|
document.querySelectorAll('.agent-card').forEach(card => {
|
|
1113
1125
|
if (!q) {
|
package/dist/web/aware.js
CHANGED
|
@@ -4655,6 +4655,13 @@
|
|
|
4655
4655
|
if (req.type === 'new-workflow') {
|
|
4656
4656
|
return `Help me build a brand-new floless workflow from scratch, step by step, with your floless-app-new-workflow skill. Ask me what I want it to do, then before writing any node confirm the agents it needs are installed (offer to install a missing one from the catalogue, or to report a not-yet-existing agent as an idea). Author the .flo node by node, verifying each step is correct (plain-English descriptions, validate, compile, a real run), and finish by installing → Compile → ▶ Run so I can see it work and approve.`;
|
|
4657
4657
|
}
|
|
4658
|
+
if (req.type === 'revision-read') {
|
|
4659
|
+
const snaps = req.snapshots && req.snapshots.length
|
|
4660
|
+
? `\nRevised drawing${req.snapshots.length > 1 ? 's' : ''} (read these): ${req.snapshots.join(', ')}`
|
|
4661
|
+
: '';
|
|
4662
|
+
const note = req.instruction ? `\nMy note: ${req.instruction}` : '';
|
|
4663
|
+
return `In floless project "${req.project}" (app "${req.appId}"), a REVISED drawing set was attached — re-read it into the SAME project as a new version (do NOT create a new project). Read the drawings per the floless-app-steel-takeoff skill, compose the updated steel.takeoff/v1 contract, then POST /api/projects/${req.project}/revision-read with { contract, message: "<plain-English what changed>", requestId: "${req.id}" }. The server derives the base version + source provenance from this queued request, records a "revision-read" version, and clears the request — do NOT DELETE the request yourself. If you can't read the set, POST /api/projects/${req.project}/revision-requests/${req.id}/fail with { error }.${snaps}${note}`;
|
|
4664
|
+
}
|
|
4658
4665
|
return '';
|
|
4659
4666
|
}
|
|
4660
4667
|
|
|
@@ -4665,6 +4672,7 @@
|
|
|
4665
4672
|
tweak: 'floless-app-workflows',
|
|
4666
4673
|
'ui-customize': 'floless-app-ui',
|
|
4667
4674
|
rebake: 'floless-app-rebake',
|
|
4675
|
+
'revision-read': 'floless-app-steel-takeoff',
|
|
4668
4676
|
// The guided-tour ask is picked up by onboarding; the instruction body names the
|
|
4669
4677
|
// per-app floless-app-<appId> skill for depth when one exists.
|
|
4670
4678
|
guide: 'floless-app-onboarding',
|
|
@@ -4682,10 +4690,15 @@
|
|
|
4682
4690
|
if (!body) return '';
|
|
4683
4691
|
const skill = REQUEST_SKILL[req.type] || 'floless-app-workflows';
|
|
4684
4692
|
const base = (typeof location !== 'undefined' && location.origin) ? location.origin : 'http://127.0.0.1:4317';
|
|
4693
|
+
// A revision-read is cleared by its own /revision-read POST — a separate DELETE would 404. Every
|
|
4694
|
+
// other type is applied-then-DELETEd by the terminal AI.
|
|
4695
|
+
const clears = req.type === 'revision-read'
|
|
4696
|
+
? `apply it via the route in the line below (that POST clears this request — do NOT DELETE it separately).`
|
|
4697
|
+
: `apply that request, then DELETE ${base}/api/requests/${req.id}.`;
|
|
4685
4698
|
const marker =
|
|
4686
4699
|
`[floless-request type=${req.type} id=${req.id}] — queued from the FloLess Dashboard. ` +
|
|
4687
4700
|
`Apply it with your ${skill} skill: fetch the authoritative spec from GET ${base}/api/requests, ` +
|
|
4688
|
-
|
|
4701
|
+
`${clears} Don't run the line below verbatim.`;
|
|
4689
4702
|
return `${marker}\n${body}`;
|
|
4690
4703
|
}
|
|
4691
4704
|
|
|
@@ -4764,8 +4777,8 @@
|
|
|
4764
4777
|
return;
|
|
4765
4778
|
}
|
|
4766
4779
|
$list.innerHTML = pendingRequests.map((r) => {
|
|
4767
|
-
const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : r.type === 'guide' ? 'guide' : r.type === 'new-workflow' ? 'new workflow' : 'tweak';
|
|
4768
|
-
const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' ? 'req-type req-type-tweak' : 'req-type';
|
|
4780
|
+
const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : r.type === 'revision-read' ? 'revision' : r.type === 'guide' ? 'guide' : r.type === 'new-workflow' ? 'new workflow' : 'tweak';
|
|
4781
|
+
const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' || r.type === 'revision-read' ? 'req-type req-type-tweak' : 'req-type';
|
|
4769
4782
|
const target = r.type === 'tweak' && r.nodeId
|
|
4770
4783
|
? ` · node <code>${escapeHtml(r.nodeId)}</code>`
|
|
4771
4784
|
: r.type === 'rebake' && r.inputName
|
package/dist/web/index.html
CHANGED
|
@@ -170,6 +170,15 @@
|
|
|
170
170
|
<button type="button" data-step="exports" role="tab" aria-selected="false">Exports</button>
|
|
171
171
|
<button type="button" data-step="history" role="tab" aria-selected="false">History</button>
|
|
172
172
|
</div>
|
|
173
|
+
<!-- Drawings step: a shell toolbar band + revision-read status card ABOVE the filter iframe
|
|
174
|
+
(chrome around it, never inside — the filter owns its own layout). Shown only on Drawings. -->
|
|
175
|
+
<div class="ws-drawings-bar" id="ws-drawings-bar" hidden>
|
|
176
|
+
<span class="ws-drawings-bar-label">Drawing set</span>
|
|
177
|
+
<span class="ws-drawings-bar-sep"></span>
|
|
178
|
+
<button type="button" id="ws-attach-revision" class="btn-mini" data-tip="Attach a revised drawing set — your terminal AI reads it into a new version">⤒ Attach revised drawings…</button>
|
|
179
|
+
<input type="file" id="ws-revision-file" accept=".pdf,image/*" multiple hidden>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="ws-revision-card" id="ws-revision-card" hidden></div>
|
|
173
182
|
<!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
|
|
174
183
|
Same-origin like #contract-editor-frame (they call /api/contract directly). -->
|
|
175
184
|
<iframe id="ws-frame-model" class="ws-frame" title="Project model editor" hidden></iframe>
|
|
@@ -851,6 +851,7 @@ let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimen
|
|
|
851
851
|
let dimSplitMode=false; // "add split point" mode on a selected dim — each click inserts a point and splits the dim segment under it into two
|
|
852
852
|
let sel3dDimIds=new Set(),dim3dAnchor=null; // selected 3D dimension ids (multi-select like parts; 3D view highlights them, Delete removes them)
|
|
853
853
|
let selIds=new Set();
|
|
854
|
+
let findHits=new Set(); // Find-in-model highlight (Workspaces Ctrl+F) — kept SEPARATE from selIds so a search never clobbers/drops the user's real selection
|
|
854
855
|
let undo=[], redo=[];
|
|
855
856
|
const byId=id=>P.members.find(m=>m.id===id);
|
|
856
857
|
const selArr=()=>P.members.filter(m=>selIds.has(m.id));
|
|
@@ -1420,8 +1421,8 @@ function render(){
|
|
|
1420
1421
|
let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
|
|
1421
1422
|
s+=gridSvg(); // structural grid under the linework (members/dims stay on top)
|
|
1422
1423
|
for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
|
|
1423
|
-
for(const m of P.members){const c=colorFor(m.profile);const
|
|
1424
|
-
s+=`<line class="member${m.rfi?' rfi':''}${
|
|
1424
|
+
for(const m of P.members){const c=colorFor(m.profile);const sel=selIds.has(m.id);const fh=findHits.has(m.id);const on=sel||fh;const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
|
|
1425
|
+
s+=`<line class="member${m.rfi?' rfi':''}${sel?' sel':''}${fh?' find-hit':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
|
|
1425
1426
|
{const hsel=selArr();if(hsel.length>=1){const HR=epR();for(const sm of hsel)for(let i=0;i<2;i++) s+=`<circle class="handle ${i===0?'ep-start':'ep-end'}" data-mid="${sm.id}" data-h="${i}" cx="${sm.wp[i][0]}" cy="${sm.wp[i][1]}" r="${HR}"/>`;}} // end 1 (start) yellow, end 2 (end) magenta · shown for every selected member · radius grows with zoom (epR) so it stays visible against the thick line
|
|
1426
1427
|
if((mode==='add'||(picking&&pickKind==='profile'))&&P.labels) for(const lb of P.labels){const w=Math.max(40,lb.text.length*11);
|
|
1427
1428
|
s+=`<rect class=lblhot data-prof="${esc(lb.text)}" x="${lb.disp[0]-w/2}" y="${lb.disp[1]-10}" width="${w}" height="20" rx="3"><title>${esc(lb.text)}</title></rect>`;}
|
|
@@ -3036,16 +3037,36 @@ const view3dApi={
|
|
|
3036
3037
|
onClipModeChange:(m)=>{const b=document.getElementById('m3dClip');if(b){b.classList.toggle('on',!!m);b.textContent=m?'Clip ✕':'Clip ▾';}}, // armed → button fills brand-blue + becomes a cancel target (✕ = cancel)
|
|
3037
3038
|
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';}}, // armed → cancel target
|
|
3038
3039
|
onInsertPlace:(pick,pending)=>{
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
const
|
|
3045
|
-
if(
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3040
|
+
if(pending&&pending.kind==='connection'&&pending.connection){
|
|
3041
|
+
const conn=pending.connection;const rc=conn.recipe;
|
|
3042
|
+
// Slice C: a RECOGNIZED base plate dropped onto a COLUMN → bake an EDITABLE base-plate recipe joint;
|
|
3043
|
+
// expandBasePlate re-derives it on that column from the fitted params (frame-independent scalars), so
|
|
3044
|
+
// it becomes a first-class parametric connection, not opaque mesh. Needs a column at the pick.
|
|
3045
|
+
const col=(rc&&rc.kind==='base-plate'&&pick.anchorId)?P.members.find(m=>m&&m.id===pick.anchorId&&m.role==='column'):null;
|
|
3046
|
+
if(col){
|
|
3047
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3048
|
+
const joint={id,kind:'base-plate',main:col.id,at:'base',params:Object.assign({},rc.params),source:'user'};
|
|
3049
|
+
pendingConnSel=id; // its parts only exist after the 3D rebuild → select the whole connection there
|
|
3050
|
+
// Applying a base plate to a column REPLACES any base plate already on it (a column has one base
|
|
3051
|
+
// plate) — else the two overlap and "edit on member" would target the older joint, not this import.
|
|
3052
|
+
const had=(C.joints||[]).some(x=>x&&x.kind==='base-plate'&&x.main===col.id);
|
|
3053
|
+
edit(()=>{C.joints=(Array.isArray(C.joints)?C.joints:[]).filter(x=>!(x&&x.kind==='base-plate'&&x.main===col.id));C.joints.push(joint);selIds=new Set();});
|
|
3054
|
+
toast((had?'Base plate on '+col.id+' replaced with imported “'+(conn.name||'connection')+'”':'Base plate “'+(conn.name||'imported')+'” applied to '+col.id)+' — edit its parameters on the member');
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
// Slice B: opaque custom mesh — bake at the picked point (joint.place); expandCustom re-expands it into
|
|
3058
|
+
// the scene as one selectable unit. Unrecognized imports, and a recognized base plate NOT dropped on a
|
|
3059
|
+
// column, land here (still faithful geometry) with a hint toward the editable path.
|
|
3060
|
+
if(Array.isArray(conn.geometry)&&conn.geometry.length){
|
|
3061
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3062
|
+
const joint={id,kind:'custom',name:conn.name||'Imported connection',place:pick.point,geometry:conn.geometry,source:'user'};
|
|
3063
|
+
if(pick.anchorId)joint.main=pick.anchorId; // snapped to a member face → record it for the inspector's "on member" line
|
|
3064
|
+
edit(()=>{if(!Array.isArray(C.joints))C.joints=[];C.joints.push(joint);selIds=new Set(conn.geometry.map((g,i)=>id+':'+(g.id||'m'+i)));});
|
|
3065
|
+
toast(rc?('Imported “'+(conn.name||'connection')+'” as geometry — drop it on a column to apply it as an editable base plate')
|
|
3066
|
+
:('Connection “'+(conn.name||'imported')+'” placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — select it to move or replace'));
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
toast('That connection has no geometry to place');return;
|
|
3049
3070
|
}
|
|
3050
3071
|
if(!pending||!pending.name){toast('Pick a detail to insert first');return;} // Slice 4: place the queued detail at the pick, select it, record the create intent
|
|
3051
3072
|
const id='det'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);const sheet=(P&&P.sheet)||'';
|
|
@@ -3058,7 +3079,11 @@ const view3dApi={
|
|
|
3058
3079
|
};
|
|
3059
3080
|
// Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
|
|
3060
3081
|
// changes go through render()'s setSelection — only geometry mutations need a rebuild.
|
|
3061
|
-
|
|
3082
|
+
let pendingConnSel=null; // a just-baked connection whose parts exist only AFTER the next rebuild → select the whole thing there (a recognized base-plate import, whose part ids aren't known ahead of expansion)
|
|
3083
|
+
function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{
|
|
3084
|
+
if(pendingConnSel&&window.Steel3DView.selectWholeConn){const c=pendingConnSel;pendingConnSel=null;window.Steel3DView.selectWholeConn(c);} // whole-connection select (envelope + "Parametric — editable" inspector), same as a 3D click
|
|
3085
|
+
else window.Steel3DView.setSelection(selIds);
|
|
3086
|
+
build3DLegend();panel();}).catch(()=>{pendingConnSel=null;});}} // rebuild also refreshes the legend (an edit may add/remove a profile) + re-renders the inspector so a selection of just-created parts (e.g. a placed custom connection) resolves against the freshly-fetched partsById, not the pre-rebuild stale copy
|
|
3062
3087
|
// Insert-a-detail helpers (Slice 4). armInsert queues a detail image + arms the 3D placement pick;
|
|
3063
3088
|
// detailRequest records a create/modify request on the SAME tweak-contract channel, adding intent+target
|
|
3064
3089
|
// so the terminal AI knows whether to build a new detail or update a placed one, and where.
|
|
@@ -3066,12 +3091,17 @@ function armInsert(name){if(!name)return;const raw=(C.custom_details||{})[name];
|
|
|
3066
3091
|
if(!view3d){toast('Switch to the 3D view to place a detail');return;}
|
|
3067
3092
|
window.Steel3DView.setInsertMode(true,{name});
|
|
3068
3093
|
toast('Click a beam or the model to place “'+name+'” — Esc to cancel');}
|
|
3069
|
-
// Slice B: arm the crosshair to place an IMPORTED connection (its LOCAL mesh geometry
|
|
3070
|
-
// pending object; onInsertPlace bakes
|
|
3094
|
+
// Slice B/C: arm the crosshair to place an IMPORTED connection (its LOCAL mesh geometry + optional
|
|
3095
|
+
// recognized recipe ride on the pending object; onInsertPlace bakes a base-plate recipe on a column, or
|
|
3096
|
+
// the custom mesh where the user clicks). 3D view only.
|
|
3071
3097
|
function armConnectionInsert(connection){if(!connection||!Array.isArray(connection.geometry)||!connection.geometry.length){toast('That connection has no geometry to place');return;}
|
|
3072
3098
|
if(!view3d){toast('Switch to the 3D view to place a connection');return;}
|
|
3073
3099
|
window.Steel3DView.setInsertMode(true,{kind:'connection',connection});
|
|
3074
|
-
|
|
3100
|
+
const nm=connection.name||'connection';
|
|
3101
|
+
// Recognized base plate → tell the user to target a column (the editable path); else the generic place hint.
|
|
3102
|
+
const recognized=connection.recipe&&connection.recipe.kind==='base-plate';
|
|
3103
|
+
toast(recognized?('Click a column to place “'+nm+'” as an editable base plate — Esc to cancel')
|
|
3104
|
+
:('Click in the model to place “'+nm+'” — Esc to cancel'));}
|
|
3075
3105
|
async function detailRequest(intent,place,note){
|
|
3076
3106
|
// flushContract PUTs C to the server so the terminal AI reads the latest contract — but it clears the
|
|
3077
3107
|
// debounced autosave (saveT) WITHOUT writing localStorage, which would drop the just-placed detail from
|
|
@@ -3767,13 +3797,40 @@ function rfiReason(m){if(!m.profile)return 'No profile assigned';
|
|
|
3767
3797
|
function zoomToMember(m){const a=m.wp[0],b=m.wp[1],w=Math.abs(a[0]-b[0])||40,h=Math.abs(a[1]-b[1])||40;
|
|
3768
3798
|
applyZoom(Math.max(.3,Math.min(2.5,Math.min(stage.clientWidth/(w*2.2),stage.clientHeight/(h*2.2)))));
|
|
3769
3799
|
const cx=(a[0]+b[0])/2,cy=(a[1]+b[1])/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
|
|
3770
|
-
//
|
|
3771
|
-
|
|
3800
|
+
// ── Find in model (Workspaces Ctrl+F) ──────────────────────────────────────────
|
|
3801
|
+
// Match a member's id/mark + profile (case-insensitive substring) across ALL plans, switch to the
|
|
3802
|
+
// first plan with a hit, HIGHLIGHT those hits (via findHits — a set kept SEPARATE from selIds so a
|
|
3803
|
+
// same-plan search never touches the user's real selection; a cross-plan hit switches sheets via
|
|
3804
|
+
// setPlan, which resets selection like any plan change), and frame them. Returns {count,shown}
|
|
3805
|
+
// for the shell's find overlay. Called cross-iframe by workspaces.js via the same-origin
|
|
3806
|
+
// contentWindow, like flushContract(). clearFind() drops the find highlight only (leaves selection).
|
|
3807
|
+
function _findMatch(m, q){ return [m.id, m.mark, m.profile].filter(Boolean).join(' ').toLowerCase().includes(q); }
|
|
3808
|
+
function findMember(query){
|
|
3809
|
+
const q = String(query == null ? '' : query).toLowerCase().trim();
|
|
3810
|
+
if(!q){ clearFind(); return { count: 0, shown: 0 }; }
|
|
3811
|
+
let total = 0, firstPlan = -1, firstHits = [];
|
|
3812
|
+
(C.plans || []).forEach((pl, pi) => {
|
|
3813
|
+
const ids = (pl.members || []).filter((m) => _findMatch(m, q)).map((m) => m.id);
|
|
3814
|
+
total += ids.length;
|
|
3815
|
+
if(ids.length && firstPlan < 0){ firstPlan = pi; firstHits = ids; }
|
|
3816
|
+
});
|
|
3817
|
+
if(firstPlan < 0){ findHits = new Set(); render(); return { count: 0, shown: 0 }; }
|
|
3818
|
+
if(firstPlan !== C.active) setPlan(firstPlan);
|
|
3819
|
+
findHits = new Set(firstHits);
|
|
3820
|
+
render(); zoomToMembers(P.members.filter((m) => findHits.has(m.id)));
|
|
3821
|
+
return { count: total, shown: firstHits.length };
|
|
3822
|
+
}
|
|
3823
|
+
function clearFind(){ if(findHits.size){ findHits = new Set(); render(); } }
|
|
3824
|
+
window.findMember = findMember; window.clearFind = clearFind; // expose to the shell, like window.flushContract (top-level fns aren't reliably on window here)
|
|
3825
|
+
// Frame an arbitrary set of members (bbox → fit); falls back to fit-all when empty. Shared by
|
|
3826
|
+
// zoomToSelection (Tekla "Zoom selected") and Find-in-model — mirrors the 3D view's frameSelection().
|
|
3827
|
+
function zoomToMembers(arr){if(!arr.length){fitToWindow();return;}
|
|
3772
3828
|
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
|
|
3773
3829
|
for(const m of arr)for(const p of m.wp){if(p[0]<x0)x0=p[0];if(p[0]>x1)x1=p[0];if(p[1]<y0)y0=p[1];if(p[1]>y1)y1=p[1];}
|
|
3774
3830
|
const w=Math.max(x1-x0,40),h=Math.max(y1-y0,40);
|
|
3775
3831
|
applyZoom(Math.min(stage.clientWidth/(w*1.35),stage.clientHeight/(h*1.35)));
|
|
3776
3832
|
const cx=(x0+x1)/2,cy=(y0+y1)/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
|
|
3833
|
+
function zoomToSelection(){zoomToMembers(selArr());}
|
|
3777
3834
|
function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
|
|
3778
3835
|
g.innerHTML=list.length?('<div class=hint style="margin-bottom:10px">'+list.length+' member'+(list.length>1?'s':'')+' have no resolved AISC size, so their weight is excluded from the BOM. Assign a profile to clear it — for <b>MF/BF</b> marks use the <b>Frames</b> schedule. Edits here update the contract.</div><table class=ftab><thead><tr><th>#</th><th>Profile / mark</th><th>Role</th><th>Length</th><th>Why it’s flagged</th><th></th></tr></thead><tbody>'+
|
|
3779
3836
|
list.map((m,i)=>{ensureMeta(m);const L=_lenFt(m).toFixed(1);
|
|
@@ -4037,7 +4094,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
|
|
|
4037
4094
|
scheduleSave();setTimeout(()=>toast('Auto-removed '+red.size+' duplicate member'+(red.size>1?'s':'')),60);}}
|
|
4038
4095
|
profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
|
|
4039
4096
|
undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
|
|
4040
|
-
selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
|
|
4097
|
+
selIds=new Set();findHits=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null; // findHits resets per plan like selIds — a stale Find highlight must not bleed onto another sheet via a colliding member id
|
|
4041
4098
|
dimMode=false;dimChain=false;dimSplitMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain + split); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
|
|
4042
4099
|
if(gridMode||gridPick){gridMode=false;gridPick=false;document.body.classList.remove('gridpick');} // grid panel/pick-origin disarm per plan like the other takeover tools (a leaked pick would set the origin in the WRONG sheet's display space)
|
|
4043
4100
|
csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
|
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
|
})();
|