// Modules = reusable building blocks, each a sub-project with its own Forgejo
// repo + spec (brief) + roadmap. Agents improve a module the same way they work
// any project. This screen: Library (cards) / Graph (deps) / Matrix (project×module).
// Reuses globals from board.jsx (Icon, mdToHtml) and pages.jsx (RepoFiles,
// RoadmapModal, BriefModal). brief/roadmap/files hit the shared project endpoints.
const MOD_CAT_ORDER = ['foundation', 'content', 'support', 'revenue', 'insight'];
const MOD_STATUS = { planned: 'var(--c-text-3)', building: 'var(--c-status-running)', done: 'var(--c-status-done)' };
// ---- dependency layering (column = dependency depth) ----
function moduleLayers(modules) {
const by = Object.fromEntries(modules.map(m => [m.id, m]));
const memo = {};
const depth = (id, seen = new Set()) => {
if (id in memo) return memo[id];
if (seen.has(id)) return 0; // cycle guard
seen.add(id);
const m = by[id];
const deps = (m && m.deps) || [];
const d = deps.length ? 1 + Math.max(...deps.map(x => (by[x] ? depth(x, seen) : -1))) : 0;
return (memo[id] = d);
};
modules.forEach(m => depth(m.id));
const layers = [];
modules.forEach(m => { const d = memo[m.id]; (layers[d] = layers[d] || []).push(m); });
return layers;
}
function ModuleGraph({ modules }) {
const layers = moduleLayers(modules);
const NW = 160, NH = 48, VGAP = 20, COLW = 230, PADX = 24, PADY = 28;
const pos = {};
layers.forEach((col, li) => (col || []).forEach((m, ri) => {
pos[m.id] = { x: PADX + li * COLW, y: PADY + ri * (NH + VGAP), m };
}));
const W = PADX * 2 + (layers.length - 1) * COLW + NW;
const maxRows = Math.max(1, ...layers.map(c => (c || []).length));
const H = PADY * 2 + maxRows * (NH + VGAP) - VGAP;
const edges = [];
modules.forEach(m => ((m.deps || []).forEach(dep => {
if (pos[dep] && pos[m.id]) edges.push({ from: pos[dep], to: pos[m.id], key: `${dep}-${m.id}` });
})));
return (
{edges.map(e => {
const x1 = e.from.x + NW, y1 = e.from.y + NH / 2;
const x2 = e.to.x, y2 = e.to.y + NH / 2;
const mx = (x1 + x2) / 2;
return ;
})}
{Object.values(pos).map(({ x, y, m }) => (
{m.icon || m.name[0]}
{m.name}
{m.category}
))}
);
}
function AssemblyMatrix({ data, onToggle, busyCell }) {
const { projects, modules, links } = data;
const linkOf = (pid, mid) => links.find(l => l.project_id === pid && l.module_id === mid);
if (!projects.length) return no projects yet
;
return (
Project \ Module
{modules.map(m => (
{m.icon || m.name[0]}
{m.name}
))}
{projects.map(p => (
{p.name}
{modules.map(m => {
const link = linkOf(p.id, m.id);
const busy = busyCell === `${p.id}:${m.id}`;
return (
onToggle(p.id, m.id, link)}>
{busy ? '·' : link
?
: }
);
})}
))}
{Object.entries(MOD_STATUS).map(([k, c]) => (
{k}
))}
);
}
function ImproveModal({ module, onSubmit, onClose }) {
const [text, setText] = React.useState('');
const [busy, setBusy] = React.useState(false);
async function submit() {
if (!text.trim()) return;
setBusy(true);
try { await onSubmit(text.trim()); onClose(); } finally { setBusy(false); }
}
return (
e.stopPropagation()}>
Improve {module.name}
codex-agent works the module's own repo ({module.repo} ). The spec is sent as context automatically.
Cancel
{busy ? 'Dispatching…' : 'Dispatch to agent'}
);
}
function ModuleDetail({ module, tasks, onBack, pushToast }) {
const [tab, setTab] = React.useState('spec');
const [kb, setKb] = React.useState(null);
const [editBrief, setEditBrief] = React.useState(false);
const [roadmapItem, setRoadmapItem] = React.useState(null); // {} = new
const [improve, setImprove] = React.useState(false);
const loadKb = React.useCallback(() => {
window.ORC_API.getKb(module.id).then(setKb).catch(() => setKb({ brief: '', items: [] }));
}, [module.id]);
React.useEffect(() => { loadKb(); }, [loadKb]);
const mtasks = (tasks || []).filter(t => t.project === module.id);
const badge = (col) => col === 'review' ? 'review' : col === 'done' ? 'live' : col === 'failed' ? 'idle' : col === 'running' ? 'update' : 'idle';
async function saveBrief(text) {
await window.ORC_API.updateBrief(module.id, text);
loadKb();
pushToast({ tone: 'ok', title: 'Spec saved', msg: module.name });
}
async function submitRoadmap(body) {
if (roadmapItem && roadmapItem.id) await window.ORC_API.updateRoadmap(roadmapItem.id, body);
else await window.ORC_API.createRoadmap(module.id, body);
setRoadmapItem(null); loadKb();
}
async function generate() {
pushToast({ tone: 'review', title: 'Generating roadmap', msg: module.name });
try { await window.ORC_API.generateRoadmap(module.id); setTimeout(loadKb, 1500); }
catch (e) { pushToast({ tone: 'fail', title: 'Generate failed', msg: e.message || 'error' }); }
}
async function dispatchImprove(prompt) {
const t = await window.ORC_API.improveModule(module.id, prompt);
pushToast({ tone: 'ok', title: 'Dispatched', msg: `${t.id} → ${module.name}` });
}
const items = (kb && kb.items) || [];
return (
{module.icon || module.name[0]}
{module.name}
{module.repo} · {module.default_branch || 'main'} · {module.category}
All modules
setImprove(true)}> Improve
{(module.deps && module.deps.length > 0) && (
depends on
{module.deps.map(d => {d} )}
)}
{[
{ id: 'spec', label: 'Spec', icon: 'book' },
{ id: 'roadmap', label: 'Roadmap', icon: 'steps' },
{ id: 'files', label: 'Files', icon: 'folder' },
{ id: 'tasks', label: `Tasks (${mtasks.length})`, icon: 'log' },
].map(t => (
setTab(t.id)}>
{t.label}
))}
{tab === 'spec' && (
Spec
setEditBrief(true)}> Edit
{kb === null ?
loading…
: (kb.brief || '').trim()
?
:
no spec yet — click Edit to write the module concept
}
)}
{tab === 'roadmap' && (
Roadmap
{kb && kb.generating ? 'Generating…' : 'Generate'}
setRoadmapItem({})}> Add
{items.length === 0 ?
no milestones yet
: items.map(it => (
setRoadmapItem(it)}>
{it.status}
{it.title}
{it.detail &&
{it.detail}
}
{it.estimate}
))}
)}
{tab === 'files' &&
}
{tab === 'tasks' && (
Tasks {mtasks.length}
{mtasks.length === 0 ?
no tasks — hit Improve to dispatch one
: [...mtasks].reverse().map(t => (
{t.title}
{t.id}{t.agent ? ` · ${t.agent}` : ''}
{t.column}
))}
)}
{editBrief &&
setEditBrief(false)} />}
{roadmapItem !== null && setRoadmapItem(null)} />}
{improve && setImprove(false)} />}
);
}
function ModulesScreen({ tasks, pushToast }) {
const [view, setView] = React.useState('library'); // library | graph | matrix
const [modules, setModules] = React.useState(null);
const [openId, setOpenId] = React.useState(null);
const [assembly, setAssembly] = React.useState(null);
const [busyCell, setBusyCell] = React.useState(null);
const loadModules = React.useCallback(() => {
window.ORC_API.getModules().then(setModules).catch(() => setModules([]));
}, []);
React.useEffect(() => { loadModules(); }, [loadModules]);
React.useEffect(() => {
if (view === 'matrix') window.ORC_API.getAssembly().then(setAssembly).catch(() => setAssembly({ projects: [], modules: [], links: [] }));
}, [view]);
async function toggleCell(pid, mid, link) {
setBusyCell(`${pid}:${mid}`);
try {
if (link) await window.ORC_API.unsetProjectModule(pid, mid);
else await window.ORC_API.setProjectModule(pid, { module_id: mid });
const a = await window.ORC_API.getAssembly(); setAssembly(a);
} catch (e) { pushToast({ tone: 'fail', title: 'Failed', msg: e.message || 'error' }); }
finally { setBusyCell(null); }
}
if (openId && modules) {
const m = modules.find(x => x.id === openId);
if (m) return { setOpenId(null); loadModules(); }} pushToast={pushToast} />;
}
const byCat = {};
(modules || []).forEach(m => { (byCat[m.category] || (byCat[m.category] = [])).push(m); });
const cats = Object.keys(byCat).sort((a, b) => {
const ia = MOD_CAT_ORDER.indexOf(a), ib = MOD_CAT_ORDER.indexOf(b);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
});
const counts = (modules || []).reduce((acc, m) => { acc[m.id] = (tasks || []).filter(t => t.project === m.id).length; return acc; }, {});
return (
Modules
Reusable building blocks · each a sub-project with its own repo, spec and roadmap
{[{ id: 'library', label: 'Library' }, { id: 'graph', label: 'Graph' }, { id: 'matrix', label: 'Matrix' }].map(v => (
setView(v.id)}>{v.label}
))}
{modules === null ?
loading…
: view === 'library' ? (
cats.length === 0 ?
no modules
: cats.map(cat => (
{cat}
{byCat[cat].map(m => (
setOpenId(m.id)}>
{m.icon || m.name[0]}
{counts[m.id] > 0 && {counts[m.id]} task{counts[m.id] > 1 ? 's' : ''} }
{firstLine(m.brief) || 'no spec yet'}
category {m.category}
{(m.deps && m.deps.length > 0) && deps {m.deps.join(', ')} }
{ e.stopPropagation(); setOpenId(m.id); }}> Open
))}
))
) : view === 'graph' ?
: assembly === null ?
loading…
:
}
);
}
// first non-empty, non-heading line of a markdown brief (for card description)
function firstLine(md) {
if (!md) return '';
for (const l of md.split('\n')) {
const t = l.trim();
if (t && !t.startsWith('#')) return t.length > 140 ? t.slice(0, 140) + '…' : t;
}
return '';
}
window.ModulesScreen = ModulesScreen;