diff --git a/packages/webui/src/public/assistant.js b/packages/webui/src/public/assistant.js index 27abd59de..bea6bad49 100644 --- a/packages/webui/src/public/assistant.js +++ b/packages/webui/src/public/assistant.js @@ -299,9 +299,34 @@ } // ---------- panes ---------- + // SP11: a pane's unsent message is kept per thread for the life of the browser tab, so leaving the + // Assistant page (a Full view link included) and coming back puts it in the composer again. + // sessionStorage can be missing or refuse a write; the Map still keeps it while the page lives. + const DRAFTS = 'mosaic-assistant-drafts'; + const kept = (() => { + try { + const v = JSON.parse(sessionStorage.getItem(DRAFTS) || 'null'); + return new Map(v && typeof v === 'object' && !Array.isArray(v) ? Object.entries(v).filter(([k, t]) => typeof t === 'string' && t.trim() && parse(`open=${k}`).keys[0] === k) : []); + } catch { return new Map(); } + })(); + const saveKept = () => { try { if (kept.size) sessionStorage.setItem(DRAFTS, JSON.stringify(Object.fromEntries(kept))); else sessionStorage.removeItem(DRAFTS); } catch { /* the Map still holds it */ } }; + const typedIn = p => p.chat?.draft?.() || p.draft || p.el?.querySelector('form textarea')?.value || ''; + // Signed out, by an expired session or by the Sign out form: the kept drafts go and nothing is kept + // again, not even by the pagehide the form's own submit causes, so the next person to sign in on + // this tab never sees text typed before. + let draftsOff = false; + function keep(p) { if (draftsOff) return; const text = typedIn(p); if (text.trim()) kept.set(p.key, text); else kept.delete(p.key); saveKept(); } + function drop(p) { if (kept.delete(p.key)) saveKept(); } + window.addEventListener('pagehide', () => { if (mounted) for (const p of mounted.panes) keep(p); }); + function forgetDrafts() { + draftsOff = true; kept.clear(); saveKept(); + if (mounted) for (const p of mounted.panes) { p.draft = ''; const t = p.el?.querySelector('form textarea'); if (t) t.value = ''; } + } + document.addEventListener('mosaic-signed-out', forgetDrafts); + document.addEventListener('submit', e => { if (e.target?.id === 'sign-out') forgetDrafts(); }, true); function makePane(key) { const { org, id } = split(key); - const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: '', held: false, seq: 0 }; + const p = { key, org, id, n: ++paneSeq, el: null, chat: null, first: true, title: null, draft: kept.get(key) || '', held: false, seq: 0 }; const box = mounted.root.querySelector('[data-assist-panes]'); box.insertAdjacentHTML('beforeend', paneShell(p)); p.el = box.lastElementChild; @@ -320,7 +345,9 @@ } // SP7: a thread opened from a link takes a free slot or, with all three taken, the place of the // first pane that holds no unsent message. When every pane holds one, nothing is replaced. - const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft(); + // SP11: a pane whose send is in flight, or whose sent message the conversation does not show yet, + // is held too, until its receipt is in. + const holds = p => !!p.draft.trim() || !!p.chat?.hasDraft() || !!p.chat?.busy?.(); function place(key) { const had = mounted.panes.find(p => p.key === key); if (had) return had; @@ -328,11 +355,11 @@ const old = mounted.panes.find(p => !holds(p)); if (!old) return null; const p = makePane(key); - old.chat?.dispose(); old.chat = null; old.el.replaceWith(p.el); + drop(old); old.chat?.dispose(); old.chat = null; old.el.replaceWith(p.el); mounted.panes[mounted.panes.indexOf(old)] = p; return p; } - const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message. Send or clear one, or close a pane, then open the thread again.`; + const REFUSED = `That thread was not opened: all ${MAX} panes hold an unsent message or a message waiting to show. Send or clear one, wait for a sent message to show, or close a pane, then open the thread again.`; function add(key) { if (!mounted) return false; const p = place(key); @@ -360,10 +387,13 @@ else location.hash = `#/assistant?add=${key}`; }); + // SP11: closing a pane that holds unsent text asks first; a close drops the kept text. function closePane(p) { if (!mounted || !p) return; const at = mounted.panes.indexOf(p); if (at < 0) return; + if (typedIn(p).trim() && !window.confirm(`Close ${p.title || 'this thread'}? Its unsent message is lost.`)) return; + drop(p); mounted.notice = ''; p.chat?.dispose(); p.chat = null; p.el.remove(); mounted.panes.splice(at, 1); @@ -484,7 +514,7 @@ function unmount() { if (!mounted) return; clearTimeout(mounted.timer); - for (const p of mounted.panes) p.chat?.dispose(); + for (const p of mounted.panes) { keep(p); p.chat?.dispose(); } window.removeEventListener('resize', fit); mounted = null; mountGen++; } @@ -495,7 +525,7 @@ if (typeof query === 'string') { const want = parse(query); if (want.named && want.keys.join(',') !== mounted.panes.map(p => p.key).join(',')) { - for (const p of [...mounted.panes]) if (!want.keys.includes(p.key)) { p.chat?.dispose(); p.el.remove(); } + for (const p of [...mounted.panes]) if (!want.keys.includes(p.key)) { keep(p); p.chat?.dispose(); p.el.remove(); } const kept = new Map(mounted.panes.map(p => [p.key, p])); mounted.panes = want.keys.map(k => kept.get(k) || makePane(k)); const box = mounted.root.querySelector('[data-assist-panes]'); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index c9cc4ad79..dd281fb42 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -208,7 +208,7 @@ out.groupAs = m?.from_role === 'human' ? 'request' : m?.decision ? 'decision' : 'other'; } else if (r.table === 'decisions') { out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run }; - out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`; + out.text = `Raised a ${String(classWord(r.class)).toLowerCase()} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`; } else if (r.table === 'decision_events') { // The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the // signed-in Console (via console); every other row is the broker's. @@ -243,7 +243,9 @@ const badge = (text, kind = 'muted') => `${txt(text)}`; // Class chip: the decision class, the action in mono. BLOCKING is its own chip (CSS capitalises it; the text stays lowercase). const CLASSES = ['routine', 'within-role', 'cross-role', 'gated']; - const chip = (cls, action) => `${txt(cls)} · ${txt(action)}`; + // SP11: the class in the Inbox's words, from workspace.js's map. + const classWord = cls => window.MosaicWorkspace?.classWord?.(cls) ?? cls; + const chip = (cls, action) => `${txt(classWord(cls))} · ${txt(action)}`; const blockChip = text => `${txt(text)}`; // An empty state says what would appear and where it comes from. const empty = (what, from) => `
${what}${from ? `

${from}

` : ''}
`; @@ -354,7 +356,7 @@ function repliesHtml() { if (replies === null) return '

The replies to it could not be read. Read again to retry; mosaic inbox in a terminal lists them.

'; if (!replies.length) return '

No reply yet. One shows here when the role answers, as the page refreshes.

'; - return `

Replies ${replies.length}

    ${replies.map(m => `
  1. ${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · trail

    ${txt(m.body)}

  2. `).join('')}
${replies.length >= REPLIES ? `

The first ${REPLIES} replies show here.

` : ''}`; + return `

Replies ${replies.length}

    ${replies.map(m => `
  1. ${txt(m.from_role)} ${when(m.at)} · ${txt(classWord(m.class))} · trail

    ${txt(m.body)}

  2. `).join('')}
${replies.length >= REPLIES ? `

The first ${REPLIES} replies show here.

` : ''}`; } const draft = { to: '', body: '' }; function composerHtml() { @@ -707,7 +709,7 @@ if (after !== null && !SAFE.test(after)) return notFound('The message cursor is not a message id.'); const page = await get('messages', after === null ? undefined : after); const body = page.rows.length ? `` + `
  • ${txt(m.from_role)} · ${txt(classWord(m.class))} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · trail

    ${messageText(m)}

  • `).join('')}` : empty('No messages to you.', 'Messages appear here as the bus is read.'); return `${head('Messages', `${readNote(get)} Every message to you, newest first. Reading here marks nothing read.`)}

    ${page.total} messages · ${page.unread} unread

    ${body} @@ -1689,7 +1691,9 @@ if (parts.includes(null)) return notFound(); const [a, b, c] = parts, n = parts.length; // #1873 SP1: `#/dashboard` is the system dashboard, every organization on one page. - if ((n === 0 || (a === 'dashboard' && n === 1)) && window.MosaicOrg) return window.MosaicWorkspace.home(); + // SP11: with no organization it is still the Dashboard (it says so and offers New organization); + // #/ is then the Console's own board. + if ((n === 0 && window.MosaicOrg) || (a === 'dashboard' && n === 1 && window.MosaicWorkspace)) return window.MosaicWorkspace.home(); if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q); if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard(); // SP3: #/inbox is every organization's items; #/orgs//inbox is that list for one, with its hire form. @@ -1737,7 +1741,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' || /^\/workflows(\/[^/]+)?$/.test(path)); + const wsPage = path => (path === '/dashboard' && !!window.MosaicWorkspace) || !!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/chat.js b/packages/webui/src/public/chat.js index 6f55818bd..5faf149cf 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -90,6 +90,16 @@ signedOut = true; forget(); document.dispatchEvent(new Event('mosaic-signed-out')); } + // SP11: whether the thread's agent runs no process, from the bus instances read the Agents page uses. + // true: paused or stopped; false: running; null: the read failed or does not list the agent. + async function agentIdle(instance, signal) { + try { + const res = await fetch(pane ? `/api/orgs/${encodeURIComponent(env.org)}/bus/instances` : '/api/bus/instances', { cache: 'no-store', redirect: 'manual', signal }); + if (!res.ok || res.type === 'opaqueredirect') return null; + const item = (await res.json())?.rows?.[0]?.instances?.[instance]; + return item && typeof item === 'object' && Array.isArray(item.running) ? item.running.length === 0 : null; + } catch (err) { if (err.name === 'AbortError') throw err; return null; } + } async function request(path, value, signal) { if (out()) throw new ChatError('signed-out'); let res; @@ -317,8 +327,10 @@ const messages = s.rows.map((m, i) => `
  • ${txt(m.from_role === 'human' ? 'You' : m.from_role)} ${stamp(m.at)}
    ${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}
  • ${redesigned ? (mountsAfter.get(i) || []).map(origin => `
  • ${mount(origin)}
  • `).join('') : ''}`).join(''); const frame = pane ? '' : redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name); // A pane leaves out the details: sessions and sharing are unavailable on an organization Console. - const resume = threadWrites() ? `` : ''; - const details = pane ? `

    Reading never wakes the assistant. An accepted message is not an answer.

    ` : redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}`; + // SP11: Resume shows unless the instances read says the agent is running. + const resumable = threadWrites() && current.agentIdle !== false; + const resume = resumable ? `` : ''; + const details = pane ? `

    Reading never wakes the assistant. An accepted message is not an answer.

    ` : redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${resumable ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${resumable ? `` : ''}`; return `
    ${pane ? '' : `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    `}${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '

    ' : ''}
      ${messages || (current.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${current.readFailed ? '' : s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `

    To: ${redesigned ? `${txt(t.instance)}` : `${txt(t.instance)} · Private conversation`}

    ${redesigned ? '

    Enter adds a new line.

    ' : ''}
    ` : ''}

    ${txt(receipt)}

    ${pane ? resume : ''}${redesigned || pane ? '' : '

    Files

    Files unavailable.
    '}
    `; } // C2-10c: rooms reuse the same state, renderer and generation fences as threads. @@ -789,7 +801,7 @@ paint(); return; } const old = current; - current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id }; + current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id, agentIdle: old && old.id === id ? old.agentIdle : null }; current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived); if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false; if (current.projectArchived) current.archiveUnknown = false; @@ -873,6 +885,10 @@ if (g !== generation || out()) return; current.shares = shares.rows; } + if (kind === 'thread' && current.thread && threadWrites()) { + current.agentIdle = await agentIdle(current.thread.instance, signal); + if (g !== generation || out()) return; + } let s = null, page = null, after = null; if (kind === 'thread' && catalog === null) { current.legacyDefault = true; current.sessions = []; current.session = null; @@ -1274,12 +1290,16 @@ } on('mosaic-signed-out', () => { signedOut = true; forget(); }); const hasDraft = () => [...states.values()].some(s => Boolean(s.draft?.trim())) || [...forms.values()].some(f => Boolean(f.name?.trim() || f.repo || f.instanceChosen)); + // SP11: the unsent text of a pane's thread, and whether a write is in flight or a sent message + // still waits for the conversation to show it. + const draft = () => [...states.values()].find(s => s.draft?.trim())?.draft || ''; + const busy = () => [...states.values()].some(s => s.pending || !!s.receiptId); // Ends a pane instance: its listeners, reads and observers stop and its container is left as is. function dispose() { life.abort(); generation++; controller?.abort(); transcriptObserver?.disconnect(); transcriptResize?.disconnect(); current = null; lastPaint = null; } - return { render, leave, forget, refresh, projectPath, hasDraft, dispose }; + return { render, leave, forget, refresh, projectPath, hasDraft, draft, busy, dispose }; } const main = chatInstance(); const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; @@ -1288,7 +1308,7 @@ function pane(org) { if (typeof org !== 'string' || !ORG.test(org)) throw new TypeError('pane needs an organization id'); const chat = chatInstance({ org, prefix: `chat-p${++panes}-` }); - return Object.freeze({ render: chat.render, refresh: chat.refresh, dispose: chat.dispose, hasDraft: chat.hasDraft }); + return Object.freeze({ render: chat.render, refresh: chat.refresh, dispose: chat.dispose, hasDraft: chat.hasDraft, draft: chat.draft, busy: chat.busy }); } window.MosaicChat = Object.freeze({ render: main.render, leave: main.leave, forget: main.forget, projectPath: main.projectPath, hasDraft: main.hasDraft, pane }); })(); diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index 7a9978fae..df2ed3ed9 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -118,7 +118,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( return read; }; let previous = location.hash, skipGuard = false; - const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim()); + // SP11: the Assistant keeps its panes' unsent text per thread, so it is not asked about here. + const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim()); // A switch scopes later reads to the new organization; listeners drop what they held for the old one. function rescope(seg) { const from = current; diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index 9bdc95273..00ca60c5d 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -41,7 +41,12 @@ // The layout is remembered per viewer and per width: a phone opens on List until its viewer picks // Kanban, a wide screen on Kanban until its viewer picks List. const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })(); - const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' }; + // done: 'week' shows the tasks the Dashboard's Done this week tile counts. It stays in the browser: + // the rows already carry done and their last change, the same test the tile's count uses. + const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '', done: '' }; + const WEEK_MS = 7 * 24 * 3600 * 1000; + const doneThisWeek = t => t.done && t.changedAt && Date.now() - Date.parse(t.changedAt) <= WEEK_MS; + const DONE_NOTE = 'Done this week counts done tasks by their last change; the bus records no time a task was done.'; // The board style in force: the filtered project's when it has one, else the one organization's the // view is narrowed to (by the org chip or by a project of that org), else none. function activeBoard() { @@ -167,6 +172,7 @@ && (!filter.assignee || (filter.assignee === '-' ? !t.assignees.length : t.assignees.includes(filter.assignee))) && (!filter.state || stateOf(t) === filter.state) && (!filter.label || t.labels.map(String).includes(filter.label)) + && (!filter.done || doneThisWeek(t)) && (!q || !needle || `${t.title ?? ''} ${shortId(t.ref)} ${projectName(t) ?? ''}`.toLowerCase().includes(needle))); } const select = (name, label, options, focus) => ``; @@ -189,6 +195,7 @@ const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : ''; return `
    ${scope ? '' : `
    ${chips}
    `} + ${filter.done ? '' : ''} ${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
    `; @@ -244,8 +251,9 @@ // total; with one of those on, the line says how many match rather than inventing an "of N". // The scope's org= is where the board reads, not a filter. const onServer = new URLSearchParams(readQuery()).size > (scope ? 2 : 1), n = rows.length; - const line = onServer ? `

    Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.

    ` - : n !== all ? `

    Showing ${n} of ${all} tasks.

    ` : ''; + const note = filter.done ? ` ${DONE_NOTE}` : ''; + const line = onServer ? `

    Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.${note}

    ` + : n !== all ? `

    Showing ${n} of ${all} tasks.${note}

    ` : note ? `

    ${note.trim()}

    ` : ''; // An empty result is only exhaustive when every organization's tasks were read. const unread = snap.orgs.filter(o => !tasksRead(o)); const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`); @@ -349,6 +357,7 @@ const org = String(q.get('org') || ''), project = String(q.get('project') || ''); filter.org = scope || (ORG_ID.test(org) ? org : ''); filter.project = PROJECT_SELECTOR.test(project) || project === '-' ? project : ''; + filter.done = q.get('done') === 'week' ? 'week' : ''; if (filter.org && filter.project && filter.project !== '-' && !filter.project.startsWith(`${filter.org}:`)) filter.project = ''; if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]); // Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read. @@ -592,6 +601,7 @@ ws()?.setQuery?.({ org: org || null, ...(clear ? { project: null } : {}) }, true); return; } + if (e.target.closest('[data-tb-done]')) { filter.done = ''; paint(); ws()?.setQuery?.({ done: null }, true); return; } const open = e.target.closest('[data-tb-open]'); if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey, t); return; } const add = e.target.closest('[data-tb-new]'); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 69c5b4212..1656000c7 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -68,6 +68,12 @@ // An agent's activity is the state the bus derives for the run holding its role. A live process // alone says only running, never working or idle. const ACTIVITY = { working: ['s-working', '●', 'working'], waiting: ['s-waiting', '◐', 'waiting on you'], idle: ['s-idle', '–', 'idle'] }; + // SP11 (#1873): the agent counts share two words. Running is an agent row with a live process, + // whatever it is doing; working is a running one whose role session is working. + const LIVE_STATES = new Set(['working', 'waiting', 'idle', 'running']); + const runningOf = rows => rows.filter(a => LIVE_STATES.has(a.state)).length; + const workingOf = rows => rows.filter(a => a.state === 'working').length; + const agentNote = (run, waiting) => `${run} running${waiting ? `, ${waiting} waiting on you` : ''}`; const activity = (name, item, agents) => { const on = running(item) > 0; const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null; @@ -280,9 +286,10 @@ const offTitle = "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail"; const stopTitle = "Stopping the bus is the owner's trailed action, on a Console started with send and the action trail"; const bus = restart ? (org.busHealthy ? `${restart}` : '') : ` `; - const reload = ``; + // SP11 (#1873): the line beside the disabled reload says how a change to the organization file loads. + const reload = ``; const [bcls, bglyph] = state(org); - const meta = `

    Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : org.busStopped ? `bus stopped, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}Restart is a trailed human action; reload arrives with the cross-org update.

    `; + const meta = `

    Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : org.busStopped ? `bus stopped, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}The bus reads the organization file when it starts. To load a change to the file, restart the bus.

    `; const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid; const docChips = !docRead ? 'Docs unavailable' : docRead.failed ? 'Docs unavailable' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `${icon('doc')}${esc(docName(d.path))}${esc(projectName(d.project))}`).join('') : 'No docs yet'; @@ -297,27 +304,35 @@ const agentsWhy = whyNot(['agents'], false) || (orgAgents ? null : 'the agent read did not answer'); const kpi = orgsOf(ws), oq = `org=${enc(id)}`; const orgTile = (key, title, href, why, fig) => kpiTile(kpi, { key, name: title, href, ...(why ? { none: `Not read: ${esc(why)}` } : fig) }); - const working = orgAgents?.filter(a => a.state === 'working').length ?? 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0; + const working = orgAgents ? workingOf(orgAgents) : 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0; const orgTiles = `
    - ${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: waiting ? `${waiting} waiting on you` : '' })} + ${orgTile('agents', 'Agents working', `#/agents?${oq}&state=working`, agentsWhy, { big: working, small: `of ${orgAgents?.length ?? 0}`, delta: orgAgents ? agentNote(runningOf(orgAgents), waiting) : '' })} ${orgTile('tasks', 'Open tasks', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.openTasks) })} ${orgTile('needs', 'Needs you', `#/inbox?${oq}`, whyNot(['inbox']), { big: num(mine?.needsYou), small: num(mine?.needsYou) === 1 ? 'item' : 'items', delta: num(mine?.needsYou) && mine?.oldestNeedsYouAt ? `oldest ${span(mine.oldestNeedsYouAt)}` : 'nothing waiting' })} - ${orgTile('done', 'Done this week', `#/tasks?${oq}`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })} + ${orgTile('done', 'Done this week', `#/tasks?${oq}&done=week`, whyNot(['tasks']), { big: num(mine?.doneThisWeek) })}
    `; const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model; - const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null; + // SP11: the sessions are the running rows the tile counts: the organization's instances and the + // root and project copies its bus booted with. A copy shows its own name. + const copyLive = Object.entries(copiesOf(value(2))).filter(([, c]) => c && typeof c.name === 'string' && !copyBlocked(c) && running(c)).map(([key, c]) => [key, c, c.name]); + const live = instances ? [...Object.entries(instances).filter(([, item]) => running(item)).map(([key, item]) => [key, item, key]), ...copyLive] : null; + // The sessions heading and rows take their activity from the session list the tile counts, so the + // two never disagree. When the tile shows no figure they use the bus agents read instead; when + // that failed too, the heading says working was not read rather than a figure. + const tileAgents = orgAgents && !agentsWhy ? arr(ws.sessions).filter(x => x?.kind === 'role' && x.org === id && typeof x.role === 'string').map(x => ({ role: x.role, holder_run: x.run, state: x.state })) : agents; + const liveWorking = live ? live.filter(([key, item]) => activity(key, item, tileAgents)[2] === 'working').length : 0; // A session's project and task is the thread its live run holds a claim on (thread.claims), not // any older conversation of the same agent. A failed read says unavailable, never "none". - const sessionRows = (live || []).map(([agent, item]) => { + const sessionRows = (live || []).map(([agent, item, shown]) => { const held = claims ? claims.filter(c => item.running.includes(c?.holder_run)) : null; const first = held?.[0], thread = first && threads ? threads.find(t => t?.id === first.thread) : null; - const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, agents); + const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, tileAgents); const more = held && held.length > 1 ? ` · ${held.length - 1} more` : ''; const where = held === null ? 'Conversation unavailable' : !first ? 'No conversation held right now' : thread ? `${esc(projectName(thread.project))}${esc(thread.name || thread.id)}${more}` : threads ? `Conversation${esc(first.thread)}${more}` : 'Conversation unavailable'; - return `${esc(agent)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; + return `${esc(shown)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; }).join(''); const sessions = live === null ? (stopped ? '

    Sessions not read: the bus is stopped.

    ' : '

    Sessions unavailable. The bus did not answer the instance read.

    ') : live.length ? `
    ${sessionRows}
    AgentProject and taskModelStatus
    ` : '

    No active agent sessions

    '; const open = queue ? queue.filter(r => r?.state !== 'done').length : null; @@ -338,7 +353,7 @@ ${docrow}
    - ${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', `This organization's agents`)} + ${secH('Active agent sessions', live ? (live.length ? `${live.length} running${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `This organization's agents`)} ${sessions} ${secH('Queue', queueNote, 'Open queue')} ${queueWhy} @@ -624,7 +639,9 @@ const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' }; // The one map of decision classes to words: the class chips and the Inbox Class filter both read it. const TYPE_WORD = { gated: 'Gated', 'within-role': 'Within role', 'cross-role': 'Cross-role', routine: 'Routine' }; - const classChip = c => (c ? `${esc(TYPE_WORD[c] || c)}` : ''); + // SP11: the Inbox's word for a decision class; a class the map does not know shows as it is. + const classWord = c => (Object.hasOwn(TYPE_WORD, c) ? TYPE_WORD[c] : c); + const classChip = c => (c ? `${esc(classWord(c))}` : ''); const itemKey = d => `${d.org}:${d.id}`; const roleOf = d => d.session?.role || d.raisedBy || ''; const fromWords = (o, d, withOrg = true) => [withOrg ? o.name(d.org) : '', d.project?.name || 'No project', roleOf(d)].filter(Boolean).map(esc).join(' · '); @@ -669,25 +686,27 @@ const needsN = fig('needs', ps.needs); const archived = per.filter(x => readers(o, SRC.projects).has(x.org)).reduce((a, x) => a + num(x.projects?.archived), 0); const tiles = !o.rows.length ? '

    No organizations yet, so there is nothing to count.

    ' : `
    - ${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: waitingOn ? `${waitingOn} waiting on you` : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })} + ${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: agentOk.length ? agentNote(runningOf(agentAll), waitingOn) : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })} ${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })} ${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })} ${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })} - ${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })} + ${kpiTile(o, { key: 'done', name: 'Done this week', href: '#/tasks?done=week', big: fig('done', ps.done), parts: ps.done, miss: notIn(SRC.done), ...noFig })} ${kpiTile(o, { key: 'projects', name: 'Projects', href: selHref('/projects'), big: fig('projects', ps.projects), small: 'active', parts: ps.projects, delta: `${archived} archived`, miss: notIn(SRC.projects), ...noFig })}
    `; // SP1 (sample r2-01): one row per organization. A figure shows only when its read gave it; an // organization that did not answer shows its state and reason instead of figures. - const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs), sessionsRead = readers(o, SRC.sessions); + const projectsRead = readers(o, SRC.projects), inboxRead = readers(o, SRC.needs); const none = '–'; const orgTable = o.rows.map(r => { const id = r.org, fromList = listRow(id), x = per.find(p => p.org === id); const [cls, glyph, word] = rowState(r, fromList); const lv = levels?.orgs?.find(v => v?.id === id); - const count = lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null; - const working = x && sessionsRead.has(id) ? num(x.sessions?.working) : null; - const agentsCell = count === null ? none : `${plural(count, 'agent')}${working === null ? '' : ` (${working} working)`}`; + // SP11: an organization whose agents were read counts the tile's rows, running and working; one + // that was not (a stopped bus) counts the agents in its file and says nothing of what runs. + const mineRows = agentOk.some(a => a.id === id) ? agentAll.filter(a => a.org === id) : null; + const count = mineRows ? mineRows.length : lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null; + const agentsCell = count === null ? none : `${plural(count, 'agent')}${mineRows ? ` (${runningOf(mineRows)} running, ${workingOf(mineRows)} working)` : ''}`; const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none; const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none; // #1873 SP2: a reason that only restates the state word ("the bus is not running" beside "bus stopped") is said once. @@ -695,7 +714,7 @@ const why = r.state === 'ok' || !(reason || r.boot?.at) ? '' : `${esc(reason)}${r.boot?.at ? `${reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}`; return `${av(o.meta(id))}${esc(o.name(id))}${dot(cls, glyph, word, true)}${why}${agentsCell}${projectsCell}${needsCell}`; }).join(''); - const orgList = o.rows.length ? `
    ${orgTable}
    OrganizationAgentsProjectsNeeds you
    ` : '

    No organizations yet

    '; + const orgList = o.rows.length ? `
    ${orgTable}
    OrganizationAgentsProjectsNeeds you
    ` : '

    No organizations yet. New organization

    '; const recent = projectRows.filter(p => p.lastActivity).slice(0, 6); const STATE_WORD = { working: 'has a session working', waiting: 'is waiting on you', idle: 'has an idle session', none: 'no live session' }; @@ -950,7 +969,7 @@ // `opts.scope` names one organization (#/orgs//agents): the same table, that organization only. // `opts.fireIn(org)` says from that organization's own config whether this Console decides there; // Fire is drawn only on the system page and only there, and posts to the row's organization (bus.js). - const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]]; + const AGENT_STATE = [['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['running', 'Running, state unknown'], ['not-running', 'Not running'], ['cant-launch', "Can't launch"]]; const AGENT_STATE_KEY = { working: 'working', 'waiting on you': 'waiting', idle: 'idle', running: 'running', 'not running': 'not-running', "can't launch": 'cant-launch' }; // Each named organization's instances read, and whether this Console decides there when `fireIn` is given. const readInstances = (ids, fireIn) => Promise.all(ids.map(id => Promise.all([ @@ -1044,7 +1063,8 @@ const notRead = new Set(reads.filter(r => !r.inst).map(r => r.id)); const chipOrgs = { ...every, rows: every.rows.map(r => (notRead.has(r.org) ? { ...r, errors: { ...(r.errors || {}), instances: 'not read' } } : r)) }; const orgsWith = new Set(rows.map(a => a.org)).size; - const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}.` : ''; + // SP11: running and working in the Dashboard tiles' words, over the rows shown. + const tally = rows.length ? ` ${plural(rows.length, 'agent')}${scope ? '' : ` in ${plural(orgsWith, 'organization')}`}: ${runningOf(rows)} running, ${workingOf(rows)} working.` : ''; const sub = scope ? `Agents in ${esc(o.name(scope))} only.${tally} Every organization` : `Every agent on the system, by organization.${tally} Open one to see it in its organization.`; @@ -1121,6 +1141,6 @@ // U4b: the org mark (colour and initials) for the task board (tasks-board.js). const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials }); - window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, get pollMs() { return pollMs; } }); + window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, agentsPage, inboxCount, setGrouping, orgsOf, orgChip, projChip, unreadNote, setQuery, classWord, get pollMs() { return pollMs; } }); refresh(); })(); diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index e265c43d6..1f854b3cd 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -42,7 +42,7 @@ function orgs(state) { reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }), queue: async () => ({ rows: [] }) }, bus: { inbox: async () => [], agents: async () => [], tasks: async () => [], 'thread.claims': async () => [], messages: async () => ({ messages: [], unread: 0 }), - instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {} } } }), + instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {}, ...(state.run?.[id] ? { running: state.run[id] } : {}) } } }), }, chat: { projects: async () => PROJECTS[id], @@ -51,7 +51,7 @@ function orgs(state) { shares: async () => [], messages: async ({ thread }) => [ ...(MESSAGES[thread] ? [{ id: thread.replace(/^2/, '3'), from_role: 'assistant', body: MESSAGES[thread], at: '2026-11-15T10:00:00.000Z' }] : []), - ...state.sent.filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })), + ...(state.hideSent ? [] : state.sent).filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })), ...(state.replies || []).filter(r => r.org === id && r.thread === thread).map((r, n) => ({ id: `50000000-0000-4000-8000-${String(n + 1).padStart(12, '0')}`, from_role: 'assistant', body: r.body, at: '2026-11-15T10:02:00.000Z' })), ], send: async ({ thread, body }) => { @@ -304,6 +304,26 @@ test('SP7: a thread link on a project page opens the thread as a pane on the Ass assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`); }); +// SP11 (#1873): Resume assistant shows only when the organization's instances read says the thread's +// agent runs no process (paused or stopped). An agent the read does not say about keeps the control. +test('Resume assistant shows only in a pane whose agent the instances read says is not running', async t => { + const state = { down: new Set(), sent: [], created: [], run: { alpha: ['run-1'], beta: [] } }; + const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1},home:${T.h1}`, state }); + const resume = sel => `!!${PANE(sel)}.querySelector('button.pane-resume')`; + await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)} && ${ready(`home:${T.h1}`)}`); + await wait(resume(`beta:${T.b1}`)); + assert.equal(await b.evaluate(resume(`alpha:${T.a1}`)), false, 'a running assistant offers no resume'); + assert.equal(await b.evaluate(resume(`home:${T.h1}`)), true, 'a read that does not say keeps the control'); + // Alpha's assistant stops: the next read shows the control. + state.run.alpha = []; + await b.evaluate('window.MosaicAssistant.refresh()'); + await wait(resume(`alpha:${T.a1}`)); + // Beta's starts again: its control goes. + state.run.beta = ['run-2']; + await b.evaluate('window.MosaicAssistant.refresh()'); + await wait(`!${resume(`beta:${T.b1}`)}`); +}); + test('SP7: with three open, a fourth thread replaces the first pane with no unsent message; when all three hold one, nothing is replaced and the page says why', async t => { const state = { down: new Set(), sent: [], created: [] }; const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state }); @@ -331,8 +351,11 @@ test('SP7: with three open, a fourth thread replaces the first pane with no unse assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]); assert.deepEqual(await b.evaluate(`[${field(`alpha:${T.a1}`)}.value, ${field(`home:${T.h1}`)}.value, ${field(`alpha:${T.a2}`)}.value]`), ['alpha draft', 'home draft', 'kickoff draft']); assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`); - // Closing a pane clears the reason; the thread then opens. + // Closing a pane clears the reason; the thread then opens. SP11: the pane holds a message, so the + // close asks first. + await b.evaluate(`window.__asked = []; window.confirm = m => (window.__asked.push(m), true)`); await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('button[data-assist-close]').click()`); + assert.equal(await b.evaluate('window.__asked.length'), 1); await wait(`!/not opened/.test(document.querySelector('[data-assist-note]').textContent)`); assert.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), false); await follow(b, `#/orgs/beta/projects/threads/${T.b1}`); @@ -351,6 +374,115 @@ test('SP7: each pane names its own organization\'s Messages page, and the bar no assert.doesNotMatch(await b.evaluate(`document.querySelector('.assist-bar').innerText`), /Messages/); }); +// SP11 (#1873): a pane's unsent message is kept per thread while the tab is open. Leaving the +// Assistant page, by the sidebar or by a pane's Full view into another organization, keeps it, and the +// pane shows it again when it opens. Closing a pane that holds one asks first. +test('SP11: an unsent pane message survives leaving the Assistant page, through Full view too, and closing a pane with one asks first', async t => { + const state = { down: new Set(), sent: [], created: [] }; + const a1 = `alpha:${T.a1}`, b1 = `beta:${T.b1}`, both = `#/assistant?open=${a1},${b1}`; + const { b, wait } = await open(t, { hash: both, state }); + await wait(`${ready(a1)} && ${ready(b1)} && !${field(a1)}.disabled && !${field(b1)}.disabled`); + await type(b, a1, 'alpha keep'); + await type(b, b1, 'beta keep'); + // Away to Tasks and back. + await b.evaluate(`location.hash = '#/tasks'`); + await wait(`!document.querySelector('[data-assist-pane]') && !!document.querySelector('[data-tasks-board]')`); + await b.evaluate(`location.hash = ${JSON.stringify(both)}`); + await wait(`${field(a1)}?.value === 'alpha keep' && ${field(b1)}?.value === 'beta keep'`); + // Away through Beta's Full view: no discard question, and the message is still there after. + await b.evaluate(`${PANE(b1)}.querySelector('a[data-assist-pane-full]').click()`); + await wait(`location.hash.startsWith('#/orgs/beta/') && !document.querySelector('[data-assist-pane]')`); + assert.equal(await b.evaluate(`!!document.querySelector('dialog[open]')`), false, 'leaving asks nothing: the message is kept'); + await b.evaluate(`location.hash = ${JSON.stringify(both)}`); + await wait(`${field(a1)}?.value === 'alpha keep' && ${field(b1)}?.value === 'beta keep'`); + // Closing a pane with an unsent message asks; saying no keeps the pane. + await b.evaluate(`window.__asked = []; window.confirm = m => (window.__asked.push(m), false)`); + await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`); + assert.deepEqual(await b.evaluate('window.__asked'), ['Close Launch email? Its unsent message is lost.']); + assert.deepEqual(await b.evaluate(panes), [a1, b1]); + // Saying yes closes it and drops the message: the thread opens again empty. + await b.evaluate(`window.confirm = m => (window.__asked.push(m), true)`); + await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`); + await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`); + await follow(b, `#/orgs/alpha/projects/threads/${T.a1}`); + await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); + assert.equal(await b.evaluate(`${field(a1)}.value`), ''); + // A pane with nothing typed closes with no question. + await b.evaluate(`${PANE(a1)}.querySelector('button[data-assist-close]').click()`); + await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`); + assert.equal(await b.evaluate('window.__asked.length'), 2); + assert.deepEqual(state.sent, []); +}); + +// SP11 review: signing out drops every kept pane message, so the next person to sign in on this tab +// never sees text typed before, after a reload too. +test('SP11: signing out drops kept pane messages, and a reload after it opens the pane empty', async t => { + const state = { down: new Set(), sent: [], created: [] }; + const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`; + const { b, base, wait } = await open(t, { hash, state }); + await wait(`${ready(a1)} && !${field(a1)}.disabled`); + await type(b, a1, 'private note'); + await b.evaluate(`location.hash = '#/tasks'`); + await wait(`!document.querySelector('[data-assist-pane]')`); + assert.match(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts') || ''`), /private note/); + await b.evaluate(`location.hash = ${JSON.stringify(hash)}`); + await wait(`${field(a1)}?.value === 'private note'`); + await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')), true`); + await wait(`document.body.classList.contains('signed-out')`); + assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null); + // A query string makes it a real load; the same address with only a hash would not reload. + await b.navigate(base + '?again=1' + hash); + await wait(`!document.body.classList.contains('signed-out') && ${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); + assert.equal(await b.evaluate(`${field(a1)}.value`), ''); + assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null); + assert.deepEqual(state.sent, []); +}); + +// SP11 review: the Sign out form is a plain POST that unloads the page; its submit drops the kept +// messages and the pagehide it causes keeps nothing, so a sign-in after it opens the pane empty. +test('SP11: the Sign out form drops kept pane messages, and the page it unloads keeps none', async t => { + const state = { down: new Set(), sent: [], created: [] }; + const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`; + const { b, base, wait } = await open(t, { hash, state }); + await wait(`${ready(a1)} && !${field(a1)}.disabled`); + await type(b, a1, 'private note'); + await b.evaluate(`location.hash = '#/tasks'`); + await wait(`!document.querySelector('[data-assist-pane]')`); + await b.evaluate(`location.hash = ${JSON.stringify(hash)}`); + await wait(`${field(a1)}?.value === 'private note'`); + await b.evaluate(`document.getElementById('sign-out').requestSubmit(), true`); + // The POST unloads the page; poll from here, since the old page's context goes away under it. + let left = false; + for (let i = 0; i < 200 && !left; i++) { try { left = await b.evaluate(`location.pathname === '/auth/logout'`); } catch { /* navigating */ } if (!left) await new Promise(r => setTimeout(r, 30)); } + assert.equal(left, true, 'the Sign out form posted'); + await b.navigate(base + '?again=1' + hash); + await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); + assert.equal(await b.evaluate(`${field(a1)}.value`), ''); + assert.equal(await b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts')`), null); + assert.deepEqual(state.sent, []); +}); + +// SP11 (#1873): a pane whose message was sent but not yet shown in the conversation is not free: a +// fourth thread replaces the first pane that holds nothing, and that pane only once its receipt is in. +test('SP11: a pane whose send still waits for its receipt is not replaced by a fourth thread', async t => { + const state = { down: new Set(), sent: [], created: [], hideSent: true }; + const a1 = `alpha:${T.a1}`, b1 = `beta:${T.b1}`, a2 = `alpha:${T.a2}`, h1 = `home:${T.h1}`; + const { b, wait } = await open(t, { hash: `#/assistant?open=${a1},${b1},${a2}`, state }); + await wait(`${ready(a1)} && ${ready(b1)} && ${ready(a2)} && !${field(a1)}.disabled`); + await b.evaluate(`(() => { const el = ${field(a1)}; el.value = 'in flight'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); + await wait(`/Accepted message/.test(${PANE(a1)}.innerText) && ${field(a1)}.value === ''`); + await follow(b, `#/orgs/home/projects/threads/${T.h1}`); + await wait(ready(h1)); + assert.deepEqual(await b.evaluate(panes), [a1, h1, a2], 'the waiting pane stays; the first free one gives way'); + // The message shows: the receipt is in and the pane is free again. + state.hideSent = false; + await b.evaluate('window.MosaicAssistant.refresh()'); + await wait(`/Message shown in this conversation/.test(${PANE(a1)}.innerText)`); + await follow(b, `#/orgs/beta/projects/threads/${T.b1}`); + await wait(ready(b1)); + assert.deepEqual(await b.evaluate(panes), [b1, h1, a2]); +}); + // SP7 review: a pane has no Request details, sessions or sharing, so each pane links its thread's full // view, and a link marked data-full-view is not turned into a pane. test('SP7: each pane links its thread\'s full view, and one plain click reaches it', async t => { diff --git a/packages/webui/tests/kpi-tiles-browser.test.mjs b/packages/webui/tests/kpi-tiles-browser.test.mjs index 16adcf8ad..ef50aad0e 100644 --- a/packages/webui/tests/kpi-tiles-browser.test.mjs +++ b/packages/webui/tests/kpi-tiles-browser.test.mjs @@ -16,7 +16,9 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++) const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); // fail: { org: [read names] } makes those reads refuse; `none` empties the organization list. -function fixture({ fail = {}, none = false } = {}) { +// split: { org: agents } is what that organization's own bus agents read answers, while the +// cross-organization sessions read still answers the fixture's agents. +function fixture({ fail = {}, none = false, split = {} } = {}) { const LIST = none ? [] : [ { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, @@ -63,7 +65,7 @@ function fixture({ fail = {}, none = false } = {}) { busHealthy: true, reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, bus: { - messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [], + messages: async () => ({ messages: [], unread: 0 }), agents: async () => { if (fail[id]?.includes('agents')) throw Object.assign(new Error('off'), { code: 'channel-disabled' }); return split[id] ?? DATA[id].agents; }, 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [], instances: () => (fail[id]?.includes('instances') ? Promise.reject(Object.assign(new Error('off'), { code: 'bus-unavailable' })) : Promise.resolve({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances })), }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, @@ -72,8 +74,8 @@ function fixture({ fail = {}, none = false } = {}) { }; } -async function setup(t, { viewport = [1440, 900], hash = '#/dashboard', fail, none } = {}) { - const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, none }), log: () => {} }); +async function setup(t, { viewport = [1440, 900], hash = '#/dashboard', fail, none, split } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, none, split }), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); @@ -110,7 +112,7 @@ test('The Dashboard tiles add both answering organizations\' numbers, one row, e assert.equal(links.orgs, '#/dashboard'); assert.equal(links.needs, '#/inbox'); assert.equal(links.tasks, '#/tasks'); - assert.equal(links.done, '#/tasks'); + assert.equal(links.done, '#/tasks?done=week'); assert.match(links.projects, /^#\/orgs\/[a-z0-9._-]+\/projects$/); // No sample numbers and no trend line: no tile draws an svg sparkline. assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tiles] svg:not(.i)").length'), 0); @@ -143,6 +145,11 @@ test('Following a tile lands on its page showing what the tile counted', async t await wait('location.hash === "#/inbox" && document.querySelectorAll("[data-ws-item]").length === 3'); await b.evaluate('location.hash = "#/dashboard"'); await wait(homeReady); + // SP11: Done this week lands on the board showing the one task it counted, beta's Logo. + await b.evaluate('document.querySelector("[data-ws-tile=done]").click()'); + await wait('location.hash === "#/tasks?done=week" && JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)) === JSON.stringify(["beta|local:9"])'); + await b.evaluate('location.hash = "#/dashboard"'); + await wait(homeReady); // The Organizations tile's list is this page's own table: following it keeps the page and moves focus there. await b.evaluate('document.querySelector("[data-ws-tile=orgs]").click()'); await wait('document.activeElement?.closest?.("[data-ws-orgs-h]")'); @@ -165,10 +172,22 @@ test('When no answering organization could read its agents, the Agents tile says assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=agents]").getAttribute("href")'), '#/agents?state=working'); }); -test('With no organizations there is nothing to count, so no tile shows a zero', async t => { - // With no organization the workspace pages are not drawn; the Console's own board keeps its dashes. - const { b, wait } = await setup(t, { none: true }); - await wait('document.querySelector("#dashboard-kpis .kpi strong")'); +// SP11 (#1873): with no organizations, #/dashboard is the Dashboard, not Not found. It says there is +// nothing to count and offers New organization, which opens the Organizations form at its ID field. +// #/ stays the Console's own board, with its dashes. +test('With no organizations the Dashboard says so, shows no tile, and offers New organization', async t => { + const { b, wait, text } = await setup(t, { none: true }); + await wait('document.querySelector("[data-ws-home] h1")'); + assert.equal(await text('#bus-view h1'), 'Dashboard'); + assert.doesNotMatch(await text('#bus-view'), /Not found/); + assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0); + assert.match(await text('[data-ws-home] [data-ws-tiles]'), /^No organizations yet, so there is nothing to count\./); + assert.equal(await b.evaluate('document.querySelector("[data-ws-home] [data-ws-orgs] a")?.getAttribute("href") ?? null'), '#/settings/organizations?new=1'); + assert.equal(await text('[data-ws-home] [data-ws-orgs] a'), 'New organization'); + await b.evaluate('document.querySelector("[data-ws-home] [data-ws-orgs] a").click()'); + await wait('document.activeElement?.id === "org-new-id"'); + await b.evaluate('location.hash = "#/"'); + await wait('document.querySelector("#dashboard-kpis .kpi strong") && !document.querySelector("[data-ws-home]")'); assert.equal(await b.evaluate('document.querySelectorAll(".ws-tile, .ws-big").length'), 0); assert.deepEqual(await b.evaluate('[...new Set([...document.querySelectorAll("#dashboard-kpis .kpi strong")].map(e => e.textContent.trim()))]'), ['–']); }); @@ -183,7 +202,7 @@ test('An organization page leads with its own tiles, each linking to the system assert.equal(await b.evaluate(big('[data-org-tiles]', 'tasks')), '2'); assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '2 items'); assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '1'); - assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta' }); + assert.deepEqual(await b.evaluate(hrefs('[data-org-tiles]')), { agents: '#/agents?org=beta&state=working', tasks: '#/tasks?org=beta', needs: '#/inbox?org=beta', done: '#/tasks?org=beta&done=week' }); // The empty KPI and metric placeholders are gone; there is one tile row. assert.doesNotMatch(await text('[data-org-dashboard]'), /No KPIs defined|No metrics recorded yet/); assert.equal(await b.evaluate('document.querySelectorAll("[data-org-dashboard] .ws-tiles, [data-org-dashboard] [data-org-tiles]").length'), 1); @@ -200,6 +219,47 @@ test('An organization page leads with its own tiles, each linking to the system await wait('location.hash === "#/agents?org=acme&state=working" && JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])'); }); +// SP11 (#1873): "working" and "running" each name what they count. Running is an agent with a live +// process, whatever it is doing; working is a running agent whose role session is working. The tile, +// the Dashboard organization rows and the sessions heading count the same agent rows. +const delta = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-delta:not(.ws-miss)`)})?.textContent.trim() ?? null`; +const rowAgents = org => `document.querySelector("[data-ws-orgrow=${org}] td:nth-child(2)")?.innerText.replace(/\\s+/g, " ").trim() ?? null`; +test('Agent counts say running and working, and count the same agents on the tiles, the organization rows and the sessions heading', async t => { + const { b, wait, text } = await setup(t); + await wait(homeReady); + // acme's coder runs and works; beta's pm runs and waits on you; chief and writer run nothing. + assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4'); + assert.equal(await b.evaluate(delta('[data-ws-home]', 'agents')), '2 running, 1 waiting on you'); + assert.equal(await b.evaluate(rowAgents('acme')), '2 agents (1 running, 1 working)'); + assert.equal(await b.evaluate(rowAgents('beta')), '2 agents (1 running, 0 working)'); + await b.evaluate('location.hash = "#/orgs/beta"'); + await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")'); + assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2'); + assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running, 1 waiting on you'); + assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 0 working/); + await b.evaluate('location.hash = "#/orgs/acme"'); + await wait(`location.hash.startsWith("#/orgs/acme") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`); + assert.equal(await b.evaluate(delta('[data-org-tiles]', 'agents')), '1 running'); + assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/); +}); + +test('An organization page counts working from one read: the sessions heading and rows follow the tile when the two bus reads disagree', async t => { + // acme's own agents read says the coder is idle; the sessions read the tile counts says working. + const split = { acme: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'idle', at: at(40) }] }; + const { b, wait, text } = await setup(t, { hash: '#/orgs/acme', split }); + await wait(`document.querySelector("[data-org-dashboard]") && (${big('[data-org-tiles]', 'agents')}) === "1 of 2"`); + assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, 1 working/); + assert.match(await text('[data-org-dashboard] .ws-split tbody'), /coder[\s\S]*working/); +}); + +test('When neither bus read gives agent activity, the sessions heading says working was not read, never a figure', async t => { + const { b, wait, text } = await setup(t, { hash: '#/orgs/acme', fail: { acme: ['agents'] } }); + await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents]")'); + assert.match(await text('[data-org-tiles] [data-ws-tile=agents]'), /Not read/); + assert.match(await text('[data-org-dashboard] .ws-split'), /Active agent sessions\s*1 running, working not read/); + assert.doesNotMatch(await text('[data-org-dashboard] .ws-split'), /\d+ working/); +}); + test('A stopped organization\'s page shows its tiles as not read, still linking to its filtered pages', async t => { const { b, wait, text } = await setup(t, { hash: '#/orgs/gamma' }); await wait('document.querySelector("[data-org-dashboard] [data-org-tiles]")'); diff --git a/packages/webui/tests/system-agents-browser.test.mjs b/packages/webui/tests/system-agents-browser.test.mjs index 0b153b08d..914c31f3c 100644 --- a/packages/webui/tests/system-agents-browser.test.mjs +++ b/packages/webui/tests/system-agents-browser.test.mjs @@ -17,7 +17,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++) const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b'; -function fixture({ empty = false } = {}) { +function fixture({ empty = false, unheld = false } = {}) { const LIST = [ { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, @@ -38,6 +38,8 @@ function fixture({ empty = false } = {}) { }, }; if (empty) for (const d of Object.values(DATA)) { d.instances = {}; d.agents = []; } + // `unheld`: a live designer run that holds no role session, so the bus reports no state for it. + if (unheld) DATA.acme.instances.designer = { definition: 'designer', running: ['run-5'] }; const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : [])); const opts = id => ({ bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') }, @@ -90,8 +92,8 @@ const STUB = `(() => { }; })();`; -async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, stub = false, noDecide = [] } = {}) { - const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} }); +async function setup(t, { viewport = [1440, 900], hash = '#/agents', empty = false, unheld = false, stub = false, noDecide = [] } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty, unheld }), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); @@ -132,6 +134,19 @@ test('#/agents lists both organizations\' agents in one table, each row with its assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] a[data-ws-open]\')?.getAttribute("href")'), '#/orgs/beta/agents?agent=pm'); }); +// SP11 (#1873): the page says how many of its rows run and how many work, in the words the Dashboard +// tiles use. A live agent with no reported state is "Running, state unknown", not a bare "Running" +// that reads as every running agent. +test('#/agents counts its running and working agents, and names a running agent with no reported state', async t => { + const { b, wait, text } = await setup(t, { unheld: true }); + await wait(are(['acme:chief', 'acme:coder', 'acme:designer', 'beta:pm', 'beta:research-2'])); + assert.match(await text('[data-ws-agents] .sub'), /^Every agent on the system, by organization\. 5 agents in 2 organizations: 3 running, 1 working\./); + assert.equal(await b.evaluate('document.querySelector("[data-ws-astate=running]")?.innerText.replace(/\\s+/g, " ").trim()'), 'Running, state unknown 1'); + await b.evaluate('document.querySelector("[data-ws-astate=working]").click()'); + await wait(are(['acme:coder'])); + assert.match(await text('[data-ws-agents] .sub'), /1 agent in 1 organization: 1 running, 1 working\./); +}); + test('the organization and state filters live in the hash and survive a reload', async t => { const { b, base, wait } = await setup(t); await wait(are(ALL)); diff --git a/packages/webui/tests/system-tasks-browser.test.mjs b/packages/webui/tests/system-tasks-browser.test.mjs index 70082c723..9ddc5712b 100644 --- a/packages/webui/tests/system-tasks-browser.test.mjs +++ b/packages/webui/tests/system-tasks-browser.test.mjs @@ -18,7 +18,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++) const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString(); const D = n => String(n).repeat(64); -function fixture({ empty = false, archived = [] } = {}) { +function fixture({ empty = false, archived = [], done = false } = {}) { const LIST = [ { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, @@ -33,9 +33,11 @@ function fixture({ empty = false, archived = [] } = {}) { beta: [{ n: 2, title: 'Price page', state: 'in-progress', group: 'shop', assignees: [] }, { n: 3, title: 'Spring banner', state: 'todo', group: 'ads', assignees: [] }], }; if (empty) { STORE.acme = []; STORE.beta = []; } + // `done`: one task finished two days ago and one ten days ago; only the first is done this week. + if (done) { STORE.acme.push({ n: 4, title: 'Old logo', state: 'done', group: 'site', assignees: [], ago: 60 * 24 * 10 }); STORE.beta.push({ n: 5, title: 'Launch email', state: 'done', group: 'shop', assignees: [], ago: 60 * 24 * 2 }); } for (const [org, list] of Object.entries(PROJECTS)) for (const p of list) p.archived = archived.includes(`${org}:${p.id}`); - const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1', - fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null } })); + const tasks = id => (STORE[id] || []).map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: at(t.ago ?? 10), priority: 0, digest: D(t.n), requirement: 'REQ-WEB-1', + fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: null, ...(t.state === 'done' ? { done: true } : {}) } })); const writes = []; const taskWrite = org => async (verb, args) => { writes.push([org, verb, args]); return { task_ref: args.task_ref, digest: D(9) }; }; const opts = id => ({ @@ -81,8 +83,8 @@ const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)}; }; })();`; -async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [] } = {}) { - const orgs = fixture({ empty, archived }); +async function setup(t, { viewport = [1440, 900], hash = '#/tasks', empty = false, noWrite = [], archived = [], done = false } = {}) { + const orgs = fixture({ empty, archived, done }); const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, send: async () => ({}), log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close()); @@ -118,6 +120,26 @@ test('#/tasks shows both organizations\' tasks on one board, each card with its assert.equal(await b.evaluate('document.querySelector("#bus-view .s1-stale")'), null); }); +// SP11 (#1873): the Dashboard's Done this week tile opens the board on the tasks it counted: done, and +// changed in the last seven days. The bus keeps no time a task was done, so the filter says it reads +// the last change, the time the tile counts by. The filter lives in the address and clears. +test('#/tasks?done=week shows only the tasks Done this week counted, says what it counts, and clears', async t => { + const { b, base, wait, text } = await setup(t, { hash: '#/tasks?done=week', done: true }); + await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`); + assert.match(await text('[data-tb-done]'), /^Done this week\s+×$/); + assert.match(await b.evaluate('document.querySelector("[data-tb-done]").getAttribute("aria-label")'), /^Clear Done this week: done tasks last changed in the past 7 days$/); + assert.match(await text('[data-tasks-board] .tb-count'), /^Showing 1 of 5 tasks\. Done this week counts done tasks by their last change; the bus records no time a task was done\.$/); + // It survives a reload and joins the organization filter. + await b.navigate(`${base}/#/tasks?org=acme&done=week`); + await wait('document.querySelector("[data-tb-done]") && document.querySelector("[data-tasks-board] .ws-panel")?.textContent.includes("No tasks match these filters")'); + await b.evaluate('location.hash = "#/tasks?done=week"'); + await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:5']))}`); + // Clearing it drops it from the address and shows every task again. + await b.evaluate('document.querySelector("[data-tb-done]").click()'); + await wait(`location.hash === "#/tasks" && ${cards} === ${JSON.stringify(JSON.stringify(['acme|task:1', 'acme|task:4', 'beta|task:2', 'beta|task:3', 'beta|task:5']))}`); + assert.equal(await b.evaluate('document.querySelector("[data-tb-done]")'), null); +}); + test('the organization and project filters live in the address and survive a reload', async t => { const { b, base, wait } = await setup(t); await wait(`${cards} === ${ALL}`); diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 9987bc80a..6fd418757 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -211,7 +211,8 @@ test('each header control is present, reload is disabled, and restart and stop a stop: stop ? [stop.textContent, stop.disabled, stop.closest('.bus-restart') === live.closest('.bus-restart')] : null, stopOff: stopOff ? [stopOff.textContent, stopOff.disabled, !!stopOff.title] : null, reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] }; })()`)); - const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.']; + // SP11 (#1873): the line beside the disabled reload says in plain words how a change to the file loads. + const RELOAD = ['Reload organization file', true, 'Reloading without a restart is not available yet', 'The bus reads the organization file when it starts. To load a change to the file, restart the bus.']; // The owner on a Console started with --send gets console-restart.js's restart button. // Stop bus sits beside it, in the same control, and is one press like Restart bus. assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, stop: ['Stop bus', false, true], stopOff: null, reload: RELOAD }); diff --git a/packages/webui/tests/w15-fixture.mjs b/packages/webui/tests/w15-fixture.mjs index 84b6b044e..61fdb957f 100644 --- a/packages/webui/tests/w15-fixture.mjs +++ b/packages/webui/tests/w15-fixture.mjs @@ -45,7 +45,7 @@ export async function w15Fixture(t, { scenario = {}, slice = 'shell' } = {}) { if(/^\\/api\\/chat\\/threads\\/[^/]+\\/sessions$/.test(path)) return data({rows:[{id:ids.session,name:'Default',default:true}]}); if(/^\\/api\\/chat\\/threads\\/[^/]+\\/messages(?:\\?.*)?$/.test(path)) return data({rows:[],after:null,hasMore:false}); if(path==='/api/chat/threads' || /^\\/api\\/chat\\/threads\\?(shared=true|project=)/.test(path)) return data({rows:[{id:ids.thread,project:ids.project,name:'Project conversation',instance:'assistant'}]}); - if(/^\\/api\\/bus\\/(inbox|messages|agents|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0}); + if(/^\\/api\\/bus\\/(inbox|messages|agents|instances|tasks|queue|trail)(\\?|$)/.test(path)) return data({rows:[],unread:0}); __unexpected.push(method+' '+path); return new Response(JSON.stringify({error:'fixture-unexpected-api'}),{status:403,headers:{'content-type':'application/json'}}); }; })();` }); diff --git a/packages/webui/tests/workspace-pages-browser.test.mjs b/packages/webui/tests/workspace-pages-browser.test.mjs index 8d2a4fde1..c782fd0e9 100644 --- a/packages/webui/tests/workspace-pages-browser.test.mjs +++ b/packages/webui/tests/workspace-pages-browser.test.mjs @@ -14,7 +14,8 @@ const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString() // acme and beta answer; gamma's bus is stopped. Decision ids are long so a shortened id would show. const ACME_DECISION = 'dec-acme-7f3c9a21-5b8e-4d1f-9c2a-0e6b7d8f9a10'; const BETA_DECISION = 'dec-beta-1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d'; -function fixture({ pollMs, fail = {}, down = [] } = {}) { +// messages: { org: rows } is what that organization's bus messages read answers. +function fixture({ pollMs, fail = {}, down = [], messages = {} } = {}) { const LIST = [ { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, { id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true }, @@ -61,7 +62,9 @@ function fixture({ pollMs, fail = {}, down = [] } = {}) { return { busHealthy: true, reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, - bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) }, + bus: { messages: async () => ({ messages: messages[id] ?? [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }), + // SP11: a decision's own page reads its trail. + trail: async subject => DATA[id].inbox.filter(d => d.id === subject).map((d, i) => ({ table: 'decisions', seq: i + 1, ...d, business: id, raised_by_run: 'run', route_to: 'human', action: 'price.set', recommendation: null })) }, chat: { projects: async () => DATA[id].projects, threads: async () => [] }, }; }, @@ -69,8 +72,8 @@ function fixture({ pollMs, fail = {}, down = [] } = {}) { }; } -async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down } = {}) { - const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) }); +async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down, messages } = {}) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down, messages }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(...viewport); @@ -224,6 +227,36 @@ test('Inbox lists both organizations, copies a command with --org and the full i await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`); }); +// SP11 (#1873): an organization's own decision page names the class in the words the Inbox uses, +// from the same map, not the raw class. +test('SP11: the per-organization decision page names the class as the Inbox does', async t => { + const { b, base, wait } = await setup(t, { hash: '#/inbox' }); + const item = `[data-ws-item="beta:${BETA_DECISION}"]`; + await wait(`!!document.querySelector(${JSON.stringify(`${item} .ws-chip.within`)})`); + const word = await b.evaluate(`document.querySelector(${JSON.stringify(`${item} .ws-chip.within`)}).textContent`); + assert.equal(word, 'Within role'); + await b.navigate(`${base}/#/orgs/beta/inbox/${BETA_DECISION}`); + await wait('!!document.querySelector("#bus-view .tag.tag-within-role")'); + const chip = await b.evaluate('document.querySelector("#bus-view .tag.tag-within-role").textContent'); + assert.match(chip, new RegExp(`^${word} · `), chip); + assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /within-role/); +}); + +test('SP11: the per-organization Messages page names a known class as the Inbox does and shows an unknown one as it is', async t => { + const at = '2026-11-15T10:00:00.000Z'; + const messages = { beta: [ + { id: 'msg-beta-1a2b3c4d-0001', from_role: 'pm', class: 'within-role', body: 'Price list is ready.', at, read: false }, + { id: 'msg-beta-1a2b3c4d-0002', from_role: 'pm', class: 'NOTE', body: 'Logo is in.', at, read: true }, + ] }; + const { b, base, wait } = await setup(t, { hash: '#/inbox', messages }); + await b.navigate(`${base}/#/orgs/beta/messages`); + await wait('document.querySelectorAll("#message-list li").length === 2'); + const lines = await b.evaluate('JSON.stringify([...document.querySelectorAll("#message-list li p.small")].map(p => p.textContent))'); + const [known, unknown] = JSON.parse(lines); + assert.match(known, /^pm · Within role · /, known); + assert.match(unknown, /^pm · NOTE · /, unknown); +}); + test('A poll repaints in place: focus, caret and scroll stay where they were', async t => { const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] }); await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3');