1/**2 * Hermes OpenSpec — Dashboard Plugin3 *4 * Spec-driven development board backed by the plugin's own backend at5 * /api/plugins/openspec/. Register repos with an openspec/ folder, drill into6 * change proposals/tasks/designs/specs, and browse current specs or branch7 * diffs.8 *9 * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ for React +10 * shadcn primitives. Mirrors the kanban dashboard plugin pattern.11 */12(function () {13 "use strict";1415 var SDK = window.__HERMES_PLUGIN_SDK__;16 if (!SDK) return;17 var React = SDK.React;18 var h = React.createElement;19 var C = SDK.components;20 var Button = C.Button, Card = C.Card, CardContent = C.CardContent, Badge = C.Badge;21 var Input = C.Input, Label = C.Label, Select = C.Select, SelectOption = C.SelectOption;22 var Separator = C.Separator;23 var useState = SDK.hooks.useState, useEffect = SDK.hooks.useEffect;24 var useCallback = SDK.hooks.useCallback, useMemo = SDK.hooks.useMemo, useRef = SDK.hooks.useRef;25 var cn = SDK.utils.cn;26 var isoTimeAgo = SDK.utils.isoTimeAgo;27 var fetchJSON = SDK.fetchJSON;2829 var BASE = "/api/plugins/openspec";3031 // ── API helpers ──────────────────────────────────────────────────────32 function apiErr(err) {33 var raw = err && err.message ? String(err.message) : String(err || "");34 var m = raw.match(/^(\d{3}):\s*([\s\S]*)$/);35 var body = m ? m[2] : raw;36 try { var p = JSON.parse(body); if (p && typeof p.detail === "string") return p.detail; } catch (e) {}37 return body || raw;38 }39 function getSources() { return fetchJSON(BASE + "/sources"); }40 function addSource(path, name) {41 return fetchJSON(BASE + "/sources", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: path, name: name || undefined }) });42 }43 function updateSource(id, path, name) {44 return fetchJSON(BASE + "/sources/" + encodeURIComponent(id), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: path, name: name || undefined }) });45 }46 function removeSource(id) {47 return fetchJSON(BASE + "/sources/" + encodeURIComponent(id), { method: "DELETE" });48 }49 function initSource(id) {50 return fetchJSON(BASE + "/sources/" + encodeURIComponent(id) + "/init", { method: "POST" });51 }52 function getChange(sid, name) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/changes/" + encodeURIComponent(name)); }53 function getIdea(sid, name) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/ideas/" + encodeURIComponent(name)); }54 function getSpec(sid, path) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/specs?path=" + encodeURIComponent(path)); }55 function getSpecBrowser(sid, params) {56 var qs = new URLSearchParams();57 if (params && params.before) qs.set("before", params.before);58 if (params && params.after) qs.set("after", params.after);59 if (params && params.dirty) qs.set("dirty", "true");60 var s = qs.toString();61 return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/spec-browser" + (s ? "?" + s : ""));62 }6364 // ── Minimal markdown renderer (headers, bold, code, lists, hr, para) ──65 function esc(s) {66 return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");67 }68 function inline(s) {69 s = esc(s);70 s = s.replace(/`([^`]+)`/g, '<code class="os-code">$1</code>');71 s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");72 s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");73 s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');74 return s;75 }76 function renderMarkdown(md) {77 if (!md) return "";78 var lines = String(md).split("\n");79 var html = [];80 var i = 0;81 var inCode = false, codeBuf = [];82 var listType = null; // "ul" | "ol"83 var paraBuf = [];84 function flushPara() {85 if (paraBuf.length) { html.push("<p>" + inline(paraBuf.join(" ")) + "</p>"); paraBuf = []; }86 }87 function flushList() {88 if (listType) { html.push("</" + listType + ">"); listType = null; }89 }90 while (i < lines.length) {91 var line = lines[i];92 // fenced code block93 if (/^```/.test(line)) {94 if (inCode) { html.push('<pre class="os-pre"><code>' + esc(codeBuf.join("\n")) + "</code></pre>"); codeBuf = []; inCode = false; }95 else { flushPara(); flushList(); inCode = true; }96 i++; continue;97 }98 if (inCode) { codeBuf.push(line); i++; continue; }99 // heading100 var hm = line.match(/^(#{1,4})\s+(.*)$/);101 if (hm) { flushPara(); flushList(); var lvl = hm[1].length; html.push("<h" + lvl + ">" + inline(hm[2]) + "</h" + lvl + ">"); i++; continue; }102 // hr103 if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { flushPara(); flushList(); html.push('<hr class="os-hr"/>'); i++; continue; }104 // list item105 var lm = line.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);106 if (lm) {107 flushPara();108 var lt = /\d+\./.test(lm[2]) ? "ol" : "ul";109 if (listType !== lt) { flushList(); html.push("<" + lt + ' class="os-list">'); listType = lt; }110 html.push("<li>" + inline(lm[3]) + "</li>");111 i++; continue;112 }113 // blank line114 if (line.trim() === "") { flushPara(); flushList(); i++; continue; }115 // paragraph text116 flushList();117 paraBuf.push(line.trim());118 i++;119 }120 if (inCode) html.push('<pre class="os-pre"><code>' + esc(codeBuf.join("\n")) + "</code></pre>");121 flushPara(); flushList();122 return html.join("\n");123 }124 function Markdown(_ref) {125 var md = _ref.md;126 var html = useMemo(function () { return renderMarkdown(md); }, [md]);127 return h("div", { className: "os-md", dangerouslySetInnerHTML: { __html: html } });128 }129130 // ── Icons (inline SVG, no icon lib available in plugin sandbox) ──────131 function Icon(_ref2) {132 var d = _ref2.d, size = _ref2.size;133 var s = size || 16;134 return h("svg", { width: s, height: s, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },135 h("path", { d: d })136 );137 }138 var ICO = {139 refresh: "M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16",140 plus: "M12 5v14M5 12h14",141 edit: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z",142 trash: "M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6",143 copy: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2M9 2h6a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Z",144 close: "M18 6 6 18M6 6l12 12",145 file: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6",146 folder: "M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z",147 chevron: "M9 18l6-6-6-6",148 branch: "M6 3v12M18 9a3 3 0 1 0-3-3M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a3 3 0 0 1-3 3H6",149 proposal: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M8 13h8M8 17h5",150 tasks: "M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",151 design: "M12 19l7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5zM2 2l7.586 7.586M11 11a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z",152 specs: "M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",153 };154155 // ── Modal (lightweight, no Dialog in SDK) ─────────────────────────────156 function Modal(_ref3) {157 var title = _ref3.title, onClose = _ref3.onClose, children = _ref3.children, wide = _ref3.wide;158 useEffect(function () {159 function onKey(e) { if (e.key === "Escape") onClose(); }160 window.addEventListener("keydown", onKey);161 return function () { window.removeEventListener("keydown", onKey); };162 }, [onClose]);163 return h("div", { className: "os-overlay", onClick: onClose },164 h("div", { className: cn("os-modal", wide && "os-modal-wide"), onClick: function (e) { e.stopPropagation(); } },165 h("div", { className: "os-modal-head" },166 h("span", { className: "os-modal-title" }, title),167 h("button", { className: "os-icon-btn", onClick: onClose, "aria-label": "Close" }, h(Icon, { d: ICO.close }))168 ),169 h("div", { className: "os-modal-body" }, children)170 )171 );172 }173174 // ── Copy chip ────────────────────────────────────────────────────────175 function CopyChip(_ref4) {176 var text = _ref4.text;177 var _s = useState(false);178 var copied = _s[0], setCopied = _s[1];179 return h("button", {180 className: "os-chip",181 onClick: function () {182 navigator.clipboard.writeText(text).then(function () { setCopied(true); setTimeout(function () { setCopied(false); }, 1200); });183 },184 title: "Copy: " + text,185 }, copied ? "copied" : text, h("span", { className: "os-chip-ico" }, h(Icon, { d: ICO.copy, size: 11 })));186 }187188 // ── Status badge colors ──────────────────────────────────────────────189 var STATUS_LABEL = { ideas: "Ideas", draft: "Draft", todo: "Todo", in_progress: "In Progress", done: "Done", archived: "Archived" };190 var STATUS_TONE = { ideas: "os-tone-ideas", draft: "os-tone-draft", todo: "os-tone-todo", in_progress: "os-tone-progress", done: "os-tone-done", archived: "os-tone-archived" };191 var COLUMN_ORDER = ["ideas", "draft", "todo", "in_progress", "done", "archived"];192193 // ── Source toolbar ───────────────────────────────────────────────────194 function Toolbar(_ref5) {195 var sources = _ref5.sources, activeId = _ref5.activeId, onSelect = _ref5.onSelect;196 var onRefresh = _ref5.onRefresh, onAdd = _ref5.onAdd, onEdit = _ref5.onEdit, onRemove = _ref5.onRemove;197 var active = sources.find(function (s) { return s.id === activeId; });198 return h("div", { className: "os-toolbar" },199 h(Label, { htmlFor: "os-project" }, "Project"),200 h(Select, { id: "os-project", value: activeId || "", onValueChange: onSelect },201 sources.length === 0 ? h(SelectOption, { value: "" }, "—") : null,202 sources.map(function (s) { return h(SelectOption, { key: s.id, value: s.id }, s.name + (s.valid ? "" : " (invalid)")); })203 ),204 h("button", { className: "os-icon-btn", onClick: onRefresh, title: "Refresh", disabled: sources.length === 0 }, h(Icon, { d: ICO.refresh })),205 h("div", { className: "os-toolbar-actions" },206 h("button", { className: "os-icon-btn", onClick: onEdit, title: "Edit source", disabled: !active }, h(Icon, { d: ICO.edit })),207 h("button", { className: "os-icon-btn", onClick: onRemove, title: "Remove source", disabled: !active }, h(Icon, { d: ICO.trash })),208 h("button", { className: "os-icon-btn", onClick: onAdd, title: "Add source" }, h(Icon, { d: ICO.plus }))209 )210 );211 }212213 // ── Add / Edit dialogs ───────────────────────────────────────────────214 function SourceDialog(_ref6) {215 var mode = _ref6.mode, initialPath = _ref6.initialPath, initialName = _ref6.initialName, onClose = _ref6.onClose, onSaved = _ref6.onSaved;216 var activeId = _ref6.activeId;217 var _p = useState(initialPath || ""), _n = useState(initialName || ""), _b = useState(false), _e = useState("");218 var path = _p[0], setPath = _p[1], name = _n[0], setName = _n[1], busy = _b[0], setBusy = _b[1], err = _e[0], setErr = _e[1];219 function save() {220 if (!path.trim()) { setErr("Path is required"); return; }221 setBusy(true); setErr("");222 var p = mode === "edit" ? updateSource(activeId, path.trim(), name.trim()) : addSource(path.trim(), name.trim());223 p.then(function (res) { onSaved(res); }).catch(function (e) { setErr(apiErr(e)); setBusy(false); });224 }225 return h(Modal, { title: mode === "edit" ? "Edit OpenSpec source" : "Add OpenSpec source", onClose: onClose },226 h("div", { className: "os-field" },227 h(Label, { htmlFor: "os-path" }, "Path"),228 h(Input, { id: "os-path", value: path, onChange: function (e) { setPath(e.target.value); }, placeholder: "~/repos/my-project", disabled: busy })229 ),230 h("div", { className: "os-field" },231 h(Label, { htmlFor: "os-name" }, "Display name (optional)"),232 h(Input, { id: "os-name", value: name, onChange: function (e) { setName(e.target.value); }, placeholder: "my-project", disabled: busy })233 ),234 err ? h("p", { className: "os-err" }, err) : null,235 h("div", { className: "os-dialog-actions" },236 h(Button, { variant: "outline", onClick: onClose, disabled: busy }, "Cancel"),237 h(Button, { onClick: save, disabled: busy }, busy ? "Saving…" : "Save")238 )239 );240 }241242 // ── Board (kanban) ───────────────────────────────────────────────────243 function Board(_ref7) {244 var source = _ref7.source, onSelectItem = _ref7.onSelectItem;245 var board = source.openspec;246 var _ft = useState(""), _sa = useState(true);247 var filterText = _ft[0], setFilterText = _ft[1];248 var showArchived = _sa[0], setShowArchived = _sa[1];249 if (!board) return h("p", { className: "os-empty" }, "No openspec/ content found.");250 var filter = filterText.trim().toLowerCase();251 function matchesFilter(it) {252 if (!filter) return true;253 return (it.title || it.name || "").toLowerCase().indexOf(filter) >= 0 ||254 (it.name || "").toLowerCase().indexOf(filter) >= 0 ||255 (it.token || "").toLowerCase().indexOf(filter) >= 0;256 }257 function sequencePosition(it) {258 return it && it.sequence && typeof it.sequence.position === "number" ? it.sequence.position : Number.MAX_SAFE_INTEGER;259 }260 function sortBySequence(items) {261 return (items || []).slice().sort(function (a, b) {262 var ap = sequencePosition(a), bp = sequencePosition(b);263 if (ap !== bp) return ap - bp;264 return String(a.title || a.name || "").localeCompare(String(b.title || b.name || ""));265 });266 }267 var byCol = {};268 COLUMN_ORDER.forEach(function (c) { byCol[c] = []; });269 board.ideas.forEach(function (it) { if (matchesFilter(it)) byCol.ideas.push(it); });270 board.changes.forEach(function (ch) { var s = ch.status || "draft"; (byCol[s] || (byCol[s] = [])).push(ch); });271 // Filter changes in non-archived columns too272 COLUMN_ORDER.forEach(function (c) {273 if (c !== "ideas") byCol[c] = (byCol[c] || []).filter(matchesFilter);274 });275 var visibleColumns = COLUMN_ORDER.filter(function (c) { return c !== "archived" || showArchived; });276 var totalVisible = visibleColumns.reduce(function (n, c) { return n + (byCol[c] || []).length; }, 0);277 return h("div", { className: "os-board" },278 h("div", { className: "os-board-head" },279 h("span", { className: "os-repo-name" }, source.name),280 h(CopyChip, { text: source.name }),281 h("span", { className: "os-repo-path" }, source.path)282 ),283 h("div", { className: "os-board-filter" },284 h(Input, { placeholder: "🔍 Filter by title, name, or token…", value: filterText, onChange: function (e) { setFilterText(e.target.value); }, className: "os-filter-input" }),285 h(Button, { variant: showArchived ? "default" : "outline", size: "sm", onClick: function () { setShowArchived(!showArchived); } }, "Archived " + (showArchived ? "✓" : "✗"))286 ),287 totalVisible === 0 && filter ? h("div", { className: "os-empty" }, "No cards match \"" + filterText + "\"") :288 h("div", { className: "os-cols" },289 visibleColumns.map(function (col) {290 var items = sortBySequence(byCol[col] || []);291 return h("div", { key: col, className: "os-col" },292 h("div", { className: "os-col-head" },293 h("span", null, STATUS_LABEL[col] || col),294 h(Badge, { variant: "secondary" }, String(items.length))295 ),296 h("div", { className: "os-col-list" },297 items.length === 0 ? h("span", { className: "os-col-empty" }, "—") :298 items.map(function (it) {299 var artifacts = [300 { on: it.hasProposal, c: "#60a5fa", label: "proposal" },301 { on: it.hasTasks, c: "#34d399", label: "tasks" },302 { on: it.hasDesign, c: "#a78bfa", label: "design" },303 { on: it.hasSpecs, c: "#fbbf24", label: "specs" },304 ];305 var pct = it.taskStats && it.taskStats.total > 0 ? Math.round((it.taskStats.done / it.taskStats.total) * 100) : 0;306 return h("button", {307 key: it.id || it.name, className: cn("os-card", STATUS_TONE[it.status] || ""), onClick: function () { onSelectItem(it); },308 },309 h("div", { className: "os-card-title" },310 it.sequence ? h("span", { className: "os-card-seq", title: "Sequence " + it.sequence.position + (it.sequence.dependsOn && it.sequence.dependsOn.length ? "; depends on " + it.sequence.dependsOn.join(", ") : "") }, "#" + it.sequence.position) : null,311 it.title || it.name312 ),313 h("div", { className: "os-card-foot" },314 h("span", { className: "os-card-artifacts" },315 artifacts.map(function (a, i) { return h("span", { key: i, className: cn("os-card-dot", !a.on && "os-card-dot-off"), title: a.label }); })316 ),317 it.taskStats ? h("span", { className: "os-card-progress" },318 h("span", { className: "os-card-progress-track" }, h("span", { className: "os-card-progress-fill", style: { width: pct + "%" } })),319 h("span", { className: "os-card-stats" }, it.taskStats.done + "/" + it.taskStats.total)320 ) : null,321 it.token ? h("button", {322 className: "os-card-copy",323 title: "Copy: " + source.name + "/" + it.token,324 onClick: function (e) { e.stopPropagation(); navigator.clipboard.writeText(source.name + "/" + it.token); }325 }, h(Icon, { d: ICO.copy, size: 12 })) : null326 )327 );328 })329 )330 );331 })332 )333 );334 }335336 // ── Change / Idea detail dialog ──────────────────────────────────────337 function DetailDialog(_ref8) {338 var source = _ref8.source, item = _ref8.item, onClose = _ref8.onClose, anchor = _ref8.anchor, onTabChange = _ref8.onTabChange;339 var _d = useState(null), _l = useState(true), _e2 = useState("");340 var detail = _d[0], setDetail = _d[1], loading = _l[0], setLoading = _l[1], err = _e2[0], setErr = _e2[1];341 var isIdea = item.status === "ideas";342 useEffect(function () {343 setLoading(true); setErr(""); setDetail(null);344 var p = isIdea ? getIdea(source.id, item.name) : getChange(source.id, item.name);345 p.then(function (d) { setDetail(d); setLoading(false); }).catch(function (e) { setErr(apiErr(e)); setLoading(false); });346 }, [source.id, item.name, isIdea]);347 return h(Modal, { title: item.title || item.name, onClose: onClose, wide: true },348 h("div", { className: "os-detail-chip" }, h(CopyChip, { text: source.name + "/" + item.token })),349 loading ? h("p", { className: "os-empty" }, "Loading…") :350 err ? h("p", { className: "os-err" }, err) :351 isIdea ? h(Markdown, { md: detail && detail.content }) :352 detail ? h(ChangeDetailTabs, { detail: detail, anchor: anchor, onTabChange: onTabChange }) : null353 );354 }355 // ── Task parser ─────────────────────────────────────────────────────356 function parseTasks(md) {357 if (!md) return { sections: [], total: 0, done: 0 };358 var lines = md.split("\n");359 var sections = [];360 var current = null;361 var total = 0, done = 0;362 for (var i = 0; i < lines.length; i++) {363 var line = lines[i];364 var h = line.match(/^##\s+(.+)$/);365 if (h) {366 current = { title: h[1].trim(), tasks: [] };367 sections.push(current);368 continue;369 }370 var t = line.match(/^[-*]\s+\[(.)\]\s+(.+)$/);371 if (t) {372 var checked = t[1] === "x" || t[1] === "X";373 var text = t[2].trim();374 if (checked) done++;375 total++;376 if (current) current.tasks.push({ checked: checked, text: text });377 else { current = { title: "", tasks: [] }; sections.push(current); current.tasks.push({ checked: checked, text: text }); }378 }379 }380 return { sections: sections, total: total, done: done };381 }382383 function ChangeSpecsView(_ref11) {384 var specs = _ref11.specs;385 var _m = useState("proposed");386 var mode = _m[0], setMode = _m[1];387 return h("div", null,388 h("div", { className: "os-specs-modes", style: { marginBottom: "0.5rem" } },389 h(Button, { variant: mode === "proposed" ? "default" : "outline", size: "sm", onClick: function () { setMode("proposed"); } }, "Proposed"),390 h(Button, { variant: mode === "diff" ? "default" : "outline", size: "sm", onClick: function () { setMode("diff"); } }, "Diff vs current")391 ),392 specs.map(function (s) {393 return h("div", { key: s.path, className: "os-spec-block" },394 h("div", { className: "os-specs-detail-head" },395 h("span", { className: "os-specs-detail-path" }, s.path),396 s.status && s.status !== "unchanged" && s.status !== "missing"397 ? h(Badge, { className: "os-spec-status os-status-" + s.status }, s.status)398 : null399 ),400 mode === "diff" && s.diff401 ? h("div", { className: "os-spec-diff" },402 h(SpecDeltaView, { semanticDiff: s.semantic_diff, lineDiff: s.diff, specPath: s.path })403 )404 : h(SpecContentView, { md: s.content })405 );406 })407 );408 }409410 function ChangeDetailTabs(_ref9) {411 var detail = _ref9.detail, anchor = _ref9.anchor, onTabChange = _ref9.onTabChange;412 var tabs = [];413 if (detail.proposal) tabs.push(["proposal", "Proposal"]);414 if (detail.tasks) tabs.push(["tasks", "Tasks"]);415 if (detail.design) tabs.push(["design", "Design"]);416 if (detail.specs && detail.specs.length) tabs.push(["specs", "Specs (" + detail.specs.length + ")"]);417 if (!tabs.length) return h("p", { className: "os-empty" }, "No documents found.");418 var validAnchor = anchor && tabs.some(function (t) { return t[0] === anchor; });419 var initialTab = validAnchor ? anchor : tabs[0][0];420 var _tab = useState(initialTab);421 var activeTab = _tab[0], setActiveTab = _tab[1];422 // Sync from hash when anchor changes (browser back/forward).423 useEffect(function () {424 if (validAnchor && anchor !== activeTab) setActiveTab(anchor);425 }, [anchor]);426 function switchTab(t) {427 setActiveTab(t);428 if (onTabChange) onTabChange(t);429 }430 var content = activeTab === "specs"431 ? h(ChangeSpecsView, { specs: detail.specs })432 : activeTab === "tasks"433 ? h(TasksView, { md: detail[activeTab], stats: detail.taskStats })434 : h(Markdown, { md: detail[activeTab] });435 return h("div", { className: "os-tabs" },436 h("div", { className: "os-tabs-bar" }, tabs.map(function (t) {437 return h("button", {438 key: t[0], className: cn("os-tab-btn", activeTab === t[0] && "os-tab-btn-active"),439 onClick: function () { switchTab(t[0]); }440 }, t[1]);441 })),442 h("div", { className: "os-tab-panel" }, content)443 );444 }445446 // ── Tasks view (structured checklist) ───────────────────────────────447 function TasksView(_ref) {448 var md = _ref.md, stats = _ref.stats;449 var parsed = useMemo(function () { return parseTasks(md); }, [md]);450 var total = stats ? stats.total : parsed.total;451 var done = stats ? stats.done : parsed.done;452 var pct = total > 0 ? Math.round((done / total) * 100) : 0;453 return h("div", { className: "os-tasks" },454 total > 0 ? h("div", { className: "os-tasks-progress" },455 h("div", { className: "os-tasks-bar" },456 h("div", { className: "os-tasks-bar-fill", style: { width: pct + "%" } })457 ),458 h("span", { className: "os-tasks-count" }, done + " / " + total + " done (" + pct + "%)")459 ) : null,460 parsed.sections.map(function (sec, si) {461 return h("div", { key: si, className: "os-tasks-section" },462 sec.title ? h("h4", { className: "os-tasks-section-title" }, sec.title) : null,463 sec.tasks.map(function (t, ti) {464 return h("div", { key: ti, className: cn("os-task-item", t.checked && "os-task-done") },465 h("span", { className: "os-task-check" }, t.checked ? "\u2713" : "\u25CB"),466 h("span", { className: "os-task-text" }, t.text)467 );468 })469 );470 })471 );472 }473474 // ── Spec content parser + structured view ───────────────────────────475 function parseSpec(md) {476 if (!md) return { title: "", purpose: "", requirements: [] };477 var lines = md.split("\n");478 var title = "", purpose = "";479 var reqs = [];480 var state = "top"; // top | purpose | reqDesc | scenario481 var currentReq = null, currentScn = null;482483 for (var i = 0; i < lines.length; i++) {484 var line = lines[i];485 var trim = line.trim();486487 if (trim.startsWith("# ") && !trim.startsWith("## ")) {488 title = trim.replace(/^#\s+/, "").replace(/\s+Specification\s*$/i, "");489 state = "top"; continue;490 }491 if (/^##\s+Purpose/i.test(trim)) { state = "purpose"; continue; }492 if (/^##\s+Requirements/i.test(trim)) { state = "top"; continue; }493 if (/^###\s+Requirement:\s*(.+)$/i.test(trim)) {494 var m = trim.match(/^###\s+Requirement:\s*(.+)$/i);495 currentReq = { name: m[1].trim(), description: "", scenarios: [] };496 reqs.push(currentReq);497 currentScn = null;498 state = "reqDesc"; continue;499 }500 if (/^####\s+Scenario:\s*(.+)$/i.test(trim)) {501 var m2 = trim.match(/^####\s+Scenario:\s*(.+)$/i);502 currentScn = { name: m2[1].trim(), steps: [] };503 if (currentReq) currentReq.scenarios.push(currentScn);504 state = "scenario"; continue;505 }506507 // Accumulate text based on state508 if (state === "purpose" && trim) {509 purpose += (purpose ? "\n" : "") + trim;510 } else if (state === "reqDesc" && trim) {511 if (currentReq) currentReq.description += (currentReq.description ? "\n" : "") + trim;512 } else if (state === "scenario" && trim) {513 var step = trim.match(/^[-*]\s+\*\*(\w+)\*\*\s*(.+)$/);514 if (step && currentScn) {515 currentScn.steps.push({ type: step[1].toUpperCase(), text: step[2].trim() });516 } else if (currentScn) {517 // Non-bullet text inside scenario — append to last step or skip518 if (currentScn.steps.length) {519 currentScn.steps[currentScn.steps.length - 1].text += " " + trim;520 }521 }522 }523 }524 return { title: title, purpose: purpose, requirements: reqs };525 }526527 function SpecContentView(_ref) {528 var md = _ref.md;529 var spec = useMemo(function () { return parseSpec(md); }, [md]);530 if (!spec.requirements.length) {531 return h(Markdown, { md: md });532 }533 return h("div", { className: "os-spec-content" },534 spec.purpose ? h("div", { className: "os-spec-purpose" },535 h("div", { className: "os-spec-purpose-label" }, "Purpose"),536 h("p", null, spec.purpose)537 ) : null,538 spec.requirements.map(function (req, ri) {539 return h("div", { key: ri, className: "os-spec-req" },540 h("div", { className: "os-spec-req-head" },541 h("span", { className: "os-spec-req-num" }, "R" + (ri + 1)),542 h("span", { className: "os-spec-req-name" }, req.name)543 ),544 req.description ? h("p", { className: "os-spec-req-desc" }, req.description) : null,545 req.scenarios.map(function (scn, si) {546 return h("div", { key: si, className: "os-spec-scn" },547 h("div", { className: "os-spec-scn-name" }, scn.name),548 scn.steps.map(function (st, sti) {549 return h("div", { key: sti, className: cn("os-spec-step", "os-spec-step-" + st.type.toLowerCase()) },550 h("span", { className: "os-spec-step-label" }, st.type),551 h("span", { className: "os-spec-step-text" }, st.text)552 );553 })554 );555 })556 );557 })558 );559 }560561 // ── Semantic spec delta view (requirement-level diff) ──────────────562 function SpecDeltaView(_ref2) {563 var semanticDiff = _ref2.semanticDiff, lineDiff = _ref2.lineDiff, specPath = _ref2.specPath;564 var _rs = useState(true);565 var rawCollapsed = _rs[0], setRawCollapsed = _rs[1];566567 if (!semanticDiff || !semanticDiff.requirements) {568 // No semantic diff available — fall back to SpecContentView569 return h("p", { className: "os-empty" }, "No semantic diff available.");570 }571572 var reqs = semanticDiff.requirements;573 var added = reqs.added || [], modified = reqs.modified || [], removed = reqs.removed || [], unchanged = reqs.unchanged || [];574 var summaryParts = [];575 if (added.length) summaryParts.push("+" + added.length + " added");576 if (modified.length) summaryParts.push("~" + modified.length + " modified");577 if (removed.length) summaryParts.push("-" + removed.length + " removed");578579 function renderScenario(scn, key, suffix) {580 return h("div", { key: key, className: "os-spec-scn" + (suffix ? " os-spec-scn-" + suffix : "") },581 h("div", { className: "os-spec-scn-name" }, scn.name),582 scn.steps ? scn.steps.map(function (st, sti) {583 return h("div", { key: sti, className: cn("os-spec-step", "os-spec-step-" + st.type.toLowerCase()) },584 h("span", { className: "os-spec-step-label" }, st.type),585 h("span", { className: "os-spec-step-text" }, st.text)586 );587 }) : null588 );589 }590591 function renderRequirement(req, key, badge) {592 return h("div", { key: key, className: "os-spec-req os-spec-req-delta" },593 h("div", { className: "os-spec-req-head" },594 badge ? h(Badge, { className: "os-spec-status os-status-" + badge }, badge) : null,595 h("span", { className: "os-spec-req-name" }, req.name)596 ),597 req.description ? h("p", { className: "os-spec-req-desc" }, req.description) : null,598 req.scenarios ? req.scenarios.map(function (scn, si) {599 return renderScenario(scn, si, badge);600 }) : null601 );602 }603604 return h("div", { className: "os-spec-delta" },605 // Summary606 h("div", { className: "os-delta-summary" },607 summaryParts.length608 ? h("span", { className: "os-delta-summary-text" }, summaryParts.join(", "))609 : h("span", { className: "os-delta-summary-text os-delta-unchanged" }, "No changes")610 ),611612 // Added requirements613 added.length ? h("div", { className: "os-delta-section" },614 h("div", { className: "os-delta-section-head" }, "Added"),615 added.map(function (req, i) { return renderRequirement(req, "a" + i, "added"); })616 ) : null,617618 // Modified requirements619 modified.length ? h("div", { className: "os-delta-section" },620 h("div", { className: "os-delta-section-head" }, "Modified"),621 modified.map(function (mod, i) {622 return h("div", { key: "m" + i, className: "os-spec-req os-spec-req-delta" },623 h("div", { className: "os-spec-req-head" },624 h(Badge, { className: "os-spec-status os-status-modified" }, "modified"),625 h("span", { className: "os-spec-req-name" }, mod.name)626 ),627 // Description change628 mod.before.description !== mod.after.description ? h("div", { className: "os-delta-desc-change" },629 h("div", { className: "os-delta-before" }, h("span", { className: "os-delta-label" }, "Before:"), " ", mod.before.description),630 h("div", { className: "os-delta-after" }, h("span", { className: "os-delta-label" }, "After:"), " ", mod.after.description)631 ) : null,632 // Scenario-level deltas633 (mod.scenarios_added && mod.scenarios_added.length) || (mod.scenarios_modified && mod.scenarios_modified.length) || (mod.scenarios_removed && mod.scenarios_removed.length)634 ? h("div", { className: "os-delta-scenarios" },635 (mod.scenarios_added || []).map(function (scn, si) { return renderScenario(scn, "sa" + si, "added"); }),636 (mod.scenarios_modified || []).map(function (sm, si) {637 return h("div", { key: "sm" + si, className: "os-spec-scn os-spec-scn-modified" },638 h("div", { className: "os-spec-scn-name" }, sm.name, h(Badge, { className: "os-spec-status os-status-modified", style: { marginLeft: "0.5rem" } }, "modified")),639 h("div", { className: "os-delta-scenario-change" },640 sm.before.steps ? h("div", { className: "os-delta-before" }, sm.before.steps.map(function (st, sti) {641 return h("div", { key: sti, className: "os-spec-step os-spec-step-" + st.type.toLowerCase() }, h("span", { className: "os-spec-step-label" }, st.type), h("span", { className: "os-spec-step-text" }, st.text));642 })) : null,643 sm.after.steps ? h("div", { className: "os-delta-after" }, sm.after.steps.map(function (st, sti) {644 return h("div", { key: sti, className: "os-spec-step os-spec-step-" + st.type.toLowerCase() }, h("span", { className: "os-spec-step-label" }, st.type), h("span", { className: "os-spec-step-text" }, st.text));645 })) : null646 )647 );648 }),649 (mod.scenarios_removed || []).map(function (scn, si) { return renderScenario(scn, "sr" + si, "removed"); })650 ) : null651 );652 })653 ) : null,654655 // Removed requirements656 removed.length ? h("div", { className: "os-delta-section" },657 h("div", { className: "os-delta-section-head" }, "Removed"),658 removed.map(function (req, i) { return renderRequirement(req, "r" + i, "removed"); })659 ) : null,660661 // Unchanged requirements (collapsed)662 unchanged.length ? h("details", { className: "os-delta-unchanged-section" },663 h("summary", null, unchanged.length + " unchanged requirement" + (unchanged.length === 1 ? "" : "s")),664 h("div", { className: "os-delta-unchanged-list" },665 unchanged.map(function (name, i) { return h("div", { key: i, className: "os-delta-unchanged-req" }, name); })666 )667 ) : null,668669 // Raw diff (collapsible)670 lineDiff ? h("details", { className: "os-delta-raw-section", open: !rawCollapsed ? true : false },671 h("summary", { onClick: function (e) { e.preventDefault(); setRawCollapsed(!rawCollapsed); } }, "Raw diff"),672 rawCollapsed ? null : h("pre", { className: "os-diff-unified" }, lineDiff)673 ) : null674 );675 }676677 // ── Specs view (spec browser: current / dirty / refs) ────────────────678 var SORT_MODES = [679 { value: "alpha", label: "A→Z" },680 { value: "alpha-desc", label: "Z→A" },681 { value: "edited", label: "Latest edited" },682 { value: "edited-desc", label: "Oldest edited" },683 ];684 function sortFiles(files, sortMode) {685 if (!files || !files.length) return files;686 var arr = files.slice();687 if (sortMode === "alpha-desc") {688 arr.sort(function (a, b) { return b.path.localeCompare(a.path); });689 } else if (sortMode === "edited") {690 arr.sort(function (a, b) {691 var ta = a.mtime ? Date.parse(a.mtime) : 0;692 var tb = b.mtime ? Date.parse(b.mtime) : 0;693 return tb - ta;694 });695 } else if (sortMode === "edited-desc") {696 arr.sort(function (a, b) {697 var ta = a.mtime ? Date.parse(a.mtime) : 0;698 var tb = b.mtime ? Date.parse(b.mtime) : 0;699 return ta - tb;700 });701 } else {702 arr.sort(function (a, b) { return a.path.localeCompare(b.path); });703 }704 return arr;705 }706 function SpecsView(_ref10) {707 var source = _ref10.source, anchorToken = _ref10.anchorToken, onSelectToken = _ref10.onSelectToken;708 var _m = useState("current"), _b2 = useState(null), _l2 = useState(true), _e3 = useState("");709 var _sel = useState(null), _bf = useState(""), _af = useState("");710 var _sm = useState("alpha");711 var _sdm = useState("semantic");712 var mode = _m[0], setMode = _m[1], browser = _b2[0], setBrowser = _b2[1];713 var loading = _l2[0], setLoading = _l2[1], err = _e3[0], setErr = _e3[1];714 var selPath = _sel[0], setSelPath = _sel[1], before = _bf[0], setBefore = _bf[1], after = _af[0], setAfter = _af[1];715 var sortMode = _sm[0], setSortMode = _sm[1];716 var selDiffModes = _sdm;717 var firstLoad = useRef(true);718719 function load(currentMode, b, a) {720 setLoading(true); setErr("");721 var params = {};722 if (currentMode === "dirty") params.dirty = true;723 else if (currentMode === "refs") { params.before = b; params.after = a; }724 getSpecBrowser(source.id, params).then(function (d) {725 setBrowser(d);726 // Pick initial selection: anchor token match, else first file.727 var pick = null;728 if (anchorToken && d.files) {729 pick = d.files.find(function (f) { return f.token === anchorToken; });730 }731 if (!pick && d.files && d.files.length) pick = d.files[0];732 setSelPath(pick ? pick.path : null);733 setLoading(false);734 }).catch(function (e) { setErr(apiErr(e)); setLoading(false); });735 }736 useEffect(function () {737 if (firstLoad.current) { firstLoad.current = false; load(mode, before, after); }738 }, []);739 // Sync selection when anchor token changes (browser back/forward + hash edits).740 useEffect(function () {741 if (!anchorToken || !browser || !browser.files) return;742 var match = browser.files.find(function (f) { return f.token === anchorToken; });743 if (match && match.path !== selPath) setSelPath(match.path);744 }, [anchorToken, browser]);745 function switchMode(m) {746 setMode(m);747 if (m === "refs" && (!before.trim() || !after.trim())) { setBrowser(null); setLoading(false); return; }748 load(m, before, after);749 }750 function reload() { load(mode, before, after); }751 var selFile = browser && browser.files ? browser.files.find(function (f) { return f.path === selPath; }) : null;752 return h("div", { className: "os-specs" },753 h("div", { className: "os-specs-toolbar" },754 h("span", { className: "os-specs-title" }, "Specs"),755 browser && browser.branch ? h(Badge, { variant: "outline" }, h(Icon, { d: ICO.branch, size: 11 }), " ", browser.branch) : null,756 h("div", { className: "os-specs-modes" },757 h(Button, { variant: mode === "current" ? "default" : "outline", size: "sm", onClick: function () { switchMode("current"); } }, "Current"),758 h(Button, { variant: mode === "dirty" ? "default" : "outline", size: "sm", onClick: function () { switchMode("dirty"); } }, "Dirty (HEAD→worktree)"),759 h(Button, { variant: mode === "refs" ? "default" : "outline", size: "sm", onClick: function () { switchMode("refs"); } }, "Compare refs")760 ),761 mode === "refs" ? h("div", { className: "os-refs-inputs" },762 h(Input, { placeholder: "before (HEAD~1)", value: before, onChange: function (e) { setBefore(e.target.value); }, className: "os-ref-input" }),763 h(Input, { placeholder: "after (origin/main)", value: after, onChange: function (e) { setAfter(e.target.value); }, className: "os-ref-input" }),764 h(Button, { size: "sm", onClick: reload, disabled: !before.trim() || !after.trim() }, "Load")765 ) : null,766 h("button", { className: "os-icon-btn", onClick: reload, title: "Refresh" }, h(Icon, { d: ICO.refresh })),767 h(Select, { value: sortMode, onValueChange: setSortMode, "aria-label": "Sort specs" },768 SORT_MODES.map(function (s) { return h(SelectOption, { key: s.value, value: s.value }, s.label); })769 )770 ),771 browser && browser.changedCount > 0 ? h("div", { className: "os-specs-changed" }, browser.changedCount + " changed spec" + (browser.changedCount === 1 ? "" : "s")) : null,772 loading ? h("p", { className: "os-empty" }, "Loading…") :773 err ? h("p", { className: "os-err" }, err) :774 browser && browser.files && browser.files.length ? (function () {775 var sorted = sortFiles(browser.files, sortMode);776 return h("div", { className: "os-specs-grid" },777 h("div", { className: "os-specs-list" },778 sorted.map(function (f) {779 var summaryParts = [];780 if (f.semantic_summary) {781 if (f.semantic_summary.added) summaryParts.push("+" + f.semantic_summary.added);782 if (f.semantic_summary.modified) summaryParts.push("~" + f.semantic_summary.modified);783 if (f.semantic_summary.removed) summaryParts.push("-" + f.semantic_summary.removed);784 }785 return h("button", {786 key: f.path, className: cn("os-spec-item", f.path === selPath && "os-spec-item-active", f.changed && "os-spec-item-changed"),787 onClick: function () { setSelPath(f.path); if (onSelectToken && f.token) onSelectToken(f.token); },788 },789 h(Icon, { d: ICO.file, size: 13 }),790 h("span", { className: "os-spec-item-path" }, f.path),791 f.changed ? h(Badge, { className: "os-spec-status os-status-" + f.status }, f.status) : null,792 summaryParts.length ? h("span", { className: "os-spec-item-summary" }, summaryParts.join(" ")) : null793 );794 })795 ),796 selFile ? h("div", { className: "os-specs-detail" },797 h("div", { className: "os-specs-detail-head" },798 h("span", { className: "os-specs-detail-path" }, selFile.path),799 selFile.token ? h(CopyChip, { text: source.name + "/" + selFile.token }) : null800 ),801 (selFile.ctime || selFile.mtime) ? h("div", { className: "os-specs-meta" },802 selFile.ctime ? h("span", { title: selFile.ctime }, "created ", isoTimeAgo(selFile.ctime)) : null,803 selFile.mtime ? h("span", { title: selFile.mtime }, "edited ", isoTimeAgo(selFile.mtime)) : null804 ) : null,805 mode === "current" ? h(SpecContentView, { md: selFile.after }) :806 (function () {807 var diffModes = ["semantic", "split", "raw"];808 var diffLabels = { semantic: "Semantic", split: "Side-by-side", raw: "Raw" };809 // viewMode state is hoisted to SpecsView via a ref-like closure810 var selDiffMode = selDiffModes[0];811 function setDiffMode(m) { selDiffModes[1](m); }812 return h("div", { className: "os-spec-diff" },813 h("div", { className: "os-specs-diff-modes" },814 diffModes.map(function (dm) {815 return h(Button, { key: dm, variant: selDiffMode === dm ? "default" : "outline", size: "sm", onClick: function () { setDiffMode(dm); } }, diffLabels[dm]);816 })817 ),818 selDiffMode === "semantic" && selFile.semantic_diff819 ? h(SpecDeltaView, { semanticDiff: selFile.semantic_diff, lineDiff: selFile.diff, specPath: selFile.path })820 : selDiffMode === "split"821 ? h("div", { className: "os-diff-cols" },822 h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "before"), h(SpecContentView, { md: selFile.before })),823 h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "after"), h(SpecContentView, { md: selFile.after }))824 )825 : selFile.diff ? h("pre", { className: "os-diff-unified" }, selFile.diff) : null826 );827 })()828 ) : h("p", { className: "os-empty" }, "Select a spec.")829 );830 })() : h("p", { className: "os-empty" }, mode === "dirty" ? "No changes between HEAD and worktree." : mode === "refs" ? "No differences between refs." : "No specs found.")831 );832 }833834 // ── Error boundary (class-based, catches render crashes) ─────────────835 var ErrorBoundary = (function (_super) {836 function EB(props) {837 _super.call(this, props);838 this.state = { hasError: false, error: null };839 }840 EB.prototype = Object.create(_super.prototype);841 EB.prototype.constructor = EB;842 EB.getDerivedStateFromError = function (error) {843 return { hasError: true, error: error };844 };845 EB.prototype.componentDidCatch = function (error) {846 console.error("[openspec] render error:", error);847 };848 EB.prototype.render = function () {849 if (this.state.hasError) {850 return h("div", { className: "os-err" },851 "Something went wrong rendering this view: " + (this.state.error && this.state.error.message || String(this.state.error)),852 h("button", { className: "os-icon-btn", style: { marginLeft: "0.5rem" }, onClick: function () { this.setState({ hasError: false, error: null }); }.bind(this) }, "Retry")853 );854 }855 return this.props.children;856 };857 return EB;858 })(React.Component);859860 // ── Hash deep-linking ────────────────────────────────────────────────861 // Format: #project/token#anchor862 // - project/token is the route (which source + which change/spec)863 // - anchor is the sub-view (proposal/tasks/design/specs tab)864 // The second # is a literal char in the hash string; we split on it.865 function parseHash() {866 var raw = window.location.hash.replace(/^#/, "").trim();867 if (!raw) return { project: null, token: null, anchor: null };868 var route, anchor;869 var hi = raw.indexOf("#");870 if (hi >= 0) { route = raw.slice(0, hi); anchor = raw.slice(hi + 1); }871 else { route = raw; anchor = null; }872 var parts = route.split("/");873 return {874 project: decodeURIComponent(parts[0]) || null,875 token: parts[1] ? decodeURIComponent(parts[1]) : null,876 anchor: anchor || null,877 };878 }879 function setHash(project, token, anchor) {880 var v = project ? (token ? project + "/" + token : project) : "";881 if (anchor) v += "#" + anchor;882 if ((window.location.hash || "#") !== "#" + v) window.location.hash = v;883 }884885 // ── Main page component ──────────────────────────────────────────────886 function OpenSpecPage() {887 var _s2 = useState([]), _a2 = useState(null), _l3 = useState(true), _e4 = useState("");888 var sources = _s2[0], setSources = _s2[1], activeId = _a2[0], setActiveId = _a2[1];889 var loading = _l3[0], setLoading = _l3[1], err = _e4[0], setErr = _e4[1];890 var _dlg = useState(null), _anchor = useState({ project: null, token: null });891 var dlg = _dlg[0], setDlg = _dlg[1], anchor = _anchor[0], setAnchor = _anchor[1];892 var _view = useState("board"), _selItem = useState(null);893 var view = _view[0], setView = _view[1], selItem = _selItem[0], setSelItem = _selItem[1];894895 function loadSources(silentAfter) {896 if (!silentAfter) setLoading(true);897 setErr("");898 getSources().then(function (res) {899 setSources(res.sources || []);900 setLoading(false);901 }).catch(function (e) { setErr(apiErr(e)); setLoading(false); });902 }903 useEffect(function () { loadSources(); }, []);904905 // Apply hash anchor on load + when sources change. Auto-select the906 // first source when no hash project is present so the user doesn't907 // have to manually pick from the dropdown to see anything.908 useEffect(function () {909 if (!sources.length) return;910 var ha = parseHash();911 setAnchor(ha);912 if (ha.project) {913 var proj = ha.project.toLowerCase();914 var match = sources.find(function (s) {915 return (s.name || "").toLowerCase() === proj || (s.id || "").toLowerCase() === proj;916 });917 if (match && match.id !== activeId) setActiveId(match.id);918 return;919 }920 if (!activeId) setActiveId(sources[0].id);921 }, [sources]);922923 // When anchor has a token, switch view / open detail.924 useEffect(function () {925 if (!anchor.token || !activeId || !sources.length) return;926 var src = sources.find(function (s) { return s.id === activeId; });927 if (!src || !src.openspec) return;928 // Check changes + ideas929 var item = (src.openspec.changes || []).find(function (c) { return c.token === anchor.token; })930 || (src.openspec.ideas || []).find(function (c) { return c.token === anchor.token; });931 if (item) { setView("board"); setSelItem(item); return; }932 // Check specs933 var spec = (src.openspec.specs || []).find(function (s) { return s.token === anchor.token; });934 if (spec) { setView("specs"); }935 }, [anchor, activeId, sources]);936937 var active = sources.find(function (s) { return s.id === activeId; });938939 function selectSource(id) { setActiveId(id); var s = sources.find(function (x) { return x.id === id; }); setHash(s ? s.name : null, null); setView("board"); setSelItem(null); }940 function selectItem(item) { setSelItem(item); if (active && item.token) setHash(active.name, item.token, null); }941 function closeItem() { setSelItem(null); if (active) setHash(active.name, null); }942 function onTabChange(tab) { if (active && selItem && selItem.token) setHash(active.name, selItem.token, tab); }943 function selectSpec(token) { if (active) setHash(active.name, token, null); }944945 function onAdd() { setDlg({ mode: "add" }); }946 function onEdit() { if (active) setDlg({ mode: "edit", activeId: active.id, path: active.path, name: active.name }); }947 function onRemove() {948 if (!active) return;949 if (!window.confirm("Remove source '" + active.name + "'?")) return;950 removeSource(active.id).then(function () {951 setSources(sources.filter(function (s) { return s.id !== active.id; }));952 setActiveId(null); setHash(null, null);953 }).catch(function (e) { setErr(apiErr(e)); });954 }955 function onSaved(res) {956 setDlg(null);957 getSources().then(function (r) {958 setSources(r.sources || []);959 if (res && res.source) setActiveId(res.source.id);960 }).catch(function (e) { setErr(apiErr(e)); });961 }962963 // Hash change listener (browser back/forward + manual URL edits).964 useEffect(function () {965 function onHash() {966 var ha = parseHash();967 setAnchor(ha);968 if (ha.project && sources.length) {969 var proj = ha.project.toLowerCase();970 var match = sources.find(function (s) {971 return (s.name || "").toLowerCase() === proj || (s.id || "").toLowerCase() === proj;972 });973 if (match) setActiveId(match.id);974 }975 }976 window.addEventListener("hashchange", onHash);977 return function () { window.removeEventListener("hashchange", onHash); };978 }, [sources]);979980 if (loading) return h("div", { className: "os-page os-loading" }, "Loading…");981 return h(ErrorBoundary, null,982 h("div", { className: "os-page" },983 h(Toolbar, {984 sources: sources, activeId: activeId, onSelect: selectSource,985 onRefresh: function () { loadSources(true); }, onAdd: onAdd, onEdit: onEdit, onRemove: onRemove,986 }),987 err ? h("p", { className: "os-err" }, err) : null,988 sources.length === 0 ? h("div", { className: "os-empty-state" },989 h("p", null, "No OpenSpec sources registered."),990 h(Button, { onClick: onAdd }, h(Icon, { d: ICO.plus, size: 14 }), " Add your first source")991 ) : null,992 active && active.valid ? h("div", { className: "os-view-switch" },993 h(Button, { variant: view === "board" ? "default" : "outline", size: "sm", onClick: function () { setView("board"); } }, "Changes"),994 h(Button, { variant: view === "specs" ? "default" : "outline", size: "sm", onClick: function () { setView("specs"); } }, "Specs")995 ) : null,996 active && !active.valid ? h("div", { className: "os-invalid-state" },997 h("p", { className: "os-err" }, active.error || "Source path is invalid."),998 active.error && active.error.toLowerCase().indexOf("openspec") >= 0 ? h(Button, {999 size: "sm", onClick: function () {1000 initSource(active.id).then(function () { loadSources(true); }).catch(function (e) { setErr(apiErr(e)); });1001 }1002 }, h(Icon, { d: ICO.plus, size: 14 }), " Initialize OpenSpec") : null1003 ) : null,1004 active && active.valid && view === "board" ? h(Board, { source: active, onSelectItem: selectItem }) : null,1005 active && active.valid && view === "specs" ? h(SpecsView, { source: active, anchorToken: anchor.token, onSelectToken: selectSpec }) : null,1006 selItem && active ? h(DetailDialog, { source: active, item: selItem, onClose: closeItem, anchor: anchor.anchor, onTabChange: onTabChange }) : null,1007 dlg ? h(SourceDialog, {1008 mode: dlg.mode, initialPath: dlg.path, initialName: dlg.name, activeId: dlg.activeId,1009 onClose: function () { setDlg(null); }, onSaved: onSaved,1010 }) : null1011 )1012 );1013 }10141015 // ── Register ─────────────────────────────────────────────────────────1016 if (window.__HERMES_PLUGINS__ && window.__HERMES_PLUGINS__.register) {1017 window.__HERMES_PLUGINS__.register("openspec", OpenSpecPage);1018 }1019})();