@floless/app 0.7.0 → 0.8.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.
@@ -51392,6 +51392,49 @@ function parseRunHandle(stdout) {
51392
51392
  function traceLogPath(id, instance, runId, logsRoot = LOGS_DIR) {
51393
51393
  return (0, import_node_path3.join)(logsRoot, id, instance, `${runId}.jsonl`);
51394
51394
  }
51395
+ function latestTracePath(id, logsRoot = LOGS_DIR) {
51396
+ const appDir2 = (0, import_node_path3.join)(logsRoot, id);
51397
+ if (!(0, import_node_fs4.existsSync)(appDir2)) return null;
51398
+ let best = null;
51399
+ let instances;
51400
+ try {
51401
+ instances = (0, import_node_fs4.readdirSync)(appDir2);
51402
+ } catch {
51403
+ return null;
51404
+ }
51405
+ for (const instance of instances) {
51406
+ const instDir = (0, import_node_path3.join)(appDir2, instance);
51407
+ let entries;
51408
+ try {
51409
+ if (!(0, import_node_fs4.statSync)(instDir).isDirectory()) continue;
51410
+ entries = (0, import_node_fs4.readdirSync)(instDir);
51411
+ } catch {
51412
+ continue;
51413
+ }
51414
+ for (const f of entries) {
51415
+ if (!f.endsWith(".jsonl")) continue;
51416
+ const p = (0, import_node_path3.join)(instDir, f);
51417
+ try {
51418
+ const m = (0, import_node_fs4.statSync)(p).mtimeMs;
51419
+ if (!best || m > best.mtimeMs) best = { path: p, mtimeMs: m };
51420
+ } catch {
51421
+ }
51422
+ }
51423
+ }
51424
+ return best ? best.path : null;
51425
+ }
51426
+ function readLatestTrace(id, logsRoot = LOGS_DIR) {
51427
+ const p = latestTracePath(id, logsRoot);
51428
+ if (!p) return null;
51429
+ let text;
51430
+ try {
51431
+ text = (0, import_node_fs4.readFileSync)(p, "utf8");
51432
+ } catch {
51433
+ return null;
51434
+ }
51435
+ const runId = p.split(/[\\/]/).pop()?.replace(/\.jsonl$/, "") ?? "";
51436
+ return { runId, path: p, text };
51437
+ }
51395
51438
  function startTrigger(id, opts = {}) {
51396
51439
  assertId(id);
51397
51440
  const invoker = currentInvoker();
@@ -52053,6 +52096,9 @@ function signInUrl() {
52053
52096
  function updateApiBase() {
52054
52097
  return `${env().apiBase}/updates/releases`;
52055
52098
  }
52099
+ function apiBaseUrl() {
52100
+ return env().apiBase;
52101
+ }
52056
52102
  async function accessToken() {
52057
52103
  return ensureFreshToken();
52058
52104
  }
@@ -52308,7 +52354,7 @@ function appVersion() {
52308
52354
  return resolveVersion({
52309
52355
  isSea: isSea2(),
52310
52356
  sqVersionXml: readSqVersionXml(),
52311
- define: true ? "0.7.0" : void 0,
52357
+ define: true ? "0.8.0" : void 0,
52312
52358
  pkgVersion: readPkgVersion()
52313
52359
  });
52314
52360
  }
@@ -52318,7 +52364,7 @@ function resolveChannel(s) {
52318
52364
  return "dev";
52319
52365
  }
52320
52366
  function appChannel() {
52321
- return resolveChannel({ isSea: isSea2(), define: true ? "0.7.0" : void 0 });
52367
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.8.0" : void 0 });
52322
52368
  }
52323
52369
 
52324
52370
  // oauth-presets.ts
@@ -53844,6 +53890,69 @@ async function applyUpdate(check, opts) {
53844
53890
  return { applied: true, message: `updating to v${check.targetVersion}\u2026 the app will relaunch` };
53845
53891
  }
53846
53892
 
53893
+ // report-relay.ts
53894
+ var REPORT_TIMEOUT_MS = 15e3;
53895
+ async function reportIssue(input) {
53896
+ let token;
53897
+ try {
53898
+ token = await accessToken();
53899
+ } catch {
53900
+ return { ok: false, error: "offline" };
53901
+ }
53902
+ if (!token) return { ok: false, error: "signed_out" };
53903
+ const url = `${apiBaseUrl()}/issues`;
53904
+ let res;
53905
+ try {
53906
+ res = await authedFetch(url, {
53907
+ method: "POST",
53908
+ headers: { "Content-Type": "application/json" },
53909
+ body: JSON.stringify(input),
53910
+ signal: AbortSignal.timeout(REPORT_TIMEOUT_MS)
53911
+ });
53912
+ } catch {
53913
+ return { ok: false, error: "offline" };
53914
+ }
53915
+ if (res.status === 401) return { ok: false, error: "signed_out" };
53916
+ if (res.status === 429) return { ok: false, error: "rate_limited" };
53917
+ if (!res.ok) return { ok: false, error: "offline" };
53918
+ let ref = "";
53919
+ try {
53920
+ const b = await res.json();
53921
+ if (typeof b.ref === "string") ref = b.ref;
53922
+ } catch {
53923
+ }
53924
+ return { ok: true, ref };
53925
+ }
53926
+
53927
+ // run-summary.ts
53928
+ var clampMsg = (s) => {
53929
+ const t = s.trim();
53930
+ return t.length > 1e3 ? t.slice(0, 1e3) : t;
53931
+ };
53932
+ function summarizeRun(events) {
53933
+ const runEnd = [...events].reverse().find((e) => e.kind === "run-end");
53934
+ const status = runEnd && typeof runEnd.status === "string" ? runEnd.status : events.length ? "unknown" : "none";
53935
+ let errorNodeId = null;
53936
+ let errorMessage = null;
53937
+ for (const e of events) {
53938
+ if (e.kind === "node-error" && typeof e.node === "string") {
53939
+ errorNodeId = e.node;
53940
+ const err = e.error;
53941
+ errorMessage = typeof err === "string" && err.trim() ? clampMsg(err) : null;
53942
+ break;
53943
+ }
53944
+ const data = e.data;
53945
+ const result = data?.result ?? data;
53946
+ if (result && result.ok === false && typeof e.node === "string") {
53947
+ errorNodeId = e.node;
53948
+ const err = result.error;
53949
+ errorMessage = typeof err === "string" && err.trim() ? clampMsg(err) : null;
53950
+ break;
53951
+ }
53952
+ }
53953
+ return { status, errorNodeId, errorMessage };
53954
+ }
53955
+
53847
53956
  // launch.mjs
53848
53957
  var import_node_child_process5 = require("node:child_process");
53849
53958
  var import_node_path14 = require("node:path");
@@ -54321,13 +54430,15 @@ var import_yaml5 = __toESM(require_dist6(), 1);
54321
54430
  // build/ship-skills.mjs
54322
54431
  var PRODUCT_SKILLS = [
54323
54432
  "floless-app-bridge",
54324
- // drive the floless.app CLI / desktop bridge from the user's AI
54433
+ // drive the floless.app CLI / desktop bridge from the user's AI
54325
54434
  "floless-app-onboarding",
54326
- // guided, re-runnable tour of AWARE + floless.app for new users
54435
+ // guided, re-runnable tour of AWARE + floless.app for new users
54436
+ "floless-app-report-issue",
54437
+ // file a diagnosed bug/idea/question to the private log via the floless.io relay
54327
54438
  "floless-app-routines",
54328
- // author event-driven ("on trigger") routines
54439
+ // author event-driven ("on trigger") routines
54329
54440
  "floless-app-workflows"
54330
- // author/run .flo workflows
54441
+ // author/run .flo workflows
54331
54442
  ];
54332
54443
  function selectShippedSkillNames(names) {
54333
54444
  const present = new Set(names);
@@ -56318,7 +56429,10 @@ async function startServer() {
56318
56429
  });
56319
56430
  app.addHook("onRequest", async (req, reply) => {
56320
56431
  if (!req.url.startsWith("/api/")) return;
56321
- if (req.url.startsWith("/api/health") || req.url.startsWith("/api/license/") || req.url.startsWith("/api/bootstrap/") || req.url.startsWith("/api/autostart") || req.url.startsWith("/api/update") || req.url.startsWith("/api/aware/update")) return;
56432
+ if (req.url.startsWith("/api/health") || req.url.startsWith("/api/license/") || req.url.startsWith("/api/bootstrap/") || req.url.startsWith("/api/autostart") || // Report-an-issue is a support lifeline: a user whose subscription lapsed must still be
56433
+ // able to report "I got locked out". It touches no workspace data and the relay still
56434
+ // requires a valid SESSION token (so it's not open abuse — see report-relay.ts).
56435
+ req.url.startsWith("/api/report-issue") || req.url.startsWith("/api/update") || req.url.startsWith("/api/aware/update")) return;
56322
56436
  const { state: state2, signInUrl: signInUrl2 } = await getLicenseStatus();
56323
56437
  if (state2 !== "valid" && state2 !== "offline-grace") {
56324
56438
  return reply.status(402).send({ ok: false, error: "subscription required", state: state2, signInUrl: signInUrl2 });
@@ -56355,6 +56469,24 @@ async function startServer() {
56355
56469
  logout();
56356
56470
  return { ok: true };
56357
56471
  });
56472
+ app.post(
56473
+ "/api/report-issue",
56474
+ async (req, reply) => {
56475
+ const b = req.body ?? {};
56476
+ const category = b.category === "idea" || b.category === "question" ? b.category : "bug";
56477
+ const title = typeof b.title === "string" ? b.title.trim() : "";
56478
+ const body = typeof b.body === "string" ? b.body.trim() : "";
56479
+ if (!title || !body) {
56480
+ return reply.status(400).send({ ok: false, error: "title and body are required" });
56481
+ }
56482
+ const clamp = (s, n) => s.length > n ? s.slice(0, n) : s;
56483
+ const context = b.context && typeof b.context === "object" && !Array.isArray(b.context) && JSON.stringify(b.context).length <= 16e3 ? b.context : void 0;
56484
+ const result = await reportIssue({ category, title: clamp(title, 200), body: clamp(body, 12e3), context });
56485
+ if (result.ok) return { ok: true, ref: result.ref };
56486
+ const status = result.error === "signed_out" ? 401 : result.error === "rate_limited" ? 429 : 503;
56487
+ return reply.status(status).send({ ok: false, error: result.error });
56488
+ }
56489
+ );
56358
56490
  app.get("/api/autostart", async () => {
56359
56491
  const supported = autostartSupported();
56360
56492
  return { ok: true, supported, enabled: supported ? autostartPresent() : false };
@@ -56614,6 +56746,30 @@ async function startServer() {
56614
56746
  broadcast({ type: "grafted", id: stage.agentId });
56615
56747
  return { ok: true, agentId: stage.agentId };
56616
56748
  });
56749
+ async function detectCredentialIssue(id) {
56750
+ try {
56751
+ const app2 = readApp(id);
56752
+ const agents = new Set(app2.nodes.map((n) => n.agent).filter((a) => !!a));
56753
+ if (!agents.size) return null;
56754
+ const live = await aware.connectList();
56755
+ for (const c of live) {
56756
+ if (agents.has(c.integration) && c.status !== "valid") {
56757
+ return { id: c.integration, label: friendlyIntegrationName(c.integration) };
56758
+ }
56759
+ }
56760
+ } catch {
56761
+ }
56762
+ return null;
56763
+ }
56764
+ function hasAuthFailure(events) {
56765
+ for (const ev of events) {
56766
+ const data = ev.data;
56767
+ const result = data?.result ?? data;
56768
+ const status = result && typeof result.status === "number" ? result.status : null;
56769
+ if (status === 401 || status === 403) return true;
56770
+ }
56771
+ return false;
56772
+ }
56617
56773
  app.post(
56618
56774
  "/api/run",
56619
56775
  async (req, reply) => {
@@ -56633,7 +56789,8 @@ async function startServer() {
56633
56789
  }
56634
56790
  const stderr = detail && typeof detail.stderr === "string" ? detail.stderr.trim() : "";
56635
56791
  app.log.warn({ detail: err.detail }, err.message);
56636
- return reply.send({ ok: false, error: err.message, reason: stderr || null, simulate: !!simulate });
56792
+ const credentialIssue2 = simulate ? null : await detectCredentialIssue(id);
56793
+ return reply.send({ ok: false, error: err.message, reason: stderr || null, simulate: !!simulate, credentialIssue: credentialIssue2 });
56637
56794
  }
56638
56795
  throw err;
56639
56796
  }
@@ -56642,13 +56799,26 @@ async function startServer() {
56642
56799
  for (const ev of events) broadcast({ type: "trace", id, event: ev });
56643
56800
  broadcast({ type: "run-ended", id, runId: result.runId });
56644
56801
  const report = extractReportHtml(events);
56645
- return { ok: true, runId: result.runId, tracePath: result.tracePath, events, report };
56802
+ const credentialIssue = !simulate && hasAuthFailure(events) ? await detectCredentialIssue(id) : null;
56803
+ return { ok: true, runId: result.runId, tracePath: result.tracePath, events, report, credentialIssue };
56646
56804
  }
56647
56805
  );
56648
56806
  app.post("/api/run/stop", async () => {
56649
56807
  const running = cancelActiveRun();
56650
56808
  return { ok: true, running };
56651
56809
  });
56810
+ app.get("/api/latest-run/:id", async (req, reply) => {
56811
+ const id = req.params.id;
56812
+ if (!/^[A-Za-z0-9._-]+$/.test(id)) {
56813
+ return reply.status(400).send({ ok: false, error: "invalid app id" });
56814
+ }
56815
+ const latest = readLatestTrace(id);
56816
+ if (!latest) {
56817
+ return { ok: true, id, runId: null, status: "none", errorNodeId: null, errorMessage: null, events: [] };
56818
+ }
56819
+ const events = parseTrace(latest.text);
56820
+ return { ok: true, id, runId: latest.runId, ...summarizeRun(events), events };
56821
+ });
56652
56822
  function resolveArgs(configArgs, inputs) {
56653
56823
  const out = {};
56654
56824
  for (const [k, v] of Object.entries(configArgs)) {
@@ -0,0 +1,127 @@
1
+ ---
2
+ name: floless-app-report-issue
3
+ description: File a diagnosed bug / idea / question from floless.app into the PRIVATE product log via the floless.io relay. Use when the user invokes /floless-app-report-issue, clicks "Report an issue" in the floless.app UI (a queued report or a copied prompt), or says "report this", "file a bug", "something broke in floless", "send feedback / a feature idea". It READS the live run evidence first (latest run trace, failing node + error, the .flo, exec code), offers a workaround when one exists, then composes a maintainer-ready report and sends it — always showing the exact text and confirming first. Read-only: it never re-runs a workflow node.
4
+ metadata:
5
+ version: 0.1.0
6
+ ---
7
+
8
+ # floless.app — report an issue
9
+
10
+ You are not a feedback form. You are the **on-device incident responder**: a capable AI already
11
+ running on the user's machine, with its hands on the exact thing that broke — the workflow, the
12
+ run trace, the `.flo`, the exec code. A report you file should arrive **diagnosed, not described**.
13
+
14
+ The destination is floless.app's **private** product log. End users can't file there directly
15
+ (it's a private repo), so the local server **relays** the report to floless.io, which files it
16
+ on their behalf under the signed-in session. You compose + send; you never touch GitHub directly.
17
+
18
+ ## Preconditions
19
+
20
+ - The local server is up: `curl -s http://127.0.0.1:4317/api/health` → `{"ok":true,…}` (fixed port
21
+ 4317; override `$PORT`). It returns `appVersion`, `awareVersion`, and `license`. If it's down,
22
+ the user starts it from the repo (`cd server && npm run dev`).
23
+ - A signed-in session is required to file (the relay authenticates with it). If a send returns
24
+ `signed_out`, tell the user to sign in (menu → the license state in the footer) and retry — do
25
+ not try to work around it.
26
+
27
+ ## Safety contract — READ-ONLY (non-negotiable)
28
+
29
+ Diagnosing must never change the user's model or data.
30
+
31
+ - **Never re-run an individual exec node.** Exec nodes run Roslyn C# against a **live Tekla/Trimble
32
+ host** and can mutate the model. There is no "re-run just this node" — don't invent one.
33
+ - **Only** offer a *full-workflow* re-run when **all** hold: the user explicitly asks, the app is
34
+ `runnable`, and the latest trace shows the previous run was **non-mutating** (read-only nodes).
35
+ Otherwise diagnose from the evidence already on disk.
36
+ - Everything below (`/api/health`, `/api/latest-run/:id`, `/api/app/:id`, reading the `.flo`/exec
37
+ source) is read-only. Stay there.
38
+
39
+ ## Discriminator + routing — keep the trackers clean
40
+
41
+ Everything from a user funnels into the **one private floless log**; the maintainer triages and
42
+ escalates from there. The user never has to decide "is this AWARE or floless?".
43
+
44
+ - File here for: anything in the floless.app experience — the UI, a workflow/app behaving wrong, a
45
+ confusing result, a feature idea, a question.
46
+ - The one exception (maintainers only): if it is *plainly* an AWARE **runtime** defect (a compiler
47
+ panic, a CLI bug) **and** you're operating with repo access, prefer `/aware-log-issue`
48
+ (→ `aware-aeco`). For an ordinary user, still file here — the maintainer escalates.
49
+
50
+ ## Workflow — read, diagnose, confirm, send
51
+
52
+ ```dot
53
+ digraph { "health + identify app" -> "read evidence (READ-ONLY)" -> "root-cause hypothesis" ->
54
+ "offer workaround" -> "compose report" -> "privacy scrub + SHOW + confirm" ->
55
+ "POST /api/report-issue" -> "report the ref" }
56
+ ```
57
+
58
+ 1. **Health + identify the app.** `GET /api/health` for versions + license. Establish which app/
59
+ workflow the report is about (ask if unclear, or take it from a queued UI request).
60
+ 2. **Read the evidence (the killer step — READ-ONLY):**
61
+ - `GET /api/latest-run/<appId>` → structured trace: `runId`, `status`, `errorNodeId`,
62
+ `errorMessage`, `events`. This is the spine of the diagnosis.
63
+ - `GET /api/app/<appId>` → the normalized topology + `.flo` source + each node's `config`
64
+ (incl. exec `code`). Read the failing node's code; pin `file:line`.
65
+ - Form a concrete **root-cause hypothesis** — not "it failed", but *why*.
66
+ 3. **Offer a fix/workaround in the moment** when the evidence supports one. The user often leaves
67
+ already unstuck — that's the point.
68
+ 4. **Compose** the report from the template below: a diagnosed body, problem-first title.
69
+ 5. **Privacy scrub + confirm (ALWAYS).** The private log is still real: include versions + a
70
+ sanitized summary by default. **Ask before** attaching the `.flo`, raw logs, a screenshot, or
71
+ any model/company names. **Show the user the exact title + body**, then wait for an explicit
72
+ yes. Nothing sends without it.
73
+ 6. **Send** via the local relay:
74
+ ```bash
75
+ curl -s -X POST http://127.0.0.1:4317/api/report-issue \
76
+ -H 'Content-Type: application/json' \
77
+ -d '{"category":"bug","title":"…","body":"…","context":{ "appId":"…","appVersion":"…","awareVersion":"…","runId":"…","errorNodeId":"…" }}'
78
+ ```
79
+ → `{ "ok": true, "ref": "#123" }`. Errors: `signed_out` (sign in), `rate_limited` (wait), or
80
+ `offline` (check connection) — relay them plainly; the user's text is not lost.
81
+ 7. **Report** the `ref` to the user, plus any workaround you already gave. Because the repo is
82
+ private, there is no link to open — say so; the maintainer follows up.
83
+
84
+ ### Local API
85
+
86
+ | Call | Purpose |
87
+ |---|---|
88
+ | `GET /api/health` | versions + license state |
89
+ | `GET /api/latest-run/:id` | **read-only** structured trace of the app's most recent run |
90
+ | `GET /api/app/:id` | normalized topology + `.flo` source + node config/exec code |
91
+ | `POST /api/report-issue` | send the composed report through the relay → `{ ok, ref }` |
92
+
93
+ ## Report body template
94
+
95
+ ```
96
+ **Summary** — <one line, problem-first>
97
+
98
+ **Observed** (with evidence)
99
+ - <what happened> — node `<errorNodeId>` · `<errorMessage>`
100
+ - <file:line in the exec code, quoted, when relevant>
101
+
102
+ **Root-cause hypothesis**
103
+ - <why it happened — your diagnosis, marked as hypothesis if unconfirmed>
104
+
105
+ **Repro**
106
+ - app `<appId>` (FloLess v<appVersion>, AWARE v<awareVersion>), run `<runId>`
107
+ - <steps / inputs> ← attach the .flo only with consent
108
+
109
+ **Expected**
110
+ - <what AWARE/floless should have done>
111
+
112
+ **Workaround given** (if any)
113
+ - <what you told the user to do right now>
114
+ ```
115
+
116
+ The local route stamps category → label and the relay adds `user-report` + the reporter id.
117
+
118
+ ## Guardrails
119
+
120
+ - **Read-only.** Diagnose from evidence on disk; never re-run a node (live-host mutation risk).
121
+ - **Relay only.** This skill never calls `gh` — the user has no access to the private repo; the
122
+ relay files on their behalf. (The maintainer `gh` runbook is the separate `floless-app-log-issue`.)
123
+ - **Always confirm** the exact text before sending; default to versions + sanitized summary, and
124
+ ask before attaching anything richer.
125
+ - **Don't invent** node ids, errors, or a fix you can't support from the evidence. A wrong-but-
126
+ confident diagnosis is worse than an honest "couldn't reproduce — here's what I see."
127
+ - **One report per concern.** Don't bundle a bug and a feature idea into one.
package/dist/web/app.css CHANGED
@@ -987,6 +987,9 @@
987
987
  background: var(--accent-bright);
988
988
  box-shadow: 0 0 14px var(--accent-glow);
989
989
  }
990
+ /* A disabled action reads as inert — never a primary that looks clickable but isn't. */
991
+ .modal-actions button:disabled { opacity: 0.5; cursor: default; }
992
+ .modal-actions button.primary:disabled:hover { background: var(--accent); box-shadow: none; }
990
993
  .modal.library {
991
994
  width: 720px;
992
995
  max-height: 82vh;
@@ -2065,6 +2068,42 @@ body {
2065
2068
  }
2066
2069
  .report-overlay .overlay-stop:disabled { opacity: 0.6; cursor: default; }
2067
2070
 
2071
+ /* Credential-failure reconnect card — shown in .report-overlay when a run hits a
2072
+ rejected OAuth token. Amber-topped (recoverable, needs attention); the Reconnect
2073
+ action keeps primary-blue (the one critical next step), Open Integrations is ghost. */
2074
+ .cred-fail-card {
2075
+ background: var(--surface); border: 1px solid var(--border-strong); border-top: 3px solid var(--warn);
2076
+ border-radius: 8px; padding: 22px 24px; max-width: 380px; width: 100%;
2077
+ display: flex; flex-direction: column; gap: 14px; text-align: left;
2078
+ }
2079
+ .cred-fail-status {
2080
+ display: flex; align-items: center; gap: 8px;
2081
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-dim);
2082
+ }
2083
+ .cred-fail-icon { color: var(--warn); font-size: 16px; line-height: 1; }
2084
+ .cred-fail-headline { font-size: 15px; font-weight: 600; color: var(--text); }
2085
+ .cred-fail-body { font-size: 13px; color: var(--text-muted); line-height: 1.55; }
2086
+ .cred-fail-err { font-size: 12px; color: var(--err); }
2087
+ .cred-fail-ok { font-size: 13px; color: var(--ok); font-weight: 600; }
2088
+ .cred-fail-actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
2089
+ .cred-fail-actions button { font-family: var(--ui); font-size: 13px; line-height: 1.2; border-radius: 4px; cursor: pointer; }
2090
+ .cred-fail-actions .cred-reconnect {
2091
+ padding: 7px 16px; background: var(--accent); color: white; border: 1px solid var(--accent); font-weight: 600;
2092
+ }
2093
+ .cred-fail-actions .cred-reconnect:hover:not(:disabled) {
2094
+ background: var(--accent-bright); box-shadow: 0 0 14px var(--accent-glow);
2095
+ }
2096
+ .cred-fail-actions .cred-reconnect:disabled { opacity: 0.7; cursor: default; }
2097
+ .cred-fail-actions .cred-open-integrations {
2098
+ padding: 7px 12px; background: transparent; border: 1px solid var(--border-strong); color: var(--text-muted);
2099
+ }
2100
+ .cred-fail-actions .cred-open-integrations:hover { color: var(--text); border-color: var(--accent-dim); }
2101
+ .cred-reconnect-spinner {
2102
+ display: inline-block; width: 12px; height: 12px; vertical-align: -2px; margin-right: 6px;
2103
+ border: 2px solid rgba(255, 255, 255, 0.3); border-top-color: #fff; border-radius: 50%;
2104
+ animation: spin 0.8s linear infinite;
2105
+ }
2106
+
2068
2107
  /* Report + input nodes on the canvas carry a first-class action button
2069
2108
  ("View report ▸" / "Set inputs ▸"). Double-click still works as a shortcut. */
2070
2109
  .agent-card.report-node,
package/dist/web/app.js CHANGED
@@ -972,6 +972,7 @@ function handleMenuAction(action) {
972
972
  case 'find': openFind(); break;
973
973
  case 'integrations': openIntegrations(); break;
974
974
  case 'routines': openRoutines(); break;
975
+ case 'report-issue': openReportIssue(); break;
975
976
  }
976
977
  }
977
978
 
package/dist/web/aware.js CHANGED
@@ -106,7 +106,11 @@
106
106
  ...opts,
107
107
  });
108
108
  const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
109
- if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`);
109
+ if (!res.ok || body.ok === false) {
110
+ const err = new Error(body.error || `HTTP ${res.status}`);
111
+ err.body = body; // preserve the in-band payload (e.g. credentialIssue) for callers
112
+ throw err;
113
+ }
110
114
  return body;
111
115
  }
112
116
 
@@ -959,6 +963,9 @@
959
963
  : await api('/api/run', { method: 'POST', body: JSON.stringify({ id: currentId, simulate: false, inputs }) });
960
964
  // Reflect the run in the Execution tab too.
961
965
  if (Array.isArray(res.events)) { liveTrace.length = 0; res.events.forEach(pushTrace); state.hasRun = true; if (state.currentTab === 'execution') renderInspect(); }
966
+ // A run can "succeed" yet carry a rejected token (the report would be an error
967
+ // body, not real data) — prompt reconnect instead of rendering the noise.
968
+ if (res.credentialIssue) { surfaceCredentialIssue(res.credentialIssue); return; }
962
969
  const html = res.report && res.report.html;
963
970
  if (!html) {
964
971
  $reportOverlay.innerHTML = `<div>Run completed but <code>${escapeHtml(nodeId)}</code> returned no <code>html</code>. Check the Execution tab.</div>`;
@@ -977,17 +984,115 @@
977
984
  $reportOverlay.innerHTML = `<div>Run cancelled. A node already running on the host may still finish there; click <strong>▶ Run workflow</strong> to run again.</div>`;
978
985
  showToast('Run cancelled', 'info');
979
986
  } else {
980
- // Expected, recoverable failure (host not attached, stale lock, …): show it
981
- // in the viewer overlay + a toast. No console.error — the server returns the
982
- // failure in-band, so a failed run isn't a console-worthy fault.
983
- $reportOverlay.innerHTML = `<div>Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.</div>`;
984
- showToast('Run failed: ' + msg, 'warn');
987
+ // Expected, recoverable failure. If it was a rejected token, prompt the
988
+ // user to reconnect that integration (not the misleading host hint).
989
+ const cred = e.body && e.body.credentialIssue;
990
+ if (cred) {
991
+ surfaceCredentialIssue(cred);
992
+ } else {
993
+ // Other recoverable failures (host not attached, stale lock, …): show it
994
+ // in the viewer overlay + a toast. No console.error — the server returns the
995
+ // failure in-band, so a failed run isn't a console-worthy fault.
996
+ $reportOverlay.innerHTML = `<div>Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.</div>`;
997
+ showToast('Run failed: ' + msg, 'warn');
998
+ }
985
999
  }
986
1000
  } finally {
987
1001
  reportRunning = false;
988
1002
  }
989
1003
  }
990
1004
 
1005
+ // ── Credential-reconnect card ──────────────────────────────────────────────
1006
+ // When a real run fails (or "succeeds" with a 401 body) because an integration's
1007
+ // OAuth token was rejected, the server returns `credentialIssue: {id,label}`. We
1008
+ // replace the misleading "check the host" message with a focused prompt to
1009
+ // reconnect that integration — generic across Trimble, M365, any OAuth provider.
1010
+ // Built with DOM nodes + textContent (no innerHTML) so the integration label can
1011
+ // never inject markup.
1012
+ let credReconnect = null; // { id, onSuccess, onFail } — latched while a card reconnect runs
1013
+
1014
+ function mkEl(tag, cls, text) {
1015
+ const n = document.createElement(tag);
1016
+ if (cls) n.className = cls;
1017
+ if (text != null) n.textContent = text;
1018
+ return n;
1019
+ }
1020
+
1021
+ const CRED_BODY_IDLE = 'Sign in again and then run the workflow — it takes about 30 seconds.';
1022
+
1023
+ function showCredentialReconnect(cred) {
1024
+ $reportFrame.srcdoc = '';
1025
+ $reportOverlay.hidden = false;
1026
+ $reportOverlay.replaceChildren();
1027
+
1028
+ const card = mkEl('div', 'cred-fail-card');
1029
+ card.setAttribute('role', 'status');
1030
+
1031
+ const status = mkEl('div', 'cred-fail-status');
1032
+ const icon = mkEl('span', 'cred-fail-icon', iconFor(cred.id));
1033
+ icon.setAttribute('aria-hidden', 'true');
1034
+ status.append(icon, document.createTextNode('SESSION EXPIRED'));
1035
+
1036
+ const headline = mkEl('div', 'cred-fail-headline', `Your ${cred.label} session expired`);
1037
+ const body = mkEl('div', 'cred-fail-body', CRED_BODY_IDLE);
1038
+
1039
+ const actions = mkEl('div', 'cred-fail-actions');
1040
+ const reconnect = mkEl('button', 'primary cred-reconnect', 'Reconnect');
1041
+ reconnect.type = 'button';
1042
+ const openInt = mkEl('button', 'cred-open-integrations', 'Open Integrations');
1043
+ openInt.type = 'button';
1044
+ actions.append(reconnect, openInt);
1045
+
1046
+ card.append(status, headline, body, actions);
1047
+ $reportOverlay.append(card);
1048
+ showModal($reportModal);
1049
+
1050
+ openInt.onclick = () => { hideModal($reportModal); openIntegrations(); };
1051
+ reconnect.onclick = () => credReconnectStart(cred, card, reconnect, body);
1052
+ reconnect.focus();
1053
+ }
1054
+
1055
+ function credReconnectStart(cred, card, btn, body) {
1056
+ const reset = (errMsg) => {
1057
+ credReconnect = null;
1058
+ btn.disabled = false; btn.textContent = 'Reconnect';
1059
+ body.textContent = CRED_BODY_IDLE;
1060
+ let e = card.querySelector('.cred-fail-err');
1061
+ if (!e) { e = mkEl('div', 'cred-fail-err'); body.after(e); }
1062
+ e.textContent = errMsg;
1063
+ };
1064
+ const oldErr = card.querySelector('.cred-fail-err'); if (oldErr) oldErr.remove();
1065
+ btn.disabled = true;
1066
+ btn.textContent = '';
1067
+ const sp = mkEl('span', 'cred-reconnect-spinner'); sp.setAttribute('aria-hidden', 'true');
1068
+ btn.append(sp, document.createTextNode('Signing in…'));
1069
+ body.textContent = 'Opening your browser to sign in — complete it there and this will update.';
1070
+
1071
+ credReconnect = {
1072
+ id: cred.id,
1073
+ onSuccess: () => {
1074
+ credReconnect = null;
1075
+ const actions = card.querySelector('.cred-fail-actions'); if (actions) actions.remove();
1076
+ body.replaceChildren(mkEl('span', 'cred-fail-ok', 'Connected — click ▶ Run workflow to continue.'));
1077
+ setTimeout(() => {
1078
+ if ($reportOverlay.querySelector('.cred-fail-card')) { $reportOverlay.hidden = true; $reportOverlay.replaceChildren(); }
1079
+ }, 2000);
1080
+ },
1081
+ onFail: () => reset('Sign-in failed — try again, or use the device code flow in Integrations.'),
1082
+ };
1083
+ api(`/api/connect/${encodeURIComponent(cred.id)}`, { method: 'POST', body: '{}' })
1084
+ .then((res) => { if (res && res.started === false) reset('Couldn’t start sign-in — open Integrations to retry.'); })
1085
+ .catch(() => reset('Sign-in failed — try again, or use the device code flow in Integrations.'));
1086
+ }
1087
+
1088
+ // Shared by both run paths: when the server flags a credential issue, show the
1089
+ // reconnect card + a quiet toast/narration instead of the generic failure copy.
1090
+ function surfaceCredentialIssue(cred) {
1091
+ showCredentialReconnect(cred);
1092
+ showToast(`Session expired — reconnect ${cred.label} to continue.`, 'warn');
1093
+ appendNarration(`Run stopped — your <strong>${escapeHtml(cred.label)}</strong> session expired. Reconnect and run again.`);
1094
+ }
1095
+
991
1096
  function showModal(el) { el.classList.add('show'); }
992
1097
  function hideModal(el) { el.classList.remove('show'); }
993
1098
 
@@ -1622,6 +1727,8 @@
1622
1727
  const res = await api('/api/run', { method: 'POST', body: JSON.stringify({ id: currentId, simulate, inputs: currentInputs() }) });
1623
1728
  // SSE usually fills liveTrace first; backfill from the response if it didn't.
1624
1729
  if (liveTrace.length === 0 && Array.isArray(res.events)) res.events.forEach(pushTrace);
1730
+ // A "succeeded" run carrying a rejected token → prompt reconnect, not a result.
1731
+ if (res.credentialIssue) { surfaceCredentialIssue(res.credentialIssue); return; }
1625
1732
  switchToExecution();
1626
1733
  const steps = liveTrace.filter((r) => r.kind === 'node-start').length;
1627
1734
  appendNarration(simulate
@@ -1634,12 +1741,18 @@
1634
1741
  showToast('Run cancelled', 'info');
1635
1742
  appendNarration('Run cancelled. Click <strong>▶ Run workflow</strong> to run again.');
1636
1743
  } else {
1637
- // A failed run is an expected, recoverable outcome (the server returns it
1638
- // in-band) — surface it as a toast + narration, not a console error.
1639
- showToast((simulate ? 'Simulate failed' : 'Run failed') + ': ' + msg, 'warn');
1640
- appendNarration(simulate
1641
- ? `Simulate couldn't validate <code>${escapeHtml(currentId)}</code> — it stubs each node from its output-schema, but exec nodes have none, so the cross-node templates render undefined. Use <strong>▶ Run workflow</strong> for a live run against the host.`
1642
- : `Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.`);
1744
+ const cred = e.body && e.body.credentialIssue;
1745
+ if (cred) {
1746
+ // A rejected token prompt reconnect for that integration, not the host hint.
1747
+ surfaceCredentialIssue(cred);
1748
+ } else {
1749
+ // A failed run is an expected, recoverable outcome (the server returns it
1750
+ // in-band) — surface it as a toast + narration, not a console error.
1751
+ showToast((simulate ? 'Simulate failed' : 'Run failed') + ': ' + msg, 'warn');
1752
+ appendNarration(simulate
1753
+ ? `Simulate couldn't validate <code>${escapeHtml(currentId)}</code> — it stubs each node from its output-schema, but exec nodes have none, so the cross-node templates render undefined. Use <strong>▶ Run workflow</strong> for a live run against the host.`
1754
+ : `Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.`);
1755
+ }
1643
1756
  }
1644
1757
  } finally {
1645
1758
  state.running = false;
@@ -2215,6 +2328,12 @@
2215
2328
  } else if (m.type === 'trigger-session-changed') {
2216
2329
  applyTriggerSnapshot(m.id, m.snapshot);
2217
2330
  } else if (m.type === 'connect-result') {
2331
+ // A run's credential-reconnect card may be waiting on this integration —
2332
+ // drive its success/fail state from the same SSE result.
2333
+ if (credReconnect && credReconnect.id === m.id) {
2334
+ if (m.status === 'connected') credReconnect.onSuccess();
2335
+ else credReconnect.onFail(m.error);
2336
+ }
2218
2337
  // The device-code sign-in resolved. On success, refresh so the card flips
2219
2338
  // to Connected; otherwise surface the outcome in the integration's slot.
2220
2339
  connecting.delete(m.id);
@@ -3147,6 +3266,188 @@
3147
3266
  else if ($routinesModal.classList.contains('show')) hideModal($routinesModal);
3148
3267
  });
