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>
5.9 KiB
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>`);
}