@crewhaus/hangar-ui 0.5.7 → 0.5.8

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/README.md CHANGED
@@ -285,16 +285,60 @@ origin — never part of this one. The asset-hygiene suite pins the exception
285
285
  to that single module, and `paneSandbox` strips `allow-same-origin` even if a
286
286
  payload asks for it.
287
287
 
288
+ ## M5 — the Advisor
289
+
290
+ `views/advisor.js` draws one more harness tab (`#/h/<id>/advisor`, right
291
+ after Overview — the strip order is the triage order) and one more fleet
292
+ screen (`#/advisor`):
293
+
294
+ - **The feed** — every alert, suggestion and optimization signal the manager
295
+ can derive about the harness, in one severity-ranked list. Every item
296
+ carries a hover TOOLTIP (`withTip`/`tipIcon` in `dom.js`: a `data-tip`
297
+ attribute the stylesheet renders via `attr()` — text only, so the
298
+ injection ban holds) explaining why it matters, the fact it was derived
299
+ from, guidance on what to do, and a quick action: an executable button for
300
+ a queued CLI verb (with its CLI twin beside it) or a deep link into the
301
+ tab that owns the fix. Acting opens an inline confirm where a COMMENT can
302
+ be attached — it is recorded in the harness-local decisions ledger next to
303
+ the queued job. Dismissing REQUIRES a reason (the server refuses without
304
+ one), dismissed items keep their recorded reasoning in a collapsible fold,
305
+ and every dismissal can be reopened. Zero open items renders as a
306
+ first-class "running optimally" state, never as an empty screen.
307
+ - **The trend** — "is it improving?": the eval pass-rate bars, the decision
308
+ counts, and a one-line verdict, all folded from durable sources.
309
+ - **Reports** — generate model-usage / costs / usefulness / optimization
310
+ reports (each button tooltipped with what it measures); saved reports are
311
+ listed and re-readable in place.
312
+ - **Issues** — describe a problem and it is tuned into an update ready to
313
+ run: the kind selector (tooltipped per kind) picks what gets queued, with
314
+ `optimize` — the eval→patch loop whose artifact is a reviewable spec
315
+ patch — as the default.
316
+ - **The fleet board** (`#/advisor`) — every harness's rollup, worst first,
317
+ each row deep-linking its Advisor tab.
318
+
319
+ The Library also becomes curatable (M5): harness rows carry a `hidden` flag
320
+ (the row editor's Visibility checkbox), hidden rows fold out of every view
321
+ except an explicit **Hidden** rail bucket, and a **Find harnesses…** panel
322
+ lists the unregistered harnesses under the scan roots (via
323
+ `GET /api/registry/discover`) with per-candidate Add buttons — discovery
324
+ without registration, so the Library only shows what you add. The blanket
325
+ Scan (which registers everything) stays as the labeled bulk fallback.
326
+
288
327
  ## Testing
289
328
 
290
- `bun test src` — the embed-map completeness/hygiene suite plus unit tests
291
- for the pure browser modules (`util.js`, `supervision.js`, `markdown.js`,
292
- `router.js`, `routes.js`, `shapes.js`) and the `api.js` client under a
293
- stubbed `fetch` (every wrapper asserted against the route map's
294
- method/path/body). All DOM-free at import time and imported directly by
295
- bun. Deterministic: clocks are injected, no network, no subprocesses. The
296
- server side of the same contract runs in `@crewhaus/hangar-server`'s
297
- `contract.test.ts`.
329
+ `bun run test` — two `bun test` invocations, DELIBERATELY two processes:
330
+ `bun test src` runs the unit tests for the pure browser modules
331
+ (`util.js`, `supervision.js`, `markdown.js`, `router.js`, `routes.js`,
332
+ `shapes.js`, the view decisions) and the `api.js` client under a stubbed
333
+ `fetch` (every wrapper asserted against the route map's method/path/body);
334
+ `bun test ./src/embed-suite.ts` then runs the embed-map
335
+ completeness/hygiene suite alone. The split is load-bearing, not taste:
336
+ the embed suite imports every asset as TEXT while the view tests import
337
+ the same files as ES modules, and bun's module registry keys a module by
338
+ path alone — in one process whichever load lands first poisons the other.
339
+ All DOM-free at import time and imported directly by bun. Deterministic:
340
+ clocks are injected, no network, no subprocesses. The server side of the
341
+ same contract runs in `@crewhaus/hangar-server`'s `contract.test.ts`.
298
342
 
299
343
  The M2 screens stay testable without a browser by keeping the decisions —
300
344
  state → row, envelope → disabled-with-reason, SSE frame → feed item,
package/assets/hangar.css CHANGED
@@ -1724,3 +1724,178 @@ tr.deflated td {
1724
1724
  grid-column: 2 / 3;
1725
1725
  }
1726
1726
  }
1727
+
1728
+ /* ── tooltips (M5): data-tip rendered via attr() — text only, never markup */
1729
+
1730
+ .has-tip {
1731
+ position: relative;
1732
+ cursor: help;
1733
+ }
1734
+ .has-tip::after {
1735
+ content: attr(data-tip);
1736
+ position: absolute;
1737
+ left: 0;
1738
+ bottom: calc(100% + 7px);
1739
+ z-index: 60;
1740
+ width: max-content;
1741
+ max-width: min(34rem, 78vw);
1742
+ padding: 7px 10px;
1743
+ border: 1px solid var(--rule-2);
1744
+ border-radius: var(--radius-sm);
1745
+ background: var(--panel-3);
1746
+ color: var(--ink);
1747
+ font-size: 12.5px;
1748
+ font-weight: 400;
1749
+ font-family: var(--sans);
1750
+ line-height: 1.45;
1751
+ white-space: normal;
1752
+ text-align: left;
1753
+ opacity: 0;
1754
+ pointer-events: none;
1755
+ transition: opacity 120ms ease;
1756
+ box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35);
1757
+ }
1758
+ .has-tip:hover::after,
1759
+ .has-tip:focus-visible::after {
1760
+ opacity: 1;
1761
+ }
1762
+ .tip-icon {
1763
+ display: inline-flex;
1764
+ align-items: center;
1765
+ justify-content: center;
1766
+ width: 15px;
1767
+ height: 15px;
1768
+ border: 1px solid var(--rule-2);
1769
+ border-radius: 50%;
1770
+ font-size: 10.5px;
1771
+ color: var(--ink-3);
1772
+ background: var(--panel-2);
1773
+ vertical-align: middle;
1774
+ margin-left: 5px;
1775
+ }
1776
+
1777
+ /* ── advisor (M5): the unified alert/suggestion feed ───────────────────── */
1778
+
1779
+ .adv-item {
1780
+ display: grid;
1781
+ grid-template-columns: 96px minmax(0, 1fr) auto;
1782
+ gap: 8px 12px;
1783
+ align-items: start;
1784
+ padding: 10px 12px;
1785
+ border: 1px solid var(--rule);
1786
+ border-left-width: 3px;
1787
+ border-radius: var(--radius-sm);
1788
+ margin: 8px 0;
1789
+ background: var(--panel-2);
1790
+ }
1791
+ .adv-critical {
1792
+ border-left-color: var(--bad);
1793
+ }
1794
+ .adv-warn {
1795
+ border-left-color: var(--warn);
1796
+ }
1797
+ .adv-suggestion {
1798
+ border-left-color: var(--info);
1799
+ }
1800
+ .adv-main {
1801
+ min-width: 0;
1802
+ }
1803
+ .adv-title {
1804
+ font-weight: 550;
1805
+ }
1806
+ .adv-detail {
1807
+ margin-top: 2px;
1808
+ font-size: 12.5px;
1809
+ }
1810
+ .adv-guidance {
1811
+ margin-top: 4px;
1812
+ font-size: 12.5px;
1813
+ color: var(--ink-2);
1814
+ }
1815
+ .adv-actions {
1816
+ display: flex;
1817
+ flex-direction: column;
1818
+ align-items: flex-end;
1819
+ gap: 6px;
1820
+ }
1821
+ .adv-cli {
1822
+ font-size: 11px;
1823
+ color: var(--ink-3);
1824
+ font-family: var(--mono);
1825
+ max-width: 24rem;
1826
+ overflow: hidden;
1827
+ text-overflow: ellipsis;
1828
+ white-space: nowrap;
1829
+ }
1830
+ .adv-form {
1831
+ grid-column: 1 / -1;
1832
+ display: flex;
1833
+ gap: 8px;
1834
+ align-items: center;
1835
+ flex-wrap: wrap;
1836
+ }
1837
+ .adv-form .input {
1838
+ flex: 1 1 16rem;
1839
+ }
1840
+ .adv-optimal {
1841
+ border: 1px solid var(--ok);
1842
+ border-radius: var(--radius);
1843
+ background: var(--accent-ghost);
1844
+ padding: 16px 18px;
1845
+ margin: 10px 0;
1846
+ }
1847
+ .adv-note {
1848
+ font-size: 12.5px;
1849
+ color: var(--ink-2);
1850
+ }
1851
+ .adv-decision {
1852
+ font-size: 12px;
1853
+ color: var(--ink-3);
1854
+ }
1855
+ .adv-board-row {
1856
+ display: grid;
1857
+ grid-template-columns: minmax(10rem, 1fr) auto auto minmax(0, 2fr);
1858
+ gap: 8px 14px;
1859
+ align-items: center;
1860
+ padding: 9px 12px;
1861
+ border-bottom: 1px solid var(--rule);
1862
+ }
1863
+ .trend-bars {
1864
+ display: flex;
1865
+ align-items: flex-end;
1866
+ gap: 3px;
1867
+ height: 44px;
1868
+ margin: 6px 0;
1869
+ }
1870
+ .trend-bar {
1871
+ width: 14px;
1872
+ min-height: 2px;
1873
+ background: var(--accent-ghost);
1874
+ border-top: 2px solid var(--accent);
1875
+ }
1876
+
1877
+ /* ── find harnesses (M5): discovery without registration ───────────────── */
1878
+
1879
+ .find-row {
1880
+ display: flex;
1881
+ gap: 10px;
1882
+ align-items: center;
1883
+ padding: 7px 10px;
1884
+ border-bottom: 1px solid var(--rule);
1885
+ }
1886
+ .find-row .mono {
1887
+ flex: 1 1 auto;
1888
+ min-width: 0;
1889
+ overflow: hidden;
1890
+ text-overflow: ellipsis;
1891
+ white-space: nowrap;
1892
+ }
1893
+
1894
+ @media (max-width: 720px) {
1895
+ .adv-item {
1896
+ grid-template-columns: minmax(0, 1fr);
1897
+ }
1898
+ .adv-actions {
1899
+ align-items: flex-start;
1900
+ }
1901
+ }
package/assets/js/api.js CHANGED
@@ -262,7 +262,9 @@ export const api = {
262
262
  groupProc: (name, verb, body = {}) => call(ROUTES.groupProc, { name, verb }, body),
263
263
  setTags: (id, tags) => call(ROUTES.setTags, { id }, { tags }),
264
264
  setPin: (id, pinned) => call(ROUTES.setPin, { id }, { pinned }),
265
+ setHidden: (id, hidden) => call(ROUTES.setHidden, { id }, { hidden }),
265
266
  setNotes: (id, notes) => call(ROUTES.setNotes, { id }, { notes }),
267
+ discover: () => call(ROUTES.discover, {}),
266
268
  relocateHarness: (id, newDir) => call(ROUTES.relocate, { id }, { newDir }),
267
269
  removeHarness: (id) => call(ROUTES.removeHarness, { id }),
268
270
 
package/assets/js/app.js CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  import { shapeAccent, shapeLabel } from "./shapes.js";
38
38
  import { procRow } from "./supervision.js";
39
39
  import { renderActivity } from "./views/activity.js";
40
+ import { renderAdvisor, renderAdvisorBoard } from "./views/advisor.js";
40
41
  import { renderApprovals } from "./views/approvals.js";
41
42
  import { renderChannels } from "./views/channels.js";
42
43
  import { renderCosts } from "./views/costs.js";
@@ -68,6 +69,7 @@ import { renderTokenScreen } from "./views/token.js";
68
69
  const THEME_KEY = "hangar.theme";
69
70
  const TAB_LABELS = {
70
71
  overview: "Overview",
72
+ advisor: "Advisor",
71
73
  spec: "Spec",
72
74
  runs: "Runs",
73
75
  schedulers: "Schedulers",
@@ -94,6 +96,7 @@ const NAV = [
94
96
  { view: "review", label: "Review" },
95
97
  { view: "activity", label: "Activity" },
96
98
  { view: "health", label: "Health" },
99
+ { view: "advisor", label: "Advisor" },
97
100
  { view: "credentials", label: "Credentials" },
98
101
  { view: "feedback", label: "Feedback" },
99
102
  { view: "thredz", label: "Thredz" },
@@ -352,6 +355,8 @@ async function dispatch(route) {
352
355
  await renderThredzGlobal(viewRoot);
353
356
  } else if (route.view === "health") {
354
357
  await renderHealthBoard(viewRoot);
358
+ } else if (route.view === "advisor") {
359
+ await renderAdvisorBoard(viewRoot);
355
360
  } else if (route.view === "settings") {
356
361
  await renderSettings(viewRoot);
357
362
  } else {
@@ -423,7 +428,8 @@ async function renderHarnessPage(root, route) {
423
428
  if (route.tab === "spec") {
424
429
  await renderSpec(tabRoot, ctx);
425
430
  await renderSpecEdit(section(tabRoot), ctx);
426
- } else if (route.tab === "runs") await renderRuns(tabRoot, ctx);
431
+ } else if (route.tab === "advisor") await renderAdvisor(tabRoot, ctx);
432
+ else if (route.tab === "runs") await renderRuns(tabRoot, ctx);
427
433
  else if (route.tab === "schedulers") await renderSchedulers(tabRoot, ctx);
428
434
  else if (route.tab === "sessions") await renderSessions(tabRoot, ctx);
429
435
  else if (route.tab === "evals") {
package/assets/js/dom.js CHANGED
@@ -161,6 +161,36 @@ export function copyBtn(value, label = "copy") {
161
161
  return btn;
162
162
  }
163
163
 
164
+ /**
165
+ * Attach a rich hover tooltip. The text rides a `data-tip` attribute the
166
+ * stylesheet renders via `attr()` — always text, never markup, so the
167
+ * injection ban holds — with the native `title` kept as the fallback for
168
+ * assistive tech and anything that never hovers. Returns the node.
169
+ */
170
+ export function withTip(node, tipText) {
171
+ const value = String(tipText ?? "");
172
+ if (value === "") return node;
173
+ node.classList.add("has-tip");
174
+ node.dataset.tip = value;
175
+ if (!node.getAttribute("title")) node.setAttribute("title", value);
176
+ return node;
177
+ }
178
+
179
+ /** A small "?" affordance carrying only its tooltip — guidance on rows where
180
+ * a whole sentence inline would drown the content. Keyboard-focusable, so
181
+ * the tip is reachable without a pointer. */
182
+ export function tipIcon(tipText) {
183
+ return withTip(
184
+ el("span", {
185
+ class: "tip-icon",
186
+ tabindex: "0",
187
+ "aria-label": String(tipText ?? ""),
188
+ text: "?",
189
+ }),
190
+ tipText,
191
+ );
192
+ }
193
+
164
194
  /** A native collapsible (<details>) card. */
165
195
  export function collapsible(summaryChildren, bodyChildren, open = false) {
166
196
  return el("details", { class: "fold", open: open || null }, [
@@ -40,6 +40,9 @@
40
40
 
41
41
  export const HARNESS_TABS = [
42
42
  "overview",
43
+ // M5: the unified alert/suggestion feed — right after Overview because it
44
+ // is the "what needs doing" screen; the strip order is the triage order.
45
+ "advisor",
43
46
  "spec",
44
47
  "runs",
45
48
  "schedulers",
@@ -77,6 +80,9 @@ export const M3_TABS = [
77
80
  "thredz",
78
81
  "inspect",
79
82
  "dev",
83
+ // M5: the advisor's sub-screens (a saved report, say) are the view's to
84
+ // name — the same generic `rest` capture the M3 tabs use.
85
+ "advisor",
80
86
  ];
81
87
 
82
88
  /** The global (fleet-wide) screens, in nav order. `#/` is the Library. */
@@ -92,6 +98,9 @@ export const GLOBAL_VIEWS = [
92
98
  // notification rules, read-only mode, the plugin inventory).
93
99
  "health",
94
100
  "settings",
101
+ // M5: the fleet advisor board — every harness's open alerts/suggestions,
102
+ // worst first.
103
+ "advisor",
95
104
  ];
96
105
 
97
106
  /**
@@ -56,7 +56,15 @@ export const ROUTES = {
56
56
  body: "GroupProc", // {force?, acknowledge?, parallel?}
57
57
  },
58
58
  addScanRoot: { method: "POST", path: "/api/registry/scan-roots", body: "ScanRootCreate" }, // {dir}
59
+ // Discovery WITHOUT registration: the harnesses under the scan roots that
60
+ // are NOT yet registered, each one an Add away — the "find harnesses on
61
+ // this machine" flow. Adding stays the default gesture; a blanket scan
62
+ // (which registers everything) is the bulk fallback, not the front door.
63
+ discover: { method: "GET", path: "/api/registry/discover" },
59
64
  removeHarness: { method: "DELETE", path: "/api/h/:id" },
65
+ // Curation, not removal: hidden entries stay registered (state, groups,
66
+ // history intact) but the Library's default view omits them.
67
+ setHidden: { method: "PUT", path: "/api/h/:id/visibility", body: "SetVisibility" }, // {hidden}
60
68
  relocate: { method: "POST", path: "/api/h/:id/relocate", body: "Relocate" }, // {newDir}
61
69
  setGroups: { method: "PUT", path: "/api/h/:id/groups", body: "SetGroups" }, // {groups}
62
70
  // One member's BOOT position inside a group; `order: null` clears it.
@@ -741,6 +749,60 @@ export const ROUTES = {
741
749
  group: "inspect",
742
750
  },
743
751
 
752
+ // ---- group "advisor": the unified alert/suggestion feed + its loops ----
753
+ // M5. Every alert, suggestion and optimization signal the manager can read
754
+ // about a harness, in ONE feed: each item carries its severity, the fact it
755
+ // came from, a tooltip-grade explanation, guidance, the screen that owns
756
+ // the fix, and a quick action (a queued CLI verb from a closed vocabulary,
757
+ // or a deep link — a suggestion is never an application). Acting takes an
758
+ // optional operator comment; dismissing REQUIRES a reason; both append to
759
+ // a harness-local ledger and reopen supersedes rather than deletes.
760
+ advisor: { method: "GET", path: "/api/h/:id/advisor", group: "advisor" },
761
+ advisorAct: {
762
+ method: "POST",
763
+ path: "/api/h/:id/advisor/:itemId/act",
764
+ body: "AdvisorAct", // {comment?}
765
+ group: "advisor",
766
+ },
767
+ advisorDismiss: {
768
+ method: "POST",
769
+ path: "/api/h/:id/advisor/:itemId/dismiss",
770
+ body: "AdvisorDismiss", // {reason}
771
+ group: "advisor",
772
+ },
773
+ advisorReopen: {
774
+ method: "POST",
775
+ path: "/api/h/:id/advisor/:itemId/reopen",
776
+ body: "Empty", // {}
777
+ group: "advisor",
778
+ },
779
+ // Improvement over time, folded from durable sources only (the eval index,
780
+ // the cost ledger, the decisions ledger) — a read never stores a snapshot.
781
+ advisorTrend: { method: "GET", path: "/api/h/:id/advisor/trend", group: "advisor" },
782
+ advisorReports: { method: "GET", path: "/api/h/:id/advisor/reports", group: "advisor" },
783
+ advisorReportRun: {
784
+ method: "POST",
785
+ path: "/api/h/:id/advisor/reports",
786
+ body: "AdvisorReportRun", // {kind: model-usage|costs|usefulness|optimization}
787
+ group: "advisor",
788
+ },
789
+ advisorReport: {
790
+ method: "GET",
791
+ path: "/api/h/:id/advisor/reports/:reportId",
792
+ group: "advisor",
793
+ },
794
+ // The issue inbox: a complaint submitted here is tuned into an update
795
+ // ready to run — `optimize` queues the eval→patch loop whose artifact is a
796
+ // reviewable spec patch; `note` records without queueing anything.
797
+ advisorIssues: { method: "GET", path: "/api/h/:id/advisor/issues", group: "advisor" },
798
+ advisorIssueSubmit: {
799
+ method: "POST",
800
+ path: "/api/h/:id/advisor/issues",
801
+ body: "AdvisorIssue", // {title, detail?, kind?}
802
+ group: "advisor",
803
+ },
804
+ advisorFleet: { method: "GET", path: "/api/advisor", group: "advisor" },
805
+
744
806
  // ---- group "runtime": the mcp-server + dev run classes -----------------
745
807
  // Both are PROCESSES: they get runfiles, ledger rows and ledger-claimed
746
808
  // ports, and their live output rides the ONE existing SSE feed
@@ -763,8 +825,9 @@ export const ROUTES = {
763
825
  devStop: { method: "POST", path: "/api/h/:id/dev/stop", body: "Empty", group: "runtime" },
764
826
  };
765
827
 
766
- /** The M3 area groups, in tab order. A route's `group` names the module that
767
- * owns it on the server and the view that renders it in the console. */
828
+ /** The grouped areas, in tab order (the M3 areas plus M5's advisor). A
829
+ * route's `group` names the module that owns it on the server and the view
830
+ * that renders it in the console. */
768
831
  export const M3_GROUPS = [
769
832
  "spec",
770
833
  "memory",
@@ -777,6 +840,7 @@ export const M3_GROUPS = [
777
840
  "thredz",
778
841
  "inspect",
779
842
  "runtime",
843
+ "advisor",
780
844
  ];
781
845
 
782
846
  /** Every route key belonging to `group`, in map order. The views render
package/assets/js/util.js CHANGED
@@ -180,6 +180,7 @@ export function normalizeRows(feed) {
180
180
  groups: Array.isArray(src.groups) ? src.groups.map(String) : [],
181
181
  tags: Array.isArray(src.tags) ? src.tags.map(String) : [],
182
182
  pinned: src.pinned === true,
183
+ hidden: src.hidden === true,
183
184
  notes: typeof src.notes === "string" ? src.notes : "",
184
185
  missingSince: typeof src.missingSince === "string" ? src.missingSince : null,
185
186
  lastSeen: typeof src.lastSeen === "string" ? src.lastSeen : null,