the-custodian/state-hub/dashboard/src/workstreams.md
tegwick 95fcbba716 Dashboard: reusable MultiSelect dropdown component for workstreams filters
Adds src/components/multiselect.js — a compact dropdown multi-select that is
Observable-compatible (exposes .value, dispatches bubbling input events) so it
works with view(), Inputs.form, and Generators.input without modification.

Component behaviour:
- Closed state: pill button showing "Label: All" (muted) or active selection
  (1-2 items shown by name, 3+ shown as "N of M"); blue border when active
- Open state: dropdown with per-item checkboxes + "Clear selection" link
  (only visible when something is selected); closes on outside click / Escape
- Styles injected once into document.head (STYLE_ID guard prevents duplicates)
- Uses CSS custom properties for light/dark mode compatibility

Workstreams page update:
- Domain and Status filters now use MultiSelect instead of Inputs.checkbox
- Filter bar layout reduced to a tight inline row (0.5rem gap)
- Owner text filter restyled to match trigger button height
- No changes to filter logic or downstream cells (filters.domain / .status
  are still string[] with empty = show all)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 00:19:58 +01:00

5.9 KiB

title
Workstreams
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
// Fetch workstreams + topics + summary (for dep graph) in parallel
const wsState = (async function*() {
  while (true) {
    let data = [], openWs = [], ok = false;
    try {
      const [rw, rt, rs] = await Promise.all([
        fetch(`${API}/workstreams/`),
        fetch(`${API}/topics/`),
        fetch(`${API}/state/summary`),
      ]);
      ok = rw.ok && rt.ok && rs.ok;
      if (ok) {
        const [wsList, topicList, summary] = await Promise.all([rw.json(), rt.json(), rs.json()]);
        const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
        data = wsList.map(w => ({
          ...w,
          domain: topicMap[w.topic_id]?.domain ?? "unknown",
          topic_title: topicMap[w.topic_id]?.title ?? "—",
        }));
        // open_workstreams from summary carry depends_on / blocks lists
        openWs = summary.open_workstreams ?? [];
      }
    } catch {}
    yield {data, openWs, ok, ts: new Date()};
    await new Promise(res => setTimeout(res, POLL));
  }
})();
const data   = wsState.data   ?? [];
const openWs = wsState.openWs ?? [];
const _ok    = wsState.ok     ?? false;
const _ts    = wsState.ts;

Workstreams

display(html`<div class="live-bar">
  <span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
  ${_ok
    ? `Live · updated ${_ts?.toLocaleTimeString()}`
    : `<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`);
import {MultiSelect} from "./components/multiselect.js";

// Static options — no dependency on `data`, so selections survive polls
const DOMAINS  = ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
const STATUSES = ["active", "blocked", "completed", "archived"];

const filters = view(Inputs.form(
  {
    domain: MultiSelect(DOMAINS,  {label: "Domain",  placeholder: "All domains"}),
    status: MultiSelect(STATUSES, {label: "Status",  placeholder: "All statuses"}),
    owner:  Inputs.text({placeholder: "Owner…", style: "width:120px"}),
  },
  {
    template: ({domain, status, owner}) => html`<div class="filter-bar">
      ${domain}${status}
      <div class="filter-owner">${owner}</div>
    </div>`,
  }
));
// Empty array = no filter applied (show all)
const filtered = data.filter(w =>
  (filters.domain.length === 0 || filters.domain.includes(w.domain)) &&
  (filters.status.length === 0 || filters.status.includes(w.status)) &&
  (!filters.owner || (w.owner ?? "").toLowerCase().includes(filters.owner.toLowerCase()))
);

display(Inputs.table(filtered.map(w => ({
  Title:   w.title,
  Domain:  w.domain,
  Status:  w.status,
  Owner:   w.owner ?? "—",
  Due:     w.due_date ?? "—",
  Updated: new Date(w.updated_at).toLocaleDateString(),
})), {rows: 20}));

Status Distribution

import * as Plot from "npm:@observablehq/plot";

const byStatus = Object.entries(
  filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {})
).map(([status, count]) => ({status, count}));

display(Plot.plot({
  marks: [
    Plot.barX(byStatus, {y: "status", x: "count", fill: "status", tip: true}),
    Plot.ruleX([0]),
  ],
  marginLeft: 80,
  width: 500,
}));

Dependencies

// Build dep cards from the enriched open_workstreams in the summary
const wsWithDeps = openWs.filter(w => {
  const domain = data.find(d => d.id === w.id)?.domain ?? "unknown";
  return (filters.domain.length === 0 || filters.domain.includes(domain)) &&
    (filters.status.length === 0 || filters.status.includes(w.status)) &&
    (w.depends_on.length > 0 || w.blocks.length > 0);
});

if (wsWithDeps.length === 0) {
  display(html`<p class="dim">No dependency edges recorded for the current filter. Use <code>create_dependency()</code> via the MCP server to link workstreams.</p>`);
} else {
  display(html`<div class="dep-grid">${wsWithDeps.map(w => {
    const depRows = w.depends_on.map(d =>
      html`<div class="dep-row dep-on">↳ depends on <strong>${d.workstream_title}</strong>${d.description ? html` <span class="dep-desc">— ${d.description}</span>` : ""}</div>`
    );
    const blockRows = w.blocks.map(d =>
      html`<div class="dep-row dep-block">⊳ blocks <strong>${d.workstream_title}</strong>${d.description ? html` <span class="dep-desc">— ${d.description}</span>` : ""}</div>`
    );
    return html`<div class="dep-card">
      <div class="dep-title">${w.title}</div>
      <div class="dep-status dep-status-${w.status}">${w.status}</div>
      ${depRows}${blockRows}
    </div>`;
  })}</div>`);
}