clone

a hermes agent plugin for openspec: tools for spec workflows and a dashboard tab for browsing changes and specs

feat: spec diff view, structured rendering, hash routing with anchors

dami etoile committed ff36a1d
- Change dialog Specs tab: Proposed/Diff toggle with status badges,
  side-by-side current vs proposed using SpecContentView
- Backend: _change_detail computes before/status/diff per spec
- SpecContentView falls back to raw markdown when no parseable requirements
- Hash routing: #project/token#anchor separates route from tab anchor
- Spec view: clicking a spec updates the hash, anchor syncs selection
- Select dropdown: width: min-content on listbox popup
commit
ff36a1db819dcddaf8a32cf88493e173459c7938
parent
9eed0bb8bd55f379844366d3744b09e8ca9e9eeb
author
dami etoile <dami.etoile@gmail.com>
3 files changed, +104 -21
Mdashboard/dist/index.js110+++++++++++++++++++++++++-----
Mdashboard/dist/style.css2+
Mdashboard/plugin_api.py13+++-
M dashboard/dist/index.js +91 -19
@@ -46,6 +46,9 @@
4646 function removeSource(id) {4747 return fetchJSON(BASE + "/sources/" + encodeURIComponent(id), { method: "DELETE" });4848 }49+ function initSource(id) {50+ return fetchJSON(BASE + "/sources/" + encodeURIComponent(id) + "/init", { method: "POST" });51+ }4952 function getChange(sid, name) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/changes/" + encodeURIComponent(name)); }5053 function getIdea(sid, name) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/ideas/" + encodeURIComponent(name)); }5154 function getSpec(sid, path) { return fetchJSON(BASE + "/sources/" + encodeURIComponent(sid) + "/specs?path=" + encodeURIComponent(path)); }
@@ -298,7 +301,7 @@
298301 299302 // ── Change / Idea detail dialog ──────────────────────────────────────300303 function DetailDialog(_ref8) {301- var source = _ref8.source, item = _ref8.item, onClose = _ref8.onClose;304+ var source = _ref8.source, item = _ref8.item, onClose = _ref8.onClose, anchor = _ref8.anchor, onTabChange = _ref8.onTabChange;302305 var _d = useState(null), _l = useState(true), _e2 = useState("");303306 var detail = _d[0], setDetail = _d[1], loading = _l[0], setLoading = _l[1], err = _e2[0], setErr = _e2[1];304307 var isIdea = item.status === "ideas";
@@ -312,7 +315,7 @@
312315 loading ? h("p", { className: "os-empty" }, "Loading…") :313316 err ? h("p", { className: "os-err" }, err) :314317 isIdea ? h(Markdown, { md: detail && detail.content }) :315- detail ? h(ChangeDetailTabs, { detail: detail }) : null318+ detail ? h(ChangeDetailTabs, { detail: detail, anchor: anchor, onTabChange: onTabChange }) : null316319 );317320 }318321 // ── Task parser ─────────────────────────────────────────────────────
@@ -343,18 +346,59 @@
343346 return { sections: sections, total: total, done: done };344347 }345348 349+ function ChangeSpecsView(_ref11) {350+ var specs = _ref11.specs;351+ var _m = useState("proposed");352+ var mode = _m[0], setMode = _m[1];353+ return h("div", null,354+ h("div", { className: "os-specs-modes", style: { marginBottom: "0.5rem" } },355+ h(Button, { variant: mode === "proposed" ? "default" : "outline", size: "sm", onClick: function () { setMode("proposed"); } }, "Proposed"),356+ h(Button, { variant: mode === "diff" ? "default" : "outline", size: "sm", onClick: function () { setMode("diff"); } }, "Diff vs current")357+ ),358+ specs.map(function (s) {359+ return h("div", { key: s.path, className: "os-spec-block" },360+ h("div", { className: "os-specs-detail-head" },361+ h("span", { className: "os-specs-detail-path" }, s.path),362+ s.status && s.status !== "unchanged" && s.status !== "missing"363+ ? h(Badge, { className: "os-spec-status os-status-" + s.status }, s.status)364+ : null365+ ),366+ mode === "diff" && s.diff367+ ? h("div", { className: "os-spec-diff" },368+ h("div", { className: "os-diff-cols" },369+ h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "current"), h(SpecContentView, { md: s.before })),370+ h("div", { className: "os-diff-col" }, h("div", { className: "os-diff-col-head" }, "proposed"), h(SpecContentView, { md: s.content }))371+ ),372+ h("pre", { className: "os-diff-unified" }, s.diff)373+ )374+ : h(SpecContentView, { md: s.content })375+ );376+ })377+ );378+ }379+346380 function ChangeDetailTabs(_ref9) {347- var detail = _ref9.detail;381+ var detail = _ref9.detail, anchor = _ref9.anchor, onTabChange = _ref9.onTabChange;348382 var tabs = [];349383 if (detail.proposal) tabs.push(["proposal", "Proposal"]);350384 if (detail.tasks) tabs.push(["tasks", "Tasks"]);351385 if (detail.design) tabs.push(["design", "Design"]);352386 if (detail.specs && detail.specs.length) tabs.push(["specs", "Specs (" + detail.specs.length + ")"]);353387 if (!tabs.length) return h("p", { className: "os-empty" }, "No documents found.");354- var _tab = useState(tabs[0][0]);388+ var validAnchor = anchor && tabs.some(function (t) { return t[0] === anchor; });389+ var initialTab = validAnchor ? anchor : tabs[0][0];390+ var _tab = useState(initialTab);355391 var activeTab = _tab[0], setActiveTab = _tab[1];392+ // Sync from hash when anchor changes (browser back/forward).393+ useEffect(function () {394+ if (validAnchor && anchor !== activeTab) setActiveTab(anchor);395+ }, [anchor]);396+ function switchTab(t) {397+ setActiveTab(t);398+ if (onTabChange) onTabChange(t);399+ }356400 var content = activeTab === "specs"357- ? detail.specs.map(function (s) { return h("div", { key: s.path, className: "os-spec-block" }, h("h4", null, s.path), h(Markdown, { md: s.content })); })401+ ? h(ChangeSpecsView, { specs: detail.specs })358402 : activeTab === "tasks"359403 ? h(TasksView, { md: detail[activeTab], stats: detail.taskStats })360404 : h(Markdown, { md: detail[activeTab] });
@@ -362,7 +406,7 @@
362406 h("div", { className: "os-tabs-bar" }, tabs.map(function (t) {363407 return h("button", {364408 key: t[0], className: cn("os-tab-btn", activeTab === t[0] && "os-tab-btn-active"),365- onClick: function () { setActiveTab(t[0]); }409+ onClick: function () { switchTab(t[0]); }366410 }, t[1]);367411 })),368412 h("div", { className: "os-tab-panel" }, content)
@@ -453,7 +497,7 @@
453497 function SpecContentView(_ref) {454498 var md = _ref.md;455499 var spec = useMemo(function () { return parseSpec(md); }, [md]);456- if (!spec.title && !spec.purpose && !spec.requirements.length) {500+ if (!spec.requirements.length) {457501 return h(Markdown, { md: md });458502 }459503 return h("div", { className: "os-spec-content" },
@@ -514,7 +558,7 @@
514558 return arr;515559 }516560 function SpecsView(_ref10) {517- var source = _ref10.source, anchorToken = _ref10.anchorToken;561+ var source = _ref10.source, anchorToken = _ref10.anchorToken, onSelectToken = _ref10.onSelectToken;518562 var _m = useState("current"), _b2 = useState(null), _l2 = useState(true), _e3 = useState("");519563 var _sel = useState(null), _bf = useState(""), _af = useState("");520564 var _sm = useState("alpha");
@@ -544,6 +588,12 @@
544588 useEffect(function () {545589 if (firstLoad.current) { firstLoad.current = false; load(mode, before, after); }546590 }, []);591+ // Sync selection when anchor token changes (browser back/forward + hash edits).592+ useEffect(function () {593+ if (!anchorToken || !browser || !browser.files) return;594+ var match = browser.files.find(function (f) { return f.token === anchorToken; });595+ if (match && match.path !== selPath) setSelPath(match.path);596+ }, [anchorToken, browser]);547597 function switchMode(m) {548598 setMode(m);549599 if (m === "refs" && (!before.trim() || !after.trim())) { setBrowser(null); setLoading(false); return; }
@@ -580,7 +630,7 @@
580630 sorted.map(function (f) {581631 return h("button", {582632 key: f.path, className: cn("os-spec-item", f.path === selPath && "os-spec-item-active", f.changed && "os-spec-item-changed"),583- onClick: function () { setSelPath(f.path); },633+ onClick: function () { setSelPath(f.path); if (onSelectToken && f.token) onSelectToken(f.token); },584634 },585635 h(Icon, { d: ICO.file, size: 13 }),586636 h("span", { className: "os-spec-item-path" }, f.path),
@@ -638,15 +688,28 @@
638688 })(React.Component);639689 640690 // ── Hash deep-linking ────────────────────────────────────────────────691+ // Format: #project/token#anchor692+ // - project/token is the route (which source + which change/spec)693+ // - anchor is the sub-view (proposal/tasks/design/specs tab)694+ // The second # is a literal char in the hash string; we split on it.641695 function parseHash() {642- var h2 = window.location.hash.replace(/^#/, "").trim();643- if (!h2) return { project: null, token: null };644- var parts = h2.split("/");645- return { project: decodeURIComponent(parts[0]) || null, token: parts[1] ? decodeURIComponent(parts[1]) : null };696+ var raw = window.location.hash.replace(/^#/, "").trim();697+ if (!raw) return { project: null, token: null, anchor: null };698+ var route, anchor;699+ var hi = raw.indexOf("#");700+ if (hi >= 0) { route = raw.slice(0, hi); anchor = raw.slice(hi + 1); }701+ else { route = raw; anchor = null; }702+ var parts = route.split("/");703+ return {704+ project: decodeURIComponent(parts[0]) || null,705+ token: parts[1] ? decodeURIComponent(parts[1]) : null,706+ anchor: anchor || null,707+ };646708 }647- function setHash(project, token) {709+ function setHash(project, token, anchor) {648710 var v = project ? (token ? project + "/" + token : project) : "";649- if (("#" + v) !== window.location.hash) window.location.hash = v;711+ if (anchor) v += "#" + anchor;712+ if ((window.location.hash || "#") !== "#" + v) window.location.hash = v;650713 }651714 652715 // ── Main page component ──────────────────────────────────────────────
@@ -704,8 +767,10 @@
704767 var active = sources.find(function (s) { return s.id === activeId; });705768 706769 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); }707- function selectItem(item) { setSelItem(item); if (active && item.token) setHash(active.name, item.token); }770+ function selectItem(item) { setSelItem(item); if (active && item.token) setHash(active.name, item.token, null); }708771 function closeItem() { setSelItem(null); if (active) setHash(active.name, null); }772+ function onTabChange(tab) { if (active && selItem && selItem.token) setHash(active.name, selItem.token, tab); }773+ function selectSpec(token) { if (active) setHash(active.name, token, null); }709774 710775 function onAdd() { setDlg({ mode: "add" }); }711776 function onEdit() { if (active) setDlg({ mode: "edit", activeId: active.id, path: active.path, name: active.name }); }
@@ -758,10 +823,17 @@
758823 h(Button, { variant: view === "board" ? "default" : "outline", size: "sm", onClick: function () { setView("board"); } }, "Changes"),759824 h(Button, { variant: view === "specs" ? "default" : "outline", size: "sm", onClick: function () { setView("specs"); } }, "Specs")760825 ) : null,761- active && !active.valid ? h("p", { className: "os-err" }, active.error || "Source path is invalid.") : null,826+ active && !active.valid ? h("div", { className: "os-invalid-state" },827+ h("p", { className: "os-err" }, active.error || "Source path is invalid."),828+ active.error && active.error.toLowerCase().indexOf("openspec") >= 0 ? h(Button, {829+ size: "sm", onClick: function () {830+ initSource(active.id).then(function () { loadSources(true); }).catch(function (e) { setErr(apiErr(e)); });831+ }832+ }, h(Icon, { d: ICO.plus, size: 14 }), " Initialize OpenSpec") : null833+ ) : null,762834 active && active.valid && view === "board" ? h(Board, { source: active, onSelectItem: selectItem }) : null,763- active && active.valid && view === "specs" ? h(SpecsView, { source: active, anchorToken: anchor.token }) : null,764- selItem && active ? h(DetailDialog, { source: active, item: selItem, onClose: closeItem }) : null,835+ active && active.valid && view === "specs" ? h(SpecsView, { source: active, anchorToken: anchor.token, onSelectToken: selectSpec }) : null,836+ selItem && active ? h(DetailDialog, { source: active, item: selItem, onClose: closeItem, anchor: anchor.anchor, onTabChange: onTabChange }) : null,765837 dlg ? h(SourceDialog, {766838 mode: dlg.mode, initialPath: dlg.path, initialName: dlg.name, activeId: dlg.activeId,767839 onClose: function () { setDlg(null); }, onSaved: onSaved,
M dashboard/dist/style.css +2 -0
M dashboard/plugin_api.py +11 -2