diff --git a/packages/webui/src/public/brand.js b/packages/webui/src/public/brand.js
index 787f2b37c..9c729f475 100644
--- a/packages/webui/src/public/brand.js
+++ b/packages/webui/src/public/brand.js
@@ -201,7 +201,7 @@
if (parts[0] === 'board' && /^#\/orgs\//.test(location.hash)) { box.innerHTML = 'Control board'; return; }
if (!parts.length || parts[0] === 'board' || parts[0] === 'dashboard') { box.innerHTML = 'Dashboard'; return; }
if (parts[0] === 'organization') { const h = $('bus-view').querySelector('[data-org-dashboard] h1')?.textContent?.trim(); box.innerHTML = `Dashboard${h ? `/${escape(h)}` : ''}`; return; }
- const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',assistant:'Assistant',tasks:'Tasks',agents:'Agents',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'};
+ const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',assistant:'Assistant',tasks:'Tasks',agents:'Agents',workflows:'Workflows',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'};
if (parts[0] === 'settings' && parts[1] === 'business' && $('bus-view').querySelector('#settings-page-title')?.textContent.trim() === 'Business') { box.innerHTML = 'Settings/Business'; return; }
const name = names[parts[0]] || 'Dashboard', heading = $('bus-view').querySelector('h1')?.textContent?.trim();
const project = $('bus-view').querySelector('.chat-conversation > p:first-child a, .chat-room > p:first-child a');
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index d0213eb88..c9cc4ad79 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -1717,6 +1717,12 @@
return agentsView(get, q, org);
}
if (a === 'agents' && n === 1) return agentsView(get);
+ // SP9 (#1873): #/orgs//workflows lists an organization's workflows; #/orgs//workflows/ draws one, read only.
+ if (a === 'workflows' && (n === 1 || n === 2) && window.MosaicOrg && window.MosaicWorkflows?.page) {
+ const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
+ let org = null; try { org = scope ? decodeURIComponent(scope) : window.MosaicOrg.id; } catch { return notFound(); }
+ return org ? window.MosaicWorkflows.page(org, n === 2 ? b : null, q) : notFound();
+ }
if (a === 'queue' && n === 1) return queueView(get, q);
if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`);
if (a === 'settings' && (n === 1 || (n === 2 && [...settingsPages.map(p=>p[0]),'names'].includes(b)))) return settingsView(get, b || 'appearance');
@@ -1731,7 +1737,7 @@
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
// The cross-organization pages (U3) poll at the workspace read's pollMs.
- const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks');
+ const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks' || /^\/workflows(\/[^/]+)?$/.test(path));
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
// Keep focus, open and the copy status across a refresh of the same page.
// In the send form, the focused field and its caret too (its values come from `draft`).
diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html
index 7092bb291..d6b78363e 100644
--- a/packages/webui/src/public/index.html
+++ b/packages/webui/src/public/index.html
@@ -7,7 +7,7 @@
-
+
Skip to content
diff --git a/packages/webui/src/public/workflows.css b/packages/webui/src/public/workflows.css
new file mode 100644
index 000000000..261dd7ad7
--- /dev/null
+++ b/packages/webui/src/public/workflows.css
@@ -0,0 +1,56 @@
+/* SP9 (#1873): the read-only workflow pages (#/orgs//workflows[/]) and the organization
+ page's Workflows card. Colours come from the theme tokens, so light and dark both follow the theme. */
+.wfg{min-width:0}
+.wfg h2{font-size:15px;margin:22px 0 8px}
+.wfg-scope:first-of-type h2{margin-top:6px}
+.wfg-list{list-style:none;margin:0;padding:0;background:var(--surface);border:1px solid var(--line);border-radius:10px}
+.wfg-item{padding:12px 14px;border-top:1px solid var(--line-soft,var(--line));overflow-wrap:anywhere}
+.wfg-item:first-child{border-top:0}
+.wfg-item-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px}
+.wfg-item-h a{font-weight:600;color:var(--action,var(--accent));text-decoration:none}
+.wfg-item-h a:hover{text-decoration:underline}
+.wfg-desc{margin:4px 0 0;color:var(--muted);font-size:13.5px}
+.wfg-chain{list-style:none;display:flex;flex-wrap:wrap;gap:4px 0;margin:8px 0 0;padding:0;font-size:13px;color:var(--text)}
+.wfg-chain li{display:inline-flex;align-items:center}
+.wfg-chain li+li::before{content:"\2192";color:var(--muted);padding:0 8px}
+:is(.wfg,.wfg-card) a:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:4px}
+
+/* The graph scrolls inside its own frame when it is wider than the frame; the page never does. */
+.wfg-frame{max-width:100%;overflow-x:auto;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px}
+.wfg-graph{display:block}
+.wfg-graph.is-vertical{width:100%;max-width:340px;height:auto;margin:0 auto}
+.wfg-node{cursor:pointer;text-decoration:none}
+.wfg-node rect{fill:var(--canvas);stroke:var(--line);stroke-width:1.5}
+.wfg-node:hover rect{stroke:var(--muted)}
+.wfg-node.is-sel rect{stroke:var(--accent);stroke-width:2.5;fill:var(--raised)}
+.wfg-node:focus{outline:none}
+.wfg-node:focus-visible rect{stroke:var(--focus);stroke-width:3}
+.wfg-node text{font-family:var(--font);fill:var(--text)}
+.wfg-meta{font-size:12px;fill:var(--muted)!important;letter-spacing:.02em}
+.wfg-title{font-size:15px;font-weight:600;fill:var(--strong,var(--text))!important}
+.wfg-roles{font-size:13px;font-family:var(--mono)!important;fill:var(--text)}
+.wfg-miss{font-size:12px;fill:var(--danger)!important}
+.wfg-node .wfg-none{fill:var(--muted);font-family:var(--font)}
+.wfg-edge{fill:none;stroke:var(--muted);stroke-width:1.5}
+.wfg-arrow{fill:var(--muted)}
+
+.wfg-detail{margin-top:16px;overflow-wrap:anywhere}
+.wfg-detail h2{margin:0 0 10px}
+.wfg-dl{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:baseline;gap:8px 16px;margin:0 0 12px}
+.wfg-dl dt{color:var(--muted);font-size:13px}
+.wfg-dl dd{margin:0}
+@media (max-width:700px){
+ .wfg-dl{grid-template-columns:minmax(0,1fr);gap:2px}
+ .wfg-dl dd{margin-bottom:8px}
+}
+
+/* The organization page's card, above Settings in the right column. */
+.wfg-card>.ws-sec-h{margin-top:0}
+.wfg-card+.ws-sec-h{margin-top:22px}
+.wfg-cardlist{list-style:none;margin:0}
+.wfg-cardlist li+li{border-top:1px solid var(--line-soft,var(--line))}
+.wfg-cardlist a{display:block;padding:8px 0;color:var(--text);text-decoration:none;overflow-wrap:anywhere}
+.wfg-cardlist a:hover strong{text-decoration:underline}
+.wfg-cardlist strong{color:var(--action,var(--accent))}
+.wfg-card [data-wfg-none]{margin:0 0 8px}
+.wfg-card p:last-child{margin-bottom:0}
diff --git a/packages/webui/src/public/workflows.js b/packages/webui/src/public/workflows.js
index b99beba0c..fa1031136 100644
--- a/packages/webui/src/public/workflows.js
+++ b/packages/webui/src/public/workflows.js
@@ -197,5 +197,189 @@
} catch (error) { s.status = error.message; }
redraw(org);
});
- window.MosaicWorkflows = { load, section };
+
+ // ---------- SP9 (#1873): the read-only view ----------
+ // #/orgs//workflows lists the organization's workflows; #/orgs//workflows/[?project=
]
+ // draws one as an inline-SVG graph. The data is the same read as the editor above: each step's title,
+ // state and roles, in file order. A workflow has no edges or branches in the file, so a step hands on to
+ // the next one. Nothing else is drawn: no gate, no inputs and no running mark, because no read gives them.
+ const WS = () => window.MosaicWorkspace;
+ const orgHash = org => `#/orgs/${encodeURIComponent(org)}`;
+ const flowHref = (org, scope, name, step) => {
+ const q = new URLSearchParams();
+ if (scope) q.set('project', scope);
+ if (step) q.set('step', String(step));
+ return `${orgHash(org)}/workflows/${encodeURIComponent(name)}${q.size ? `?${q}` : ''}`;
+ };
+ const stepsOf = w => (Array.isArray(w?.steps) ? w.steps.filter(st => st && typeof st === 'object') : []);
+ const rolesOf = st => (Array.isArray(st?.roles) ? st.roles.filter(r => typeof r === 'string') : []);
+ const count = n => `${n} step${n === 1 ? '' : 's'}`;
+ const stateLabel = st => STATE_LABEL[st?.state] ?? String(st?.state ?? '');
+ const scopeLabel = scope => (scope ? `Project ${scope}` : 'Organization');
+ // Each scope that holds a workflow, the organization first. Names are own keys only.
+ const listed = s => scopes(s).map(([scope, label, flows]) => [scope, label, Object.keys(plain(flows) ? flows : {}).sort().map(n => [n, flows[n]])]);
+ const total = s => listed(s).reduce((n, [, , rows]) => n + rows.length, 0);
+ // The organization's bus state from the membership list, and for one that is down the workspace read's
+ // reason in the Inbox's words (orgsOf + unreadNote, the data-ws-unread note).
+ async function orgOf(org) {
+ const list = await window.MosaicWorkspaceData.organizations().catch(() => null);
+ const row = Array.isArray(list) ? list.find(o => o?.id === org && !o.invalid) : null;
+ if (!row) return { row: null, list: Array.isArray(list) };
+ let unread = '';
+ if (!row.busHealthy) {
+ const body = await window.MosaicWorkspaceData.workspace({ org, sections: 'projects' }).catch(() => null);
+ const o = body ? WS().orgsOf({ orgs: (Array.isArray(body.orgs) ? body.orgs : []).filter(r => r?.org === org) }) : null;
+ unread = o?.unread.length ? WS().unreadNote(o, [], 'workflows')
+ : `
Not read: ${esc(row.label || org)} ${row.busStopped ? 'bus stopped' : 'bus not answering'}. No reason was read.
No workflow named ${esc(name)} in ${scope ? `project ${esc(scope)}` : 'the organization'}.
` };
+ const w = flows[name], steps = stepsOf(w);
+ const raw = Number(q.get('step')), sel = Number.isInteger(raw) && raw >= 1 && raw <= steps.length ? raw : 0;
+ const sub = `${esc(scopeLabel(scope))}, ${esc(name)}, ${count(steps.length)}.${w?.description ? ` ${esc(w.description)}` : ''}`;
+ const body = steps.length
+ ? `
${graph(org, s, scope, name, steps, sel)}
${detail(org, s, scope, name, steps, sel)}`
+ : '
This workflow has no steps.
';
+ return { title: esc(w?.title || name), sub, body };
+ }
+ // The page for #/orgs//workflows (name null) or #/orgs//workflows/.
+ async function page(org, name, q) {
+ const attr = name === null ? 'data-wfg-list' : 'data-wfg-page';
+ const info = await orgOf(org);
+ if (!info.row) return `${head('Organization unavailable', info.list ? 'This organization is not on your list.' : 'The organization list could not be read.')}`;
+ const wrapIt = (title, sub, body) => `${head(title, sub)}${info.unread}${body}`;
+ // E2E6 P2-8: a stopped bus is not read; the note above says why.
+ if (info.stopped) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, '');
+ await load(org);
+ const s = states.get(org);
+ if (!s?.data) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, `
${esc(s?.error ?? 'Workflows could not be read.')}
`);
+ if (name === null) return wrapIt('Workflows', `The steps a task goes through in ${esc(info.label)}, in order, with the roles that do each. Read only; the organization page changes them.`, listBody(org, s, info.label));
+ const g = graphBody(org, s, name, q);
+ return wrapIt(g.title, g.sub, g.body);
+ }
+ // The organization page's card: each workflow as a link to its graph, from the page's one workflow read.
+ function card(org, { stopped = false, label = org } = {}) {
+ const all = `All workflows`;
+ const h = note => `
Workflows
${note ? `${note}` : ''}${all}
`;
+ if (stopped) return `
${h('')}
Not read: the bus is stopped.
`;
+ const s = states.get(org);
+ if (!s?.data) return `
${h('')}
${esc(s?.error ?? 'Workflows could not be read.')}