From 3116f43e65224d95eaa9d88d8134e64e06bacf4b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 07:32:18 -0600 Subject: [PATCH 1/3] feat(webui): sp9, an organization's workflows read only, a list at #/orgs//workflows, a step graph at #/orgs//workflows/ with the selected step's roles, state and neighbours, and a Workflows card on the organization page (#1873, lane sp9-workflows) The graph reads the existing GET /api/orgs//workflows; no new route and no write. A stopped bus shows the data-ws-unread note, never "no workflows". An organization with none says so and links Apply a pack only where this Console may apply one. At 700px and below the graph runs top to bottom inside its own frame. --- packages/webui/src/public/brand.js | 2 +- packages/webui/src/public/bus.js | 8 +- packages/webui/src/public/index.html | 2 +- packages/webui/src/public/workflows.css | 55 ++++ packages/webui/src/public/workflows.js | 174 +++++++++++- packages/webui/src/public/workspace.js | 3 +- packages/webui/src/serve.mjs | 3 +- .../webui/tests/workflows-browser.test.mjs | 252 ++++++++++++++++++ 8 files changed, 493 insertions(+), 6 deletions(-) create mode 100644 packages/webui/src/public/workflows.css create mode 100644 packages/webui/tests/workflows-browser.test.mjs 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 = 'SettingsBusiness'; 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 @@ - + diff --git a/packages/webui/src/public/workflows.css b/packages/webui/src/public/workflows.css new file mode 100644 index 000000000..2cc11a773 --- /dev/null +++ b/packages/webui/src/public/workflows.css @@ -0,0 +1,55 @@ +/* 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-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..e21abab7f 100644 --- a/packages/webui/src/public/workflows.js +++ b/packages/webui/src/public/workflows.js @@ -197,5 +197,177 @@ } 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.

`; + } + return { row, unread, stopped: !row.busHealthy && row.busStopped === true, label: row.label || org }; + } + const head = (title, sub) => `

${title}

${sub}

`; + const applyLink = (org, s) => (s?.data?.writable + ? `

Apply a pack

` + : '

A starter pack brings workflows. Only the owner, on a Console started with send, can apply one.

'); + const none = (org, s, label) => `

No workflows in ${esc(label)} yet.

${applyLink(org, s)}`; + + function listBody(org, s, label) { + if (total(s) === 0) return none(org, s, label); + return listed(s).filter(([scope, , rows]) => !scope || rows.length).map(([scope, label2, rows]) => `

${esc(label2)}

${rows.length ? `
    ${rows.map(([name, w]) => { + const steps = stepsOf(w); + return `
  • ${esc(w?.title || name)}${w?.title ? ` ${esc(name)}` : ''} ${count(steps.length)}
    + ${w?.description ? `

    ${esc(w.description)}

    ` : ''}
      ${steps.map(st => `
    1. ${esc(st.title)}
    2. `).join('')}
  • `; + }).join('')}
` : '

None at the organization level.

'}
`).join(''); + } + + // Layout: left to right on a wide screen, top to bottom on a narrow one. The graph scrolls inside its + // own frame when it is wider than the frame; the page never does. + const NARROW = '(max-width: 700px)'; + const narrow = () => window.matchMedia(NARROW).matches; + const clip = (text, n) => (text.length > n ? `${text.slice(0, n - 1)}…` : text); + // Up to two lines of about `n` characters, broken at spaces; the second ends in … when the title is longer. + function wrap(text, n) { + const words = String(text ?? '').split(/\s+/).filter(Boolean), lines = ['']; + for (const w of words) { + const cur = lines[lines.length - 1]; + if (!cur || (cur + ' ' + w).length <= n) lines[lines.length - 1] = cur ? `${cur} ${w}` : w; + else if (lines.length < 2) lines.push(w); + else { lines[1] = `${lines[1]} ${w}`; } + } + if (lines[0].length > n) return [clip(lines[0], n)]; + if (lines[1] && lines[1].length > n) lines[1] = clip(lines[1], n); + return lines; + } + function graph(org, s, scope, name, steps, sel) { + const vertical = narrow(); + const W = vertical ? 300 : 188, GAP = vertical ? 32 : 36, PAD = 14; + const per = vertical ? 36 : 21; + // Each box is as tall as the tallest step needs: meta, one or two title lines, roles, and the missing-role line if any. + const rolesY = st => 46 + (wrap(st.title, per).length - 1) * 19 + 24; + const H = Math.max(...steps.map(st => rolesY(st) + (rolesOf(st).some(r => !s.known.has(r)) ? 18 : 0) + 16)); + const pos = i => (vertical ? [PAD, PAD + i * (H + GAP)] : [PAD + i * (W + GAP), PAD]); + const width = vertical ? W + 2 * PAD : PAD * 2 + steps.length * W + (steps.length - 1) * GAP; + const height = vertical ? PAD * 2 + steps.length * H + (steps.length - 1) * GAP : H + 2 * PAD; + const edges = steps.slice(1).map((_, i) => { + const [x, y] = pos(i), [x2, y2] = pos(i + 1); + const d = vertical ? `M${x + W / 2} ${y + H} V${y2 - 3}` : `M${x + W} ${y + H / 2} H${x2 - 3}`; + return ``; + }).join(''); + const nodes = steps.map((st, i) => { + const [x, y] = pos(i), n = i + 1, roles = rolesOf(st), missing = roles.filter(r => !s.known.has(r)); + const lines = wrap(st.title, per), on = sel === n; + const label = `Step ${n} of ${steps.length}: ${st.title ?? ''}. ${stateLabel(st)}. Roles ${roles.join(', ')}${missing.length ? `; ${missing.join(', ')} ${MISSING}` : ''}.`; + return ` + ${esc(st.title)} + Step ${n} · ${esc(stateLabel(st))} + ${lines.map((l, j) => `${esc(l)}${j < lines.length - 1 ? ' ' : ''}`).join('')} + ${esc(clip(roles.join(', '), per + 4))} + ${missing.length ? `${esc(clip(`${missing.join(', ')} ${MISSING}`, per + 4))}` : ''}`; + }).join(''); + return ` + ${edges}${nodes}`; + } + const stepRef = (org, scope, name, steps, n) => `Step ${n}, ${esc(steps[n - 1].title)}`; + function detail(org, s, scope, name, steps, sel) { + if (!sel) return `

Step details

Select a step to see its roles, the state it puts a task in and the step it hands on to.

`; + const st = steps[sel - 1], roles = rolesOf(st); + const roleList = roles.map(r => r === 'human' ? 'human (a person)' : s.known.has(r) ? `${esc(r)}` : `${esc(r)} ${MISSING}`).join(', '); + return `

${esc(st.title)}

+
Position
Step ${sel} of ${steps.length}
+
Roles
${roleList || 'None recorded'}
+
Puts the task in
${esc(stateLabel(st))}
+
Comes after
${sel > 1 ? stepRef(org, scope, name, steps, sel - 1) : 'Nothing: this is the first step'}
+
Hands on to
${sel < steps.length ? stepRef(org, scope, name, steps, sel + 1) : 'Nothing: this is the last step'}
+

The workflow file records no gate or inputs for a step, and no read says which step a task is on now.

`; + } + function graphBody(org, s, name, q) { + const scope = q.get('project') || ''; + const flows = scope ? s.data.projects[scope] : s.data.workflows; + if (scope && !Object.hasOwn(s.data.projects, scope)) return { title: esc(name), sub: '', body: `

No code project named ${esc(scope)} in the organization file.

` }; + if (!plain(flows) || !Object.hasOwn(flows, name)) return { title: esc(name), sub: '', body: `

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.')}

`; + const rows = listed(s).flatMap(([scope, , list]) => list.map(([name, w]) => [scope, name, w])); + if (!rows.length) return `
${h('none')}
${none(org, s, label)}
`; + return `
${h(String(rows.length))}
`; + } + // A step box or a step link in the details selects that step in the address (setQuery repaints). + document.addEventListener('click', ev => { + const link = ev.target.closest('[data-wfg-step],[data-wfg-go]'); + if (link && WS()?.setQuery && ev.button === 0 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey && !ev.altKey) { + ev.preventDefault(); + WS().setQuery({ step: link.dataset.wfgStep ?? link.dataset.wfgGo }); + return; + } + // Apply a pack: the pack picker on the organization page, scrolled into view. + const apply = ev.target.closest('[data-wfg-apply]'); + if (!apply || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return; + const show = () => { const p = document.querySelector('[data-packs]'); if (!p) return false; p.scrollIntoView({ block: 'start' }); return true; }; + if (location.hash === apply.getAttribute('href') && show()) { ev.preventDefault(); return; } + const until = Date.now() + 8000; + const wait = () => { if (location.hash === apply.getAttribute('href') && document.querySelector('[data-org-dashboard] [data-packs]')) setTimeout(show, 0); else if (Date.now() < until) setTimeout(wait, 60); }; + setTimeout(wait, 60); + }); + // Crossing the narrow breakpoint redraws the graph in the other direction. + window.matchMedia(NARROW).addEventListener('change', () => { + if (document.querySelector('[data-wfg-graph]')) document.dispatchEvent(new CustomEvent('mosaic-workspace-render')); + }); + window.MosaicWorkflows = { load, section, page, card }; })(); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index c15392a14..69c5b4212 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -343,7 +343,8 @@ ${secH('Queue', queueNote, 'Open queue')} ${queueWhy} -
+
+ ${window.MosaicWorkflows?.card?.(id, { stopped, label: label(org) }) ?? ''} ${secH('Settings', 'every setting, with where its value comes from')} ${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index e92a58943..14c058fc2 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -86,6 +86,7 @@ const files = new Map([ ['/packs.js', ['packs.js', 'text/javascript; charset=utf-8']], ['/workflows.js', ['workflows.js', 'text/javascript; charset=utf-8']], ['/packs.css', ['packs.css', 'text/css; charset=utf-8']], + ['/workflows.css', ['workflows.css', 'text/css; charset=utf-8']], ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]), ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]), ]); @@ -1085,7 +1086,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF return templatesHTTP(req, res, { path, owner, read: requestRead, write: send ? templatesWrite : null, session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso?.publicOrigin }); } - if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/packs.js', '/packs.css', '/workflows.js'].includes(path)) return json(res, 403, { error: 'owner-only' }); + if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/packs.js', '/packs.css', '/workflows.js', '/workflows.css'].includes(path)) return json(res, 403, { error: 'owner-only' }); // Row 194 (#1692, D4.1): the tester also launches and terminates. if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path)) return json(res, 403, { error: 'read-only', sent: false }); diff --git a/packages/webui/tests/workflows-browser.test.mjs b/packages/webui/tests/workflows-browser.test.mjs new file mode 100644 index 000000000..619c808f0 --- /dev/null +++ b/packages/webui/tests/workflows-browser.test.mjs @@ -0,0 +1,252 @@ +// Single pane SP9 (#1873): an organization's workflows, read only. #/orgs//workflows lists them, +// #/orgs//workflows/ draws one as an inline-SVG graph (one box per step, an arrowed line per +// hand-off), and the organization page carries a small card. The data is what GET /api/orgs//workflows +// returns from the organization file: steps in order, each with a title, a state and its roles. Nothing +// else is drawn. A stopped organization is not read and says why. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer } from '../src/serve.mjs'; +import { createDevOwner } from '../src/dev-owner.mjs'; +import { browser } from './browser.mjs'; +import { close } from './fixture.mjs'; + +const XSS = ''; +const at = minutes => new Date(Date.now() - minutes * 60000).toISOString(); +const waitFor = b => (expression, what = expression) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,1500))})()`); + +// The coding starter pack's workflow as pack.apply writes it (the pack's step ids are not kept). +const CHANGE = { + description: 'One change from task to merge.', + steps: [ + { title: 'Write the task', roles: ['pm'], state: 'todo' }, + { title: 'Implement the change', roles: ['coder'], state: 'in-progress' }, + { title: 'Review the change', roles: ['reviewer'], state: 'in-review' }, + { title: 'Approve the merge', roles: ['human', 'merge-gate'], state: 'done' }, + ], +}; +const DOCS = { + acme: { + definitions: { pm: {}, coder: {}, reviewer: {} }, + workflows: { change: CHANGE, intake: { title: `Intake ${XSS}`, steps: [{ title: `Sort ${XSS}`, state: 'todo', roles: ['human'] }] } }, + projects: { site: { workflows: { change: { title: 'Site release', steps: [{ title: 'Build', state: 'in-progress', roles: ['coder'] }, { title: 'Ship', state: 'done', roles: ['human'] }] } } }, notes: { kind: 'library', workflows: { hidden: { steps: [{ title: 'x', state: 'todo', roles: ['human'] }] } } } }, + }, + bare: { definitions: {}, projects: {} }, +}; +const LIST = [ + { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, + { id: 'bare', label: 'Bare Works', theme: 'grove', busHealthy: true }, + { id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true }, +]; + +function organizations(reads) { + const member = org => ({ + id: org.id, doc: { appearance: { theme: org.theme }, roles: {}, projects: {} }, + status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }), + options: () => ({ bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => [] }, chat: { threads: async () => [], projects: async () => [] } }), + }); + return { + list: async () => LIST, + members: async () => LIST.map(member), + resolve: async id => { + const org = LIST.find(o => o.id === id); if (!org) return null; + const workflowsRead = async () => { reads.push(id); return { revision: 'a'.repeat(64), document: structuredClone(DOCS[id] ?? {}), templates: ['writer'] }; }; + if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, workflowsRead, chat: { projects: async () => [], threads: async () => [] } }; + return { + busHealthy: true, workflowsRead, packsWrite: async () => ({}), decisionWrite: async () => ({}), send: async () => ({}), + requestRead: async verb => { if (verb === 'packs.console') return { packs: [] }; if (verb === 'autonomy.get.console') return { revision: 'e'.repeat(64), autonomy: {}, levels: {} }; throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); }, + reads: { business: async () => ({ business: { id, projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) }, + bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) }, + chat: { projects: async () => [], threads: async () => [] }, + }; + }, + }; +} + +async function setup(t, { path, viewport = [1440, 900], mode = null } = {}) { + const reads = []; + const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: organizations(reads) }); + t.after(() => close(server)); + const b = await browser({ machine: false, args: ['--disable-extensions'] }); + t.after(() => b.close()); + await b.viewport(...viewport); + // The Console's own mode wins over the OS setting (Harbor in the dark by default), so a theme test sets it per organization. + if (mode) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `try { for (const o of ['acme','bare','gamma']) localStorage.setItem('mosaic-console-appearance:' + o, JSON.stringify({ theme: 'harbor', mode: ${JSON.stringify(mode)} })); } catch {}` }); + await b.navigate(`http://127.0.0.1:${server.address().port}/${path}`); + return { b, reads, wait: waitFor(b) }; +} + +const boxes = `[...document.querySelectorAll('[data-wfg-graph] [data-wfg-step]')]`; + +test('the list names each workflow by scope with its steps in order, links to its graph, and keeps titles as text', { timeout: 60000 }, async t => { + const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows' }); + await wait(`document.querySelector('[data-wfg-list] [data-wfg-item="change"]')`, 'workflow list'); + assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`), 'Workflows'); + // Organization first, then each code project; a library project has no workflows. + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-scope-list]')].map(n=>[n.dataset.wfgScopeList,n.querySelector('h2').textContent])`), [['', 'Organization'], ['site', 'Project site']]); + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-scope-list=""] [data-wfg-item]')].map(n=>n.dataset.wfgItem)`), ['change', 'intake']); + const change = `[data-wfg-scope-list=""] [data-wfg-item="change"]`; + assert.equal(await b.evaluate(`document.querySelector('${change} a[data-wfg-open]').getAttribute('href')`), '#/orgs/acme/workflows/change'); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-scope-list="site"] [data-wfg-item="change"] a[data-wfg-open]').getAttribute('href')`), '#/orgs/acme/workflows/change?project=site'); + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('${change} [data-wfg-chain] li')].map(l=>l.textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']); + assert.match(await b.evaluate(`document.querySelector('${change}').textContent`), /4 steps/); + assert.match(await b.evaluate(`document.querySelector('${change}').textContent`), /One change from task to merge\./); + // The title is shown as written. + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-item="intake"] a[data-wfg-open]').textContent`), `Intake ${XSS}`); + assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view img').length`), 0); + assert.equal(await b.evaluate(`window.__sp9pwn===undefined`), true); + assert.equal(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]')`), null, 'a running bus has no unread note'); + // The trail names the organization and the page. + await wait(`document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent === 'Acme Studio'`, 'org crumb'); + // A click opens the graph. + await b.evaluate(`document.querySelector('${change} a[data-wfg-open]').click();true`); + await wait(`location.hash === '#/orgs/acme/workflows/change' && document.querySelectorAll('[data-wfg-graph] [data-wfg-step]').length === 4`, 'graph after click'); +}); + +test('the graph draws one box per step in order with its roles and one arrowed edge per hand-off', { timeout: 60000 }, async t => { + const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows/change' }); + await wait(`${boxes}.length === 4`, 'four boxes'); + assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`), 'change'); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]').tagName.toLowerCase()`), 'svg'); + assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.dataset.wfgStep)`), ['1', '2', '3', '4']); + assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelector('[data-wfg-title]').textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']); + assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelector('[data-wfg-roles]').textContent)`), ['pm', 'coder', 'reviewer', 'human, merge-gate']); + // A role that resolves to no definition or template (and is not human) is marked on its box. + assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelectorAll('[data-wfg-missing]').length)`), [0, 0, 0, 1]); + // At 1440 the steps run left to right. + const xs = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().left)`); + assert.deepEqual([...xs].sort((p, q) => p - q), xs); + assert.equal(new Set(xs).size, 4); + // One edge per hand-off, from each box to the next, with an arrowhead. + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-graph] [data-wfg-edge]')].map(e=>e.dataset.wfgEdge)`), ['1-2', '2-3', '3-4']); + const touches = await b.evaluate(`[...document.querySelectorAll('[data-wfg-graph] [data-wfg-edge]')].map(e=>{ + const [f,t]=e.dataset.wfgEdge.split('-'), box=n=>document.querySelector('[data-wfg-graph] [data-wfg-step="'+n+'"] rect').getBBox(); + const p=e.getPointAtLength(0), q=e.getPointAtLength(e.getTotalLength()), a=box(f), z=box(t), near=(pt,r)=>pt.x>=r.x-14&&pt.x<=r.x+r.width+14&&pt.y>=r.y-14&&pt.y<=r.y+r.height+14; + return near(p,a)&&near(q,z)&&!!e.getAttribute('marker-end');})`); + assert.deepEqual(touches, [true, true, true]); + // The graph scrolls inside its own box, never the page. + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true); + // Nothing running is drawn: no read links a task to a step. + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-running]')`), null); +}); + +test('selecting a step shows what the data holds for it and survives a repaint', { timeout: 60000 }, async t => { + const { b, wait, reads } = await setup(t, { path: '#/orgs/acme/workflows/change' }); + await wait(`${boxes}.length === 4`, 'four boxes'); + assert.match(await b.evaluate(`document.querySelector('[data-wfg-detail]').textContent`), /Select a step/); + await b.evaluate(`document.querySelector('[data-wfg-step="2"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));true`); + await wait(`/[?&]step=2/.test(location.hash) && document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'detail for step 2'); + assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=2'); + const field = name => b.evaluate(`document.querySelector('[data-wfg-detail] [data-wfg-field="${name}"]')?.textContent.replace(/\\s+/g,' ').trim() ?? null`); + assert.equal(await field('position'), 'Step 2 of 4'); + assert.equal(await field('roles'), 'coder'); + assert.equal(await field('state'), 'In progress'); + assert.equal(await field('after'), 'Step 1, Write the task'); + assert.equal(await field('next'), 'Step 3, Review the change'); + // The workflow file holds no gate or inputs for a step; the panel says so once instead of inventing them. + assert.match(await b.evaluate(`document.querySelector('[data-wfg-detail]').textContent`), /records no gate or inputs/); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="2"]').getAttribute('aria-current')`), 'step'); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-wfg-graph] [aria-current]').length`), 1); + // The last step hands on to nothing; an unknown role is named in the panel too. + await b.evaluate(`document.querySelector('[data-wfg-step="4"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));true`); + await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'detail for step 4'); + assert.equal(await field('next'), 'Nothing: this is the last step'); + assert.match(await field('roles'), /human \(a person\)/); + assert.match(await field('roles'), /merge-gate names no current role/); + // Keyboard: a step box is a link that takes focus and opens on Enter. + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="1"]').closest('a,[tabindex]') !== null`), true); + // A repaint (the poll) keeps the selection. + const before = reads.length; + await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`); + await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'kept after repaint'); + assert.ok(reads.length >= before); + // A project's workflow of the same name is its own graph. + await b.evaluate(`location.hash = '#/orgs/acme/workflows/change?project=site'`); + await wait(`${boxes}.length === 2`, 'project graph'); + assert.match(await b.evaluate(`document.querySelector('#bus-view').textContent`), /Project site/); + // A name the scope does not hold says so. + await b.evaluate(`location.hash = '#/orgs/acme/workflows/nope'`); + await wait(`/No workflow named nope/.test(document.querySelector('#bus-view').textContent)`, 'unknown workflow'); +}); + +test('an organization with no workflows says so on its page and its card, and links to apply a pack', { timeout: 60000 }, async t => { + const { b, wait } = await setup(t, { path: '#/orgs/bare/workflows' }); + await wait(`document.querySelector('[data-wfg-list] [data-wfg-none]')`, 'none note'); + assert.match(await b.evaluate(`document.querySelector('[data-wfg-none]').textContent`), /No workflows in Bare Works yet/); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-list] [data-wfg-apply]').getAttribute('href')`), '#/orgs/bare/organization'); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-list] [data-wfg-item]')`), null); + // The organization page's card says the same and links the list. + await b.evaluate(`location.hash = '#/orgs/bare/organization'`); + await wait(`document.querySelector('[data-org-dashboard] [data-wfg-card] [data-wfg-none]')`, 'card none'); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-all]').getAttribute('href')`), '#/orgs/bare/workflows'); + // Apply a pack takes the person to the pack picker on the same page. + await wait(`document.querySelector('[data-packs]')`, 'packs section'); + await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-apply]').click();true`); + await wait(`(()=>{const r=document.querySelector('[data-packs]').getBoundingClientRect();return r.top>=0&&r.top { + const { b, wait, reads } = await setup(t, { path: '#/orgs/acme/organization' }); + await wait(`document.querySelectorAll('[data-org-dashboard] [data-wfg-card] [data-wfg-card-item]').length === 3`, 'card items'); + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-card] [data-wfg-card-item]')].map(a=>[a.getAttribute('href'),a.textContent.replace(/\\s+/g,' ').trim()])`), [ + ['#/orgs/acme/workflows/change', 'change 4 steps'], + ['#/orgs/acme/workflows/intake', `Intake ${XSS} 1 step`], + ['#/orgs/acme/workflows/change?project=site', 'Site release 2 steps, project site'], + ]); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-all]').getAttribute('href')`), '#/orgs/acme/workflows'); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-dashboard] img').length`), 0); + assert.equal(await b.evaluate(`window.__sp9pwn===undefined`), true); + // The card reuses the page's one workflow read; it sends no second one. + assert.equal(reads.filter(id => id === 'acme').length, 1); + await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-card-item]').click();true`); + await wait(`location.hash === '#/orgs/acme/workflows/change' && document.querySelectorAll('[data-wfg-graph] [data-wfg-step]').length === 4`, 'graph from card'); +}); + +test('a stopped organization is not read: the page and card say the bus is stopped with the reason, never "no workflows"', { timeout: 60000 }, async t => { + const { b, wait, reads } = await setup(t, { path: '#/orgs/gamma/workflows' }); + await wait(`document.querySelector('[data-wfg-list] [data-ws-unread]')`, 'unread note'); + const note = await b.evaluate(`document.querySelector('[data-wfg-list] [data-ws-unread]').textContent`); + assert.match(note, /^Not read: Gamma Home/); + assert.match(note, /stopped by the owner/); + assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), /No workflows/); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-none],[data-wfg-item]')`), null); + // The graph address says the same. + await b.evaluate(`location.hash = '#/orgs/gamma/workflows/change'`); + await wait(`document.querySelector('[data-wfg-page] [data-ws-unread]')`, 'graph unread note'); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]')`), null); + assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), /No workflow/); + // The organization page card. + await b.evaluate(`location.hash = '#/orgs/gamma/organization'`); + await wait(`document.querySelector('[data-org-dashboard] [data-wfg-card][data-bus-stopped]')`, 'stopped card'); + assert.match(await b.evaluate(`document.querySelector('[data-wfg-card]').textContent`), /Not read: the bus is stopped\./); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-none]')`), null); + assert.equal(reads.filter(id => id === 'gamma').length, 0, 'a stopped organization is not read'); +}); + +test('at 390 the graph runs top to bottom inside its box and the page does not scroll sideways', { timeout: 60000 }, async t => { + for (const mode of ['light', 'dark']) { + const dark = mode === 'dark'; + await t.test(mode, async t2 => { + const { b, wait } = await setup(t2, { path: '#/orgs/acme/workflows/change?step=3', viewport: [390, 844], mode }); + await wait(`${boxes}.length === 4 && document.querySelector('[data-wfg-detail-title]')`, 'graph at 390'); + assert.equal(await b.evaluate(`document.documentElement.dataset.mode`), mode, 'the page shows the mode under test'); + const ys = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().top)`); + assert.deepEqual([...ys].sort((p, q) => p - q), ys); + assert.equal(new Set(ys).size, 4); + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true, 'no sideways page scroll'); + // Every box fits the width of its own box. + assert.equal(await b.evaluate(`(()=>{const w=document.querySelector('[data-wfg-graph]').closest('[data-wfg-frame]').getBoundingClientRect();return ${boxes}.every(g=>{const r=g.querySelector('rect').getBoundingClientRect();return r.left>=w.left-1&&r.right<=w.right+1})})()`), true); + // The box text and the edges read in the theme's colours, not the browser default black on dark. + const fill = await b.evaluate(`getComputedStyle(document.querySelector('[data-wfg-step="1"] [data-wfg-title]')).fill`); + const bg = await b.evaluate(`getComputedStyle(document.body).backgroundColor`); + assert.notEqual(fill, bg); + assert.notEqual(fill, dark ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)'); + // An SVG rect with no theme fill is drawn black. + assert.notEqual(await b.evaluate(`getComputedStyle(document.querySelector('[data-wfg-step="1"] rect')).fill`), 'rgb(0, 0, 0)'); + // The list and the card fit too. + await b.evaluate(`location.hash = '#/orgs/acme/workflows'`); + await wait(`document.querySelector('[data-wfg-list] [data-wfg-item]')`, 'list at 390'); + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true, 'list: no sideways page scroll'); + }); + } +}); -- 2.54.0 From 3597e0daf661f1edb0c44032cf10708240119cf3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 07:47:06 -0600 Subject: [PATCH 2/3] fix(webui): sp9 review round, a step box lists its roles one per line (three at most, then "+N more"), clips its text to the box, and the step panel drops aria-live; tests press Enter on a step and check a four-role step stays inside its box (#1873, lane sp9-workflows) --- packages/webui/src/public/workflows.css | 1 + packages/webui/src/public/workflows.js | 28 +++++++++---- .../webui/tests/workflows-browser.test.mjs | 42 +++++++++++++++++-- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/webui/src/public/workflows.css b/packages/webui/src/public/workflows.css index 2cc11a773..261dd7ad7 100644 --- a/packages/webui/src/public/workflows.css +++ b/packages/webui/src/public/workflows.css @@ -30,6 +30,7 @@ .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)} diff --git a/packages/webui/src/public/workflows.js b/packages/webui/src/public/workflows.js index e21abab7f..fa1031136 100644 --- a/packages/webui/src/public/workflows.js +++ b/packages/webui/src/public/workflows.js @@ -271,9 +271,15 @@ const vertical = narrow(); const W = vertical ? 300 : 188, GAP = vertical ? 32 : 36, PAD = 14; const per = vertical ? 36 : 21; - // Each box is as tall as the tallest step needs: meta, one or two title lines, roles, and the missing-role line if any. - const rolesY = st => 46 + (wrap(st.title, per).length - 1) * 19 + 24; - const H = Math.max(...steps.map(st => rolesY(st) + (rolesOf(st).some(r => !s.known.has(r)) ? 18 : 0) + 16)); + // Roles are monospace (13px, about 7.9px a character), so a character budget fits the box; the clip path below catches the rest. + const rper = Math.floor((W - 24) / 7.9); + // One role a line, at most three lines: with four or more, two roles and "+N more". Every role is in the label and the detail panel. + const roleLines = st => { const r = rolesOf(st); return !r.length ? [] : r.length <= 3 ? r : [...r.slice(0, 2), `+${r.length - 2} more`]; }; + const titleEnd = st => 46 + (wrap(st.title, per).length - 1) * 19; + const rolesY = st => titleEnd(st) + 24; + const missY = st => rolesY(st) + (Math.max(roleLines(st).length, 1) - 1) * 17 + 18; + // Each box is as tall as the tallest step needs: meta, one or two title lines, its role lines, and the missing-role line if any. + const H = Math.max(...steps.map(st => (rolesOf(st).some(r => !s.known.has(r)) ? missY(st) : missY(st) - 18) + 16)); const pos = i => (vertical ? [PAD, PAD + i * (H + GAP)] : [PAD + i * (W + GAP), PAD]); const width = vertical ? W + 2 * PAD : PAD * 2 + steps.length * W + (steps.length - 1) * GAP; const height = vertical ? PAD * 2 + steps.length * H + (steps.length - 1) * GAP : H + 2 * PAD; @@ -286,22 +292,28 @@ const [x, y] = pos(i), n = i + 1, roles = rolesOf(st), missing = roles.filter(r => !s.known.has(r)); const lines = wrap(st.title, per), on = sel === n; const label = `Step ${n} of ${steps.length}: ${st.title ?? ''}. ${stateLabel(st)}. Roles ${roles.join(', ')}${missing.length ? `; ${missing.join(', ')} ${MISSING}` : ''}.`; + const rl = roleLines(st), more = roles.length > 3; + const roleText = rl.length + ? rl.map((r, j) => `${esc(more && j === rl.length - 1 ? r : clip(r, rper))}`).join('') + : 'No roles recorded'; return ` - ${esc(st.title)} + ${esc(`${st.title ?? ''}${roles.length ? ` (${roles.join(', ')})` : ''}`)} + + Step ${n} · ${esc(stateLabel(st))} ${lines.map((l, j) => `${esc(l)}${j < lines.length - 1 ? ' ' : ''}`).join('')} - ${esc(clip(roles.join(', '), per + 4))} - ${missing.length ? `${esc(clip(`${missing.join(', ')} ${MISSING}`, per + 4))}` : ''}`; + ${roleText} + ${missing.length ? `${esc(clip(`No current role: ${missing.join(", ")}`, rper + 2))}` : ''}`; }).join(''); return ` ${edges}${nodes}`; } const stepRef = (org, scope, name, steps, n) => `Step ${n}, ${esc(steps[n - 1].title)}`; function detail(org, s, scope, name, steps, sel) { - if (!sel) return `

Step details

Select a step to see its roles, the state it puts a task in and the step it hands on to.

`; + if (!sel) return `

Step details

Select a step to see its roles, the state it puts a task in and the step it hands on to.

`; const st = steps[sel - 1], roles = rolesOf(st); const roleList = roles.map(r => r === 'human' ? 'human (a person)' : s.known.has(r) ? `${esc(r)}` : `${esc(r)} ${MISSING}`).join(', '); - return `

${esc(st.title)}

+ return `

${esc(st.title)}

Position
Step ${sel} of ${steps.length}
Roles
${roleList || 'None recorded'}
Puts the task in
${esc(stateLabel(st))}
diff --git a/packages/webui/tests/workflows-browser.test.mjs b/packages/webui/tests/workflows-browser.test.mjs index 619c808f0..e97db689a 100644 --- a/packages/webui/tests/workflows-browser.test.mjs +++ b/packages/webui/tests/workflows-browser.test.mjs @@ -109,7 +109,8 @@ test('the graph draws one box per step in order with its roles and one arrowed e assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]').tagName.toLowerCase()`), 'svg'); assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.dataset.wfgStep)`), ['1', '2', '3', '4']); assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelector('[data-wfg-title]').textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']); - assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelector('[data-wfg-roles]').textContent)`), ['pm', 'coder', 'reviewer', 'human, merge-gate']); + // One role per line. + assert.deepEqual(await b.evaluate(`${boxes}.map(g=>[...g.querySelectorAll('[data-wfg-roles] tspan')].map(t=>t.textContent))`), [['pm'], ['coder'], ['reviewer'], ['human', 'merge-gate']]); // A role that resolves to no definition or template (and is not human) is marked on its box. assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelectorAll('[data-wfg-missing]').length)`), [0, 0, 0, 1]); // At 1440 the steps run left to right. @@ -152,8 +153,15 @@ test('selecting a step shows what the data holds for it and survives a repaint', assert.equal(await field('next'), 'Nothing: this is the last step'); assert.match(await field('roles'), /human \(a person\)/); assert.match(await field('roles'), /merge-gate names no current role/); - // Keyboard: a step box is a link that takes focus and opens on Enter. - assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="1"]').closest('a,[tabindex]') !== null`), true); + // Keyboard: a step box takes focus and opens on Enter, and keeps focus through the repaint. + await b.evaluate(`document.querySelector('[data-wfg-step="1"]').focus();true`); + assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-step:1'); + await b.key('Enter'); + await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Write the task' && document.activeElement?.dataset.focusKey === 'wfg-step:1'`, 'Enter opens step 1 and keeps focus'); + assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=1'); + await b.evaluate(`document.querySelector('[data-wfg-step="4"]').focus();true`); + await b.key('Enter'); + await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'Enter opens step 4'); // A repaint (the poll) keeps the selection. const before = reads.length; await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`); @@ -168,6 +176,34 @@ test('selecting a step shows what the data holds for it and survives a repaint', await wait(`/No workflow named nope/.test(document.querySelector('#bus-view').textContent)`, 'unknown workflow'); }); +test('a step with more roles than fit shows three lines, the last counting the rest, all inside its box', { timeout: 60000 }, async t => { + // The business-management pack's first step names four long roles. + const roles = ['chief-executive-officer', 'chief-financial-officer', 'chief-information-officer', 'chief-information-security-officer']; + DOCS.acme.workflows.brief = { steps: [{ title: 'Draft the brief', state: 'todo', roles }, { title: 'WWWWWWWWWWWWWWWWWWWWWWWW WWWWWWWWWWWWWWWWWWWWWWWW WWWWWWWWWWWWWWWWWWWW', state: 'done', roles: ['human'] }] }; + t.after(() => { delete DOCS.acme.workflows.brief; }); + for (const viewport of [[1440, 900], [390, 844]]) { + await t.test(String(viewport[0]), async t2 => { + const { b, wait } = await setup(t2, { path: '#/orgs/acme/workflows/brief', viewport }); + await wait(`${boxes}.length === 2`, 'two boxes'); + const one = `document.querySelector('[data-wfg-step="1"]')`; + // A role longer than the box is shortened with an ellipsis (the full name is in the box's title and the step panel). + const lines = await b.evaluate(`[...${one}.querySelectorAll('[data-wfg-roles] tspan')].map(t=>t.textContent)`); + assert.equal(lines.length, 3); + assert.equal(lines[2], '+2 more'); + lines.slice(0, 2).forEach((line, i) => assert.ok(line === roles[i] || (line.endsWith('…') && roles[i].startsWith(line.slice(0, -1))), line)); + if (viewport[0] === 390) assert.deepEqual(lines.slice(0, 2), roles.slice(0, 2), 'the 390 column is wide enough for the whole name'); + assert.equal(await b.evaluate(`${one}.querySelector('[data-wfg-roles] [data-wfg-more]').textContent`), '+2 more'); + // The box names every role for a pointer or a screen reader. + const named = await b.evaluate(`${one}.querySelector('title').textContent + ' ' + (${one}.getAttribute('aria-label') ?? '')`); + for (const r of roles) assert.ok(named.includes(r), r); + // Every text line ends inside its own box. + const out = await b.evaluate(`${boxes}.flatMap(g=>{const r=g.querySelector('rect').getBoundingClientRect();return [...g.querySelectorAll('text tspan, text:not(:has(tspan))')].filter(t=>{const q=t.getBoundingClientRect();const c=g.querySelector('clipPath rect')?.getBoundingClientRect()??r;return Math.min(q.right,c.right)>r.right+0.5||Math.min(q.bottom,c.bottom)>r.bottom+0.5}).map(t=>t.textContent)})`); + assert.deepEqual(out, []); + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true); + }); + } +}); + test('an organization with no workflows says so on its page and its card, and links to apply a pack', { timeout: 60000 }, async t => { const { b, wait } = await setup(t, { path: '#/orgs/bare/workflows' }); await wait(`document.querySelector('[data-wfg-list] [data-wfg-none]')`, 'none note'); -- 2.54.0 From 7c6571768f357283d3ad0eca229532b893873281 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 08:09:41 -0600 Subject: [PATCH 3/3] test(webui): sp9, the organization page's tab order names its right column Workflows and settings and reaches the All workflows link (#1873, lane sp9-workflows) --- packages/webui/tests/unified-workspace-browser.test.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index ea01c40c9..9987bc80a 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -301,11 +301,12 @@ test('keyboard focus follows visual order and survives a data refresh', async t await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`); await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0 && document.querySelectorAll("#workspace-nav .ws-proj").length === 3'); const wide = await tabStops(b, 'document.querySelector("#sections a")'); - const regions = ['nav', 'head', 'Measures and sessions', 'Settings']; + // The right column holds the Workflows card (SP9) above Settings. + const regions = ['nav', 'head', 'Measures and sessions', 'Workflows and settings']; assert.deepEqual([...new Set(wide.map(s => s.region))].filter(r => r !== 'other'), regions); assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440'); const keys = wide.map(s => s.key); - for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`); + for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'wfg-all', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`); // The disabled reload is not a tab stop; the restart button is, and Stop bus follows it. assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus')); assert.equal(keys.indexOf('Stop bus'), keys.indexOf('Restart bus') + 1); -- 2.54.0