3149
3268
 
3269
+ // ── Report an issue ──────────────────────────────────────────────────────────
3270
+ // The hamburger "Report an issue" item files into the PRIVATE product log via
3271
+ // POST /api/report-issue (the floless.io relay; report-relay.ts). The user gets a
3272
+ // confirmation ref, not a public link (private repo). The /floless-app-report-issue
3273
+ // skill is the richer terminal path (it reads run traces + the .flo to file a
3274
+ // diagnosed report). Modal elements are static in index.html, present when this runs.
3275
+ const $riModal = document.getElementById('report-issue-modal');
3276
+ const $riForm = document.getElementById('ri-form');
3277
+ const $riState = document.getElementById('ri-state');
3278
+ const $riTitle = document.getElementById('ri-title');
3279
+ const $riDesc = document.getElementById('ri-desc');
3280
+ const $riContext = document.getElementById('ri-context');
3281
+ const $riSend = document.getElementById('ri-send');
3282
+ const $riCancel = document.getElementById('ri-cancel');
3283
+ let riCategory = 'bug';
3284
+ let riSubmitting = false;
3285
+ let riAppVersion = ''; // captured from /api/health on open so the report actually carries
3286
+ let riAwareVersion = ''; // the versions the modal promises ("sent automatically: …")
3287
+
3288
+ const riSyncSend = () => { $riSend.disabled = !($riTitle.value.trim() && $riDesc.value.trim()); };
3289
+ const riClose = () => { if (!riSubmitting) hideModal($riModal); };
3290
+ const riSetCategory = (cat) => {
3291
+ riCategory = cat;
3292
+ $riModal.querySelectorAll('#ri-category .rtn-mode-btn').forEach((b) => {
3293
+ const on = b.dataset.cat === cat;
3294
+ b.classList.toggle('active', on);
3295
+ b.setAttribute('aria-pressed', String(on));
3296
+ });
3297
+ };
3298
+ // Build the post-submit states with safe DOM methods (textContent, not innerHTML).
3299
+ const riButton = (id, label, primary, onclick) => {
3300
+ const b = document.createElement('button');
3301
+ b.id = id;
3302
+ if (primary) b.className = 'primary';
3303
+ b.textContent = label;
3304
+ b.onclick = onclick;
3305
+ return b;
3306
+ };
3307
+ // A Lucide-style circle-check (stroke SVG, matching the menu icons) for the success state —
3308
+ // it actually means "received/done", vs an ad-hoc glyph. Inherits var(--ok) via currentColor.
3309
+ const riCheckIcon = () => {
3310
+ const NS = 'http://www.w3.org/2000/svg';
3311
+ const svg = document.createElementNS(NS, 'svg');
3312
+ svg.setAttribute('viewBox', '0 0 24 24');
3313
+ svg.setAttribute('width', '34');
3314
+ svg.setAttribute('height', '34');
3315
+ svg.setAttribute('fill', 'none');
3316
+ svg.setAttribute('stroke', 'currentColor');
3317
+ svg.setAttribute('stroke-width', '1.75');
3318
+ svg.setAttribute('stroke-linecap', 'round');
3319
+ svg.setAttribute('stroke-linejoin', 'round');
3320
+ svg.style.display = 'block';
3321
+ svg.style.margin = '0 auto';
3322
+ const circle = document.createElementNS(NS, 'circle');
3323
+ circle.setAttribute('cx', '12');
3324
+ circle.setAttribute('cy', '12');
3325
+ circle.setAttribute('r', '10');
3326
+ const check = document.createElementNS(NS, 'path');
3327
+ check.setAttribute('d', 'm9 12 2 2 4-4');
3328
+ svg.append(circle, check);
3329
+ return svg;
3330
+ };
3331
+ const riShowState = (...nodes) => {
3332
+ while ($riState.firstChild) $riState.removeChild($riState.firstChild);
3333
+ for (const n of nodes) $riState.appendChild(n);
3334
+ $riState.hidden = false;
3335
+ $riForm.hidden = true;
3336
+ };
3337
+
3338
+ openReportIssue = function openReportIssueReal() {
3339
+ riSubmitting = false;
3340
+ $riForm.hidden = false;
3341
+ $riState.hidden = true;
3342
+ while ($riState.firstChild) $riState.removeChild($riState.firstChild);
3343
+ $riTitle.value = '';
3344
+ $riDesc.value = '';
3345
+ riSetCategory('bug');
3346
+ riAppVersion = '';
3347
+ riAwareVersion = '';
3348
+ $riSend.disabled = true;
3349
+ $riSend.textContent = 'Send report';
3350
+ // Auto-attached diagnostics line — versions + the open workflow, read fresh from health.
3351
+ $riContext.textContent = 'Also sent automatically: app + AWARE version, and the open workflow.';
3352
+ api('/api/health').then((h) => {
3353
+ riAppVersion = h.appVersion || '';
3354
+ riAwareVersion = h.awareVersion || '';
3355
+ const app = currentId && apps.get(currentId);
3356
+ const wf = app ? ` · workflow "${app.displayName || currentId}"` : '';
3357
+ $riContext.textContent = `Also sent automatically: FloLess v${h.appVersion || '?'}, AWARE v${h.awareVersion || '?'}${wf}`;
3358
+ }).catch(() => { /* keep the generic line on a health blip */ });
3359
+ showModal($riModal);
3360
+ setTimeout(() => $riTitle.focus(), 0);
3361
+ };
3362
+
3363
+ function riRenderSuccess(ref) {
3364
+ const wrap = document.createElement('div');
3365
+ wrap.className = 'graft-success';
3366
+ const icon = document.createElement('div');
3367
+ icon.className = 'gs-icon';
3368
+ icon.setAttribute('aria-hidden', 'true');
3369
+ icon.appendChild(riCheckIcon());
3370
+ const msg = document.createElement('div');
3371
+ msg.className = 'gs-msg';
3372
+ msg.textContent = ref
3373
+ ? `Received — logged as ${ref} · We'll follow up privately.`
3374
+ : "Received · We'll follow up privately.";
3375
+ wrap.append(icon, msg);
3376
+ const actions = document.createElement('div');
3377
+ actions.className = 'modal-actions';
3378
+ const done = riButton('ri-done', 'Done', true, () => hideModal($riModal));
3379
+ actions.append(done);
3380
+ riShowState(wrap, actions);
3381
+ done.focus();
3382
+ }
3383
+
3384
+ function riRenderSignIn() {
3385
+ const sub = document.createElement('div');
3386
+ sub.className = 'modal-sub';
3387
+ sub.textContent = 'Sign in to send a report — your session has expired.';
3388
+ const actions = document.createElement('div');
3389
+ actions.className = 'modal-actions';
3390
+ const close = riButton('ri-signin-close', 'Close', false, () => hideModal($riModal));
3391
+ const signin = riButton('ri-signin-btn', 'Sign in', true, async () => {
3392
+ try { await api('/api/license/start', { method: 'POST' }); } catch { /* surfaced via license polling */ }
3393
+ hideModal($riModal);
3394
+ });
3395
+ actions.append(close, signin);
3396
+ riShowState(sub, actions);
3397
+ }
3398
+
3399
+ async function riSubmit() {
3400
+ if (riSubmitting) return;
3401
+ const title = $riTitle.value.trim();
3402
+ const body = $riDesc.value.trim();
3403
+ if (!title || !body) return;
3404
+ riSubmitting = true;
3405
+ $riSend.disabled = true;
3406
+ $riSend.textContent = 'Sending…';
3407
+ // Versions are filled by the health fetch on open; if a fast submit beat it, fetch them
3408
+ // now so the report carries the versions the modal promised ("sent automatically: …").
3409
+ if (!riAppVersion || !riAwareVersion) {
3410
+ try {
3411
+ const h = await api('/api/health');
3412
+ riAppVersion = riAppVersion || h.appVersion || '';
3413
+ riAwareVersion = riAwareVersion || h.awareVersion || '';
3414
+ } catch { /* best-effort — send with whatever we have */ }
3415
+ }
3416
+ const app = currentId && apps.get(currentId);
3417
+ const context = {
3418
+ source: 'web',
3419
+ appId: currentId || null,
3420
+ workflow: app ? (app.displayName || currentId) : null,
3421
+ appVersion: riAppVersion || null,
3422
+ awareVersion: riAwareVersion || null,
3423
+ };
3424
+ try {
3425
+ const r = await api('/api/report-issue', { method: 'POST', body: JSON.stringify({ category: riCategory, title, body, context }) });
3426
+ riSubmitting = false; // response in hand — Done / Escape / backdrop may close
3427
+ riRenderSuccess(r.ref || '');
3428
+ } catch (e) {
3429
+ riSubmitting = false;
3430
+ const err = (e && e.body && e.body.error) || '';
3431
+ if (err === 'signed_out') {
3432
+ riRenderSignIn(); // the action isn't possible until signed in — hide the form, don't grey it out
3433
+ } else if (err === 'rate_limited') {
3434
+ showToast('Too many reports just now — please wait a moment and try again', 'warn');
3435
+ $riSend.disabled = false; $riSend.textContent = 'Send report';
3436
+ } else {
3437
+ showToast("Couldn't send — check your connection and try again", 'err');
3438
+ $riSend.disabled = false; $riSend.textContent = 'Send report';
3439
+ }
3440
+ }
3441
+ }
3442
+
3443
+ $riTitle.addEventListener('input', riSyncSend);
3444
+ $riDesc.addEventListener('input', riSyncSend);
3445
+ $riSend.onclick = () => riSubmit();
3446
+ $riCancel.onclick = () => riClose();
3447
+ $riModal.onclick = (e) => { if (e.target === $riModal) riClose(); };
3448
+ $riModal.querySelectorAll('#ri-category .rtn-mode-btn').forEach((b) => { b.onclick = () => riSetCategory(b.dataset.cat); });
3449
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $riModal.classList.contains('show')) riClose(); });
3450
+
3150
3451
  // ── init (after app.js bootstrap) ────────────────────────────────────────────
