@staix/agent-console 0.0.0-stage → 0.1.1
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/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +354 -2
- package/bin/acon.js +8 -0
- package/package.json +38 -4
- package/shell/asset-viewer.css +5 -0
- package/shell/asset-viewer.html +11 -0
- package/shell/asset-viewer.js +23 -0
- package/shell/icon.svg +1 -0
- package/shell/index.html +53 -0
- package/shell/shell.css +534 -0
- package/shell/shell.js +2214 -0
- package/shell/space-view.js +164 -0
- package/shell/space.js +241 -0
- package/shell/space3d.js +364 -0
- package/shell/static.js +111 -0
- package/shell/vendor/BufferGeometryUtils.js +1523 -0
- package/shell/vendor/GLTFLoader.js +5006 -0
- package/shell/vendor/NOTICE +30 -0
- package/shell/vendor/SkeletonUtils.js +496 -0
- package/shell/vendor/manifest.json +42 -0
- package/shell/vendor/mermaid/NOTICE +25 -0
- package/shell/vendor/mermaid/manifest.json +19 -0
- package/shell/vendor/mermaid/mermaid.js +3580 -0
- package/shell/vendor/three.core.js +61162 -0
- package/shell/vendor/three.module.js +19537 -0
- package/shell/visual-frame.css +5 -0
- package/shell/visual-frame.html +14 -0
- package/shell/visual-frame.js +54 -0
- package/src/assets.ts +200 -0
- package/src/canonical.ts +16 -0
- package/src/cli.ts +246 -0
- package/src/conformance.ts +299 -0
- package/src/contract.ts +276 -0
- package/src/install.ts +248 -0
- package/src/pets.ts +63 -0
- package/src/plugins.ts +357 -0
- package/src/serve.ts +184 -0
- package/src/server.ts +462 -0
- package/src/setup.ts +74 -0
- package/src/space.ts +106 -0
- package/src/static.ts +102 -0
- package/test/fixtures/review/cases.json +726 -0
package/shell/shell.js
ADDED
|
@@ -0,0 +1,2214 @@
|
|
|
1
|
+
// acon shell (H0). Vanilla ES module under a strict CSP: no inline code, and data is rendered as text, never as markup.
|
|
2
|
+
const NODE = "local";
|
|
3
|
+
const GROUPS = [["decision", "Needs your decision"], ["review", "To review"], ["result", "Results"], ["candidate", "Candidates"], ["alert", "Alerts"]];
|
|
4
|
+
const NEEDS_ME = new Set(["decision", "review", "candidate"]);
|
|
5
|
+
const TOOL_ORDER = ["ahub", "amem", "vmem"];
|
|
6
|
+
const TOKENS = ["bg", "surface", "surface-2", "line", "line-2", "text", "text-2", "accent", "accent-soft", "accent-text", "on-accent", "ok", "ok-soft", "warn", "warn-soft", "err", "err-soft", "focus"].map((t) => `--acon-${t}`);
|
|
7
|
+
const INBOX_MS = 5000;
|
|
8
|
+
const TOOLS_MS = 30000;
|
|
9
|
+
const PING_MS = 60000;
|
|
10
|
+
const SNOOZE_MS = 60 * 60 * 1000;
|
|
11
|
+
const ICONS = {
|
|
12
|
+
inbox: "M3 13h5l1.5 3h5L16 13h5M5.5 5h13L21 13v6H3v-6z",
|
|
13
|
+
agents: "M4 5h16v11H4zM8 20h8M12 16v4M8 9l2 2-2 2M13 13h3",
|
|
14
|
+
memory: "M12 3l9 5-9 5-9-5zM3 13l9 5 9-5",
|
|
15
|
+
vault: "M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3zM5 17a3 3 0 0 1 3-3h11",
|
|
16
|
+
system: "M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4",
|
|
17
|
+
box: "M4 7l8-4 8 4v10l-8 4-8-4zM4 7l8 4 8-4M12 11v10",
|
|
18
|
+
you: "M12 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8zM4 21a8 8 0 0 1 16 0",
|
|
19
|
+
review: "M20 6L9 17l-5-5",
|
|
20
|
+
candidate: "M12 5v14M5 12h14",
|
|
21
|
+
alert: "M12 3l10 18H2zM12 10v4M12 17.5v.5",
|
|
22
|
+
close: "M6 6l12 12M18 6L6 18",
|
|
23
|
+
go: "M5 12h14M13 6l6 6-6 6",
|
|
24
|
+
search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4",
|
|
25
|
+
popout: "M14 4h6v6M20 4l-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5",
|
|
26
|
+
brand: "M6 4h12a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3zM7 9l3 3-3 3M12 15h5",
|
|
27
|
+
};
|
|
28
|
+
const TOOL_ICON = { ahub: "agents", amem: "memory", vmem: "vault" };
|
|
29
|
+
const toolIcon = (t) => (t?.icon && ICONS[t.icon] ? t.icon : TOOL_ICON[t?.tool] ?? "box");
|
|
30
|
+
|
|
31
|
+
const $ = (id) => document.getElementById(id);
|
|
32
|
+
// Module references: these move into a Picture-in-Picture document with Mini, where getElementById cannot find them.
|
|
33
|
+
const confirmEl = $("confirm");
|
|
34
|
+
const state = {
|
|
35
|
+
tools: [], pets: [], assetPacks: [], space: new Map(), node: null, serverErrors: [], clientErrors: [],
|
|
36
|
+
route: { area: "inbox", params: {} },
|
|
37
|
+
inbox: new Map(), inboxSig: "", selectedKey: null, peekKey: null,
|
|
38
|
+
panels: new Map(),
|
|
39
|
+
lastOk: 0, offline: false, ended: false,
|
|
40
|
+
lastRequest: 0, nextInbox: 0, nextTools: 0,
|
|
41
|
+
confirmOpen: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ---------- DOM helpers ----------
|
|
45
|
+
function h(tag, props = {}, ...kids) {
|
|
46
|
+
const el = document.createElement(tag);
|
|
47
|
+
for (const [k, v] of Object.entries(props)) {
|
|
48
|
+
if (v === undefined || v === null || v === false) continue;
|
|
49
|
+
if (k === "class") el.className = v;
|
|
50
|
+
else if (k === "text") el.textContent = v;
|
|
51
|
+
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
|
|
52
|
+
else el.setAttribute(k, v === true ? "" : String(v));
|
|
53
|
+
}
|
|
54
|
+
for (const kid of kids.flat()) if (kid !== undefined && kid !== null && kid !== false) el.append(kid instanceof Node ? kid : String(kid));
|
|
55
|
+
return el;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------- public panel kit ----------
|
|
59
|
+
// This surface builds text, never markup. URL attributes and executable elements are not kit inputs.
|
|
60
|
+
const UI_TAGS = new Set("div span p strong em small code pre section article header footer h1 h2 h3 h4 ul ol li dl dt dd button input textarea select option form label fieldset legend details summary time hr br".split(" "));
|
|
61
|
+
const UI_ATTRS = new Set("class text id title role tabindex type name value placeholder required disabled readonly checked multiple autocomplete maxlength min max step for open datetime".split(" "));
|
|
62
|
+
function uiH(tag, props = {}, ...kids) {
|
|
63
|
+
if (!UI_TAGS.has(tag)) throw new TypeError("Unsupported panel kit tag");
|
|
64
|
+
for (const [key, value] of Object.entries(props)) {
|
|
65
|
+
if (key.startsWith("on") && typeof value === "function") continue;
|
|
66
|
+
if (!UI_ATTRS.has(key) && !/^aria-[a-z-]+$|^data-[a-z0-9-]+$/.test(key)) throw new TypeError("Unsupported panel kit attribute");
|
|
67
|
+
}
|
|
68
|
+
return h(tag, props, ...kids);
|
|
69
|
+
}
|
|
70
|
+
// Input bounds for table and charts; over-limit input is cut and a notice says so.
|
|
71
|
+
const UI_LIMITS = Object.freeze({ rows: 500, columns: 12, bars: 50, points: 365 });
|
|
72
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
73
|
+
function svgEl(tag, attrs = {}) {
|
|
74
|
+
const el = document.createElementNS(SVG_NS, tag);
|
|
75
|
+
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
|
76
|
+
return el;
|
|
77
|
+
}
|
|
78
|
+
const finite = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
79
|
+
const fmtNum = (v) => finite(v).toLocaleString("en-US", { maximumFractionDigits: 2 });
|
|
80
|
+
function fmtCell(v, format) {
|
|
81
|
+
if (v === undefined || v === null) return "";
|
|
82
|
+
if (typeof v === "number" && format === "number") return fmtNum(v);
|
|
83
|
+
if (typeof v === "number" && format === "percent") return `${fmtNum(v)}%`;
|
|
84
|
+
return String(v);
|
|
85
|
+
}
|
|
86
|
+
function cutNotice(what, shown, total) {
|
|
87
|
+
return total > shown ? consoleUi.notice(`Showing the first ${shown} of ${total} ${what}.`, "warn") : null;
|
|
88
|
+
}
|
|
89
|
+
const consoleUi = {
|
|
90
|
+
h: uiH,
|
|
91
|
+
rows(items, render) {
|
|
92
|
+
if (!items.length) return null;
|
|
93
|
+
return h("ul", { class: "rows ui-rows" }, items.map((item) => {
|
|
94
|
+
const row = render(item);
|
|
95
|
+
return row.tagName === "LI" ? row : h("li", {}, row);
|
|
96
|
+
}));
|
|
97
|
+
},
|
|
98
|
+
row(o) {
|
|
99
|
+
const main = h(o.onOpen ? "button" : "div", { class: `row-main${o.onOpen ? " ui-row-open" : ""}`, ...(o.onOpen ? { type: "button", onclick: o.onOpen } : {}) },
|
|
100
|
+
h("span", { class: "row-text" }, h("span", { class: "row-title", text: o.title }), o.sub ? h("span", { class: "row-sub", text: o.sub }) : null));
|
|
101
|
+
return h("li", { class: "row" }, main, o.chip ? consoleUi.chip(o.chip.text, o.chip.tone) : null,
|
|
102
|
+
o.verbs?.length ? h("div", { class: "row-verbs" }, o.verbs) : null);
|
|
103
|
+
},
|
|
104
|
+
empty(title, hint) {
|
|
105
|
+
return h("div", { class: "empty" }, h("strong", { text: title }), hint ? h("span", { text: hint }) : null);
|
|
106
|
+
},
|
|
107
|
+
chip(text, tone = "neutral") {
|
|
108
|
+
return h("span", { class: `chip ${toneClass(tone)}`, text });
|
|
109
|
+
},
|
|
110
|
+
button(label, onClick, o = {}) {
|
|
111
|
+
const tone = o.tone === "primary" || o.tone === "danger" ? ` ${o.tone}` : "";
|
|
112
|
+
return h("button", { type: "button", class: `btn${tone}`, disabled: Boolean(o.disabled), onclick: () => onClick(), text: label });
|
|
113
|
+
},
|
|
114
|
+
notice(text, tone = "neutral") {
|
|
115
|
+
return h("p", { class: `notice ui-notice ${toneClass(tone)}`, role: tone === "err" ? "alert" : "status", text });
|
|
116
|
+
},
|
|
117
|
+
form(fields, submit, onSubmit) {
|
|
118
|
+
const inputs = fields.map((field) => h("input", { type: "text", name: field.name, required: field.required, value: field.value ?? "" }));
|
|
119
|
+
const form = h("form", { class: "ui-form", onsubmit: (event) => {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
if (!form.reportValidity()) return;
|
|
122
|
+
onSubmit(Object.fromEntries(fields.map((field, i) => [field.name, inputs[i].value])));
|
|
123
|
+
} }, fields.map((field, i) => h("label", { class: "ui-field" }, h("span", { text: field.label }), inputs[i])),
|
|
124
|
+
h("button", { type: "submit", class: `btn${submit.primary ? " primary" : ""}`, text: submit.label }));
|
|
125
|
+
return form;
|
|
126
|
+
},
|
|
127
|
+
detail(o) {
|
|
128
|
+
return h("article", { class: "ui-detail" },
|
|
129
|
+
h("header", { class: "ui-detail-head" },
|
|
130
|
+
h("span", { class: "ui-detail-kind", text: o.kind }),
|
|
131
|
+
h("h2", { text: o.title }),
|
|
132
|
+
o.state ? consoleUi.chip(o.state.text, o.state.tone) : null,
|
|
133
|
+
o.whoseMove ? h("span", { class: "ui-detail-move", text: o.whoseMove === "you" ? "Your move" : "Agent's move" }) : null),
|
|
134
|
+
o.actions?.length ? h("div", { class: "ui-detail-actions", role: "group", "aria-label": "Next actions" }, o.actions) : null,
|
|
135
|
+
o.summary ? h("p", { class: "ui-detail-summary", text: o.summary }) : null,
|
|
136
|
+
o.provenance?.length ? h("dl", { class: "ui-provenance" }, o.provenance.flatMap((p) => [h("dt", { text: p.label }), h("dd", {}, h("span", { text: p.value }), p.at ? h("time", { datetime: p.at, text: p.at }) : null)])) : null,
|
|
137
|
+
...(o.sections ?? []).map((s) => h("details", { class: "ui-detail-section", open: s.open }, h("summary", { text: s.title }), h("div", { class: "ui-detail-body" }, s.body))),
|
|
138
|
+
o.footer?.length ? h("footer", { class: "ui-detail-footer mono" }, o.footer.map((text) => h("div", { text }))) : null);
|
|
139
|
+
},
|
|
140
|
+
table(o) {
|
|
141
|
+
const allColumns = Array.isArray(o.columns) ? o.columns : [];
|
|
142
|
+
const allRows = Array.isArray(o.rows) ? o.rows : [];
|
|
143
|
+
const columns = allColumns.slice(0, UI_LIMITS.columns);
|
|
144
|
+
const rows = allRows.slice(0, UI_LIMITS.rows);
|
|
145
|
+
const sortable = o.sortable !== false;
|
|
146
|
+
let sort = null;
|
|
147
|
+
const tbody = h("tbody");
|
|
148
|
+
const heads = columns.map((c) => {
|
|
149
|
+
const label = String(c.label ?? c.key ?? "");
|
|
150
|
+
const th = h("th", { scope: "col", class: c.align === "end" ? "end" : null });
|
|
151
|
+
if (!sortable) { th.append(label); return th; }
|
|
152
|
+
th.append(h("button", { type: "button", class: "ui-sort", onclick: () => {
|
|
153
|
+
sort = sort?.key === c.key && sort.dir === "ascending" ? { key: c.key, dir: "descending" } : { key: c.key, dir: "ascending" };
|
|
154
|
+
render();
|
|
155
|
+
} }, label, h("span", { class: "ui-sort-mark", "aria-hidden": "true" })));
|
|
156
|
+
return th;
|
|
157
|
+
});
|
|
158
|
+
const compare = (a, b) => (typeof a === "number" && typeof b === "number" ? a - b : String(a ?? "").localeCompare(String(b ?? ""), "en", { numeric: true }));
|
|
159
|
+
function render() {
|
|
160
|
+
const order = sort ? [...rows].sort((a, b) => (sort.dir === "ascending" ? 1 : -1) * compare(a?.[sort.key], b?.[sort.key])) : rows;
|
|
161
|
+
columns.forEach((c, i) => {
|
|
162
|
+
const th = heads[i];
|
|
163
|
+
if (sort?.key === c.key) th.setAttribute("aria-sort", sort.dir); else th.removeAttribute?.("aria-sort");
|
|
164
|
+
const mark = th.querySelector?.(".ui-sort-mark");
|
|
165
|
+
if (mark) mark.textContent = sort?.key === c.key ? (sort.dir === "ascending" ? " \u2191" : " \u2193") : "";
|
|
166
|
+
});
|
|
167
|
+
tbody.replaceChildren(...order.map((row) => h("tr", {}, columns.map((c, i) => h(i === 0 ? "th" : "td", {
|
|
168
|
+
...(i === 0 ? { scope: "row" } : {}), class: c.align === "end" ? "end" : null, text: fmtCell(row?.[c.key], c.format),
|
|
169
|
+
})))));
|
|
170
|
+
}
|
|
171
|
+
render();
|
|
172
|
+
const table = h("table", { class: "ui-table" }, o.caption ? h("caption", { text: o.caption }) : null, h("thead", {}, h("tr", {}, heads)), tbody);
|
|
173
|
+
return h("div", { class: "ui-table-block" },
|
|
174
|
+
cutNotice("columns", columns.length, allColumns.length), cutNotice("rows", rows.length, allRows.length),
|
|
175
|
+
h("div", { class: "ui-table-wrap", tabindex: "0", role: "region", "aria-label": o.caption || "Table" }, table));
|
|
176
|
+
},
|
|
177
|
+
bars(o) {
|
|
178
|
+
const all = Array.isArray(o.items) ? o.items : [];
|
|
179
|
+
const items = all.slice(0, UI_LIMITS.bars);
|
|
180
|
+
const max = finite(o.max) > 0 ? finite(o.max) : Math.max(1e-9, ...items.flatMap((i) => [finite(i.value), finite(i.target)]));
|
|
181
|
+
const pct = (v) => Math.max(0, Math.min(100, (finite(v) / max) * 100));
|
|
182
|
+
const list = h("ul", { class: "ui-bars" }, items.map((item) => {
|
|
183
|
+
const svg = svgEl("svg", { viewBox: "0 0 100 10", preserveAspectRatio: "none", class: "ui-bar-svg", "aria-hidden": "true", focusable: "false" });
|
|
184
|
+
svg.append(svgEl("rect", { class: "ui-bar-track", x: 0, y: 0, width: 100, height: 10 }), svgEl("rect", { class: "ui-bar-fill", x: 0, y: 0, width: pct(item.value), height: 10 }));
|
|
185
|
+
const hasTarget = typeof item.target === "number" && Number.isFinite(item.target);
|
|
186
|
+
if (hasTarget) svg.append(svgEl("line", { class: "ui-bar-target", x1: pct(item.target), x2: pct(item.target), y1: 0, y2: 10, "vector-effect": "non-scaling-stroke" }));
|
|
187
|
+
return h("li", { class: "ui-bar" },
|
|
188
|
+
h("span", { class: "ui-bar-label", text: String(item.label ?? "") }), svg,
|
|
189
|
+
h("span", { class: "ui-bar-value", text: hasTarget ? `${fmtNum(item.value)} (target ${fmtNum(item.target)})` : fmtNum(item.value) }));
|
|
190
|
+
}));
|
|
191
|
+
return h("figure", { class: "ui-chart ui-bars-chart", role: "figure", "aria-label": o.caption },
|
|
192
|
+
h("figcaption", { text: o.caption }), cutNotice("items", items.length, all.length), list);
|
|
193
|
+
},
|
|
194
|
+
spark(o) {
|
|
195
|
+
const all = (Array.isArray(o.values) ? o.values : []).map(finite);
|
|
196
|
+
const values = all.slice(-UI_LIMITS.points);
|
|
197
|
+
const lo = values.length ? Math.min(...values) : 0;
|
|
198
|
+
const hi = values.length ? Math.max(...values) : 0;
|
|
199
|
+
const span = hi - lo || 1;
|
|
200
|
+
const svg = svgEl("svg", { viewBox: "0 0 100 30", preserveAspectRatio: "none", class: "ui-spark-svg", "aria-hidden": "true", focusable: "false" });
|
|
201
|
+
if (values.length) {
|
|
202
|
+
const step = values.length > 1 ? 100 / (values.length - 1) : 0;
|
|
203
|
+
const points = values.map((v, i) => `${(i * step).toFixed(2)},${(28 - ((v - lo) / span) * 26).toFixed(2)}`);
|
|
204
|
+
if (values.length === 1) points.push(`100,${points[0].split(",")[1]}`);
|
|
205
|
+
svg.append(svgEl("polyline", { class: "ui-spark-line", points: points.join(" "), "vector-effect": "non-scaling-stroke" }));
|
|
206
|
+
}
|
|
207
|
+
const summary = values.length ? `${values.length} points, low ${fmtNum(lo)}, high ${fmtNum(hi)}, last ${fmtNum(values.at(-1))}` : "No data";
|
|
208
|
+
const cut = all.length > values.length ? consoleUi.notice(`Showing the last ${values.length} of ${all.length} points.`, "warn") : null;
|
|
209
|
+
return h("figure", { class: "ui-chart ui-spark", role: "figure", "aria-label": o.caption },
|
|
210
|
+
h("figcaption", {}, h("span", { text: o.caption }), h("span", { class: "ui-spark-summary", text: summary })), cut, svg,
|
|
211
|
+
h("ol", { class: "ui-sr" }, values.map((v) => h("li", { text: fmtNum(v) }))));
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
// Per-plugin kit: img, frame and link build their own same-origin URLs from the tool and an asset id or panel.
|
|
215
|
+
const ASSET_ID_MAX = 512;
|
|
216
|
+
const PANEL_ID = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
217
|
+
function assetId(to) {
|
|
218
|
+
if (!to || typeof to !== "object" || typeof to.asset !== "string" || !to.asset || to.asset.length > ASSET_ID_MAX) throw new TypeError("Panel kit assets take {asset: id}, never a URL");
|
|
219
|
+
return to.asset;
|
|
220
|
+
}
|
|
221
|
+
// ---------- review surface (ui.review) ----------
|
|
222
|
+
// Bounds from #28; larger input is cut with a notice. Anchors are built here, never written anywhere by acon.
|
|
223
|
+
const REVIEW = Object.freeze({ matches: 10000, bytes: 512 * 1024, lines: 20000, threads: 200, exact: 2000, context: 64, body: 4000, chunk: 500, eager: 1000 });
|
|
224
|
+
/** Rows of a review doc: text/prose lines, or parsed unified-diff rows with old/new numbers and their hunk. */
|
|
225
|
+
function reviewRows(doc) {
|
|
226
|
+
const source = String(doc.kind === "diff" ? doc.unified ?? "" : doc.text ?? "");
|
|
227
|
+
let text = source;
|
|
228
|
+
const notes = [];
|
|
229
|
+
if (text.length > REVIEW.bytes) { text = text.slice(0, REVIEW.bytes); notes.push(`Showing the first ${REVIEW.bytes / 1024} KB of ${Math.ceil(source.length / 1024)} KB.`); }
|
|
230
|
+
let lines = text.split("\n");
|
|
231
|
+
if (lines.length > 1 && lines.at(-1) === "" && text.endsWith("\n") && doc.kind === "diff") lines.pop();
|
|
232
|
+
if (lines.length > REVIEW.lines) { notes.push(`Showing the first ${REVIEW.lines} of ${lines.length} lines.`); lines = lines.slice(0, REVIEW.lines); }
|
|
233
|
+
if (doc.kind !== "diff") return { rows: lines.map((t, i) => ({ kind: "line", text: t, no: i + 1 })), notes };
|
|
234
|
+
const rows = [];
|
|
235
|
+
let hunk = null, oldNo = 0, newNo = 0;
|
|
236
|
+
for (const raw of lines) {
|
|
237
|
+
const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
|
|
238
|
+
if (m) { hunk = raw; oldNo = Number(m[1]); newNo = Number(m[2]); rows.push({ kind: "hunk", text: raw, hunk }); continue; }
|
|
239
|
+
if (!hunk || raw.startsWith("diff --git ")) { hunk = raw.startsWith("diff --git ") ? null : hunk; rows.push({ kind: "meta", text: raw }); continue; }
|
|
240
|
+
const sign = raw[0];
|
|
241
|
+
if (sign === "+") rows.push({ kind: "add", text: raw.slice(1), newNo: newNo++, hunk });
|
|
242
|
+
else if (sign === "-") rows.push({ kind: "del", text: raw.slice(1), oldNo: oldNo++, hunk });
|
|
243
|
+
else if (sign === " " || raw === "") rows.push({ kind: "ctx", text: raw.slice(1), oldNo: oldNo++, newNo: newNo++, hunk });
|
|
244
|
+
else rows.push({ kind: "meta", text: raw, hunk });
|
|
245
|
+
}
|
|
246
|
+
return { rows, notes };
|
|
247
|
+
}
|
|
248
|
+
const reviewSides = (row) => (row.kind === "line" ? ["doc"] : row.kind === "ctx" ? ["base", "head"] : row.kind === "add" ? ["head"] : row.kind === "del" ? ["base"] : []);
|
|
249
|
+
const reviewNo = (row, side) => (side === "doc" ? row.no : side === "head" ? row.newNo : row.oldNo);
|
|
250
|
+
/** Per side: the row indexes on that side, the joined text and each row's start offset in it. */
|
|
251
|
+
// Between rows that are not adjacent on a side (another hunk, or a jump in line numbers) the side text gets a
|
|
252
|
+
// separator no quote can span, so a quote never matches across a gap in a diff.
|
|
253
|
+
const REVIEW_GAP = "\n\uFFFF\n";
|
|
254
|
+
function reviewIndex(rows) {
|
|
255
|
+
const sides = {};
|
|
256
|
+
rows.forEach((row, i) => {
|
|
257
|
+
for (const key of reviewSides(row)) {
|
|
258
|
+
const side = sides[key] ??= { rows: [], starts: [], text: "", at: new Map(), lastHunk: undefined, lastNo: undefined };
|
|
259
|
+
const no = reviewNo(row, key);
|
|
260
|
+
if (side.rows.length) side.text += row.hunk !== side.lastHunk || no !== side.lastNo + 1 ? REVIEW_GAP : "\n";
|
|
261
|
+
side.at.set(i, side.rows.length);
|
|
262
|
+
side.rows.push(i); side.starts.push(side.text.length); side.text += row.text;
|
|
263
|
+
side.lastHunk = row.hunk; side.lastNo = no;
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
return sides;
|
|
267
|
+
}
|
|
268
|
+
function reviewRowAt(side, offset) {
|
|
269
|
+
let lo = 0, hi = side.starts.length - 1;
|
|
270
|
+
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (side.starts[mid] <= offset) lo = mid; else hi = mid - 1; }
|
|
271
|
+
return lo;
|
|
272
|
+
}
|
|
273
|
+
const sameProjection = (a, b) => (!a && !b) || (Boolean(a) && Boolean(b) && a.name === b.name && a.version === b.version);
|
|
274
|
+
/** Placement order: hash match by lineRange, else one textQuote match (prefix/suffix break ties), else unplaced. */
|
|
275
|
+
function reviewPlace(doc, rows, sides, thread, truncated = false) {
|
|
276
|
+
const anchor = thread?.anchor;
|
|
277
|
+
const none = { placed: false, via: null, rows: [] };
|
|
278
|
+
if (!anchor || typeof anchor !== "object" || !Array.isArray(anchor.selectors) || anchor.path !== doc.path) return none;
|
|
279
|
+
if (!sameProjection(anchor.projection, doc.projection)) return none;
|
|
280
|
+
const sideKey = doc.kind === "diff" ? (anchor.side === "base" ? "base" : "head") : "doc";
|
|
281
|
+
const side = sides[sideKey];
|
|
282
|
+
if (!side) return none;
|
|
283
|
+
const range = anchor.selectors.find((s) => s?.kind === "lineRange");
|
|
284
|
+
if (range && anchor.contentHash && anchor.contentHash === doc.contentHash && Number.isInteger(range.startLine) && Number.isInteger(range.endLine) && range.startLine <= range.endLine) {
|
|
285
|
+
const hit = side.rows.filter((i) => { const n = reviewNo(rows[i], sideKey); return n >= range.startLine && n <= range.endLine; });
|
|
286
|
+
if (hit.length && reviewNo(rows[hit[0]], sideKey) === range.startLine && reviewNo(rows[hit.at(-1)], sideKey) === range.endLine) return { placed: true, via: "hash", rows: hit };
|
|
287
|
+
}
|
|
288
|
+
// A cut document cannot prove a quote unique (it may repeat in the part not shown), so quotes do not place.
|
|
289
|
+
if (truncated) return none;
|
|
290
|
+
const quote = anchor.selectors.find((s) => s?.kind === "textQuote");
|
|
291
|
+
if (!quote || typeof quote.exact !== "string" || !quote.exact || quote.exact.includes("\uFFFF")) return none;
|
|
292
|
+
// Every occurrence counts: a quote with more than REVIEW.matches occurrences is ambiguous, never guessed.
|
|
293
|
+
const found = [];
|
|
294
|
+
for (let at = side.text.indexOf(quote.exact); at >= 0; at = side.text.indexOf(quote.exact, at + 1)) {
|
|
295
|
+
if (found.length === REVIEW.matches) return none;
|
|
296
|
+
found.push(at);
|
|
297
|
+
}
|
|
298
|
+
const prefix = typeof quote.prefix === "string" ? quote.prefix : "";
|
|
299
|
+
const suffix = typeof quote.suffix === "string" ? quote.suffix : "";
|
|
300
|
+
const pick = found.length === 1 ? found : found.filter((at) => side.text.slice(Math.max(0, at - prefix.length), at) === prefix && side.text.slice(at + quote.exact.length, at + quote.exact.length + suffix.length) === suffix);
|
|
301
|
+
if (pick.length !== 1) return none;
|
|
302
|
+
const first = reviewRowAt(side, pick[0]);
|
|
303
|
+
const last = reviewRowAt(side, pick[0] + quote.exact.length - (quote.exact.length ? 1 : 0));
|
|
304
|
+
return { placed: true, via: "quote", rows: side.rows.slice(first, last + 1) };
|
|
305
|
+
}
|
|
306
|
+
/** Anchor for rows [a, b] (row indexes), with optional character offsets in the first and last row. */
|
|
307
|
+
function reviewAnchor(doc, rows, sides, a, b, startChar, endChar) {
|
|
308
|
+
let picked = [];
|
|
309
|
+
let sideKey = "doc";
|
|
310
|
+
if (doc.kind === "diff") {
|
|
311
|
+
const hunk = rows[a].hunk;
|
|
312
|
+
const span = rows.slice(a, b + 1).map((row, k) => [row, a + k]).filter(([row]) => row.hunk === hunk && reviewSides(row).length);
|
|
313
|
+
// No added lines: anchor on base, so deleted lines stay in the quote; any addition anchors on head.
|
|
314
|
+
sideKey = span.some(([row]) => row.kind === "add") ? "head" : "base";
|
|
315
|
+
picked = span.filter(([row]) => reviewSides(row).includes(sideKey)).map(([, i]) => i);
|
|
316
|
+
if (!picked.length) return null;
|
|
317
|
+
} else picked = rows.slice(a, b + 1).map((_, k) => a + k);
|
|
318
|
+
const side = sides[sideKey];
|
|
319
|
+
const first = picked[0], last = picked.at(-1);
|
|
320
|
+
const startAt = side.starts[side.at.get(first)] + (first === a && startChar !== undefined ? Math.min(startChar, rows[first].text.length) : 0);
|
|
321
|
+
const endAt = side.starts[side.at.get(last)] + (last === b && endChar !== undefined ? Math.min(endChar, rows[last].text.length) : rows[last].text.length);
|
|
322
|
+
// A selection that spans a gap keeps only its first contiguous part as the quote.
|
|
323
|
+
let exact = side.text.slice(startAt, Math.max(startAt, endAt)).slice(0, REVIEW.exact);
|
|
324
|
+
if (exact.includes("\uFFFF")) exact = exact.slice(0, exact.indexOf("\uFFFF") - 1);
|
|
325
|
+
const selectors = [{ kind: "lineRange", startLine: reviewNo(rows[first], sideKey), endLine: reviewNo(rows[last], sideKey) }];
|
|
326
|
+
// A blank selection has no quote to keep (an empty exact is not a valid textQuote); the line range alone anchors it.
|
|
327
|
+
// Context stops at a gap, so prefix/suffix never carry text from another hunk or the separator itself.
|
|
328
|
+
const before = side.text.slice(Math.max(0, startAt - REVIEW.context), startAt);
|
|
329
|
+
const after = side.text.slice(startAt + exact.length, startAt + exact.length + REVIEW.context);
|
|
330
|
+
const prefix = before.includes("\uFFFF") ? before.slice(before.lastIndexOf("\uFFFF") + 2) : before;
|
|
331
|
+
const suffix = after.includes("\uFFFF") ? after.slice(0, after.indexOf("\uFFFF") - 1) : after;
|
|
332
|
+
if (exact) selectors.push({ kind: "textQuote", exact, prefix, suffix });
|
|
333
|
+
if (doc.kind === "diff") selectors.push({ kind: "diffHunk", hunkHeader: rows[first].hunk });
|
|
334
|
+
return {
|
|
335
|
+
path: doc.path, ...(doc.kind === "diff" ? { side: sideKey } : {}), contentHash: doc.contentHash,
|
|
336
|
+
...(doc.kind !== "diff" && doc.projection ? { projection: { name: doc.projection.name, version: doc.projection.version } } : {}),
|
|
337
|
+
selectors,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function reviewView(o, openSheet, onError) {
|
|
341
|
+
const doc = o?.doc ?? {};
|
|
342
|
+
if (!["text", "prose", "diff"].includes(doc.kind)) throw new TypeError("ui.review doc.kind must be text, prose or diff");
|
|
343
|
+
const { rows, notes } = reviewRows(doc);
|
|
344
|
+
const cut = notes.length > 0;
|
|
345
|
+
const sides = reviewIndex(rows);
|
|
346
|
+
const allThreads = Array.isArray(o.threads) ? o.threads : [];
|
|
347
|
+
const threads = allThreads.slice(0, REVIEW.threads);
|
|
348
|
+
if (allThreads.length > threads.length) notes.push(`Showing the first ${threads.length} of ${allThreads.length} comment threads.`);
|
|
349
|
+
// Units are what the gutter selects: a line, a diff row, or a prose paragraph.
|
|
350
|
+
const units = [];
|
|
351
|
+
if (doc.kind === "prose") {
|
|
352
|
+
let start = -1;
|
|
353
|
+
rows.forEach((row, i) => {
|
|
354
|
+
if (row.text.trim()) { if (start < 0) start = i; }
|
|
355
|
+
else if (start >= 0) { units.push({ a: start, b: i - 1 }); start = -1; }
|
|
356
|
+
});
|
|
357
|
+
if (start >= 0) units.push({ a: start, b: rows.length - 1 });
|
|
358
|
+
} else rows.forEach((row, i) => { if (reviewSides(row).length) units.push({ a: i, b: i }); });
|
|
359
|
+
const unitOf = new Map();
|
|
360
|
+
units.forEach((u, k) => { for (let i = u.a; i <= u.b; i++) unitOf.set(i, k); });
|
|
361
|
+
// Placement first, so marks are known before any chunk renders.
|
|
362
|
+
const marks = new Map();
|
|
363
|
+
const placed = [];
|
|
364
|
+
const unplaced = [];
|
|
365
|
+
const results = threads.map((thread) => {
|
|
366
|
+
const r = reviewPlace(doc, rows, sides, thread, cut);
|
|
367
|
+
const id = String(thread?.id ?? "");
|
|
368
|
+
// Prose units skip blank lines: a range that lands only on blank lines marks the next paragraph (else the
|
|
369
|
+
// previous one); with no paragraph at all it goes to the strip, so every thread is shown somewhere.
|
|
370
|
+
let ks = r.placed ? [...new Set(r.rows.map((i) => unitOf.get(i)).filter((k) => k !== undefined))] : [];
|
|
371
|
+
if (r.placed && !ks.length && units.length) {
|
|
372
|
+
const at = r.rows[0] ?? 0;
|
|
373
|
+
const next = units.findIndex((u) => u.a > at);
|
|
374
|
+
ks = [next >= 0 ? next : units.length - 1];
|
|
375
|
+
}
|
|
376
|
+
const shown = r.placed && ks.length > 0;
|
|
377
|
+
if (shown) {
|
|
378
|
+
placed.push(thread);
|
|
379
|
+
for (const k of ks) (marks.get(k) ?? marks.set(k, []).get(k)).push(thread);
|
|
380
|
+
} else unplaced.push(thread);
|
|
381
|
+
return { threadId: id, placed: shown, via: shown ? r.via : null };
|
|
382
|
+
});
|
|
383
|
+
const status = h("p", { class: "ui-review-status", role: "status", "aria-live": "polite" });
|
|
384
|
+
const textarea = h("textarea", { class: "ui-review-input", name: "comment", rows: "3", maxlength: String(REVIEW.body), required: true, "aria-label": "Comment" });
|
|
385
|
+
const target = h("p", { class: "ui-review-target" });
|
|
386
|
+
const sendBtn = h("button", { type: "submit", class: "btn primary", text: "Comment" });
|
|
387
|
+
const box = h("form", { class: "ui-review-box", hidden: true, "aria-label": "Comment on the selection" },
|
|
388
|
+
target, textarea, h("div", { class: "ui-review-box-actions" }, sendBtn, h("button", { type: "button", class: "btn", text: "Cancel", onclick: () => clear(true) })));
|
|
389
|
+
const docEl = h("div", { class: `ui-review-doc ui-review-${doc.kind}`, role: "group", "aria-label": `Review of ${String(doc.path ?? "")}` });
|
|
390
|
+
const unitEls = new Array(units.length);
|
|
391
|
+
let focusAt = 0, anchorAt = null, selection = null, rendered = 0;
|
|
392
|
+
const painted = new Set();
|
|
393
|
+
const describe = (s) => {
|
|
394
|
+
const sel = s.anchor.selectors[0];
|
|
395
|
+
const where = sel.startLine === sel.endLine ? `line ${sel.startLine}` : `lines ${sel.startLine} to ${sel.endLine}`;
|
|
396
|
+
return `${doc.kind === "diff" ? `${s.anchor.side} ` : ""}${where}`;
|
|
397
|
+
};
|
|
398
|
+
function unitLabel(u) {
|
|
399
|
+
const r = rows[u.a];
|
|
400
|
+
if (doc.kind === "prose") return u.a === u.b ? `Line ${u.a + 1}` : `Lines ${u.a + 1} to ${u.b + 1}`;
|
|
401
|
+
if (doc.kind === "diff") return r.kind === "add" ? `Added line ${r.newNo}` : r.kind === "del" ? `Removed line ${r.oldNo}` : `Line ${r.newNo}`;
|
|
402
|
+
return `Line ${r.no}`;
|
|
403
|
+
}
|
|
404
|
+
function threadsButton(k) {
|
|
405
|
+
const list = marks.get(k);
|
|
406
|
+
if (!list) return null;
|
|
407
|
+
return h("button", { type: "button", class: "ui-review-mark", text: list.length === 1 ? "1 comment" : `${list.length} comments`, onclick: () => showThreads(list) });
|
|
408
|
+
}
|
|
409
|
+
function showThreads(list) {
|
|
410
|
+
const body = openSheet?.(list.length === 1 ? "Comment" : `${list.length} comments`);
|
|
411
|
+
if (!body) return;
|
|
412
|
+
body.replaceChildren(...list.map((t) => h("article", { class: "ui-review-thread" },
|
|
413
|
+
h("header", {}, h("strong", { text: String(t.author ?? "") }), " ", h("time", { datetime: String(t.at ?? ""), text: String(t.at ?? "") }), t.state ? consoleUi.chip(String(t.state), t.state === "resolved" ? "ok" : "neutral") : null),
|
|
414
|
+
h("p", { class: "ui-review-thread-body", text: String(t.body ?? "") }))));
|
|
415
|
+
}
|
|
416
|
+
function renderUnit(k) {
|
|
417
|
+
const u = units[k];
|
|
418
|
+
const lines = [];
|
|
419
|
+
for (let i = u.a; i <= u.b; i++) lines.push(h("span", { class: "ui-review-src", "data-i": String(i), text: rows[i].text }));
|
|
420
|
+
const r = rows[u.a];
|
|
421
|
+
const nums = doc.kind === "diff" ? `${r.oldNo ?? ""} ${r.newNo ?? ""}`.trim() : String(doc.kind === "prose" ? u.a + 1 : r.no);
|
|
422
|
+
const gutter = h("button", { type: "button", class: "ui-review-gutter", tabindex: k === focusAt ? "0" : "-1", "data-u": String(k), "aria-label": unitLabel(u), text: nums });
|
|
423
|
+
const el = h("div", { class: `ui-review-unit${doc.kind === "diff" ? ` ui-review-${r.kind}` : ""}${marks.has(k) ? " ui-review-has-thread" : ""}`, "data-u": String(k) },
|
|
424
|
+
gutter, h("div", { class: "ui-review-text" }, lines), threadsButton(k));
|
|
425
|
+
unitEls[k] = el;
|
|
426
|
+
return el;
|
|
427
|
+
}
|
|
428
|
+
// Rows that are not selectable (diff file and hunk headers) render between units, in source order.
|
|
429
|
+
const order = [];
|
|
430
|
+
{ let k = 0; rows.forEach((row, i) => { if (doc.kind === "diff" && !reviewSides(row).length) order.push({ row: i }); else if (k < units.length && units[k].a === i) order.push({ unit: k++ }); }); }
|
|
431
|
+
const orderOf = new Map(order.map((item, at) => [item.unit, at]));
|
|
432
|
+
function renderUntil(limit) {
|
|
433
|
+
let chunk = null;
|
|
434
|
+
while (rendered < order.length && rendered < limit) {
|
|
435
|
+
if (!chunk || chunk.childNodes.length >= REVIEW.chunk) { chunk = h("div", { class: "ui-review-chunk" }); docEl.append(chunk); }
|
|
436
|
+
const item = order[rendered++];
|
|
437
|
+
chunk.append(item.unit !== undefined ? renderUnit(item.unit)
|
|
438
|
+
: h("div", { class: `ui-review-unit ui-review-${rows[item.row].kind}` }, h("span", { class: "ui-review-gutter-pad", "aria-hidden": "true" }), h("div", { class: "ui-review-text" }, h("span", { class: "ui-review-src", text: rows[item.row].text }))));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function ensureUnit(k) {
|
|
442
|
+
if (unitEls[k]) return;
|
|
443
|
+
const at = orderOf.get(k);
|
|
444
|
+
if (at !== undefined) renderUntil(at + 1);
|
|
445
|
+
}
|
|
446
|
+
function paint() {
|
|
447
|
+
const [lo, hi] = selection ? [selection.ua, selection.ub] : [-1, -2];
|
|
448
|
+
for (const k of painted) if (k < lo || k > hi) unitEls[k]?.classList?.remove("ui-review-selected");
|
|
449
|
+
painted.clear();
|
|
450
|
+
for (let k = Math.max(0, lo); k <= hi; k++) { ensureUnit(k); unitEls[k]?.classList?.add("ui-review-selected"); painted.add(k); }
|
|
451
|
+
}
|
|
452
|
+
function select(ua, ub, startChar, endChar) {
|
|
453
|
+
const [lo, hi] = ua <= ub ? [ua, ub] : [ub, ua];
|
|
454
|
+
const anchor = reviewAnchor(doc, rows, sides, units[lo].a, units[hi].b, lo === ua ? startChar : endChar, hi === ub ? endChar : startChar);
|
|
455
|
+
if (!anchor) return null;
|
|
456
|
+
selection = { ua: lo, ub: hi, anchor };
|
|
457
|
+
paint();
|
|
458
|
+
target.textContent = `Comment on ${describe(selection)}`;
|
|
459
|
+
box.hidden = false;
|
|
460
|
+
status.textContent = `${describe(selection)} selected`;
|
|
461
|
+
return selection;
|
|
462
|
+
}
|
|
463
|
+
function clear(refocus) {
|
|
464
|
+
selection = null; anchorAt = null;
|
|
465
|
+
box.hidden = true; textarea.value = "";
|
|
466
|
+
paint();
|
|
467
|
+
if (refocus) unitEls[focusAt]?.querySelector?.(".ui-review-gutter")?.focus();
|
|
468
|
+
}
|
|
469
|
+
function focusUnit(k) {
|
|
470
|
+
k = Math.max(0, Math.min(units.length - 1, k));
|
|
471
|
+
ensureUnit(k);
|
|
472
|
+
unitEls[focusAt]?.querySelector?.(".ui-review-gutter")?.setAttribute("tabindex", "-1");
|
|
473
|
+
focusAt = k;
|
|
474
|
+
const g = unitEls[k]?.querySelector?.(".ui-review-gutter");
|
|
475
|
+
g?.setAttribute("tabindex", "0");
|
|
476
|
+
g?.focus();
|
|
477
|
+
}
|
|
478
|
+
docEl.addEventListener("keydown", (e) => {
|
|
479
|
+
const k = Number(e.target?.getAttribute?.("data-u"));
|
|
480
|
+
if (!units.length || !Number.isInteger(k) || !String(e.target?.className ?? "").includes("ui-review-gutter")) return;
|
|
481
|
+
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
|
|
482
|
+
if (step) {
|
|
483
|
+
e.preventDefault();
|
|
484
|
+
if (e.shiftKey) { if (anchorAt === null) anchorAt = k; focusUnit(k + step); select(anchorAt, focusAt); }
|
|
485
|
+
else { anchorAt = null; focusUnit(k + step); }
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (e.key === "Home" || e.key === "End") { e.preventDefault(); anchorAt = null; focusUnit(e.key === "Home" ? 0 : units.length - 1); return; }
|
|
489
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
490
|
+
e.preventDefault();
|
|
491
|
+
if (!selection || k < selection.ua || k > selection.ub) select(k, k);
|
|
492
|
+
textarea.focus();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (e.key === "Escape" && selection) { e.preventDefault(); clear(true); }
|
|
496
|
+
});
|
|
497
|
+
docEl.addEventListener("click", (e) => {
|
|
498
|
+
const k = Number(e.target?.getAttribute?.("data-u"));
|
|
499
|
+
if (!String(e.target?.className ?? "").includes("ui-review-gutter") || !Number.isInteger(k)) return;
|
|
500
|
+
anchorAt = null; focusUnit(k);
|
|
501
|
+
if (e.shiftKey && selection) select(selection.ua, k); else select(k, k);
|
|
502
|
+
});
|
|
503
|
+
// Pointer selection: map the DOM range ends to kit-built rows and character offsets; anything else is ignored.
|
|
504
|
+
const rowOf = (node) => {
|
|
505
|
+
for (let n = node; n && n !== docEl; n = n.parentNode) {
|
|
506
|
+
const v = n.getAttribute?.("data-i");
|
|
507
|
+
if (v !== null && v !== undefined) return Number(v);
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
510
|
+
};
|
|
511
|
+
const inDoc = (node) => { for (let n = node; n; n = n.parentNode) if (n === docEl) return true; return false; };
|
|
512
|
+
const charAt = (node, offset, i) => (node?.nodeType === 3 ? offset : offset === 0 ? 0 : rows[i].text.length);
|
|
513
|
+
docEl.addEventListener("mouseup", () => {
|
|
514
|
+
const sel = typeof document.getSelection === "function" ? document.getSelection() : null;
|
|
515
|
+
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
|
|
516
|
+
const range = sel.getRangeAt(0);
|
|
517
|
+
if (!inDoc(range.startContainer) || !inDoc(range.endContainer)) return;
|
|
518
|
+
const a = rowOf(range.startContainer), b = rowOf(range.endContainer);
|
|
519
|
+
if (a === null || b === null || !unitOf.has(a) || !unitOf.has(b)) return;
|
|
520
|
+
const ua = unitOf.get(a), ub = unitOf.get(b);
|
|
521
|
+
// Offsets are relative to each row; select() keeps whole units but the quote uses the exact characters.
|
|
522
|
+
const anchor = reviewAnchor(doc, rows, sides, a, b, charAt(range.startContainer, range.startOffset, a), charAt(range.endContainer, range.endOffset, b));
|
|
523
|
+
if (!anchor) return;
|
|
524
|
+
selection = { ua, ub, anchor };
|
|
525
|
+
paint();
|
|
526
|
+
target.textContent = `Comment on ${describe(selection)}`;
|
|
527
|
+
box.hidden = false;
|
|
528
|
+
status.textContent = `${describe(selection)} selected`;
|
|
529
|
+
});
|
|
530
|
+
box.addEventListener("submit", async (e) => {
|
|
531
|
+
e.preventDefault();
|
|
532
|
+
const body = textarea.value.trim();
|
|
533
|
+
if (!selection || !body) { status.textContent = "Write a comment first."; textarea.focus(); return; }
|
|
534
|
+
// Capture what is sent: the person may select or type again while onComment is pending.
|
|
535
|
+
const sent = selection;
|
|
536
|
+
const sentBody = textarea.value;
|
|
537
|
+
sendBtn.disabled = true;
|
|
538
|
+
try {
|
|
539
|
+
await o.onComment({ anchor: sent.anchor, body: body.slice(0, REVIEW.body) });
|
|
540
|
+
status.textContent = `Comment sent on ${describe(sent)}.`;
|
|
541
|
+
// Clear only if nothing changed while the send was pending; a newer selection or draft is kept.
|
|
542
|
+
if (selection === sent && textarea.value === sentBody) clear(true);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
// Exception text goes to System only; the panel shows a fixed summary.
|
|
545
|
+
if (typeof onError === "function") onError(error);
|
|
546
|
+
status.textContent = "The comment was not sent. Your text is kept; try again, and check System for details.";
|
|
547
|
+
} finally { sendBtn.disabled = false; }
|
|
548
|
+
});
|
|
549
|
+
const strip = unplaced.length ? h("section", { class: "ui-review-strip", "aria-label": "Comments that could not be placed" },
|
|
550
|
+
h("h3", { text: unplaced.length === 1 ? "1 comment could not be placed in this version" : `${unplaced.length} comments could not be placed in this version` }),
|
|
551
|
+
h("ul", {}, unplaced.map((t) => {
|
|
552
|
+
const quote = Array.isArray(t?.anchor?.selectors) ? t.anchor.selectors.find((s) => s?.kind === "textQuote") : null;
|
|
553
|
+
return h("li", { class: "ui-review-unplaced" },
|
|
554
|
+
h("span", { class: "ui-review-who" }, h("strong", { text: String(t?.author ?? "") }), " ", h("time", { datetime: String(t?.at ?? ""), text: String(t?.at ?? "") })),
|
|
555
|
+
quote?.exact ? h("blockquote", { class: "ui-review-quote", text: String(quote.exact) }) : null,
|
|
556
|
+
h("p", { class: "ui-review-thread-body", text: String(t?.body ?? "") }));
|
|
557
|
+
}))) : null;
|
|
558
|
+
renderUntil(REVIEW.eager);
|
|
559
|
+
const later = () => { if (rendered < order.length) { renderUntil(rendered + REVIEW.chunk); setTimeout(later, 0); } };
|
|
560
|
+
if (rendered < order.length) { if (typeof setTimeout === "function") setTimeout(later, 0); else renderUntil(order.length); }
|
|
561
|
+
if (typeof o.onPlacement === "function") o.onPlacement(results);
|
|
562
|
+
return h("section", { class: "ui-review", "data-placed": String(placed.length), "data-unplaced": String(unplaced.length) },
|
|
563
|
+
notes.map((n) => consoleUi.notice(n, "warn")), strip, docEl, status, box);
|
|
564
|
+
}
|
|
565
|
+
// ---------- end review surface ----------
|
|
566
|
+
// ---------- visuals (ui.visual) ----------
|
|
567
|
+
// Mermaid renders only in a sandbox="allow-scripts" frame (opaque origin, no network by its CSP). The parent sends
|
|
568
|
+
// {source, theme}, accepts replies only from its own frame and clamps the height. Failures show the source text.
|
|
569
|
+
const VISUAL = Object.freeze({ source: 20 * 1024, timeoutMs: 5000, minHeight: 80, maxHeight: 2000, options: [2, 6], points: 10 });
|
|
570
|
+
/** Per-panel frame pool: a frame whose visual left the DOM is reused by the next diagram in that panel. */
|
|
571
|
+
function visualFrames() {
|
|
572
|
+
const pool = [];
|
|
573
|
+
const pending = new Map();
|
|
574
|
+
let seq = 0;
|
|
575
|
+
if (typeof window !== "undefined") window.addEventListener("message", (event) => {
|
|
576
|
+
const frame = pool.find((f) => f.el.contentWindow === event.source);
|
|
577
|
+
if (!frame) return;
|
|
578
|
+
const data = event.data;
|
|
579
|
+
if (!data || typeof data !== "object" || data.acon !== "visual") return;
|
|
580
|
+
if (data.ready) { frame.ready = Boolean(data.ok); frame.failed = !data.ok; const queued = frame.queued; frame.queued = null; queued?.(); return; }
|
|
581
|
+
const job = pending.get(data.id);
|
|
582
|
+
if (!job || job.frame !== frame) return;
|
|
583
|
+
pending.delete(data.id);
|
|
584
|
+
job.done(data);
|
|
585
|
+
});
|
|
586
|
+
function take() {
|
|
587
|
+
let frame = pool.find((f) => !f.el.isConnected && !f.busy);
|
|
588
|
+
if (!frame) {
|
|
589
|
+
const el = h("iframe", { class: "ui-visual-frame", src: "/visual/frame", sandbox: "allow-scripts", title: "Diagram", height: "160", referrerpolicy: "no-referrer" });
|
|
590
|
+
frame = { el, ready: false, failed: false, queued: null, busy: false, used: false, owner: null };
|
|
591
|
+
pool.push(frame);
|
|
592
|
+
}
|
|
593
|
+
// Reattaching a detached iframe reloads its document: wait for that document's ready handshake again.
|
|
594
|
+
if (frame.used) { frame.ready = false; frame.failed = false; frame.queued = null; }
|
|
595
|
+
frame.used = true;
|
|
596
|
+
frame.busy = true;
|
|
597
|
+
return frame;
|
|
598
|
+
}
|
|
599
|
+
function render(frame, msg, done) {
|
|
600
|
+
const id = ++seq;
|
|
601
|
+
const timer = setTimeout(() => { if (pending.delete(id)) done({ ok: false, error: "timeout" }); }, VISUAL.timeoutMs);
|
|
602
|
+
pending.set(id, { frame, done: (data) => { clearTimeout(timer); done(data); } });
|
|
603
|
+
const send = () => {
|
|
604
|
+
if (frame.failed) { pending.delete(id); clearTimeout(timer); done({ ok: false, error: "renderer unavailable" }); return; }
|
|
605
|
+
frame.el.contentWindow?.postMessage({ acon: "visual-render", id, ...msg }, "*");
|
|
606
|
+
};
|
|
607
|
+
if (frame.ready || frame.failed) send(); else frame.queued = send;
|
|
608
|
+
}
|
|
609
|
+
// Diagrams on screen, so a theme change can redraw them in their current frames.
|
|
610
|
+
const live = new Set();
|
|
611
|
+
return { take, render, release: (frame) => { frame.busy = false; }, live };
|
|
612
|
+
}
|
|
613
|
+
function visualView(payload, kit, frames, theme) {
|
|
614
|
+
const p = payload && typeof payload === "object" ? payload : {};
|
|
615
|
+
const fig = (cls, title, ...kids) => h("figure", { class: `ui-visual ${cls}`, ...(title ? { "aria-label": String(title) } : {}) }, title ? h("figcaption", { text: String(title) }) : null, ...kids);
|
|
616
|
+
if (p.kind === "comparison") {
|
|
617
|
+
const all = Array.isArray(p.options) ? p.options : [];
|
|
618
|
+
const options = all.slice(0, VISUAL.options[1]);
|
|
619
|
+
const list = (items, label, cls) => {
|
|
620
|
+
const shown = Array.isArray(items) ? items.slice(0, VISUAL.points) : [];
|
|
621
|
+
return shown.length ? h("div", { class: `ui-visual-points ${cls}` }, h("strong", { text: label }), h("ul", {}, shown.map((t) => h("li", { text: String(t) })))) : null;
|
|
622
|
+
};
|
|
623
|
+
return fig("ui-visual-comparison", p.title,
|
|
624
|
+
all.length < VISUAL.options[0] ? consoleUi.notice("A comparison needs at least two options.", "warn") : null,
|
|
625
|
+
all.length > options.length ? consoleUi.notice(`Showing the first ${options.length} of ${all.length} options.`, "warn") : null,
|
|
626
|
+
h("ul", { class: "ui-visual-options" }, options.map((o) => h("li", { class: `ui-visual-option${o?.recommended ? " recommended" : ""}` },
|
|
627
|
+
h("div", { class: "ui-visual-option-head" }, h("h3", { text: String(o?.title ?? "") }), o?.recommended ? h("span", { class: "chip ok ui-visual-recommended", text: "★ Recommended" }) : null),
|
|
628
|
+
o?.summary ? h("p", { text: String(o.summary) }) : null,
|
|
629
|
+
list(o?.pros, "Pros", "pros"), list(o?.cons, "Cons", "cons")))));
|
|
630
|
+
}
|
|
631
|
+
if (p.kind === "table") return fig("ui-visual-table", null, consoleUi.table({ columns: p.columns, rows: p.rows, caption: p.caption }));
|
|
632
|
+
if (p.kind === "beforeAfter") {
|
|
633
|
+
const side = (s, label) => {
|
|
634
|
+
let body;
|
|
635
|
+
if (s && typeof s === "object" && "asset" in s) body = kit.img({ asset: s.asset, alt: String(s.alt ?? "") });
|
|
636
|
+
else body = h("pre", { class: "ui-visual-text", text: String(s?.text ?? "") });
|
|
637
|
+
return h("section", { class: "ui-visual-side", "aria-label": label }, h("h3", { text: label }), body);
|
|
638
|
+
};
|
|
639
|
+
return fig("ui-visual-before-after", p.caption, h("div", { class: "ui-visual-pair" }, side(p.before, "Before"), side(p.after, "After")));
|
|
640
|
+
}
|
|
641
|
+
if (p.kind !== "mermaid") throw new TypeError("ui.visual kind must be mermaid, comparison, table or beforeAfter");
|
|
642
|
+
const source = String(p.source ?? "");
|
|
643
|
+
const status = h("p", { class: "ui-visual-status", role: "status", text: "Loading diagram…" });
|
|
644
|
+
const holder = h("div", { class: "ui-visual-holder" });
|
|
645
|
+
const slot = h("div", { class: "ui-visual-notice" }, status);
|
|
646
|
+
const root = fig("ui-visual-mermaid", p.title, slot, holder);
|
|
647
|
+
const fallback = (why) => {
|
|
648
|
+
slot.replaceChildren(consoleUi.notice(why, "warn"));
|
|
649
|
+
holder.replaceChildren(h("pre", { class: "ui-visual-source", text: source }));
|
|
650
|
+
};
|
|
651
|
+
if (!source.trim()) { fallback("The diagram is empty."); return root; }
|
|
652
|
+
if (source.length > VISUAL.source) { fallback(`The diagram source is over ${VISUAL.source / 1024} KB, so it is shown as text.`); return root; }
|
|
653
|
+
let started = false;
|
|
654
|
+
let shownTheme = null;
|
|
655
|
+
let wantedTheme = null;
|
|
656
|
+
let frame = null;
|
|
657
|
+
const draw = (mode) => {
|
|
658
|
+
frames.render(frame, { source, theme: mode, title: p.title ? String(p.title) : "" }, (data) => {
|
|
659
|
+
frames.release(frame);
|
|
660
|
+
if (frame.owner !== entry) return; // the frame moved to another diagram meanwhile
|
|
661
|
+
if (!data.ok) {
|
|
662
|
+
frames.live.delete(entry);
|
|
663
|
+
frame.owner = null;
|
|
664
|
+
frame.el.remove();
|
|
665
|
+
fallback(data.error === "timeout" ? "The diagram took longer than 5 seconds, so it is shown as text." : "The diagram could not be drawn, so it is shown as text.");
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
shownTheme = mode;
|
|
669
|
+
const height = Math.max(VISUAL.minHeight, Math.min(VISUAL.maxHeight, Math.round(Number(data.height) || 0)));
|
|
670
|
+
frame.el.setAttribute("height", String(height));
|
|
671
|
+
frame.el.classList.remove("ui-visual-loading");
|
|
672
|
+
status.textContent = "";
|
|
673
|
+
status.hidden = true;
|
|
674
|
+
// A theme change that arrived during this render is applied now.
|
|
675
|
+
if (wantedTheme !== null && wantedTheme !== shownTheme) entry.retheme(wantedTheme);
|
|
676
|
+
});
|
|
677
|
+
};
|
|
678
|
+
// Redraw in the frame it owns when the console theme changes; a detached or handed-over diagram drops out.
|
|
679
|
+
const entry = {
|
|
680
|
+
retheme(mode) {
|
|
681
|
+
if (!frame || frame.owner !== entry || !frame.el.isConnected) { frames.live.delete(entry); return; }
|
|
682
|
+
wantedTheme = mode;
|
|
683
|
+
if (mode === shownTheme || frame.busy) return; // a busy frame picks up wantedTheme when it finishes
|
|
684
|
+
frame.busy = true;
|
|
685
|
+
draw(mode);
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
const start = () => {
|
|
689
|
+
if (started) return;
|
|
690
|
+
started = true;
|
|
691
|
+
frame = frames.take();
|
|
692
|
+
// Hand-over: the frame's previous diagram stops redrawing into it.
|
|
693
|
+
if (frame.owner) frames.live.delete(frame.owner);
|
|
694
|
+
frame.owner = entry;
|
|
695
|
+
frame.el.setAttribute("title", p.title ? String(p.title) : "Diagram");
|
|
696
|
+
frame.el.classList.add("ui-visual-loading");
|
|
697
|
+
holder.replaceChildren(frame.el);
|
|
698
|
+
frames.live.add(entry);
|
|
699
|
+
wantedTheme = theme();
|
|
700
|
+
draw(wantedTheme);
|
|
701
|
+
};
|
|
702
|
+
// Load only when the visual scrolls into view.
|
|
703
|
+
if (typeof IntersectionObserver === "function") {
|
|
704
|
+
const io = new IntersectionObserver((entries) => { if (entries.some((e) => e.isIntersecting)) { io.disconnect(); start(); } }, { rootMargin: "200px" });
|
|
705
|
+
queueMicrotask(() => io.observe(root));
|
|
706
|
+
} else start();
|
|
707
|
+
return root;
|
|
708
|
+
}
|
|
709
|
+
// ---------- end visuals ----------
|
|
710
|
+
// `route` maps a plugin path to the load this page uses (#26), so assets come from the same version as the panel.
|
|
711
|
+
// `o` holds shell hooks for kit parts that need them (review: openSheet, onError; visual: theme, onTheme).
|
|
712
|
+
function kitFor(tool, hash, route = (path) => path, o = {}) {
|
|
713
|
+
const { openSheet, onError, frameAsset, theme = () => "light", onTheme = () => {} } = o;
|
|
714
|
+
const assetUrl = (id) => route(`/p/${tool}/asset?id=${encodeURIComponent(id)}`);
|
|
715
|
+
let frames = null;
|
|
716
|
+
// One theme subscription per kit: diagrams on screen redraw in the new theme.
|
|
717
|
+
onTheme(() => { if (frames) for (const entry of [...frames.live]) entry.retheme(theme()); });
|
|
718
|
+
const kit = Object.freeze({
|
|
719
|
+
...consoleUi,
|
|
720
|
+
review: (o) => reviewView(o, openSheet, onError),
|
|
721
|
+
visual: (payload) => visualView(payload, kit, frames ??= visualFrames(), theme),
|
|
722
|
+
img(o) {
|
|
723
|
+
const id = assetId(o);
|
|
724
|
+
return h("img", { class: "ui-img", src: assetUrl(id), alt: String(o.alt ?? ""), loading: "lazy", decoding: "async" });
|
|
725
|
+
},
|
|
726
|
+
frame(o) {
|
|
727
|
+
const id = assetId(o);
|
|
728
|
+
if (frameAsset) return frameAsset(assetUrl(id), String(o.title ?? "Attachment"));
|
|
729
|
+
return h("iframe", { class: "ui-frame", src: assetUrl(id), title: String(o.title ?? "Attachment"), referrerpolicy: "no-referrer" });
|
|
730
|
+
},
|
|
731
|
+
link(to, text) {
|
|
732
|
+
const keys = to && typeof to === "object" ? Object.keys(to) : [];
|
|
733
|
+
if (keys.includes("asset")) {
|
|
734
|
+
if (keys.length !== 1) throw new TypeError("Panel kit links take {asset} or {panel, params?}");
|
|
735
|
+
return h("a", { class: "ui-link", href: assetUrl(assetId(to)), target: "_blank", rel: "noopener noreferrer", text: String(text) });
|
|
736
|
+
}
|
|
737
|
+
if (!keys.includes("panel") || keys.some((k) => k !== "panel" && k !== "params") || typeof to.panel !== "string" || !PANEL_ID.test(to.panel)) throw new TypeError("Panel kit links take {asset} or {panel, params?}");
|
|
738
|
+
const params = to.params ?? {};
|
|
739
|
+
if (typeof params !== "object" || Array.isArray(params) || Object.values(params).some((v) => typeof v !== "string")) throw new TypeError("Link params must be strings");
|
|
740
|
+
return h("a", { class: "ui-link", href: hash(tool, to.panel, params), text: String(text) });
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
return kit;
|
|
744
|
+
}
|
|
745
|
+
// ---------- end public panel kit ----------
|
|
746
|
+
function icon(name, cls = "ic") {
|
|
747
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
748
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
749
|
+
svg.setAttribute("class", cls);
|
|
750
|
+
svg.setAttribute("aria-hidden", "true");
|
|
751
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
752
|
+
path.setAttribute("d", ICONS[name] ?? ICONS.box);
|
|
753
|
+
svg.append(path);
|
|
754
|
+
return svg;
|
|
755
|
+
}
|
|
756
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
757
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
758
|
+
function ago(iso) {
|
|
759
|
+
const ms = Date.now() - Date.parse(iso);
|
|
760
|
+
if (!Number.isFinite(ms)) return "";
|
|
761
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
762
|
+
if (s < 60) return `${s} s`;
|
|
763
|
+
if (s < 3600) return `${Math.round(s / 60)} min`;
|
|
764
|
+
if (s < 86400) return `${Math.round(s / 3600)} h`;
|
|
765
|
+
return `${Math.round(s / 86400)} d`;
|
|
766
|
+
}
|
|
767
|
+
const shortFp = (fp) => (fp.length > 10 ? `${fp.slice(0, 4)}...${fp.slice(-2)}` : fp);
|
|
768
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
769
|
+
function store(key, value) {
|
|
770
|
+
try {
|
|
771
|
+
if (value === undefined) return JSON.parse(localStorage.getItem(key) ?? "null");
|
|
772
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
773
|
+
} catch { /* private window or blocked storage: conveniences only */ }
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ---------- network ----------
|
|
778
|
+
// A page keeps the plugin load it first saw (#26): a version registered later serves pages opened after it.
|
|
779
|
+
let staticTransport = null;
|
|
780
|
+
const pinnedRefs = new Map();
|
|
781
|
+
const pluginPath = (path) => staticTransport ? staticTransport.resource(path, pinnedRefs) : path.replace(/^\/p\/([a-z][a-z0-9-]{1,15})\//, (whole, id) => (pinnedRefs.has(id) ? `/p/${pinnedRefs.get(id)}/` : whole));
|
|
782
|
+
|
|
783
|
+
async function post(path, body = {}) {
|
|
784
|
+
if (staticTransport) {
|
|
785
|
+
state.lastRequest = Date.now();
|
|
786
|
+
const response = await staticTransport.post(path, body, pinnedRefs);
|
|
787
|
+
if (response.status === 200) state.lastOk = Date.now();
|
|
788
|
+
return response;
|
|
789
|
+
}
|
|
790
|
+
path = pluginPath(path);
|
|
791
|
+
state.lastRequest = Date.now();
|
|
792
|
+
let res;
|
|
793
|
+
try {
|
|
794
|
+
res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), credentials: "same-origin", cache: "no-store" });
|
|
795
|
+
} catch {
|
|
796
|
+
state.offline = true;
|
|
797
|
+
renderLive();
|
|
798
|
+
return { status: 0, data: { ok: false, error: "The console server is not reachable." } };
|
|
799
|
+
}
|
|
800
|
+
state.offline = false;
|
|
801
|
+
state.lastOk = Date.now();
|
|
802
|
+
let data = null;
|
|
803
|
+
try { data = await res.json(); } catch { /* non-JSON error body */ }
|
|
804
|
+
if (res.status === 401 && path !== "/session") endSession("This console session ended.");
|
|
805
|
+
return { status: res.status, data: data ?? { ok: false, error: `HTTP ${res.status}` } };
|
|
806
|
+
}
|
|
807
|
+
function clientError(tool, where, error) {
|
|
808
|
+
state.clientErrors.unshift({ at: new Date().toISOString(), tool, where, message: String(error?.message ?? error).split("\n")[0].slice(0, 200) });
|
|
809
|
+
state.clientErrors.length = Math.min(state.clientErrors.length, 50);
|
|
810
|
+
if (state.route.area === "system") renderSystem();
|
|
811
|
+
renderRail();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ---------- theme ----------
|
|
815
|
+
const darkQuery = matchMedia("(prefers-color-scheme: dark)");
|
|
816
|
+
function themeChoice() { return store("acon.theme") ?? "system"; }
|
|
817
|
+
function currentTheme() {
|
|
818
|
+
const choice = themeChoice();
|
|
819
|
+
const mode = choice === "system" ? (darkQuery.matches ? "dark" : "light") : choice;
|
|
820
|
+
const css = getComputedStyle(document.documentElement);
|
|
821
|
+
return { mode, tokens: Object.fromEntries(TOKENS.map((t) => [t, css.getPropertyValue(t).trim()])) };
|
|
822
|
+
}
|
|
823
|
+
function applyTheme(choice = themeChoice()) {
|
|
824
|
+
if (choice === "system") delete document.documentElement.dataset.theme;
|
|
825
|
+
else document.documentElement.dataset.theme = choice;
|
|
826
|
+
store("acon.theme", choice);
|
|
827
|
+
if (pip) syncPipTheme();
|
|
828
|
+
const theme = currentTheme();
|
|
829
|
+
for (const p of state.panels.values()) for (const cb of p.themeListeners) safe(p.tool, "onTheme", () => cb(theme));
|
|
830
|
+
if (state.route.area === "system") renderSystem();
|
|
831
|
+
}
|
|
832
|
+
darkQuery.addEventListener("change", () => { if (themeChoice() === "system") applyTheme("system"); });
|
|
833
|
+
|
|
834
|
+
// ---------- tools and routes ----------
|
|
835
|
+
const readyTools = () => state.tools.filter((t) => t.status === "ready").sort((a, b) => rank(a.tool) - rank(b.tool) || a.tool.localeCompare(b.tool));
|
|
836
|
+
const spaceTools = () => readyTools().filter((t) => t.capabilities?.space === true);
|
|
837
|
+
const spaceHash = () => hashFor(toolById("space") ? "~space" : "space");
|
|
838
|
+
const rank = (tool) => (TOOL_ORDER.includes(tool) ? TOOL_ORDER.indexOf(tool) : TOOL_ORDER.length);
|
|
839
|
+
const toolById = (id) => readyTools().find((t) => t.tool === id);
|
|
840
|
+
const panelsOf = (tool) => [...tool.panels].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
841
|
+
function hashFor(area, panel, params) {
|
|
842
|
+
const q = params && Object.keys(params).length ? `?${new URLSearchParams(params)}` : "";
|
|
843
|
+
return `#/${NODE}/${area}${panel ? `/${panel}` : ""}${q}`;
|
|
844
|
+
}
|
|
845
|
+
function navigate(area, panel, params) {
|
|
846
|
+
const next = hashFor(area, panel, params);
|
|
847
|
+
if (location.hash === next) route();
|
|
848
|
+
else location.hash = next;
|
|
849
|
+
}
|
|
850
|
+
function parseRoute() {
|
|
851
|
+
const m = /^#\/([^/?]+)\/([^/?]+)(?:\/([^/?]+))?(?:\?(.*))?$/.exec(location.hash);
|
|
852
|
+
if (!m) return { area: "inbox", params: {} };
|
|
853
|
+
const params = Object.fromEntries(new URLSearchParams(m[4] ?? ""));
|
|
854
|
+
if (m[1] !== NODE) return { area: "inbox", params: {} };
|
|
855
|
+
if ((m[2] === "~space" || (m[2] === "space" && !toolById("space"))) && spaceTools().length) return { area: "space", params };
|
|
856
|
+
if (m[2] === "inbox" || m[2] === "system" || m[2] === "mini") return { area: m[2], params };
|
|
857
|
+
const tool = toolById(m[2]);
|
|
858
|
+
if (!tool) return { area: "inbox", params: {} };
|
|
859
|
+
const panels = panelsOf(tool);
|
|
860
|
+
return { area: "tool", tool: tool.tool, panel: panels.some((p) => p.id === m[3]) ? m[3] : panels[0].id, params };
|
|
861
|
+
}
|
|
862
|
+
function route() {
|
|
863
|
+
if (state.ended) return;
|
|
864
|
+
const prev = state.route;
|
|
865
|
+
state.route = parseRoute();
|
|
866
|
+
const r = state.route;
|
|
867
|
+
updateReadOnly();
|
|
868
|
+
if (prev.tool && prev.tool !== r.tool) { const p = state.panels.get(prev.tool); if (p?.instance) safe(prev.tool, "hide", () => p.instance.hide?.()); }
|
|
869
|
+
$("view-inbox").hidden = r.area !== "inbox";
|
|
870
|
+
$("view-system").hidden = r.area !== "system";
|
|
871
|
+
$("view-space").hidden = r.area !== "space";
|
|
872
|
+
if (!pip) miniView.hidden = r.area !== "mini";
|
|
873
|
+
$("app").classList.toggle("mini-mode", r.area === "mini");
|
|
874
|
+
for (const [id, p] of state.panels) p.mount.hidden = r.area !== "tool" || r.tool !== id;
|
|
875
|
+
renderRail();
|
|
876
|
+
renderCrumbs();
|
|
877
|
+
renderTabs();
|
|
878
|
+
if (r.area === "inbox") { renderInbox(true); if (r.params.id) selectKey(r.params.id, true); }
|
|
879
|
+
if (r.area === "system") renderSystem();
|
|
880
|
+
if (r.area === "mini") renderMini(true);
|
|
881
|
+
if (r.area === "space") showSpace();
|
|
882
|
+
if (r.area === "tool") showTool(r);
|
|
883
|
+
if (prev.area !== r.area || prev.tool !== r.tool) $("main").focus({ preventScroll: true });
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ---------- rail, crumbs, tabs, liveness ----------
|
|
887
|
+
function needsMeCount() { return visibleItems().filter((i) => NEEDS_ME.has(i.group)).length; }
|
|
888
|
+
let railSig = "";
|
|
889
|
+
function renderRail() {
|
|
890
|
+
const r = state.route;
|
|
891
|
+
const count = needsMeCount();
|
|
892
|
+
document.title = count ? `acon (${count})` : "acon";
|
|
893
|
+
renderPet();
|
|
894
|
+
const issues = state.serverErrors.length + state.clientErrors.length + state.tools.filter((t) => t.status !== "ready").length + (state.node?.pluginsError ? 1 : 0);
|
|
895
|
+
const sig = JSON.stringify([r.area, r.tool, count, issues, spaceTools().length > 0, readyTools().map((t) => [t.tool, t.title, t.icon])]);
|
|
896
|
+
if (sig === railSig) return;
|
|
897
|
+
railSig = sig;
|
|
898
|
+
const focused = $("areas").contains(document.activeElement) ? document.activeElement.getAttribute("href") : null;
|
|
899
|
+
const item = (href, iconName, label, current, badge) => h("li", {}, h("a", { href, "aria-current": current ? "page" : undefined }, icon(iconName), h("span", { class: "label", text: label }), badge));
|
|
900
|
+
$("areas").replaceChildren(
|
|
901
|
+
item(hashFor("inbox"), "inbox", "Inbox", r.area === "inbox", count ? h("span", { class: "badge", "aria-label": `${count} waiting`, text: String(count) }) : null),
|
|
902
|
+
...readyTools().map((t) => item(hashFor(t.tool), toolIcon(t), t.title, r.area === "tool" && r.tool === t.tool)),
|
|
903
|
+
...(spaceTools().length ? [item(spaceHash(), "agents", "Space", r.area === "space")] : []),
|
|
904
|
+
item(hashFor("system"), "system", "System", r.area === "system", issues ? h("span", { class: "badge warn", "aria-label": `${issues} issues`, text: "!" }) : null),
|
|
905
|
+
);
|
|
906
|
+
if (focused) $("areas").querySelector(`a[href="${CSS.escape(focused)}"]`)?.focus({ preventScroll: true });
|
|
907
|
+
}
|
|
908
|
+
function renderCrumbs() {
|
|
909
|
+
const r = state.route;
|
|
910
|
+
const names = { system: "System", mini: "Mini", space: "Space" };
|
|
911
|
+
const parts = r.area === "tool" ? [toolById(r.tool)?.title ?? r.tool, panelsOf(toolById(r.tool)).find((p) => p.id === r.panel)?.title] : [names[r.area] ?? "Inbox"];
|
|
912
|
+
$("crumbs").replaceChildren(...parts.flatMap((p, i) => (i ? [h("span", { class: "sep", "aria-hidden": "true", text: "/" }), h("span", { class: "sub", text: p })] : [h("span", { text: p })])));
|
|
913
|
+
}
|
|
914
|
+
function renderTabs() {
|
|
915
|
+
const r = state.route;
|
|
916
|
+
const tabs = $("tabs");
|
|
917
|
+
if (r.area !== "tool") { tabs.hidden = true; tabs.replaceChildren(); return; }
|
|
918
|
+
const tool = toolById(r.tool);
|
|
919
|
+
tabs.hidden = false;
|
|
920
|
+
tabs.setAttribute("aria-label", `${tool.title} views`);
|
|
921
|
+
const hadFocus = tabs.contains(document.activeElement);
|
|
922
|
+
tabs.replaceChildren(...panelsOf(tool).map((p) => h("button", {
|
|
923
|
+
type: "button", role: "tab", class: "tab", "aria-selected": String(p.id === r.panel), tabindex: p.id === r.panel ? "0" : "-1",
|
|
924
|
+
onclick: () => navigate(tool.tool, p.id), text: p.title,
|
|
925
|
+
})));
|
|
926
|
+
if (hadFocus) tabs.querySelector('[aria-selected="true"]')?.focus();
|
|
927
|
+
requestAnimationFrame(() => {
|
|
928
|
+
tabs.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
929
|
+
updateTabOverflow();
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
function updateTabOverflow() {
|
|
933
|
+
const tabs = $("tabs");
|
|
934
|
+
const before = !tabs.hidden && tabs.scrollLeft > 1;
|
|
935
|
+
const after = !tabs.hidden && tabs.scrollWidth - tabs.clientWidth - tabs.scrollLeft > 1;
|
|
936
|
+
tabs.classList.toggle("more-before", before);
|
|
937
|
+
tabs.classList.toggle("more-after", after);
|
|
938
|
+
tabs.classList.toggle("has-overflow", before || after);
|
|
939
|
+
}
|
|
940
|
+
$("tabs").addEventListener("scroll", updateTabOverflow, { passive: true });
|
|
941
|
+
new ResizeObserver(() => {
|
|
942
|
+
$("tabs").querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
943
|
+
updateTabOverflow();
|
|
944
|
+
}).observe($("tabs"));
|
|
945
|
+
tabsKeys();
|
|
946
|
+
function tabsKeys() {
|
|
947
|
+
$("tabs").addEventListener("keydown", (e) => {
|
|
948
|
+
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
|
949
|
+
const tabs = [...$("tabs").querySelectorAll(".tab")];
|
|
950
|
+
const i = tabs.indexOf(document.activeElement);
|
|
951
|
+
if (i < 0) return;
|
|
952
|
+
const next = tabs[(i + (e.key === "ArrowRight" ? 1 : tabs.length - 1)) % tabs.length];
|
|
953
|
+
next.focus();
|
|
954
|
+
next.click();
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
function renderLive() {
|
|
958
|
+
const el = $("live");
|
|
959
|
+
const age = Math.round((Date.now() - state.lastOk) / 1000);
|
|
960
|
+
const kind = state.offline ? "offline" : age > 15 ? "stale" : "live";
|
|
961
|
+
if (staticTransport) {
|
|
962
|
+
const online = staticTransport.nodes.filter((node) => node.status === "online").length;
|
|
963
|
+
el.className = `live ${online ? "live" : "offline"}`;
|
|
964
|
+
el.textContent = `${online}/${staticTransport.nodes.length} nodes online`;
|
|
965
|
+
el.title = "Open System for each node's status and authentication";
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const text = kind === "offline" ? "Offline · retrying" : kind === "stale" ? `Stale ${age} s` : `Live · ${age} s ago`;
|
|
969
|
+
el.className = `live ${kind}`;
|
|
970
|
+
if (el.dataset.text !== text) { el.dataset.text = text; el.replaceChildren(h("span", { class: "dot", "aria-hidden": "true" }), h("span", { text })); }
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ---------- inbox ----------
|
|
974
|
+
function snoozes() { return store("acon.snooze") ?? {}; }
|
|
975
|
+
function visibleItems() {
|
|
976
|
+
const sn = snoozes();
|
|
977
|
+
const now = Date.now();
|
|
978
|
+
const out = [];
|
|
979
|
+
for (const [tool, entry] of state.inbox) for (const item of entry.items) {
|
|
980
|
+
const key = `${tool}:${item.id}`;
|
|
981
|
+
const s = sn[key];
|
|
982
|
+
if (s && s.until > now && s.at === item.at) continue;
|
|
983
|
+
out.push({ ...item, tool, key });
|
|
984
|
+
}
|
|
985
|
+
return out;
|
|
986
|
+
}
|
|
987
|
+
function validItem(i) {
|
|
988
|
+
return i && typeof i === "object" && str(i.id) && str(i.title) && GROUPS.some(([g]) => g === i.group) && Array.isArray(i.verbs) && i.verbs.every((v) => v && typeof v === "object" && str(v.id) && str(v.label) && (v.form === undefined || validForm(v.form))) && i.link && str(i.link.panel)
|
|
989
|
+
&& (i.seen === undefined || Boolean(i.seen && str(i.seen.action)));
|
|
990
|
+
}
|
|
991
|
+
let inboxSeq = 0;
|
|
992
|
+
const inboxApplied = new Map();
|
|
993
|
+
async function pollInbox() {
|
|
994
|
+
const seq = ++inboxSeq;
|
|
995
|
+
const tools = readyTools().filter((t) => t.capabilities?.inbox);
|
|
996
|
+
await Promise.all(tools.map(async (t) => {
|
|
997
|
+
const r = await post(`/p/${t.tool}/snapshot`, { after: 0, panel: "inbox" });
|
|
998
|
+
// An older response that arrives late never overwrites a newer one.
|
|
999
|
+
if ((inboxApplied.get(t.tool) ?? 0) > seq) return;
|
|
1000
|
+
inboxApplied.set(t.tool, seq);
|
|
1001
|
+
if (r.status === 200 && Array.isArray(r.data.items)) {
|
|
1002
|
+
const items = r.data.items.filter(validItem);
|
|
1003
|
+
if (items.length !== r.data.items.length) clientError(t.tool, "inbox", new Error(`${r.data.items.length - items.length} malformed inbox items were skipped`));
|
|
1004
|
+
state.inbox.set(t.tool, { items, failed: false });
|
|
1005
|
+
} else {
|
|
1006
|
+
const old = state.inbox.get(t.tool);
|
|
1007
|
+
state.inbox.set(t.tool, { items: old?.items ?? [], failed: true });
|
|
1008
|
+
}
|
|
1009
|
+
}));
|
|
1010
|
+
for (const tool of [...state.inbox.keys()]) if (!tools.some((t) => t.tool === tool)) state.inbox.delete(tool);
|
|
1011
|
+
renderRail();
|
|
1012
|
+
renderMini();
|
|
1013
|
+
if (state.route.area === "inbox") renderInbox();
|
|
1014
|
+
if (state.peekKey?.startsWith("item:")) renderPeek();
|
|
1015
|
+
}
|
|
1016
|
+
function sortedItems() {
|
|
1017
|
+
const items = visibleItems();
|
|
1018
|
+
return GROUPS.flatMap(([g]) => items.filter((i) => i.group === g).sort((a, b) => Date.parse(a.at) - Date.parse(b.at)));
|
|
1019
|
+
}
|
|
1020
|
+
function leadText(items) {
|
|
1021
|
+
const n = (g) => items.filter((i) => i.group === g).length;
|
|
1022
|
+
const d = n("decision"), rv = n("review"), c = n("candidate"), a = n("alert"), rs = n("result");
|
|
1023
|
+
if (!items.length) return ["Nothing needs you right now.", "New decisions, reviews and candidates from your tools appear here."];
|
|
1024
|
+
// Results are counted on their own: they are to read, not to decide.
|
|
1025
|
+
const head = d ? `${plural(d, "decision")} ${d === 1 ? "needs" : "need"} you.` : rv ? `${plural(rv, "review")} ${rv === 1 ? "waits" : "wait"} for you.` : c ? `${plural(c, "candidate")} ${c === 1 ? "waits" : "wait"} for you.` : a ? `${plural(a, "alert")}.` : `${plural(rs, "result")} to read.`;
|
|
1026
|
+
const rest = [d && rv ? plural(rv, "review") : "", (d || rv || c || a) && rs ? `${plural(rs, "result")} to read` : "", (d || rv) && c ? plural(c, "candidate") : "", (d || rv || c) && a ? plural(a, "alert") : ""].filter(Boolean);
|
|
1027
|
+
return [head, rest.length ? `Also waiting: ${rest.join(", ")}.` : "Oldest first in each group."];
|
|
1028
|
+
}
|
|
1029
|
+
function renderInbox(force = false) {
|
|
1030
|
+
const view = $("view-inbox");
|
|
1031
|
+
const items = sortedItems();
|
|
1032
|
+
const failed = [...state.inbox].filter(([, e]) => e.failed).map(([t]) => toolById(t)?.title ?? t);
|
|
1033
|
+
const sig = JSON.stringify([items, failed, state.selectedKey, readyTools().length, readOnly()]);
|
|
1034
|
+
if (!force && sig === state.inboxSig) return;
|
|
1035
|
+
state.inboxSig = sig;
|
|
1036
|
+
const focusRow = document.activeElement?.closest?.(".row");
|
|
1037
|
+
const focusKey = focusRow?.dataset.key;
|
|
1038
|
+
const focusIndex = focusRow ? [...focusRow.querySelectorAll("button")].indexOf(document.activeElement) : -1;
|
|
1039
|
+
const reasonFocused = Boolean(reasonBox) && reasonBox.el.contains(document.activeElement);
|
|
1040
|
+
if (!items.some((i) => i.key === state.selectedKey)) state.selectedKey = items[0]?.key ?? null;
|
|
1041
|
+
const [lead, sub] = leadText(items);
|
|
1042
|
+
const sources = readyTools().filter((t) => t.capabilities?.inbox);
|
|
1043
|
+
const kids = [h("h1", { class: "lead", id: "inbox-lead", text: lead }), h("p", { class: "sub", text: sub })];
|
|
1044
|
+
if (failed.length) kids.push(h("p", { class: "notice", role: "status" }, icon("alert"), h("span", { text: `${failed.join(", ")} could not refresh. ` }), h("a", { href: hashFor("system"), text: "Details in System" })));
|
|
1045
|
+
if (!sources.length) {
|
|
1046
|
+
kids.push(h("div", { class: "empty" }, h("strong", { text: state.tools.length ? "No registered tool sends items to the inbox yet." : "No tools are registered yet." }),
|
|
1047
|
+
h("span", { text: "Each tool registers itself with its setup command; System lists what was found and why anything was skipped." })));
|
|
1048
|
+
}
|
|
1049
|
+
for (const [g, label] of GROUPS) {
|
|
1050
|
+
const group = items.filter((i) => i.group === g);
|
|
1051
|
+
if (!group.length) continue;
|
|
1052
|
+
kids.push(h("section", { class: "group", "aria-labelledby": `g-${g}` },
|
|
1053
|
+
h("h2", { id: `g-${g}` }, h("span", { text: label }), h("span", { class: "badge", text: String(group.length) }),
|
|
1054
|
+
g === "result" && group.some((i) => i.seen) ? h("button", { type: "button", class: "btn group-command", disabled: readOnly(), title: readOnly() ? RO_TITLE : undefined, onclick: () => markAllRead(group), text: "Mark all read" }) : null),
|
|
1055
|
+
h("ul", { class: "rows" }, group.map(rowFor))));
|
|
1056
|
+
}
|
|
1057
|
+
view.replaceChildren(...kids);
|
|
1058
|
+
// Focus returns to the same control of the same row (title or verb), so background updates never move it.
|
|
1059
|
+
const row = focusKey && !reasonFocused ? view.querySelector(`.row[data-key="${CSS.escape(focusKey)}"]`) : null;
|
|
1060
|
+
if (row) (row.querySelectorAll("button")[focusIndex] ?? row.querySelector(".row-main"))?.focus({ preventScroll: true });
|
|
1061
|
+
if (!$("sheet-body").contains(reasonBox?.el)) placeReason(reasonFocused);
|
|
1062
|
+
}
|
|
1063
|
+
function whose(item) {
|
|
1064
|
+
if (item.group === "alert") return h("span", { class: "whose alert", title: "Alert" }, icon("alert"));
|
|
1065
|
+
if (item.group === "decision") return h("span", { class: "whose you", title: "Your move" }, icon("you"));
|
|
1066
|
+
if (item.group === "result") return h("span", { class: "whose", title: "Result to read" }, icon("review"));
|
|
1067
|
+
return h("span", { class: "whose", title: item.group === "review" ? "Ready for review" : "Candidate" }, icon(item.group === "review" ? "review" : "candidate"));
|
|
1068
|
+
}
|
|
1069
|
+
function subline(item) {
|
|
1070
|
+
return [item.kind, item.project, item.actor ? `from ${item.actor}` : "", toolById(item.tool)?.title, ago(item.at)].filter(Boolean).join(" · ");
|
|
1071
|
+
}
|
|
1072
|
+
// #45: a read-only node refuses every action; verbs and confirm stay visible but disabled.
|
|
1073
|
+
const readOnly = (tool = state.route.tool) => state.readOnly === true || Boolean(tool && state.tools.find((t) => t.tool === tool)?.readOnly === true);
|
|
1074
|
+
function updateReadOnly() {
|
|
1075
|
+
const chip = $("read-only");
|
|
1076
|
+
const nodes = staticTransport ? staticTransport.nodes.filter((node) => node.data?.readOnly).map((node) => node.id) : [];
|
|
1077
|
+
const routed = readOnly();
|
|
1078
|
+
chip.hidden = !routed && !nodes.length;
|
|
1079
|
+
chip.textContent = routed ? "Read-only" : `${nodes.length} read-only ${nodes.length === 1 ? "node" : "nodes"}`;
|
|
1080
|
+
chip.title = nodes.length ? `Read-only nodes: ${nodes.join(", ")}. Actions remain available on writable nodes.` : "Actions are turned off on this console";
|
|
1081
|
+
}
|
|
1082
|
+
const RO_TITLE = "This console is read-only";
|
|
1083
|
+
function verbButtons(item, max) {
|
|
1084
|
+
const verbs = [...item.verbs].sort((a, b) => Number(Boolean(b.primary)) - Number(Boolean(a.primary))).slice(0, max);
|
|
1085
|
+
return verbs.map((v, i) => h("button", {
|
|
1086
|
+
type: "button", class: `btn${v.primary ? " primary" : ""}${v.destructive ? " danger" : ""}`, disabled: readOnly(item.tool), title: readOnly(item.tool) ? RO_TITLE : undefined,
|
|
1087
|
+
onclick: (e) => { e.stopPropagation(); runVerb(item, v); },
|
|
1088
|
+
}, h("span", { text: v.label }), max <= 2 ? h("kbd", { "aria-hidden": "true", text: String(i + 1) }) : null));
|
|
1089
|
+
}
|
|
1090
|
+
function rowFor(item) {
|
|
1091
|
+
const selected = item.key === state.selectedKey;
|
|
1092
|
+
return h("li", { class: `row${selected ? " selected" : ""}`, "data-key": item.key },
|
|
1093
|
+
h("button", {
|
|
1094
|
+
type: "button", class: "row-main", "aria-current": selected ? "true" : undefined,
|
|
1095
|
+
"aria-keyshortcuts": "Space Enter 1 2 H", onclick: () => { selectKey(item.key); openPeek(item.key); },
|
|
1096
|
+
// Keys act on the focused row, so focus and selection never disagree.
|
|
1097
|
+
onfocus: () => { if (state.selectedKey !== item.key) selectKey(item.key, false); },
|
|
1098
|
+
}, whose(item), h("span", { class: "row-text" }, h("span", { class: "row-title", text: item.title }), h("span", { class: "row-sub", text: subline(item) })), item.chip ? h("span", { class: `chip ${toneClass(item.chip.tone)}`, text: str(item.chip.text) }) : null),
|
|
1099
|
+
h("div", { class: "row-verbs" }, verbButtons(item, 2)));
|
|
1100
|
+
}
|
|
1101
|
+
const toneClass = (tone) => (["ok", "warn", "err"].includes(tone) ? tone : "");
|
|
1102
|
+
function selectKey(key, focus = true) {
|
|
1103
|
+
state.selectedKey = key;
|
|
1104
|
+
for (const row of document.querySelectorAll("#view-inbox .row")) {
|
|
1105
|
+
const on = row.dataset.key === key;
|
|
1106
|
+
row.classList.toggle("selected", on);
|
|
1107
|
+
row.querySelector(".row-main")?.toggleAttribute("aria-current", on);
|
|
1108
|
+
if (on && focus) { row.querySelector(".row-main").focus({ preventScroll: false }); row.scrollIntoView({ block: "nearest" }); }
|
|
1109
|
+
}
|
|
1110
|
+
if (state.peekKey?.startsWith("item:") && state.peekKey !== `item:${key}`) openPeek(key);
|
|
1111
|
+
}
|
|
1112
|
+
function moveSelection(step) {
|
|
1113
|
+
const items = sortedItems();
|
|
1114
|
+
if (!items.length) return;
|
|
1115
|
+
const i = items.findIndex((x) => x.key === state.selectedKey);
|
|
1116
|
+
const next = items[Math.min(items.length - 1, Math.max(0, (i < 0 ? 0 : i + step)))];
|
|
1117
|
+
selectKey(next.key);
|
|
1118
|
+
}
|
|
1119
|
+
const selectedItem = () => sortedItems().find((i) => i.key === state.selectedKey);
|
|
1120
|
+
async function runVerb(item, verb, reason, extra = {}) {
|
|
1121
|
+
if (verb.form) { openAnswer(item, verb); return; }
|
|
1122
|
+
if ((verb.reason === "required" || verb.reason === "optional") && reason === undefined) { askReason(item, verb); return; }
|
|
1123
|
+
const list = sortedItems();
|
|
1124
|
+
const i = list.findIndex((x) => x.key === item.key);
|
|
1125
|
+
const next = list[i + 1] ?? list[i - 1];
|
|
1126
|
+
const result = await runAction(item.tool, { action: verb.id, id: item.id, ...(reason ? { reason } : {}) }, { label: verb.label, inbox: false, ...extra });
|
|
1127
|
+
if (!result?.ok) return;
|
|
1128
|
+
await pollInbox();
|
|
1129
|
+
// Triage flow: the next item takes the handled one's place, in the list and in the peek.
|
|
1130
|
+
if (next && !sortedItems().some((x) => x.key === item.key)) {
|
|
1131
|
+
const peeking = state.peekKey === `item:${item.key}`;
|
|
1132
|
+
selectKey(next.key);
|
|
1133
|
+
if (peeking) openPeek(next.key);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// ---------- verb reasons ----------
|
|
1137
|
+
// One inline reason row at a time, in the peek when it shows the item, else under the row. It survives re-renders.
|
|
1138
|
+
let reasonBox = null;
|
|
1139
|
+
function askReason(item, verb) {
|
|
1140
|
+
closeReason(false);
|
|
1141
|
+
const required = verb.reason === "required";
|
|
1142
|
+
const input = h("input", { type: "text", maxlength: "500", autocomplete: "off", "aria-label": `Reason to ${verb.label}${required ? " (required)" : " (optional)"}`, placeholder: required ? "Reason (required)" : "Reason (optional)" });
|
|
1143
|
+
const error = h("span", { class: "reason-error", role: "alert" });
|
|
1144
|
+
const send = () => {
|
|
1145
|
+
const text = input.value.trim();
|
|
1146
|
+
if (required && !text) { input.setAttribute("aria-invalid", "true"); error.textContent = "A reason is required."; input.focus(); return; }
|
|
1147
|
+
closeReason(true);
|
|
1148
|
+
runVerb(item, verb, text);
|
|
1149
|
+
};
|
|
1150
|
+
const el = h("div", { class: "reason-row", onkeydown: (e) => {
|
|
1151
|
+
if (e.key === "Enter" && e.target === input) { e.preventDefault(); send(); }
|
|
1152
|
+
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeReason(true); }
|
|
1153
|
+
} }, input,
|
|
1154
|
+
h("button", { type: "button", class: `btn ${verb.destructive ? "danger" : "primary"}`, onclick: send, text: verb.label }),
|
|
1155
|
+
h("button", { type: "button", class: "btn", onclick: () => closeReason(true), text: "Cancel" }), error);
|
|
1156
|
+
reasonBox = { key: item.key, el, input };
|
|
1157
|
+
placeReason(true);
|
|
1158
|
+
}
|
|
1159
|
+
function placeReason(focus) {
|
|
1160
|
+
if (!reasonBox) return;
|
|
1161
|
+
const { key, el, input } = reasonBox;
|
|
1162
|
+
const verbs = state.peekKey === `item:${key}` && !$("sheet").hidden ? $("sheet-body").querySelector(".item-verbs") : null;
|
|
1163
|
+
const row = verbs ? null : $("view-inbox").querySelector(`.row[data-key="${CSS.escape(key)}"]`);
|
|
1164
|
+
if (!verbs && !row) { closeReason(false); return; } // the item was handled or withdrawn
|
|
1165
|
+
if (verbs) verbs.after(el); else row.append(el);
|
|
1166
|
+
if (focus) input.focus({ preventScroll: true });
|
|
1167
|
+
}
|
|
1168
|
+
function closeReason(refocus) {
|
|
1169
|
+
if (!reasonBox) return;
|
|
1170
|
+
const { key, el } = reasonBox;
|
|
1171
|
+
reasonBox = null;
|
|
1172
|
+
const inSheet = $("sheet").contains(el);
|
|
1173
|
+
el.remove();
|
|
1174
|
+
if (refocus) (inSheet ? $("sheet-body").querySelector(".item-verbs .btn") : $("view-inbox").querySelector(`.row[data-key="${CSS.escape(key)}"] .row-main`))?.focus({ preventScroll: true });
|
|
1175
|
+
}
|
|
1176
|
+
// ---------- inbox answers (#27) ----------
|
|
1177
|
+
// A verb with a form asks choice questions in the peek. The draft lives in page memory per item, survives re-renders
|
|
1178
|
+
// and a refused answer, and is never stored by the console: the tool decides whether an answer is accepted.
|
|
1179
|
+
const FORM_MAX = { label: 80, description: 240, prompt: 1000, other: 1000, otherDefault: 500 };
|
|
1180
|
+
const within = (v, max) => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
1181
|
+
function validForm(f) {
|
|
1182
|
+
if (!f || !Array.isArray(f.questions) || f.questions.length < 1 || f.questions.length > 4) return false;
|
|
1183
|
+
const ids = new Set();
|
|
1184
|
+
return f.questions.every((q) => {
|
|
1185
|
+
if (!q || !within(q.id, 64) || ids.has(q.id) || !within(q.prompt, FORM_MAX.prompt) || (q.header !== undefined && !within(q.header, FORM_MAX.label))) return false;
|
|
1186
|
+
ids.add(q.id);
|
|
1187
|
+
if (!Array.isArray(q.options) || q.options.length < 2 || q.options.length > 8) return false;
|
|
1188
|
+
const oids = new Set();
|
|
1189
|
+
const okOptions = q.options.every((o) => o && within(o.id, 64) && !oids.has(o.id) && oids.add(o.id) && within(o.label, FORM_MAX.label) && (o.description === undefined || within(o.description, FORM_MAX.description)));
|
|
1190
|
+
const okOther = q.other === undefined || (q.other && typeof q.other === "object" && (q.other.label === undefined || within(q.other.label, FORM_MAX.label))
|
|
1191
|
+
&& (q.other.maxLength === undefined || (Number.isSafeInteger(q.other.maxLength) && q.other.maxLength >= 1 && q.other.maxLength <= FORM_MAX.other)));
|
|
1192
|
+
return okOptions && okOther && (q.multiple === undefined || typeof q.multiple === "boolean");
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
// The form verb the person chose per item (an item may offer more than one); the first form verb by default.
|
|
1196
|
+
const chosenForm = new Map();
|
|
1197
|
+
const formVerb = (item) => item.verbs.find((v) => v.form && v.id === chosenForm.get(item.key)) ?? item.verbs.find((v) => v.form) ?? null;
|
|
1198
|
+
// Drafts are per item and verb, so switching between two forms never mixes their answers.
|
|
1199
|
+
const drafts = new Map();
|
|
1200
|
+
function draftFor(key) {
|
|
1201
|
+
// Question ids are tool data: a null-prototype record keeps ids like "constructor" or "__proto__" ordinary keys.
|
|
1202
|
+
if (!drafts.has(key)) drafts.set(key, { step: "edit", error: "", q: Object.create(null) });
|
|
1203
|
+
return drafts.get(key);
|
|
1204
|
+
}
|
|
1205
|
+
/** Answers from a draft, or the ids of questions that still have no choice. Text, not focus, makes "Other" an answer. */
|
|
1206
|
+
function formAnswers(form, draft) {
|
|
1207
|
+
const answers = Object.create(null);
|
|
1208
|
+
const missing = [];
|
|
1209
|
+
for (const q of form.questions) {
|
|
1210
|
+
const d = draft.q[q.id] ?? { selected: [], other: "" };
|
|
1211
|
+
const selected = d.selected.filter((id) => q.options.some((o) => o.id === id));
|
|
1212
|
+
const other = q.other ? str(d.other).trim().slice(0, q.other.maxLength ?? FORM_MAX.otherDefault) : "";
|
|
1213
|
+
// A single choice is one option or the Other text; a draft kept from a multiple-choice version must be redone.
|
|
1214
|
+
if ((!selected.length && !other) || (!q.multiple && selected.length + (other ? 1 : 0) > 1)) missing.push(q.id);
|
|
1215
|
+
answers[q.id] = other ? { selected, other } : { selected };
|
|
1216
|
+
}
|
|
1217
|
+
return missing.length ? { missing } : { answers };
|
|
1218
|
+
}
|
|
1219
|
+
function choiceText(q, a) {
|
|
1220
|
+
return [...q.options.filter((o) => a.selected.includes(o.id)).map((o) => o.label), ...(a.other ? [`${q.other?.label ?? "Other"}: ${a.other}`] : [])].join(", ");
|
|
1221
|
+
}
|
|
1222
|
+
/** The form, or with several questions its review step. `submit(answers)` sends; `redraw()` re-renders the peek. */
|
|
1223
|
+
function answerForm(item, verb, draft, submit, redraw) {
|
|
1224
|
+
const form = verb.form;
|
|
1225
|
+
const name = (q) => `${item.key}:${q.id}`;
|
|
1226
|
+
const error = h("p", { class: "answer-error", role: "alert", text: draft.error });
|
|
1227
|
+
const reasonMissing = () => verb.reason === "required" && !str(draft.reason).trim();
|
|
1228
|
+
const next = () => {
|
|
1229
|
+
const r = formAnswers(form, draft);
|
|
1230
|
+
if (!r.missing && reasonMissing()) {
|
|
1231
|
+
draft.error = "A reason is required.";
|
|
1232
|
+
error.textContent = draft.error;
|
|
1233
|
+
el.querySelector(".answer-reason")?.focus();
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
if (r.missing) {
|
|
1237
|
+
draft.error = `Choose your answer for: ${r.missing.map((id) => { const q = form.questions.find((x) => x.id === id); return `${q?.header ?? q?.prompt} (${q?.multiple ? "one or more" : "exactly one"})`; }).join("; ")}.`;
|
|
1238
|
+
error.textContent = draft.error;
|
|
1239
|
+
el.querySelector(`fieldset[data-q="${CSS.escape(r.missing[0])}"] input`)?.focus();
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
draft.error = "";
|
|
1243
|
+
if (form.questions.length > 1 && draft.step === "edit") { draft.step = "review"; redraw(true); return; }
|
|
1244
|
+
submit(r.answers);
|
|
1245
|
+
};
|
|
1246
|
+
// A poll can change the question under the review step: with an answer now missing, go back to editing.
|
|
1247
|
+
if (draft.step === "review" && (formAnswers(form, draft).missing || reasonMissing())) { draft.step = "edit"; draft.error = "The question changed; check your answers again."; error.textContent = draft.error; }
|
|
1248
|
+
if (draft.step === "review") {
|
|
1249
|
+
const r = formAnswers(form, draft);
|
|
1250
|
+
const el = h("div", { class: "answer-form answer-review", role: "group", "aria-label": `Review answers for ${verb.label}` },
|
|
1251
|
+
h("h3", { text: "Review your answers" }),
|
|
1252
|
+
h("dl", { class: "kv" }, form.questions.flatMap((q) => [h("dt", { text: q.header ?? q.prompt }), h("dd", { text: r.answers ? choiceText(q, r.answers[q.id]) : "" })]),
|
|
1253
|
+
str(draft.reason).trim() ? [h("dt", { text: "Reason" }), h("dd", { text: str(draft.reason).trim() })] : []),
|
|
1254
|
+
error,
|
|
1255
|
+
h("div", { class: "sheet-verbs" },
|
|
1256
|
+
h("button", { type: "button", class: "btn primary answer-submit", disabled: readOnly(item.tool), onclick: () => submit(r.answers), text: `Submit: ${verb.label}` }),
|
|
1257
|
+
h("button", { type: "button", class: "btn", onclick: () => { draft.step = "edit"; redraw(true); }, text: "Back" })));
|
|
1258
|
+
return el;
|
|
1259
|
+
}
|
|
1260
|
+
const el = h("form", { class: "answer-form", novalidate: true, "aria-label": verb.label, onsubmit: (e) => { e.preventDefault(); next(); },
|
|
1261
|
+
onkeydown: (e) => {
|
|
1262
|
+
if (e.key === "Enter" && e.target.tagName === "INPUT") { e.preventDefault(); next(); return; }
|
|
1263
|
+
// Radios move with arrows natively; checkboxes get the same between the options of one question.
|
|
1264
|
+
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && e.target.type === "checkbox") {
|
|
1265
|
+
const boxes = [...e.target.closest("fieldset").querySelectorAll("input[type=checkbox]")];
|
|
1266
|
+
const i = boxes.indexOf(e.target) + (e.key === "ArrowDown" ? 1 : -1);
|
|
1267
|
+
if (boxes[i]) { e.preventDefault(); boxes[i].focus(); }
|
|
1268
|
+
}
|
|
1269
|
+
} },
|
|
1270
|
+
...form.questions.map((q) => {
|
|
1271
|
+
const d = draft.q[q.id] ?? (draft.q[q.id] = { selected: [], other: "" });
|
|
1272
|
+
const other = q.other ? h("input", { type: "text", class: "answer-other-text", maxlength: String(q.other.maxLength ?? FORM_MAX.otherDefault), autocomplete: "off", value: d.other,
|
|
1273
|
+
oninput: (e) => {
|
|
1274
|
+
d.other = e.target.value;
|
|
1275
|
+
// A single choice is either an option or the "Other" text.
|
|
1276
|
+
if (!q.multiple && d.other.trim()) { d.selected = []; for (const r of el.querySelectorAll(`input[name="${CSS.escape(name(q))}"]`)) r.checked = false; }
|
|
1277
|
+
} }) : null;
|
|
1278
|
+
return h("fieldset", { class: "answer-q", "data-q": q.id },
|
|
1279
|
+
h("legend", {}, q.header ? h("strong", { text: q.header }) : null, h("span", { text: q.prompt }), q.multiple ? h("small", { text: "Choose one or more." }) : null),
|
|
1280
|
+
...q.options.map((o) => h("label", { class: "answer-opt" },
|
|
1281
|
+
h("input", { type: q.multiple ? "checkbox" : "radio", name: name(q), value: o.id, checked: d.selected.includes(o.id),
|
|
1282
|
+
onchange: (e) => {
|
|
1283
|
+
d.selected = q.multiple ? (e.target.checked ? [...new Set([...d.selected, o.id])] : d.selected.filter((id) => id !== o.id)) : [o.id];
|
|
1284
|
+
if (!q.multiple && other) { d.other = ""; other.value = ""; }
|
|
1285
|
+
} }),
|
|
1286
|
+
h("span", { class: "answer-label" }, h("span", { text: o.label }), o.description ? h("small", { text: o.description }) : null))),
|
|
1287
|
+
other ? h("label", { class: "answer-opt answer-other" }, h("span", { class: "answer-label", text: q.other.label ?? "Other" }), other) : null);
|
|
1288
|
+
}),
|
|
1289
|
+
// A verb may ask for a reason as well as answers; it is sent as `reason`, like the inline reason box.
|
|
1290
|
+
verb.reason ? h("label", { class: "answer-opt answer-other" }, h("span", { class: "answer-label", text: `Reason${verb.reason === "required" ? " (required)" : " (optional)"}` }),
|
|
1291
|
+
h("input", { type: "text", class: "answer-other-text answer-reason", maxlength: "500", autocomplete: "off", value: str(draft.reason), oninput: (e) => { draft.reason = e.target.value; } })) : null,
|
|
1292
|
+
error,
|
|
1293
|
+
h("div", { class: "sheet-verbs" }, h("button", { type: "submit", class: "btn primary answer-submit", disabled: readOnly(item.tool), text: form.questions.length > 1 ? "Review answers" : `Submit: ${verb.label}` })));
|
|
1294
|
+
return el;
|
|
1295
|
+
}
|
|
1296
|
+
function openAnswer(item, verb) {
|
|
1297
|
+
if (verb && chosenForm.get(item.key) !== verb.id) { chosenForm.set(item.key, verb.id); peekSig = ""; }
|
|
1298
|
+
if (state.peekKey !== `item:${item.key}`) { selectKey(item.key, false); openPeek(item.key); }
|
|
1299
|
+
else renderPeek();
|
|
1300
|
+
$("sheet-body").querySelector(".answer-form input, .answer-form button")?.focus();
|
|
1301
|
+
}
|
|
1302
|
+
async function submitAnswer(item, verb, answers) {
|
|
1303
|
+
const draftKey = `${item.key}\n${verb.id}`;
|
|
1304
|
+
const draft = draftFor(draftKey);
|
|
1305
|
+
const reason = str(draft.reason).trim();
|
|
1306
|
+
const result = await runAction(item.tool, { action: verb.id, id: item.id, answers, ...(reason ? { reason } : {}) }, { label: verb.label, inbox: false });
|
|
1307
|
+
if (result?.ok) {
|
|
1308
|
+
drafts.delete(draftKey);
|
|
1309
|
+
const list = sortedItems();
|
|
1310
|
+
const next = list[list.findIndex((x) => x.key === item.key) + 1];
|
|
1311
|
+
await pollInbox();
|
|
1312
|
+
// Advance only if the peek still shows this item: the person may have closed it or moved on while it was sent.
|
|
1313
|
+
if (next && state.peekKey === `item:${item.key}` && !sortedItems().some((x) => x.key === item.key)) { selectKey(next.key); openPeek(next.key); }
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
// A stale or refused answer: show the tool's error, refresh, and keep the draft so the person sees what they chose.
|
|
1317
|
+
if (result?.cancelled) return;
|
|
1318
|
+
draft.error = str(result?.error) || "The answer was not accepted.";
|
|
1319
|
+
draft.step = "edit";
|
|
1320
|
+
await pollInbox();
|
|
1321
|
+
if (state.peekKey === `item:${item.key}`) { peekSig = ""; renderPeek(); }
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// ---------- results and seen receipts (#27) ----------
|
|
1325
|
+
/**
|
|
1326
|
+
* A result counts as seen once its body's end has been in view for `holdMs` in a visible, focused window, at most once
|
|
1327
|
+
* per item per page session. `tick(key)` gets the key whose body end is in view now, or null.
|
|
1328
|
+
*/
|
|
1329
|
+
function seenWatch({ now, visible, focused, fire, holdMs = 1000 }) {
|
|
1330
|
+
const done = new Set();
|
|
1331
|
+
let key = null;
|
|
1332
|
+
let since = 0;
|
|
1333
|
+
return {
|
|
1334
|
+
done,
|
|
1335
|
+
tick(inView) {
|
|
1336
|
+
if (!inView || done.has(inView) || !visible() || !focused()) { key = null; return; }
|
|
1337
|
+
if (key !== inView) { key = inView; since = now(); return; }
|
|
1338
|
+
if (now() - since >= holdMs) { done.add(key); key = null; fire(inView); }
|
|
1339
|
+
},
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
const readResults = new Map();
|
|
1343
|
+
const seen = seenWatch({ now: () => Date.now(), visible: () => document.visibilityState === "visible", focused: () => document.hasFocus(), fire: (key) => sendSeen(key, false) });
|
|
1344
|
+
let seenTimer = null;
|
|
1345
|
+
let watching = null;
|
|
1346
|
+
function watchSeen(key) {
|
|
1347
|
+
if (key && key === watching) return;
|
|
1348
|
+
// Stopping or switching drops any pending dwell, so a reopened result needs its full second again.
|
|
1349
|
+
seen.tick(null);
|
|
1350
|
+
watching = key;
|
|
1351
|
+
clearInterval(seenTimer);
|
|
1352
|
+
seenTimer = key ? setInterval(() => {
|
|
1353
|
+
const end = $("sheet").hidden ? null : $("sheet-body").querySelector(".seen-end");
|
|
1354
|
+
if (!end || state.peekKey !== `item:${key}`) { seen.tick(null); return; }
|
|
1355
|
+
const r = end.getBoundingClientRect();
|
|
1356
|
+
const box = $("sheet-body").getBoundingClientRect();
|
|
1357
|
+
seen.tick(r.top >= box.top - 1 && r.bottom <= Math.min(box.bottom, innerHeight) + 1 ? key : null);
|
|
1358
|
+
}, 250) : null;
|
|
1359
|
+
}
|
|
1360
|
+
async function sendSeen(key, explicit) {
|
|
1361
|
+
// A read-only node refuses every action, seen receipts included: send nothing, record nothing.
|
|
1362
|
+
const item = visibleItems().find((i) => i.key === key);
|
|
1363
|
+
if (readOnly(item?.tool)) return false;
|
|
1364
|
+
if (!item?.seen) return false;
|
|
1365
|
+
readResults.set(key, item);
|
|
1366
|
+
const r = await post(`/p/${item.tool}/action`, { action: item.seen.action, id: item.id, seenAt: new Date().toISOString(), ...(explicit ? { explicit: true } : {}) });
|
|
1367
|
+
if (!r.data?.ok) { clientError(item.tool, "seen receipt", new Error(str(r.data?.error) || `status ${r.status}`)); return false; }
|
|
1368
|
+
if (!explicit) await pollInbox();
|
|
1369
|
+
return true;
|
|
1370
|
+
}
|
|
1371
|
+
async function markAllRead(group) {
|
|
1372
|
+
const items = group.filter((i) => i.seen && !readOnly(i.tool));
|
|
1373
|
+
const sent = await Promise.all(items.map((i) => { seen.done.add(i.key); return sendSeen(i.key, true); }));
|
|
1374
|
+
await pollInbox();
|
|
1375
|
+
toast(`Marked ${plural(sent.filter(Boolean).length, "result")} read`);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function snooze(item) {
|
|
1379
|
+
const sn = snoozes();
|
|
1380
|
+
sn[item.key] = { at: item.at, until: Date.now() + SNOOZE_MS };
|
|
1381
|
+
store("acon.snooze", sn);
|
|
1382
|
+
moveSelection(1);
|
|
1383
|
+
renderInbox(true);
|
|
1384
|
+
renderRail();
|
|
1385
|
+
toast(`Snoozed for 1 h: ${item.title}`, { undo: () => { const s = snoozes(); delete s[item.key]; store("acon.snooze", s); renderInbox(true); renderRail(); } });
|
|
1386
|
+
}
|
|
1387
|
+
function openItem(item) {
|
|
1388
|
+
closeSheet();
|
|
1389
|
+
navigate(item.tool, item.link.panel, item.link.params);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// ---------- sheet (peek and panel sheets) ----------
|
|
1393
|
+
let sheetReturn = null;
|
|
1394
|
+
function showSheet(title, key, moveFocus) {
|
|
1395
|
+
if ($("sheet").hidden) sheetReturn = document.activeElement;
|
|
1396
|
+
state.peekKey = key;
|
|
1397
|
+
$("sheet-title").textContent = title;
|
|
1398
|
+
$("sheet").hidden = false;
|
|
1399
|
+
$("app").classList.add("peeking");
|
|
1400
|
+
if (moveFocus) $("sheet-close").focus();
|
|
1401
|
+
return $("sheet-body");
|
|
1402
|
+
}
|
|
1403
|
+
function closeSheet() {
|
|
1404
|
+
peekSig = "";
|
|
1405
|
+
watchSeen(null);
|
|
1406
|
+
if ($("sheet").hidden) return;
|
|
1407
|
+
$("sheet").hidden = true;
|
|
1408
|
+
$("app").classList.remove("peeking");
|
|
1409
|
+
if (reasonBox && $("sheet").contains(reasonBox.el)) closeReason(false);
|
|
1410
|
+
$("sheet-body").replaceChildren();
|
|
1411
|
+
state.peekKey = null;
|
|
1412
|
+
if (sheetReturn?.isConnected) sheetReturn.focus({ preventScroll: true });
|
|
1413
|
+
sheetReturn = null;
|
|
1414
|
+
}
|
|
1415
|
+
function openPeek(key) {
|
|
1416
|
+
state.peekKey = `item:${key}`;
|
|
1417
|
+
renderPeek();
|
|
1418
|
+
}
|
|
1419
|
+
let peekSig = "";
|
|
1420
|
+
function renderPeek() {
|
|
1421
|
+
const key = state.peekKey?.slice(5);
|
|
1422
|
+
const live = sortedItems().find((i) => i.key === key);
|
|
1423
|
+
// A result that was just marked seen stays readable in the open peek.
|
|
1424
|
+
const item = live ?? readResults.get(key);
|
|
1425
|
+
const sig = JSON.stringify([key, item ?? null, Boolean(live), readOnly()]);
|
|
1426
|
+
if (sig === peekSig && !$("sheet").hidden) return;
|
|
1427
|
+
peekSig = sig;
|
|
1428
|
+
// Keep keyboard focus on the same control when the data changes under it.
|
|
1429
|
+
const old = [...$("sheet-body").querySelectorAll("button")];
|
|
1430
|
+
const focusAt = old.indexOf(document.activeElement);
|
|
1431
|
+
const reasonFocused = Boolean(reasonBox) && reasonBox.el.contains(document.activeElement);
|
|
1432
|
+
const formInputs = () => [...$("sheet-body").querySelectorAll(".answer-form input")];
|
|
1433
|
+
const inputAt = formInputs().indexOf(document.activeElement);
|
|
1434
|
+
const body = showSheet(item ? item.title : "No longer waiting", state.peekKey, false);
|
|
1435
|
+
const draft = drafts.get(`${key}\n${chosenForm.get(key) ?? ""}`) ?? [...drafts].find(([k]) => k.startsWith(`${key}\n`))?.[1];
|
|
1436
|
+
if (!item) {
|
|
1437
|
+
// A withdrawn question keeps the person's draft on screen, so they can see what they had chosen.
|
|
1438
|
+
const gone = draft?.verb ? formAnswers(draft.verb.form, draft) : null;
|
|
1439
|
+
body.replaceChildren(h("p", { class: "sub", text: "This item was handled or withdrawn." }),
|
|
1440
|
+
...(gone?.answers ? [h("dl", { class: "kv" }, draft.verb.form.questions.flatMap((q) => [h("dt", { text: q.header ?? q.prompt }), h("dd", { text: choiceText(q, gone.answers[q.id]) })]))] : []));
|
|
1441
|
+
watchSeen(null);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
const fv = live ? formVerb(item) : null;
|
|
1445
|
+
if (fv) { chosenForm.set(key, fv.id); draftFor(`${key}\n${fv.id}`).verb = fv; }
|
|
1446
|
+
const dl = h("dl", { class: "kv" });
|
|
1447
|
+
const add = (k, v) => { if (v) dl.append(h("dt", { text: k }), h("dd", { text: v })); };
|
|
1448
|
+
add("Kind", item.kind); add("Tool", toolById(item.tool)?.title ?? item.tool); add("Project", item.project); add("From", item.actor);
|
|
1449
|
+
add("Waiting since", `${new Date(item.at).toLocaleString()} (${ago(item.at)})`);
|
|
1450
|
+
body.replaceChildren(...[
|
|
1451
|
+
h("div", { class: "sheet-verbs" }, item.chip ? h("span", { class: `chip ${toneClass(item.chip.tone)}`, text: str(item.chip.text) }) : null, h("span", { class: "chip", text: GROUPS.find(([g]) => g === item.group)?.[1] })),
|
|
1452
|
+
item.detail ? h("p", { class: `detail${item.group === "result" ? " result-body" : ""}`, text: item.detail }) : null,
|
|
1453
|
+
item.group === "result" && item.seen ? h("span", { class: "seen-end", "aria-hidden": "true" }) : null,
|
|
1454
|
+
!live ? h("p", { class: "sub", text: "Marked read." }) : null,
|
|
1455
|
+
dl,
|
|
1456
|
+
fv ? answerForm(item, fv, draftFor(`${key}\n${fv.id}`), (answers) => submitAnswer(item, fv, answers), (focus) => { peekSig = ""; renderPeek(); if (focus) $("sheet-body").querySelector(".answer-form input, .answer-form button")?.focus(); }) : null,
|
|
1457
|
+
live ? h("div", { class: "sheet-verbs item-verbs" }, verbButtons({ ...item, verbs: item.verbs.filter((v) => v !== fv) }, 8)) : null,
|
|
1458
|
+
h("div", { class: "sheet-verbs" }, h("button", { type: "button", class: "btn", onclick: () => openItem(item) }, h("span", { text: `Open in ${toolById(item.tool)?.title ?? item.tool}` }), icon("go"))),
|
|
1459
|
+
].filter(Boolean));
|
|
1460
|
+
if (focusAt >= 0 && !reasonFocused) (body.querySelectorAll("button")[focusAt] ?? $("sheet-close")).focus({ preventScroll: true });
|
|
1461
|
+
if (inputAt >= 0) formInputs()[inputAt]?.focus({ preventScroll: true });
|
|
1462
|
+
if (reasonBox?.key === key) placeReason(reasonFocused);
|
|
1463
|
+
watchSeen(live && item.group === "result" && item.seen ? key : null);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// ---------- tool panels ----------
|
|
1467
|
+
function safe(tool, where, run) {
|
|
1468
|
+
try { return run(); } catch (error) { clientError(tool, where, error); return undefined; }
|
|
1469
|
+
}
|
|
1470
|
+
function staticAssetFrame(url, title) {
|
|
1471
|
+
const frame = h("iframe", { class: "ui-frame", src: "/asset-viewer.html", sandbox: "allow-scripts", title, referrerpolicy: "no-referrer" });
|
|
1472
|
+
const status = h("p", { class: "sub", role: "status", text: "Loading attachment..." });
|
|
1473
|
+
const wrapper = h("div", {}, frame, status, h("a", { class: "ui-link", href: url, target: "_blank", rel: "noopener noreferrer", text: "Open attachment" }));
|
|
1474
|
+
const receive = async (event) => {
|
|
1475
|
+
if (event.source !== frame.contentWindow || event.data?.kind !== "acon-asset-ready") return;
|
|
1476
|
+
window.removeEventListener("message", receive);
|
|
1477
|
+
try {
|
|
1478
|
+
const response = await fetch(url, { credentials: "include", signal: AbortSignal.timeout(10000), redirect: "error" });
|
|
1479
|
+
const mime = response.headers.get("content-type")?.split(";")[0];
|
|
1480
|
+
if (!response.ok || !["application/pdf", "image/png", "image/jpeg", "text/plain", "text/csv"].includes(mime)) throw new Error("unavailable attachment");
|
|
1481
|
+
const chunks = [];
|
|
1482
|
+
let length = 0;
|
|
1483
|
+
const reader = response.body.getReader();
|
|
1484
|
+
for (;;) {
|
|
1485
|
+
const { value, done } = await reader.read();
|
|
1486
|
+
if (done) break;
|
|
1487
|
+
length += value.byteLength;
|
|
1488
|
+
if (length > 32 * 1024 * 1024) { await reader.cancel(); throw new Error("oversized attachment"); }
|
|
1489
|
+
chunks.push(value);
|
|
1490
|
+
}
|
|
1491
|
+
const bytes = new Uint8Array(length);
|
|
1492
|
+
let at = 0;
|
|
1493
|
+
for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.byteLength; }
|
|
1494
|
+
frame.contentWindow.postMessage({ kind: "acon-asset", mime, bytes: bytes.buffer }, "*", [bytes.buffer]);
|
|
1495
|
+
status.textContent = "";
|
|
1496
|
+
} catch { status.textContent = "Attachment unavailable; check this node's authentication in System or use Open attachment."; }
|
|
1497
|
+
};
|
|
1498
|
+
window.addEventListener("message", receive);
|
|
1499
|
+
setTimeout(() => window.removeEventListener("message", receive), 15000);
|
|
1500
|
+
frame.addEventListener("load", () => { if (!frame.isConnected) window.removeEventListener("message", receive); });
|
|
1501
|
+
return wrapper;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function hostFor(tool, p) {
|
|
1505
|
+
return {
|
|
1506
|
+
tool: tool.rawTool ?? tool.tool,
|
|
1507
|
+
node: tool.node ?? NODE,
|
|
1508
|
+
mount: p.body,
|
|
1509
|
+
ui: kitFor(tool.tool, hashFor, pluginPath, { frameAsset: staticTransport ? staticAssetFrame : undefined, openSheet: (title) => { peekSig = ""; const b = showSheet(String(title), `panel:${tool.tool}`, true); b.replaceChildren(); return b; }, onError: (error) => clientError(tool.tool, "review comment", error), theme: () => currentTheme().mode, onTheme: (cb) => { p.themeListeners.push(cb); } }),
|
|
1510
|
+
get theme() { return currentTheme(); },
|
|
1511
|
+
snapshot: async (extra = {}) => (await post(`/p/${tool.tool}/snapshot`, { after: 0, panel: p.panel ?? panelsOf(tool)[0].id, ...extra })).data,
|
|
1512
|
+
action: (input) => runAction(tool.tool, input, {}),
|
|
1513
|
+
navigate: (t, panel, params) => navigate(tool.node && !t.includes(":") ? `${tool.node}:${t}` : t, panel, params),
|
|
1514
|
+
onSnapshot: (cb) => { p.snapListeners.push(cb); },
|
|
1515
|
+
onTheme: (cb) => { p.themeListeners.push(cb); },
|
|
1516
|
+
toast: (text, options = {}) => toast(String(text), options),
|
|
1517
|
+
sheet: { open: (title) => { peekSig = ""; const b = showSheet(String(title), `panel:${tool.tool}`, true); b.replaceChildren(); return b; }, close: closeSheet },
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
async function showTool(r) {
|
|
1521
|
+
const tool = toolById(r.tool);
|
|
1522
|
+
let p = state.panels.get(tool.tool);
|
|
1523
|
+
if (!p) {
|
|
1524
|
+
const banner = h("p", { class: "notice", role: "status", hidden: true });
|
|
1525
|
+
const body = h("div", { class: "panel-body" });
|
|
1526
|
+
p = { tool: tool.tool, mount: h("div", { class: "panel-mount", "data-tool": tool.tool }, banner, body), banner, body, snapListeners: [], themeListeners: [], instance: null, panel: null, cursor: 0, next: 0, busy: false, loading: null, view: "", gen: 0, lastSig: "" };
|
|
1527
|
+
state.panels.set(tool.tool, p);
|
|
1528
|
+
$("main").append(p.mount);
|
|
1529
|
+
const styleReady = staticTransport ? staticTransport.loadStyle(`/p/${tool.tool}/panel.css`, pinnedRefs) : Promise.resolve(document.head.append(h("link", { rel: "stylesheet", href: pluginPath(`/p/${tool.tool}/panel.css`) })));
|
|
1530
|
+
p.loading = styleReady.then(() => import(pluginPath(`/p/${tool.tool}/panel.js`))).then((mod) => {
|
|
1531
|
+
if (typeof mod.default !== "function") throw new Error("panel.js has no default export");
|
|
1532
|
+
p.instance = mod.default(hostFor(tool, p));
|
|
1533
|
+
}).catch((error) => {
|
|
1534
|
+
clientError(tool.tool, "panel load", error);
|
|
1535
|
+
p.body.replaceChildren(h("p", { class: "notice", role: "status" }, h("span", { text: `The ${tool.title} panels could not load. ` }), h("a", { href: hashFor("system"), text: "Details in System" })));
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
p.mount.hidden = false;
|
|
1539
|
+
await p.loading;
|
|
1540
|
+
if (state.route.area !== "tool" || state.route.tool !== tool.tool || !p.instance) return;
|
|
1541
|
+
const view = JSON.stringify([r.panel, r.params]);
|
|
1542
|
+
if (p.view !== view) { p.cursor = 0; p.view = view; p.gen += 1; }
|
|
1543
|
+
p.panel = r.panel;
|
|
1544
|
+
p.lastSig = "";
|
|
1545
|
+
safe(tool.tool, "show", () => p.instance.show?.(r.panel, r.params));
|
|
1546
|
+
p.next = 0;
|
|
1547
|
+
pollPanel();
|
|
1548
|
+
}
|
|
1549
|
+
async function pollPanel() {
|
|
1550
|
+
const r = state.route;
|
|
1551
|
+
if (r.area !== "tool") return;
|
|
1552
|
+
const p = state.panels.get(r.tool);
|
|
1553
|
+
if (!p?.instance || p.busy || Date.now() < p.next) return;
|
|
1554
|
+
const tool = toolById(r.tool);
|
|
1555
|
+
p.busy = true;
|
|
1556
|
+
const gen = p.gen;
|
|
1557
|
+
const res = await post(`/p/${r.tool}/snapshot`, { ...r.params, after: p.cursor, panel: r.panel });
|
|
1558
|
+
p.busy = false;
|
|
1559
|
+
p.next = Date.now() + Math.min(60000, Math.max(2000, tool?.pollMs ?? 5000));
|
|
1560
|
+
// A response for an earlier panel or item never reaches the view now shown.
|
|
1561
|
+
if (state.route.tool !== r.tool) return;
|
|
1562
|
+
if (gen !== p.gen) { p.next = 0; pollPanel(); return; }
|
|
1563
|
+
if (res.status === 200) {
|
|
1564
|
+
p.banner.hidden = true;
|
|
1565
|
+
if (typeof res.data.cursor === "number") p.cursor = res.data.cursor;
|
|
1566
|
+
// Listeners hear only changed data, so an unchanged poll never re-renders the panel (focus and scroll stay).
|
|
1567
|
+
const sig = JSON.stringify(res.data);
|
|
1568
|
+
if (sig === p.lastSig) return;
|
|
1569
|
+
p.lastSig = sig;
|
|
1570
|
+
for (const cb of p.snapListeners) safe(r.tool, "onSnapshot", () => cb(res.data));
|
|
1571
|
+
} else if (res.status) {
|
|
1572
|
+
p.banner.hidden = false;
|
|
1573
|
+
p.banner.replaceChildren(icon("alert"), h("span", { text: `${tool?.title ?? r.tool} could not refresh. ` }), h("a", { href: hashFor("system"), text: "Details in System" }));
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// ---------- actions and confirm ----------
|
|
1578
|
+
async function runAction(tool, input, opts) {
|
|
1579
|
+
if (readOnly(tool)) { toast("This console is read-only; actions are turned off.", { tone: "err" }); return { ok: false, readOnly: true }; }
|
|
1580
|
+
const r = await post(`/p/${tool}/action`, input);
|
|
1581
|
+
const d = r.data;
|
|
1582
|
+
if (d?.needsConfirm) {
|
|
1583
|
+
// One confirmation at a time: a second preview never replaces an open one.
|
|
1584
|
+
if (confirmResolve || confirmEl.open) { toast("Finish the open confirmation first.", { tone: "err" }); return { ok: false, busy: true }; }
|
|
1585
|
+
return confirmFlow(tool, input, d, opts);
|
|
1586
|
+
}
|
|
1587
|
+
if (d?.ok) {
|
|
1588
|
+
toast(str(d.message) || `${opts.label ?? "Done"}: done`, d.undo && str(d.undo.action) ? { undo: () => runAction(tool, d.undo, { label: "Undo", mini: opts.mini }) } : {});
|
|
1589
|
+
afterAction(tool, opts);
|
|
1590
|
+
} else if (r.status !== 401) toast(str(d?.error) || "The action failed.", { tone: "err" });
|
|
1591
|
+
return d;
|
|
1592
|
+
}
|
|
1593
|
+
function afterAction(tool, opts = {}) {
|
|
1594
|
+
const p = state.panels.get(tool);
|
|
1595
|
+
// A snapshot already in flight predates the action: discard it (pollPanel re-polls on a generation change).
|
|
1596
|
+
if (p) { p.next = 0; p.gen += 1; }
|
|
1597
|
+
pollPanel();
|
|
1598
|
+
if (opts.inbox !== false && toolById(tool)?.capabilities?.inbox) pollInbox();
|
|
1599
|
+
}
|
|
1600
|
+
async function confirmFlow(tool, input, first, opts) {
|
|
1601
|
+
let current = first;
|
|
1602
|
+
let note = "";
|
|
1603
|
+
for (;;) {
|
|
1604
|
+
const choice = await showConfirm(tool, current, note, opts);
|
|
1605
|
+
if (choice === "cancel") return { ok: false, cancelled: true };
|
|
1606
|
+
const r = await post(`/p/${tool}/action`, choice === "confirm" ? { ...input, confirmToken: current.confirmToken } : input);
|
|
1607
|
+
const d = r.data;
|
|
1608
|
+
if (d?.needsConfirm) { current = d; note = d.changed ? "Something changed since the preview. Review it again." : ""; continue; }
|
|
1609
|
+
if (d?.stale) { current = { ...current, expired: true }; note = "This preview expired or was already used. Refresh it to continue."; continue; }
|
|
1610
|
+
closeConfirm();
|
|
1611
|
+
if (d?.ok) {
|
|
1612
|
+
toast(str(d.message) || `${current.verb ?? opts.label ?? "Done"}: done`, d.undo && str(d.undo.action) ? { undo: () => runAction(tool, d.undo, { label: "Undo", mini: opts.mini }) } : {});
|
|
1613
|
+
afterAction(tool, opts);
|
|
1614
|
+
} else if (r.status !== 401) toast(str(d?.error) || "The action failed.", { tone: "err" });
|
|
1615
|
+
return d;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
let confirmResolve = null;
|
|
1619
|
+
let confirmTimer = null;
|
|
1620
|
+
let confirmBusy = false;
|
|
1621
|
+
function closeConfirm() {
|
|
1622
|
+
confirmBusy = false;
|
|
1623
|
+
clearInterval(confirmTimer);
|
|
1624
|
+
state.confirmOpen = false;
|
|
1625
|
+
const dlg = confirmEl;
|
|
1626
|
+
if (dlg.open) dlg.close();
|
|
1627
|
+
}
|
|
1628
|
+
function showConfirm(tool, d, note, opts) {
|
|
1629
|
+
confirmBusy = false;
|
|
1630
|
+
return new Promise((resolve) => {
|
|
1631
|
+
confirmResolve = resolve;
|
|
1632
|
+
const dlg = confirmEl;
|
|
1633
|
+
// opts.mini (set by Mini and carried into its Undo) opens the confirmation in Mini's window; otherwise in the console.
|
|
1634
|
+
// Reparent only when the window changes, closing first: moving an open modal drops it from the top layer.
|
|
1635
|
+
const home = pip && opts.mini ? pip.document.body : document.body;
|
|
1636
|
+
if (dlg.parentNode !== home) { if (dlg.open) dlg.close(); home.append(dlg); }
|
|
1637
|
+
const verb = str(d.verb) || opts.label || "Confirm";
|
|
1638
|
+
const deadline = Date.now() + (d.validMs ?? 60000);
|
|
1639
|
+
const OPS = { create: ["Create", "ok"], modify: ["Modify", "accent"], delete: ["Delete", "err"], log: ["Log", ""] };
|
|
1640
|
+
const changes = Array.isArray(d.changes) && d.changes.length ? h("ul", { class: "changes" }, d.changes.map((c) => h("li", {}, h("span", { class: `chip ${OPS[c.op]?.[1] ?? ""}`, text: OPS[c.op]?.[0] ?? str(c.op) }), h("span", { class: "target", text: str(c.target) }), c.note ? h("span", { class: "sub", text: str(c.note) }) : null))) : null;
|
|
1641
|
+
const lines = (Array.isArray(d.preview) ? d.preview : []).map(str);
|
|
1642
|
+
const validity = h("p", { class: "validity", role: "status" });
|
|
1643
|
+
const typed = str(d.confirmText);
|
|
1644
|
+
const typedInput = typed ? h("input", { type: "text", autocomplete: "off", spellcheck: "false", "aria-describedby": "typed-label" }) : null;
|
|
1645
|
+
const cancel = h("button", { type: "button", class: "btn", onclick: () => finish("cancel"), text: "Cancel" });
|
|
1646
|
+
const ok = h("button", { type: "button", class: "btn primary danger", onclick: () => finish("confirm"), text: verb });
|
|
1647
|
+
const refresh = h("button", { type: "button", class: "btn", hidden: true, onclick: () => finish("refresh"), text: "Refresh preview" });
|
|
1648
|
+
const expired = () => d.expired || Date.now() >= deadline;
|
|
1649
|
+
const update = () => {
|
|
1650
|
+
const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
|
1651
|
+
validity.className = `validity${expired() ? " expired" : ""}`;
|
|
1652
|
+
validity.textContent = expired() ? `Preview of version ${shortFp(str(d.fingerprint))} expired.` : `Preview of version ${shortFp(str(d.fingerprint))} · valid ${left} s`;
|
|
1653
|
+
ok.disabled = readOnly(tool) || expired() || (typed && typedInput.value !== typed);
|
|
1654
|
+
refresh.hidden = !expired();
|
|
1655
|
+
};
|
|
1656
|
+
function finish(choice) {
|
|
1657
|
+
clearInterval(confirmTimer);
|
|
1658
|
+
if (choice === "cancel") closeConfirm();
|
|
1659
|
+
else {
|
|
1660
|
+
// The request is on its way and cannot be taken back: no Cancel, no Esc until it answers.
|
|
1661
|
+
ok.disabled = true; refresh.disabled = true; cancel.disabled = true; confirmBusy = true;
|
|
1662
|
+
validity.className = "validity";
|
|
1663
|
+
validity.textContent = choice === "confirm" ? "Working... this cannot be cancelled now." : "Refreshing the preview...";
|
|
1664
|
+
}
|
|
1665
|
+
confirmResolve = null;
|
|
1666
|
+
resolve(choice);
|
|
1667
|
+
}
|
|
1668
|
+
typedInput?.addEventListener("input", update);
|
|
1669
|
+
dlg.replaceChildren(
|
|
1670
|
+
h("div", { class: "dialog-body" },
|
|
1671
|
+
h("h2", { id: "confirm-title", text: `${verb}?` }),
|
|
1672
|
+
h("p", { class: "sub", text: `${toolById(tool)?.title ?? tool} asks you to confirm. Nothing changes until you do.` }),
|
|
1673
|
+
note ? h("p", { class: "notice", role: "alert", text: note }) : null,
|
|
1674
|
+
validity,
|
|
1675
|
+
changes ? h("h3", { text: "What will change" }) : null, changes,
|
|
1676
|
+
Array.isArray(d.warnings) && d.warnings.length ? h("ul", { class: "warnings" }, d.warnings.map((w) => h("li", { text: str(w) }))) : null,
|
|
1677
|
+
lines.length ? h("details", { class: "preview", open: changes ? undefined : true }, h("summary", { text: changes ? "Full preview" : "Preview" }), h("pre", { text: lines.join("\n") })) : null,
|
|
1678
|
+
typed ? h("label", { class: "typed", id: "typed-label" }, h("span", {}, "Type ", h("code", { text: typed }), " to confirm"), typedInput) : null),
|
|
1679
|
+
h("div", { class: "dialog-foot" }, refresh, cancel, ok));
|
|
1680
|
+
update();
|
|
1681
|
+
clearInterval(confirmTimer);
|
|
1682
|
+
confirmTimer = setInterval(update, 1000);
|
|
1683
|
+
if (!dlg.open) dlg.showModal();
|
|
1684
|
+
state.confirmOpen = true;
|
|
1685
|
+
(expired() ? refresh : cancel).focus();
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
confirmEl.addEventListener("cancel", (e) => { e.preventDefault(); if (confirmBusy) return; if (confirmResolve) { const r = confirmResolve; confirmResolve = null; closeConfirm(); r("cancel"); } else closeConfirm(); });
|
|
1689
|
+
|
|
1690
|
+
// ---------- toast ----------
|
|
1691
|
+
// A module reference, so toasts follow Mini into a Picture-in-Picture document.
|
|
1692
|
+
const toastsEl = $("toasts");
|
|
1693
|
+
function toast(text, { undo, tone } = {}) {
|
|
1694
|
+
const el = h("div", { class: `toast${tone === "err" ? " err" : ""}`, role: tone === "err" ? "alert" : "status" }, h("span", { text }));
|
|
1695
|
+
const remove = () => el.remove();
|
|
1696
|
+
if (undo) el.append(h("button", { type: "button", class: "btn", onclick: () => { remove(); undo(); }, text: "Undo" }));
|
|
1697
|
+
toastsEl.append(el);
|
|
1698
|
+
setTimeout(remove, undo ? 10000 : 5000);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// ---------- palette ----------
|
|
1702
|
+
let palSeq = 0;
|
|
1703
|
+
let palTimer = null;
|
|
1704
|
+
let palOptions = [];
|
|
1705
|
+
let palActive = 0;
|
|
1706
|
+
function openPalette() {
|
|
1707
|
+
const dlg = $("palette");
|
|
1708
|
+
if (dlg.open) return;
|
|
1709
|
+
palSeq += 1; // a search from an earlier opening never fills this one
|
|
1710
|
+
$("palette-input").value = "";
|
|
1711
|
+
dlg.showModal();
|
|
1712
|
+
renderPalette([]);
|
|
1713
|
+
$("palette-input").focus();
|
|
1714
|
+
}
|
|
1715
|
+
function parseQuery(text) {
|
|
1716
|
+
const q = { words: [], tool: null, node: null, needsMe: false, id: null };
|
|
1717
|
+
for (const token of text.trim().split(/\s+/).filter(Boolean)) {
|
|
1718
|
+
if (token.startsWith("tool:")) q.tool = token.slice(5);
|
|
1719
|
+
else if (token.startsWith("node:")) q.node = token.slice(5);
|
|
1720
|
+
else if (token === "is:needs-me") q.needsMe = true;
|
|
1721
|
+
else if (token.startsWith("#") && token.length > 1) q.id = token.slice(1);
|
|
1722
|
+
else q.words.push(token.toLowerCase());
|
|
1723
|
+
}
|
|
1724
|
+
return q;
|
|
1725
|
+
}
|
|
1726
|
+
const matches = (q, ...fields) => q.words.every((w) => fields.some((f) => str(f).toLowerCase().includes(w)));
|
|
1727
|
+
function commandOptions(q) {
|
|
1728
|
+
if (q.needsMe || q.id) return [];
|
|
1729
|
+
const out = [];
|
|
1730
|
+
const add = (title, sub, run, iconName = "go") => { if (matches(q, title, sub)) out.push({ title, sub, run, icon: iconName }); };
|
|
1731
|
+
add("Go to Inbox", "Everything waiting for you", () => navigate("inbox"), "inbox");
|
|
1732
|
+
for (const t of readyTools()) {
|
|
1733
|
+
if ((q.tool && q.tool !== t.tool && q.tool !== t.rawTool) || (q.node && q.node !== (t.node ?? NODE))) continue;
|
|
1734
|
+
for (const p of panelsOf(t)) add(`Go to ${t.title} / ${p.title}`, t.tool, () => navigate(t.tool, p.id), toolIcon(t));
|
|
1735
|
+
}
|
|
1736
|
+
if (!q.tool) {
|
|
1737
|
+
add("Go to System", "Node, plugins and errors", () => navigate("system"), "system");
|
|
1738
|
+
add("Theme: light", "Appearance", () => applyTheme("light"), "system");
|
|
1739
|
+
add("Theme: dark", "Appearance", () => applyTheme("dark"), "system");
|
|
1740
|
+
add("Theme: follow system", "Appearance", () => applyTheme("system"), "system");
|
|
1741
|
+
add("Keyboard shortcuts", "?", () => showKeys(), "system");
|
|
1742
|
+
}
|
|
1743
|
+
return out;
|
|
1744
|
+
}
|
|
1745
|
+
// Plugin commands run through action(), so a destructive one gets the same confirm dialog as a verb.
|
|
1746
|
+
function pluginCommandSections(q) {
|
|
1747
|
+
if (q.needsMe || q.id || (!staticTransport && q.node && q.node !== NODE)) return [];
|
|
1748
|
+
return readyTools().filter((t) => (!q.tool || q.tool === t.tool || q.tool === t.rawTool) && (!q.node || q.node === (t.node ?? NODE))).map((t) => [`${t.title} commands`,
|
|
1749
|
+
(Array.isArray(t.commands) ? t.commands : []).filter((c) => c && str(c.id) && str(c.title) && str(c.action) && matches(q, c.title, c.hint, t.title))
|
|
1750
|
+
.map((c) => ({ title: c.title, sub: [t.title, str(c.hint)].filter(Boolean).join(" · "), icon: toolIcon(t),
|
|
1751
|
+
run: () => runAction(t.tool, { ...(c.input && typeof c.input === "object" ? c.input : {}), action: c.action }, { label: c.title }) }))]);
|
|
1752
|
+
}
|
|
1753
|
+
function inboxOptions(q) {
|
|
1754
|
+
if (!staticTransport && q.node && q.node !== NODE) return [];
|
|
1755
|
+
return sortedItems().filter((i) => (!q.node || q.node === (toolById(i.tool)?.node ?? NODE)) && (!q.tool || q.tool === i.tool || q.tool === toolById(i.tool)?.rawTool) && (!q.needsMe || NEEDS_ME.has(i.group)) && (!q.id || i.id === q.id) && matches(q, i.title, i.kind, i.project, i.actor))
|
|
1756
|
+
.slice(0, 8).map((i) => ({ title: i.title, sub: subline(i), icon: i.group === "alert" ? "alert" : i.group === "decision" ? "you" : i.group, run: () => { navigate("inbox", undefined, { id: i.key }); }, peek: () => { navigate("inbox"); selectKey(i.key); openPeek(i.key); } }));
|
|
1757
|
+
}
|
|
1758
|
+
function renderPalette(searchGroups) {
|
|
1759
|
+
const q = parseQuery($("palette-input").value);
|
|
1760
|
+
const sections = [["Inbox", inboxOptions(q)], ...searchGroups, ...pluginCommandSections(q), ["Commands", commandOptions(q).slice(0, 12)]].filter(([, opts]) => opts.length);
|
|
1761
|
+
palOptions = sections.flatMap(([, opts]) => opts);
|
|
1762
|
+
palActive = Math.min(palActive, Math.max(0, palOptions.length - 1));
|
|
1763
|
+
let n = 0;
|
|
1764
|
+
const list = $("palette-list");
|
|
1765
|
+
list.replaceChildren(...(sections.length ? sections.flatMap(([label, opts]) => [
|
|
1766
|
+
h("li", { class: "heading", role: "presentation", text: label }),
|
|
1767
|
+
...opts.map((o) => {
|
|
1768
|
+
const i = n++;
|
|
1769
|
+
return h("li", { role: "option", id: `pal-${i}`, "aria-selected": String(i === palActive), onclick: () => runOption(i, false), onmousemove: () => setActive(i) },
|
|
1770
|
+
icon(o.icon), h("span", { class: "opt-text" }, h("span", { class: "opt-title", text: o.title }), o.sub ? h("span", { class: "opt-sub", text: o.sub }) : null));
|
|
1771
|
+
}),
|
|
1772
|
+
]) : [h("li", { class: "none", role: "presentation", text: "No matches. Try fewer words or another tool: filter." })]));
|
|
1773
|
+
$("palette-input").setAttribute("aria-activedescendant", palOptions.length ? `pal-${palActive}` : "");
|
|
1774
|
+
}
|
|
1775
|
+
function setActive(i) {
|
|
1776
|
+
palActive = i;
|
|
1777
|
+
for (const el of $("palette-list").querySelectorAll('[role="option"]')) el.setAttribute("aria-selected", String(el.id === `pal-${i}`));
|
|
1778
|
+
$("palette-input").setAttribute("aria-activedescendant", `pal-${i}`);
|
|
1779
|
+
$(`pal-${i}`)?.scrollIntoView({ block: "nearest" });
|
|
1780
|
+
}
|
|
1781
|
+
function runOption(i, peek) {
|
|
1782
|
+
const o = palOptions[i];
|
|
1783
|
+
if (!o) return;
|
|
1784
|
+
$("palette").close();
|
|
1785
|
+
(peek && o.peek ? o.peek : o.run)();
|
|
1786
|
+
}
|
|
1787
|
+
async function searchPalette() {
|
|
1788
|
+
const seq = ++palSeq;
|
|
1789
|
+
const q = parseQuery($("palette-input").value);
|
|
1790
|
+
const text = q.words.join(" ");
|
|
1791
|
+
const tools = readyTools().filter((t) => t.capabilities?.search && (!q.tool || q.tool === t.tool || q.tool === t.rawTool) && !q.needsMe && !q.id && (!q.node || q.node === (t.node ?? NODE)));
|
|
1792
|
+
if (text.length < 2 || !tools.length) { renderPalette([]); return; }
|
|
1793
|
+
renderPalette([]);
|
|
1794
|
+
const groups = await Promise.all(tools.map(async (t) => {
|
|
1795
|
+
const r = await post(`/p/${t.tool}/snapshot`, { after: 0, panel: "search", q: text, limit: 10 });
|
|
1796
|
+
const hits = r.status === 200 && Array.isArray(r.data.hits) ? r.data.hits.filter((x) => x && str(x.title) && x.link && str(x.link.panel)) : [];
|
|
1797
|
+
const total = typeof r.data.total === "number" ? r.data.total : hits.length;
|
|
1798
|
+
const label = `${t.title}${total > 3 ? ` · top 3 of ${total}` : ""}`;
|
|
1799
|
+
return [label, hits.slice(0, 3).map((x) => ({ title: x.title, sub: [x.kind, x.detail].filter(Boolean).join(" · "), icon: toolIcon(t), run: () => navigate(t.tool, x.link.panel, x.link.params) }))];
|
|
1800
|
+
}));
|
|
1801
|
+
if (seq === palSeq && $("palette").open) renderPalette(groups);
|
|
1802
|
+
}
|
|
1803
|
+
$("palette-input").addEventListener("input", () => { palSeq += 1; palActive = 0; renderPalette([]); clearTimeout(palTimer); palTimer = setTimeout(searchPalette, 150); });
|
|
1804
|
+
$("palette-input").addEventListener("keydown", (e) => {
|
|
1805
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (palOptions.length) setActive((palActive + (e.key === "ArrowDown" ? 1 : palOptions.length - 1)) % palOptions.length); }
|
|
1806
|
+
else if (e.key === "Enter") { e.preventDefault(); runOption(palActive, e.metaKey || e.ctrlKey); }
|
|
1807
|
+
});
|
|
1808
|
+
$("palette").addEventListener("click", (e) => { if (e.target === $("palette")) $("palette").close(); });
|
|
1809
|
+
|
|
1810
|
+
// ---------- keys dialog ----------
|
|
1811
|
+
const KEYS = [["Cmd/Ctrl K", "Search or run a command"], ["j / k", "Next / previous inbox item"], ["Space", "Peek the selected item"], ["Enter", "Open the selected item"], ["1", "Primary answer (allow, accept, approve)"], ["2", "Second answer (deny, reject); asks for a reason when the tool records one"], ["h", "Snooze for 1 hour"], ["Esc", "Close the sheet or dialog"], ["?", "This list"]];
|
|
1812
|
+
function showKeys() {
|
|
1813
|
+
const dlg = $("keys");
|
|
1814
|
+
dlg.replaceChildren(h("div", { class: "dialog-body" }, h("h2", { id: "keys-title", text: "Keyboard shortcuts" }),
|
|
1815
|
+
h("table", {}, h("tbody", {}, KEYS.map(([k, v]) => h("tr", {}, h("td", {}, h("kbd", { text: k })), h("td", { text: v })))))),
|
|
1816
|
+
h("div", { class: "dialog-foot" }, h("button", { type: "button", class: "btn", onclick: () => dlg.close(), text: "Close" })));
|
|
1817
|
+
dlg.showModal();
|
|
1818
|
+
dlg.querySelector(".dialog-foot .btn").focus();
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// ---------- Space snapshots (rendering is a private, lazy-loaded DOM module) ----------
|
|
1822
|
+
let spaceModule = null, spaceRenderer = null, spaceLoading = null;
|
|
1823
|
+
async function showSpace() {
|
|
1824
|
+
if (!spaceLoading) spaceLoading = import("/space.js").then((mod) => {
|
|
1825
|
+
spaceModule = mod;
|
|
1826
|
+
spaceRenderer = mod.createSpaceRenderer($("view-space"), { h, navigate, petSprite, pets: () => state.pets, packs: () => state.assetPacks, onError: (error) => clientError("acon", "space 3D", error) });
|
|
1827
|
+
}).catch((error) => {
|
|
1828
|
+
clientError("acon", "space load", error);
|
|
1829
|
+
$("view-space").replaceChildren(h("h1", { class: "lead", id: "space-lead", text: "Space" }), h("p", { class: "notice", text: "The space could not load. Details are in System." }));
|
|
1830
|
+
});
|
|
1831
|
+
await spaceLoading;
|
|
1832
|
+
if (!spaceRenderer || state.route.area !== "space") return;
|
|
1833
|
+
for (const tool of spaceTools()) {
|
|
1834
|
+
if (!state.space.has(tool.tool)) state.space.set(tool.tool, { scene: null, cursor: 0, next: 0, busy: false, failed: false });
|
|
1835
|
+
else state.space.get(tool.tool).next = 0;
|
|
1836
|
+
}
|
|
1837
|
+
renderSpace(true);
|
|
1838
|
+
pollSpace();
|
|
1839
|
+
}
|
|
1840
|
+
function renderSpace(force = false) {
|
|
1841
|
+
if (!spaceRenderer || state.route.area !== "space") return;
|
|
1842
|
+
const allowed = new Set(spaceTools().map((t) => t.tool));
|
|
1843
|
+
for (const key of state.space.keys()) if (!allowed.has(key)) state.space.delete(key);
|
|
1844
|
+
spaceRenderer.update(spaceModule.mergeSpaceScenes(state.space), force);
|
|
1845
|
+
}
|
|
1846
|
+
async function pollSpace() {
|
|
1847
|
+
if (state.route.area !== "space" || !spaceRenderer || state.ended) return;
|
|
1848
|
+
await Promise.all(spaceTools().map(async (tool) => {
|
|
1849
|
+
let entry = state.space.get(tool.tool);
|
|
1850
|
+
if (!entry) { entry = { scene: null, cursor: 0, next: 0, busy: false, failed: false }; state.space.set(tool.tool, entry); }
|
|
1851
|
+
if (entry.busy || Date.now() < entry.next) return;
|
|
1852
|
+
const startedAt = Date.now();
|
|
1853
|
+
const interval = Math.min(60000, Math.max(2000, tool.pollMs ?? 5000));
|
|
1854
|
+
const due = entry.next || startedAt;
|
|
1855
|
+
entry.next = due + (Math.floor((startedAt - due) / interval) + 1) * interval;
|
|
1856
|
+
entry.busy = true;
|
|
1857
|
+
const res = await post(`/p/${tool.tool}/snapshot`, { panel: "space", after: entry.cursor });
|
|
1858
|
+
entry.busy = false;
|
|
1859
|
+
if (res.status === 200 && res.data.scene) {
|
|
1860
|
+
entry.sampledAt = Date.now();
|
|
1861
|
+
entry.scene = res.data.scene;
|
|
1862
|
+
entry.dropped = res.data.dropped ?? { actors: 0, cards: 0, links: 0 };
|
|
1863
|
+
if (Number.isSafeInteger(res.data.cursor)) entry.cursor = res.data.cursor;
|
|
1864
|
+
entry.failed = false;
|
|
1865
|
+
} else entry.failed = true;
|
|
1866
|
+
}));
|
|
1867
|
+
renderSpace();
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// ---------- pet and Mini ----------
|
|
1871
|
+
// Codex v2 sheets: 8x11 cells of 192x208; the pose row follows inbox facts only, and idle never animates.
|
|
1872
|
+
const PET_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
1873
|
+
const POSE_TEXT = { waiting: "waiting on your decision", failed: "a tool needs attention", review: "something is ready for review", idle: "all clear" };
|
|
1874
|
+
const miniView = $("view-mini");
|
|
1875
|
+
let pip = null;
|
|
1876
|
+
function petChoice() {
|
|
1877
|
+
const choice = store("acon.pet");
|
|
1878
|
+
return choice === "off" ? null : state.pets.find((p) => p.id === choice) ?? state.pets[0] ?? null;
|
|
1879
|
+
}
|
|
1880
|
+
function petState() {
|
|
1881
|
+
const items = visibleItems();
|
|
1882
|
+
const has = (g) => items.some((i) => i.group === g);
|
|
1883
|
+
if (has("decision")) return "waiting";
|
|
1884
|
+
if (has("alert") || [...state.inbox.values()].some((e) => e.failed)) return "failed";
|
|
1885
|
+
if (has("review") || has("candidate")) return "review";
|
|
1886
|
+
return "idle";
|
|
1887
|
+
}
|
|
1888
|
+
// Oldest first across groups: Mini and the pet promise "the oldest items that need you".
|
|
1889
|
+
const needsMe = () => sortedItems().filter((i) => NEEDS_ME.has(i.group)).sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
1890
|
+
function petSprite(pet, pose, cls = "") {
|
|
1891
|
+
const el = h("span", { class: `pet-sprite ${cls}`.trim(), "data-pose": pose, "aria-hidden": "true" });
|
|
1892
|
+
// CSSOM, not a style attribute; absolute so the URL also resolves inside a Picture-in-Picture document.
|
|
1893
|
+
el.style.setProperty("--pet-sheet", `url("${location.origin}/pets/${pet.id}/sheet")`);
|
|
1894
|
+
return el;
|
|
1895
|
+
}
|
|
1896
|
+
function petLabel(pet, pose, count) { return `${pet ? `${pet.name}: ` : ""}${count ? `${count} need you, ` : ""}${POSE_TEXT[pose]}`; }
|
|
1897
|
+
let petSig = "";
|
|
1898
|
+
function renderPet() {
|
|
1899
|
+
const pet = petChoice(), pose = petState(), count = needsMeCount();
|
|
1900
|
+
const sig = JSON.stringify([pet?.id, pose, count]);
|
|
1901
|
+
if (sig === petSig) return;
|
|
1902
|
+
petSig = sig;
|
|
1903
|
+
const btn = $("pet");
|
|
1904
|
+
btn.hidden = !pet;
|
|
1905
|
+
if (!pet) { btn.replaceChildren(); return; }
|
|
1906
|
+
btn.setAttribute("aria-label", petLabel(pet, pose, count));
|
|
1907
|
+
btn.replaceChildren(...[petSprite(pet, pose), count ? h("span", { class: "badge", "aria-hidden": "true", text: String(count) }) : null].filter(Boolean));
|
|
1908
|
+
}
|
|
1909
|
+
function openOldestNeed() {
|
|
1910
|
+
const item = needsMe()[0];
|
|
1911
|
+
navigate("inbox");
|
|
1912
|
+
if (item) { selectKey(item.key); openPeek(item.key); }
|
|
1913
|
+
}
|
|
1914
|
+
// The console window: this tab, or the tab that opened this popup.
|
|
1915
|
+
function consoleWindow() {
|
|
1916
|
+
try { if (window.opener && !window.opener.closed && window.opener.location.origin === location.origin) return window.opener; } catch { /* cross-origin opener */ }
|
|
1917
|
+
return null;
|
|
1918
|
+
}
|
|
1919
|
+
function openInConsole(item) {
|
|
1920
|
+
const other = consoleWindow();
|
|
1921
|
+
if (other) { other.location.hash = hashFor("inbox", undefined, item ? { id: item.key } : undefined); other.focus(); return; }
|
|
1922
|
+
if (item) { navigate("inbox"); selectKey(item.key); openPeek(item.key); } else navigate("inbox");
|
|
1923
|
+
window.focus();
|
|
1924
|
+
}
|
|
1925
|
+
// One step, no reason, not destructive; everything else opens in the console, where confirm and reason rows live.
|
|
1926
|
+
const safeVerb = (item) => item.verbs.find((v) => v.primary && !v.destructive && !v.reason && !v.form);
|
|
1927
|
+
let miniSig = "";
|
|
1928
|
+
function renderMini(force = false) {
|
|
1929
|
+
if (miniView.hidden && !pip) return;
|
|
1930
|
+
const pet = petChoice(), pose = petState(), items = needsMe(), shown = items.slice(0, 5);
|
|
1931
|
+
const sig = JSON.stringify([pet?.id, pose, shown, items.length, Boolean(pip), readOnly()]);
|
|
1932
|
+
if (!force && sig === miniSig) return;
|
|
1933
|
+
miniSig = sig;
|
|
1934
|
+
const fkey = miniView.contains(miniView.ownerDocument.activeElement) ? miniView.ownerDocument.activeElement.dataset.fid : undefined;
|
|
1935
|
+
const lead = items.length ? `${items.length} ${items.length === 1 ? "needs" : "need"} you` : pose === "failed" ? "A tool needs attention" : "Nothing needs you";
|
|
1936
|
+
miniView.replaceChildren(...[
|
|
1937
|
+
h("header", { class: "mini-head" },
|
|
1938
|
+
pet ? petSprite(pet, pose, "large") : null,
|
|
1939
|
+
h("div", {}, h("h1", { class: "lead", id: "mini-lead", text: lead }), h("p", { class: "sub", role: "status", text: petLabel(pet, pose, 0) }))),
|
|
1940
|
+
shown.length ? h("ul", { class: "mini-list" }, shown.map((item) => {
|
|
1941
|
+
const verb = safeVerb(item);
|
|
1942
|
+
return h("li", { class: "mini-item" },
|
|
1943
|
+
h("span", { class: "row-text" }, h("span", { class: "row-title", text: item.title }), h("span", { class: "row-sub", text: [toolById(item.tool)?.title ?? item.tool, ago(item.at)].join(" · ") })),
|
|
1944
|
+
h("span", { class: "mini-verbs" },
|
|
1945
|
+
verb ? h("button", { type: "button", class: "btn primary", "data-fid": `v:${item.key}`, disabled: readOnly(item.tool), onclick: () => runVerb(item, verb, undefined, { mini: true }), text: verb.label }) : null,
|
|
1946
|
+
h("button", { type: "button", class: "btn", "data-fid": `o:${item.key}`, "aria-label": `Open ${item.title}`, onclick: () => openInConsole(item), text: "Open" })));
|
|
1947
|
+
})) : null,
|
|
1948
|
+
items.length > shown.length ? h("p", { class: "sub", text: `${items.length - shown.length} more in the inbox.` }) : null,
|
|
1949
|
+
h("footer", { class: "mini-foot" },
|
|
1950
|
+
h("button", { type: "button", class: "btn", "data-fid": "console", onclick: () => openInConsole(null), text: "Open console" }),
|
|
1951
|
+
pip || consoleWindow() ? null : h("button", { type: "button", class: "btn", "data-fid": "popout", onclick: popOutMini, text: "Pop out" })),
|
|
1952
|
+
].filter(Boolean));
|
|
1953
|
+
if (fkey) miniView.querySelector(`[data-fid="${CSS.escape(fkey)}"]`)?.focus({ preventScroll: true });
|
|
1954
|
+
}
|
|
1955
|
+
function syncPipTheme() {
|
|
1956
|
+
const theme = document.documentElement.dataset.theme;
|
|
1957
|
+
if (theme) pip.document.documentElement.dataset.theme = theme;
|
|
1958
|
+
else delete pip.document.documentElement.dataset.theme;
|
|
1959
|
+
}
|
|
1960
|
+
// Document Picture-in-Picture keeps Mini above other windows (Chromium); elsewhere a small popup.
|
|
1961
|
+
async function popOutMini() {
|
|
1962
|
+
if (pip) { pip.focus(); return; }
|
|
1963
|
+
if ("documentPictureInPicture" in window) {
|
|
1964
|
+
try { pip = await window.documentPictureInPicture.requestWindow({ width: 320, height: 440 }); } catch { pip = null; }
|
|
1965
|
+
if (pip) {
|
|
1966
|
+
const doc = pip.document;
|
|
1967
|
+
doc.title = "acon Mini";
|
|
1968
|
+
doc.head.append(h("meta", { name: "color-scheme", content: "light dark" }), h("link", { rel: "stylesheet", href: `${location.origin}/shell.css` }));
|
|
1969
|
+
doc.body.classList.add("pip");
|
|
1970
|
+
syncPipTheme();
|
|
1971
|
+
doc.body.append(miniView, toastsEl);
|
|
1972
|
+
miniView.hidden = false;
|
|
1973
|
+
renderMini(true);
|
|
1974
|
+
pip.addEventListener("pagehide", () => {
|
|
1975
|
+
// An open confirmation in the closing window is cancelled, never left pending.
|
|
1976
|
+
// Only a dialog that lives in the closing window comes back (cancelled if open); one open in the console stays modal.
|
|
1977
|
+
if (confirmEl.ownerDocument !== document) {
|
|
1978
|
+
if (confirmEl.open) confirmEl.dispatchEvent(new Event("cancel"));
|
|
1979
|
+
// A busy confirmation ignores cancel; close it anyway so a later preview reopens it modally in the console.
|
|
1980
|
+
if (confirmEl.open) confirmEl.close();
|
|
1981
|
+
document.body.append(confirmEl);
|
|
1982
|
+
}
|
|
1983
|
+
pip = null;
|
|
1984
|
+
$("main").append(miniView);
|
|
1985
|
+
document.body.append(toastsEl);
|
|
1986
|
+
miniView.hidden = state.ended || state.route.area !== "mini";
|
|
1987
|
+
if (!state.ended) renderMini(true);
|
|
1988
|
+
}, { once: true });
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
window.open(`${location.origin}${hashFor("mini")}`, "acon-mini", "popup,width=340,height=480");
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// ---------- system ----------
|
|
1996
|
+
let systemSig = "";
|
|
1997
|
+
function staticNodeCards() {
|
|
1998
|
+
return staticTransport.nodes.map((node) => {
|
|
1999
|
+
const input = h("input", { type: "password", autocomplete: "off", "data-fid": `token-${node.id}`, "aria-label": `Bearer token for ${node.id}`, oninput: (event) => { node.draft = event.target.value; } });
|
|
2000
|
+
input.value = node.draft ?? "";
|
|
2001
|
+
const reconnect = async (token) => {
|
|
2002
|
+
staticTransport.setToken(node.id, token);
|
|
2003
|
+
node.draft = "";
|
|
2004
|
+
await loadTools();
|
|
2005
|
+
route();
|
|
2006
|
+
await pollInbox();
|
|
2007
|
+
};
|
|
2008
|
+
return h("section", { class: "card", "data-node": node.id },
|
|
2009
|
+
h("h2", { text: node.id }),
|
|
2010
|
+
h("p", { class: "mono", text: node.url }),
|
|
2011
|
+
h("p", { role: "status", "data-node-status": node.id, text: `${node.status}: ${node.detail}` }),
|
|
2012
|
+
h("form", { class: "ui-form", onsubmit: (event) => { event.preventDefault(); void reconnect(input.value); } },
|
|
2013
|
+
h("label", {}, `Bearer token for ${node.id} (this tab only)`, input),
|
|
2014
|
+
h("button", { type: "submit", class: "btn primary", text: "Connect with token" })),
|
|
2015
|
+
h("p", {}, h("a", { href: node.url, target: "_blank", rel: "noopener noreferrer", text: "Open node to sign in with Access" })),
|
|
2016
|
+
h("button", { type: "button", class: "btn", onclick: () => { void reconnect(""); }, text: "Reconnect with Access cookies" }));
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function renderSystem(force = false) {
|
|
2021
|
+
const sig = JSON.stringify([state.node, state.tools, state.serverErrors, state.clientErrors, themeChoice(), state.offline, state.pets, petChoice()?.id ?? "off", staticTransport?.nodes.map((n) => [n.id, n.status, n.detail])]);
|
|
2022
|
+
if (!force && sig === systemSig && $("view-system").childElementCount) return;
|
|
2023
|
+
systemSig = sig;
|
|
2024
|
+
// Keep keyboard focus on the same control across re-renders.
|
|
2025
|
+
const fid = $("view-system").contains(document.activeElement) ? document.activeElement.dataset.fid : undefined;
|
|
2026
|
+
const node = state.node ?? {};
|
|
2027
|
+
const errors = [...state.serverErrors.map((e) => ({ ...e, where: e.where })), ...state.clientErrors.map((e) => ({ ...e, where: `${e.where} (browser)` }))].sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
|
2028
|
+
const choice = themeChoice();
|
|
2029
|
+
const statusChip = (s) => h("span", { class: `chip ${s === "ready" ? "ok" : s === "stale" ? "warn" : "err"}`, text: s });
|
|
2030
|
+
const dl = h("dl", { class: "kv" }, h("dt", { text: "Node" }), h("dd", { text: "This computer (local)" }), h("dt", { text: "Status" }), h("dd", { text: state.offline ? "Offline" : "Live" }),
|
|
2031
|
+
h("dt", { text: "acon" }), h("dd", { text: str(node.version) }), h("dt", { text: "Started" }), h("dd", { text: node.startedAt ? `${new Date(node.startedAt).toLocaleString()} (${ago(node.startedAt)} ago)` : "" }),
|
|
2032
|
+
h("dt", { text: "Plugins" }), h("dd", { class: "mono", text: str(node.pluginsDir) }));
|
|
2033
|
+
$("view-system").replaceChildren(
|
|
2034
|
+
h("h1", { class: "lead", id: "system-title", text: "System" }),
|
|
2035
|
+
h("p", { class: "sub", text: staticTransport ? "This static shell connects directly to each node. Credentials stay in this tab; actions run on their owner node." : "The console runs on this computer and stops 5 minutes after the last open tab goes away." }),
|
|
2036
|
+
...(staticTransport ? staticNodeCards() : []),
|
|
2037
|
+
h("div", { class: "cards" },
|
|
2038
|
+
h("section", { class: "card" }, h("h2", { text: "Node" }), dl),
|
|
2039
|
+
h("section", { class: "card" }, h("h2", { text: "Appearance" }),
|
|
2040
|
+
h("div", { class: "segmented", role: "group", "aria-label": "Theme" }, [["system", "System"], ["light", "Light"], ["dark", "Dark"]].map(([v, l]) => h("button", { type: "button", "data-fid": `theme-${v}`, "aria-pressed": String(choice === v), onclick: () => applyTheme(v), text: l }))),
|
|
2041
|
+
h("h3", { class: "card-sub", id: "pet-choice", text: "Pet" }),
|
|
2042
|
+
state.pets.length
|
|
2043
|
+
? h("div", { class: "segmented", role: "group", "aria-labelledby": "pet-choice" }, [["off", "Off"], ...state.pets.map((p) => [p.id, p.name])].map(([v, l]) => h("button", { type: "button", "data-fid": `pet-${v}`, "aria-pressed": String((petChoice()?.id ?? "off") === v), onclick: () => { store("acon.pet", v); renderPet(); renderMini(true); renderSystem(); }, text: l })))
|
|
2044
|
+
: h("p", { class: "sub", text: "No pet packages found. Put a Codex v2 pet (pet.json and its sprite sheet) in ~/.agenthub/console/pets/<id>/ or ~/.codex/pets/<id>/." }),
|
|
2045
|
+
h("p", { class: "sub" }, h("span", { text: "Get more with " }), h("code", { text: "acon install --list" }), h("span", { text: ", then run acon ui again." })),
|
|
2046
|
+
h("p", { class: "sub" }, h("button", { type: "button", class: "link", "data-fid": "keys", onclick: showKeys, text: "Keyboard shortcuts" }))),
|
|
2047
|
+
h("section", { class: "card wide" }, h("h2", { text: `Plugins (${state.tools.length})` }),
|
|
2048
|
+
node.pluginsError ? h("p", { class: "notice", role: "alert", text: node.pluginsError }) : null,
|
|
2049
|
+
state.tools.length ? h("ul", { class: "list" }, state.tools.map((t) => h("li", {}, h("strong", { text: t.title ?? t.tool }), h("span", { class: "meta mono", text: t.tool }), statusChip(t.status),
|
|
2050
|
+
h("span", { class: "meta", text: t.status === "ready" ? `${t.version} · ${plural(t.panels.length, "panel")}${t.capabilities?.inbox ? " · inbox" : ""}${t.capabilities?.search ? " · search" : ""}${t.update ? ` · ${t.update} is registered; reload the page to use it` : ""}` : str(t.reason) }))))
|
|
2051
|
+
: h("p", { class: "sub", text: "No plugin manifests were found. A tool registers itself with its setup command." })),
|
|
2052
|
+
h("section", { class: "card wide" }, h("h2", { text: `Errors (${errors.length})` }),
|
|
2053
|
+
errors.length ? h("ul", { class: "list" }, errors.map((e) => h("li", {}, h("span", { class: "meta mono", text: new Date(e.at).toLocaleTimeString() }), h("strong", { text: e.tool }), h("span", { class: "meta", text: e.where }), h("span", { text: e.message }))))
|
|
2054
|
+
: h("p", { class: "sub", text: "No errors since the console started." }))));
|
|
2055
|
+
if (fid) $("view-system").querySelector(`[data-fid="${fid}"]`)?.focus({ preventScroll: true });
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
async function loadTools() {
|
|
2059
|
+
const r = await post("/tools");
|
|
2060
|
+
if (r.status !== 200) return false;
|
|
2061
|
+
const incoming = Array.isArray(r.data.tools) ? r.data.tools : [];
|
|
2062
|
+
// Keep the metadata of the load this page uses; note a newer one so System can say a reload picks it up.
|
|
2063
|
+
state.tools = incoming.map((t) => {
|
|
2064
|
+
if (t.status !== "ready" || typeof t.ref !== "string") return t;
|
|
2065
|
+
if (!pinnedRefs.has(t.tool)) pinnedRefs.set(t.tool, t.ref);
|
|
2066
|
+
if (pinnedRefs.get(t.tool) === t.ref) return t;
|
|
2067
|
+
const kept = state.tools.find((o) => o.tool === t.tool && o.ref === pinnedRefs.get(t.tool));
|
|
2068
|
+
return kept ? { ...kept, update: str(t.version) } : t;
|
|
2069
|
+
});
|
|
2070
|
+
state.node = r.data.node ?? null;
|
|
2071
|
+
state.readOnly = r.data.readOnly === true;
|
|
2072
|
+
updateReadOnly();
|
|
2073
|
+
state.pets = Array.isArray(r.data.pets) ? r.data.pets.filter((p) => p && PET_ID.test(str(p.id)) && str(p.name)) : [];
|
|
2074
|
+
state.assetPacks = Array.isArray(r.data.assetPacks) ? r.data.assetPacks.filter((p) => p && PET_ID.test(str(p.id)) && Array.isArray(p.images) && p.images.every((id) => PET_ID.test(str(id))) && Array.isArray(p.tile) && p.tile.length === 2 && p.tile.every((n) => Number.isSafeInteger(n) && n > 0)) : [];
|
|
2075
|
+
state.serverErrors = Array.isArray(r.data.errors) ? r.data.errors : [];
|
|
2076
|
+
renderRail();
|
|
2077
|
+
if (state.route.area === "system") renderSystem();
|
|
2078
|
+
if (state.route.area === "space") {
|
|
2079
|
+
if (spaceTools().length) renderSpace();
|
|
2080
|
+
else navigate("inbox");
|
|
2081
|
+
}
|
|
2082
|
+
return true;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// ---------- session end ----------
|
|
2086
|
+
function endSession(message) {
|
|
2087
|
+
if (state.ended) return;
|
|
2088
|
+
state.ended = true;
|
|
2089
|
+
// Mini in Picture-in-Picture must not stay apparently actionable: closing it returns Mini to the page, where it is hidden below.
|
|
2090
|
+
if (pip) pip.close();
|
|
2091
|
+
closeSheet();
|
|
2092
|
+
for (const id of ["confirm", "palette", "keys"]) if ($(id).open) $(id).close();
|
|
2093
|
+
for (const v of document.querySelectorAll("main > .view, .panel-mount")) v.hidden = true;
|
|
2094
|
+
$("tabs").hidden = true;
|
|
2095
|
+
const ended = $("view-ended");
|
|
2096
|
+
ended.hidden = false;
|
|
2097
|
+
ended.replaceChildren(h("h1", { class: "lead", text: message }), state.serve
|
|
2098
|
+
? h("p", { class: "sub", text: "Reload the page to sign in again." })
|
|
2099
|
+
: h("p", { class: "sub" }, "Run ", h("code", { text: "acon ui" }), " in a terminal for a new link. Each link works once."));
|
|
2100
|
+
document.title = "acon (ended)";
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// ---------- keyboard ----------
|
|
2104
|
+
function typing(el) { return el && (el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)); }
|
|
2105
|
+
function panelRowsKey(e) {
|
|
2106
|
+
if (e.key !== "j" && e.key !== "k") return;
|
|
2107
|
+
const p = state.panels.get(state.route.tool);
|
|
2108
|
+
if (!p || (e.target !== $("main") && !p.mount.contains(e.target))) return;
|
|
2109
|
+
const list = e.target.closest?.(".ui-rows") ?? p.mount.querySelector(".ui-rows");
|
|
2110
|
+
if (!list) return;
|
|
2111
|
+
const rows = [...list.querySelectorAll(".ui-row-open")].filter((b) => !b.disabled && b.closest(".ui-rows") === list);
|
|
2112
|
+
if (!rows.length) return;
|
|
2113
|
+
const current = e.target.closest?.(".row")?.querySelector(".ui-row-open");
|
|
2114
|
+
const index = rows.indexOf(current);
|
|
2115
|
+
const next = index < 0 ? (e.key === "j" ? 0 : rows.length - 1) : Math.max(0, Math.min(rows.length - 1, index + (e.key === "j" ? 1 : -1)));
|
|
2116
|
+
e.preventDefault();
|
|
2117
|
+
rows[next].focus();
|
|
2118
|
+
}
|
|
2119
|
+
document.addEventListener("keydown", (e) => {
|
|
2120
|
+
if (state.ended) return;
|
|
2121
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); openPalette(); return; }
|
|
2122
|
+
if (document.querySelector("dialog[open]") || typing(e.target) || e.altKey || e.metaKey || e.ctrlKey) return;
|
|
2123
|
+
if (e.key === "?") { e.preventDefault(); showKeys(); return; }
|
|
2124
|
+
if (e.key === "Escape" && !$("sheet").hidden) { e.preventDefault(); closeSheet(); return; }
|
|
2125
|
+
if (state.route.area === "tool") { panelRowsKey(e); return; }
|
|
2126
|
+
if (state.route.area !== "inbox") return;
|
|
2127
|
+
const inPanelSheet = $("sheet").contains(e.target) && !state.peekKey?.startsWith("item:");
|
|
2128
|
+
if (inPanelSheet) return;
|
|
2129
|
+
const item = selectedItem();
|
|
2130
|
+
const onRow = e.target === document.body || e.target === $("main") || e.target.classList?.contains("row-main") || $("sheet").contains(e.target);
|
|
2131
|
+
if (!onRow) return;
|
|
2132
|
+
if (e.key === "j" || e.key === "ArrowDown") { e.preventDefault(); moveSelection(1); }
|
|
2133
|
+
else if (e.key === "k" || e.key === "ArrowUp") { e.preventDefault(); moveSelection(-1); }
|
|
2134
|
+
else if (!item) return;
|
|
2135
|
+
// Keys that act on an item must act on the one the peek shows (a just-read result stays shown after it left the list).
|
|
2136
|
+
else if ($("sheet").contains(e.target) && state.peekKey !== `item:${item.key}` && ["1", "2", "h", "Enter", " "].includes(e.key)) return;
|
|
2137
|
+
else if (e.key === " " && $("sheet").contains(e.target)) return; // Space presses the focused sheet button
|
|
2138
|
+
else if (e.key === " ") { e.preventDefault(); if (state.peekKey === `item:${item.key}`) closeSheet(); else openPeek(item.key); }
|
|
2139
|
+
else if (e.key === "Enter" && e.target.classList?.contains("row-main")) { e.preventDefault(); openItem(item); }
|
|
2140
|
+
else if (e.key === "1" || e.key === "2") {
|
|
2141
|
+
const verbs = [...item.verbs].sort((a, b) => Number(Boolean(b.primary)) - Number(Boolean(a.primary)));
|
|
2142
|
+
const verb = verbs[Number(e.key) - 1];
|
|
2143
|
+
// 1 on a form verb focuses the form in the peek, or submits it when focus is already on the form's button.
|
|
2144
|
+
const inForm = e.target.closest?.(".answer-form");
|
|
2145
|
+
if (verb?.form && inForm && verb.id === chosenForm.get(item.key)) { e.preventDefault(); inForm.querySelector(".answer-submit")?.click(); }
|
|
2146
|
+
else if (verb) { e.preventDefault(); runVerb(item, verb); }
|
|
2147
|
+
} else if (e.key === "h") { e.preventDefault(); snooze(item); }
|
|
2148
|
+
});
|
|
2149
|
+
$("sheet-close").append(icon("close"));
|
|
2150
|
+
$("brand-icon").replaceWith(icon("brand"));
|
|
2151
|
+
$("sheet-close").addEventListener("click", closeSheet);
|
|
2152
|
+
$("palette-open").addEventListener("click", openPalette);
|
|
2153
|
+
// The skip link moves focus only; "#main" must not reach the router.
|
|
2154
|
+
document.querySelector(".skip").addEventListener("click", (e) => { e.preventDefault(); $("main").focus(); });
|
|
2155
|
+
$("palette-open").prepend(icon("search"));
|
|
2156
|
+
$("mini-open").append(icon("popout"));
|
|
2157
|
+
$("mini-open").addEventListener("click", popOutMini);
|
|
2158
|
+
$("pet").addEventListener("click", openOldestNeed);
|
|
2159
|
+
$("palette-key").textContent = isMac ? "⌘K" : "Ctrl K";
|
|
2160
|
+
window.addEventListener("hashchange", route);
|
|
2161
|
+
|
|
2162
|
+
// ---------- loop ----------
|
|
2163
|
+
function tick() {
|
|
2164
|
+
if (state.ended) return;
|
|
2165
|
+
const now = Date.now();
|
|
2166
|
+
// A Mini in Picture-in-Picture stays live while this tab is hidden.
|
|
2167
|
+
if (document.visibilityState === "visible" || pip) {
|
|
2168
|
+
renderLive();
|
|
2169
|
+
if (now >= state.nextInbox) { state.nextInbox = now + INBOX_MS; pollInbox().catch((error) => clientError("acon", "inbox", error)); }
|
|
2170
|
+
if (now >= state.nextTools) { state.nextTools = now + TOOLS_MS; loadTools(); }
|
|
2171
|
+
pollPanel();
|
|
2172
|
+
pollSpace();
|
|
2173
|
+
} else if (now - state.lastRequest >= PING_MS) post("/ping");
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
async function boot() {
|
|
2177
|
+
applyTheme();
|
|
2178
|
+
if (document.documentElement.dataset.static === "true") {
|
|
2179
|
+
try {
|
|
2180
|
+
const response = await fetch("/config.json", { cache: "no-store", credentials: "omit" });
|
|
2181
|
+
if (!response.ok) throw new Error("config.json could not load; rebuild and upload the complete static output.");
|
|
2182
|
+
const { createStaticTransport } = await import("./static.js");
|
|
2183
|
+
staticTransport = createStaticTransport(await response.json(), () => { if (state.route.area === "system") renderSystem(true); });
|
|
2184
|
+
} catch (error) { endSession(String(error.message)); return; }
|
|
2185
|
+
state.serve = true;
|
|
2186
|
+
await loadTools();
|
|
2187
|
+
if (!readyTools().length) location.hash = hashFor("system");
|
|
2188
|
+
route();
|
|
2189
|
+
state.nextTools = Date.now() + TOOLS_MS;
|
|
2190
|
+
state.nextInbox = Date.now() + INBOX_MS;
|
|
2191
|
+
setInterval(tick, 1000);
|
|
2192
|
+
await pollInbox();
|
|
2193
|
+
renderLive();
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
const m = /^#([0-9a-f]{64})$/.exec(location.hash);
|
|
2197
|
+
if (m) {
|
|
2198
|
+
history.replaceState(null, "", "/#/local/inbox");
|
|
2199
|
+
const r = await post("/session", { ticket: m[1] });
|
|
2200
|
+
if (r.status !== 200) { endSession("This link was already used or has expired."); return; }
|
|
2201
|
+
} else {
|
|
2202
|
+
// acon serve has no ticket: the verified identity is the session, and /session says so.
|
|
2203
|
+
const r = await post("/session", {});
|
|
2204
|
+
state.serve = r.status === 200 && r.data?.mode === "serve";
|
|
2205
|
+
}
|
|
2206
|
+
if (!(await loadTools())) { if (!state.ended) endSession("The console could not start."); return; }
|
|
2207
|
+
state.nextTools = Date.now() + TOOLS_MS;
|
|
2208
|
+
route();
|
|
2209
|
+
state.nextInbox = Date.now() + INBOX_MS;
|
|
2210
|
+
setInterval(tick, 1000);
|
|
2211
|
+
try { await pollInbox(); } catch (error) { clientError("acon", "inbox", error); }
|
|
2212
|
+
renderLive();
|
|
2213
|
+
}
|
|
2214
|
+
boot();
|