@floless/app 0.17.0 → 0.18.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 +97 -2
- package/dist/skills/floless-app-bridge/SKILL.md +27 -1
- package/dist/skills/floless-app-rebake/SKILL.md +5 -0
- package/dist/skills/floless-app-routines/SKILL.md +1 -1
- package/dist/skills/floless-app-ui/SKILL.md +1 -1
- package/dist/skills/floless-app-workflows/SKILL.md +8 -2
- package/dist/web/app.css +63 -5
- package/dist/web/app.js +50 -5
- package/dist/web/aware.js +141 -18
- package/dist/web/index.html +11 -1
- package/dist/web/panels.js +5 -3
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -50920,6 +50920,10 @@ function appDir(id) {
|
|
|
50920
50920
|
if (!(0, import_node_fs3.existsSync)(dir)) throw new AppNotFoundError(id);
|
|
50921
50921
|
return dir;
|
|
50922
50922
|
}
|
|
50923
|
+
function appExists(id) {
|
|
50924
|
+
if (id.includes("/") || id.includes("\\") || id.includes("..")) return false;
|
|
50925
|
+
return (0, import_node_fs3.existsSync)((0, import_node_path2.join)(APPS_DIR, id));
|
|
50926
|
+
}
|
|
50923
50927
|
function firstWithExt(dir, ext) {
|
|
50924
50928
|
const hit = (0, import_node_fs3.readdirSync)(dir).find((f) => f.toLowerCase().endsWith(ext));
|
|
50925
50929
|
return hit ? (0, import_node_path2.join)(dir, hit) : null;
|
|
@@ -52679,7 +52683,7 @@ function appVersion() {
|
|
|
52679
52683
|
return resolveVersion({
|
|
52680
52684
|
isSea: isSea2(),
|
|
52681
52685
|
sqVersionXml: readSqVersionXml(),
|
|
52682
|
-
define: true ? "0.
|
|
52686
|
+
define: true ? "0.18.0" : void 0,
|
|
52683
52687
|
pkgVersion: readPkgVersion()
|
|
52684
52688
|
});
|
|
52685
52689
|
}
|
|
@@ -52689,7 +52693,7 @@ function resolveChannel(s) {
|
|
|
52689
52693
|
return "dev";
|
|
52690
52694
|
}
|
|
52691
52695
|
function appChannel() {
|
|
52692
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
52696
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.18.0" : void 0 });
|
|
52693
52697
|
}
|
|
52694
52698
|
|
|
52695
52699
|
// oauth-presets.ts
|
|
@@ -52786,6 +52790,40 @@ function bakeFloSource(source, inputs) {
|
|
|
52786
52790
|
return doc.toString();
|
|
52787
52791
|
}
|
|
52788
52792
|
|
|
52793
|
+
// app-import.ts
|
|
52794
|
+
var APP_ID2 = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
52795
|
+
var SOURCE_EXT2 = /\.(flo|app|flow|aware)$/i;
|
|
52796
|
+
var ImportError = class extends Error {
|
|
52797
|
+
code;
|
|
52798
|
+
constructor(message, code) {
|
|
52799
|
+
super(message);
|
|
52800
|
+
this.name = "ImportError";
|
|
52801
|
+
this.code = code;
|
|
52802
|
+
}
|
|
52803
|
+
};
|
|
52804
|
+
function assertImportable(content, filename) {
|
|
52805
|
+
if (filename && !SOURCE_EXT2.test(filename)) {
|
|
52806
|
+
throw new ImportError("that file isn't a .flo workflow \u2014 drop a .flo file to import", "not-flo");
|
|
52807
|
+
}
|
|
52808
|
+
if (!content || !content.trim()) {
|
|
52809
|
+
throw new ImportError("that file is empty", "invalid");
|
|
52810
|
+
}
|
|
52811
|
+
if (!/^nodes:/m.test(content)) {
|
|
52812
|
+
throw new ImportError("that file doesn't look like a valid workflow \u2014 it may be truncated", "invalid");
|
|
52813
|
+
}
|
|
52814
|
+
}
|
|
52815
|
+
function deriveAppId(content) {
|
|
52816
|
+
const m = content.match(/^app:[ \t]*["']?([^"'\r\n]+?)["']?[ \t]*$/m);
|
|
52817
|
+
if (!m) {
|
|
52818
|
+
throw new ImportError("that file doesn't look like a workflow \u2014 no top-level 'app:' name", "invalid");
|
|
52819
|
+
}
|
|
52820
|
+
const id = (m[1] ?? "").trim();
|
|
52821
|
+
if (!APP_ID2.test(id)) {
|
|
52822
|
+
throw new ImportError(`"${id}" isn't a valid workflow id`, "invalid");
|
|
52823
|
+
}
|
|
52824
|
+
return id;
|
|
52825
|
+
}
|
|
52826
|
+
|
|
52789
52827
|
// report-badge.ts
|
|
52790
52828
|
var BADGE_MARKER = "fla-credit";
|
|
52791
52829
|
var DEFAULT_URL = "https://floless.io/made-with-floless?utm_source=report_badge";
|
|
@@ -53076,6 +53114,17 @@ function deleteTemplate(id) {
|
|
|
53076
53114
|
function getTemplate(id) {
|
|
53077
53115
|
return listTemplates().find((t) => t.id === id) ?? null;
|
|
53078
53116
|
}
|
|
53117
|
+
function updateTemplate(id, patch) {
|
|
53118
|
+
const list = listTemplates();
|
|
53119
|
+
const cur = list.find((t) => t.id === id);
|
|
53120
|
+
if (!cur) return null;
|
|
53121
|
+
const name = patch.name?.trim();
|
|
53122
|
+
const category = patch.category?.trim();
|
|
53123
|
+
cur.name = name || cur.name;
|
|
53124
|
+
cur.category = category || cur.category;
|
|
53125
|
+
writeTemplates(list);
|
|
53126
|
+
return cur;
|
|
53127
|
+
}
|
|
53079
53128
|
function addRequest(req, decoded = []) {
|
|
53080
53129
|
ensureRoot();
|
|
53081
53130
|
if (!(0, import_node_fs12.existsSync)(REQUESTS_DIR)) (0, import_node_fs12.mkdirSync)(REQUESTS_DIR, { recursive: true });
|
|
@@ -57320,6 +57369,39 @@ async function startServer() {
|
|
|
57320
57369
|
broadcast({ type: "compiled", id: id ?? null, lockPath: result.lockPath });
|
|
57321
57370
|
return { ok: true, result };
|
|
57322
57371
|
});
|
|
57372
|
+
app.post("/api/import", async (req, reply) => {
|
|
57373
|
+
const { filename, content } = req.body ?? {};
|
|
57374
|
+
if (typeof content !== "string") return reply.status(400).send({ ok: false, error: "no file content", code: "invalid" });
|
|
57375
|
+
let id;
|
|
57376
|
+
try {
|
|
57377
|
+
assertImportable(content, filename);
|
|
57378
|
+
id = deriveAppId(content);
|
|
57379
|
+
} catch (err) {
|
|
57380
|
+
if (err instanceof ImportError) return reply.status(400).send({ ok: false, error: err.message, code: err.code });
|
|
57381
|
+
throw err;
|
|
57382
|
+
}
|
|
57383
|
+
if (appExists(id)) {
|
|
57384
|
+
return reply.status(409).send({ ok: false, error: `a workflow named "${id}" is already installed \u2014 remove it first, or rename the file's app: id`, code: "exists", id });
|
|
57385
|
+
}
|
|
57386
|
+
const stageRoot = (0, import_node_fs22.mkdtempSync)((0, import_node_path21.join)((0, import_node_os15.tmpdir)(), "floless-import-"));
|
|
57387
|
+
try {
|
|
57388
|
+
const stageDir = (0, import_node_path21.join)(stageRoot, id);
|
|
57389
|
+
(0, import_node_fs22.mkdirSync)(stageDir);
|
|
57390
|
+
(0, import_node_fs22.writeFileSync)((0, import_node_path21.join)(stageDir, `${id}.flo`), content);
|
|
57391
|
+
await aware.install(stageDir);
|
|
57392
|
+
} catch (err) {
|
|
57393
|
+
try {
|
|
57394
|
+
await aware.uninstall(id);
|
|
57395
|
+
} catch {
|
|
57396
|
+
}
|
|
57397
|
+
const msg = err instanceof AwareError ? err.message : String(err?.message ?? err);
|
|
57398
|
+
return reply.status(502).send({ ok: false, error: `import failed: ${msg}` });
|
|
57399
|
+
} finally {
|
|
57400
|
+
(0, import_node_fs22.rmSync)(stageRoot, { recursive: true, force: true });
|
|
57401
|
+
}
|
|
57402
|
+
broadcast({ type: "apps-changed", id });
|
|
57403
|
+
return { ok: true, id };
|
|
57404
|
+
});
|
|
57323
57405
|
app.post("/api/bake", async (req, reply) => {
|
|
57324
57406
|
const { id } = req.body ?? {};
|
|
57325
57407
|
if (!id) return reply.status(400).send({ ok: false, error: "id required" });
|
|
@@ -57629,6 +57711,19 @@ async function startServer() {
|
|
|
57629
57711
|
return { ok: true, template: tpl };
|
|
57630
57712
|
}
|
|
57631
57713
|
);
|
|
57714
|
+
app.patch(
|
|
57715
|
+
"/api/templates/:id",
|
|
57716
|
+
async (req, reply) => {
|
|
57717
|
+
const { name, category } = req.body ?? {};
|
|
57718
|
+
if ((name == null || !name.trim()) && (category == null || !category.trim())) {
|
|
57719
|
+
return reply.status(400).send({ ok: false, error: "name or category required" });
|
|
57720
|
+
}
|
|
57721
|
+
const tpl = updateTemplate(req.params.id, { name, category });
|
|
57722
|
+
if (!tpl) return reply.status(404).send({ ok: false, error: "template not found" });
|
|
57723
|
+
broadcast({ type: "templates-changed" });
|
|
57724
|
+
return { ok: true, template: tpl };
|
|
57725
|
+
}
|
|
57726
|
+
);
|
|
57632
57727
|
app.delete("/api/templates/:id", async (req, reply) => {
|
|
57633
57728
|
if (!deleteTemplate(req.params.id)) return reply.status(404).send({ ok: false, error: "template not found" });
|
|
57634
57729
|
broadcast({ type: "templates-changed" });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: floless-app-bridge
|
|
3
|
-
description: This skill should be used when the user is driving the floless.app web UI (the local prompt-native front door at http://127.0.0.1:4317) and wants the terminal AI to act on what they did there — picking up queued Tweak requests, "use this template" requests, or the context of a node they selected/edited on the canvas. Use it when the user says things like "apply the tweak I just queued", "I clicked Tweak on the bom node — make that change", "pick up my floless request", "what's pending in floless", or after they double-click/Tweak a node in the UI. It reads the floless.app local API, applies the change to the app's .flo, recompiles, and clears the request.
|
|
3
|
+
description: This skill should be used when the user is driving the floless.app web UI (the local prompt-native front door at http://127.0.0.1:4317) and wants the terminal AI to act on what they did there — picking up queued Tweak requests, "use this template" requests, or the context of a node they selected/edited on the canvas. Use it when the user says things like "apply the tweak I just queued", "I clicked Tweak on the bom node — make that change", "pick up my floless request", "what's pending in floless", or after they double-click/Tweak a node in the UI. ALSO use it the moment the user pastes a message that begins with a `[floless-request type=… id=…]` marker — that is a request copied from the FloLess Dashboard, NOT a literal instruction to run; resolve it via the API. It reads the floless.app local API, applies the change to the app's .flo, recompiles, and clears the request.
|
|
4
4
|
metadata:
|
|
5
5
|
version: 0.1.0
|
|
6
6
|
---
|
|
@@ -13,6 +13,32 @@ and you **pull** them here, do the real work (edit the `.flo`, recompile), and c
|
|
|
13
13
|
skill is that pull side. Pair it with the **`floless-app-workflows`** skill, which owns
|
|
14
14
|
the actual `.flo` authoring / install → compile → run loop.
|
|
15
15
|
|
|
16
|
+
## Receiving a *pasted* request (the `[floless-request]` marker)
|
|
17
|
+
|
|
18
|
+
The user can either ask you to **pull** pending requests (below) **or paste one straight into
|
|
19
|
+
this chat**. Every request the Dashboard copies to the clipboard is prefixed with a
|
|
20
|
+
self-identifying marker line:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
[floless-request type=ui-customize id=4a1f…] — queued from the FloLess Dashboard. Apply it with your floless-app-ui skill: fetch the authoritative spec from GET http://127.0.0.1:<port>/api/requests, apply that request, then DELETE …/api/requests/<id>. Don't run the line below verbatim.
|
|
24
|
+
<a human-readable summary of the change>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
When a message starts with `[floless-request …]`, treat it as a **queued Dashboard request, not
|
|
28
|
+
a literal instruction**:
|
|
29
|
+
|
|
30
|
+
1. **Resolve it from the API, not the pasted text.** `GET /api/requests` and find the entry whose
|
|
31
|
+
`id` matches the marker — that JSON is authoritative; the pasted line is only a summary.
|
|
32
|
+
2. **Apply it with the skill the marker names** (`ui-customize` → `floless-app-ui`;
|
|
33
|
+
`tweak` / `use-template` → `floless-app-workflows`; `rebake` → `floless-app-rebake`). Use the
|
|
34
|
+
**port from the marker** if it isn't 4317.
|
|
35
|
+
3. **`DELETE /api/requests/<id>`** when done.
|
|
36
|
+
|
|
37
|
+
Do **not** run the pasted line as a shell command, and do **not** report a "missing file/path" as
|
|
38
|
+
a bug — a path like `~/.floless/ui/extensions.json` is *created by the skill's own flow*, so its
|
|
39
|
+
absence is expected, not an error. (This is the #73 fix: a pasted request was being misread as a
|
|
40
|
+
direct instruction and a path-not-found bug was filed.)
|
|
41
|
+
|
|
16
42
|
## The local API (http://127.0.0.1:4317)
|
|
17
43
|
|
|
18
44
|
The floless.app server runs locally on a **fixed port 4317** (override: `$PORT`). Confirm it's up
|
|
@@ -22,6 +22,11 @@ The floless.app UI surfaces a **"Re-read & re-bake ▸"** button on a baked-visu
|
|
|
22
22
|
node. Clicking it (after attaching a new drawing) records a **`rebake` request** that you pick
|
|
23
23
|
up here.
|
|
24
24
|
|
|
25
|
+
> **Pasted a request?** If the user pastes a message beginning with a
|
|
26
|
+
> `[floless-request type=rebake id=…]` marker, that's a request **copied from the FloLess
|
|
27
|
+
> Dashboard**, not a literal instruction. Resolve it via `GET /api/requests` (match the id) —
|
|
28
|
+
> don't run the pasted line verbatim — then follow the loop below and `DELETE` it when done.
|
|
29
|
+
|
|
25
30
|
## The loop
|
|
26
31
|
|
|
27
32
|
1. **Find the request.** `GET http://localhost:<port>/api/requests` → find the entry with
|
|
@@ -7,7 +7,7 @@ metadata:
|
|
|
7
7
|
|
|
8
8
|
# Setting up floless.app routines (automatic .flo runs)
|
|
9
9
|
|
|
10
|
-
A **routine** runs an installed
|
|
10
|
+
A **routine** runs an installed FloLess workflow (`.flo` app) automatically — the hands-off
|
|
11
11
|
equivalent of clicking ▶ Run workflow in floless.app. There are two **kinds**, both authored
|
|
12
12
|
through the same **`/api/routines`** REST surface:
|
|
13
13
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: floless-app-ui
|
|
3
|
-
description: This skill should be used when the user wants to customize the floless.app Dashboard — add, change, rearrange, or remove custom panels (stat cards, data tables, report buttons, run buttons) — or when picking up queued 'ui-customize' requests from the floless.app Customize box. Use it when the user says things like "add a panel showing last night's BOM run", "pin a run button to my dashboard", "apply my dashboard change", "customize my floless dashboard", "pick up my floless UI request",
|
|
3
|
+
description: This skill should be used when the user wants to customize the floless.app Dashboard — add, change, rearrange, or remove custom panels (stat cards, data tables, report buttons, run buttons) — or when picking up queued 'ui-customize' requests from the floless.app Customize box. Use it when the user says things like "add a panel showing last night's BOM run", "pin a run button to my dashboard", "apply my dashboard change", "customize my floless dashboard", "pick up my floless UI request", after they type into the Dashboard's Customize box, OR when they paste a message beginning with a `[floless-request type=ui-customize id=…]` marker (a request copied from the Dashboard — resolve it via /api/requests, don't run it verbatim). It edits ~/.floless/ui/extensions.json (the declarative UI descriptor), validates with the AWARE ui agent, and the app re-renders live.
|
|
4
4
|
metadata:
|
|
5
5
|
version: 0.1.0
|
|
6
6
|
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: floless-app-workflows
|
|
3
|
-
description: This skill should be used when building, modifying, or reasoning about
|
|
3
|
+
description: This skill should be used when building, modifying, or reasoning about FloLess .flo workflows for floless.app — especially reusable apps that take inputs (e.g. a phase number) and run across different Tekla models, exec nodes that run Roslyn C# against the live model, the in-app HTML Viewer, the Code tab, or "Debug in VS". Covers the exec contract, the install→validate→compile→run loop, app inputs templating, and the server seams (aware-adapter, app-reader, index).
|
|
4
4
|
metadata:
|
|
5
5
|
version: 0.1.0
|
|
6
6
|
---
|
|
@@ -14,10 +14,16 @@ Every capability is either `aware <verb>` (shelled through one adapter) or readi
|
|
|
14
14
|
state off disk. The browser is just the window. Never make the UI compose workflows or call
|
|
15
15
|
an LLM — it renders AWARE state, triggers `aware` verbs, and relays user intent.
|
|
16
16
|
|
|
17
|
-
The deliverable floless.app produces is a **reusable
|
|
17
|
+
The deliverable floless.app produces is a **reusable FloLess `.flo` app** (which runs on AWARE): one that declares
|
|
18
18
|
`inputs:` with defaults and runs unchanged across different models. The canonical example
|
|
19
19
|
lives at `demos/tekla-bom-by-phase/` — study it before building a new one.
|
|
20
20
|
|
|
21
|
+
> **Pasted a request?** If the user pastes a message beginning with a
|
|
22
|
+
> `[floless-request type=tweak|use-template id=…]` marker, that's a request **copied from the
|
|
23
|
+
> FloLess Dashboard**, not a literal instruction. Resolve it via the API (`GET /api/requests`,
|
|
24
|
+
> match the id) and apply it per the **`floless-app-bridge`** skill's "Receiving a pasted
|
|
25
|
+
> request" section — then `DELETE /api/requests/<id>`. Don't run the pasted line verbatim.
|
|
26
|
+
|
|
21
27
|
## The reusable-app shape
|
|
22
28
|
|
|
23
29
|
An app becomes reusable by declaring a top-level `inputs:` block and templating those inputs
|
package/dist/web/app.css
CHANGED
|
@@ -347,6 +347,21 @@
|
|
|
347
347
|
}
|
|
348
348
|
/* While middle-mouse panning, force the grab cursor over the whole canvas. */
|
|
349
349
|
.canvas.panning, .canvas.panning * { cursor: grabbing !important; }
|
|
350
|
+
/* OS-file drop target for importing a shared .flo (#66). Shown ONLY while a real file
|
|
351
|
+
is dragged over the canvas — distinguished from in-app node-card drags (which carry
|
|
352
|
+
no "Files" type) by the JS that toggles .active. Existing tokens only. */
|
|
353
|
+
.canvas-drop-target {
|
|
354
|
+
position: absolute; inset: 8px; z-index: 20;
|
|
355
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
|
|
356
|
+
background: color-mix(in srgb, var(--bg) 80%, transparent);
|
|
357
|
+
border: 2px dashed var(--accent-dim); border-radius: 6px;
|
|
358
|
+
pointer-events: none; opacity: 0; transition: opacity 0.15s ease;
|
|
359
|
+
}
|
|
360
|
+
.canvas-drop-target.active { opacity: 1; pointer-events: auto; }
|
|
361
|
+
.canvas-drop-icon { font-size: 30px; color: var(--accent); line-height: 1; }
|
|
362
|
+
.canvas-drop-label { font-size: 14px; font-weight: 600; color: var(--text); letter-spacing: 0.01em; }
|
|
363
|
+
.canvas-drop-sub { font-size: 11px; color: var(--text-muted); }
|
|
364
|
+
@media (prefers-reduced-motion: reduce) { .canvas-drop-target { transition: none; } }
|
|
350
365
|
.topology {
|
|
351
366
|
flex: 1;
|
|
352
367
|
display: flex;
|
|
@@ -712,6 +727,7 @@
|
|
|
712
727
|
|
|
713
728
|
/* ========== FAVORITES BAR ========== */
|
|
714
729
|
.fav-bar {
|
|
730
|
+
position: relative; /* anchors the .fav-bar-drop-label overlay (#71) */
|
|
715
731
|
border-top: 1px solid var(--border);
|
|
716
732
|
background: var(--surface);
|
|
717
733
|
padding: 9px 16px;
|
|
@@ -720,7 +736,7 @@
|
|
|
720
736
|
gap: 12px;
|
|
721
737
|
min-height: 46px;
|
|
722
738
|
overflow-x: auto;
|
|
723
|
-
transition: background 0.15s, border-top-color 0.15s;
|
|
739
|
+
transition: background 0.15s, border-top-color 0.15s, border-top-style 0.15s;
|
|
724
740
|
}
|
|
725
741
|
.fav-bar-label {
|
|
726
742
|
font-size: 10px;
|
|
@@ -735,11 +751,38 @@
|
|
|
735
751
|
.fav-bar-label .star { color: var(--star); font-size: 12px; }
|
|
736
752
|
.fav-bar-empty { font-style: italic; font-size: 11px; color: var(--text-dim); }
|
|
737
753
|
.fav-chip-row { display: flex; gap: 6px; align-items: center; flex-wrap: nowrap; }
|
|
754
|
+
/* Two-level drop affordance (#71): the bar ADVERTISES itself as a drop target the
|
|
755
|
+
moment a node-card drag starts (.drop-armed — a quiet dashed accent edge), then
|
|
756
|
+
INTENSIFIES to the full accent fill when the node is dragged over it (.drag-over).
|
|
757
|
+
Both use existing tokens only; cleared on drop/cancel. */
|
|
758
|
+
.fav-bar.drop-armed {
|
|
759
|
+
background: var(--surface-2);
|
|
760
|
+
border-top: 1px dashed var(--accent-dim);
|
|
761
|
+
}
|
|
738
762
|
.fav-bar.drag-over {
|
|
739
763
|
background: var(--accent-soft);
|
|
740
|
-
border-top
|
|
764
|
+
border-top: 1px solid var(--accent);
|
|
741
765
|
box-shadow: inset 0 1px 0 var(--accent);
|
|
742
766
|
}
|
|
767
|
+
/* "Drop to save as Template" hint, shown while armed (and whether or not chips already
|
|
768
|
+
exist — it overlays flush-right, never reflows the chip row). The bar's own fill is
|
|
769
|
+
confirmation enough once the node is over it, so it fades out on .drag-over. */
|
|
770
|
+
.fav-bar-drop-label {
|
|
771
|
+
position: absolute;
|
|
772
|
+
right: 16px;
|
|
773
|
+
top: 50%;
|
|
774
|
+
transform: translateY(-50%);
|
|
775
|
+
font-size: 10px;
|
|
776
|
+
font-style: italic;
|
|
777
|
+
color: var(--accent-dim);
|
|
778
|
+
white-space: nowrap;
|
|
779
|
+
pointer-events: none;
|
|
780
|
+
transition: opacity 0.12s;
|
|
781
|
+
}
|
|
782
|
+
.fav-bar.drag-over .fav-bar-drop-label { opacity: 0; }
|
|
783
|
+
@media (prefers-reduced-motion: reduce) {
|
|
784
|
+
.fav-bar, .fav-bar.drop-armed, .fav-bar.drag-over, .fav-bar-drop-label { transition: none; }
|
|
785
|
+
}
|
|
743
786
|
.fav-chip {
|
|
744
787
|
display: inline-flex;
|
|
745
788
|
align-items: center;
|
|
@@ -779,6 +822,19 @@
|
|
|
779
822
|
}
|
|
780
823
|
.fav-chip:hover .del { opacity: 1; }
|
|
781
824
|
.fav-chip .del:hover { color: var(--err); background: rgba(248, 113, 113, 0.1); }
|
|
825
|
+
/* Edit (rename / recategorize) — mirrors .del, revealed on chip hover, but goes
|
|
826
|
+
accent-blue rather than err-red so the two actions read distinctly (#68). */
|
|
827
|
+
.fav-chip .edit {
|
|
828
|
+
color: var(--text-dim);
|
|
829
|
+
opacity: 0.4;
|
|
830
|
+
cursor: pointer;
|
|
831
|
+
padding: 4px 4px;
|
|
832
|
+
line-height: 1;
|
|
833
|
+
transition: all 0.15s;
|
|
834
|
+
border-radius: 2px;
|
|
835
|
+
}
|
|
836
|
+
.fav-chip:hover .edit { opacity: 1; }
|
|
837
|
+
.fav-chip .edit:hover { color: var(--accent); background: var(--accent-soft); }
|
|
782
838
|
|
|
783
839
|
/* ========== INSPECT ========== */
|
|
784
840
|
.inspect {
|
|
@@ -2221,9 +2277,11 @@ body {
|
|
|
2221
2277
|
}
|
|
2222
2278
|
|
|
2223
2279
|
/* Report + input nodes on the canvas carry a first-class action button
|
|
2224
|
-
("View report ▸" / "Set inputs ▸"). Double-click still works as a shortcut.
|
|
2225
|
-
|
|
2226
|
-
.
|
|
2280
|
+
("View report ▸" / "Set inputs ▸"). Double-click still works as a shortcut.
|
|
2281
|
+
The card BODY keeps the shared `grab` cursor (every card is draggable) — only the
|
|
2282
|
+
actionable bits (.node-action, .inspect-hint, .fav-btn) read as `pointer`. Earlier
|
|
2283
|
+
these cards forced `cursor: pointer` on the whole body, which clashed with the grab
|
|
2284
|
+
cursor on every other card (#70). */
|
|
2227
2285
|
.agent-card .node-action {
|
|
2228
2286
|
display: block; width: 100%; margin-top: 8px;
|
|
2229
2287
|
padding: 5px 8px; border-radius: 6px;
|
package/dist/web/app.js
CHANGED
|
@@ -19,6 +19,7 @@ const state = {
|
|
|
19
19
|
favorites: [],
|
|
20
20
|
collapse: { left: false, right: false },
|
|
21
21
|
pendingFavAgentId: null,
|
|
22
|
+
editingTemplateId: null, // set while the add/edit Template modal is in EDIT mode (#68)
|
|
22
23
|
traceFilter: null, // Execution tab: node id to filter rows to, or null = all
|
|
23
24
|
};
|
|
24
25
|
|
|
@@ -614,6 +615,7 @@ function renderCategorySuggestions() {
|
|
|
614
615
|
function closeAddFavModal() {
|
|
615
616
|
$addFavModal.classList.remove('show');
|
|
616
617
|
state.pendingFavAgentId = null;
|
|
618
|
+
state.editingTemplateId = null; // leave EDIT mode so the next open is a fresh create (#68)
|
|
617
619
|
}
|
|
618
620
|
|
|
619
621
|
function commitFav() {
|
|
@@ -675,17 +677,37 @@ function refreshFavMarkers() {
|
|
|
675
677
|
}
|
|
676
678
|
|
|
677
679
|
function setupFavDropZone() {
|
|
680
|
+
// Advertise the Templates bar as a drop target the moment ANY node-card drag starts,
|
|
681
|
+
// so drag-to-save is discoverable instead of invisible (#71). dragend fires for both a
|
|
682
|
+
// completed drop AND a cancelled (Esc / dropped-nowhere) drag, so it's the disarm hook.
|
|
683
|
+
document.addEventListener('dragstart', (e) => {
|
|
684
|
+
if (!(e.target instanceof Element) || !e.target.closest('.agent-card')) return;
|
|
685
|
+
$favBar.classList.add('drop-armed');
|
|
686
|
+
// The teaching label rides in the empty bar's free space. Once templates exist the
|
|
687
|
+
// chip row can fill (and horizontally scroll) the bar, so an overlaid label would
|
|
688
|
+
// collide with chips — there the dashed armed edge + hover-intensify is the affordance.
|
|
689
|
+
if (state.favorites.length === 0 && !$favBar.querySelector('.fav-bar-drop-label')) {
|
|
690
|
+
const lbl = document.createElement('span');
|
|
691
|
+
lbl.className = 'fav-bar-drop-label';
|
|
692
|
+
lbl.textContent = '▾ Drop to save as Template';
|
|
693
|
+
$favBar.appendChild(lbl);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
document.addEventListener('dragend', disarmFavBar);
|
|
697
|
+
|
|
678
698
|
$favBar.addEventListener('dragover', (e) => {
|
|
679
699
|
e.preventDefault();
|
|
680
700
|
e.dataTransfer.dropEffect = 'copy';
|
|
681
701
|
$favBar.classList.add('drag-over');
|
|
682
702
|
});
|
|
703
|
+
// relatedTarget containment, not `e.target === $favBar`: the latter cleared the
|
|
704
|
+
// highlight prematurely when the cursor crossed from the bar's padding onto a chip.
|
|
683
705
|
$favBar.addEventListener('dragleave', (e) => {
|
|
684
|
-
if (e.
|
|
706
|
+
if (!$favBar.contains(e.relatedTarget)) $favBar.classList.remove('drag-over');
|
|
685
707
|
});
|
|
686
708
|
$favBar.addEventListener('drop', (e) => {
|
|
687
709
|
e.preventDefault();
|
|
688
|
-
|
|
710
|
+
disarmFavBar();
|
|
689
711
|
const id = e.dataTransfer.getData('text/plain');
|
|
690
712
|
if (!id || !AGENTS[id]) return;
|
|
691
713
|
if (state.favorites.some(f => f.id === id)) {
|
|
@@ -697,6 +719,13 @@ function setupFavDropZone() {
|
|
|
697
719
|
});
|
|
698
720
|
}
|
|
699
721
|
|
|
722
|
+
// Clear every drag-affordance state on the Templates bar (drop, cancel, or dragend).
|
|
723
|
+
function disarmFavBar() {
|
|
724
|
+
$favBar.classList.remove('drop-armed', 'drag-over');
|
|
725
|
+
const lbl = $favBar.querySelector('.fav-bar-drop-label');
|
|
726
|
+
if (lbl) lbl.remove();
|
|
727
|
+
}
|
|
728
|
+
|
|
700
729
|
/* ============= LIBRARY ============= */
|
|
701
730
|
|
|
702
731
|
function openLibrary() {
|
|
@@ -922,8 +951,8 @@ document.querySelectorAll('.panel-toggle').forEach(btn => {
|
|
|
922
951
|
document.getElementById('fav-cancel').onclick = closeAddFavModal;
|
|
923
952
|
document.getElementById('fav-save').onclick = commitFav;
|
|
924
953
|
document.getElementById('lib-close').onclick = () => $libModal.classList.remove('show');
|
|
925
|
-
|
|
926
|
-
$libModal
|
|
954
|
+
onBackdropDismiss($addFavModal, closeAddFavModal);
|
|
955
|
+
onBackdropDismiss($libModal, () => $libModal.classList.remove('show'));
|
|
927
956
|
|
|
928
957
|
document.addEventListener('keydown', (e) => {
|
|
929
958
|
if (e.key === 'Escape') {
|
|
@@ -952,6 +981,22 @@ function escapeHtml(s) {
|
|
|
952
981
|
}
|
|
953
982
|
function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '"'); }
|
|
954
983
|
|
|
984
|
+
// Backdrop-dismiss that ignores text-selection drags. A modal closes only when BOTH
|
|
985
|
+
// the mousedown AND the click land on the backdrop element itself — so selecting text
|
|
986
|
+
// inside a modal input and releasing the cursor outside the modal no longer dismisses
|
|
987
|
+
// it on mouseup (#69). `guard` (optional) can veto dismissal, e.g. while a run is busy.
|
|
988
|
+
// Defined here (app.js loads first) and reused by aware.js for every modal.
|
|
989
|
+
function onBackdropDismiss(modal, dismiss, guard) {
|
|
990
|
+
if (!modal) return;
|
|
991
|
+
let downOnBackdrop = false;
|
|
992
|
+
modal.onmousedown = (e) => { downOnBackdrop = (e.target === modal); };
|
|
993
|
+
modal.onclick = (e) => {
|
|
994
|
+
const hit = e.target === modal && downOnBackdrop;
|
|
995
|
+
downOnBackdrop = false;
|
|
996
|
+
if (hit && (!guard || guard())) dismiss();
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
955
1000
|
/* ============= MENU ============= */
|
|
956
1001
|
|
|
957
1002
|
const $menuBtn = document.getElementById('menu-btn');
|
|
@@ -1329,7 +1374,7 @@ const $integrationsModal = document.getElementById('integrations-modal');
|
|
|
1329
1374
|
const $integrationsList = document.getElementById('integrations-list');
|
|
1330
1375
|
|
|
1331
1376
|
document.getElementById('integrations-close').onclick = () => $integrationsModal.classList.remove('show');
|
|
1332
|
-
$integrationsModal
|
|
1377
|
+
onBackdropDismiss($integrationsModal, () => $integrationsModal.classList.remove('show'));
|
|
1333
1378
|
|
|
1334
1379
|
/* ============= KEYBOARD SHORTCUTS ============= */
|
|
1335
1380
|
|
package/dist/web/aware.js
CHANGED
|
@@ -1340,7 +1340,7 @@
|
|
|
1340
1340
|
$save.onclick = () => done('save');
|
|
1341
1341
|
$dont.onclick = () => done('discard');
|
|
1342
1342
|
$cancel.onclick = () => done('cancel');
|
|
1343
|
-
$confirmModal
|
|
1343
|
+
onBackdropDismiss($confirmModal, () => done('cancel'));
|
|
1344
1344
|
document.addEventListener('keydown', onKey, true);
|
|
1345
1345
|
});
|
|
1346
1346
|
}
|
|
@@ -1510,7 +1510,7 @@
|
|
|
1510
1510
|
};
|
|
1511
1511
|
$ok.onclick = () => done(collect());
|
|
1512
1512
|
$cancel.onclick = () => done(null);
|
|
1513
|
-
$formModal
|
|
1513
|
+
onBackdropDismiss($formModal, () => done(null));
|
|
1514
1514
|
$body.onkeydown = (e) => {
|
|
1515
1515
|
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
|
|
1516
1516
|
else if (e.key === 'Escape') { e.preventDefault(); done(null); }
|
|
@@ -1535,7 +1535,7 @@
|
|
|
1535
1535
|
// BEHIND the report modal's backdrop, so a session shown in the Viewer needs its
|
|
1536
1536
|
// own reachable Stop. Visibility is driven by syncRunControls (foregroundTrigger).
|
|
1537
1537
|
if ($reportStop) $reportStop.onclick = () => stopRun();
|
|
1538
|
-
$reportModal
|
|
1538
|
+
onBackdropDismiss($reportModal, () => hideModal($reportModal));
|
|
1539
1539
|
// The Stop button is rebuilt into the overlay each run — delegate so one
|
|
1540
1540
|
// listener survives every innerHTML swap.
|
|
1541
1541
|
$reportOverlay.addEventListener('click', (e) => { if (e.target.closest('.overlay-stop')) stopRun(); });
|
|
@@ -1674,7 +1674,7 @@
|
|
|
1674
1674
|
|
|
1675
1675
|
document.getElementById('bake-cancel').onclick = () => hideModal($bakeModal);
|
|
1676
1676
|
document.getElementById('bake-confirm').onclick = () => runBake();
|
|
1677
|
-
$bakeModal
|
|
1677
|
+
onBackdropDismiss($bakeModal, () => hideModal($bakeModal), () => !document.getElementById('bake-confirm').disabled);
|
|
1678
1678
|
|
|
1679
1679
|
// ── Graft into agent ────────────────────────────────────────────────────────
|
|
1680
1680
|
// Build an agent from a foreign tool (DLL / C# source / NuGet / OpenAPI / …).
|
|
@@ -1897,7 +1897,7 @@
|
|
|
1897
1897
|
$graftBack.onclick = () => { if (graftState.onBack) graftState.onBack(); };
|
|
1898
1898
|
$graftCancel.onclick = () => hideModal($graftModal);
|
|
1899
1899
|
$graftPrimary.onclick = () => { if (graftState.onPrimary) graftState.onPrimary(); };
|
|
1900
|
-
$graftModal
|
|
1900
|
+
onBackdropDismiss($graftModal, () => hideModal($graftModal), () => !$graftCancel.disabled);
|
|
1901
1901
|
|
|
1902
1902
|
// Secondary entry: "⊕ Graft new agent" inside the Agents Library modal.
|
|
1903
1903
|
const $libGraft = document.getElementById('lib-graft');
|
|
@@ -1910,9 +1910,59 @@
|
|
|
1910
1910
|
handleMenuAction = function (action) {
|
|
1911
1911
|
if (action === 'graft') { openGraftModal(); return; }
|
|
1912
1912
|
if (action === 'bake') { openBakeModal(); return; }
|
|
1913
|
+
if (action === 'import') { triggerImport(); return; }
|
|
1913
1914
|
_handleMenuAction(action);
|
|
1914
1915
|
};
|
|
1915
1916
|
|
|
1917
|
+
// ── Import a shared workflow (#66) ────────────────────────────────────────────
|
|
1918
|
+
// A teammate's .flo, via the ≡ menu's "Import workflow…" OR dropped onto the canvas.
|
|
1919
|
+
// The server derives the id from the file's `app:` field, installs it, and we select
|
|
1920
|
+
// it so the user can Compile (we never auto-compile — Run stays a deliberate act).
|
|
1921
|
+
const $importFile = document.getElementById('import-file');
|
|
1922
|
+
function triggerImport() { if ($importFile) $importFile.click(); }
|
|
1923
|
+
|
|
1924
|
+
async function importFlo(file) {
|
|
1925
|
+
if (!file) return;
|
|
1926
|
+
let content;
|
|
1927
|
+
try { content = await file.text(); } catch { showToast('Could not read that file', 'err'); return; }
|
|
1928
|
+
try {
|
|
1929
|
+
const { id } = await api('/api/import', { method: 'POST', body: JSON.stringify({ filename: file.name, content }) });
|
|
1930
|
+
await loadApps();
|
|
1931
|
+
$promptSel.value = id;
|
|
1932
|
+
$promptSel.dispatchEvent(new Event('change', { bubbles: true })); // → loadApp(id), arms Compile
|
|
1933
|
+
showToast(`Imported "${id}" — Compile to run`, 'ok');
|
|
1934
|
+
} catch (e) {
|
|
1935
|
+
const body = e && e.body;
|
|
1936
|
+
const msg = (body && body.error) || (e && e.message) || 'Import failed';
|
|
1937
|
+
showToast(msg, body && body.code === 'exists' ? 'warn' : 'err');
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
if ($importFile) {
|
|
1942
|
+
$importFile.onchange = () => {
|
|
1943
|
+
const f = $importFile.files && $importFile.files[0];
|
|
1944
|
+
$importFile.value = ''; // reset so re-picking the same filename still fires change
|
|
1945
|
+
importFlo(f);
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// OS-file drop on the canvas. Distinguished from in-app node-card HTML5 drags (which
|
|
1950
|
+
// carry no "Files" type) so dragging a card still drops onto the Templates bar (#71).
|
|
1951
|
+
const $canvasMain = document.getElementById('canvas-main');
|
|
1952
|
+
const $dropTarget = document.getElementById('canvas-drop-target');
|
|
1953
|
+
const isFileDrag = (e) => !!e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files');
|
|
1954
|
+
if ($canvasMain && $dropTarget) {
|
|
1955
|
+
$canvasMain.addEventListener('dragenter', (e) => { if (isFileDrag(e)) { e.preventDefault(); $dropTarget.classList.add('active'); } });
|
|
1956
|
+
$canvasMain.addEventListener('dragover', (e) => { if (isFileDrag(e)) e.preventDefault(); }); // required to allow the drop
|
|
1957
|
+
$canvasMain.addEventListener('dragleave', (e) => { if (!$canvasMain.contains(e.relatedTarget)) $dropTarget.classList.remove('active'); });
|
|
1958
|
+
$canvasMain.addEventListener('drop', (e) => {
|
|
1959
|
+
if (!isFileDrag(e)) return; // let node-card drops fall through to the Templates bar
|
|
1960
|
+
e.preventDefault();
|
|
1961
|
+
$dropTarget.classList.remove('active');
|
|
1962
|
+
importFlo(e.dataTransfer.files && e.dataTransfer.files[0]);
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1916
1966
|
// ── Release notes: shared state + popover ─────────────────────────────────────
|
|
1917
1967
|
// The channel-correct public site base (e.g. https://floless.io), captured from
|
|
1918
1968
|
// /api/health in the health poll. The changelog deep-link is omitted entirely when
|
|
@@ -2985,6 +3035,9 @@
|
|
|
2985
3035
|
state.hasRun = true;
|
|
2986
3036
|
} else if (m.type === 'templates-changed') {
|
|
2987
3037
|
loadTemplates().catch(() => {});
|
|
3038
|
+
} else if (m.type === 'apps-changed') {
|
|
3039
|
+
// A workflow was imported (here or in another tab) → refresh the picker (#66).
|
|
3040
|
+
loadApps().catch(() => {});
|
|
2988
3041
|
} else if (m.type === 'baked' && m.id === currentId) {
|
|
2989
3042
|
// Baked (here or in another tab) → refresh so the menu item flips to "Re-bake".
|
|
2990
3043
|
loadApp(currentId).catch(() => {});
|
|
@@ -3100,8 +3153,9 @@
|
|
|
3100
3153
|
|
|
3101
3154
|
openAddFavModal = function openAddFavModalTpl(nodeId) {
|
|
3102
3155
|
state.pendingFavAgentId = nodeId;
|
|
3156
|
+
state.editingTemplateId = null; // CREATE mode
|
|
3103
3157
|
const a = AGENTS[nodeId];
|
|
3104
|
-
|
|
3158
|
+
setFavModalChrome('Save as Template', `Save "${nodeId}" as a reusable template — usable in any project.`, '★ Save');
|
|
3105
3159
|
$favName.value = a ? a.title : nodeId;
|
|
3106
3160
|
$favCat.value = '';
|
|
3107
3161
|
renderCategorySuggestions();
|
|
@@ -3109,7 +3163,47 @@
|
|
|
3109
3163
|
setTimeout(() => $favCat.focus(), 50);
|
|
3110
3164
|
};
|
|
3111
3165
|
|
|
3166
|
+
// Swap the shared Add-Template modal's title / subtitle / save-button label so the
|
|
3167
|
+
// same modal serves both CREATE (from a node) and EDIT (rename/recategorize) (#68).
|
|
3168
|
+
function setFavModalChrome(title, sub, saveLabel) {
|
|
3169
|
+
const $t = document.getElementById('add-fav-title');
|
|
3170
|
+
const $s = document.getElementById('fav-save');
|
|
3171
|
+
if ($t) $t.textContent = title;
|
|
3172
|
+
$addFavSub.textContent = sub;
|
|
3173
|
+
if ($s) $s.textContent = saveLabel;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
// Open the modal in EDIT mode for an existing template. Only name + category are
|
|
3177
|
+
// editable; the captured node is immutable (re-star a node to change its logic) (#68).
|
|
3178
|
+
function openEditTemplate(id) {
|
|
3179
|
+
const tpl = state.favorites.find((t) => t.id === id);
|
|
3180
|
+
if (!tpl) return;
|
|
3181
|
+
state.editingTemplateId = id;
|
|
3182
|
+
state.pendingFavAgentId = null;
|
|
3183
|
+
setFavModalChrome('Edit Template', 'Rename or recategorize this template.', 'Save changes');
|
|
3184
|
+
$favName.value = tpl.name;
|
|
3185
|
+
$favCat.value = tpl.category;
|
|
3186
|
+
renderCategorySuggestions();
|
|
3187
|
+
$addFavModal.classList.add('show');
|
|
3188
|
+
setTimeout(() => { $favName.focus(); $favName.select(); }, 50);
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3112
3191
|
commitFav = async function commitFavTpl() {
|
|
3192
|
+
// EDIT mode (#68): PATCH the existing template's name/category; node is untouched.
|
|
3193
|
+
if (state.editingTemplateId) {
|
|
3194
|
+
const id = state.editingTemplateId;
|
|
3195
|
+
const name = ($favName.value || '').trim();
|
|
3196
|
+
const category = ($favCat.value || '').trim() || 'Uncategorized';
|
|
3197
|
+
if (!name) { showToast('Name required', 'warn'); return; }
|
|
3198
|
+
try {
|
|
3199
|
+
await api(`/api/templates/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ name, category }) });
|
|
3200
|
+
$addFavModal.classList.remove('show');
|
|
3201
|
+
state.editingTemplateId = null;
|
|
3202
|
+
await loadTemplates();
|
|
3203
|
+
showToast(`Updated template "${name}"`, 'ok');
|
|
3204
|
+
} catch (e) { reportErr(e); }
|
|
3205
|
+
return;
|
|
3206
|
+
}
|
|
3113
3207
|
const nodeId = state.pendingFavAgentId;
|
|
3114
3208
|
if (!nodeId) return;
|
|
3115
3209
|
const name = ($favName.value || '').trim() || nodeId;
|
|
@@ -3144,14 +3238,16 @@
|
|
|
3144
3238
|
if (!tpls.length) { $favChipRow.innerHTML = ''; $favBarEmpty.style.display = 'block'; return; }
|
|
3145
3239
|
$favBarEmpty.style.display = 'none';
|
|
3146
3240
|
$favChipRow.innerHTML = tpls.map((t) => `
|
|
3147
|
-
<div class="fav-chip" data-tpl="${escapeAttr(t.id)}" data-tip="
|
|
3241
|
+
<div class="fav-chip" data-tpl="${escapeAttr(t.id)}" data-tip="Click to use · ✎ rename · ${escapeAttr(t.category)} · ${escapeAttr((t.node.agent || t.node.kind) + (t.node.command ? '/' + t.node.command : ''))}">
|
|
3148
3242
|
<span class="cat">${escapeHtml(t.category)}</span>
|
|
3149
3243
|
<span class="name">${escapeHtml(t.name)}</span>
|
|
3150
|
-
<span class="
|
|
3244
|
+
<span class="edit" data-tip="Rename / recategorize" aria-label="Edit template">✎</span>
|
|
3245
|
+
<span class="del" data-tip="Delete template" aria-label="Delete template">×</span>
|
|
3151
3246
|
</div>`).join('');
|
|
3152
3247
|
$favChipRow.querySelectorAll('.fav-chip').forEach((chip) => {
|
|
3153
3248
|
const id = chip.dataset.tpl;
|
|
3154
|
-
chip.onclick = (e) => { if (e.target.closest('.del')) return; useTemplate(id); };
|
|
3249
|
+
chip.onclick = (e) => { if (e.target.closest('.del') || e.target.closest('.edit')) return; useTemplate(id); };
|
|
3250
|
+
chip.querySelector('.edit').onclick = (e) => { e.stopPropagation(); openEditTemplate(id); };
|
|
3155
3251
|
chip.querySelector('.del').onclick = (e) => { e.stopPropagation(); deleteTemplate(id); };
|
|
3156
3252
|
});
|
|
3157
3253
|
};
|
|
@@ -3188,6 +3284,32 @@
|
|
|
3188
3284
|
return '';
|
|
3189
3285
|
}
|
|
3190
3286
|
|
|
3287
|
+
// Which product skill applies each request type (named in the copied marker so the
|
|
3288
|
+
// terminal AI picks up the right one).
|
|
3289
|
+
const REQUEST_SKILL = {
|
|
3290
|
+
'use-template': 'floless-app-workflows',
|
|
3291
|
+
tweak: 'floless-app-workflows',
|
|
3292
|
+
'ui-customize': 'floless-app-ui',
|
|
3293
|
+
rebake: 'floless-app-rebake',
|
|
3294
|
+
};
|
|
3295
|
+
|
|
3296
|
+
// The COPIED form of a request: instructionFor() prefixed with a self-identifying
|
|
3297
|
+
// marker so a PASTED request is unmistakable to the terminal AI — it's a queued FloLess
|
|
3298
|
+
// Dashboard request to APPLY (via the named skill + the authoritative /api/requests),
|
|
3299
|
+
// not a literal instruction to run verbatim (#73). The modal preview keeps the plain
|
|
3300
|
+
// instructionFor() text; only the clipboard carries the marker.
|
|
3301
|
+
function markedInstruction(req) {
|
|
3302
|
+
const body = instructionFor(req);
|
|
3303
|
+
if (!body) return '';
|
|
3304
|
+
const skill = REQUEST_SKILL[req.type] || 'floless-app-workflows';
|
|
3305
|
+
const base = (typeof location !== 'undefined' && location.origin) ? location.origin : 'http://127.0.0.1:4317';
|
|
3306
|
+
const marker =
|
|
3307
|
+
`[floless-request type=${req.type} id=${req.id}] — queued from the FloLess Dashboard. ` +
|
|
3308
|
+
`Apply it with your ${skill} skill: fetch the authoritative spec from GET ${base}/api/requests, ` +
|
|
3309
|
+
`apply that request, then DELETE ${base}/api/requests/${req.id}. Don't run the line below verbatim.`;
|
|
3310
|
+
return `${marker}\n${body}`;
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3191
3313
|
async function copyToClipboard(text) {
|
|
3192
3314
|
try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
|
|
3193
3315
|
}
|
|
@@ -3196,7 +3318,7 @@
|
|
|
3196
3318
|
if (!currentId) { showToast('open a workflow first', 'warn'); return; }
|
|
3197
3319
|
try {
|
|
3198
3320
|
const { request } = await api('/api/use-template', { method: 'POST', body: JSON.stringify({ appId: currentId, templateId }) });
|
|
3199
|
-
const line =
|
|
3321
|
+
const line = markedInstruction(request);
|
|
3200
3322
|
const copied = await copyToClipboard(line);
|
|
3201
3323
|
appendNarration(`Queued template <strong>${escapeHtml(request.template.name)}</strong> for workflow <code>${escapeHtml(currentId)}</code> — the UI can’t edit the workflow itself, so your terminal AI picks this up and applies it. ${copied ? 'Instruction copied to your clipboard — paste it in.' : 'Open the requests chip (bottom-right) to copy it.'}`);
|
|
3202
3324
|
showToast(copied ? 'Queued for your terminal AI · copied to clipboard' : 'Queued for your terminal AI', 'ok');
|
|
@@ -3221,7 +3343,7 @@
|
|
|
3221
3343
|
try {
|
|
3222
3344
|
const snaps = Array.isArray(res.snapshots) ? res.snapshots.map((s) => ({ name: s.name, dataUrl: s.dataUrl })) : [];
|
|
3223
3345
|
const { request } = await api('/api/tweak', { method: 'POST', body: JSON.stringify({ appId: currentId, nodeId: node, instruction, snapshots: snaps }) });
|
|
3224
|
-
const copied = await copyToClipboard(
|
|
3346
|
+
const copied = await copyToClipboard(markedInstruction(request));
|
|
3225
3347
|
appendNarration(`Tweak queued for <code>${escapeHtml(node)}</code> — your terminal AI can pull it (floless skill) ${copied ? 'or paste the copied instruction' : ''}.`);
|
|
3226
3348
|
const toastMsg = snaps.length
|
|
3227
3349
|
? `Tweak + ${snaps.length} snapshot(s) queued${copied ? ' + copied' : ''}`
|
|
@@ -3289,7 +3411,7 @@
|
|
|
3289
3411
|
b.onclick = async () => {
|
|
3290
3412
|
const r = pendingRequests.find((x) => x.id === b.dataset.id);
|
|
3291
3413
|
if (!r) return;
|
|
3292
|
-
const copied = await copyToClipboard(
|
|
3414
|
+
const copied = await copyToClipboard(markedInstruction(r));
|
|
3293
3415
|
showToast(copied ? 'Copied — paste it to your terminal AI' : 'copy failed', copied ? 'ok' : 'err');
|
|
3294
3416
|
};
|
|
3295
3417
|
});
|
|
@@ -3975,7 +4097,7 @@
|
|
|
3975
4097
|
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } };
|
|
3976
4098
|
$confirm.onclick = () => done(true);
|
|
3977
4099
|
$cancel.onclick = () => done(false);
|
|
3978
|
-
$m
|
|
4100
|
+
onBackdropDismiss($m, () => done(false));
|
|
3979
4101
|
document.addEventListener('keydown', onKey, true);
|
|
3980
4102
|
});
|
|
3981
4103
|
}
|
|
@@ -4167,11 +4289,11 @@
|
|
|
4167
4289
|
// Wiring (the modal elements are static in index.html, present when this runs).
|
|
4168
4290
|
document.getElementById('routines-btn').onclick = () => openRoutines();
|
|
4169
4291
|
document.getElementById('routines-close').onclick = () => hideModal($routinesModal);
|
|
4170
|
-
$routinesModal
|
|
4292
|
+
onBackdropDismiss($routinesModal, () => hideModal($routinesModal));
|
|
4171
4293
|
document.getElementById('rtn-add').onclick = () => openRoutineEdit(null);
|
|
4172
4294
|
document.getElementById('rtn-edit-cancel').onclick = () => hideModal($routineEditModal);
|
|
4173
4295
|
document.getElementById('rtn-edit-save').onclick = () => saveRoutine();
|
|
4174
|
-
$routineEditModal
|
|
4296
|
+
onBackdropDismiss($routineEditModal, () => hideModal($routineEditModal));
|
|
4175
4297
|
document.getElementById('rtn-kind').onchange = (e) => applySchedKind(e.target.value);
|
|
4176
4298
|
document.querySelectorAll('#rtn-mode-field .rtn-mode-btn').forEach((b) => { b.onclick = () => setRoutineMode(b.dataset.mode); });
|
|
4177
4299
|
document.getElementById('rtn-workflow').onchange = (e) => { if (!editingRoutineId) loadRoutineInputs(e.target.value, null); };
|
|
@@ -4359,7 +4481,7 @@
|
|
|
4359
4481
|
$riDesc.addEventListener('input', riSyncSend);
|
|
4360
4482
|
$riSend.onclick = () => riSubmit();
|
|
4361
4483
|
$riCancel.onclick = () => riClose();
|
|
4362
|
-
$riModal
|
|
4484
|
+
onBackdropDismiss($riModal, () => riClose());
|
|
4363
4485
|
$riModal.querySelectorAll('#ri-category .rtn-mode-btn').forEach((b) => { b.onclick = () => riSetCategory(b.dataset.cat); });
|
|
4364
4486
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $riModal.classList.contains('show')) riClose(); });
|
|
4365
4487
|
|
|
@@ -4402,13 +4524,13 @@
|
|
|
4402
4524
|
const $reqModal = document.getElementById('requests-modal');
|
|
4403
4525
|
const $reqClose = document.getElementById('requests-close');
|
|
4404
4526
|
if ($reqClose) $reqClose.onclick = () => $reqModal.classList.remove('show');
|
|
4405
|
-
if ($reqModal) $reqModal
|
|
4527
|
+
if ($reqModal) onBackdropDismiss($reqModal, () => $reqModal.classList.remove('show'));
|
|
4406
4528
|
const $reqClear = document.getElementById('requests-clear');
|
|
4407
4529
|
if ($reqClear) $reqClear.onclick = () => clearAllRequests();
|
|
4408
4530
|
const $reqCopy = document.getElementById('requests-copy');
|
|
4409
4531
|
if ($reqCopy) $reqCopy.onclick = async () => {
|
|
4410
4532
|
if (!pendingRequests.length) return;
|
|
4411
|
-
const text = pendingRequests.map(
|
|
4533
|
+
const text = pendingRequests.map(markedInstruction).filter(Boolean).join('\n\n');
|
|
4412
4534
|
const copied = await copyToClipboard(text);
|
|
4413
4535
|
showToast(copied ? `Copied ${pendingRequests.length} request(s) — paste to your terminal AI` : 'copy failed', copied ? 'ok' : 'err');
|
|
4414
4536
|
};
|
|
@@ -4561,6 +4683,7 @@
|
|
|
4561
4683
|
loadRequests,
|
|
4562
4684
|
copyToClipboard,
|
|
4563
4685
|
instructionFor,
|
|
4686
|
+
markedInstruction, // marked (paste-safe) form for clipboard copies — panels.js uses it (#73)
|
|
4564
4687
|
};
|
|
4565
4688
|
|
|
4566
4689
|
// ── boot ──────────────────────────────────────────────────────────────────────
|
package/dist/web/index.html
CHANGED
|
@@ -95,6 +95,12 @@
|
|
|
95
95
|
<span class="role" id="center-panel-role">transparency layer · read-mostly</span>
|
|
96
96
|
</span>
|
|
97
97
|
</div>
|
|
98
|
+
<div class="canvas-drop-target" id="canvas-drop-target" aria-hidden="true">
|
|
99
|
+
<div class="canvas-drop-icon" aria-hidden="true">⤓</div>
|
|
100
|
+
<div class="canvas-drop-label">Drop a .flo file to import</div>
|
|
101
|
+
<div class="canvas-drop-sub">The workflow installs and is added to your picker</div>
|
|
102
|
+
</div>
|
|
103
|
+
<input type="file" id="import-file" accept=".flo,.app,.flow,.aware" hidden>
|
|
98
104
|
<div class="find-overlay" id="find-overlay">
|
|
99
105
|
<input type="text" id="find-input" placeholder="Find agent…">
|
|
100
106
|
<span class="find-count" id="find-count"></span>
|
|
@@ -229,6 +235,10 @@
|
|
|
229
235
|
<span class="menu-label">Save inputs</span>
|
|
230
236
|
<span class="menu-kbd">Ctrl+S</span>
|
|
231
237
|
</button>
|
|
238
|
+
<button class="menu-item" data-action="import" role="menuitem">
|
|
239
|
+
<span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3v12m0 0-4-4m4 4 4-4"/><path d="M3 17v2a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2"/></svg></span>
|
|
240
|
+
<span class="menu-label">Import workflow…</span>
|
|
241
|
+
</button>
|
|
232
242
|
<div class="menu-divider"></div>
|
|
233
243
|
<button class="menu-item" data-action="find" role="menuitem">
|
|
234
244
|
<span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg></span>
|
|
@@ -347,7 +357,7 @@
|
|
|
347
357
|
|
|
348
358
|
<div class="modal-backdrop" id="add-fav-modal">
|
|
349
359
|
<div class="modal">
|
|
350
|
-
<div class="modal-title">Save as Template</div>
|
|
360
|
+
<div class="modal-title" id="add-fav-title">Save as Template</div>
|
|
351
361
|
<div class="modal-sub" id="add-fav-sub">Save this node as a reusable Template — usable in any workflow.</div>
|
|
352
362
|
<div class="modal-field">
|
|
353
363
|
<label for="fav-name">Name</label>
|
package/dist/web/panels.js
CHANGED
|
@@ -421,7 +421,9 @@
|
|
|
421
421
|
if (btn) { btn.disabled = true; btn.textContent = 'Queueing…'; }
|
|
422
422
|
try {
|
|
423
423
|
const { request } = await api('/api/extensions/customize', { method: 'POST', body: JSON.stringify({ instruction }) });
|
|
424
|
-
|
|
424
|
+
// Copy the MARKED form so a pasted ui-customize request is recognized, not run verbatim (#73).
|
|
425
|
+
const line = bridge.markedInstruction ? bridge.markedInstruction(request)
|
|
426
|
+
: bridge.instructionFor ? bridge.instructionFor(request) : instruction;
|
|
425
427
|
const copied = bridge.copyToClipboard ? await bridge.copyToClipboard(line) : false;
|
|
426
428
|
showToast(copied ? 'Queued for your terminal AI · copied to clipboard' : 'Queued for your terminal AI', 'ok');
|
|
427
429
|
if (input) input.value = '';
|
|
@@ -544,14 +546,14 @@
|
|
|
544
546
|
setTimeout(() => $cancel.focus(), 0);
|
|
545
547
|
const done = (result) => {
|
|
546
548
|
$resetModal.classList.remove('show');
|
|
547
|
-
$confirm.onclick = $cancel.onclick = $resetModal.onclick = null;
|
|
549
|
+
$confirm.onclick = $cancel.onclick = $resetModal.onclick = $resetModal.onmousedown = null;
|
|
548
550
|
document.removeEventListener('keydown', onKey, true);
|
|
549
551
|
resolve(result);
|
|
550
552
|
};
|
|
551
553
|
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } };
|
|
552
554
|
$confirm.onclick = () => done(true);
|
|
553
555
|
$cancel.onclick = () => done(false);
|
|
554
|
-
$resetModal
|
|
556
|
+
onBackdropDismiss($resetModal, () => done(false));
|
|
555
557
|
document.addEventListener('keydown', onKey, true);
|
|
556
558
|
});
|
|
557
559
|
}
|