3151
3452
  // app.js already stored the ORIGINAL functions as .onclick/.oninput before we
3152
3453
  // reassigned the globals; rebind via arrows so they resolve our versions at
@@ -191,6 +191,11 @@
191
191
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/></svg></span>
192
192
  <span class="menu-label">Routines</span>
193
193
  </button>
194
+ <div class="menu-divider"></div>
195
+ <button class="menu-item" data-action="report-issue" role="menuitem">
196
+ <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/></svg></span>
197
+ <span class="menu-label">Report an issue</span>
198
+ </button>
194
199
  <!-- Start-on-login + Theme are sibling machine preferences (no divider between
195
200
  them — UX review 2026-05-31). Start-on-login toggles the per-user logon
196
201
  Scheduled Task that keeps the local server alive across logins; hidden unless
@@ -428,6 +433,40 @@
428
433
  </div>
429
434
  </div>
430
435
 
436
+ <!-- Report an issue — files into the PRIVATE product log via POST /api/report-issue
437
+ (the floless.io relay; report-relay.ts). The user gets a confirmation ref, not a
438
+ public link (the repo is private). JS swaps #ri-form for #ri-state (success / sign-in). -->
439
+ <div class="modal-backdrop" id="report-issue-modal">
440
+ <div class="modal report-issue">
441
+ <div class="modal-title">Report an issue</div>
442
+ <div class="modal-sub">Reports go to a private log — you won't get a public link to track it. We'll follow up with you if we need more details.</div>
443
+ <div id="ri-form">
444
+ <div class="modal-field">
445
+ <label>Category</label>
446
+ <div class="rtn-mode" id="ri-category" role="group" aria-label="Report category">
447
+ <button type="button" class="rtn-mode-btn active" data-cat="bug" aria-pressed="true">Bug</button>
448
+ <button type="button" class="rtn-mode-btn" data-cat="idea" aria-pressed="false">Idea</button>
449
+ <button type="button" class="rtn-mode-btn" data-cat="question" aria-pressed="false">Question</button>
450
+ </div>
451
+ </div>
452
+ <div class="modal-field">
453
+ <label for="ri-title">Title</label>
454
+ <input id="ri-title" type="text" placeholder="Summarise the issue in a few words" autocomplete="off">
455
+ </div>
456
+ <div class="modal-field">
457
+ <label for="ri-desc">Description</label>
458
+ <textarea id="ri-desc" placeholder="What happened? What did you expect? Steps to reproduce if it's a bug."></textarea>
459
+ </div>
460
+ <div class="integrations-hint" id="ri-context"></div>
461
+ <div class="modal-actions">
462
+ <button id="ri-cancel">Cancel</button>
463
+ <button id="ri-send" class="primary" disabled>Send report</button>
464
+ </div>
465
+ </div>
466
+ <div id="ri-state" hidden></div>
467
+ </div>
468
+ </div>
469
+
431
470
  <!-- Routine delete confirmation — destructive, so Cancel holds focus (Enter cancels)
432
471
  and the Delete button is danger-on-hover, never the accent primary. -->
433
472
  <div class="modal-backdrop" id="rtn-delete-modal">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {