- 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>
| M | dashboard/ | 110 | +++++++++++++++++++++++++----- |
| M | dashboard/ | 2 | + |
| M | dashboard/ | 13 | +++- |
@@ -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,@@ -13,6 +13,7 @@
1313 /* Shrink Select dropdowns to fit their text content instead of stretching1414 (the SDK Select trigger has w-full, which over-sizes in flex toolbars). */1515 .os-page div.relative:has(> button[role="combobox"]) { width: fit-content; }16+.os-page [role="listbox"] { width: min-content; }1617 1718 .os-icon-btn {1819 display: inline-flex; align-items: center; justify-content: center;@@ -83,6 +84,7 @@
8384 .os-err { color: #f87171; font-size: 0.8rem; }8485 .os-empty { color: var(--color-muted-foreground, #777); font-size: 0.85rem; padding: 0.5rem 0; }8586 .os-empty-state { text-align: center; padding: 3rem 1rem; display: flex; flex-direction: column; align-items: center; gap: 1rem; color: var(--color-muted-foreground, #777); }87+.os-invalid-state { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem; padding: 1rem 0; }8688 8789 .os-detail-chip { margin-bottom: 0.75rem; }8890 @@ -348,12 +348,21 @@ def _change_detail(root: Path, change_name: str) -> Optional[dict[str, Any]]:
348348 specs_root = change_dir / "specs"349349 has_tasks = tasks_path.is_file()350350 stats = _task_stats(tasks_path) if has_tasks else None351- specs: list[dict[str, str]] = []351+ specs: list[dict[str, Any]] = []352352 if specs_root.is_dir():353353 try:354354 for child in sorted(specs_root.rglob("*.md")):355355 rel = child.relative_to(specs_root).as_posix()356- specs.append({"path": rel, "content": _read_doc(child) or ""})356+ after = _read_doc(child) or ""357+ before = _read_worktree_spec(root, rel)358+ status = _spec_status(before, after)359+ specs.append({360+ "path": rel,361+ "content": after,362+ "before": before,363+ "status": status,364+ "diff": _spec_diff(before, after, rel) if status not in ("unchanged", "missing") else "",365+ })357366 except OSError:358367 pass359368 return {