Compare commits

...
Author SHA1 Message Date
jason.woltje c26879637a fix(webui): sp14 review, every drawer opened while its instances read is open repaints when that read answers: the pending drawer names the read it waits on, and each open repaints its own drawer if it still waits on that read, so a second agent opened before the answer no longer says Reading state until the next poll; the Settings card's Agents and roles row uses the same running-not-read check as the Active agent sessions heading, copies included; test opens chief while coder's read is held and sees chief's state at once (#1873, lane sp14-org-page-truth) 2026-11-15 13:52:25 -06:00
jason.woltje 2fc97a3edb fix(webui): sp14 the organization page tells the truth: the Queue card and its queue read are gone (the Queue page stays at #/queue); an instances answer in which no agent carries runs says running was not read on the Active agent sessions heading and body, the Agents working tile and Settings' Agents and roles row, and on the Dashboard the Agents working tile leaves that organization out and names it while its row says running not read, never 0; the Assistant's kept pane messages are stored with an opaque viewer the /api/config read now names and are read back only under the same viewer, so a second person on the same tab, or a store from before, opens empty; the drawer's instances answer repaints only the drawer from the reads it already has, so no page read is made again (15 requests before, 0 now); a full Assistant set disables its picker at mount (#1873, lane sp14-org-page-truth) 2026-11-15 13:38:09 -06:00
jason.woltje 68df55130e fix(webui): sp13 review, a drawer closed before its instances read answers keeps no answer for the next open: the answer is dropped when no pending drawer is left to repaint, so a later open reads again instead of showing a state from minutes before; test closes the drawer while its read is held, lets the read say nothing runs, reopens and sees the agent working (#1873, lane sp13-org-page-fit) 2026-11-15 12:54:28 -06:00
jason.woltje 6ad1d33dd1 fix(webui): sp13 shots, the organization page's two columns start level: the Workflows card wraps its heading, so the split's first-heading rule did not reach it and that column started 22px low beside the other and 22px apart when stacked; the fit test checks both first headings start level at 1440 (#1873, lane sp13-org-page-fit) 2026-11-15 12:44:04 -06:00
jason.woltje 5fba4726dc fix(webui): sp13 the organization page fits its width: its two columns wrap to one once the content is narrower than both need, with or without the agent drawer, and the Settings card puts a value on its own line when the card is narrow, so no value or Open link is cut; #/orgs/<id>?query is the organization page with the query kept and ?agent= opens the drawer; the drawer says its state is being read while the instances read is open and that it was not read when that read fails, never not running, and its Stop note says the same; an instance whose runs were not read shows state not read on the Agents table; the Queue card says in plain words when this Console does not run from the checkout that holds the queue and drops Open queue there (#1873, lane sp13-org-page-fit) 2026-11-15 12:34:30 -06:00
jason.woltje 69ce460f91 fix(webui): sp12 review, the agent drawer holds at most one history entry: a row opened from the page pushes one marked entry, another row replaces it, the open row changes nothing, and Close goes Back only from that marked entry, so a shared link never leaves the Console; Escape while Stop asks cancels only the question; the Tasks board drops the assignee filter the address set when the address drops it; tests cover a Stop the bus takes (#1873, lane sp12-agent-drawer) 2026-11-15 11:46:19 -06:00
jason.woltje 307f88be3e fix(webui): sp12 shots, at 1200px wide and above the page keeps clear of the agent drawer so nothing sits under it; where this Console could change the organization's agents but Stop is not offered, the drawer says why (a copy, a protected agent, or not running); drawer sections drop the page section margin (#1873, lane sp12-agent-drawer) 2026-11-15 11:35:48 -06:00
jason.woltje bfd3ed6811 feat(webui): sp12 agent drawer, selecting an agent on #/agents or in an organization page's Active agent sessions opens its details at the right of the page with ?agent=<org>:<name> in the address; Back, Escape and Close close it and focus returns to the row; fields come from the workspace, instances and business reads; Message and Stop follow the organization's own config, Stop asks first and says why a refusal happened; the Tasks board takes an assignee from the address (#1873, lane sp12-agent-drawer) 2026-11-15 11:21:32 -06:00
jason.woltje 5b6b39f080 test(webui): sp11 item 3, the w15 fixture answers the bus instances read the conversation page now makes, with no rows, so Resume shows as before and the read is not counted as unexpected (#1873, lane sp11-fixes) 2026-11-15 10:27:28 -06:00
jason.woltje 2d7d7be5ca fix(webui): sp11 review 2, the Sign out form drops kept pane messages as an expired session does, and the page it unloads keeps none; when neither bus read gives agent activity, an organization page's sessions heading says working was not read instead of a figure (#1873, lane sp11-fixes) 2026-11-15 10:27:28 -06:00
jason.woltje f73c761ad3 fix(webui): sp11 review, signing out drops kept pane messages and stops keeping them; an organization page's sessions heading and rows take activity from the session list the Agents working tile counts, so the two agree; the Messages page test covers class words (#1873, lane sp11-fixes) 2026-11-15 10:27:28 -06:00
jason.woltje 8b4a26dd2b fix(webui): sp11 item 8, with no organizations #/dashboard is the Dashboard: 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 (#1873, lane sp11-fixes) 2026-11-15 10:27:28 -06:00
jason.woltje 11d2b43b3a fix(webui): sp11 item 7, the organization header line beside the disabled reload says in plain words that the bus reads the organization file when it starts and a change loads on restart (#1873, lane sp11-fixes) 2026-11-15 10:27:28 -06:00
jason.woltje 8aefc0f7a9 fix(webui): sp11 item 6, an organization's decision page, decision list, replies and messages name a decision class in the Inbox's words from the same map; message classes the map does not know show as they are (#1873, lane sp11-fixes) 2026-11-15 10:27:28 -06:00
jason.woltje 9cff3255e6 fix(webui): sp11 items 4 and 5, a pane's unsent message is kept per thread for the tab's life and comes back when the pane opens again, Full view included; closing a pane with one asks first; a pane whose send waits for its receipt is held when a fourth thread opens (#1873, lane sp11-fixes) 2026-11-15 10:27:27 -06:00
jason.woltje d50d79cddd fix(webui): sp11 item 3, Resume assistant shows only when the bus instances read says the thread's agent runs no process; a failed read or an unlisted agent keeps the control (#1873, lane sp11-fixes) 2026-11-15 10:27:20 -06:00
jason.woltje fb3bd16052 fix(webui): sp11 item 2, Done this week opens the Tasks board filtered to the tasks it counted: done=week in the address keeps done tasks last changed in the past 7 days, a chip clears it, and the count line says the bus records no time a task was done (#1873, lane sp11-fixes) 2026-11-15 10:27:20 -06:00
jason.woltje 13615e12b9 fix(webui): sp11 item 1, agent counts say running and working and count the same rows: the tiles say how many run beside how many work, the Dashboard organization rows count the tile's rows, the sessions heading lists the running copies too, and the Agents page names a running agent with no reported state (#1873, lane sp11-fixes) 2026-11-15 10:27:20 -06:00
jason.woltje bd29e756a5 fix(webui): sp10 recheck notes, Apply clears a refused Save's line, and a refused Delete with no pending change does not point at Discard (#1873, lane sp10-workflow-edit) 2026-11-15 09:42:07 -06:00
jason.woltje 6bd9be78ac fix(webui): sp10 review round, Save refuses while a step form holds edits not applied, Delete sends the revision the page shows so a stale view is refused, Save re-reads into the current state, and a failed read or stopped bus says a pending change is kept (#1873, lane sp10-workflow-edit) 2026-11-15 09:36:30 -06:00
jason.woltje 3a84f46b6e fix(webui): sp10, the list page's read-only line gets the same spacing class as the graph page's (#1873, lane sp10-workflow-edit) 2026-11-15 09:27:58 -06:00
jason.woltje 208439814a feat(webui): sp10, edit a workflow in its step graph, held unsaved in the page with Save and Discard, one POST with the read revision, refusals in words with the change kept, a stale note when the file moved, new and delete workflow from the list, and the sp9 follow-ups (missing role on its own line, box fit by text extent) (#1873, lane sp10-workflow-edit) 2026-11-15 09:18:43 -06:00
jason.woltje 7c6571768f 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) 2026-11-15 08:31:10 -06:00
jason.woltje 3597e0daf6 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) 2026-11-15 08:31:10 -06:00
jason.woltje 3116f43e65 feat(webui): sp9, an organization's workflows read only, a list at #/orgs/<id>/workflows, a step graph at #/orgs/<id>/workflows/<name> 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/<id>/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.
2026-11-15 08:31:10 -06:00
jason.woltje c66f1ec00c fix(webui): sp8 review round, an organization page starts its workspace read beside its own reads, a Dashboard tile with no figures read says so, the Organizations heading gets its jump target from secH (#1873, lane sp8-kpis) 2026-11-15 07:56:00 -06:00
jason.woltje 9400e0d94d feat(webui): sp8, the Dashboard leads with one row of figure tiles and an organization's page with its own, each a link to the page that lists what it counts (#1873, lane sp8-kpis)
The Dashboard's tiles are Agents working of all agents, Organizations running, Needs you, Open tasks,
Done this week and Projects, each added up only from the organizations whose reads gave it; a tile
names every organization it leaves out and why. The Sessions and Model spend tiles go: the first is
the Agents tile now, the second had no figure. An organization's page replaces its KPIs and Metrics
placeholders with Agents working, Open tasks, Needs you and Done this week for that organization,
each a link to the system page filtered to it, from one cross-organization read scoped to it. The
system Agents page's row building is shared so a tile's figure is that page's row count.
2026-11-15 07:33:21 -06:00
jason.woltje a9912983e6 fix(webui): sp7 re-check, a narrow pane wraps its header tools inside the pane, the project page's Review decision and request links keep the conversation's full view, Full view names its thread (#1873, lane sp7-polish) 2026-11-15 06:41:07 -06:00
jason.woltje 626321608f docs(webui): sp7, the Assistant's header comment names the links that keep the organization's own view (#1873, lane sp7-polish) 2026-11-15 06:28:40 -06:00
jason.woltje 5f965cdb30 fix(webui): sp7 review shots, a pane header keeps its title, Full view, Messages and close on one row with the organization and project below (#1873, lane sp7-polish) 2026-11-15 06:27:19 -06:00
jason.woltje 8296d33100 fix(webui): sp7 review round, each pane links its thread's full view in one plain click, a requirement's Open conversation keeps the organization's own view, the gated-pill assertion matches its text (#1873, lane sp7-polish) 2026-11-15 06:24:39 -06:00
jason.woltje 69ac78b97e fix(webui): sp7 shots round, a pane header keeps its close button on one line, and a thread that was not opened is said as a warning (#1873, lane sp7-polish)
- assistant.css: the pane title shrinks before the header tools wrap, so the close button stays on the title's line at 1440.
- assistant.js: paintNote draws the line under the lead; a refusal marks it data-refused and the line shows in the warning colour until the next change clears it.
- assistant-threads browser test: the refusal line carries data-refused, and closing a pane clears it.
2026-11-15 06:03:06 -06:00
jason.woltje a2a7df73df fix(webui): sp7 polish, a system page names itself once, thread links open as Assistant panes, each pane links its organization's messages, resume is a small control, Inbox class chips use words, a stopped organization's page has one restart (#1873, lane sp7-polish)
- A system page with nothing above it in the trail hides the trail that repeats its h1; drill-in trails stay.
- A project's thread link and each open-in-chat link add that thread as a pane on #/assistant. With three
  open, the first pane without an unsent message is replaced; when all three hold one, nothing is
  replaced and the page says why. Per-org chat addresses still work typed or pasted.
- "Messages in <org>" moves from the bar into each pane header as a link.
- The pane composer's Resume assistant is a small secondary control; behaviour unchanged.
- Inbox class chips read the one map of decision classes to words.
- Removes the unused pane, idPrefix and views params from projects.js and workspace.js; fixes the stale
  comment on the held-message status line.
- While an organization's bus is down, the header draws no bus control; the down banner's restart is the one.
2026-11-15 05:55:26 -06:00
jason.woltje 789e2bcdd4 fix(webui): sp6 third review round, a held message line stays steady between polls, only a pane's latest render decides the line, the held preview cuts on whole characters (#1873, lane sp6-threads) 2026-11-15 05:02:28 -06:00
jason.woltje 02df2a3a25 fix(webui): sp6 re-check notes, a held message is never overwritten or held out of sight, and a pane whose organization left the list does not promise it comes back (#1873, lane sp6-threads)
The held draft goes back only into an empty composer and is cleared only once it is in it. A thread
that reads without a composer shows the held message in the pane's line. Only a stopped or
unavailable bus says the message comes back when the pane reads again.
2026-11-15 04:57:37 -06:00
jason.woltje b982404963 fix(webui): sp6 review round, a stopped organization's pane stops reading and keeps its unsent message, an open pane picks up new replies on its own, a pasted thread id in any case shows its pane (#1873, lane sp6-threads)
A pane whose organization's bus is stopped or unavailable no longer polls or offers Refresh; it says
nothing is read until the bus starts again, holds any typed draft, and restores it untouched when the
bus returns. Unsent pane drafts now count for the organization-switch and Console-restart guards.
The Assistant sidebar item drops the Messages unread badge. "Start thread" is the primary action.
Tests cover the stopped pane's draft, a reply arriving with no refresh, and an uppercase show id.
2026-11-15 04:51:23 -06:00
jason.woltje 5dd940165d feat(webui): sp6, the Assistant page holds up to three threads from any organization side by side, with the open set in the address (#1873, lane sp6-threads)
The sidebar Assistant opens one system page, #/assistant. Each pane is the
chat thread view of its own organization: it reads, sends and updates
through that organization's routes, with a header carrying the title, the
organization chip, the project chip and a close control. "Open thread"
lists the threads of every answering organization, grouped by organization,
and starts a new thread in a chosen one. The open set and its order live in
the hash (open=org:id,...&show=org:id), validated on read. A stopped
organization is named at the top and in its own pane; the other panes keep
working. At 390 one pane shows with a tab strip.

Side by side is removed (panes.js, panes.css and its test); its old address
lands on the Assistant page.
2026-11-15 04:37:52 -06:00
jason.woltje ed4f45ed23 fix(webui): sp4 third review round, a move still writing never paints the board over the not-on-your-list page, an archived project keeps its filter while another filter matches none of its tasks (#1873, lane sp4) 2026-11-15 03:54:28 -06:00
jason.woltje 79df01f1de fix(webui): sp4 second review round, a project filter stays while its organization does not answer, a gone filter is cleaned only from the address its read started on, a read in flight never paints over the not-on-your-list page (#1873, lane sp4) 2026-11-15 03:54:28 -06:00
jason.woltje d7b98b4698 fix(webui): sp4, an organization's own task board with no filter on no longer says its tasks match the filters (#1873, lane sp4)
The scope's org= in the workspace read is where the board reads, not a filter, so the
count line and the empty state no longer treat it as one. Found in the retaken shots.
2026-11-15 03:54:28 -06:00
jason.woltje 0f29fe0d92 fix(webui): sp4 review round, one organization chip per card with its avatar, a filter that is gone leaves the address, an impossible organization address is not the system board (#1873, lane sp4)
- The card's organization chip carries the avatar in place of its dot; the bare avatar
  before it is gone (prototype shot 13).
- A filter naming an organization or project that is not on the list is dropped from the
  address after the render, and a dropped project reads again without its server filter.
- #/orgs/<id>/tasks with an id no organization can have says "This organization is not
  on your list." instead of showing every organization's tasks.
- The write check's comment and the test's fake say orgSetup is Console-wide today.
2026-11-15 03:54:28 -06:00
jason.woltje 32be3a01bc feat(webui): one task board for every organization, with organization and project filters in the address (#1873, lane sp4)
#/tasks is a system route that shows every organization's tasks on one board,
with the per-organization columns, organization and project chips on each card,
and the organization and project filters kept in the address. An organization
that did not answer is named first with its reason. A move or edit goes to the
row's own organization and is offered only where that organization's own config
takes task changes. #/orgs/<id>/tasks is the same board scoped to one
organization, and a card opened from the system board links to its task there.
A render replaced by a newer one now draws from that newer read instead of
throwing on an empty board.
2026-11-15 03:54:28 -06:00
jason.woltje f97e0d3aeb fix(webui): sp5 re-check notes, a late named Fire leaves a newer Fire's busy state alone, the picked agent row gets the left bar, tests for the picked agent with the state filter and a refused scope (#1873, lane sp5) 2026-11-15 03:32:32 -06:00
jason.woltje eebdb2f0ce fix(webui): sp5 review round, Open addresses the agent and the scoped page marks it, Fire words name the organization, a named Fire keeps its outcome, Cancel restores focus (#1873, lane sp5)
- Open on #/agents goes to #/orgs/<id>/agents?agent=<key>; the scoped table puts that agent first,
  marks it (aria-current) and draws no Open or Actions column (review F1, F2).
- Fire's aria-label, confirm group and receipt use the organization's label (F5).
- A Fire sent to a named organization keeps its receipt after the opened organization changes (F7).
- Cancel moves focus back to the row's Fire (F10).
- agentsPage refuses a scope that is not an organization id instead of listing every organization (F11).
2026-11-15 03:26:18 -06:00
jason.woltje 5373b110ac feat(webui): one Agents page for the whole system, with organization and state filters, Fire sent to the row's organization (#1873, lane sp5)
#/agents lists every organization's agents in one table: name, role, state,
what each works on, organization and project chips. Org and state filters
live in the hash query. A stopped organization is named at the top. A row
opens the agent in its organization; Fire is drawn only where that
organization's own config lets this Console decide, and posts to that
organization's hire route. #/orgs/<id>/agents leads with the same table
scoped to one organization, above the levels grid and held roles. A hired
agent reads as Hired (or Hired from pack X) with its org:hire id as
secondary text. The sidebar and dashboard Agents links open the system page.
2026-11-15 03:13:54 -06:00
jason.woltje 3885f4628b fix(webui): sp3 second review round, #/inbox keeps the system theme, hire links follow each organization's own decide (#1873, lane sp3) 2026-11-15 02:09:04 -06:00
jason.woltje ba9933309b fix(webui): sp3 review round, answers from the system Inbox read route_to and show who raised them, the hire form is linked, the sidebar count stays system-wide (#1873, lane sp3) 2026-11-15 01:10:13 -06:00
jason.woltje 0785af8129 feat(webui): one Inbox for the whole system, with organization filters and answers pinned to the row's organization (#1873, lane sp3)
#/inbox lists every answering organization's decisions from the one GET /api/workspace read, newest
first, each row with its organization chip and a project chip when it has one. Organization and
type filters live in the hash query. A stopped organization is named at the top with its reason.
An answer from the list reads and posts to the row's own organization (/api/orgs/<org>/...), never
the one opened last, and each item can be opened in its own organization. #/orgs/<id>/inbox is the
same list filtered to that organization. No server route was added.
2026-11-15 01:10:13 -06:00
jason.woltje d1b01329bc fix(webui): sp2 polish, an organization's bare address opens its page and the dashboard keeps the system theme (#1873, lane sp2)
- #/orgs/<id> with no page and no trailing slash now opens #/orgs/<id>/organization instead of the
  system dashboard.
- The system dashboard resolves its own appearance (this browser's choice made
  there, else the default) under mosaic-console-appearance-system; it no
  longer shows the theme of the organization opened last.
- A stopped organization's page shows one bus banner: the page's own down
  banner now carries the restart action, and the shell's "Bus unavailable"
  banner hides on that page (display.js paintBus and app.js busDown).
- The dashboard row for a stopped organization no longer repeats "the bus is
  not running" under "bus stopped"; the last start time still shows.
- Organizations table figures keep to one line (white-space, no font change).
- The Active agents status cell was class "st", which picked up the global
  .st::before "?" glyph; it is now ws-st.
- Tests: red-first browser tests for each item, plus the test sp1 lacked: a
  read still in flight at an organization switch keeps nothing.
- org-switch-browser: a stopped organization's page asserts one banner; the
  shell's "Bus unavailable" is checked on its tasks page instead.
2026-11-15 00:44:18 -06:00
jason.woltje c7aecbffa9 fix(webui): sp1 second review round, a read or file save in flight at a switch keeps nothing (#1873, lane sp1)
The second review found two paths the first fix round left open.

- The project-file save captures orgEpoch before its POST. When a switch
  happened meanwhile, it neither paints nor announces the old file's
  outcome on the new organization's page. An opaqueredirect still signs out.
- A live read (reader, get.api), the palette's inbox and tasks reads and
  the board poll keep their answer in `last` only while the organization
  they were sent for is still open. A later cached render on the new
  organization no longer draws the old one's rows.

single-pane test 11 holds acme's file save across a switch to beta and
asserts nothing is announced: red on the previous bus.js ("Saved as commit
cccccccccccc."), green now. The kept-read guard has no browser test.
2026-11-14 23:51:59 -06:00
jason.woltje bb2f79f3d9 fix(webui): sp1 review round, a switch carries nothing across and first run keeps its board (#1873, lane sp1)
- First run (no organization yet) keeps #/ as its board; the router no longer rewrites it to
  #/dashboard, which first run cannot draw.
- An organization switch stops the agents section's request poll and clears what it held. A
  route-change restart chain finishes on the organization it started in.
- A write in flight at a switch drops its answer: an org epoch in bus.js, checked after each
  await. The switch clears every busy flag, receipt and reply, and the doc drafts.
- The bus banner reads org state again only when the route crosses /dashboard, not on every
  hashchange inside one organization.
- The down banner does not repeat its heading as the reason.
- Tests: first run wordmark, poll stop on switch, restart pinned to its organization, settings
  save in flight at a switch, banner without a repeated reason. The org-switch test comment and
  wait match the in-place switch.
2026-11-14 23:19:05 -06:00
jason.woltje e24e7e93a2 feat(webui): one shell, a system dashboard, organization pages as drill-ins (#1873, lane sp1)
#/dashboard is the system landing route: one row per organization with its bus
state, agent count (organization files), project count and needs-you count,
each only when a read gave it, and active agents. Opening an organization from
the sidebar or a dashboard row is a route inside the same page: no reload, a
Dashboard / <organization> breadcrumb, and later /api reads scoped to it through
the existing fetch wrapper, whose organization is now a variable the router sets
(mosaic-org-switch event). #/orgs/<id>/... stays the address. An organization
whose bus is down says so on the dashboard and on its page, with the reason the
workspace read gives.
2026-11-14 22:35:00 -06:00
44 changed files with 6647 additions and 1562 deletions

No files matched your search

+34 -15
View File
@@ -76,8 +76,11 @@
if (!usable() || busy || pending || !view) return;
const [url, payload] = plan(action, name);
busy = true; confirming = null; receipt = ''; redraw();
const gen = generation;
let answer;
try { answer = await post(url, payload); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
// #1873 SP1: an organization switch while this was in flight drops the answer; the switch already cleared busy.
if (gen !== generation) return;
busy = false;
if (answer === null) return;
const { ok, result } = answer;
@@ -122,8 +125,10 @@
const instance = create.instance.trim(), definition = create.definition;
if (!instance || !definition) { receipt = 'Name the instance and choose a definition.'; redraw(); return; }
busy = true; receipt = ''; redraw();
const gen = generation;
let answer;
try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
if (gen !== generation) return;
busy = false;
if (answer === null) return;
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); }
@@ -154,19 +159,31 @@
document.addEventListener('submit', e => { if (e.target?.id !== 'agent-create-form') return; e.preventDefault(); createAgent(); });
// A sign-out stops any poll; nothing that was asked is retried.
document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; });
// #1873 SP1: opening another organization stops any poll and clears what this section held for the
// old one. A request already sent finishes on the old organization's bus; its page reads it again there.
document.addEventListener('mosaic-org-switch', () => {
generation++; pending = null; receipt = ''; editing = null; confirming = null; busy = false;
create = { instance: '', definition: '' };
for (const k of Object.keys(reasons)) delete reasons[k];
});
// F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One
// terminate; then reads of the instances until the run has left (up to the 60 s regenerate
// allows); then one launch. A thread definition is not launched: its next message starts it.
// A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried.
const restarts = Object.create(null);
let restarts = Object.create(null);
// #1873 SP1: the chain's reads and writes go to the organization it started in, which the fetch
// wrapper passes through unchanged. A switch clears what the new page shows; the old chain keeps
// its own entry and stops painting.
const pin = (org, path) => org ? `/api/orgs/${encodeURIComponent(org)}/${path.slice('/api/'.length)}` : path;
document.addEventListener('mosaic-org-switch', () => { restarts = Object.create(null); });
const RESTART_WAIT = 30;
function restartMarkup(name, thread = false) {
const r = restarts[name];
return `<span class="agent-restart"><button type="button" class="btn" data-agent-restart="${esc(name)}" data-agent-thread="${thread ? 'yes' : 'no'}"${r?.busy ? ' disabled' : ''}>${r?.busy ? 'Restarting…' : `Restart ${esc(name)} now`}</button> <span role="status" data-agent-restart-status="${esc(name)}">${esc(r?.text ?? '')}</span></span>`;
}
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const paintRestart = name => document.querySelectorAll('.agent-restart').forEach(el => {
const paintRestart = (name, org = window.MosaicOrg?.id ?? null) => (window.MosaicOrg?.id ?? null) !== org ? undefined : document.querySelectorAll('.agent-restart').forEach(el => {
const b = el.querySelector('[data-agent-restart]');
if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes');
});
@@ -179,45 +196,47 @@
return { ok: res.ok, result };
}
// The request's closing read, or a reason the chain stops.
async function closing(request) {
async function closing(request, org) {
for (let tries = 0; tries < POLL_LIMIT; tries++) {
await new Promise(r => setTimeout(r, POLL_MS));
let res, r = null;
try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
try { res = await fetch(pin(org, `/api/agent/request?id=${encodeURIComponent(request)}`), { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' };
try { r = await res.json(); } catch { continue; }
if (res.ok && r?.request === request && r.state === 'closed') return { done: r };
}
return { stop: `Request ${request} is still open. Read the agents again later.` };
}
async function runs(name) {
async function runs(name, org) {
try {
const res = await fetch('/api/bus/instances', { cache: 'no-store', redirect: 'manual' });
const res = await fetch(pin(org, '/api/bus/instances'), { cache: 'no-store', redirect: 'manual' });
if (!res.ok || res.type === 'opaqueredirect') return null;
const e = (await res.json())?.rows?.[0]?.instances?.[name];
return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0;
} catch { return null; }
}
async function restartAgent(name, thread) {
const org = window.MosaicOrg?.id ?? null;
const r = restarts[name] = { busy: true, text: `Terminating ${name}…` };
const stop = text => { r.busy = false; r.text = text; paintRestart(name); };
paintRestart(name);
const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' });
const paint = () => paintRestart(name, org);
const stop = text => { r.busy = false; r.text = text; paint(); };
paint();
const asked = await send(pin(org, '/api/agent/terminate'), { instance: name, reason: 'route change' });
if (asked.signedOut) return stop('Sign in again, then retry.');
if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result));
const ended = await closing(asked.result.request);
const ended = await closing(asked.result.request, org);
if (ended.stop) return stop(ended.stop);
if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`);
r.text = `Terminated ${name}. Waiting for its session to end…`; paintRestart(name);
r.text = `Terminated ${name}. Waiting for its session to end…`; paint();
let left = false;
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name)) === 0; }
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name, org)) === 0; }
if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`);
if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`);
r.text = `Launching ${name}…`; paintRestart(name);
const launch = await send('/api/agent/launch', { instance: name });
r.text = `Launching ${name}…`; paint();
const launch = await send(pin(org, '/api/agent/launch'), { instance: name });
if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.');
if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`);
const started = await closing(launch.result.request);
const started = await closing(launch.result.request, org);
if (started.stop) return stop(started.stop);
if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`);
return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`);
+64 -18
View File
@@ -11,8 +11,10 @@
// Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
// page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
let machine = false;
const boardKey = window.MosaicOrg ? `mosaic-console-board:${window.MosaicOrg.id}` : 'mosaic-console-board';
try { machine = JSON.parse(localStorage.getItem(boardKey))?.machine === true; } catch {}
// #1873 SP1: kept per organization, read again when the open organization changes.
const boardKey = () => window.MosaicOrg?.id ? `mosaic-console-board:${window.MosaicOrg.id}` : 'mosaic-console-board';
const readMachine = () => { try { return JSON.parse(localStorage.getItem(boardKey()))?.machine === true; } catch { return false; } };
machine = readMachine();
// Signed out (#1589): a Console fetch answered with a redirect, so the sign-in proxy turned it away.
// The first one sets body.signed-out (the listener below); after that no Console fetch is made.
const out = () => document.body.classList.contains('signed-out');
@@ -58,6 +60,11 @@
const darkPreference = window.matchMedia('(prefers-color-scheme: dark)');
const APPEARANCE_KEY = 'mosaic-console-appearance', LAST_KEY = 'mosaic-console-appearance-last';
const orgKey = id => `${APPEARANCE_KEY}:${id}`;
// #1873 SP2: the system dashboard is no organization's page. It shows the system theme: this browser's
// own choice made there, else the default, never the theme of the organization opened last.
const SYSTEM_KEY = 'mosaic-console-appearance-system';
// SP3: display.js owns which addresses are system pages (the dashboard and #/inbox); the bare address is one before it normalizes.
const onSystem = () => !!window.MosaicOrg && ((!/^#\/orgs\//.test(location.hash) && ['', '/'].includes(location.hash.replace(/^#/, '').split('?')[0])) || window.MosaicOrg.system?.() === true);
const store = {
read(k) { try { const v = JSON.parse(localStorage.getItem(k)); return v && typeof v === 'object' && !Array.isArray(v) ? v : null; } catch { return null; } },
write(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch { return false; } },
@@ -66,7 +73,10 @@
const orgId = v => typeof v === 'string' && v.length > 0 && v.length <= 64;
// org: null until the Console knows which organization it shows. Then the override lives under that organization's own key;
// before that, and for a Console with no organization, it is the browser-wide record this page has always kept.
let org = null, override = null, themes = null, themesUnavailable = false, current = null, applied = [];
let org = null, override = null, themes = null, themesUnavailable = false, current = null, applied = [], system = false;
const shown = () => (system ? null : org);
// Kept in memory too: a choice storage refused still shows until reload, as an organization's does.
let systemOverride = Brand.appearanceOf(store.read(SYSTEM_KEY));
const readOverride = () => {
if (org) return Brand.appearanceOf(store.read(orgKey(org.id)));
const v = store.read(APPEARANCE_KEY);
@@ -78,7 +88,7 @@
$('theme-switch').setAttribute('aria-checked', String(dark));
}
function paintNotices() {
const w = current.warning, banner = $('theme-warning'), cue = $('org-cue'), label = org?.label;
const w = current.warning, banner = $('theme-warning'), cue = $('org-cue'), label = shown()?.label;
banner.hidden = !w;
if (w) {
const asked = String(w.theme).slice(0, 64), kept = w.source === 'browser' ? 'The choice is kept.' : 'The saved value is unchanged.';
@@ -91,11 +101,12 @@
$('theme-clear').textContent = label ? `Use ${label}’s theme` : 'Use the default theme';
}
// Colour is never the only cue to which organization this is: whenever the theme is not the built-in one, the name is written out.
cue.hidden = !(org && (current.theme !== Brand.DEFAULT_THEME || w));
cue.hidden = !(shown() && (current.theme !== Brand.DEFAULT_THEME || w));
if (!cue.hidden) cue.textContent = w ? `${label} · Harbor shown instead of “${String(w.theme).slice(0, 64)}”` : `${label} · ${nameOf(current.theme)} theme · ${current.themeSource === 'browser' ? 'your choice in this browser' : 'set by the organization'}`;
}
function paint() {
current = Brand.resolveAppearance({ themes, unavailable: themesUnavailable, saved: org?.saved ?? null, override });
system = onSystem();
current = Brand.resolveAppearance({ themes, unavailable: themesUnavailable, saved: shown()?.saved ?? null, override: system ? systemOverride : override });
const root = document.documentElement;
root.dataset.palette = current.theme;
if (current.mode === 'system') delete root.dataset.mode; else root.dataset.mode = current.mode;
@@ -126,15 +137,16 @@
ready: null,
// The installed, valid themes as the server listed them, or null until /api/themes has answered.
themes: () => themes && themes.map(t => ({ id: t.id, displayName: t.displayName, tokens: t.tokens })),
state: () => ({ theme: current.theme, mode: current.mode, requested: current.requested, themeSource: current.themeSource, modeSource: current.modeSource, warning: current.warning, provisional: current.provisional, organization: org && { id: org.id, label: org.label }, saved: org?.saved ?? null, override: override && { ...override } }),
state: () => { const o = shown(), v = system ? systemOverride : override; return { theme: current.theme, mode: current.mode, requested: current.requested, themeSource: current.themeSource, modeSource: current.modeSource, warning: current.warning, provisional: current.provisional, organization: o && { id: o.id, label: o.label }, saved: o?.saved ?? null, override: v && { ...v } }; },
// Writes this browser's override for the organization (a theme, a mode, or both). false when storage refused it; the page still shows it until reload.
set(patch) {
if (onSystem()) { const next = Brand.appearanceOf({ ...(systemOverride || {}), ...patch }), ok = next ? store.write(SYSTEM_KEY, next) : true; systemOverride = next; showAppearance(ok); return ok; }
const next = Brand.appearanceOf({ ...(override || {}), ...patch });
const ok = next ? (org ? store.write(orgKey(org.id), next) : store.write(APPEARANCE_KEY, next)) : true;
override = next; showAppearance(ok); return ok;
},
// Removes the override: the organization's saved value, or else the default, shows again.
clear() { const ok = org ? store.remove(orgKey(org.id)) : store.remove(APPEARANCE_KEY); override = null; showAppearance(ok); return ok; },
clear() { if (onSystem()) { const ok = store.remove(SYSTEM_KEY); systemOverride = null; showAppearance(ok); return ok; } const ok = org ? store.remove(orgKey(org.id)) : store.remove(APPEARANCE_KEY); override = null; showAppearance(ok); return ok; },
// The seam for the organization switcher (stream S5): call it with the organization now shown and its saved appearance.
organization(id, saved, label) {
if (!orgId(id)) return;
@@ -151,6 +163,20 @@
override = readOverride();
paint();
document.dispatchEvent(new CustomEvent('mosaic-theme-change', {detail: {palette: current.theme, mode: current.mode, stored: true, warning: null, picked: current.theme}}));
// The open organization's saved appearance, read again when another organization opens (#1873 SP1).
const readBusiness = async () => {
try {
const { ok, body } = await getJSON('/api/business');
if (ok && orgId(body?.business?.id)) return window.MosaicAppearance.organization(body.business.id, body.business.appearance, body.business.id);
} catch (err) { if (err?.signedOut) return; }
// No fresh answer: keep the organization this browser last showed for its own override, but not its stale saved value.
if (org && !org.confirmed) { org.saved = null; showAppearance(); }
};
document.addEventListener('mosaic-org-switch', () => { readBusiness(); });
// The dashboard and an organization page share the shell: moving between them repaints the theme.
const repaintScope = () => { if (onSystem() !== system) showAppearance(); };
window.addEventListener('hashchange', repaintScope);
window.MosaicOrg?.ready?.then(repaintScope);
window.MosaicAppearance.ready = (async () => {
await Promise.allSettled([
(async () => {
@@ -161,14 +187,7 @@
} catch (err) { if (err?.signedOut) return; themes = []; themesUnavailable = true; }
showAppearance();
})(),
(async () => {
try {
const { ok, body } = await getJSON('/api/business');
if (ok && orgId(body?.business?.id)) return window.MosaicAppearance.organization(body.business.id, body.business.appearance, body.business.id);
} catch (err) { if (err?.signedOut) return; }
// No fresh answer: keep the organization this browser last showed for its own override, but not its stale saved value.
if (org && !org.confirmed) { org.saved = null; showAppearance(); }
})(),
readBusiness(),
]);
})();
$('palette').onchange = e => window.MosaicAppearance.set({ theme: e.target.value });
@@ -488,12 +507,22 @@
const box = $('bus-down');
// E2E5 review P2: the organization list is read first and on its own, so a failed bus-status read
// (likely when the bus is down) still refreshes the list, its banner and the Settings Start control.
// #1873 SP1: the dashboard lists every organization's bus on its own rows; this banner is an organization page's.
if (window.MosaicOrg && window.MosaicOrg.route().split('?')[0] === '/dashboard') { box.hidden = true; return; }
// An answer about an organization that is no longer open is dropped: the new one's read draws the banner.
// So is one drawn for a page left while it was read (#1873 SP2: the organization page draws none).
const where = () => window.MosaicOrg?.route().split('?')[0] === '/organization';
const asked = window.MosaicOrg?.id ?? null, onPage = where(), stale = () => (window.MosaicOrg?.id ?? null) !== asked || where() !== onPage;
if (window.MosaicOrg) {
const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id);
const org = (await window.MosaicOrg.readList()).find(row => row.id === asked);
if (stale()) return;
// #1873 SP2: the organization's own page says it in its down banner, with the restart action: one banner, not two.
if (org?.busHealthy === false && onPage) { box.hidden = true; return; }
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; window.MosaicConsole?.attachBus(box, 'Restart the bus'); return; }
}
try {
const s = await api('/api/bus-status');
if (stale()) return;
if (!s.down) { box.hidden = true; return; }
const b = s.boot;
box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`));
@@ -528,11 +557,28 @@
function showMachine() { $('show-machine').checked = machine; document.body.classList.toggle('machine-off', !machine); }
$('show-machine').onchange = e => {
machine = e.target.checked; showMachine();
try { localStorage.setItem(boardKey, JSON.stringify({ machine })); } catch {}
try { localStorage.setItem(boardKey(), JSON.stringify({ machine })); } catch {}
if (machine) { $('status').textContent = 'Loading board…'; fresh(); refresh(); } else hideMachine();
};
showMachine();
if (!machine) hideMachine();
// #1873 SP1: another organization opened in place. Its board setting and bus state are its own.
const onOrgSwitch = () => {
machine = readMachine(); showMachine();
if (machine) { fresh(); refresh(); } else { hideMachine(); busDown(); }
};
document.addEventListener('mosaic-org-switch', onOrgSwitch);
// An address with no organization picks one after the first list read; its own setting applies then.
window.MosaicOrg?.ready?.then(() => { if (readMachine() !== machine) onOrgSwitch(); });
// The banner hides on the system dashboard, gives way to the organization page's own down banner,
// and shows on the organization's other pages, so a move across those lines redraws it. Other moves
// inside one organization read nothing (the switch path reads its own).
let busPlace = null;
window.addEventListener('hashchange', () => {
if (!window.MosaicOrg) return;
const path = window.MosaicOrg.route().split('?')[0], now = ['/dashboard', '/organization'].includes(path) ? path : 'page';
if (now !== busPlace) { busPlace = now; busDown(); }
});
$('refresh').onclick = refresh;
$('mobile-pause').onclick = () => $('pause').click();
$('pause').onclick = () => { paused = !paused; $('pause').querySelector('span').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-label', paused ? 'Resume' : 'Pause'); $('pause').title = paused ? 'Resume' : 'Pause'; $('mobile-pause').textContent = paused ? 'Resume' : 'Pause'; $('pause').setAttribute('aria-pressed', String(paused)); schedule(); if (data) render(); };
+88
View File
@@ -0,0 +1,88 @@
/* #1873 SP6: the Assistant page (r2-13). A bar with the page heading and "Open thread", then up to
three thread panes as cards side by side, each with its own header (title, organization and
project chips, close), its own scroll and its own composer. One pane and a tab strip on a phone.
Loaded after shell.css and chat.css; every rule is scoped to the Assistant page. */
html[data-organizations] .assist{display:flex;flex-direction:column;gap:10px;min-width:0}
html[data-organizations] .assist-bar{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap}
html[data-organizations] .assist-vh{flex:1 1 320px;min-width:0}
html[data-organizations] .assist-vh h1{margin:0}
html[data-organizations] .assist-vh h1:focus{outline:none}
html[data-organizations] .assist-vh .sub{margin:4px 0 0;color:var(--muted);font-size:14px}
html[data-organizations] .assist-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
html[data-organizations] .assist-open{min-width:220px;max-width:100%;font:500 14px/1.4 var(--font);color:var(--text);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:6px 8px}
html[data-organizations] .assist-open:disabled{color:var(--muted);cursor:not-allowed}
html[data-organizations] .assist [data-assist-unread]:empty,html[data-organizations] .assist [data-assist-note]:empty{display:none}
html[data-organizations] .assist [data-assist-unread] .note{margin:0}
html[data-organizations] .assist [data-assist-note]{margin:0;font-size:13px;color:var(--muted)}
html[data-organizations] .assist [data-assist-note][data-refused]{color:var(--warning);font-weight:600}
html[data-organizations] .assist-new{border:1px solid var(--line);border-radius:var(--r);background:var(--surface);padding:12px 14px;display:grid;gap:10px}
html[data-organizations] .assist-new[hidden]{display:none}
html[data-organizations] .assist-new h2{margin:0;font-size:16px}
html[data-organizations] .assist-new h2:focus{outline:none}
html[data-organizations] .assist-new-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
html[data-organizations] .assist-new-fields label{display:grid;gap:4px;font-size:13px;color:var(--muted);min-width:0}
html[data-organizations] .assist-new-fields select,html[data-organizations] .assist-new-fields input{font:500 14px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:6px 8px;min-width:0}
html[data-organizations] .assist-new-actions{display:flex;gap:8px;flex-wrap:wrap}
html[data-organizations] .assist-new .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
html[data-organizations] .assist-new .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
html[data-organizations] .assist-new [data-assist-new-status]{margin:0;font-size:13px}
html[data-organizations] .assist-new [data-assist-new-status]:empty{display:none}
html[data-organizations] .assist-tabs{display:none;margin-left:0;max-width:100%;flex-wrap:wrap}
html[data-organizations] .assist-tabs>button{max-width:60vw;overflow:hidden;text-overflow:ellipsis;min-height:32px;padding:2px 10px}
html[data-organizations] .assist-panes{display:grid;grid-template-columns:repeat(var(--assist-n,1),minmax(0,1fr));gap:12px;min-height:0}
html[data-organizations] .assist-panes[hidden]{display:none}
html[data-organizations] .assist-pane{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--line);border-radius:var(--r);background:var(--surface);overflow:hidden}
html[data-organizations] .assist-pane-h{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0;flex-wrap:wrap}
html[data-organizations] .assist-pane-h h2{margin:0;font-size:15px;font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:5em;flex:1 1 0}
html[data-organizations] .assist-pane-h h2:focus{outline:none}
html[data-organizations] .assist-chips{display:inline-flex;gap:6px;flex-wrap:wrap;min-width:0;order:1;flex:1 0 100%}
html[data-organizations] .assist-chips:empty{display:none}
/* SP7: a pane's tools (Full view, Messages, close) stay together on the title's row; the chips take the row under it.
A pane too narrow for them wraps the tools, never pushes one past the pane's edge. */
html[data-organizations] .assist-pane-tools{display:inline-flex;align-items:center;justify-content:flex-end;gap:2px;flex:0 1 auto;flex-wrap:wrap;max-width:100%;margin-left:auto}
html[data-organizations] .assist-chips .ws-chip{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .assist .pane-tool{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer;flex:none}
html[data-organizations] .assist .pane-tool:hover{background:var(--raised);color:var(--text)}
/* SP7: each pane's own organization's Messages page, a text link among the pane's tools. */
html[data-organizations] .assist .pane-tool.assist-msgs{width:auto;padding:0 8px;font-size:12px;font-weight:600;text-decoration:none}
/* SP7: "Resume assistant" under a pane's composer is a small secondary control, not a second primary button. */
html[data-organizations] .assist-body .btn.pane-resume{align-self:flex-start;min-height:0;padding:2px 8px;margin-top:4px;font-size:12px;font-weight:500;background:none;border-color:transparent;color:var(--muted)}
html[data-organizations] .assist-body .btn.pane-resume:hover:not(:disabled){background:var(--raised);color:var(--text)}
html[data-organizations] .assist .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
html[data-organizations] .assist-line{margin:0;padding:6px 12px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))}
html[data-organizations] .assist-line[hidden]{display:none}
html[data-organizations] .assist-body{flex:1;min-height:0;overflow:auto;padding:12px;display:flex;flex-direction:column}
/* The chat thread view in a pane: the log scrolls, the composer stays at the foot of the card.
Messages read as the project conversation's bubbles (projects.css scopes those to .project-chat,
whose page-level layout a pane must not take), sized for a third of the screen. */
html[data-organizations] .assist-body .chat-log{flex:1 1 0;min-height:120px;max-height:none;overflow:auto;margin:8px 0 0;padding:0;border:0}
html[data-organizations] .assist-body .chat-log>li{border:0;display:flex;flex-direction:column;align-items:flex-start;gap:4px;padding-block:6px}
html[data-organizations] .assist-body .chat-log>li.chat-message-human{align-items:flex-end}
html[data-organizations] .assist-body .chat-log>li>.chat-body{background:var(--raised);border:1px solid var(--line);border-radius:12px;padding:8px 12px;max-width:100%;box-sizing:border-box;overflow-wrap:anywhere}
html[data-organizations] .assist-body .chat-log>li.chat-message-human>.chat-body{background:var(--accent-fill);color:var(--on-accent);border-color:transparent}
html[data-organizations] .assist-body .chat-message-meta{display:flex;align-items:center;gap:6px;opacity:1;font-size:12px;color:var(--muted)}
html[data-organizations] .assist-body .chat-message-meta strong{color:var(--text);font-weight:600}
html[data-organizations] .assist-body .chat-avatar{display:grid;place-items:center;flex:none;width:24px;height:24px;border-radius:50%;background:var(--raised);color:var(--action);font-weight:700;font-size:12px}
html[data-organizations] .assist-body .chat-message-human .chat-avatar{background:var(--accent-fill);color:var(--on-accent)}
html[data-organizations] .assist-body>.chat-area{max-width:none;margin:0;flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
html[data-organizations] .assist-body .chat-conversation{flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
html[data-organizations] .assist-body .chat-conversation>p.small{margin:0;font-size:12px}
html[data-organizations] .assist-body form{margin-top:auto;padding-top:10px}
html[data-organizations] .assist-body .chat-conversation>button,html[data-organizations] .assist-body form>button{align-self:flex-start;justify-self:start;width:auto}
html[data-organizations] .assist-body textarea{width:100%;box-sizing:border-box;min-height:56px;height:56px}
html[data-organizations] .assist-empty{margin:24px 0;color:var(--muted)}
html[data-organizations] .assist-empty[hidden]{display:none}
@media (max-width:760px){
html[data-organizations] .assist-tabs:not([hidden]){display:inline-flex}
html[data-organizations] .assist-panes{grid-template-columns:minmax(0,1fr);height:auto}
html[data-organizations] .assist-pane.alt{display:none}
html[data-organizations] .assist-body{overflow:visible}
html[data-organizations] .assist-body .chat-log{flex:none;min-height:0;overflow:visible}
html[data-organizations] .assist-open{flex:1 1 100%;min-width:0}
html[data-organizations] .assist-tools{flex:1 1 100%}
html[data-organizations] .assist-new-fields{grid-template-columns:minmax(0,1fr)}
}
+568
View File
@@ -0,0 +1,568 @@
// #1873 SP6: the Assistant page (#/assistant), one system page with no organization prefix. Up to
// three chat threads, from any organization on the list, open next to each other. Each pane is the
// chat thread view (chat.js) in its own instance pinned to the pane's organization, so its reads and
// its sends name that organization in the address whichever organization the page opened last.
// A phone shows one pane at a time with a tab strip.
//
// The open set, its order and the phone's shown pane live in the address
// (#/assistant?open=<org>:<thread>,…&show=<org>:<thread>), checked before use and rewritten with
// replaceState. A bare #/assistant opens the set this browser had last.
//
// SP7: a plain click on a thread link anywhere in the Console (a project's conversation rows, the
// sidebar's thread links) opens that thread as a pane here, added to the open set; #/assistant?add=<org>:<thread>
// carries it across the page change. A link marked data-full-view (a pane's Full view, a requirement's
// "Open conversation") and a per-organization chat address typed or pasted open that organization's own chat view.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace;
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const MAX = 3, POLL = 10000, LIST_TTL = 3000, INFO_TTL = 30000, STORE = 'mosaic-assistant-open-v1';
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true';
const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } };
const save = value => { try { localStorage.setItem(STORE, value); } catch {} };
const svg = d => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; };
const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; };
const rowsOf = body => (Array.isArray(body?.rows) ? body.rows : null);
// "<org>:<thread>,…" from the address: valid selectors only, each once, at most three.
function parse(query) {
const q = new URLSearchParams(query || ''), keys = [];
for (const part of (q.get('open') || '').split(',')) {
const cut = part.indexOf(':');
if (cut < 1) continue;
const org = part.slice(0, cut), id = part.slice(cut + 1).toLowerCase();
if (!ORG.test(org) || !UUID.test(id)) continue;
const key = `${org}:${id}`;
if (!keys.includes(key) && keys.length < MAX) keys.push(key);
}
const want = q.get('show') || '', at = want.indexOf(':');
const show = at > 0 ? `${want.slice(0, at)}:${want.slice(at + 1).toLowerCase()}` : null;
return { keys, show: keys.includes(show) ? show : null, named: q.has('open') };
}
const split = key => { const cut = key.indexOf(':'); return { org: key.slice(0, cut), id: key.slice(cut + 1) }; };
// ---------- reads ----------
// The organization list, and per organization its threads, projects and whether this Console may
// send there (its config's chatSend). An organization whose bus is down is not read.
let listAt = 0, listPromise = null;
const infos = new Map();
function orgList(fresh = false) {
if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) {
listAt = Date.now();
listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null);
}
return listPromise;
}
const readConfig = org => fetch(`/api/orgs/${enc(org)}/config`, { cache: 'no-store', redirect: 'manual' })
.then(r => (r.ok ? r.json() : null)).then(c => c?.chatSend === true).catch(() => false);
function info(org, fresh = false) {
if (!org || org.invalid || org.busHealthy === false) return Promise.resolve({ threads: null, projects: null, chatSend: false, read: false });
const hit = infos.get(org.id);
if (hit && !fresh && Date.now() - hit.at < INFO_TTL) return hit.promise;
const promise = Promise.all([
data.threads(org.id).then(rowsOf).catch(() => null),
data.projects(org.id).then(rowsOf).catch(() => null),
readConfig(org.id),
]).then(([threads, projects, chatSend]) => ({
threads: threads && threads.filter(t => t && !t.shared && typeof t.id === 'string' && UUID.test(t.id)),
projects: projects && projects.filter(p => p && typeof p.id === 'string'),
chatSend, read: true,
}));
infos.set(org.id, { at: Date.now(), promise });
return promise;
}
// The rows ws().orgsOf reads: an organization not ok is named at the top of the page.
function orgRows(list, got) {
return list.map(o => {
const why = o.invalid ? ['invalid', o.reason || ''] : o.busHealthy === false ? ['down', ws().state(o)[2]] : got.get(o.id)?.threads === null ? ['timeout', 'the thread list did not answer'] : ['ok', ''];
return { org: o.id, label: o.label, theme: o.theme, state: why[0], reason: why[1] };
});
}
// ---------- state ----------
let mounted = null; // { root, panes: [pane], show, turn, timer, list, got, o, form }
let mountGen = 0, paneSeq = 0;
function writeUrl() {
if (!mounted) return;
const keys = mounted.panes.map(p => p.key);
const qs = keys.length ? `open=${keys.join(',')}${mounted.show && mounted.show !== keys[0] ? `&show=${mounted.show}` : ''}` : '';
save(keys.join(','));
const next = qs ? `#/assistant?${qs}` : '#/assistant';
if (location.hash !== next) history.replaceState(null, '', next);
}
// ---------- markup ----------
function frame() {
return `<section class="assist" data-assistant aria-labelledby="assist-title">
<div class="assist-bar">
<div class="assist-vh"><h1 id="assist-title" tabindex="-1">Assistant</h1>
<p class="sub">Threads from any organization, side by side. Each pane reads and sends through its own organization.</p></div>
<div class="assist-tools">
<label class="ws-sr" for="assist-open">Open thread</label>
<select id="assist-open" class="assist-open" data-assist-open><option value="">Open thread…</option></select>
</div>
</div>
<div data-assist-unread></div>
<p class="note" data-assist-note role="status"></p>
<form class="assist-new" data-assist-new hidden aria-labelledby="assist-new-title">
<h2 id="assist-new-title" tabindex="-1">New thread in <span data-assist-new-org></span></h2>
<div class="assist-new-fields">
<label><span>Project</span><select name="project" required></select></label>
<label><span>Agent</span><select name="instance" required></select></label>
<label><span>Name</span><input name="name" required maxlength="200" autocomplete="off"></label>
</div>
<div class="assist-new-actions"><button class="ws-btn primary" type="submit">Start thread</button><button class="ws-btn" type="button" data-assist-new-cancel>Cancel</button></div>
<p class="note" data-assist-new-status role="status"></p>
</form>
<div class="ws-seg assist-tabs" role="group" aria-label="Shown thread" data-assist-tabs></div>
<div class="assist-panes" data-assist-panes></div>
<p class="assist-empty" data-assist-empty hidden>No thread is open. Choose one under Open thread.</p>
</section>`;
}
function paneShell(p) {
const n = p.n;
return `<section class="assist-pane ws-o" data-assist-pane="${esc(p.key)}" id="assist-p${n}" aria-labelledby="assist-p${n}-title">
<div class="assist-pane-h">
<h2 id="assist-p${n}-title" data-assist-title>Reading…</h2>
<span class="assist-chips" data-assist-chips></span>
<span class="assist-pane-tools">
<a class="pane-tool assist-msgs" data-assist-pane-full data-full-view href="#/orgs/${enc(p.org)}/projects/threads/${enc(p.id)}">Full view</a>
<a class="pane-tool assist-msgs" data-assist-pane-messages href="#/orgs/${enc(p.org)}/messages" aria-label="Messages in ${esc(p.org)}">Messages</a>
<button type="button" class="pane-tool" data-assist-close aria-label="Close thread">${CLOSE}</button>
</span>
</div>
<p class="assist-line" data-assist-line role="status" hidden></p>
<div class="assist-body" data-assist-body></div>
</section>`;
}
const els = p => ({
title: p.el.querySelector('[data-assist-title]'), chips: p.el.querySelector('[data-assist-chips]'),
close: p.el.querySelector('[data-assist-close]'), full: p.el.querySelector('[data-assist-pane-full]'), messages: p.el.querySelector('[data-assist-pane-messages]'), line: p.el.querySelector('[data-assist-line]'), body: p.el.querySelector('[data-assist-body]'),
});
// The picker: every organization on the list, its threads, and "New thread in …" where this
// Console may send. A stopped, invalid or silent organization is named and offers nothing.
function pickerHtml(list, got) {
const open = new Set(mounted.panes.map(p => p.key));
const groups = (list || []).filter(o => o && typeof o.id === 'string' && ORG.test(o.id)).map(o => {
const name = ws().label(o), i = got.get(o.id);
if (o.invalid) return `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
if (o.busHealthy === false) return `<optgroup label="${esc(name)} · ${esc(ws().state(o)[2])}"><option disabled value="">No threads while its bus is down</option></optgroup>`;
if (!i?.threads) return `<optgroup label="${esc(name)} · not answering"><option disabled value="">Threads unavailable</option></optgroup>`;
const project = id => i.projects?.find(p => p.id === id)?.name;
const rows = i.threads.map(t => {
const key = `${o.id}:${t.id.toLowerCase()}`, where = project(t.project);
return `<option value="${esc(key)}"${open.has(key) ? ' disabled' : ''}>${esc(t.name || t.id)}${where ? ` · ${esc(where)}` : ''}${open.has(key) ? ' (open)' : ''}</option>`;
}).join('');
const fresh = i.chatSend ? `<option value="new:${esc(o.id)}">New thread in ${esc(name)}…</option>` : '';
return `<optgroup label="${esc(name)}">${rows || (fresh ? '' : '<option disabled value="">No threads</option>')}${fresh}</optgroup>`;
});
return `<option value="">Open thread…</option>${groups.join('')}`;
}
// ---------- drawing ----------
async function draw({ fresh = false, navigated = false } = {}) {
if (!mounted) return;
const gen = mountGen, turn = ++mounted.turn;
const live = () => mounted && gen === mountGen && turn === mounted.turn;
clearTimeout(mounted.timer);
const list = await orgList(fresh);
if (!live()) return;
const openOrgs = new Set(mounted.panes.map(p => p.org));
const valid = (list || []).filter(o => o && typeof o.id === 'string' && ORG.test(o.id));
const got = new Map(await Promise.all(valid.map(async o => [o.id, await info(o, fresh || (!navigated && openOrgs.has(o.id)))])));
if (!live()) return;
mounted.list = list; mounted.got = got;
mounted.o = ws().orgsOf({ orgs: orgRows(valid, got) });
paintTop();
for (const p of mounted.panes) drawPane(p);
paintTabs(); layout();
mounted.timer = setTimeout(tick, POLL);
}
function tick() {
if (!mounted) return;
if (paused()) { mounted.timer = setTimeout(tick, POLL); return; }
void draw();
}
// The line under the lead: how many panes are open, or why a thread was not opened (drawn as a warning).
function paintNote() {
const el = mounted.root.querySelector('[data-assist-note]');
setText(el, mounted.notice || (mounted.panes.length ? `${mounted.panes.length} open, up to ${MAX}` : ''));
el?.toggleAttribute('data-refused', !!mounted.notice);
}
function paintTop() {
const { root, list, o, panes } = mounted;
setHtml(root.querySelector('[data-assist-unread]'), list ? ws().unreadNote(o, [], 'assistant') : '<p class="note ws-unread-note">The organization list could not be read. Open panes only read.</p>');
paintNote();
const pick = root.querySelector('[data-assist-open]');
if (document.activeElement !== pick) { setHtml(pick, pickerHtml(list, mounted.got)); pick.value = ''; }
const full = panes.length >= MAX;
pick.disabled = full;
pick.title = full ? `${MAX} threads are open. Close one to open another.` : '';
root.querySelector('[data-assist-empty]').hidden = panes.length > 0;
}
function drawPane(p) {
if (!mounted || !p.el.isConnected) return;
const { list, got, o } = mounted, e = els(p);
const org = list?.find(x => x?.id === p.org) || null, i = got.get(p.org);
const name = org ? ws().label(org) : p.org;
const thread = i?.threads?.find(t => t.id.toLowerCase() === p.id) || null;
const title = thread?.name || `Thread ${p.id.slice(0, 8)}`;
p.title = title;
setText(e.title, title);
e.close.setAttribute('aria-label', `Close ${title}, ${name}`);
e.full.setAttribute('aria-label', `Full view of ${title}, ${name}`);
e.messages.setAttribute('aria-label', `Messages in ${name}`);
if (org) p.el.setAttribute('style', ws().colours(org)); else p.el.removeAttribute('style');
const project = thread && i.projects?.find(x => x.id === thread.project)?.name;
setHtml(e.chips, `${ws().orgChip(o, p.org)}${ws().projChip(project || '')}`);
let line = '', readable = true, why = 'Nothing is read for this thread.', back = false;
if (!list) line = 'The organization list could not be read, so sending is off here until it reads again.';
else if (!org) { line = `${p.org} is not on your organization list.`; readable = false; }
else if (org.invalid) { line = `${name}: organization invalid. Fix its file under Settings, Organizations.`; readable = false; }
else if (org.busHealthy === false) {
const how = org.busStopped ? 'stopped' : 'unavailable';
line = `${name}: bus is ${how}. This pane cannot read or send until it starts again; the other panes are not affected.`;
why = `Nothing is read for this thread while ${name}'s bus is ${how}.`; readable = false; back = true;
}
else if (i?.threads && !thread) line = `This thread is not in ${name}'s thread list.`;
// A held message the last render had no composer for stays in the line between polls, so the
// status line neither flickers nor is announced again on every read.
const withHeld = () => {
const cps = [...p.draft.replace(/\s+/g, ' ').trim()];
return `${line ? `${line} ` : ''}Your unsent message is kept until this thread can send: “${cps.length > 200 ? `${cps.slice(0, 200).join('')}…` : cps.join('')}”`;
};
const shown = readable && p.held && p.draft ? withHeld() : line;
setText(e.line, shown); e.line.hidden = !shown;
if (!readable) {
p.held = false;
// An unsent message outlives the chat view: it is held on the pane and put back when the
// thread reads again.
const typed = p.chat && e.body.querySelector('form textarea')?.value;
if (typed?.trim()) p.draft = typed;
p.chat?.dispose(); p.chat = null;
const kept = !p.draft ? '' : back ? ' Your unsent message is kept and comes back here when it reads again.' : ' Your unsent message is kept while this pane stays open.';
setHtml(e.body, `<p class="ws-line">${esc(why)}${kept}</p>`);
return;
}
if (!p.chat) { p.chat = window.MosaicChat.pane(p.org); p.first = true; }
const first = p.first, chat = p.chat, seq = ++p.seq; p.first = false;
void chat.render({ path: `/chat/threads/${p.id}`, container: e.body, canSend: !!list && i?.chatSend === true, navigated: first }).then(() => {
// Only the latest render of this pane decides: an earlier one that a newer render cut short
// has no composer yet and must not claim the thread cannot send.
if (seq !== p.seq || p.chat !== chat || !p.el.isConnected) return;
const field = p.draft ? e.body.querySelector('form textarea') : null;
if (p.draft && !field) {
// The thread reads but this render drew no composer (it cannot send now): the held message is
// quoted in the pane's status line until a composer is back to take it.
p.held = true; const text = withHeld(); setText(e.line, text); e.line.hidden = false;
return;
}
// Put the held message back only into an empty composer; text typed meanwhile is never overwritten.
if (field && !field.value) { field.value = p.draft; field.dispatchEvent(new Event('input', { bubbles: true })); }
if (field && field.value === p.draft) p.draft = '';
if (p.held && (field || !p.draft)) { p.held = false; setText(e.line, line); e.line.hidden = !line; }
}, () => {});
}
function paintTabs() {
const { root, panes, show } = mounted;
const tabs = root.querySelector('[data-assist-tabs]');
const focused = tabs.contains(document.activeElement) ? document.activeElement.dataset.assistShow : null;
const html = panes.map(p => `<button type="button" data-assist-show="${esc(p.key)}" aria-pressed="${show === p.key}" aria-controls="assist-p${p.n}">${esc(p.title || 'Reading…')}</button>`).join('');
if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focused) tabs.querySelector(`[data-assist-show="${CSS.escape(focused)}"]`)?.focus({ preventScroll: true }); }
tabs.hidden = panes.length < 2;
}
function layout() {
const { root, panes, show } = mounted;
const box = root.querySelector('[data-assist-panes]');
box.style.setProperty('--assist-n', String(Math.max(1, panes.length)));
box.hidden = !panes.length;
for (const p of panes) p.el.classList.toggle('alt', show !== p.key);
fit();
}
// Wide screens: the panes fill the window below the bar and each scrolls on its own.
const wide = window.matchMedia('(min-width: 761px)');
function fit() {
if (!mounted) return;
const box = mounted.root.querySelector('[data-assist-panes]');
if (!wide.matches) { box.style.height = ''; return; }
const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0;
const top = box.getBoundingClientRect().top;
box.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot - 16))}px`;
}
// ---------- 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.
// SP14: the kept messages belong to the person who typed them. They are stored with the viewer the
// page's /api/config read names (bus.js, window.MosaicViewer), and read back only by a page that
// read the same viewer. An entry stored under another viewer, or under none (an older entry, a
// failed read), is removed unread, so a tab that never saw a sign-out (another tab signed out, the
// session expired) does not hand one person's text to the next. A Console with no sign-in has one
// fixed viewer. Until the viewer is read nothing is written; with none, messages live only in the page.
const DRAFTS = 'mosaic-assistant-drafts';
const kept = new Map();
let viewer = null, known = false, viewerRead = null;
const saveKept = () => {
if (!known) return;
try { if (kept.size && viewer) sessionStorage.setItem(DRAFTS, JSON.stringify({ viewer, drafts: Object.fromEntries(kept) })); else sessionStorage.removeItem(DRAFTS); } catch { /* the Map still holds it */ }
};
const ready = () => (viewerRead ??= (async () => {
let v = null, stored = null;
try { v = await window.MosaicViewer; } catch { /* no viewer */ }
try { stored = JSON.parse(sessionStorage.getItem(DRAFTS) || 'null'); } catch { /* none kept */ }
viewer = typeof v === 'string' && v ? v : null;
known = true;
const own = !draftsOff && viewer && stored && typeof stored === 'object' && stored.viewer === viewer && stored.drafts && typeof stored.drafts === 'object' && !Array.isArray(stored.drafts);
if (own) for (const [k, t] of Object.entries(stored.drafts)) if (!kept.has(k) && typeof t === 'string' && t.trim() && parse(`open=${k}`).keys[0] === k) kept.set(k, t);
saveKept();
})());
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();
try { sessionStorage.removeItem(DRAFTS); } catch { /* nothing to remove */ }
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: kept.get(key) || '', held: false, seq: 0 };
const box = mounted.root.querySelector('[data-assist-panes]');
box.insertAdjacentHTML('beforeend', paneShell(p));
p.el = box.lastElementChild;
return p;
}
function addPane(key, { focus = false } = {}) {
if (!mounted) return;
const had = mounted.panes.find(p => p.key === key);
if (had) { mounted.show = key; paintTabs(); layout(); writeUrl(); return; }
if (mounted.panes.length >= MAX) return;
const p = makePane(key);
mounted.panes.push(p); mounted.show = key;
writeUrl();
if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); }
if (focus) els(p).title.setAttribute('tabindex', '-1'), els(p).title.focus({ preventScroll: true });
}
// 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.
// 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;
if (mounted.panes.length < MAX) { const p = makePane(key); mounted.panes.push(p); return p; }
const old = mounted.panes.find(p => !holds(p));
if (!old) return null;
const p = makePane(key);
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 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);
if (!p) { mounted.notice = REFUSED; paintNote(); return false; }
mounted.notice = ''; mounted.show = key;
writeUrl();
if (mounted.got) { paintTop(); drawPane(p); paintTabs(); layout(); }
els(p).title.setAttribute('tabindex', '-1'); els(p).title.focus({ preventScroll: true });
return true;
}
const addOf = query => { const v = new URLSearchParams(query || '').get('add'); return v ? parse(new URLSearchParams({ open: v }).toString()).keys[0] || null : null; };
const THREAD_LINK = /^#\/(?:orgs\/([^/?#]+)\/)?(?:projects|chat)\/threads\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
document.addEventListener('click', e => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target.closest?.('a[href]');
// A link marked data-full-view (a pane's Full view, a requirement's "Open conversation") goes to the
// organization's own thread view, which has Request details, sessions and sharing.
if (!a || a.hasAttribute('download') || a.hasAttribute('data-full-view') || (a.target && a.target !== '_self')) return;
const m = THREAD_LINK.exec(a.getAttribute('href') || '');
const org = m && (m[1] || window.MosaicOrg?.id);
if (!m || !org || !ORG.test(org)) return;
e.preventDefault();
const key = `${org}:${m[2].toLowerCase()}`;
if (mounted && /^#\/assistant(?:\?|$)/.test(location.hash)) add(key);
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);
const next = mounted.panes[Math.min(at, mounted.panes.length - 1)];
if (mounted.show === p.key) mounted.show = next?.key ?? null;
writeUrl(); paintTop(); paintTabs(); layout();
// The close button just left the page: focus the next pane's close control, or the picker.
(next ? els(next).close : mounted.root.querySelector('[data-assist-open]')).focus({ preventScroll: true });
}
// ---------- a new thread ----------
async function openForm(orgId) {
if (!mounted) return;
const org = mounted.list?.find(o => o?.id === orgId);
if (!org || org.busHealthy === false || mounted.got.get(orgId)?.chatSend !== true) return;
const f = mounted.root.querySelector('[data-assist-new]'), formTurn = ++mounted.formTurn;
mounted.form = { org: orgId, busy: false };
f.hidden = false; f.reset();
setText(f.querySelector('[data-assist-new-org]'), ws().label(org));
setText(f.querySelector('[data-assist-new-status]'), 'Reading projects and agents…');
const project = f.elements.project, instance = f.elements.instance;
project.innerHTML = ''; instance.innerHTML = ''; project.disabled = true; instance.disabled = true;
f.querySelector('h2').focus({ preventScroll: true });
const [i, agents] = await Promise.all([
info(org, true),
fetch(`/api/orgs/${enc(orgId)}/chat/instances`, { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).then(rowsOf).catch(() => null),
]);
if (!mounted || mounted.formTurn !== formTurn || f.hidden) return;
const projects = (i.projects || []).filter(p => !p.archived);
project.innerHTML = projects.map(p => `<option value="${esc(p.id)}">${esc(p.name || p.id)}</option>`).join('');
instance.innerHTML = (agents || []).filter(a => a && typeof a.instance === 'string').map(a => `<option value="${esc(a.instance)}">${esc(a.name || a.instance)}</option>`).join('');
project.disabled = !projects.length; instance.disabled = !agents?.length;
const missing = [i.projects === null ? 'Projects could not be read.' : !projects.length ? 'This organization has no open project.' : '', agents === null ? 'Agents could not be read.' : !agents.length ? 'No agent takes threads here.' : ''].filter(Boolean).join(' ');
setText(f.querySelector('[data-assist-new-status]'), missing);
}
function closeForm() {
if (!mounted) return;
mounted.form = null; mounted.formTurn++;
mounted.root.querySelector('[data-assist-new]').hidden = true;
}
async function submitForm(f) {
const form = mounted?.form;
if (!form || form.busy) return;
const status = f.querySelector('[data-assist-new-status]');
const body = { project: f.elements.project.value, name: f.elements.name.value.trim(), instance: f.elements.instance.value };
if (!body.project || !body.instance || !body.name) { setText(status, 'Choose a project and an agent, and name the thread.'); return; }
form.busy = true;
const submit = f.querySelector('[type=submit]'); submit.disabled = true;
setText(status, 'Starting the thread…');
let result = null, why = '';
try {
const r = await fetch(`/api/orgs/${enc(form.org)}/chat/threads`, { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const v = await r.json().catch(() => null);
if (r.ok && typeof v?.id === 'string' && UUID.test(v.id)) result = v.id.toLowerCase();
else why = r.type === 'opaqueredirect' || r.status === 401 ? 'You are signed out. Reload the page to sign in again.' : `The thread was not started${typeof v?.error === 'string' ? ` (${v.error})` : ''}.`;
} catch { why = 'Outcome unknown. Look under Open thread before trying again.'; }
if (mounted?.form !== form) return;
form.busy = false; submit.disabled = false;
if (!result) { setText(status, why); return; }
infos.delete(form.org);
const key = `${form.org}:${result}`;
if (mounted.panes.length >= MAX && !mounted.panes.some(p => p.key === key)) { setText(status, `Started. ${MAX} threads are open; close one to open it.`); return; }
closeForm();
addPane(key, { focus: true });
void draw({ fresh: false });
}
// ---------- events (bound once to the mount root) ----------
function bind(root) {
root.addEventListener('change', e => {
const pick = e.target.closest?.('[data-assist-open]');
if (!pick || !mounted) return;
const value = pick.value;
pick.value = '';
if (value.startsWith('new:')) { void openForm(value.slice(4)); return; }
const want = parse(`open=${value}`).keys[0];
if (want) { mounted.notice = ''; addPane(want); }
});
root.addEventListener('click', e => {
if (!mounted) return;
const show = e.target.closest?.('[data-assist-show]');
if (show) { const p = mounted.panes.find(x => x.key === show.dataset.assistShow); if (p) { mounted.show = p.key; paintTabs(); layout(); writeUrl(); } return; }
const close = e.target.closest?.('[data-assist-close]');
if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; }
if (e.target.closest?.('[data-assist-new-cancel]')) { closeForm(); root.querySelector('[data-assist-open]').focus({ preventScroll: true }); }
});
root.addEventListener('submit', e => {
const f = e.target.closest?.('[data-assist-new]');
if (!f) return;
e.preventDefault();
void submitForm(f);
});
}
// ---------- public ----------
async function mount(container, query, { navigated = false } = {}) {
unmount();
const gen = ++mountGen;
// SP14: the kept messages are known only once the viewer is; a page left meanwhile mounts nothing.
await ready();
if (gen !== mountGen) return;
container.innerHTML = frame();
const root = container.querySelector('[data-assistant]');
mounted = { root, panes: [], show: null, turn: 0, timer: null, list: null, got: null, o: null, form: null, formTurn: 0, notice: '' };
bind(root);
window.addEventListener('resize', fit);
document.title = 'Assistant · Mosaic Console';
let want = parse(query);
if (!want.named) want = parse(`open=${load() || ''}`);
for (const key of want.keys) mounted.panes.push(makePane(key));
mounted.show = want.show || want.keys[0] || null;
// A thread link followed from another page: a fresh set holds no unsent message, so it always lands.
const extra = addOf(query), added = extra && place(extra);
if (added) mounted.show = extra;
if (added) { els(added).title.setAttribute('tabindex', '-1'); els(added).title.focus({ preventScroll: true }); }
else if (navigated) root.querySelector('h1').focus({ preventScroll: true });
writeUrl(); paintTabs(); layout();
root.querySelector('[data-assist-empty]').hidden = mounted.panes.length > 0;
// SP14: a full set offers no fourth from the start, not only once the organization reads answer.
root.querySelector('[data-assist-open]').disabled = mounted.panes.length >= MAX;
await draw({ navigated: true });
}
function unmount() {
if (!mounted) { mountGen++; return; }
clearTimeout(mounted.timer);
for (const p of mounted.panes) { keep(p); p.chat?.dispose(); }
window.removeEventListener('resize', fit);
mounted = null; mountGen++;
}
// The Console's Refresh, and a redraw of the same page: every pane reads again now. A query that
// names a different open set (an address edited in place) opens that set.
function refresh(query) {
if (!mounted) return;
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)) { 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]');
for (const p of mounted.panes) box.append(p.el);
mounted.show = want.show || want.keys[0] || null;
writeUrl();
}
const extra = addOf(query);
if (extra) add(extra);
}
infos.clear();
void draw({ fresh: true });
}
const hasDraft = () => !!mounted && mounted.panes.some(p => !!p.draft.trim() || !!p.chat?.hasDraft());
// A write that changed an organization reads it again.
document.addEventListener('mosaic-org-changed', e => { if (mounted && mounted.panes.some(p => p.org === e.detail?.org)) { infos.delete(e.detail.org); void draw(); } });
window.MosaicAssistant = Object.freeze({ mount, unmount, refresh, hasDraft, add });
})();
+19 -3
View File
@@ -181,11 +181,27 @@
menu.addEventListener('click', e => { if (e.target.closest('a')) close(); });
document.addEventListener('click', e => { if (!e.target.closest('.avatar-wrap')) close(); });
function breadcrumbs() {
const path = location.hash.slice(2).split('?')[0], parts = path.split('/').filter(Boolean), box = $('breadcrumbs');
draw();
// SP1: every page inside an organization says which one, after the dashboard: Dashboard / Acme / ...
const box = $('breadcrumbs'), org = /^#\/orgs\/[^/?]+/.test(location.hash) ? window.MosaicOrg : null;
if (box && org?.id && !box.querySelector('[data-crumb-org]') && !/^#\/orgs\/[^/?]+\/organization(?:[/?]|$)/.test(location.hash)) box.insertAdjacentHTML('afterbegin', `<a href="#/dashboard">Dashboard</a><span aria-hidden="true">/</span><a href="#/orgs/${encodeURIComponent(org.id)}/organization" data-crumb-org>${escape(org.label)}</a><span aria-hidden="true">/</span>`);
// SP7: a trail with nothing above the page only repeats the page's heading, so the heading names it once.
if (!box) return;
const heading = [...$('main').querySelectorAll('h1')].find(h => h.getClientRects().length)?.textContent.trim();
const echo = !box.querySelector('a') && !!heading && box.textContent.trim() === heading;
if (box.hidden !== echo) box.hidden = echo;
}
window.MosaicBreadcrumbs = breadcrumbs;
function draw() {
// SP1: an organization's pages are routes under #/orgs/<id>; the crumb reads the route inside it.
const path = location.hash.replace(/^#\/orgs\/[^/?]+/, '#').slice(2).split('?')[0], parts = path.split('/').filter(Boolean), box = $('breadcrumbs');
if (!box || !$('bus-view')) return;
if (window.MosaicProjects?.breadcrumb?.(box, $('bus-view'))) return;
if (!parts.length || parts[0] === 'board') { box.innerHTML = '<span aria-current="page">Dashboard</span>'; return; }
const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',tasks:'Tasks',agents:'Agents',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'};
// Inside an organization the board is its control board; the system dashboard is the crumb before it.
if (parts[0] === 'board' && /^#\/orgs\//.test(location.hash)) { box.innerHTML = '<span aria-current="page">Control board</span>'; return; }
if (!parts.length || parts[0] === 'board' || parts[0] === 'dashboard') { box.innerHTML = '<span aria-current="page">Dashboard</span>'; return; }
if (parts[0] === 'organization') { const h = $('bus-view').querySelector('[data-org-dashboard] h1')?.textContent?.trim(); box.innerHTML = `<a href="#/dashboard">Dashboard</a>${h ? `<span aria-hidden="true">/</span><span aria-current="page">${escape(h)}</span>` : ''}`; return; }
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 = '<a href="#/settings">Settings</a><span aria-hidden="true">/</span><span aria-current="page">Business</span>'; 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');
+259 -83
View File
@@ -75,13 +75,17 @@
window.MosaicIcons.succeeded();
return body;
}
// #1873 SP1: bumped by each in-place organization switch. A write, or a read that keeps its answer,
// captures it before its await and drops the answer when it changed, so nothing the old organization
// answered lands on the new page or in its kept answers.
let orgEpoch = 0;
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
function reader(live) {
const used = [];
const get = async (verb, subject) => {
const k = subject === undefined ? verb : `${verb}:${subject}`;
let r;
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
if (live) { const epoch = orgEpoch; r = await fetchRead(verb, subject); if (epoch === orgEpoch) last.set(k, r); }
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
used.push(r.at);
if (verb === 'messages') { messageCount = r.unread; return r; }
@@ -90,7 +94,7 @@
get.api = async path => {
const k = `api:${path}`;
let r;
if (live) { r = await fetchApi(path); last.set(k, r); }
if (live) { const epoch = orgEpoch; r = await fetchApi(path); if (epoch === orgEpoch) last.set(k, r); }
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
used.push(r.at);
return r;
@@ -204,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.
@@ -239,7 +243,9 @@
const badge = (text, kind = 'muted') => `<span class="badge badge-${kind}">${txt(text)}</span>`;
// 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) => `<span class="tag${CLASSES.includes(cls) ? ` tag-${cls}` : ''}">${txt(cls)} · <code>${txt(action)}</code></span>`;
// 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) => `<span class="tag${CLASSES.includes(cls) ? ` tag-${cls}` : ''}">${txt(classWord(cls))} · <code>${txt(action)}</code></span>`;
const blockChip = text => `<span class="tag tag-block">${txt(text)}</span>`;
// An empty state says what would appear and where it comes from.
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
@@ -264,8 +270,8 @@
// ---------------------------------------------------------------- views
let inboxCount = null, messageCount = null;
// Organization mode (sample 01): Dashboard, Inbox, Tasks, Projects, Side by side, Agents and
// Assistant, with Settings pinned to the foot of the sidebar. Queue opens from the organization
// Organization mode (sample 01): Dashboard, Inbox, Tasks, Projects, Agents and Assistant (SP6:
// `#/assistant`, threads of any organization side by side), with Settings pinned to the foot of the sidebar. Queue opens from the organization
// dashboard and Docs from its Docs row, so neither is a sidebar item here.
function orgSections(route) {
const nav = $('console-nav');
@@ -275,9 +281,10 @@
const focused = $('sections').contains(active) || foot.contains(active) ? active.getAttribute('href') : null;
const icon = id => window.MosaicWorkspace?.icon?.(id) ?? `<svg class="i" aria-hidden="true"><use href="/icons.svg#i-${id}"/></svg>`;
const n = (count, what) => count > 0 ? `<span class="ws-n hot" aria-label="${count} ${what}">${count}</span>` : '';
const items = [['/', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/side-by-side', 'Side by side', '', 'columns'], ['/agents', 'Agents', '', 'agents'], ['/messages', 'Assistant', n(messageCount, 'unread messages'), 'talk']];
const items = [['/dashboard', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/agents', 'Agents', '', 'agents'], ['/assistant', 'Assistant', '', 'talk']];
const link = ([h, l, extra, id]) => {
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
// #1873 SP1: the Dashboard item is the system dashboard, `#/dashboard`, outside any organization.
const cur = h === '/dashboard' ? (route === '/dashboard' || route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
return `<a href="#${h}" class="s1-section ws-nav"${cur ? ' aria-current="page"' : ''}>${icon(id)}<span class="t">${l}</span>${extra}</a>`;
};
// No project tree under Projects here: the sidebar's Projects group lists every organization's
@@ -342,12 +349,14 @@
});
let configRead = Promise.resolve();
// #1873 SP1: the config read an organization switch started, until the next render has waited for it.
let switchRead = null;
let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap
function repliesHtml() {
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic inbox</code> in a terminal lists them.</p>';
if (!replies.length) return '<p class="small muted">No reply yet. One shows here when the role answers, as the page refreshes.</p>';
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(m.class)} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
return `<h3 class="small">Replies <span class="count">${replies.length}</span></h3><ol class="s1-plain s1-replies">${replies.map(m => `<li><p class="small muted">${txt(m.from_role)} ${when(m.at)} · ${txt(classWord(m.class))} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-q">${txt(m.body)}</p></li>`).join('')}</ol>${replies.length >= REPLIES ? `<p class="small muted">The first ${REPLIES} replies show here.</p>` : ''}`;
}
const draft = { to: '', body: '' };
function composerHtml() {
@@ -462,7 +471,9 @@
if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';hireCode='';paintHireStart();return;}
const thinking=d.thinking?.trim(),hire={op:d.op,level:d.level,name:d.name,reason:d.reason,...(d.level==='project'?{project:d.project}:{}),...(d.op==='fire'?{}:{role:{template:template?.name,sha256:template?.sha256},route:{harness:d.harness,model:d.model,...(thinking?{thinking}:{})}})};
hireBusy=true;hireStatus='';hireCode='';paintHireStart();
const epoch=orgEpoch;
const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:hireOrg() ?? d.business,...(templatesOnly?{}:{hire})});
if(epoch!==orgEpoch){if(res?.type==='opaqueredirect')signOut();return;}
hireBusy=false;
if(res?.type==='opaqueredirect'){signOut();return;}
if(res?.ok && templatesOnly && Array.isArray(json?.templates)){hireTemplates=json.templates;hireStatus=hireTemplates.length?'Templates read.':'No templates available.';}
@@ -479,44 +490,65 @@
const FIRE_REASON='Fired from the Agents page.';
let fireAsk=null,fireBusy=false,fireReceipt=null;
const fireKey=(org,name)=>`${org}\u0000${name}`;
function fireNote(){const org=hireOrg();const r=fireReceipt&&fireReceipt.org===org?fireReceipt:null;
// #1873 SP5: the system Agents page lists every organization's agents, so its controls carry their
// organization (data-agent-fire-org) and post to that organization's own bus, never to the one
// opened last. Its receipt box is `data-agents-receipt="*"` and shows a receipt from any of them.
// The agent cards keep the old shape: no attribute, the opened organization, canDecide.
function fireNote(scope=hireOrg()){const r=fireReceipt&&(scope==='*'?fireReceipt.system:fireReceipt.org===scope)?fireReceipt:null;
return r?`<p class="receipt${r.failed?' failed':''}" data-agents-receipt-text>${txt(r.text)}${r.code?` (<code>${txt(r.code)}</code>)`:''}</p>`:'';}
function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote();}
async function fireAgent(name){
const org=hireOrg();
if(fireBusy||!org||out()||!canDecide)return;
function paintFireNote(){for(const e of document.querySelectorAll('[data-agents-receipt]'))e.innerHTML=fireNote(e.dataset.agentsReceipt||hireOrg());}
const fireSel=(attr,name,org)=>`[${attr}="${CSS.escape(name)}"]${org?`[data-agent-fire-org="${CSS.escape(org)}"]`:':not([data-agent-fire-org])'}`;
async function fireAgent(name,named=null,orgName=named){
const org=named??hireOrg();
// A named organization was checked against its own config when the control was drawn (decidesIn).
if(fireBusy||!org||out()||(!named&&!canDecide)||isTester)return;
fireBusy=true;
for(const b of document.querySelectorAll('[data-agent-fire-yes],[data-agent-fire-no]'))b.disabled=true;
const {res,json}=await decidePost('/api/decisions/hire/start',{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
fireBusy=false;fireAsk=null;
const epoch=orgEpoch;
const {res,json}=await decidePost(`${decideBase({ org: named })}/decisions/hire/start`,{business:org,hire:{op:'fire',level:'org',name,reason:FIRE_REASON}});
// SP5 review F7: a named organization's Fire went to that organization, so its outcome still
// stands after the opened organization changes.
// After a switch, busy and confirm state belong to whatever Fire started since; leave them alone.
const stale=epoch!==orgEpoch;
if(stale&&!named){if(res?.type==='opaqueredirect')signOut();return;}
if(!stale){fireBusy=false;fireAsk=null;}
if(res?.type==='opaqueredirect'){signOut();return;}
const where=named?` in ${orgName}`:'';
if(res?.ok && json?.op==='resolved'){
for(const c of document.querySelectorAll(`[data-org="${CSS.escape(org)}"] [data-agent-card="${CSS.escape(name)}"]`))c.remove();
for(const r of document.querySelectorAll(`[data-agents-org="${CSS.escape(org)}"] tr[data-agent="${CSS.escape(name)}"]`))r.remove();
fireReceipt={org,text:`Fired ${name}. Recorded decision ${json.id}.`};
for(const r of document.querySelectorAll(`[data-ws-agent="${CSS.escape(`${org}:${name}`)}"]`))r.remove();
fireReceipt={org,system:!!named,text:`Fired ${name}${where}. Recorded decision ${json.id}.`};
paintFireNote();announce(fireReceipt.text);
document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org}}));
} else {
const r=decideRefusal(res,json,'outcome-unknown');
fireReceipt={org,failed:true,text:`${name} was not fired: ${r.message}`,code:r.code};
fireReceipt={org,system:!!named,failed:true,text:`${name}${where} was not fired: ${r.message}`,code:r.code};
paintFireNote();
for(const c of document.querySelectorAll(`[data-agent-fire-confirm="${CSS.escape(name)}"]`))c.outerHTML=fireButton(name);
for(const c of document.querySelectorAll(fireSel('data-agent-fire-confirm',name,named)))c.outerHTML=fireButton(name,named,orgName);
}
}
const fireButton=name=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}" data-focus-key="${esc(`fire:${name}`)}" aria-label="Fire ${esc(name)}">Fire</button>`;
const fireConfirm=name=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}?" data-agent-fire-confirm="${esc(name)}">Fire ${txt(name)}? <button type="button" class="btn small primary" data-agent-fire-yes="${esc(name)}" data-focus-key="${esc(`fire-yes:${name}`)}"${fireBusy?' disabled':''}>Confirm fire</button><button type="button" class="btn small" data-agent-fire-no="${esc(name)}"${fireBusy?' disabled':''}>Cancel</button></span>`;
// What the agent card shows for one agent: nothing without the decide right or outside the page's own organization.
function fireControl(org,name){
if(!canDecide||isTester||org!==hireOrg())return '';
return fireAsk===fireKey(org,name)?fireConfirm(name):fireButton(name);
const fireOrgAttr=org=>org?` data-agent-fire-org="${esc(org)}"`:'';
const fireFocus=(kind,name,org)=>esc(org?`${kind}:${org}:${name}`:`${kind}:${name}`);
// SP5 review F5: the words name the organization by its label (`orgName`); its id addresses the control.
const fireNameAttr=(org,orgName)=>org&&orgName&&orgName!==org?` data-agent-fire-name="${esc(orgName)}"`:'';
const fireButton=(name,org=null,orgName=org)=>`<button type="button" class="btn small" data-agent-fire="${esc(name)}"${fireOrgAttr(org)}${fireNameAttr(org,orgName)} data-focus-key="${fireFocus('fire',name,org)}" aria-label="Fire ${esc(name)}${org?` in ${esc(orgName)}`:''}">Fire</button>`;
const fireConfirm=(name,org=null,orgName=org)=>`<span class="ws-fire-confirm" role="group" aria-label="Fire ${esc(name)}${org?` in ${esc(orgName)}`:''}?" data-agent-fire-confirm="${esc(name)}"${fireOrgAttr(org)}${fireNameAttr(org,orgName)}>Fire ${txt(name)}? <button type="button" class="btn small primary" data-agent-fire-yes="${esc(name)}"${fireOrgAttr(org)} data-focus-key="${fireFocus('fire-yes',name,org)}"${fireBusy?' disabled':''}>Confirm fire</button><button type="button" class="btn small" data-agent-fire-no="${esc(name)}"${fireOrgAttr(org)}${fireBusy?' disabled':''}>Cancel</button></span>`;
// What the agent card shows for one agent: nothing without the decide right or outside the page's own
// organization. SP5: `allowed` given means the caller read that organization's own config (decidesIn);
// the control is then drawn for that organization, whichever one was opened last.
function fireControl(org,name,allowed,orgName=org){
const named=typeof allowed==='boolean';
if(isTester||(named?!allowed:(!canDecide||org!==hireOrg())))return '';
return fireAsk===fireKey(org,name)?fireConfirm(name,named?org:null,named?orgName:null):fireButton(name,named?org:null,named?orgName:null);
}
document.addEventListener('click',e=>{
const t=e.target.closest?.('[data-agent-fire],[data-agent-fire-yes],[data-agent-fire-no]');
if(!t)return;
const org=hireOrg();
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name);document.querySelector(`[data-agent-fire-yes="${CSS.escape(name)}"]`)?.focus();}
else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name);announce('Cancelled. Nothing was fired.');}
else void fireAgent(t.dataset.agentFireYes);
const named=t.dataset.agentFireOrg??null,org=named??hireOrg(),orgName=t.closest('[data-agent-fire-name]')?.dataset.agentFireName??named;
if(t.hasAttribute('data-agent-fire')){const name=t.dataset.agentFire;fireAsk=fireKey(org,name);t.outerHTML=fireConfirm(name,named,orgName);document.querySelector(fireSel('data-agent-fire-yes',name,named))?.focus();}
else if(t.hasAttribute('data-agent-fire-no')){const name=t.dataset.agentFireNo;fireAsk=null;t.closest('[data-agent-fire-confirm]').outerHTML=fireButton(name,named,orgName);document.querySelector(fireSel('data-agent-fire',name,named))?.focus();announce('Cancelled. Nothing was fired.');}
else void fireAgent(t.dataset.agentFireYes,named,orgName);
});
window.MosaicDecide=Object.freeze({fireControl,fireNote});
document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);});
@@ -572,6 +604,9 @@
};
const decideRefusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && typeof json?.error === 'string' && typeof json?.message === 'string'
? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] };
// SP3: an answer started from the system Inbox names its organization and goes there, whichever
// organization was opened last. One started on a decision page keeps the page's own scope.
const decideBase = a => a.org ? `/api/orgs/${enc(a.org)}` : '/api';
async function decide(step) {
const a = answer;
if (!a || a.pending || a.result) return;
@@ -582,7 +617,7 @@
const choice = selected.key, ordinary = ['decision','hire'].includes(a.console.kind);
const adjust=choice==='yes'?collectHireAdjustment(a.console):undefined;
a.pending = step; a.error = null; a.confirm = null; paintDecide();
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice,...(adjust===undefined?{}:{adjust}) });
const { res, json } = await decidePost(`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`, { choice,...(adjust===undefined?{}:{adjust}) });
a.pending = null;
// The page moved to another decision meanwhile: its confirmation is dropped, never shown.
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; }
@@ -596,7 +631,7 @@
if (step !== 'confirm' || !a.confirm) return;
const c = a.confirm;
a.pending = 'confirm'; a.error = null; paintDecide();
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
const { res, json } = await decidePost(`${decideBase(a)}/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
// One use, whatever came back.
a.pending = null; a.confirm = null;
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
@@ -606,6 +641,53 @@
paintDecide('#decide-status'); announce(['decision','hire'].includes(c.kind) ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
}
// Whether this Console decides in one organization, from that organization's own config: the system
// Inbox's hire links must not follow the organization opened last, whose bus may be the stopped one.
const orgConfig = id => (isTester ? Promise.resolve(null) : fetch(`/api/orgs/${enc(id)}/config`, { cache: 'no-store', redirect: 'manual' })
.then(r => (r.ok ? r.json() : null)).catch(() => null));
const decidesIn = id => orgConfig(id).then(c => c?.decide === true && c?.tester !== true);
// SP4: whether this Console takes task changes in one organization, from that organization's own
// config (orgSetup, as the per-organization board read it), so the system task board offers a move
// only where the row's organization takes it.
const tasksIn = id => orgConfig(id).then(c => c?.orgSetup === true && c?.tester !== true);
// SP3: the answer panel for the row selected in the Inbox, in that row's organization. That
// organization's config says whether this Console answers decisions there, and its bus inbox says
// whether this decision is eligible (its `console` field), so neither comes from the organization
// opened last. The state follows the same keep rules as the decision page's. `current` is the
// render's own check: a read that returns after the page moved on changes nothing.
async function inboxAnswer(d, current) {
if (!d || isTester || !d.console || typeof d.org !== 'string') return inboxKeep(d);
const base = `/api/orgs/${enc(d.org)}`;
let eligible = null, failed = false, seen = null;
try {
const [c, i] = await Promise.all([`${base}/config`, `${base}/bus/inbox`].map(u => fetch(u, { cache: 'no-store', redirect: 'manual' })));
if (c.type === 'opaqueredirect' || i.type === 'opaqueredirect') { if (current()) signOut(); return ''; }
if (!c.ok || !i.ok) throw new Error('not read');
const [cfg, box] = await Promise.all([c.json(), i.json()]);
const row = (Array.isArray(box?.rows) ? box.rows : []).find(r => r?.id === d.id);
// The broker's inbox row is the decisions row (`route_to`) plus `authorization`; the confirm
// step shows who raised it and what it affects from it, as the decision page does from the trail.
eligible = cfg?.decide === true && cfg?.tester !== true && row?.route_to === 'human' && row.console && typeof row.console === 'object' ? row.console : null;
if (eligible) seen = { at: row.at, raisedBy: { role: row.raised_by_role, run: row.raised_by_run }, routeTo: row.route_to, authorization: row.authorization ?? null };
} catch { failed = true; }
if (!current()) return '';
const same = answer?.org === d.org && answer.id === d.id;
if (!same) answer = eligible ? { id: d.id, org: d.org, console: eligible, pending: null, confirm: null, error: null, result: null } : null;
else if (eligible && !answer.pending) {
if (answer.console.digest !== eligible.digest) answer.confirm = null;
answer.console = eligible;
}
else if (!eligible && !failed && !answer.pending && !answer.result) answer = null;
if (answer?.org === d.org && answer.id === d.id) { if (seen) answer.decision = seen; return decideHtml(); }
return failed ? '<p class="note" data-ws-answer-why>Whether this Console can answer it here was not read: the organization did not answer. Open it in its organization, or use a terminal.</p>' : '';
}
// A row this Console cannot answer drops any answer held for another row, unless one is in flight.
function inboxKeep(d) {
if (answer?.org && !answer.pending && !(d && answer.org === d.org && answer.id === d.id && answer.result)) answer = null;
return answer?.org && d && answer.org === d.org && answer.id === d.id ? decideHtml() : '';
}
// The receipt stays on screen after the answered row leaves the list.
const inboxAnswered = key => answer?.org && answer.result && `${answer.org}:${answer.id}` === key ? decideHtml() : '';
async function inboxView(get) {
await configRead;
if(!hireDraft.business && window.MosaicOrg?.id)hireDraft.business=window.MosaicOrg.id;
@@ -627,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 ? `<ul id="message-list" class="waiting s1-replies">${page.rows.map(m =>
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${messageText(m)}</p></li>`).join('')}</ul>`
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(classWord(m.class))} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${messageText(m)}</p></li>`).join('')}</ul>`
: 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.`)}
<p class="small muted">${page.total} messages · ${page.unread} unread</p>${body}
@@ -820,12 +902,15 @@
return `<section aria-labelledby="b-thread-claims">${title}<div class="banner err" role="alert"><p>${why(err)}</p>${retry}</div></section>`;
}
}
async function agentsView(get) {
// SP5: an organization's scoped page leads with its rows of the system Agents table (which owns the
// heading), then the levels grid under its own h2, then held roles and threads.
const scopedTop = async (q, org, note) => `${await window.MosaicWorkspace.agentsPage(q, { scope: org })}${await window.MosaicWorkspace.agentGrid(note, undefined, { under: true })}`;
async function agentsView(get, q = new URLSearchParams(), org = null) {
let claims;
try { claims = await get('agents'); } catch (err) {
if (!window.MosaicOrg || err.code === 'signed-out') throw err;
if (refusal(err)) forget();
return `${await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
return `${org ? await scopedTop(q, org) : await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
}
let instances = null, threads = [];
if (!isTester) {
@@ -842,7 +927,7 @@
const threadSection = await threadClaimsSection(get, holders, threads, !!instances, running);
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(holders.get(c.holder_run) || c.role)}<span class="source">run ${txt(c.holder_run)}${!instances ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running'}</span></td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
// Organization mode (sample 08): the levels grid carries the page heading; held roles follow.
const top = window.MosaicOrg ? await window.MosaicWorkspace.agentGrid(readNote(get)) : head('Agents', readNote(get));
const top = !window.MosaicOrg ? head('Agents', readNote(get)) : org ? await scopedTop(q, org, readNote(get)) : await window.MosaicWorkspace.agentGrid(readNote(get));
return `${top}
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
@@ -1038,9 +1123,9 @@
document.addEventListener('change',e=>{const form=e.target.closest?.('#launch-settings-form');if(form){settingsInputs=settingDraft(form);markSettingsDraft('limits',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=$('launch-settings-reload');if(discard)discard.hidden=false;}});
document.addEventListener('click',e=>{if(e.target.id!=='launch-settings-reload')return;settingsInputs=null;settingsSnapshot=null;settingsReceipt='';last.delete('api:settings');render(true);});
document.addEventListener('submit',async e=>{
if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message+' Draft kept.';render(false);return;}settingsSaving=true;settingsReceipt='';render(false);
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}}
catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);}
if(e.target.id!=='launch-settings-form')return;e.preventDefault();if(!canSend||out()||settingsSaving)return;settingsInputs=settingDraft(e.target);let payload;try{payload=settingPayload();}catch(err){settingsReceipt=err.message+' Draft kept.';render(false);return;}settingsSaving=true;settingsReceipt='';render(false);const epoch=orgEpoch;
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){settingsReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsInputs=null;settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});settingsReceipt='Launch settings saved.';}else settingsReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again. Draft kept.':`${result?.message||'Settings refused.'} Draft kept.`;}}
catch{if(epoch!==orgEpoch)return;settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{if(epoch===orgEpoch){settingsSaving=false;if(!out())render(false);}}
});
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): each token limit as the bus's checks report it. A
// limit no switched-on check counts reads "not enforced". Reset counts it again from now. Row 126
@@ -1061,9 +1146,9 @@
document.addEventListener('click',async e=>{
const path=e.target.dataset?.limitReset;if(path===undefined||!canSend||out()||settingsSaving)return;
const revision=settingsSnapshot?.revision;if(!/^[a-f0-9]{64}$/.test(revision??''))return;
settingsSaving=true;resetReceipt='';render(false);let reread=false;
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}}
catch{resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{settingsSaving=false;if(!out())render(reread);}
settingsSaving=true;resetReceipt='';render(false);let reread=false;const epoch=orgEpoch;
try{const response=await fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){resetReceipt='Sign in again.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;if(response.ok&&/^[a-f0-9]{64}$/.test(result?.revision??'')){settingsSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});reread=true;resetReceipt='Limit reset.';}else resetReceipt=result?.sent==='unknown'?'Outcome unknown. Read current settings before trying again.':`${result?.message||'Reset refused.'}`;}}
catch{if(epoch!==orgEpoch)return;resetReceipt='Outcome unknown. Read current settings before trying again.';}finally{if(epoch===orgEpoch){settingsSaving=false;if(!out())render(reread);}}
});
function tokenUsage(value) {
if (!value?.total) return '<section class="panel" aria-labelledby="token-usage-title"><h2 id="token-usage-title">Token usage</h2><p>Usage unavailable. No count was read.</p></section>';
@@ -1118,9 +1203,9 @@
}
const unknownAssistant='Outcome unknown. Read current settings before trying again. Draft kept.';
async function assistantPost(url,payload,instance,ok,kind='tester'){
if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false);
try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}}
catch{assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);}
if(kind==='tester')assistantBusy=true;else assistantFormsBusy.add(assistantKey(instance,kind));assistantReceiptKinds[instance]=kind;assistantReceipts[instance]='';if(kind!=='tester')assistantCardReceipts[assistantKey(instance,kind)]='';render(false);const epoch=orgEpoch;
try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}}
catch{if(epoch!==orgEpoch)return;assistantReceiptKinds[instance]=kind;assistantReceipts[instance]=unknownAssistant;}finally{if(epoch===orgEpoch){if(kind==='tester')assistantBusy=false;else{assistantCardReceipts[assistantKey(instance,kind)]=assistantReceipts[instance];assistantFormsBusy.delete(assistantKey(instance,kind));}if(!out())render(false);}}
}
const assistantForm=el=>el?.closest?.('.assistant-form');
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=assistantForm(e.target);if(!form)return;const instance=form.dataset.instance,field=e.target.dataset.assistantField;if(field==='name')nameDrafts[instance]=e.target.value;else if(field)assistantDrafts[instance]={...Object.fromEntries(['definition','vars','tracker'].map(f=>[f,form.querySelector(`[data-assistant-field="${f}"]`).value]))};if(field){markSettingsDraft('assistant',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=form.querySelector('[data-assistant-discard]');if(discard)discard.hidden=false;}});
@@ -1185,11 +1270,11 @@
const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!(canSend || isTester)||out()||!instanceView)return;
const op=form.dataset.op;if(instanceBusyOps.has(op))return;instanceReceiptOp=op;instanceDrafts[op]=instanceFields(form);let payload;
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;}
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);const epoch=orgEpoch;
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;}
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}
catch{if(epoch!==orgEpoch)return;instanceReceipt=unknownInstance;}finally{if(epoch===orgEpoch){instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}}
});
// Row 65 (mos-0840): the routing table. One row per instance: harness, model and thinking, and
// the fallback's three. Each cell offers the switched-on harnesses, the model table's keys and the
@@ -1215,8 +1300,8 @@
const routesApplyMarkup=()=>routesApply?.length?`<ul class="routes-apply" aria-label="How the change reaches each agent">${routesApply.map(a=>`<li><b>${txt(a.name)}</b>: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}</li>`).join('')}</ul>`:'';
let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false;
function readHarnesses(){
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;
fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);});
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;const epoch=orgEpoch;
fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(epoch!==orgEpoch)return;if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{if(epoch!==orgEpoch)return;harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{if(epoch!==orgEpoch)return;harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);});
}
const ROUTE_CELLS=[['harness','Harness'],['model','Model'],['thinking','Thinking'],['fallback.harness','Fallback harness'],['fallback.model','Fallback model'],['fallback.thinking','Fallback thinking']];
const routeCells=r=>({harness:r.harness??'',model:r.model??'',thinking:r.thinking??'','fallback.harness':r.fallback?.harness??'','fallback.model':r.fallback?.model??'','fallback.thinking':r.fallback?.thinking??''});
@@ -1293,32 +1378,32 @@
if(harnessSettings.revision!==routesView?.revision){harnessReceipt='The business changed. Reload this page before saving harnesses.';render(false);return;}
const used=old.filter(n=>!selected.includes(n) && Object.values(routesView.instances).some(r=>r.harness===n || r.fallback?.harness===n));
if(used.length){harnessReceipt=`A saved route uses ${used.join(', ')}. Change the route before switching it off.`;render(false);return;}
harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt='';
try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');}
catch{harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;}
finally{harnessBusy=false;if(!out())render(false);}
harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt='';const epoch=orgEpoch;
try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(epoch!==orgEpoch)return;if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');}
catch{if(epoch!==orgEpoch)return;harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;}
finally{if(epoch===orgEpoch){harnessBusy=false;if(!out())render(false);}}
});
document.addEventListener('click',async e=>{if(e.target.id!=='provider-switch'||!canSend||isTester)return;
const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;
try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.');
const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;const epoch=orgEpoch;
try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(epoch!==orgEpoch)return;if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.');
const names=Array.isArray(settings.plugins?.['model-provider'])?settings.plugins['model-provider']:[];
// The fresh read decides: a stale label never turns the proxy module on when it meant off.
const on=names.includes('cliproxy');if(String(on)!==button.dataset.enabled){button.dataset.enabled=String(on);button.textContent=`${on?'Disable':'Enable'} model proxy catalogue`;button.disabled=false;status.textContent='The switch now shows the current setting. Press it again to change it.';return;}
const next=on?names.filter(n=>n!=='cliproxy'):[...names,'cliproxy'];
const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});
if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
}catch(error){status.textContent=error.message;button.disabled=false;}
if(epoch!==orgEpoch)return;if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
}catch(error){if(epoch!==orgEpoch)return;status.textContent=error.message;button.disabled=false;}
});
document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);});
document.addEventListener('submit',async e=>{
if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return;
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
routesBusy=true;routesReceipt='';routesApply=null;render(false);
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));}
routesBusy=true;routesReceipt='';routesApply=null;render(false);const epoch=orgEpoch;
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;const applied=await routesApplyRead(Object.keys(payload.routes));if(epoch!==orgEpoch)return;routesApply=applied;}
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'}${catalogueStep(result?.error,payload.routes)} Draft kept.`;}}
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
catch{if(epoch!==orgEpoch)return;routesReceipt=unknownRoutes;}finally{if(epoch===orgEpoch){routesBusy=false;if(!out())render(false);}}
});
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
// the same instances read. A refresh drops that read so the next render asks the bus again.
@@ -1529,6 +1614,7 @@
if (!s || !saveable(s)) return;
const sent = { project: s.project, path: s.path, base: s.commit, content: s.text }, unknown = 'Outcome unknown: the save may have gone through. Copy your text, then reload the file to check before saving again. Draft kept.';
s.saving = true; s.status = '';
const epoch = orgEpoch;
let res = null, body = null;
try {
paintDoc();
@@ -1540,13 +1626,16 @@
} else if (body?.sent === false) s.status = `${typeof body.message === 'string' && body.message ? body.message : 'Not saved.'} Draft kept.`;
else s.status = unknown;
} finally { s.saving = false; } // a throw above must not leave Save off and Reload refused
// #1873 SP1: a switch while this was in flight cleared the drafts; the old file's outcome is not
// painted or announced on the new organization's page.
if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; }
paintDoc();
announce(s.status);
if (res?.type === 'opaqueredirect') signOut();
});
// Not found and states
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'}</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="${window.MosaicOrg ? '#/dashboard' : '#/'}">Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'}</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
const loadingView = (source = null) => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
@@ -1601,17 +1690,43 @@
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
if (parts.includes(null)) return notFound();
const [a, b, c] = parts, n = parts.length;
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
// #1873 SP1: `#/dashboard` is the system dashboard, every organization on one page.
// 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();
if (a === 'inbox' && n === 1 && window.MosaicOrg) return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { hire: hireStartHtml }));
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard(undefined, { q, configIn: orgConfig });
// SP3: #/inbox is every organization's items; #/orgs/<id>/inbox is that list for one, with its hire form.
if (a === 'inbox' && n === 1 && window.MosaicOrg) {
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { scope: org, hire: org ? hireStartHtml : null, hireIn: org ? null : decidesIn, answer: d => inboxAnswer(d, current), answered: inboxAnswered }));
}
// SP4: #/tasks is every organization's tasks on one board; #/orgs/<id>/tasks is that board for one.
if (a === 'tasks' && n === 1 && window.MosaicOrg) {
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
return configRead.then(() => window.MosaicTasksBoard.view(q, { scope: org, writesIn: tasksIn }));
}
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'messages' && n === 1) return messagesView(get, q);
if (a === 'tasks' && n === 1) return tasksView(get);
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
// SP5: #/agents is every organization's agents; #/orgs/<id>/agents is that table for one, above its levels grid and held roles.
if (a === 'agents' && n === 1 && window.MosaicOrg) {
const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1];
let org = null; try { org = scope ? decodeURIComponent(scope) : null; } catch { return notFound(); }
if (!org) return configRead.then(() => window.MosaicWorkspace.agentsPage(q, { scope: null, fireIn: decidesIn, configIn: orgConfig }));
return agentsView(get, q, org);
}
if (a === 'agents' && n === 1) return agentsView(get);
// SP9 (#1873): #/orgs/<id>/workflows lists an organization's workflows; #/orgs/<id>/workflows/<name> 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');
@@ -1626,7 +1741,15 @@
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 === '/projects' || path === '/inbox');
// SP12: the organization page repaints in place too when only its query changes (the agent drawer).
const drawerPage = path => wsPage(path) || (path === '/organization' && !!window.MosaicOrg);
// A hash change that only opens, closes or switches the agent drawer is a repaint, not a navigation.
const agentOnly = (from, to) => {
const split = u => { const h = String(u || '').split('#')[1] ?? ''; const [p, q = ''] = h.split('?'); const n = new URLSearchParams(q); const agent = n.get('agent'); n.delete('agent'); n.sort(); return { p, rest: n.toString(), agent }; };
const a = split(from), b = split(to);
return a.p === b.p && a.rest === b.rest && a.agent !== b.agent && /^\/(agents|(orgs\/[^/?]+\/)?organization)$/.test(b.p);
};
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 <details> 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`).
@@ -1676,14 +1799,22 @@
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
clearTimeout(timer);
if (out()) return;
// #1873 SP1: an address with no organization waits for the first list read to pick one, and a
// page drawn right after a switch waits for the new organization's config.
if ((window.MosaicOrg && !window.MosaicOrg.id) || switchRead) {
const g0 = ++gen, waiting = switchRead;
await window.MosaicOrg?.ready; await waiting;
if (waiting && switchRead === waiting) switchRead = null;
if (g0 !== gen || out()) return;
}
const route = routeOf(), [path, query = ''] = route.split('?'), board = window.MosaicOrg ? path === '/board' : path === '/' || path === '' || path === '/board';
// On the cross-organization pages a filter or selection changes only the query: that is a
// repaint of the same page, not a navigation (no loading view, focus and scroll kept).
const samePage = wsPage(path) && typeof shown === 'string' && shown.split('?')[0] === path;
const samePage = drawerPage(path) && typeof shown === 'string' && shown.split('?')[0] === path;
if (path !== '/settings/model-proxy' && proxyCleanup) {proxyCleanup();proxyCleanup=null;}
if ((navigated || path !== '/settings/organizations') && orgCleanup) {orgCleanup();orgCleanup=null;}
// Leaving a decision discards its confirmation immediately, including a preview in flight.
if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null;
if (answer && (navigated || path !== (answer.org ? '/inbox' : `/inbox/${answer.id}`))) answer = null;
const g = ++gen, routeVersion = routesEditVersion;
const current = () => g === gen && route === routeOf() && !out();
// In organization mode #/ is the cross-organization dashboard; the processing controls stay on the board.
@@ -1696,13 +1827,17 @@
window.MosaicChat?.leave(chatPath);
if (path === '/business') { location.replace('#/settings/business'); return; }
if (path === '/settings/display') { location.replace('#/settings/appearance'); return; }
// U5: Side by side draws its own two panes (panes.js); every other page stops their polling.
if (path !== '/side-by-side') window.MosaicPanes?.unmount();
else if (window.MosaicOrg && window.MosaicPanes) {
// #1873 SP6: the Assistant page draws its own thread panes (assistant.js); every other page
// stops their polling. It is a system page: an organization's address for it, and the old
// Side by side address, land on `#/assistant`.
if (window.MosaicOrg && window.MosaicAssistant && path === '/side-by-side') { location.replace('#/assistant'); return; }
if (path !== '/assistant') window.MosaicAssistant?.unmount();
else if (window.MosaicOrg && window.MosaicAssistant) {
if (!window.MosaicOrg.system()) { location.replace(`#/assistant${query ? `?${query}` : ''}`); return; }
const fresh = navigated || shown !== path;
shown = path;
if (fresh) { unmountEditor(); await window.MosaicPanes.mount($('bus-view'), query, { navigated }); }
else window.MosaicPanes.refresh();
if (fresh) { unmountEditor(); await window.MosaicAssistant.mount($('bus-view'), query, { navigated }); }
else window.MosaicAssistant.refresh(query);
return;
}
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
@@ -1737,19 +1872,25 @@
// The board page keeps its own title; the Inbox count still follows the bus.
if (shown !== null) { shown = null; unmountEditor(); $('bus-view').innerHTML = ''; document.title = 'Dashboard · Mosaic Console'; if (navigated) $('main').focus(); }
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
const epoch = orgEpoch;
const [inbox, agents, messages, tasks, queue, business, projects] = await Promise.allSettled([
fetchRead('inbox'), fetchRead('agents'), fetchRead('messages'), fetchRead('tasks'), fetchApi('queue'), fetchApi('business'),
fetch('/api/chat/projects', { cache: 'no-store', redirect: 'manual' }).then(async r => { if (r.type === 'opaqueredirect') { signOut(); throw signedOut(); } const body = await r.json(); if (signInRequired(r, body)) { signOutSso(); throw signedOut(); } if (!r.ok || !Array.isArray(body.rows)) throw new ReadError(body?.error, body?.message, r.status); return body; })
]);
const failed = [inbox, agents, messages, tasks].filter(s => s.status === 'rejected').map(s => s.reason);
if (failed.some(err => err.code === 'signed-out')) return;
// #1873 SP1: an answer read for an organization that is no longer open is not kept.
if (epoch !== orgEpoch) return;
if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
if (agents.status === 'fulfilled') last.set('agents', agents.value);
if (messages.status === 'fulfilled') last.set('messages', messages.value);
const refused = failed.find(refusal);
if (refused) forget();
if (g !== gen) return;
if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
// SP3 (#1873): with organizations the sidebar count is every organization's, from the same
// workspace read as #/inbox, never this one organization's inbox.
if (window.MosaicOrg) window.MosaicWorkspace?.inboxCount?.();
else if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
if (messages.status === 'fulfilled' && !refused) messageCount = messages.value.unread;
const good = result => !refused && result.status === 'fulfilled' ? result.value : null;
const taskRows = good(tasks)?.rows, queueRows = good(queue)?.rows, projectRows = good(projects)?.rows;
@@ -1810,6 +1951,7 @@
else if (focusSend && $('send-body')) { $('send-body').focus(); announce(`Message to ${draft.to}`); }
else if (navigated && h1) { const target=settingsFocusDisclosure&&path.startsWith('/settings/') ? $('settings-disclosure') : path.startsWith('/settings/') ? ($('settings-page-title')||h1) : h1; settingsFocusDisclosure=false; target.focus(); announce(target.textContent); }
focusSend = false;
window.MosaicWorkspace?.afterPaint?.();
schedule();
}
@@ -1861,7 +2003,7 @@
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
// back where it was. It only navigates.
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Assistant'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ...(window.MosaicAssistant ? [['#/assistant', 'Assistant'], ['#/messages', 'Messages']] : [['#/messages', 'Assistant']]), ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
function pkItems() {
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
@@ -1883,7 +2025,9 @@
if (d.open) return;
pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = '';
pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus();
Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => {
const epoch = orgEpoch;
Promise.allSettled(['inbox', 'tasks'].map(async v => { const r = await fetchRead(v); if (epoch === orgEpoch) last.set(v, r); })).then(rs => {
if (epoch !== orgEpoch) return;
if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); }
if (d.open) { pkItems(); pkFill(); }
});
@@ -1923,11 +2067,13 @@
if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in', message: 'signed out; reload the page to sign in again' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
sending = true; receipt = null; paintSend();
const epoch = orgEpoch;
let res = null, body = null;
try {
res = await fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });
try { body = await res.json(); } catch { body = null; }
} catch { res = null; }
if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; }
// Sent: an id came back. Not sent: a 4xx (refused before the bus wrote), the server's
// sent:false, or a redirect, which only the sign-in proxy gives (an expired session), so the
// request never reached the Console. Anything else, no answer included, may have been sent,
@@ -1958,7 +2104,7 @@
// F3: `settingsSend` lets the owner of an organization Console save Settings on the selected
// organization's bus; messaging a role still follows `send` (canMessage) and `chatSend`.
// A bus restart reads the config again and sends it here as a mosaic-config event.
const applyConfig = c => {
const applyConfig = (c, opts = {}) => {
isTester = c?.tester === true;
canEdit = !isTester && c?.edit === true;
canSend = !isTester && (c?.send === true || c?.settingsSend === true);
@@ -1975,20 +2121,50 @@
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
$('main').prepend(banner);
}
if (!canSend && !isTester && !ssoOn) return;
if (opts.quiet || (!canSend && !isTester && !ssoOn)) return;
const path = routeOf().split('?')[0];
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr);
};
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => { applyConfig(c); return c; }).catch(() => null);
// SP14 (#1873): who is signed in, from this same read, for what the page keeps per person
// (assistant.js's kept pane messages). null when the read failed or named nobody.
window.MosaicViewer = configRead.then(c => (typeof c?.viewer === 'string' && c.viewer ? c.viewer : null));
// E1 (P1-5): after Restart bus the organization dashboard draws its bus line now, not at the next poll.
// E2E6 P2-2: a write on the organization page that changes other sections (a pack apply) asks for a
// fresh read of the page; editors in progress are kept by holdLive.
// Ruling 2026-11-14: an apply, hire or fire redraws the Agents page in the same response cycle, not at the next poll.
document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); });
// SP5: the system Agents page shows every organization, so a change in any of them redraws it.
document.addEventListener('mosaic-org-changed', e => { if (!window.MosaicOrg) return; const path = routeOf().split('?')[0]; if ((path === '/agents' && window.MosaicOrg.system()) || (e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(path))) render(false); });
// #1873 SP1: another organization opened in place (display.js, before this file's hashchange
// render). Nothing read, drafted or confirmed for the old one carries over; the new one's config
// is read before the next page draws. A write already sent finishes against the old organization,
// and its answer is not drawn on the new one.
document.addEventListener('mosaic-org-switch', () => {
// Writes still in flight keep going to the old organization; their answers are dropped (orgEpoch)
// and every busy flag and receipt starts clean here.
orgEpoch++;
sending = false; receipt = null; replies = [];
hireBusy = false; fireBusy = false; settingsSaving = false; resetReceipt = '';
assistantBusy = false; assistantFormsBusy.clear(); instanceBusyOps = new Set();
routesBusy = false; routesApply = null; harnessBusy = false; harnessLoading = false;
forget(); clearTimeout(timer);
answer = null; heldErr = null; heldHtml = null; readAt = null; shown = null; projectContext = null;
draft.to = ''; draft.body = '';
hireDraft = { op: 'hire', level: 'org' }; hireTemplates = []; hireStatus = ''; hireCode = '';
fireAsk = null; fireReceipt = null;
settingsSnapshot = null; settingsInputs = null; settingsReceipt = ''; settingInvalidKey = null;
assistantDrafts = Object.create(null); nameDrafts = Object.create(null); assistantReceipts = Object.create(null); assistantReceiptKinds = Object.create(null); assistantCardReceipts = Object.create(null);
instanceView = null; instanceDrafts = { add: null, change: null }; instanceReceipt = ''; instanceReceiptOp = null; instanceReceipts = Object.create(null);
routesView = null; routesDraft = null; routesReceipt = ''; routesEditVersion++;
harnessSettings = null; harnessDraft = null; harnessReceipt = '';
// A file draft is keyed by project and path only; the page reload that used to drop it is gone.
docKey = null; docStates.clear(); pkAll = []; pkShown = []; pkAt = 0;
configRead = switchRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => applyConfig(c, { quiet: true })).catch(() => {});
});
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
// The cross-organization pages ask for a repaint after a filter, sort or selection change,
// and report the inbox count across organizations for the sidebar.
document.addEventListener('mosaic-workspace-render', () => { if (wsPage(routeOf().split('?')[0])) render(false); });
document.addEventListener('mosaic-workspace-render', () => { if (drawerPage(routeOf().split('?')[0])) render(false); });
document.addEventListener('mosaic-workspace-inbox', e => { const d = e.detail; if (d && Number.isFinite(d.open)) { inboxCount = { open: d.open, blocking: Number.isFinite(d.blocking) ? d.blocking : 0 }; sections(routeOf().split('?')[0]); } });
// This context comes from the conversations renderer after its authorized read.
let projectContext = null;
@@ -2065,7 +2241,7 @@
});
nav.addEventListener('click', e => { if (drawerOpen && e.target.closest('a[href^="#/"]')) drawer(false, false); });
window.addEventListener('resize', () => { if (drawerOpen && innerWidth >= 760) drawer(false, false); if (drawerOpen) $('nav-overflow').hidden = nav.scrollHeight <= nav.clientHeight + 1; });
window.addEventListener('hashchange', () => render(true));
window.addEventListener('hashchange', e => render(!agentOnly(e.oldURL, e.newURL)));
$('refresh').addEventListener('click', () => render(false));
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
render(false).then(() => { if (!$('bus-view').hidden) { const h1 = $('bus-view').querySelector('h1'); if (h1 && location.hash.startsWith('#/')) h1.focus(); } });
+126 -64
View File
@@ -1,6 +1,11 @@
/* Row 129: business chat, isolated from the Board's seat conversations and role composer. */
(() => {
'use strict';
// #1873 SP6: one chat view, more than one instance. The page's own instance (no env) is the chat
// as it always was. An Assistant pane makes its own instance with env.org: its reads and writes
// name that organization in the address, so a send always reaches the pane's own organization,
// whichever organization the page last opened, and its listeners answer only inside its container.
function chatInstance(env = {}) {
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// crypto.randomUUID exists only in a secure context; a dev owner Console opened by LAN name
// (http://NAME:PORT/) is not one, so build the same v4 form from getRandomValues there.
@@ -18,6 +23,14 @@
// E2E6 P2-15: a message's `inline code` spans show as code, not as raw backticks. The text is escaped
// first; a pair of single backticks on one line then wraps its already-escaped contents in <code>.
const prose = value => txt(value).replace(/`([^`\n]+)`/g, '<code>$1</code>');
const pane = typeof env.org === 'string', life = new AbortController();
const on = (type, fn, capture = false) => document.addEventListener(type, fn, { capture, signal: life.signal });
// A pane prefixes its element ids so two panes never share one; handlers read them back unprefixed.
const idOf = el => { const id = typeof el?.id === 'string' ? el.id : ''; return pane && id.startsWith(env.prefix) ? 'chat-' + id.slice(env.prefix.length) : id; };
// A pane's ids carry its prefix; byId takes the page's own 'chat-…' name.
const byId = id => document.getElementById(pane && id.startsWith('chat-') ? env.prefix + id.slice(5) : id);
const pid = id => (pane && id.startsWith('chat-') ? env.prefix + id.slice(5) : id);
const mine = target => pane ? !!current?.container.contains(target) : !target?.closest?.('[data-assist-pane]');
const states = new Map(), forms = new Map(), knownRequests = new Map();
const pinnedScrolls = new WeakMap(), settlingPins = new WeakMap();
const pageFlow = log => log.closest('.project-chat') && matchMedia('(max-width: 319px), (max-height: 540px)').matches;
@@ -27,7 +40,7 @@
? (pageTailTarget(log)?.getBoundingClientRect().bottom ?? 0) - innerHeight < 40 || messageEnd && Math.abs((log.lastElementChild?.getBoundingClientRect().bottom ?? 0) - innerHeight) < 40
: log.scrollHeight - log.scrollTop - log.clientHeight < 40;
const pageTailTarget = log => {
const form = log.closest('.project-chat')?.querySelector('#chat-send-form, #chat-room-send-form');
const form = log.closest('.project-chat')?.querySelector(`#${pid('chat-send-form')}, #${pid('chat-room-send-form')}`);
const field = form?.querySelector('textarea');
const usable = current && (!field?.disabled && !current.channelDisabled || state(current.key ?? current.id).pending);
return form && field && usable && form.getClientRects().length ? form : log.lastElementChild;
@@ -69,8 +82,7 @@
const channelMessage = 'The chat channel is disabled for this business. Ask the person who manages it to enable WebUI chat, then refresh.';
function forget() {
generation++; controller?.abort(); states.clear(); forms.clear(); knownRequests.clear(); libraries.clear(); reverts.clear();
window.MosaicRequests?.forget();
window.MosaicProjectArchive?.forget();
if (!pane) { window.MosaicRequests?.forget(); window.MosaicProjectArchive?.forget(); }
if (current) current.container.replaceChildren();
current = null;
}
@@ -78,11 +90,21 @@
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;
try {
res = await fetch('/api/chat/' + path, {
res = await fetch((pane ? `/api/orgs/${encodeURIComponent(env.org)}/chat/` : '/api/chat/') + path, {
cache: 'no-store', redirect: 'manual', signal,
...(value === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) }),
});
@@ -104,12 +126,14 @@
}
// In organization mode #/projects is the cross-organization page, so the organization's own
// project list lives at #/chat. That list keeps the project surface and its #/projects links.
const legacyRoute = () => { const r = window.MosaicOrg?.route() ?? location.hash.slice(1); return r.startsWith('/chat') && !(window.MosaicOrg && r.split('?')[0] === '/chat'); };
const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `#/projects/${id}`;
const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `#/projects/threads/${id}`;
const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `#/projects/rooms/${id}`;
const orgBase = pane ? '#/orgs/' + encodeURIComponent(env.org) : '#';
const legacyRoute = () => { if (pane) return false; const r = window.MosaicOrg?.route() ?? location.hash.slice(1); return r.startsWith('/chat') && !(window.MosaicOrg && r.split('?')[0] === '/chat'); };
const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `${orgBase}/projects/${id}`;
const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `${orgBase}/projects/threads/${id}`;
const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `${orgBase}/projects/rooms/${id}`;
const link = (type, id, label) => UUID.test(id) ? `<a href="${type === 'projects' ? projectPath(id) : threadPath(id)}">${txt(label)}</a>` : txt(label);
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
// A pane's title is its own header; the page keeps its one h1.
const heading = text => pane ? `<p class="chat-pane-state"><strong>${txt(text)}</strong></p>` : `<h1 tabindex="-1">${txt(text)}</h1>`;
const selectedInactive = () => { const s = current?.sessions?.find(r => r.id === current.session); return !!s?.state && s.state !== 'active'; };
const disabledSession = pending => disabled(pending || selectedInactive());
const disabled = pending => !current.canSend || current.orgArchived || out() || pending ? ' disabled' : '';
@@ -199,7 +223,8 @@
return `<div class="chat-library"><p role="status">${parts.join(' · ')}</p>${list}</div>`;
}
async function loadLibrary(project, g, signal) {
if (project === null) return;
// The library routes read the page's organization, not a pane's: a pane shows no library line.
if (project === null || pane) return;
let entry;
try {
const res = await fetch('/api/library/status?project=' + encodeURIComponent(project), { cache: 'no-store', redirect: 'manual', signal });
@@ -241,6 +266,8 @@
const REQUEST_ROWS = 3;
// The request panel, Files and summary each read independently. Share only confirmed
// request-not-found responses while a thread is open, without changing other fetch paths.
// Panes mount no request panels, so only the page's instance wraps fetch.
if (!pane) {
const fetchPage = window.fetch.bind(window);
window.fetch = async (url, init) => {
const path = typeof url === 'string' && /^\/api\/requests\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/workspace)?$/.exec(url);
@@ -260,11 +287,12 @@
}
return response;
};
}
// A request read elsewhere on this page (any requests.js panel) proves the id now exists, so
// every open thread forgets that miss and the next refresh reads it again. The id's own
// generation also advances, so a 404 still in flight from before this observation cannot
// record the miss after it. Only that id moves; other ids' slow misses still settle.
document.addEventListener('mosaic-request-state', event => {
on('mosaic-request-state', event => {
const id = event.detail?.state?.request;
if (typeof id !== 'string' || out()) return;
for (const s of states.values()) {
@@ -274,7 +302,7 @@
});
function requestRows(rows) {
const ids = new Set();
if (!window.MosaicRequests || !roomOwner()) return ids;
if (pane || !window.MosaicRequests || !roomOwner()) return ids;
for (let i = rows.length - 1; i >= 0 && ids.size < REQUEST_ROWS; i--) {
const m = rows[i];
if (m.from_role === 'human' && typeof m.request === 'string' && REQUEST_ID.test(m.request)) ids.add(m.id);
@@ -286,7 +314,7 @@
const withRequest = requestRows(s.rows);
const receipt = s.receipt;
const project = current.projects.find(p => p.id === t.project);
const redesigned = project && roomOwner() && window.MosaicProjects && (!legacyRoute() || current.path?.startsWith('/projects'));
const redesigned = !pane && project && roomOwner() && window.MosaicProjects && (!legacyRoute() || current.path?.startsWith('/projects'));
// Keep each request tied to its originating message while placing its card after the first reply.
const mountsAfter = new Map();
if (redesigned) s.rows.forEach((m, i) => {
@@ -297,9 +325,13 @@
});
const mount = m => `<div class="request-mount" data-request="${esc(m.request)}" data-origin-message="${esc(m.id)}"></div>`;
const messages = s.rows.map((m, i) => `<li class="${m.from_role === 'human' ? 'chat-message-human' : 'chat-message-agent'}" data-message="${esc(m.id)}"><div class="chat-message-meta"><span class="chat-avatar" aria-hidden="true">${txt(String(m.from_role || '?').slice(0, 1).toUpperCase())}</span><strong>${txt(m.from_role === 'human' ? 'You' : m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}</li>${redesigned ? (mountsAfter.get(i) || []).map(origin => `<li class="project-request-item">${mount(origin)}</li>`).join('') : ''}`).join('');
const frame = redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name);
const details = redesigned ? `<dialog class="chat-details project-dialog" id="project-conversation-dialog" aria-labelledby="project-conversation-title"><h2 id="project-conversation-title" tabindex="-1">Conversation details</h2><button class="btn" type="button" data-dialog-close>Close</button>${sessionControls(t)}${sharing(t)}<p>Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}</dialog>` : `<details class="chat-details"><summary id="chat-details-toggle">Conversation details</summary>${sessionControls(t)}${sharing(t)}</details><p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}`;
return `<section class="chat-conversation${redesigned ? ' project-chat' : ''}" data-project="${esc(t.project)}" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '<p class="small muted" data-conversation-request-status></p>' : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '<button type="button" class="btn" data-chat-retry>Read again</button>' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message ${txt(t.instance)}</label><textarea id="chat-body" rows="4" placeholder="${redesigned ? `Message ${esc(t.instance)}` : 'Write a message…'}"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${redesigned ? `<span class="room-chip" aria-label="Recipient ${esc(t.instance)}">${txt(t.instance)}</span>` : `${txt(t.instance)} · Private conversation`}</p>${redesigned ? '<p class="project-composer-hint">Enter adds a new line.</p>' : ''}<button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p>${redesigned ? '' : '<section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div>'}</section>`;
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.
// SP11: Resume shows unless the instances read says the agent is running.
const resumable = threadWrites() && current.agentIdle !== false;
const resume = resumable ? `<button class="btn${pane ? ' pane-resume' : ''}" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : '';
const details = pane ? `<p class="muted small">Reading never wakes the assistant. An accepted message is not an answer.</p>` : redesigned ? `<dialog class="chat-details project-dialog" id="project-conversation-dialog" aria-labelledby="project-conversation-title"><h2 id="project-conversation-title" tabindex="-1">Conversation details</h2><button class="btn" type="button" data-dialog-close>Close</button>${sessionControls(t)}${sharing(t)}<p>Reading never wakes the assistant. An accepted message is not an answer.</p>${resumable ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}</dialog>` : `<details class="chat-details"><summary id="chat-details-toggle">Conversation details</summary>${sessionControls(t)}${sharing(t)}</details><p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${resumable ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}`;
return `<section class="chat-conversation${redesigned ? ' project-chat' : ''}" data-project="${esc(t.project)}" data-thread="${esc(t.id)}" data-session="${esc(current.session)}">${pane ? '' : `<p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>`}${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '<p class="small muted" data-conversation-request-status></p>' : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '<button type="button" class="btn" data-chat-retry>Read again</button>' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message ${txt(t.instance)}</label><textarea id="chat-body" rows="4" placeholder="${redesigned ? `Message ${esc(t.instance)}` : 'Write a message…'}"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${redesigned ? `<span class="room-chip" aria-label="Recipient ${esc(t.instance)}">${txt(t.instance)}</span>` : `${txt(t.instance)} · Private conversation`}</p>${redesigned ? '<p class="project-composer-hint">Enter adds a new line.</p>' : ''}<button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p>${pane ? resume : ''}${redesigned || pane ? '' : '<section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div>'}</section>`;
}
// C2-10c: rooms reuse the same state, renderer and generation fences as threads.
const ROOM_LINES = Object.freeze({
@@ -475,19 +507,20 @@
if (err.code === 'channel-disabled' && current?.key === c.key) current.channelDisabled = true;
} finally { s.pending = false; if (current?.key === c.key && !out()) paint(); }
}
document.addEventListener('keydown', event => {
if (current?.kind !== 'room' || event.target.id !== 'chat-room-body' || !current.completion?.items.length) return;
on('keydown', event => {
if (!mine(event.target)) return;
if (current?.kind !== 'room' || idOf(event.target) !== 'chat-room-body' || !current.completion?.items.length) return;
const c = current.completion;
if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); c.index = (c.index + (event.key === 'ArrowDown' ? 1 : c.items.length - 1)) % c.items.length; drawRoomCompletion(); }
if (['Enter', 'Tab'].includes(event.key)) { event.preventDefault(); completeRoom(c.items[c.index].instance); }
if (event.key === 'Escape') { event.preventDefault(); current.completion = null; drawRoomCompletion(); }
});
document.addEventListener('mousedown', event => { if (event.target.closest?.('[data-room-complete]')) event.preventDefault(); });
on('mousedown', event => { if (event.target.closest?.('[data-room-complete]')) event.preventDefault(); });
function capture() {
if (!current) return null;
const active = document.activeElement, log = current.container.querySelector('.chat-log');
const field = active?.id?.startsWith('chat-') ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null;
const field = idOf(active).startsWith('chat-') && current.container.contains(active) ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null;
const dialog = current.container.querySelector('dialog.project-dialog[open]');
const dialogValues = dialog ? [...dialog.querySelectorAll('input[id],select[id],textarea[id]')].map(e=>({id:e.id,value:e.value,checked:e.checked})) : [];
const dialogFocus = dialog?.contains(active) ? { id:active.id, index:[...dialog.querySelectorAll('button,a,input,select,textarea,[tabindex]')].indexOf(active), value:'value' in active ? active.value : null, start:active.selectionStart, end:active.selectionEnd } : null;
@@ -512,7 +545,7 @@
// Refreshes often read exactly the same view. Remember the generated presentation,
// not the mutable DOM (which also contains independently mounted request controls).
function refreshProjectData(c, redesigned, restore) {
if (c.user || c.tester || !window.MosaicProjects) return;
if (pane || c.user || c.tester || !window.MosaicProjects) return;
const project = c.projects.find(p => p.id === c.id);
const projectContent = c.container.querySelector('[data-project-content]');
if (redesigned && (c.kind === 'project' || c.kind === 'thread' || c.kind === 'room')) {
@@ -526,6 +559,7 @@
// visible view is unchanged: a request's phase moves without a new message. A quiet reread
// keeps the present content until a changed result arrives.
function refreshRequestData(c, quiet) {
if (pane) return;
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
const t = c.kind === 'thread' ? c.thread : null;
const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
@@ -562,7 +596,7 @@
});
for (let i = 0; i < desired.length; i++) if (liveLog.children[i] !== desired[i]) liveLog.insertBefore(desired[i], liveLog.children[i] || null);
while (liveLog.children.length > desired.length) liveLog.lastElementChild.remove();
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
if (!pane) window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
const s = state(c.key ?? c.id);
if (s.tailRequested || s.follow && ((keep?.tail ?? true) || keep?.settlingPin)) pinTranscript(liveLog);
else if (keep?.top !== undefined) { if (pageFlow(liveLog)) window.scrollTo(0, keep.top); else liveLog.scrollTop = keep.top; }
@@ -604,8 +638,8 @@
function paint(navigated = false, read = false) {
if (!current || out()) return;
const keep = capture(), c = current;
const redesigned = (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester;
let html = '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
const redesigned = !pane && (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester;
let html = pane ? '<div class="chat-area">' : '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
if (c.orgArchived) html += `<p role="status">${c.orgArchived === 'room-not-open' ? 'This room is not open.' : 'This organization is archived.'} Sending is unavailable; your draft is kept.</p>`;
if (c.error && !(c.kind === 'room' && c.error === ROOM_LINES['room-not-open'] && redesigned)) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
if (c.archiveUnknown && !c.loading) html += '<p role="status" data-archive-notice>Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.</p>';
@@ -635,8 +669,10 @@
} else {
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
}
if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
if (!c.canSend && pane) html += '<p class="muted">Sending is unavailable in this organization. This thread only reads.</p>';
else if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
html += '</div>';
if (pane) html = html.replace(/\b(id|for|aria-controls|aria-describedby|aria-labelledby)="chat-/g, `$1="${env.prefix}`);
// Drafts already live in the focused textarea; their changing text is not a
// reason for a background read to replace the control.
const fingerprint = visualKey(html);
@@ -690,13 +726,13 @@
// which names the project as the organization file does: the chat project's repo key.
if (remote && roomOwner() && window.MosaicOrg && typeof window.MosaicProjectRepo?.mount === 'function') window.MosaicProjectRepo.mount({ container: remote, project: project?.repo ?? null, archived: !!c.projectArchived });
else if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); }
if (!window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c);
if (!pane && !window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c);
if (redesigned && c.kind === 'project' && c.projectArchived && !c.settings) {
const controls = c.container.querySelector('.project-archive-control');
if (controls) c.container.querySelector('.project-tabs')?.insertAdjacentElement('afterend', controls);
}
refreshProjectData(c, redesigned, !navigated ? keep : null);
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
if (!pane) document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
if (!navigated && keep?.toggles) [...c.container.querySelectorAll('.project-new-conversation,.project-create')].forEach((e,i)=>{e.open=!!keep.toggles[i];});
const details = c.container.querySelector('.chat-details');
if (details && keep?.detailsOpen && details.tagName === 'DETAILS') details.open = true;
@@ -736,7 +772,7 @@
f.focus({ preventScroll: true });
if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
}
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
} else if (navigated && !pane) c.container.querySelector('h1')?.focus({ preventScroll: true });
updateRoomCompletion();
}
function readError(err) {
@@ -765,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;
@@ -773,8 +809,8 @@
// server paths lack archive guards, and the request panel's writes.
const withdraw = () => {
lastPaint = null;
for (const el of container.querySelectorAll('#chat-session-form input, #chat-session-form button, #chat-share-form input, #chat-share-form button, [data-unshare], #chat-cancel, [data-revert]')) el.disabled = true;
window.MosaicRequests?.mount?.(container, { archiveUnknown: !!current.archiveUnknown, projectArchived: !!current.projectArchived });
for (const el of container.querySelectorAll(['chat-session-form input', 'chat-session-form button', 'chat-share-form input', 'chat-share-form button', 'chat-cancel'].map(x => '#' + pid(x)).concat('[data-unshare]', '[data-revert]').join(', '))) el.disabled = true;
if (!pane) window.MosaicRequests?.mount?.(container, { archiveUnknown: !!current.archiveUnknown, projectArchived: !!current.projectArchived });
if (!current.archiveUnknown) return;
if (container.querySelector('[data-archive-notice]')) return;
const notice = document.createElement('p');
@@ -815,7 +851,8 @@
current.thread = current.threads.find(t => t.id === id);
current.shared = current.thread?.shared === true;
const projectId = kind === 'project' ? id : current.thread?.project;
if (projectId && !current.projects.some(p => p.id === projectId) && !user && !tester) {
// A pane leaves an unlisted project's archive state unknown: the archive lookup reads the page's organization.
if (projectId && !current.projects.some(p => p.id === projectId) && !user && !tester && !pane) {
try {
if (typeof window.MosaicProjectArchive?.resolve !== 'function') throw Error('archive-unavailable');
const resolved = await window.MosaicProjectArchive.resolve(projectId, signal);
@@ -830,7 +867,7 @@
current.archiveUnknown = true;
}
}
else if (projectId) current.archiveUnknown = false;
else if (projectId && (!pane || current.projects.some(p => p.id === projectId))) current.archiveUnknown = false;
current.projectArchived = current.projects.some(p => p.id === projectId && p.archived);
// A same-thread refresh kept the controls of the last resolved (live) state. If this
// lookup now finds the project archived or unknown, withdraw them before the slower
@@ -848,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;
@@ -958,7 +999,7 @@
for (const row of state(current.key).rows) if (row.from_role === 'human' && typeof row.request === 'string' && REQUEST_ID.test(row.request)) ids.add(row.request);
knownRequests.set(projectId, { repo: project.repo, ids });
}
if (project) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } }));
if (project && !pane) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } }));
}
paint(navigated, true);
const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null;
@@ -988,17 +1029,17 @@
}
}
function refresh(next = false) {
if (current) return render({ ...current, path: (window.MosaicOrg?.route() ?? location.hash.slice(1)).split('?')[0], next });
if (current) return render({ ...current, path: pane ? current.path : (window.MosaicOrg?.route() ?? location.hash.slice(1)).split('?')[0], next });
}
function leave(path) {
if (!path.startsWith('/chat') && !path.startsWith('/projects')) { generation++; controller?.abort(); current = null; }
}
document.addEventListener('input', event => {
if (!current || out()) return;
if (event.target.id === 'chat-room-body') { state(current.key).draft = event.target.value; updateRoomCompletion(); return; }
if (event.target.id === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = document.getElementById('chat-cancel'); if (button) button.disabled = !current.canSend || current.archiveUnknown || current.projectArchived || f.pending || !f.op; }
if (event.target.id === 'chat-share-target') form('share:' + current.id).to = event.target.value;
if (event.target.id === 'chat-body') state(current.key ?? current.id).draft = event.target.value;
on('input', event => {
if (!current || out() || !mine(event.target)) return;
if (idOf(event.target) === 'chat-room-body') { state(current.key).draft = event.target.value; updateRoomCompletion(); return; }
if (idOf(event.target) === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = byId('chat-cancel'); if (button) button.disabled = !current.canSend || current.archiveUnknown || current.projectArchived || f.pending || !f.op; }
if (idOf(event.target) === 'chat-share-target') form('share:' + current.id).to = event.target.value;
if (idOf(event.target) === 'chat-body') state(current.key ?? current.id).draft = event.target.value;
const f = event.target.closest('.chat-create');
if (f && event.target.dataset.chatField) {
const draft = form(f.dataset.key), field = event.target.dataset.chatField;
@@ -1010,7 +1051,7 @@
// User input cancels a pending page pin, including scrollbar dragging.
// Tab counts from any target: it moves focus and can scroll the document.
const readerInput = event => {
if (!['thread', 'room'].includes(current?.kind)) return;
if (!['thread', 'room'].includes(current?.kind) || !mine(event.target)) return;
if (event.type === 'keydown' && event.key !== 'Tab' && (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key) || event.target.closest?.('input, textarea, select, button, a, [contenteditable=true]'))) return;
if (event.type === 'pointerdown' && event.target !== document.documentElement && event.target !== document.body && !event.target.matches?.('.chat-log')) return;
const log = current.container.querySelector('.chat-log');
@@ -1024,8 +1065,8 @@
}
else if (event.key === 'Tab' || event.target === log || log.contains(event.target)) { pinnedScrolls.delete(log); settlingPins.delete(log); }
};
for (const type of ['wheel', 'touchstart', 'pointerdown', 'keydown']) document.addEventListener(type, readerInput, true);
document.addEventListener('scroll', event => {
for (const type of ['wheel', 'touchstart', 'pointerdown', 'keydown']) on(type, readerInput, true);
on('scroll', event => {
if (!['thread', 'room'].includes(current?.kind)) return;
const log = current.container.querySelector('.chat-log');
if (!log) return;
@@ -1110,25 +1151,25 @@
if (!out() && current && (isThread ? (current.key ?? current.id) === key : (action === 'createSession' ? current.id === targetThread : key === 'projects' ? current.kind === 'list' : current.id === key))) paint();
}
}
document.addEventListener('submit', event => {
if (!current || !event.target.id.startsWith('chat-')) return;
on('submit', event => {
if (!current || !mine(event.target) || !idOf(event.target).startsWith('chat-')) return;
event.preventDefault();
if (event.target.id === 'chat-room-send-form') {
if (idOf(event.target) === 'chat-room-send-form') {
void sendRoom();
} else if (event.target.id === 'chat-share-form') {
} else if (idOf(event.target) === 'chat-share-form') {
void changeShare('share', form('share:' + current.id).to ?? '');
} else if (event.target.id === 'chat-send-form') {
} else if (idOf(event.target) === 'chat-send-form') {
const id = current.id, session = current.session, key = current.key;
void write('send', key, { body: state(key).draft, ...(session ? { session } : {}) }, `threads/${id}/messages`);
} else if (event.target.id === 'chat-session-form') {
} else if (idOf(event.target) === 'chat-session-form') {
const key = 'session:' + current.id, f = form(key);
// Keep an ambiguous create's key and payload; editing requires a deliberate new attempt.
f.op ??= opId();
f.payload ??= { name: f.name, op: f.op };
void write('createSession', key, f.payload, `threads/${current.id}/sessions`);
} else if (event.target.id === 'chat-operation-form') {
} else if (idOf(event.target) === 'chat-operation-form') {
const clear = form('clear:' + current.key);
clear.op = document.getElementById('chat-operation').value;
clear.op = byId('chat-operation').value;
void checkClear(current.id, current.key, clear);
} else if (event.target.classList.contains('chat-create')) {
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
@@ -1137,9 +1178,9 @@
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
}
});
document.addEventListener('click', event => {
if (!current || out()) return;
if (event.target.id === 'chat-room-open' || event.target.closest?.('[data-room-open-inline]')) void openRoom();
on('click', event => {
if (!current || out() || !mine(event.target)) return;
if (idOf(event.target) === 'chat-room-open' || event.target.closest?.('[data-room-open-inline]')) void openRoom();
const projectRoomOpen=event.target.closest?.('[data-project-room-open]'); if(projectRoomOpen && UUID.test(projectRoomOpen.dataset.projectRoomOpen)) void openRoom(projectRoomOpen.dataset.projectRoomOpen);
const chip = event.target.closest?.('[data-room-chip]');
if (chip) { const name = chip.dataset.roomChip; document.querySelector(`[data-room-member="${CSS.escape(name)}"]`)?.click(); document.querySelector(`[data-room-chip="${CSS.escape(name)}"]`)?.focus(); }
@@ -1154,16 +1195,17 @@
const option = event.target.closest?.('[data-room-complete]');
if (option) completeRoom(option.dataset.roomComplete);
if (event.target.dataset.unshare) void changeShare('unshare', event.target.dataset.unshare);
if (event.target.id === 'chat-resume') void write('open', current.key, current.session ? { session: current.session } : {}, `threads/${current.id}/open`);
if (event.target.id === 'chat-cancel') void cancelClear();
if (event.target.id === 'chat-clear') void clearContext();
if (event.target.id === 'chat-next') void refresh(true);
if (idOf(event.target) === 'chat-resume') void write('open', current.key, current.session ? { session: current.session } : {}, `threads/${current.id}/open`);
if (idOf(event.target) === 'chat-cancel') void cancelClear();
if (idOf(event.target) === 'chat-clear') void clearContext();
if (idOf(event.target) === 'chat-next') void refresh(true);
if (event.target.closest?.('[data-chat-retry]')) void refresh();
const revertButton = event.target.closest?.('[data-revert]');
if (revertButton) void revert(revertButton.dataset.revert);
if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
if (idOf(event.target) === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
});
document.addEventListener('change', event => {
on('change', event => {
if (!mine(event.target)) return;
if (event.target.matches?.('[data-room-member]') && current?.kind === 'room') {
const field = document.getElementById('chat-room-body'), s = state(current.key);
const parsed = roomAddress(field.value);
@@ -1175,7 +1217,7 @@
s.draft = chosen.map(name => '@' + name).join(' ') + (parsed.body ? ' ' + parsed.body : ' ');
s.receipt = ''; paint(); return;
}
if (event.target.id === 'chat-session' && current && UUID.test(event.target.value)) {
if (idOf(event.target) === 'chat-session' && current && UUID.test(event.target.value)) {
location.hash = sessionPath(current.id, event.target.value);
}
});
@@ -1246,7 +1288,27 @@
if (!out() && current?.key === key) { await refresh(); paint(); }
}
}
document.addEventListener('mosaic-signed-out', () => { signedOut = true; forget(); });
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));
window.MosaicChat = Object.freeze({ render, leave, forget, projectPath, hasDraft });
// 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, draft, busy, dispose };
}
const main = chatInstance();
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
let panes = 0;
// An Assistant pane: one thread of one organization, read and sent through that organization's own routes.
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, 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 });
})();
+1 -1
View File
@@ -21,7 +21,7 @@
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null);
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.()
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicAssistant?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || window.MosaicWorkflows?.hasDraft?.()
|| document.querySelector('.settings-dot')
|| [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim()));
+140 -66
View File
@@ -1,60 +1,73 @@
window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleString() : 'unknown';
// An organization switch reloads the page, which drops every per-page cache and
// subscription. Requests already sent keep the organization captured here.
// #1873 SP1: one shell for the whole system. `#/dashboard` is the system landing route and
// `#/orgs/<id>/...` is a route inside the same page, not a mode: opening another organization
// changes the scope of later /api reads and fires `mosaic-org-switch`. Nothing reloads.
// Requests already sent keep the organization they were sent with.
(() => {
const nativeFetch = window.fetch.bind(window);
const match = /^#\/orgs\/([^/]+)(\/.*)?$/.exec(location.hash);
const id = match?.[1] ?? null;
if (!id) {
if (!document.documentElement.hasAttribute('data-organizations')) return;
const readInitialOrganizations = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => {
if (r.ok) { window.MosaicOrganizationError = null; return r.json(); }
const body = await r.json().catch(() => ({}));
window.MosaicOrganizationError = body.message || 'Organization registry could not be read';
return null;
}).catch(() => { window.MosaicOrganizationError = 'Organization registry could not be read'; return null; });
const refreshInitial = () => {
const initialOrganizations = window.MosaicInitialOrganizations = readInitialOrganizations();
initialOrganizations.then(async rows => {
if (!Array.isArray(rows) || !rows.length || location.hash.startsWith('#/orgs/')) return;
rows = rows.filter(row => !row.invalid);
if (!rows.length) return;
let preferred;
try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ }
const selected = rows.find(row => row.id === preferred) ?? rows[0];
const tail = location.hash.startsWith('#/') ? location.hash.slice(1) : '/';
history.replaceState(null, '', `#/orgs/${encodeURIComponent(selected.id)}${tail}`);
location.reload();
}).catch(() => {});
return initialOrganizations;
};
window.MosaicRefreshOrganizations = refreshInitial;
refreshInitial();
return;
}
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
// SP13: the organization segment stops at `/` or `?`, so `#/orgs/<id>?agent=…` names <id>.
const orgOf = hash => /^#\/orgs\/([^/?]+)/.exec(hash)?.[1] ?? null;
const atLoad = orgOf(location.hash);
if (!atLoad && !document.documentElement.hasAttribute('data-organizations')) return;
// current: the scoping organization as its hash segment. settled: false only while the first
// list read picks one for an address that names none. With no valid organization at all the
// page stays in its first-run mode (window.MosaicOrg reads undefined), as before.
let current = atLoad, settled = !!atLoad, markReady;
const ready = new Promise(resolve => { markReady = resolve; });
const settle = seg => { current = seg; settled = true; markReady(seg ? decodeURIComponent(seg) : null); };
// An address that names its organization is settled at load: anything waiting on ready goes on.
if (atLoad) markReady(decodeURIComponent(atLoad));
const tail = hash => { const m = /^#\/orgs\/[^/?]+(.*)$/.exec(hash); return m ? (!m[1] ? '/' : m[1][0] === '?' ? `/${m[1]}` : m[1]) : hash.startsWith('#/') ? hash.slice(1) : '/'; };
const route = () => tail(location.hash);
// The bare address and the old per-organization dashboard address are the system dashboard;
// an organization page without the prefix gets the current organization's. #1873 SP2: an
// organization's own bare address (#/orgs/<id>, no trailing slash) is that organization's page;
// #/orgs/<id>/ stays the old dashboard address. SP3: `#/inbox` is a system route too (every
// organization's items); `#/orgs/<id>/inbox` is that list, filtered. SP5: `#/agents` is every
// organization's agents; `#/orgs/<id>/agents` is that table for one organization. SP4: `#/tasks` is
// one task board for every organization; `#/orgs/<id>/tasks` is that board, scoped to one.
// SP6: `#/assistant` is the Assistant page, threads of any organization side by side.
const system = hash => { const bare = tail(hash).split('?')[0]; return bare === '/dashboard' || (!orgOf(hash) && (bare === '/inbox' || bare === '/agents' || bare === '/tasks' || bare === '/assistant')); };
const normalize = (hash, seg = current) => {
const path = tail(hash), bare = path.split('?')[0], named = /^#\/orgs\/([^/?]+)$/.exec(hash.split('?')[0])?.[1];
if (named) return `#/orgs/${named}/organization${hash.slice(hash.split('?')[0].length)}`;
if (bare === '/' || bare === '/dashboard') return `#/dashboard${path.slice(bare.length)}`;
return orgOf(hash) || !seg || system(hash) ? hash : `#/orgs/${seg}${path}`;
};
// /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path.
const unscoped = /^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/(?:levels|root)(?:\?|$))/;
window.fetch = (input, init) => {
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/(?:levels|root)(?:\?|$))/.test(input))
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
return nativeFetch(input, init);
if (typeof input !== 'string' || !input.startsWith('/api/') || unscoped.test(input)) return nativeFetch(input, init);
const send = () => nativeFetch(current ? `/api/orgs/${current}/${input.slice('/api/'.length)}` : input, init);
return settled ? send() : ready.then(send);
};
// A failed read keeps its reason, so the header can say why the list is empty.
let listError = null;
const readList = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => {
let listError = null, rowsSeen = [];
const readRows = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => {
if (r.ok) {
const body = await r.json(); listError = null;
const body = await r.json(); listError = null; window.MosaicOrganizationError = null;
const rows = Array.isArray(body) ? body : [];
// E2E5 review P2: an open panel (Settings > Organizations) follows every fresh list read, not only its own.
document.dispatchEvent(new CustomEvent('mosaic-org-list', { detail: { rows } }));
return rows;
}
const body = await r.json().catch(() => ({}));
listError = typeof body.message === 'string' && body.message ? body.message : 'Organization registry could not be read';
return [];
}).catch(() => { listError = 'Organization registry could not be read'; return []; });
// Idempotent: each read replaces the banner and the wordmark name, and removes them on recovery.
listError = window.MosaicOrganizationError = typeof body.message === 'string' && body.message ? body.message : 'Organization registry could not be read';
return null;
}).catch(() => { listError = window.MosaicOrganizationError = 'Organization registry could not be read'; return null; });
const readList = () => readRows().then(rows => rows ?? []);
// The bus banner belongs to an organization page; the dashboard and the Inbox name every bus themselves.
// #1873 SP2: the organization's own page draws its down banner with the restart action, so this one hides there.
const paintBus = () => {
const banner = document.getElementById('bus-down');
if (!banner) return;
const org = rowsSeen.find(row => current && row.id === decodeURIComponent(current) && !row.invalid), path = route().split('?')[0];
if (system(location.hash) || (path === '/organization' && org?.busHealthy === false) || (banner.dataset.orgBus && org?.busHealthy !== false)) { banner.hidden = true; delete banner.dataset.orgBus; return; }
if (org?.busHealthy === false) { banner.textContent = 'Bus unavailable'; banner.hidden = false; banner.dataset.orgBus = '1'; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); }
};
// Idempotent: each read replaces the banner, and removes it on recovery.
const paintHeader = rows => {
rowsSeen = rows;
document.getElementById('org-list-banner')?.remove();
const lines = [...(listError ? [listError] : []), ...rows.filter(row => row.invalid).map(row => `${row.id}: invalid — ${row.reason}`)];
if (lines.length) {
@@ -62,58 +75,119 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
banner.textContent = lines.join('; ');
document.body.prepend(banner);
}
// The shell no longer names one organization in its wordmark: the breadcrumb does.
document.getElementById('current-organization')?.remove();
const org = rows.find(row => row.id === decodeURIComponent(id) && !row.invalid);
const mark = document.querySelector('.wordmark');
if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); }
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } }
paintBus();
window.MosaicBreadcrumbs?.();
return rows;
};
let list = readList().then(paintHeader);
let list;
const refreshList = () => (list = readList().then(paintHeader));
window.MosaicRefreshOrganizations = refreshList;
let previous = location.hash;
const dirty = () => window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.() || [...document.querySelectorAll('#reply, #conv-reply, #doc-source')].some(el => !!el.value?.trim());
function change(next) {
history.replaceState(null, '', next);
location.reload();
if (atLoad) {
const first = normalize(location.hash);
if (first !== location.hash) history.replaceState(null, '', first);
refreshList();
} else {
const initial = window.MosaicInitialOrganizations = readRows();
list = initial.then(rows => paintHeader(rows ?? []));
initial.then(async rows => {
const valid = Array.isArray(rows) ? rows.filter(row => !row.invalid) : [];
if (!valid.length) return null;
let preferred;
try { preferred = (await (await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })).json()).preselect; } catch { /* first authorized organization */ }
return encodeURIComponent((valid.find(row => row.id === preferred) ?? valid[0]).id);
}).catch(() => null).then(seg => {
settle(orgOf(location.hash) ?? seg);
if (!current) return;
const next = normalize(location.hash);
if (next !== location.hash) history.replaceState(null, '', next);
previous = location.hash; paintBus();
});
}
// From the first-run page (no organization yet) the first one changes the page's mode, which
// still takes one reload. Once an organization is open, a list refresh repaints in place.
window.MosaicRefreshOrganizations = () => {
if (current || !settled) return refreshList();
const read = window.MosaicInitialOrganizations = readRows();
list = read.then(rows => paintHeader(rows ?? []));
read.then(rows => {
const valid = Array.isArray(rows) ? rows.filter(row => !row.invalid) : [];
if (!valid.length || orgOf(location.hash)) return;
history.replaceState(null, '', `#/orgs/${encodeURIComponent(valid[0].id)}${tail(location.hash) === '/' ? '/organization' : tail(location.hash)}`);
location.reload();
});
return read;
};
let previous = location.hash, skipGuard = false;
// 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;
current = seg;
document.dispatchEvent(new CustomEvent('mosaic-org-switch', { detail: { from: from ? decodeURIComponent(from) : null, to: decodeURIComponent(seg) } }));
}
function discard() {
window.MosaicChat?.forget?.();
window.MosaicWorkflows?.forget?.();
for (const el of document.querySelectorAll('#reply, #conv-reply, #doc-source')) el.value = '';
}
function confirmSwitch(next) {
const dialog = document.createElement('dialog');
dialog.setAttribute('aria-label', 'Unsaved draft');
dialog.innerHTML = '<p>You have an unsaved draft. Stay here or discard it and continue.</p><button type="button" data-stay>Stay</button> <button type="button" data-discard>Discard</button>';
document.body.append(dialog);
const stay = () => { const picker = document.getElementById('org-picker'); if (picker) picker.value = decodeURIComponent(id); dialog.close(); dialog.remove(); };
const close = () => { dialog.close(); dialog.remove(); };
const stay = () => { const picker = document.getElementById('org-picker'); if (picker && current) picker.value = decodeURIComponent(current); close(); };
dialog.querySelector('[data-stay]').onclick = stay;
dialog.querySelector('[data-discard]').onclick = () => change(next);
dialog.querySelector('[data-discard]').onclick = () => { close(); discard(); skipGuard = true; location.hash = next; };
dialog.addEventListener('cancel', e => { e.preventDefault(); stay(); });
dialog.showModal(); dialog.querySelector('[data-stay]').focus();
}
// Capture phase: this runs before bus.js renders, so a switch is scoped before the page reads.
window.addEventListener('hashchange', event => {
const oldHash = event.oldURL ? new URL(event.oldURL).hash : previous;
let next = location.hash;
if (!next.startsWith('#/orgs/')) { next = `#/orgs/${id}${next.startsWith('#/') ? next.slice(1) : '/'}`; history.replaceState(null, '', next); }
const nextId = /^#\/orgs\/([^/]+)/.exec(next)?.[1];
if ((nextId && nextId !== id) || (next !== oldHash && window.MosaicOrgCreate?.hasDraft?.())) {
const raw = location.hash;
// First run (no organization yet) keeps its own addresses: #/ is its board, not #/dashboard.
// Before the first list read settles, the settle step normalizes whatever address it finds.
if (!settled || !current) { previous = raw; return; }
const seg = orgOf(raw) ?? current, next = normalize(raw, seg);
if (next !== raw) history.replaceState(null, '', next);
const switching = seg !== current;
if (!skipGuard && next !== oldHash && (switching || window.MosaicOrgCreate?.hasDraft?.()) && dirty()) {
event.stopImmediatePropagation();
if (dirty()) { history.replaceState(null, '', oldHash); confirmSwitch(next); } else change(next);
history.replaceState(null, '', oldHash); confirmSwitch(next);
return;
}
previous = next;
skipGuard = false; previous = next;
if (switching) rescope(seg);
paintBus();
}, true);
window.MosaicOrg = Object.freeze({ id: decodeURIComponent(id), route, get list() { return list; }, readList, refreshList, switchTo: next => { location.hash = `#/orgs/${encodeURIComponent(next)}${route()}`; } });
const api = Object.freeze({
get id() { return current ? decodeURIComponent(current) : null; },
// The open organization's name as the last list read gave it, for the breadcrumb.
get label() { const id = current ? decodeURIComponent(current) : null, row = id && rowsSeen.find(r => r.id === id); return id ? (typeof row?.label === 'string' && row.label ? row.label : id) : null; },
ready, route, get list() { return list; }, readList, refreshList,
// SP3: whether this address is a system page (no organization's), for the shell's theme scope.
system: () => system(location.hash),
switchTo: next => { const path = route().split('?')[0]; location.hash = `#/orgs/${encodeURIComponent(next)}${path === '/dashboard' || path === '/' ? '/organization' : route()}`; },
});
Object.defineProperty(window, 'MosaicOrg', { configurable: true, enumerable: true, get: () => (current || !settled ? api : undefined) });
})();
if (document.documentElement.hasAttribute('data-settings-proxy-document') && !location.hash) location.hash = '#/settings/model-proxy';
/* Browser-local sidebar density. No authority or project data is stored here. */
(() => {
const key=window.MosaicOrg ? `mosaic-sidebar-display-v1:${window.MosaicOrg.id}` : 'mosaic-sidebar-display-v1';
// #1873 SP1: the key follows the open organization, read again when it changes.
const key=()=>window.MosaicOrg?.id ? `mosaic-sidebar-display-v1:${window.MosaicOrg.id}` : 'mosaic-sidebar-display-v1';
const defaults=()=>({version:1,density:'comfortable',order:'title-first'});
const valid=x=>x&&x.version===1&&['comfortable','compact'].includes(x.density)&&['title-first','contact-first'].includes(x.order)&&Object.keys(x).sort().join(',')==='density,order,version';
let saved=defaults();
try {const value=JSON.parse(localStorage.getItem(key));if(valid(value))saved=value;} catch {}
const load=()=>{saved=defaults();try {const value=JSON.parse(localStorage.getItem(key()));if(valid(value))saved=value;} catch {}};
const apply=()=>{document.documentElement.dataset.sidebarDensity=saved.density;document.documentElement.dataset.sidebarOrder=saved.order;document.dispatchEvent(new CustomEvent('mosaic-display-change',{detail:{...saved}}));};
apply();
window.MosaicDisplay={get:()=>({...saved}),setDensity(density){if(!['comfortable','compact'].includes(density))return false;saved={...saved,density};apply();try{localStorage.setItem(key,JSON.stringify(saved));return true;}catch{return false;}}};
load();apply();
window.MosaicOrg?.ready?.then(()=>{load();apply();});
document.addEventListener('mosaic-org-switch',()=>{load();apply();});
window.MosaicDisplay={get:()=>({...saved}),setDensity(density){if(!['comfortable','compact'].includes(density))return false;saved={...saved,density};apply();try{localStorage.setItem(key(),JSON.stringify(saved));return true;}catch{return false;}}};
})();
// P4 page: credentials and OAuth state exist only during the current operation.
+2 -2
View File
@@ -6,8 +6,8 @@
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/assistant.css">
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><link rel="stylesheet" href="/workflows.css"><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/assistant.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
<a class="skip" href="#main">Skip to content</a>
-93
View File
@@ -1,93 +0,0 @@
/* Side by side (U5, sample 07 "EXT 8"): a bar, then two panes with a divider between them. Each pane
has its own header with the organization's mark and edge, and its own scroll. One pane and a
shown-pane switch on a phone. Loaded after shell.css; every rule is scoped to the side-by-side page. */
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
html[data-organizations] .sbs{margin:0;display:flex;flex-direction:column;min-height:0;height:calc(100vh - var(--bar-h,53px) - var(--foot-h,33px))}
html[data-organizations] .sbs .panebar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--canvas);flex-wrap:wrap}
html[data-organizations] .sbs .panebar h1{font-size:16px;font-weight:700;margin:0;line-height:1.4}
html[data-organizations] .sbs .panebar h1:focus{outline:none}
html[data-organizations] .sbs .panebar .note{font-size:13px;color:var(--muted)}
html[data-organizations] .sbs .panebar [data-pane-add]{margin-left:auto}
html[data-organizations] .sbs .ws-seg.pane-tabs{display:none;margin-left:0}
html[data-organizations] .sbs .pane-tabs>button{gap:5px;max-width:44vw}
html[data-organizations] .sbs .pane-tabs>button>span:last-child{overflow:hidden;text-overflow:ellipsis}
html[data-organizations] .panes{--sbs-a:1fr;--sbs-b:1fr;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}
html[data-organizations] .panes.two{grid-template-columns:minmax(0,var(--sbs-a)) 1px minmax(0,var(--sbs-b))}
html[data-organizations] .pane{display:flex;flex-direction:column;min-width:0;min-height:0}
html[data-organizations] .pane[hidden]{display:none}
html[data-organizations] .pane-h{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0}
html[data-organizations] .pane-h .ttl{font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}
html[data-organizations] .pane-h .ttl small{font-weight:500;color:var(--muted);margin-left:6px;font-size:inherit}
html[data-organizations] .pane-h [data-pane-state]:empty{display:none}
html[data-organizations] .pane-pick{flex:0 1 180px;min-width:0;max-width:220px;margin-left:auto;font:500 12px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:3px 6px;min-height:0}
html[data-organizations] .pane-h .tools{display:flex;gap:2px;flex:none}
html[data-organizations] .pane-tool{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer}
html[data-organizations] .pane-tool:hover{background:var(--raised);color:var(--text)}
html[data-organizations] .pane-tool[hidden]{display:none}
html[data-organizations] .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
html[data-organizations] .pane-line{margin:0;padding:6px 14px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))}
html[data-organizations] .pane-line[hidden]{display:none}
html[data-organizations] .pane-body{overflow:auto;padding:14px;min-height:0;flex:1}
html[data-organizations] .pane-body:focus{outline:none}
html[data-organizations] .pane-body:focus-visible{outline:2px solid var(--focus,var(--action));outline-offset:-2px}
/* A page inside a pane is a section of the side-by-side page: smaller headings, no page chrome. */
html[data-organizations] .pane-body .ws h1{font-size:20px}
html[data-organizations] .pane-body .chat-area.pane-project{display:block;height:auto;min-height:0;overflow:visible;padding:0}
/* A project in a pane is compact (sample 07): the pane header already names the project and its
organization, so the page heading stays only for assistive technology and focus, the full-page
actions give way (North star and PRD are on the Requirements tab, Settings in the full view), and
the tabs, measures and current work come first. Project pages size some parts from the window
(projects.css); in a pane they size from the pane. */
html[data-organizations] .pane-body{container-type:inline-size}
html[data-organizations] .pane-body .project-header{display:block;width:auto;max-width:100%;margin:0 0 6px;padding:0}
html[data-organizations] .pane-body .project-header .project-heading,html[data-organizations] .pane-body .project-header .project-heading>div{display:block}
html[data-organizations] .pane-body .project-header-icon,html[data-organizations] .pane-body .project-actions{display:none}
html[data-organizations] .pane-body .project-header h1{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
html[data-organizations] .pane-body:has(.project-header h1:focus-visible){outline:2px solid var(--focus,var(--action));outline-offset:-2px}
html[data-organizations] .pane-body .project-header .project-heading p{margin:0;font-size:13px;color:var(--muted)}
html[data-organizations] .pane-body .project-tabs a{min-height:40px;padding:0 12px;font-size:14px}
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:10px;padding:6px 0}
html[data-organizations] .pane-body .project-metrics a{padding:4px 12px;border-right:0;border-bottom:0;line-height:1.3}
html[data-organizations] .pane-body .project-metrics a span,html[data-organizations] .pane-body .project-metrics small{font-size:12px;overflow-wrap:anywhere}
html[data-organizations] .pane-body .project-metrics strong{font-size:20px}
html[data-organizations] .pane-body .project-metrics p{margin:6px 12px 0;padding-top:6px}
html[data-organizations] .pane-body .project-columns{grid-template-columns:minmax(0,1fr);gap:14px;margin-top:12px}
html[data-organizations] .pane-body .project-columns h2{font-size:16px;margin:12px 0 8px}
html[data-organizations] .pane-body .project-columns h3{font-size:14px}
html[data-organizations] .pane-body .project-columns .project-section-title h2,html[data-organizations] .pane-body .project-columns aside>h2:first-child{margin-top:0}
@container (min-width:560px){
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}
html[data-organizations] .pane-body .project-metrics a:not(:nth-child(4)){border-right:1px solid var(--line)}
}
/* E2E6 P2-9: a dashboard in a pane sizes from the pane, not the window. Half a 1440 window left the
Settings column too narrow for its values (clipped to 1-2 characters): the split stacks below 900px
of pane, and a narrow Settings list puts each value on its own line, as at phone width. */
@container (max-width:899px){
html[data-organizations] .pane-body .ws-split{grid-template-columns:minmax(0,1fr)}
}
@container (max-width:559px){
html[data-organizations] .pane-body .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
html[data-organizations] .pane-body .ws-cfg .v{grid-column:2/-1;grid-row:2}
}
/* The side-by-side page has its own bar; the page breadcrumb would name only one pane. */
body:has(#bus-view > .sbs) #breadcrumbs{display:none}
html[data-organizations] .content:has(> #bus-view > .sbs) > .org-cue{margin:8px 14px}
html[data-organizations] .pane-div{position:relative;background:var(--line);cursor:col-resize;touch-action:none;outline:none}
html[data-organizations] .pane-div::before{content:"";position:absolute;inset:0 -4px;z-index:1}
html[data-organizations] .pane-div:hover,html[data-organizations] .pane-div.dragging{background:var(--action)}
html[data-organizations] .pane-div:focus-visible{background:var(--action);box-shadow:0 0 0 2px var(--action)}
html[data-organizations] .pane-div[hidden]{display:none}
@media (max-width:760px){
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
html[data-organizations] .sbs{height:auto}
html[data-organizations] .sbs .ws-seg.pane-tabs{display:inline-flex}
html[data-organizations] .sbs .hide-m{display:none}
html[data-organizations] .panes,html[data-organizations] .panes.two{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .pane.alt,html[data-organizations] .pane-div{display:none}
html[data-organizations] .pane-body{overflow:visible}
html[data-organizations] .pane-h{flex-wrap:wrap}
html[data-organizations] .pane-pick{flex:1 1 100%;max-width:none;order:5;margin-left:0}
}
-484
View File
@@ -1,484 +0,0 @@
// Side by side (U5, sample 07): two panes, each showing one page of any organization. A pane holds
// the organization dashboard, the Agents levels or a project page (overview, conversations, tasks,
// requirements, files), drawn by the same modules as the full view (workspace.js, projects.js) into
// the pane instead of #bus-view. Every read names the pane's organization, so the panes never share
// state. Pages that keep one global form, dialog or editor (a conversation, the room, settings, the
// queue, docs, the inbox) are not offered in a pane; their links open the full view.
//
// The panes' routes, the divider and the phone's shown pane live in the URL
// (#/orgs/<page>/side-by-side?a=<org>/<route>&b=<org>/<route>&w=<percent>&show=a|b), updated with
// replaceState so nothing reloads, and the last layout is remembered in this browser.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace;
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const STORE = 'mosaic-side-by-side-v1', POLL = 10000, LIST_TTL = 3000, PROJECTS_TTL = 30000;
const MIN = 25, MAX = 75, STEP = 5;
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const PROJECT = /^\/projects\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/(conversations|tasks|requirements|files))?$/;
const SECTIONS = { overview: 'Overview', conversations: 'Conversations', tasks: 'Tasks', requirements: 'Requirements', files: 'Files' };
const FOCUSABLE = 'a[href],button:not([disabled]),select,textarea,input,summary,[tabindex]:not([tabindex="-1"])';
const NAMES = { a: 'left', b: 'right' };
const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } };
const save = value => { try { localStorage.setItem(STORE, value); } catch {} };
const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true';
const svg = d => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
const FULL = svg('<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>');
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
const PLUS = svg('<path d="M12 5v14M5 12h14"/>');
// A pane's page: the routes a pane can draw. Anything else is not pane-safe.
function page(route) {
if (route === '/organization') return { kind: 'dashboard' };
if (route === '/agents') return { kind: 'agents' };
const m = PROJECT.exec(route);
if (m && m[1] !== 'threads' && m[1] !== 'rooms') return { kind: 'project', id: m[1], section: m[2] || 'overview' };
return null;
}
// "<org>/<route>" from the URL; null when either part is not one a pane draws.
function parsePane(value) {
if (typeof value !== 'string') return null;
const cut = value.indexOf('/');
if (cut < 1) return null;
const org = value.slice(0, cut), route = value.slice(cut);
return ORG.test(org) && page(route) ? { org, route } : null;
}
const paneValue = p => `${p.org}${p.route}`;
const clampW = n => Math.min(MAX, Math.max(MIN, Math.round(n)));
// ---------- shared reads (one organization list and project lists for both panes) ----------
let listAt = 0, listPromise = null, projectReads = new Map();
function orgList(fresh = false) {
if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) {
listAt = Date.now();
listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null);
}
return listPromise;
}
function projectsOf(org, fresh = false) {
const hit = projectReads.get(org);
if (hit && !fresh && Date.now() - hit.at < PROJECTS_TTL) return hit.promise;
const promise = data.projects(org).then(b => (Array.isArray(b?.rows) ? b.rows.filter(p => p && typeof p.id === 'string') : null)).catch(() => null);
projectReads.set(org, { at: Date.now(), promise });
return promise;
}
// ---------- state ----------
let mounted = null; // { root, panes: [pane], w, show, gen }
let mountGen = 0;
let listCache = null; // the last organization list read, for the shown-pane tabs
function readQuery(query) {
const q = new URLSearchParams(query || '');
return { a: parsePane(q.get('a')), b: parsePane(q.get('b')), w: Number(q.get('w')), show: q.get('show'), named: q.has('a') || q.has('b') };
}
function queryOf(state) {
const q = new URLSearchParams();
state.panes.forEach((p, i) => q.set(i ? 'b' : 'a', paneValue(p)));
q.set('w', String(state.w));
q.set('show', state.show);
return q.toString().replace(/%2F/g, '/');
}
function writeUrl() {
if (!mounted) return;
const qs = queryOf(mounted);
save(qs);
const next = `#/orgs/${enc(window.MosaicOrg.id)}/side-by-side?${qs}`;
if (location.hash !== next) history.replaceState(null, '', next);
}
// The first pane is this page's organization; the second is the next organization on the list,
// or this one's Agents when there is no other.
async function defaults() {
const here = window.MosaicOrg.id, list = await orgList();
const other = (list || []).find(o => o && !o.invalid && o.id !== here && ORG.test(o.id));
return [{ org: here, route: '/organization' }, other ? { org: other.id, route: '/organization' } : { org: here, route: '/agents' }];
}
// ---------- markup ----------
function paneShell(key) {
const name = NAMES[key];
return `<section class="pane" id="pane-${key}" data-pane="${key}" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane">
<div class="pane-h ws-o" data-pane-head>
<span data-pane-av></span><span class="ttl" data-pane-ttl></span><span data-pane-state></span>
<label class="ws-sr" for="pane-${key}-pick">Page in the ${name} pane</label>
<select id="pane-${key}-pick" class="pane-pick" data-pane-pick></select>
<span class="tools"><a class="pane-tool" data-pane-full href="#" title="Open in full view"><span class="ws-sr" data-pane-full-label>Open in full view</span>${FULL}</a><button type="button" class="pane-tool" data-pane-close title="Close pane" aria-label="Close the ${name} pane">${CLOSE}</button></span>
</div>
<p class="pane-line" data-pane-line role="status"></p>
<div class="pane-body" data-pane-body tabindex="0" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane content"></div>
</section>`;
}
function frame() {
return `<section class="sbs" data-side-by-side aria-labelledby="sbs-title">
<div class="panebar"><h1 id="sbs-title" tabindex="-1">Side by side</h1><span class="note" data-sbs-note></span>
<span class="ws-seg pane-tabs" role="group" aria-label="Shown pane" data-sbs-tabs></span>
<button type="button" class="ws-btn hide-m" data-pane-add>${PLUS}Add pane</button></div>
<div class="panes" data-sbs-panes>${paneShell('a')}<div class="pane-div" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Resize panes" aria-controls="pane-a pane-b" aria-valuemin="${MIN}" aria-valuemax="${MAX}" data-sbs-divider></div>${paneShell('b')}</div>
</section>`;
}
// Orgs in the picker: every organization on the list. A pane draws a valid one's dashboard, Agents or
// any project the organization's project read lists; an invalid one is listed and cannot be chosen.
async function pickerHtml(p, list) {
const groups = await Promise.all((list || []).filter(o => o && ORG.test(o.id)).map(async org => {
const name = ws().label(org);
if (org.invalid) return `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
const rows = await projectsOf(org.id);
const opt = (route, text) => `<option value="${esc(`${org.id}${route}`)}">${esc(text)}</option>`;
const projects = rows === null ? '<option disabled value="">Projects unavailable</option>' : rows.filter(r => !r.archived && PROJECT.test(`/projects/${r.id}`)).map(r => opt(`/projects/${r.id}`, `Project: ${r.name || r.id}`)).join('');
return `<optgroup label="${esc(name)}${org.busHealthy ? '' : ' · bus unavailable'}">${opt('/organization', 'Dashboard')}${opt('/agents', 'Agents')}${projects}</optgroup>`;
}));
let html = groups.join('');
const value = paneValue(p);
// A project section, or a page the list no longer names, is still the pane's page: keep it selectable.
if (!html.includes(`value="${esc(value)}"`)) html = `<option value="${esc(value)}">${esc(p.title || value)}</option>${html}`;
return html;
}
// ---------- drawing one pane ----------
const els = p => ({
head: p.el.querySelector('[data-pane-head]'), av: p.el.querySelector('[data-pane-av]'), ttl: p.el.querySelector('[data-pane-ttl]'),
state: p.el.querySelector('[data-pane-state]'), pick: p.el.querySelector('[data-pane-pick]'), full: p.el.querySelector('[data-pane-full]'),
fullLabel: p.el.querySelector('[data-pane-full-label]'), close: p.el.querySelector('[data-pane-close]'), line: p.el.querySelector('[data-pane-line]'), body: p.el.querySelector('[data-pane-body]'),
});
const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; };
const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; };
// Links a page draws are relative to the page's organization. In a pane they name the pane's
// organization; reads the page links to (/api/...) name it too.
function rewrite(p, root) {
const prefix = `#/orgs/${enc(p.org)}`;
for (const a of root.querySelectorAll('a[href^="#/"]')) if (!a.getAttribute('href').startsWith('#/orgs/')) a.setAttribute('href', prefix + a.getAttribute('href').slice(1));
for (const a of root.querySelectorAll('a[href^="/api/"]')) if (!a.getAttribute('href').startsWith('/api/orgs/')) a.setAttribute('href', `/api/orgs/${enc(p.org)}/${a.getAttribute('href').slice(5)}`);
// The pane's title is the page's heading; the side-by-side page keeps the one level-1 heading.
for (const h of root.querySelectorAll('h1:not([aria-level])')) h.setAttribute('aria-level', '2');
}
// Focus and scroll survive a repaint of this pane only. Focus in the other pane is never taken.
function capture(p, root) {
const a = document.activeElement, inside = !!a && a !== root && root.contains(a);
return {
top: root.scrollTop,
focus: inside ? { key: a.dataset?.focusKey || null, href: a.getAttribute('href'), index: [...root.querySelectorAll(FOCUSABLE)].indexOf(a), h1: a.tagName === 'H1' } : null,
};
}
function restore(p, root, kept) {
root.scrollTop = kept.top;
const f = kept.focus, a = document.activeElement;
if (!f || (a && a !== document.body && !root.contains(a))) return;
const target = (f.key && root.querySelector(`[data-focus-key="${CSS.escape(f.key)}"]`))
|| (f.href && [...root.querySelectorAll('a[href]')].find(x => x.getAttribute('href') === f.href))
|| (f.h1 && root.querySelector('h1'))
|| root.querySelectorAll(FOCUSABLE)[f.index];
(target || root).focus({ preventScroll: true });
}
function paintBody(p, html) {
const { body } = els(p);
if (p.html === html && body.childElementCount) return;
const kept = capture(p, body), live = window.MosaicWorkspace?.holdLive?.(body);
body.innerHTML = html; p.html = html; p.project = null;
rewrite(p, body);
// E2E6 P1-1: an editor in use keeps its node, draft and focus; its ids were rewritten when first painted.
if (live && window.MosaicWorkspace.graftLive(body, live)) kept.focus = null;
restore(p, body, kept);
}
// A focus move that finishes after an asynchronous read happens only if the viewer has not
// moved on: focus is still where it was when the action started, nowhere, or inside this pane.
function mayFocus(p, before) {
const a = document.activeElement;
return !a || a === document.body || a === before || p.el.contains(a);
}
async function drawPane(p, { fresh = false, focusHead = false } = {}) {
if (!mounted || !p.el.isConnected) return;
const before = document.activeElement;
const turn = ++p.turn, gen = mountGen, live = () => mounted && gen === mountGen && turn === p.turn && p.el.isConnected;
clearTimeout(p.timer);
const e = els(p), what = page(p.route);
const list = await orgList(fresh);
if (!live()) return;
if (list) listCache = list;
const org = list?.find(o => o?.id === p.org) || null;
const name = org ? ws().label(org) : p.org;
// Header: the organization's mark, the page's title, its state, the picker and the tools.
if (org) e.head.setAttribute('style', ws().colours(org)); else e.head.removeAttribute('style');
setHtml(e.av, org ? ws().av(org, 'md') : '<span class="ws-av md" aria-hidden="true">?</span>');
const [scls, sglyph, sword] = org ? ws().state(org) : ['s-unknown', '?', 'not on your list'];
setHtml(e.state, org?.busHealthy && !org.invalid ? '' : ws().dot(scls, sglyph, sword, true));
const fullHref = `#/orgs/${enc(p.org)}${p.route}`;
if (e.full.getAttribute('href') !== fullHref) e.full.setAttribute('href', fullHref);
let line = '', html = null;
if (!list) line = 'The organization list could not be read. This pane shows what its own reads return.';
else if (!org) { line = `${p.org} is not on your organization list.`; html = '<p class="ws-line">Nothing is read for an organization that is not on your list.</p>'; }
else if (org.invalid) { line = `${name}: organization invalid${org.reason ? ` (${org.reason})` : ''}.`; html = '<p class="ws-line">Nothing is read from an invalid organization. Fix its file under Settings, Organizations.</p>'; }
else if (!org.busHealthy) line = `${name}: bus unavailable. Reads from this organization may fail; the other pane is not affected.`;
setText(e.line, line); e.line.hidden = !line;
let title = what?.kind === 'agents' ? 'Agents' : 'Dashboard';
if (html === null && what?.kind === 'dashboard') html = await ws().dashboard(p.org, { pane: true, idPrefix: `pane-${p.key}-` }).catch(() => '<p class="ws-line">Dashboard unavailable.</p>');
else if (html === null && what?.kind === 'agents') html = await ws().agentGrid('', p.org).catch(() => '<p class="ws-line">Agents unavailable.</p>');
if (!live()) return;
if (html === null && what?.kind === 'project') {
title = await drawProject(p, e, what, fresh, live);
if (title === null || !live()) return;
} else if (html !== null) paintBody(p, html);
p.title = title;
setHtml(e.ttl, `${esc(title)}<small>${esc(name)}</small>`);
p.el.setAttribute('aria-label', `${NAMES[p.key][0].toUpperCase()}${NAMES[p.key].slice(1)} pane: ${title}, ${name}`);
setText(e.fullLabel, `Open ${title}, ${name}, in full view`);
// The picker is rebuilt only while it is closed and unfocused, so a choice in progress stays.
if (document.activeElement !== e.pick) {
const options = await pickerHtml(p, list);
if (!live()) return;
if (document.activeElement !== e.pick) { setHtml(e.pick, options); e.pick.value = paneValue(p); }
}
paintBar(); fit();
if (focusHead && mayFocus(p, before)) (e.body.querySelector('h1') || e.body).focus({ preventScroll: true });
p.timer = setTimeout(() => tick(p), POLL);
}
function tick(p) {
if (!mounted || !p.el.isConnected) return;
if (paused()) { p.timer = setTimeout(() => tick(p), POLL); return; }
void drawPane(p);
}
// A project page: projects.js's header and content, mounted into the pane with the pane's
// organization, read-only (a pane never sends).
// Every write below happens only while this draw is still the pane's current one (live()), so a
// slow read never paints an old page or organization over the one the viewer has since chosen.
async function drawProject(p, e, what, fresh, live) {
const org = p.org;
const [projects, threadsRead] = await Promise.all([projectsOf(org, fresh), data.threads(org).catch(() => null)]);
if (!live() || p.org !== org) return null;
const project = projects?.find(r => r.id === what.id);
const sectionName = SECTIONS[what.section];
if (!project) {
paintBody(p, projects === null ? '<p class="ws-line">Projects unavailable. The organization did not answer the project read.</p>' : '<p class="ws-line">This project is not in the organization\'s project list.</p>');
return what.id;
}
const threads = Array.isArray(threadsRead?.rows) ? threadsRead.rows : [];
const head = window.MosaicProjects.header(project, what.section, threads);
const conv = what.section === 'conversations' ? conversations(project, threads, threadsRead) : null;
const key = JSON.stringify([org, project.id, what.section, head, conv]);
const { body } = e;
if (p.project?.key !== key) {
const kept = capture(p, body);
body.innerHTML = `<div class="chat-area pane-project">${head}${conv ?? '<div data-project-content>Reading…</div>'}</div>`;
p.html = null; p.project = { key };
rewrite(p, body);
restore(p, body, kept);
}
const content = body.querySelector('[data-project-content]');
if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org), idPrefix: `pane-${p.key}-` });
return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`;
}
// A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint
// that replaces the project content keeps them.
function viewsOf(p, org) {
if (!p.views.has(org)) p.views.set(org, { selected: new Map(), expanded: new Set() });
return p.views.get(org);
}
function conversations(project, threads, read) {
if (!read) return '<p class="ws-line">Conversations unavailable.</p>';
const own = threads.filter(t => t && !t.shared && t.project === project.id && typeof t.id === 'string');
const rows = own.map(t => `<a class="project-conversation-row" href="#/projects/threads/${enc(t.id)}" data-focus-key="${esc(`conv:${t.id}`)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${esc(t.name || t.id)}</strong><small>With ${esc(t.instance || 'no agent recorded')}</small></span><span aria-hidden="true">›</span></a>`).join('');
return `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${rows || '<p>No conversations yet.</p>'}</div><p class="ws-line">A conversation opens in its full view.</p>`;
}
// ---------- the bar, tabs, divider and layout ----------
function paintBar() {
if (!mounted) return;
const { root, panes, show } = mounted, two = panes.length === 2;
setText(root.querySelector('[data-sbs-note]'), `${panes.length} ${panes.length === 1 ? 'pane' : 'panes'} · each pane reads its own organization's bus and keeps its own state`);
const tabs = root.querySelector('[data-sbs-tabs]'), focusedTab = tabs.contains(document.activeElement) ? document.activeElement.dataset.paneShow : null;
const list = listCache;
const html = panes.map(p => {
const org = list?.find(o => o?.id === p.org);
return `<button type="button" data-pane-show="${p.key}" aria-pressed="${show === p.key}" aria-controls="pane-${p.key}">${org ? ws().av(org, 'xs') : ''}<span>${esc(p.title || 'Reading…')}</span></button>`;
}).join('');
if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focusedTab) tabs.querySelector(`[data-pane-show="${focusedTab}"]`)?.focus({ preventScroll: true }); }
const add = root.querySelector('[data-pane-add]');
add.disabled = two; add.title = two ? 'Side by side holds two panes. Close one to choose another.' : 'Open a second pane';
for (const p of panes) { const c = p.el.querySelector('[data-pane-close]'); c.hidden = !two; }
}
function layout() {
if (!mounted) return;
const { root, panes, w, show } = mounted, two = panes.length === 2;
const box = root.querySelector('[data-sbs-panes]'), divider = root.querySelector('[data-sbs-divider]');
box.classList.toggle('two', two);
box.style.setProperty('--sbs-a', `${w}fr`); box.style.setProperty('--sbs-b', `${100 - w}fr`);
divider.hidden = !two;
divider.setAttribute('aria-valuenow', String(w));
divider.setAttribute('aria-valuetext', `Left pane ${w} percent, right pane ${100 - w} percent`);
root.querySelector('#pane-b').hidden = !two;
for (const p of panes) p.el.classList.toggle('alt', show !== p.key);
fit();
}
// Wide screens: the panes fill the window below the bar and each scrolls on its own.
const wide = window.matchMedia('(min-width: 761px)');
function fit() {
if (!mounted) return;
const { root } = mounted;
if (!wide.matches) { root.style.height = ''; return; }
const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0;
const top = root.getBoundingClientRect().top;
root.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot))}px`;
}
function setWidth(w, persist = true) {
if (!mounted) return;
const next = clampW(w);
if (next === mounted.w) return;
mounted.w = next; layout(); if (persist) writeUrl();
}
// ---------- events (bound once to the mount root) ----------
function bind(root) {
const divider = root.querySelector('[data-sbs-divider]');
divider.addEventListener('keydown', e => {
const w = mounted?.w; if (w === undefined) return;
const next = { ArrowLeft: w - STEP, ArrowDown: w - STEP, ArrowRight: w + STEP, ArrowUp: w + STEP, PageDown: w - 10, PageUp: w + 10, Home: MIN, End: MAX }[e.key];
if (next === undefined) return;
e.preventDefault(); setWidth(next);
});
divider.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
e.preventDefault(); divider.focus({ preventScroll: true }); divider.setPointerCapture(e.pointerId); divider.classList.add('dragging');
const box = root.querySelector('[data-sbs-panes]').getBoundingClientRect();
const move = ev => setWidth(((ev.clientX - box.left) / box.width) * 100, false);
const up = () => { divider.removeEventListener('pointermove', move); divider.removeEventListener('pointerup', up); divider.removeEventListener('pointercancel', up); divider.classList.remove('dragging'); writeUrl(); };
divider.addEventListener('pointermove', move); divider.addEventListener('pointerup', up); divider.addEventListener('pointercancel', up);
});
root.addEventListener('change', e => {
const pick = e.target.closest?.('[data-pane-pick]'); if (!pick || !mounted) return;
const p = mounted.panes.find(x => x.el.contains(pick)), next = parsePane(pick.value);
if (!p || !next) return;
go(p, next, false);
});
root.addEventListener('click', e => {
if (!mounted) return;
const show = e.target.closest?.('[data-pane-show]');
if (show) { mounted.show = show.dataset.paneShow === 'b' && mounted.panes.length === 2 ? 'b' : 'a'; layout(); paintBar(); writeUrl(); return; }
if (e.target.closest?.('[data-pane-add]')) { void addPane(); return; }
const close = e.target.closest?.('[data-pane-close]');
if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; }
// A plain click on a link to a page a pane draws, in the pane's own organization, opens it in
// the pane. Anything else (another organization, a page a pane does not draw, a new tab) is
// an ordinary link to the full view.
const a = e.target.closest?.('a[href^="#/orgs/"]');
if (!a || a.hasAttribute('data-pane-full') || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const p = mounted.panes.find(x => x.el.contains(a)); if (!p) return;
const m = /^#\/orgs\/([^/?#]+)(\/[^?#]*)$/.exec(a.getAttribute('href'));
let org, route;
try { org = decodeURIComponent(m?.[1] ?? ''); route = decodeURIComponent(m?.[2] ?? ''); } catch { return; }
if (org !== p.org || !page(route)) return;
e.preventDefault();
go(p, { org, route }, true);
});
}
function go(p, next, focusHead) {
p.org = next.org; p.route = next.route; p.html = null; p.project = null; p.title = null;
els(p).body.scrollTop = 0;
writeUrl();
void drawPane(p, { focusHead });
}
async function addPane() {
if (!mounted || mounted.panes.length !== 1) return;
const before = document.activeElement;
const [a] = mounted.panes, list = await orgList();
if (!mounted || mounted.panes.length !== 1) return;
const other = (list || []).find(o => o && !o.invalid && o.id !== a.org && ORG.test(o.id));
const b = makePane('b', other ? { org: other.id, route: '/organization' } : { org: a.org, route: a.route === '/agents' ? '/organization' : '/agents' });
mounted.panes.push(b); mounted.show = 'b';
layout(); paintBar(); writeUrl();
await drawPane(b);
if (mounted?.panes.includes(b) && mayFocus(b, before)) els(b).pick.focus({ preventScroll: true });
}
function closePane(p) {
if (!mounted || !p || mounted.panes.length !== 2) return;
clearTimeout(p.timer); p.turn++;
const keep = mounted.panes.find(x => x !== p);
// The remaining pane becomes the first: its page moves into pane a's element.
const a = mounted.panes[0];
if (keep !== a) {
const b = mounted.panes[1];
clearTimeout(b.timer); b.turn++;
a.org = keep.org; a.route = keep.route; a.html = null; a.project = null; a.title = keep.title; a.views = keep.views;
}
setHtml(els(mounted.panes[1]).body, ''); mounted.panes[1].html = null; mounted.panes[1].project = null;
mounted.panes = [a]; mounted.show = 'a';
layout(); paintBar(); writeUrl();
// The close button just left the page; focus goes to the remaining pane's picker now, and again
// after its draw only if the viewer has not moved elsewhere meanwhile.
els(a).pick.focus({ preventScroll: true });
const before = document.activeElement;
void drawPane(a).then(() => { if (mounted?.panes[0] === a && mayFocus(a, before)) els(a).pick.focus({ preventScroll: true }); });
}
function makePane(key, at) {
const el = mounted.root.querySelector(`#pane-${key}`);
return { key, el, org: at.org, route: at.route, turn: 0, timer: null, html: null, project: null, title: null, views: new Map() };
}
// ---------- public ----------
async function mount(container, query, { navigated = false } = {}) {
unmount();
const gen = ++mountGen;
container.innerHTML = frame();
const root = container.querySelector('[data-side-by-side]');
mounted = { root, panes: [], w: 50, show: 'a', gen, observers: [] };
// projects.js paints a project's content after its own reads; its links are rewritten as they
// land. One observer per pane element for the life of the mount, applying whichever pane object
// holds that element now, so a closed and re-added pane never runs an old organization's rewrite.
const m = mounted;
for (const key of ['a', 'b']) {
const body = root.querySelector(`#pane-${key} [data-pane-body]`);
const observer = new MutationObserver(() => {
if (mounted !== m) return;
const p = m.panes.find(x => x.key === key);
if (p) rewrite(p, body);
});
observer.observe(body, { childList: true, subtree: true });
m.observers.push(observer);
}
bind(root);
window.addEventListener('resize', fit);
document.title = 'Side by side · Mosaic Console';
if (navigated) root.querySelector('h1').focus({ preventScroll: true });
let wanted = readQuery(query);
if (!wanted.named) { const kept = readQuery(load() || ''); if (kept.a) wanted = { ...kept, named: true }; }
let at = [wanted.a, wanted.b].filter(Boolean);
if (!at.length) at = await defaults();
if (!mounted || gen !== mountGen) return;
listCache = await orgList();
if (!mounted || gen !== mountGen) return;
mounted.panes = at.slice(0, 2).map((x, i) => makePane(i ? 'b' : 'a', x));
mounted.w = Number.isFinite(wanted.w) && wanted.w ? clampW(wanted.w) : 50;
mounted.show = wanted.show === 'b' && mounted.panes.length === 2 ? 'b' : 'a';
layout(); paintBar(); writeUrl();
await Promise.all(mounted.panes.map(p => drawPane(p)));
}
function unmount() {
if (!mounted) return;
for (const p of mounted.panes) { clearTimeout(p.timer); p.turn++; }
for (const o of mounted.observers) o.disconnect();
window.removeEventListener('resize', fit);
mounted = null; mountGen++;
}
// The Console's Refresh: both panes read again now, each keeping its own focus and scroll.
function refresh() {
if (!mounted) return;
projectReads = new Map();
void orgList(true).then(rows => { if (rows) listCache = rows; });
for (const p of mounted.panes) void drawPane(p, { fresh: true });
}
// E2E6 P2-2: a pane showing the organization a write changed reads it again.
document.addEventListener('mosaic-org-changed', e => {
if (!mounted) return;
for (const p of mounted.panes) if (p.org === e.detail?.org) void drawPane(p, { fresh: true });
});
// The list the tabs draw from follows each fresh read of the organization list.
document.addEventListener('mosaic-org-list', e => { if (Array.isArray(e.detail?.rows)) listCache = e.detail.rows; });
window.MosaicPanes = Object.freeze({ mount, unmount, refresh, page });
})();
+1 -1
View File
@@ -30,7 +30,7 @@
.avatar-wrap{position:relative}.avatar-wrap>button{display:flex;align-items:center;gap:8px;min-height:44px;border:0;background:transparent;color:var(--strong);font:inherit;cursor:pointer}.avatar-initial{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:var(--action);color:var(--onAction);font-weight:700}.avatar-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:50;width:240px;background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:8px;box-shadow:var(--shadow)}.avatar-menu[hidden]{display:none}.avatar-menu a,.avatar-menu button,.avatar-menu label,.avatar-menu #local-sign-out{display:block;width:100%;padding:9px;color:var(--text);font:inherit;text-align:left}.avatar-menu a{text-decoration:none}.avatar-menu button{background:none;border:0;cursor:pointer}.avatar-menu a:hover,.avatar-menu button:hover{background:var(--raised)}.avatar-menu label{font-size:12px;color:var(--muted)}.avatar-menu select{display:block;width:100%;min-height:44px;margin-top:4px}.avatar-menu small{display:block;color:var(--muted)}.avatar-menu #local-sign-out{opacity:.7}
#project-context,#project-tree,#board-tree{display:none!important}
.s1-section{font-size:14px;gap:12px}.s1-section[aria-current=page]{background:color-mix(in srgb,var(--action) 30%,var(--surface));color:var(--strong)}
.breadcrumbs{display:flex;gap:12px;align-items:center;margin:0 0 18px;color:var(--muted);font-size:13px}.breadcrumbs a{color:var(--action);text-decoration:none}.breadcrumbs [aria-current]{color:var(--strong)}
.breadcrumbs[hidden]{display:none}.breadcrumbs{display:flex;gap:12px;align-items:center;margin:0 0 18px;color:var(--muted);font-size:13px}.breadcrumbs a{color:var(--action);text-decoration:none}.breadcrumbs [aria-current]{color:var(--strong)}
#board-head h1{margin-bottom:0}#board-head p{margin:2px 0}.machine-off #status{display:none}
#dashboard-summary,.dashboard-card{border:1px solid var(--line);border-radius:var(--r-lg);background:var(--surface)}#dashboard-summary{margin:20px 0}.kpi-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));padding:18px 0}.kpi{padding:0 18px;border-right:1px solid var(--line);min-width:0}.kpi:last-child{border-right:0}.kpi div{font-size:12px;color:var(--strong);font-weight:700;min-height:36px}.kpi strong{display:block;font-size:28px;line-height:1.2;color:var(--strong)}.kpi span{display:block;color:var(--muted);font-size:12px;margin-top:4px}.kpi-footer{border-top:1px solid var(--line);padding:8px 18px;margin:0;color:var(--muted);font-size:12px}
#dashboard-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:20px 0}.dashboard-card{padding:16px}.dashboard-card h2{margin:0 0 10px}.dashboard-card .processing-controls{padding:0;border:0}.dashboard-card .processing-controls strong{display:none}.dashboard-card .processing-controls .btn{min-height:44px}.dashboard-card ul{padding-left:20px}.dashboard-card a{color:var(--action)}
+19 -26
View File
@@ -27,10 +27,14 @@
}
document.addEventListener('focusin',e=>{const tab=e.target.closest?.('.project-tabs a'); if(tab) revealTab(tab.parentElement,tab);});
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const storageKey=window.MosaicOrg ? `mosaic-project-tree:${window.MosaicOrg.id}` : 'mosaic-project-tree';
// #1873 SP1: the tree and its open/closed state belong to the open organization; a switch drops both.
const storageKey=()=>window.MosaicOrg?.id ? `mosaic-project-tree:${window.MosaicOrg.id}` : 'mosaic-project-tree';
let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0;
const treeFocus = new WeakMap();
try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; }
const loadTreeState=()=>{ try { treeState=JSON.parse(localStorage.getItem(storageKey()) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; } };
loadTreeState();
window.MosaicOrg?.ready?.then(loadTreeState);
document.addEventListener('mosaic-org-switch',()=>{ treeGeneration++; treeRows=[]; treeThreads=[]; treeRead=null; treeReadAt=0; loadTreeState(); });
// A side-by-side pane reads its own organization: its reads name that organization explicitly.
const at=(org,url)=>org && url.startsWith('/api/') ? `/api/orgs/${encodeURIComponent(org)}/${url.slice(5)}` : url;
async function read(url) {
@@ -76,7 +80,7 @@
target?.focus({preventScroll:true});
}
treeFocus.delete(container);
container.onclick=e=>{const button=e.target.closest('[data-project-disclosure]');if(!button)return;const id=button.dataset.projectDisclosure;treeState[id]=button.getAttribute('aria-expanded')!=='true';try{localStorage.setItem(storageKey,JSON.stringify(treeState));}catch{}drawTree(container,route);container.querySelector(`[data-project-disclosure="${id}"]`)?.focus();};
container.onclick=e=>{const button=e.target.closest('[data-project-disclosure]');if(!button)return;const id=button.dataset.projectDisclosure;treeState[id]=button.getAttribute('aria-expanded')!=='true';try{localStorage.setItem(storageKey(),JSON.stringify(treeState));}catch{}drawTree(container,route);container.querySelector(`[data-project-disclosure="${id}"]`)?.focus();};
} catch { if(generation===treeGeneration && container.isConnected && !document.body.classList.contains('signed-out')) {
treeRead=null;
const active=document.activeElement;
@@ -198,30 +202,22 @@
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><span class="project-delivery-icon" aria-hidden="true">▤</span><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><div class="project-delivery-actions"><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}</div><p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><span>Request ${clean(r.id)} · <span class="project-chip">Draft</span></span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':data.recordsMissing?window.MosaicRecords.markup(project.repo):!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
}
const expandedPrds = new Set();
// U5: a project in a Side by side pane keeps its own request choice and PRD disclosure, so two
// panes showing the same project (or two organizations' projects with one id) never share them.
// The pane passes `views`, kept on the pane per organization, so the state outlives a repaint
// that replaces the content element (a new conversation changes the header). Without it, the
// state lives as long as the container.
const paneViews = new WeakMap();
const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);};
function requirementView(data,project,selected,expandedSet=expandedPrds,idPrefix='') {
const pid=esc(idPrefix);
function requirementView(data,project,selected,expandedSet=expandedPrds) {
const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft;
const north=typeof data.north?.content==='string'?data.north.content:null;
const select=options.length>1?`<label>Request <select id="${pid}project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
const select=options.length>1?`<label>Request <select id="project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id);
const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null;
const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="${pid}project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="${pid}project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a data-full-view href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a data-full-view href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
}
function overview(data,project,canSend,receipt='') {
const active=data.requests.filter(r=>r.state && !ended(r.state)), waiting=active.filter(r=>needs(r.state)), ordered=[...data.requests].sort(requestOrder), first=ordered[0], threads=[...data.threads];
if(first) threads.sort((a,b)=>Number(b.id===first.thread.id)-Number(a.id===first.thread.id));
const f=filesData(data), filesKnown=!data.failed && !!f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries) && r.state), count=filesKnown?f.reviewed.length+f.drafts.length+f.docs.length:'–';
const n=typeof data.north?.content==='string'?data.north.content:null, candidate=ordered.find(r=>r.draft)?.draft;
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations')],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations')],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]];
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3>${n!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:`<p>${data.northRecordsMissing?window.MosaicRecords.markup(project.repo):data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet."}</p>`}<h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations'),!!first],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations'),!!waiting[0]],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]];
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" data-full-view href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href,full])=>`<a${full?' data-full-view':''} href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3>${n!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:`<p>${data.northRecordsMissing?window.MosaicRecords.markup(project.repo):data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet."}</p>`}<h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
}
function tasksView(data) {
const refs=taskRefs(data);
@@ -256,9 +252,7 @@
const focus=f.id ? root.querySelector('#'+CSS.escape(f.id)) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
focus?.focus({preventScroll:true});
}
// A Side by side pane passes `idPrefix` (pane-a-, pane-b-), so the same project in both panes
// draws distinct ids; the full view passes none and keeps its ids.
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null,idPrefix=''}) {
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null}) {
if(!container || !project) return;
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
contentRuns.set(container,run);
@@ -266,11 +260,10 @@
const data=await gather(project,threads,org).catch(()=>null);
if(!container.isConnected || generation!==epoch || contentRuns.get(container)!==run || document.body.classList.contains('signed-out')) return;
if(!data){contentPaints.delete(container);container.textContent='Project data unavailable. Refresh to try again.';return;}
const views=viewState(container,pane,paneState), selectedRequests=views.selected, expandedPrds=views.expanded;
const selected=selectedRequests.get(project.id);
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
const selection=selectedRequests.get(project.id);
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds,idPrefix):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
// The generated presentation and separately mounted Markdown determine visible content.
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
@@ -280,7 +273,7 @@
// Retained controls (the request selector) draw from the newest accepted read, even when
// this read changes nothing visible and the DOM is kept.
contentModels.set(container,{data,project,section,canSend,roomReceipt});
if(!pane) window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
if (contentPaints.get(container) === signature) return;
contentPaints.set(container, signature);
// Capture at commit time: the user may have interacted while the read was pending.
@@ -288,12 +281,12 @@
const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null;
const draw=selected=>{
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds,idPrefix):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); }
const north=container.querySelector('[data-north-star]'); if(north && typeof data.north?.content==='string') { const text=section==='overview' && data.north.content.length>500 ? previewCut(data.north.content,500) : data.north.content; north.replaceChildren(window.MosaicMarkdown?.render(text) || document.createTextNode(text)); }
const requestSelect='#'+CSS.escape(idPrefix+'project-request-select');
const requestSelect='#project-request-select';
container.querySelector(requestSelect)?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector(requestSelect)?.focus({preventScroll:true});});
};
draw(selection);
@@ -301,8 +294,8 @@
if (restore) {
restoreView(container,restore);
}
const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section');
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#'+CSS.escape(idPrefix+'project-'+sectionTarget))?.scrollIntoView({block:'start'});
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
}
// Cut at a word boundary, unless that would leave only a markdown marker (a long heading).
function previewCut(content,max) {
+6 -5
View File
@@ -20,7 +20,8 @@
const txt = value => esc(inert(value));
const enc = encodeURIComponent;
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
const STORE = window.MosaicOrg ? `mosaic-request-answer:${window.MosaicOrg.id}:` : 'mosaic-request-answer:';
// #1873 SP1: read per call, so answers stay with the organization open when they were written.
const storeOf = () => window.MosaicOrg?.id ? `mosaic-request-answer:${window.MosaicOrg.id}:` : 'mosaic-request-answer:';
const QUESTION_PHASES = ['discovery', 'prd-review', 'planning'];
const MODE_PHASES = [...QUESTION_PHASES, 'implementation', 'review', 'delivery'];
const PICKER_CODES = ['request-existing-project', 'request-attachment-selected'];
@@ -55,15 +56,15 @@
// removed once the bus has answered either way, so only a lost response leaves one behind.
const load = id => {
try {
const v = JSON.parse(sessionStorage.getItem(STORE + id));
const v = JSON.parse(sessionStorage.getItem(storeOf() + id));
return v && text(v.op) && ID.test(v.op) && text(v.text) && text(v.question) && ID.test(v.question) && positive(v.revision)
? { op: v.op, text: v.text, question: v.question, revision: v.revision } : null;
} catch { return null; }
};
const save = (id, record) => { try { sessionStorage.setItem(STORE + id, JSON.stringify(record)); } catch { /* kept in memory only */ } };
const drop = id => { try { sessionStorage.removeItem(STORE + id); } catch { /* nothing stored */ } };
const save = (id, record) => { try { sessionStorage.setItem(storeOf() + id, JSON.stringify(record)); } catch { /* kept in memory only */ } };
const drop = id => { try { sessionStorage.removeItem(storeOf() + id); } catch { /* nothing stored */ } };
const dropAll = () => {
try { for (const key of Object.keys(sessionStorage)) if (key.startsWith(STORE)) sessionStorage.removeItem(key); } catch { /* nothing stored */ }
try { for (const key of Object.keys(sessionStorage)) if (key.startsWith(storeOf())) sessionStorage.removeItem(key); } catch { /* nothing stored */ }
};
function forget() {
+91 -18
View File
@@ -462,7 +462,7 @@ html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:cente
/* Page frame (sample main column). */
html[data-organizations] .content:has(.ws){padding-top:18px;padding-inline:24px}
body:has([data-org-dashboard]) #breadcrumbs,body:has([data-org-agents]) #breadcrumbs{display:none}
body:has([data-org-dashboard]) #breadcrumbs{display:none}
html[data-organizations] .ws{font-size:14px;line-height:1.5;color:var(--text);min-width:0}
html[data-organizations] .ws h1{margin:0;font-size:28px;font-weight:800;letter-spacing:-.02em;line-height:1.15;color:var(--strong)}
html[data-organizations] .ws h1:focus,html[data-organizations] .ws h1:focus-visible{outline:none}
@@ -485,6 +485,13 @@ html[data-organizations] .ws-panel.tight{padding:12px 14px}
html[data-organizations] .ws-split{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:16px;align-items:start}
html[data-organizations] .ws-split{margin-top:22px}
html[data-organizations] .ws-split>section>.ws-sec-h:first-child{margin-top:0}
/* SP13: a column whose first child wraps its heading (the Workflows card) starts level with the other. */
html[data-organizations] .ws-split>section>:first-child>.ws-sec-h:first-child{margin-top:0}
/* SP13 (#1873): the organization page's two columns stack once its content is narrower than both need,
with or without the agent drawer beside it: a wrap, not a viewport breakpoint. */
html[data-organizations] .ws-dash>.ws-split{display:flex;flex-wrap:wrap;align-items:flex-start}
html[data-organizations] .ws-dash>.ws-split>section{flex:1.45 1 480px;min-width:0}
html[data-organizations] .ws-dash>.ws-split>section+section{flex:1 1 360px}
/* E2E6 P1-3: the long editors (Autonomy, Packs, Workflows) span the page below the split, so neither
column runs screens past the other at 1440. */
html[data-organizations] .ws-wide{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;margin-top:22px}
@@ -506,9 +513,13 @@ html[data-organizations] .ws-doc:hover{background:var(--raised)}
html[data-organizations] .ws-doc svg.i{width:14px;height:14px;color:var(--muted)}
html[data-organizations] .ws-doc small{font-weight:500;color:var(--muted);font-size:11px}
html[data-organizations] .ws-more{font-size:13px;font-weight:600;margin-left:4px;color:var(--action);text-decoration:none}
html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start}
html[data-organizations] .ws-tile.is-empty{grid-column:1/-1;border-style:dashed;background:transparent}
/* SP8 (#1873): one row of figure tiles under the page heading, each a link to the page that lists what it counts. */
html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:0 0 16px}
html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(6,minmax(0,1fr))}
html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start;min-width:0;color:inherit;text-decoration:none}
html[data-organizations] a.ws-tile:hover{border-color:color-mix(in srgb,var(--action) 45%,var(--line))}
html[data-organizations] a.ws-tile:focus-visible{outline:2px solid var(--action);outline-offset:2px}
html[data-organizations] .ws-tile>.ws-label{font-size:12.5px;font-weight:600;letter-spacing:0;text-transform:none}
html[data-organizations] .ws-empty-t{font-size:13.5px;font-weight:600;color:var(--strong)}
html[data-organizations] .ws-tbl{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
html[data-organizations] .ws-tbl table{width:100%;border-collapse:collapse;font-size:13px;margin:0}
@@ -518,15 +529,20 @@ html[data-organizations] .ws-tbl tr:last-child td{border-bottom:0}
html[data-organizations] .ws-tbl .t1{color:var(--strong)}
html[data-organizations] .ws-tbl .t2{display:block;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-tbl td.mono{font-family:var(--mono);font-size:12.5px;white-space:nowrap}
html[data-organizations] .ws-cfg{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
html[data-organizations] .ws-cfg{container-type:inline-size;display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
html[data-organizations] .ws-cfg li{display:grid;align-items:center;grid-template-columns:18px 132px minmax(0,1fr) auto auto;gap:0 10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
html[data-organizations] .ws-cfg li:last-child{border-bottom:0}
html[data-organizations] .ws-cfg svg.i{width:16px;height:16px;color:var(--muted)}
html[data-organizations] .ws-cfg .k{font-size:13px;font-weight:600;color:var(--strong)}
html[data-organizations] .ws-cfg .v{font-size:12px;color:var(--muted);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-cfg .v{font-size:12px;color:var(--muted);min-width:0;overflow-wrap:anywhere}
html[data-organizations] .ws-cfg .src-tag{font:500 11px var(--mono);color:var(--accent);white-space:nowrap}
html[data-organizations] .ws-cfg .go{font-size:12.5px;font-weight:600;color:var(--action);text-decoration:none;min-width:32px;text-align:right}
html[data-organizations] .ws-cfg a.go:hover{text-decoration:underline}
/* SP13: a Settings card narrower than its five columns need puts each value on its own line. */
@container (max-width:560px){
html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2}
}
html[data-organizations] .ws-cfg li.danger .k,html[data-organizations] .ws-cfg li.danger svg.i,html[data-organizations] .ws-cfg li.danger .go,html[data-organizations] .ws-cfg li.danger .src-tag{color:var(--danger)}
/* Agents (sample 08). */
@@ -567,7 +583,7 @@ html[data-organizations] .ws-rules .ws-lvl{margin-right:0}
html[data-organizations] .ws-scope{margin:-8px 0 14px}
/* Across organizations (U3): the main dashboard, projects and inbox, samples 02, 04 and 05. */
body:has([data-ws-home]) #breadcrumbs,body:has([data-ws-projects]) #breadcrumbs,body:has([data-ws-inbox]) #breadcrumbs{display:none}
body:has([data-ws-home]) #breadcrumbs{display:none}
html[data-organizations] .ws-edge{box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .ws-orgref{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--strong);font-weight:600}
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap;line-height:1.5}
@@ -580,9 +596,8 @@ html[data-organizations] .ws-tbl .ws-dot.s-working b,html[data-organizations] .w
html[data-organizations] .ws-tbl .ws-dot.s-waiting b{color:var(--warning)}
html[data-organizations] .ws-tbl .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-tbl .ws-dot.s-idle b,html[data-organizations] .ws-tbl .ws-dot.s-offline b{color:var(--muted)}
html[data-organizations] .ws-g5{display:grid;gap:10px;grid-template-columns:repeat(5,minmax(0,1fr))}
html[data-organizations] .ws-tile .ws-big{font-size:24px;font-weight:800;color:var(--strong);letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0;margin-left:4px}
html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0}
html[data-organizations] .ws-tile .ws-delta{font-size:12px;color:var(--muted)}
html[data-organizations] .ws-tile .ws-delta b{color:var(--success);font-weight:600}
html[data-organizations] .ws-tile.quiet{border-style:dashed;background:transparent}
@@ -591,6 +606,18 @@ html[data-organizations] .ws-bar i{display:block;background:var(--org,var(--acti
html[data-organizations] .ws-by{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px;color:var(--text)}
html[data-organizations] .ws-by>span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;font-variant-numeric:tabular-nums}
html[data-organizations] .ws-by .ws-av{width:14px;height:14px;border-radius:3px;font-size:7px}
html[data-organizations] .ws-orgtbl td{vertical-align:top}
html[data-organizations] .ws-orgtbl .who{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;min-width:0}
html[data-organizations] .ws-orgtbl .who a{display:inline-flex;align-items:center;gap:8px;min-width:0;color:var(--strong);font-weight:700;text-decoration:none}
html[data-organizations] .ws-orgtbl .who a:hover .nm{text-decoration:underline}
html[data-organizations] .ws-orgtbl .who .ws-dot{font-size:12px;color:var(--muted)}
html[data-organizations] .ws-orgtbl .who .ws-dot.s-working b{color:var(--success)}
html[data-organizations] .ws-orgtbl .who .why{flex:1 0 100%;font-size:12px;color:var(--danger);overflow-wrap:anywhere}
html[data-organizations] .ws-orgtbl tr.off .ws-dot{color:var(--danger)}
/* #1873 SP2: a figure keeps its words on one line ("4 agents (0 working)", "none waiting"); the organization cell takes the wrap. */
html[data-organizations] .ws-orgtbl td:not(.who){white-space:nowrap}
html[data-organizations] .ws-dash [data-org-down]{margin:0 0 14px}
html[data-organizations] .ws-dash [data-org-down] .why{display:block;margin-top:4px;color:var(--muted)}
html[data-organizations] .ws-orgcards{list-style:none;margin:0;padding:0;display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
html[data-organizations] .ws-orgcard a{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:2px 10px;align-items:center;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);box-shadow:inset 3px 0 0 var(--org);color:var(--text);text-decoration:none;font-size:13px;height:100%}
html[data-organizations] .ws-orgcard a:hover{background:var(--raised)}
@@ -628,7 +655,7 @@ html[data-organizations] .ws-needs .ws-chip{grid-row:1/3;grid-column:3}
html[data-organizations] .ws-more-line{margin:6px 0 0}
html[data-organizations] .ws-sess td.who{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0;width:100%}
html[data-organizations] .ws-sess td.who .ws-orgref{vertical-align:middle}
html[data-organizations] .ws-sess td.st{white-space:nowrap}
html[data-organizations] .ws-sess td.ws-st{white-space:nowrap}
/* Filters and tables on Projects and Inbox. */
html[data-organizations] .ws-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
html[data-organizations] .ws-toolbar .ws-right{align-items:center}
@@ -674,6 +701,24 @@ html[data-organizations] .ws-ibx .q:hover{text-decoration:underline}
html[data-organizations] .ws-ibx .via{display:block;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-ibx tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
html[data-organizations] .ws-ibx tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
/* SP3: the system Inbox's filter rows and the organization and project chips on each item. */
html[data-organizations] .ws-ibx-filters{flex-direction:column;align-items:flex-start;gap:8px}
html[data-organizations] .ws-fgroup{display:flex;gap:6px 8px;align-items:center;flex-wrap:wrap}
html[data-organizations] .ws-ibx-filters .ws-fchips>.ws-label,html[data-organizations] .ws-fgroup>.ws-label{min-width:36px}
html[data-organizations] .ws-ichips{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;margin-top:3px}
html[data-organizations] .ws-ichips .via{display:inline}
/* #1873 SP5: the system Agents table. Open and Fire sit side by side at one size; a confirm wraps in its cell. */
html[data-organizations] .ws-agt .ws-arole{font-size:12px;color:var(--muted)}
html[data-organizations] .ws-agt td.ws-act{white-space:nowrap;text-align:right;width:1%}
html[data-organizations] .ws-agt td.ws-act>*+*{margin-left:6px}
html[data-organizations] .ws-agt td.ws-act .btn.small{min-height:0;padding:4px 10px;font:600 13px/1.4 var(--font);border-radius:var(--r)}
html[data-organizations] .ws-agt .ws-fire-confirm{display:inline-flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;white-space:normal}
html[data-organizations] .ws-agt .t2.mono{overflow-wrap:anywhere}
html[data-organizations] .ws-agt tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
html[data-organizations] .ws-agt tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
@media (forced-colors:active){html[data-organizations] .ws-agt tr.on td:first-child{border-left:3px solid Highlight}}
html[data-organizations] .ws-ochip{background:color-mix(in srgb,var(--org,var(--muted)) 14%,var(--surface))}
html[data-organizations] .ws-ochip i{width:8px;height:8px;border-radius:999px;background:var(--org,var(--muted));flex:none}
html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
html[data-organizations] .ws-insp{border:1px solid var(--line);border-radius:10px;background:var(--surface);display:flex;flex-direction:column;min-width:0;position:sticky;top:12px;max-height:calc(100vh - 80px);overflow:auto}
html[data-organizations] .ws-insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:1;box-shadow:inset 3px 0 0 var(--org)}
@@ -693,8 +738,41 @@ html[data-organizations] .ws-cmd{display:flex;gap:6px;align-items:center;min-wid
html[data-organizations] .ws-cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap;background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:3px 6px;font-size:12px;color:var(--strong)}
html[data-organizations] .ws-cmd .ws-btn{padding:4px 6px}
html[data-organizations] #copy-status:empty{display:none}
/* SP12 (#1873): the agent drawer, a non-modal region at the right of the page; the page stays under it. */
html[data-organizations] .ws-drawer-open{color:inherit;font-weight:inherit;text-decoration:underline;text-decoration-color:color-mix(in srgb,currentColor 35%,transparent);text-underline-offset:3px}
html[data-organizations] .ws-drawer-open:hover{text-decoration-color:currentColor}
html[data-organizations] .ws-drawer{position:fixed;top:var(--bar-h,52px);right:0;bottom:0;width:min(420px,100vw);z-index:40;display:flex;flex-direction:column;background:var(--surface);border-left:1px solid var(--line);box-shadow:-8px 0 24px color-mix(in srgb,#000 14%,transparent);overflow:auto;overscroll-behavior:contain}
html[data-organizations] .ws-drawer .hd{display:flex;align-items:center;gap:8px;padding:12px 14px 12px 17px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:1;box-shadow:inset 4px 0 0 var(--org,var(--action))}
html[data-organizations] .ws-drawer h2{margin:0;font-size:18px;font-weight:800;color:var(--strong);overflow-wrap:anywhere;min-width:0}
html[data-organizations] .ws-drawer h2:focus{outline:none}
html[data-organizations] .ws-drawer h2:focus-visible{outline:2px solid var(--action);outline-offset:2px}
html[data-organizations] .ws-drawer .x{margin-left:auto;flex:none;width:32px;height:32px;border:1px solid var(--line);border-radius:6px;background:var(--canvas);color:var(--strong);font-size:20px;line-height:1;cursor:pointer}
html[data-organizations] .ws-drawer .body{padding:14px;display:grid;gap:16px;align-content:start}
html[data-organizations] .ws-drawer-fields{margin:0;display:grid;gap:8px}
html[data-organizations] .ws-drawer-f{display:grid;grid-template-columns:96px minmax(0,1fr);gap:8px;align-items:baseline;font-size:13px}
html[data-organizations] .ws-drawer-f dt{color:var(--muted);font-weight:600}
html[data-organizations] .ws-drawer-f dd{margin:0;color:var(--strong);overflow-wrap:anywhere;min-width:0}
html[data-organizations] .ws-drawer-f code,html[data-organizations] .ws-drawer .mono{font:12px var(--mono)}
html[data-organizations] .ws-drawer-sec{display:grid;gap:6px;margin:0}
html[data-organizations] .ws-drawer-sec h3{margin:0;font:700 11px/1.3 var(--font);letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
html[data-organizations] .ws-drawer-list{list-style:none;margin:0;padding:0;display:grid;gap:4px;font-size:13px}
html[data-organizations] .ws-drawer-list li{border:1px solid var(--line);border-radius:6px;padding:6px 9px;background:var(--canvas);overflow-wrap:anywhere}
html[data-organizations] .ws-drawer-list .t2{margin-left:4px;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-drawer .ws-more{font-size:13px}
html[data-organizations] .ws-drawer .note{margin:0}
html[data-organizations] .ws-drawer-acts{display:flex;flex-wrap:wrap;gap:8px;align-items:center;border-top:1px solid var(--line);padding-top:12px}
html[data-organizations] .ws-drawer-confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:13px;color:var(--strong);flex-basis:100%}
html[data-organizations] .ws-drawer .ws-btn.danger{color:var(--danger)}
html[data-organizations] .ws-drawer-status{margin:0;flex-basis:100%;font-size:13px;color:var(--strong)}
html[data-organizations] .ws-drawer-status:empty{display:none}
html[data-organizations] .ws-drawer-acts .note{flex-basis:100%}
/* Wide screens: the page keeps clear of the drawer, so nothing sits under it. */
@media (min-width:1200px){html[data-organizations] body:has(.ws-drawer) .content{padding-right:calc(420px + 22px)}}
@media (max-width:700px){
html[data-organizations] .ws-drawer{top:0;left:0;right:0;width:auto;border-left:0;box-shadow:none}
}
@media (max-width:1100px){
html[data-organizations] .ws-g5{grid-template-columns:repeat(3,minmax(0,1fr))}
html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(3,minmax(0,1fr))}
html[data-organizations] .ws-ibx-grid.with-insp{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-insp{position:static;max-height:none}
}
@@ -704,11 +782,9 @@ html[data-organizations] #copy-status:empty{display:none}
html[data-organizations] .content:has(.ws){padding:14px 16px 24px}
html[data-organizations] .tree{padding:10px 12px 20px}
html[data-organizations] .ws h1{font-size:24px}
html[data-organizations] .ws-split,html[data-organizations] .ws-tiles{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-tile.is-empty{grid-column:auto}
html[data-organizations] .ws-split{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-tiles,html[data-organizations] .ws-tiles.six{grid-template-columns:repeat(2,minmax(0,1fr))}
html[data-organizations] .ws-sec-h .note{flex-basis:100%;order:3}
html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2}
html[data-organizations] .ws-meta{padding-left:0;margin-top:0}
html[data-organizations] .ws-orghead .ws-right{margin-left:0}
html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start}
@@ -721,9 +797,6 @@ html[data-organizations] #copy-status:empty{display:none}
html[data-organizations] .ws-levels>.span{grid-column:auto}
html[data-organizations] .ws-levels>.last.lh{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
html[data-organizations] .ws-levels .mlabel{display:flex}
html[data-organizations] .ws-g5{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-g5.keep2{grid-template-columns:repeat(2,minmax(0,1fr))}
html[data-organizations] .ws-g5.keep2>:last-child:nth-child(odd){grid-column:1/-1}
html[data-organizations] .ws-orgcards{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-srow{grid-template-columns:92px 20px minmax(0,1fr)}
html[data-organizations] .ws-srow .ws-by{grid-column:1/-1}
+155 -32
View File
@@ -10,6 +10,12 @@
// shown as the bus answered it. Vikunja tasks are read only here: the bus refuses them
// (tracker-not-store), so the board offers no control for them.
// bus.js calls view() for #/tasks in organization mode and repaints it on its own 10 s poll.
// SP4 (#1873): #/tasks is a system page, every organization on one board; #/orgs/<id>/tasks is the
// same board scoped to that organization (bus.js passes the scope). The org and project filters live
// in the address query (?org=, ?project=), so a reload keeps them. A move or edit goes to the row's own
// organization and is offered only where that organization's own config takes task changes (bus.js
// passes writesIn), never judged by the organization opened last. A card opened on the system board
// links to the task in its organization (#/orgs/<id>/tasks?task=<ref>), which opens it there.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
@@ -35,7 +41,13 @@
// 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: '' };
let addrAssignee = '';
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() {
@@ -56,6 +68,11 @@
// `opened` is the task as the edit dialog showed it: Save diffs against it and sends its digest, so a
// poll that reads a newer task while the dialog is open turns a stale save into a task-conflict.
let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null, opened = null;
// `scope` is the one organization of #/orgs/<id>/tasks, or null on the system board. `allowed` holds
// the organizations whose own config takes task changes from this Console, asked on every read.
let scope = null, writesIn = null, allowed = new Set(), autoOpened = null;
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/, TASK_REF = /^(?:task:\d{1,15}|vikunja:\d{1,15}\/\d{1,15})$/;
const ws = () => window.MosaicWorkspace;
const mark = () => window.MosaicWorkspace?.orgMark;
const orgOf = id => snap?.orgs.find(o => o.id === id) || { id, label: id, theme: null };
@@ -69,7 +86,7 @@
const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD);
// An org can answer while its tasks read fails (errors.tasks): its tasks are unavailable, not zero.
const tasksRead = o => o?.state === 'ok' && !o.errors?.tasks;
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in';
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in' && allowed.has(o.id);
const taskWritable = t => t.writable === true && typeof t.digest === 'string' && orgWritable(orgOf(t.org));
const projectName = t => t.project?.name || t.project?.key || null;
const dueText = iso => {
@@ -84,6 +101,7 @@
const PROJECT_SELECTOR = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9._-]{1,128}$/, ROLE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
function readQuery() {
const q = new URLSearchParams({ sections: 'tasks,projects' });
if (scope) q.set('org', scope);
if (PROJECT_SELECTOR.test(filter.project)) q.set('project', filter.project);
if (filter.state) q.set('task-state', filter.state === UNREAD ? 'open' : filter.state);
if (ROLE.test(filter.assignee)) q.set('assignee', filter.assignee);
@@ -97,7 +115,6 @@
mark()?.ready().catch(() => null),
window.MosaicConsole?.ready,
]);
canWrite = window.MosaicConsole?.config?.orgSetup === true;
if (response.type === 'opaqueredirect' || !response.ok) {
const body = await response.json().catch(() => null);
throw Object.assign(new Error('read failed'), { status: response.status, message: typeof body?.message === 'string' ? body.message : null });
@@ -106,16 +123,46 @@
if (!Array.isArray(body?.orgs) || !Array.isArray(body.tasks) || !Array.isArray(body.projects)) throw new Error('read failed');
const named = new Map((list || []).map(o => [o.id, o]));
const orgs = body.orgs.map(o => ({ ...o, id: o.org, label: named.get(o.org)?.label || o.org, theme: named.get(o.org)?.theme ?? o.theme ?? null, roles: Array.isArray(o.roles) ? o.roles : [] }));
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt };
// Each organization that could take a change is asked through its own config. The server sets
// orgSetup for the whole Console today, so the answer only differs where an organization's config
// read fails or 404s. With no way to ask, none is asked and the board is read only.
const asked = orgs.filter(o => tasksRead(o) && o.tracker === 'built-in');
const ask = typeof writesIn === 'function' ? writesIn : null;
const yes = ask ? await Promise.all(asked.map(o => Promise.resolve(ask(o.id)).then(v => v === true, () => false))) : [];
const takes = new Set(asked.filter((o, i) => yes[i]).map(o => o.id));
// The Console-wide word, for the head's reason: no organization that could take a change takes one.
// With none to ask, the open organization's config still says whether this Console writes at all.
const writes = asked.length ? takes.size > 0 : window.MosaicConsole?.config?.orgSetup === true;
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt, takes, asked: asked.map(o => o.id), writes, view: ws()?.orgsOf?.(body) ?? null };
}
// Only the latest read lands: a filter change and the poll can both be reading.
let readSeq = 0;
const reload = async () => { const n = ++readSeq, next = await read(); if (n !== readSeq) return false; snap = next; prune(); return true; };
// A read that a newer one replaced keeps nothing; `newest` lets a view wait for the read that does.
let readSeq = 0, newest = null;
// The address the read starts from goes to prune, so a cleanup never touches a page opened since.
const reload = () => { const n = ++readSeq, at = location.hash; return (newest = read().then(next => { if (n !== readSeq) return false; snap = next; allowed = next.takes; canWrite = next.writes; return prune(at) ? reload() : true; })); };
async function refilter() { paint(); try { if (await reload()) paint(); } catch { /* the next poll shows the failed read */ } }
// A filter naming an org or project that is gone falls back to all.
function prune() {
if (filter.org && !snap.orgs.some(o => o.id === filter.org)) filter.org = '';
if (filter.project && filter.project !== '-' && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) filter.project = '';
// The address drops it too, so it never names a filter the board is not using. A dropped project
// was filtered on the server, so it returns true and the read runs again without it.
// A project is gone only when its organization is off the list, or answered with its projects and
// the project is not among them and no task came back for it (with no other filter on). An organization that is down,
// starting or timed out, or whose projects read failed, lists no projects, so its filter stays.
// `at` is the address the read started from.
function prune(at) {
const gone = {};
if (!scope && filter.org && !snap.orgs.some(o => o.id === filter.org)) { filter.org = ''; gone.org = null; }
if (filter.project && filter.project !== '-') {
const po = snap.orgs.find(o => o.id === filter.project.split(':')[0]);
const answered = !po || (po.state === 'ok' && !po.errors?.projects);
// No task back is proof only when no other filter narrowed the read on the server.
const narrowed = filter.state || filter.assignee || filter.label;
if (answered && !narrowed && !snap.tasks.length && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) { filter.project = ''; gone.project = null; }
}
// After this render: bus.js drops a render whose address changed while it ran. Only while the
// address is still the one the read started from, and with a repaint in case the render in
// flight was the one dropped.
if (Object.keys(gone).length) setTimeout(() => { if (location.hash === at) ws()?.setQuery?.(gone, true); }, 0);
return 'project' in gone;
}
// ---------- filters ----------
@@ -126,6 +173,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) => `<label class="tb-sel"><span class="ws-label">${esc(label)}</span><select data-tb-filter="${name}" data-focus-key="${focus}">${options}</select></label>`;
@@ -137,17 +185,18 @@
const chips = [chip('', `All <span class="n">${snap.tasks.length}</span>`)].concat(snap.orgs.map(o => chip(o.id,
`${av(o.id)}${esc(o.label)}<span class="n">${tasksRead(o) ? counts.get(o.id) : 'unavailable'}</span>`,
`${o.label}, ${tasksRead(o) ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join('');
const scope = snap.orgs.filter(o => !filter.org || o.id === filter.org);
const shown = snap.orgs.filter(o => !filter.org || o.id === filter.org);
const projectOpts = opt('', `All ${snap.projects.filter(p => !filter.org || p.org === filter.org).length}`, filter.project) + opt('-', 'No project', filter.project)
+ scope.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
const roles = [...new Set(scope.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
+ shown.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
const roles = [...new Set(shown.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
if (filter.assignee && filter.assignee !== '-' && !roles.includes(filter.assignee)) roles.push(filter.assignee);
const assigneeOpts = opt('', 'Anyone', filter.assignee) + opt('-', 'Nobody', filter.assignee) + roles.map(r => opt(r, r, filter.assignee)).join('');
const stateOpts = opt('', 'Any', filter.state) + states().map(([v, l]) => opt(v, l, filter.state)).join('') + (snap.tasks.some(t => stateOf(t) === UNREAD) ? opt(UNREAD, 'State not read', filter.state) : '');
const labels = [...new Set(snap.tasks.flatMap(t => t.labels.map(String)))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : '';
return `<div class="tb-toolbar">
<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>
${scope ? '' : `<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>`}
${filter.done ? '<button type="button" class="tb-f plain" data-tb-done aria-pressed="true" aria-label="Clear Done this week: done tasks last changed in the past 7 days" data-focus-key="tb-done">Done this week <span aria-hidden="true">×</span></button>' : ''}
${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
<div class="tb-right"><label class="tb-search">${icon('search')}<input type="search" placeholder="Find a task" aria-label="Find a task" data-tb-q data-focus-key="tb-q" value="${esc(filter.q)}" autocomplete="off"></label></div>
</div>`;
@@ -158,12 +207,15 @@
const prio = t => (Number.isSafeInteger(t.priority) && t.priority > 0 ? `<span class="tb-chip${t.priority >= 4 ? ' warn' : ''}" title="Priority ${t.priority} of 5">P${t.priority}</span>` : '');
const due = t => { const d = t.dueDate && dueText(t.dueDate); return d ? `<span class="tb-chip${overdue(t) ? ' warn' : ''}" title="Due ${esc(t.dueDate.slice(0, 10))} (UTC)">${overdue(t) ? 'overdue' : 'due'} ${esc(d)}</span>` : ''; };
const title = t => esc(t.title || 'Untitled');
// The shared chips (workspace.js), so a card reads as the Inbox's rows do.
const orgChip = t => (snap.view && ws()?.orgChip ? ws().orgChip(snap.view, t.org, av(t.org, 'xs') || undefined) : `${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span>`);
const projChip = t => (projectName(t) ? (ws()?.projChip?.(projectName(t)) ?? `<span>${esc(projectName(t))}</span>`) : '');
function card(t) {
const w = taskWritable(t), k = esc(key(t));
return `<article class="tb-card ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${k}" data-org="${esc(t.org)}"${w ? ' draggable="true"' : ''}>
<div class="top">${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span><span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
<div class="top">${orgChip(t)}<span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
<div class="tt"><button type="button" class="tb-open" data-tb-open="${k}" data-focus-key="open:${k}">${title(t)}</button></div>
<div class="bot"><span>${projectName(t) ? esc(projectName(t)) : '<span class="muted">No project</span>'}</span>${who(t)}${prio(t)}${due(t)}</div>
<div class="bot">${projChip(t) || '<span><span class="muted">No project</span></span>'}${who(t)}${prio(t)}${due(t)}</div>
</article>`;
}
function kanban(rows) {
@@ -184,10 +236,10 @@
};
return `<div class="ws-tbl tb-list" data-tb-list><table><thead><tr><th scope="col">Org</th><th scope="col">Id</th><th scope="col">Task</th><th scope="col" class="hide-m">Project</th><th scope="col">State</th><th scope="col" class="hide-m">Assignee</th><th scope="col" class="hide-m">Priority</th><th scope="col" class="hide-m">Due</th></tr></thead><tbody>
${sorted.map(t => `<tr class="tb-row ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${esc(key(t))}" data-org="${esc(t.org)}">
<td>${av(t.org)}<span class="ws-sr">${esc(orgOf(t.org).label)}</span></td>
<td>${orgChip(t)}</td>
<td class="mono">${esc(shortId(t.ref))}<span class="t2">${t.tracker === 'vikunja' ? 'Vikunja' : 'built-in'}</span></td>
<td><button type="button" class="tb-open t1" data-tb-open="${esc(key(t))}" data-focus-key="lopen:${esc(key(t))}">${title(t)}</button><span class="t2 show-m">${esc(projectName(t) || 'No project')}</span></td>
<td class="hide-m">${projectName(t) ? esc(projectName(t)) : '<span class="muted">none</span>'}</td>
<td class="hide-m">${projChip(t) || '<span class="muted">none</span>'}</td>
<td>${stateCell(t)}</td>
<td class="hide-m">${t.assignees.length ? esc(t.assignees.join(', ')) : '<span class="muted">nobody</span>'}</td>
<td class="hide-m">${prio(t) || '<span class="muted">none</span>'}</td>
@@ -198,13 +250,15 @@
const rows = filtered(), all = snap.tasks.length;
// Project, state, assignee and label filter on the server, so the board never holds the unfiltered
// total; with one of those on, the line says how many match rather than inventing an "of N".
const onServer = new URLSearchParams(readQuery()).size > 1, n = rows.length;
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.</p>`
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.</p>` : '';
// 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 note = filter.done ? ` ${DONE_NOTE}` : '';
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.${note}</p>`
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.${note}</p>` : note ? `<p class="tb-count">${note.trim()}</p>` : '';
// 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}.`);
const body = !snap.orgs.length ? '<p class="ws-panel tight note">No organizations yet.</p>'
const body = !snap.orgs.length ? `<p class="ws-panel tight note">${scope ? 'This organization is not on your list.' : 'No organizations yet.'}</p>`
: !snap.orgs.some(tasksRead) ? '<p class="ws-panel tight note">No organization answered with its tasks, so no tasks could be read.</p>'
: !all && !onServer ? `<p class="ws-panel tight note">${esc(none('No tasks yet'))}</p>`
: !rows.length ? `<p class="ws-panel tight note">${esc(none('No tasks match these filters'))}</p>`
@@ -230,17 +284,33 @@
const seg = `<span class="ws-seg" role="group" aria-label="Layout"><button type="button" data-tb-layout="kanban" aria-pressed="${lay === 'kanban'}" data-focus-key="tb-layout-kanban">Kanban</button><button type="button" data-tb-layout="list" aria-pressed="${lay === 'list'}" data-focus-key="tb-layout-list">List</button></span>`;
const add = `<button type="button" class="ws-btn" data-tb-new data-focus-key="tb-new"${why ? ` disabled title="${esc(why)}"` : ''}>${icon('plus')}New task</button>`;
let sub = '';
if (snap) {
if (snap && scope) {
const n = snap.tasks.length, name = snap.orgs.find(o => o.id === scope)?.label || scope;
sub = `Tasks from ${esc(name)} only. ${n} ${n === 1 ? 'task' : 'tasks'}. <a href="#/tasks" data-focus-key="tasks-all">Every organization</a>`;
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
} else if (snap) {
const n = snap.tasks.length, m = snap.orgs.filter(tasksRead).length, of = snap.summary?.of ?? snap.orgs.length;
sub = `${n} ${n === 1 ? 'task' : 'tasks'} from ${m} of ${of} ${of === 1 ? 'organization' : 'organizations'}. Each organization keeps its own tasks; a change made here goes only to that organization's bus.`;
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
else {
const no = snap.asked.filter(id => !allowed.has(id)).map(id => esc(orgOf(id).label));
if (no.length) sub += ` This Console takes no task changes in ${no.length < 2 ? no[0] : `${no.slice(0, -1).join(', ')} and ${no.at(-1)}`}, so ${no.length < 2 ? 'its' : 'their'} tasks are read only here.`;
}
}
return `<div class="ws-vh"><h1 tabindex="-1">Tasks</h1><div class="ws-right">${seg}${add}</div>${sub ? `<p class="sub">${sub}</p>` : ''}</div>`;
}
const noticeLine = () => (notice ? `<p class="tb-notice${notice.error ? ' is-error' : ''}" role="${notice.error ? 'alert' : 'status'}" data-tb-notice>${esc(notice.text)}</p>` : '<p class="tb-notice" role="status" data-tb-notice></p>');
// An organization that did not answer is named first, with its reason, as on the Inbox.
function unreadLine() {
const v = snap?.view;
if (!v || !ws()?.unreadNote) return '';
const o = filter.org && !scope ? { ...v, rows: v.rows.filter(r => r.org === filter.org), unread: v.unread.filter(r => r.org === filter.org) } : v;
return ws().unreadNote(o, ['tasks'], 'tasks');
}
function markup() {
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}">${head()}${noticeLine()}${trackers()}${toolbar()}${results()}</section>`;
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}"${scope ? ` data-tb-scope="${esc(scope)}"` : ''}>${head()}${noticeLine()}${unreadLine()}${trackers()}${toolbar()}${results()}</section>`;
}
const failed = err => `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1><p class="sub">The task board could not be read${err?.message && err.message !== 'read failed' ? `: ${esc(err.message)}` : ''}. It tries again on the next refresh.</p></div></section>`;
@@ -266,7 +336,8 @@
// Repaint in place, for the board's own changes (filters, layout, a write).
function paint(part = 'all') {
const root = document.querySelector('[data-tasks-board]');
if (!root || !snap) return;
// Only over a board drawn from a read: never over the not-on-your-list page or a failed read.
if (!root || !snap || !root.querySelector('[data-tb-results]')) return;
const kept = snapshotView(root);
if (part === 'results') root.querySelector('[data-tb-results]').outerHTML = results();
else root.outerHTML = markup();
@@ -274,9 +345,44 @@
}
// bus.js's view for #/tasks. A drag in progress holds the poll's repaint until it ends.
async function view() {
// q is the address query; opts.scope the organization of #/orgs/<id>/tasks; opts.writesIn asks one
// organization's own config whether it takes task changes.
async function view(q = new URLSearchParams(), opts = {}) {
// An address that names an organization no id can match is not the system board.
// A read still in flight from the board left behind lands nowhere and paints nothing.
if (typeof opts.scope === 'string' && opts.scope && !ORG_ID.test(opts.scope)) { ++readSeq; snap = null; return `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1></div><p class="ws-panel tight note">This organization is not on your list.</p></section>`; }
const s = typeof opts.scope === 'string' && ORG_ID.test(opts.scope) ? opts.scope : null;
if (s !== scope) snap = null;
scope = s; writesIn = typeof opts.writesIn === 'function' ? opts.writesIn : null;
// The org and project filters come from the address on every render, checked; anything else is all.
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' : '';
// SP12: `assignee` in the address (the agent drawer's link) sets the Assignee filter once per new
// value; the select changes it after that, and a poll does not put the address's value back.
const who = String(q.get('assignee') || '');
// When the address drops it, the filter it set goes too, unless the select has changed it since.
if (who !== addrAssignee) { const was = addrAssignee; addrAssignee = who; if (ROLE.test(who)) filter.assignee = who; else if (!who && was && filter.assignee === was) filter.assignee = ''; }
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))]);
try { await reload(); } catch (err) { snap = null; return failed(err); }
// Two renders at once (a first load, a poll during a filter change): the replaced one draws from the newer read.
try { let p = reload(); while (!(await p) && newest !== p) p = newest; } catch (err) { snap = null; return failed(err); }
// A not-on-your-list page opened meanwhile dropped every read; bus.js drops this render as not current.
if (!snap) return '';
// #/orgs/<id>/tasks?task=<ref> opens that task once, after bus.js has drawn the board.
const want = scope ? String(q.get('task') || '') : '';
if (!want) autoOpened = null;
else if (autoOpened !== `${scope}|${want}`) {
autoOpened = `${scope}|${want}`;
const k = `${scope}|${want}`;
setTimeout(() => {
if (!document.querySelector(`[data-tasks-board][data-tb-scope="${CSS.escape(scope || '')}"]`) || autoOpened !== k) return;
const t = TASK_REF.test(want) ? find(k) : null;
if (t) { openDialog(editForm(t), `open:${key(t)}`, t); dialogEl().dataset.fromAddress = '1'; }
else { notice = { error: true, text: `Task ${shortId(want)} is not on this board.` }; paint(); }
}, 0);
}
// Taken after the read, just before bus.js replaces the view, so focus, caret or scroll moved while
// the read was in flight is what is kept. bus.js puts focus back by key; this puts back the rest.
const kept = snapshotView(document.querySelector('[data-tasks-board]'));
@@ -317,7 +423,13 @@
d = document.createElement('dialog');
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
document.body.append(d);
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; d.removeAttribute('aria-describedby'); opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
d.addEventListener('close', () => {
const back = d.dataset.return, fromAddress = d.dataset.fromAddress === '1';
d.innerHTML = ''; d.removeAttribute('aria-describedby'); delete d.dataset.fromAddress; opened = null;
if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus();
// A task the address opened leaves the address when it closes, so a reload does not reopen it.
if (fromAddress && /^#\/orgs\/[^/?]+\/tasks\?(?:.*&)?task=/.test(location.hash)) ws()?.setQuery?.({ task: null }, true);
});
}
return d;
}
@@ -348,14 +460,17 @@
function editForm(t, refusal = null) {
const o = orgOf(t.org), w = taskWritable(t);
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.'
: !allowed.has(t.org) ? `This Console takes no task changes in ${o.label}.` : !w ? 'The bus did not give this task a version to check a change against.' : '';
// On the system board the task also opens in its own organization.
const goto = scope ? '' : `<a class="ws-btn" href="${esc(`#/orgs/${encodeURIComponent(t.org)}/tasks?${new URLSearchParams({ task: t.ref })}`)}" data-tb-goto>Open in ${esc(o.label)}</a>`;
const facts = `<p class="tb-facts">${av(t.org, 'xs')}${esc(o.label)} · <span class="mono">${esc(shortId(t.ref))}</span> ${trk(t.tracker)}${t.requirement ? ` · <span class="mono">${esc(t.requirement)}</span>` : ''}${projectName(t) ? ` · ${esc(projectName(t))}` : ''}</p>`;
if (!w) {
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
<dl class="tb-dl"><dt>State</dt><dd>${esc(stateLabel(t.state) || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
<p class="tb-field-note">${esc(why)}</p>
${refusal ? `<p id="tb-dialog-status" class="tb-form-status" role="status" data-tb-form-status>${esc(refusal)}</p>` : ''}
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
<div class="tb-actions">${goto}<span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
}
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
<h2 id="tb-dialog-h">Edit ${esc(shortId(t.ref))}</h2>${facts}
@@ -363,11 +478,12 @@
<div class="tb-row2">${stateSelect(t.state)}${prioSelect(t.priority)}<label>Due date<input type="date" name="due_date" value="${dateValue(t.dueDate)}"></label></div>
${roleBoxes([...new Set([...o.roles, ...t.assignees])].sort(), t.assignees)}
<p class="tb-form-status" role="alert" data-tb-form-status></p>
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button><span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button>${goto}<span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
</form>`;
}
function openDialog(html, back, task = null) {
const d = dialogEl();
delete d.dataset.fromAddress;
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
if (d.querySelector('#tb-dialog-status')) d.setAttribute('aria-describedby', 'tb-dialog-status'); else d.removeAttribute('aria-describedby');
if (!d.open) d.showModal();
@@ -474,6 +590,8 @@
const d = e.target.closest?.('#tb-dialog');
if (d) {
if (e.target.closest('[data-tb-cancel]')) { e.preventDefault(); d.close(); }
// The link navigates; the dialog closes so the organization's board opens the task itself.
else if (e.target.closest('[data-tb-goto]')) { d.dataset.return = ''; d.close(); }
else if (e.target.closest('[data-tb-archive]')) { e.preventDefault(); archive(e.target.closest('form')); }
return;
}
@@ -482,10 +600,14 @@
if (lay) { layouts[phone() ? 'phone' : 'wide'] = lay.dataset.tbLayout; save(LAYOUT, JSON.stringify(layouts)); paint(); return; }
const chip = e.target.closest('[data-tb-org]');
if (chip) {
filter.org = chip.dataset.tbOrg;
if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) { filter.project = ''; refilter(); } else paint();
// The org filter lives in the address: paint now, then bus.js re-renders from the new query.
const org = chip.dataset.tbOrg, clear = filter.project && filter.project !== '-' && org && !filter.project.startsWith(`${org}:`);
filter.org = org; if (clear) filter.project = '';
paint();
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]');
@@ -500,6 +622,7 @@
}
if (!inBoard(e) || !snap) return;
const f = e.target.dataset?.tbFilter;
if (f === 'project') { filter.project = e.target.value; paint(); ws()?.setQuery?.({ project: e.target.value || null }, true); return; }
if (f) { filter[f] = e.target.value; refilter(); return; }
if (e.target.matches('[data-tb-move]')) move(e.target.dataset.tbMove, e.target.value);
});
+94
View File
@@ -0,0 +1,94 @@
/* SP9 (#1873): the read-only workflow pages (#/orgs/<id>/workflows[/<name>]) and the organization
page's Workflows card. Colours come from the theme tokens, so light and dark both follow the theme. */
.wfg{min-width:0}
.wfg h2{font-size:15px;margin:22px 0 8px}
.wfg-scope:first-of-type h2{margin-top:6px}
.wfg-list{list-style:none;margin:0;padding:0;background:var(--surface);border:1px solid var(--line);border-radius:10px}
.wfg-item{padding:12px 14px;border-top:1px solid var(--line-soft,var(--line));overflow-wrap:anywhere}
.wfg-item:first-child{border-top:0}
.wfg-item-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px}
.wfg-item-h a{font-weight:600;color:var(--action,var(--accent));text-decoration:none}
.wfg-item-h a:hover{text-decoration:underline}
.wfg-desc{margin:4px 0 0;color:var(--muted);font-size:13.5px}
.wfg-chain{list-style:none;display:flex;flex-wrap:wrap;gap:4px 0;margin:8px 0 0;padding:0;font-size:13px;color:var(--text)}
.wfg-chain li{display:inline-flex;align-items:center}
.wfg-chain li+li::before{content:"\2192";color:var(--muted);padding:0 8px}
:is(.wfg,.wfg-card) a:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:4px}
/* The graph scrolls inside its own frame when it is wider than the frame; the page never does. */
.wfg-frame{max-width:100%;overflow-x:auto;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px}
.wfg-graph{display:block}
.wfg-graph.is-vertical{width:100%;max-width:340px;height:auto;margin:0 auto}
.wfg-node{cursor:pointer;text-decoration:none}
.wfg-node rect{fill:var(--canvas);stroke:var(--line);stroke-width:1.5}
.wfg-node:hover rect{stroke:var(--muted)}
.wfg-node.is-sel rect{stroke:var(--accent);stroke-width:2.5;fill:var(--raised)}
.wfg-node:focus{outline:none}
.wfg-node:focus-visible rect{stroke:var(--focus);stroke-width:3}
.wfg-node text{font-family:var(--font);fill:var(--text)}
.wfg-meta{font-size:12px;fill:var(--muted)!important;letter-spacing:.02em}
.wfg-title{font-size:15px;font-weight:600;fill:var(--strong,var(--text))!important}
.wfg-roles{font-size:13px;font-family:var(--mono)!important;fill:var(--text)}
.wfg-miss{font-size:12px;fill:var(--danger)!important}
.wfg-node .wfg-none{fill:var(--muted);font-family:var(--font)}
.wfg-edge{fill:none;stroke:var(--muted);stroke-width:1.5}
.wfg-arrow{fill:var(--muted)}
.wfg-detail{margin-top:16px;overflow-wrap:anywhere}
.wfg-detail h2{margin:0 0 10px}
.wfg-dl{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:baseline;gap:8px 16px;margin:0 0 12px}
.wfg-dl dt{color:var(--muted);font-size:13px}
.wfg-dl dd{margin:0}
@media (max-width:700px){
.wfg-dl{grid-template-columns:minmax(0,1fr);gap:2px}
.wfg-dl dd{margin-bottom:8px}
}
/* The organization page's card, above Settings in the right column. */
.wfg-card>.ws-sec-h{margin-top:0}
.wfg-card+.ws-sec-h{margin-top:22px}
.wfg-cardlist{list-style:none;margin:0}
.wfg-cardlist li+li{border-top:1px solid var(--line-soft,var(--line))}
.wfg-cardlist a{display:block;padding:8px 0;color:var(--text);text-decoration:none;overflow-wrap:anywhere}
.wfg-cardlist a:hover strong{text-decoration:underline}
.wfg-cardlist strong{color:var(--action,var(--accent))}
.wfg-card [data-wfg-none]{margin:0 0 8px}
.wfg-card p:last-child{margin-bottom:0}
/* SP10 (#1873): editing a workflow in its graph. The bar above the graph holds Unsaved, Discard, Save and
Delete workflow; the step panel holds the step actions and the step form; the list page holds New workflow. */
html[data-organizations] :is(.wfg-bar,.wfg-step-acts,.wfg-form,.wfg-new) .ws-btn{min-height:32px}
html[data-organizations] :is(.wfg-bar,.wfg-form) .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
html[data-organizations] :is(.wfg-bar,.wfg-form) .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
html[data-organizations] :is(.wfg-bar,.wfg-step-acts) .ws-btn.danger{color:var(--danger)}
html[data-organizations] :is(.wfg-bar,.wfg-step-acts,.wfg-form,.wfg-new) .ws-btn:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
.wfg-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin:0 0 10px}
.wfg-acts{display:inline-flex;flex-wrap:wrap;align-items:center;gap:8px;margin-left:auto}
.wfg-bar:not(.is-dirty) .wfg-acts:empty{display:none}
.wfg-badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600;color:var(--strong,var(--text));background:color-mix(in srgb,var(--accent) 18%,var(--surface));border:1px solid color-mix(in srgb,var(--accent) 45%,var(--line))}
.wfg-confirm{display:inline-flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:13.5px}
.wfg-status,.wfg-stale{flex-basis:100%;margin:0;font-size:13.5px;overflow-wrap:anywhere}
.wfg-stale{color:var(--danger)}
.wfg-ro{margin:0 0 10px}
.wfg-step-acts{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px}
.wfg-form{display:grid;gap:10px;min-width:0}
.wfg-form h2{margin:0}
.wfg-form h3{font-size:14px;margin:6px 0 0}
.wfg-form>label,.wfg-form label:has(>select){display:grid;gap:4px;font-size:13px;color:var(--muted)}
.wfg-form input:not([type=checkbox]),.wfg-form select{font:14px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:6px;padding:6px 8px;min-width:0;max-width:100%}
.wfg-form :is(input,select):focus-visible{outline:2px solid var(--focus);outline-offset:1px}
.wfg-form .note{margin:0}
.wfg-roles-pick{border:1px solid var(--line);border-radius:8px;padding:8px 12px 10px;margin:0;min-width:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 12px}
.wfg-roles-pick legend{font-size:13px;color:var(--muted);padding:0 4px}
.wfg-pick-h{grid-column:1/-1;margin:6px 0 0;font-size:12px;color:var(--muted)}
.wfg-pick{display:flex;align-items:baseline;gap:6px;font-size:13.5px;color:var(--text);overflow-wrap:anywhere;min-width:0}
.wfg-pick input{margin:0;flex:none}
.wfg-why{margin:0;color:var(--danger);font-size:13.5px}
.wfg-why:empty{display:none}
.wfg-form-acts{display:flex;flex-wrap:wrap;gap:8px;margin:0}
.wfg-new{margin:0 0 12px}
.wfg-newform{margin:0 0 16px}
@media (max-width:700px){
.wfg-acts{margin-left:0}
.wfg-roles-pick{grid-template-columns:minmax(0,1fr)}
}
+575 -16
View File
@@ -33,7 +33,8 @@
const r = await fetch(url, method === 'GET' ? { cache: 'no-store' } : { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
let value = null;
try { value = await r.json(); } catch { value = null; }
if (!r.ok) throw Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`);
// The refusal code rides on the error, so a caller with its own words for one (the graph editor) can use them.
if (!r.ok) throw Object.assign(Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`), { code: value?.error ?? null });
return value;
}
const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v);
@@ -122,6 +123,40 @@
}
const flowsIn = (s, scope) => scope ? s.data.projects[scope] : s.data.workflows;
const target = (s, scope) => scope ? { project: scope } : {};
// SP10: the one check, body and write both editors use, the organization page's form and the graph.
// A step is {title, state, roles: [names]}. The order of the checks is the form's: the name, a name the
// scope already holds, a role that names no current role, then the shape the bus checks (organization-config
// checkWorkflows: 1 to 20 steps, each with a title and 1 to 8 distinct roles), named here before the bus
// refuses it in general words.
const rolesIn = text => String(text ?? '').split(',').map(r => r.trim()).filter(Boolean);
function stepRefusal(st, n) {
if (!String(st.title ?? '').trim()) return `Step ${n} needs a title.`;
if (!st.roles.length) return `Step ${n} needs at least one role.`;
if (st.roles.length > 8) return `Step ${n} names ${st.roles.length} roles. A step takes at most eight.`;
if (new Set(st.roles).size !== st.roles.length) return `Step ${n} names the same role twice.`;
return '';
}
function refusal(s, { isNew, scope, name, steps }) {
if (!NAME.test(name)) return NAME_RULE;
// A save is a set: a new workflow under a name the scope already holds would replace it without a word.
if (isNew && Object.hasOwn(flowsIn(s, scope) ?? {}, name)) return `A workflow named ${name} already exists here. Edit it instead.`;
// E2E6 P2-1: a step role that names no current role is named before the bus refuses it generically.
const unknown = steps.flatMap((st, i) => st.roles.filter(r => !s.known.has(r)).map(r => [i + 1, r]));
if (unknown.length) return `${unknown.map(([i, r]) => `Step ${i}: ${r}`).join('; ')} ${unknown.length === 1 ? 'names' : 'name'} no current role. Use an organization role, a role template or human.`;
if (!steps.length) return 'A workflow needs at least one step.';
if (steps.length > 20) return 'A workflow has at most 20 steps.';
for (const [i, st] of steps.entries()) { const why = stepRefusal(st, i + 1); if (why) return why; }
return '';
}
const shape = ({ title = '', description = '', steps }) => ({
...(title.trim() ? { title: title.trim() } : {}),
...(description.trim() ? { description: description.trim() } : {}),
steps: steps.map(st => ({ title: st.title.trim(), state: st.state, roles: [...st.roles] })),
});
const flowUrl = (org, name) => `${base(org)}/workflows/${encodeURIComponent(name)}`;
// The revision is the one the editor opened on: a change made since then is a revision-conflict, not overwritten.
const saveFlow = (org, s, { scope, name, revision, workflow }) => call(flowUrl(org, name), 'POST', { revision, workflow, ...target(s, scope) });
const removeFlow = (org, s, { scope, name, revision }) => call(flowUrl(org, name), 'DELETE', { revision, ...target(s, scope) });
async function reload(org, s, status) {
try { await read(org, s); s.status = status; } catch (error) { s.status = `${status} ${error.message}`; }
}
@@ -149,7 +184,7 @@
} else if (button.hasAttribute('data-wf-delete-yes')) {
button.disabled = true;
try {
await call(`${base(org)}/workflows/${encodeURIComponent(name)}`, 'DELETE', { revision: s.data.revision, ...target(s, scope) });
await removeFlow(org, s, { scope, name, revision: s.data.revision });
s.confirm = null;
await reload(org, s, `Deleted ${name}.`);
} catch (error) { s.status = error.message; }
@@ -176,26 +211,550 @@
if (!s?.editing) return;
capture(root, s);
const e = s.editing;
if (!NAME.test(e.name)) { s.status = NAME_RULE; redraw(org); return; }
// A save is a set: a new workflow under a name the scope already holds would replace it without a word.
if (e.isNew && Object.hasOwn(flowsIn(s, e.scope) ?? {}, e.name)) { s.status = `A workflow named ${e.name} already exists here. Edit it instead.`; redraw(org); return; }
// E2E6 P2-1: a step role that names no current role is named before the bus refuses it generically.
const unknown = e.draft.steps.flatMap((st, i) => st.roles.split(',').map(r => r.trim()).filter(r => r && !s.known.has(r)).map(r => [i + 1, r]));
if (unknown.length) { s.status = `${unknown.map(([i, r]) => `Step ${i}: ${r}`).join('; ')} ${unknown.length === 1 ? 'names' : 'name'} no current role. Use an organization role, a role template or human.`; redraw(org); return; }
const workflow = {
...(e.draft.title.trim() ? { title: e.draft.title.trim() } : {}),
...(e.draft.description.trim() ? { description: e.draft.description.trim() } : {}),
steps: e.draft.steps.map(st => ({ title: st.title.trim(), state: st.state, roles: st.roles.split(',').map(r => r.trim()).filter(Boolean) })),
};
const draft = { ...e.draft, steps: e.draft.steps.map(st => ({ ...st, roles: rolesIn(st.roles) })) };
const why = refusal(s, { isNew: e.isNew, scope: e.scope, name: e.name, steps: draft.steps });
if (why) { s.status = why; redraw(org); return; }
const workflow = shape(draft);
const button = ev.target.querySelector('button[type="submit"]');
button.disabled = true;
try {
// The revision the editor opened on: a change made since then is a revision-conflict, not overwritten.
await call(`${base(org)}/workflows/${encodeURIComponent(e.name)}`, 'POST', { revision: e.revision ?? s.data.revision, workflow, ...target(s, e.scope) });
await saveFlow(org, s, { scope: e.scope, name: e.name, revision: e.revision ?? s.data.revision, workflow });
s.editing = null;
await reload(org, s, `Saved ${e.name}.`);
} catch (error) { s.status = error.message; }
redraw(org);
});
window.MosaicWorkflows = { load, section };
// ---------- SP9 (#1873): the read-only view ----------
// #/orgs/<id>/workflows lists the organization's workflows; #/orgs/<id>/workflows/<name>[?project=<p>]
// 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')
: `<p class="note ws-unread-note" data-ws-unread>Not read: ${esc(row.label || org)} ${row.busStopped ? 'bus stopped' : 'bus not answering'}. No reason was read.</p>`;
}
return { row, unread, stopped: !row.busHealthy && row.busStopped === true, label: row.label || org };
}
const head = (title, sub) => `<div class="ws-vh"><h1 tabindex="-1">${title}</h1><p class="sub">${sub}</p></div>`;
const applyLink = (org, s) => (s?.data?.writable
? `<p><a class="ws-btn" href="${esc(`${orgHash(org)}/organization`)}" data-wfg-apply data-focus-key="wfg-apply">Apply a pack</a></p>`
: '<p class="note">A starter pack brings workflows. Only the owner, on a Console started with send, can apply one.</p>');
const none = (org, s, label) => `<p class="note" data-wfg-none>No workflows in ${esc(label)} yet.</p>${applyLink(org, s)}`;
// 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;
// SP10 (sp9 follow-up): box text is fitted by its measured width in the font it is drawn in, not by a
// character count, so a line ends inside its box with an ellipsis and a short name shows whole. The clip
// path stays as a backstop. Without a canvas the old per-character estimate is used.
let ctx;
function measure(text, font, per) {
if (ctx === undefined) { try { ctx = document.createElement('canvas').getContext('2d'); } catch { ctx = null; } }
if (!ctx) return text.length * per;
ctx.font = font;
return ctx.measureText(text).width;
}
const token = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const fonts = () => ({
title: [`600 15px ${token('--font', 'sans-serif')}`, 8.6],
roles: [`13px ${token('--mono', 'monospace')}`, 7.9],
miss: [`12px ${token('--font', 'sans-serif')}`, 6.8],
});
function fit(text, [font, per], max) {
if (measure(text, font, per) <= max) return text;
let lo = 0, hi = text.length;
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (measure(`${text.slice(0, mid)}…`, font, per) <= max) lo = mid; else hi = mid - 1; }
return `${text.slice(0, lo)}…`;
}
// Up to two lines broken at spaces; the second ends in … when the title is longer.
function wrap(text, f, max) {
const words = String(text ?? '').split(/\s+/).filter(Boolean), lines = [''];
let over = false;
for (const w of words) {
const cur = lines[lines.length - 1], next = cur ? `${cur} ${w}` : w;
if (!cur || measure(next, f[0], f[1]) <= max) lines[lines.length - 1] = next;
else if (lines.length < 2) lines.push(w);
else { lines[1] = `${lines[1]} ${w}`; over = true; }
}
if (measure(lines[0], f[0], f[1]) > max) return [fit(lines[0], f, max)];
if (lines[1] && (over || measure(lines[1], f[0], f[1]) > max)) lines[1] = fit(lines[1], f, max);
return lines;
}
function graph(org, s, scope, name, steps, sel) {
const vertical = narrow(), f = fonts();
const W = vertical ? 300 : 188, GAP = vertical ? 32 : 36, PAD = 14;
// Text runs from x+12 and ends 12px before the box edge; 2px more absorbs a font that loads after the measure.
const room = W - 26;
// 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`]; };
// A role that names no current role: "No current role:" and then each such role on its own line, at most
// two lines, so the whole name shows (a name longer than the box ends in …).
const missing = st => rolesOf(st).filter(r => !s.known.has(r));
const missLines = st => { const m = missing(st); return m.length <= 2 ? m : [m[0], `+${m.length - 1} more`]; };
const titleEnd = st => 46 + (wrap(st.title, f.title, room).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 lines if any.
const bottom = st => (missing(st).length ? missY(st) + missLines(st).length * 15 : missY(st) - 18);
const H = Math.max(...steps.map(st => bottom(st) + 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 `<path class="wfg-edge" d="${d}" data-wfg-edge="${i + 1}-${i + 2}" marker-end="url(#wfg-arrow)"/>`;
}).join('');
const nodes = steps.map((st, i) => {
const [x, y] = pos(i), n = i + 1, roles = rolesOf(st), miss = missing(st);
const lines = wrap(st.title, f.title, room), on = sel === n;
const label = `Step ${n} of ${steps.length}: ${st.title ?? ''}. ${stateLabel(st)}. Roles ${roles.join(', ')}${miss.length ? `; ${miss.join(', ')} ${MISSING}` : ''}.`;
const rl = roleLines(st), more = roles.length > 3;
const roleText = rl.length
? rl.map((r, j) => `<tspan x="${x + 12}" dy="${j ? 17 : 0}"${more && j === rl.length - 1 ? ' data-wfg-more' : ''}>${esc(more && j === rl.length - 1 ? r : fit(r, f.roles, room))}</tspan>`).join('')
: '<tspan class="wfg-none">No roles recorded</tspan>';
const missText = miss.length
? `<text class="wfg-miss" data-wfg-missing x="${x + 12}" y="${y + missY(st)}"><tspan x="${x + 12}" dy="0">No current role:</tspan>${missLines(st).map((r, j) => `<tspan x="${x + 12}" dy="15"${miss.length > 2 && j === 1 ? ' data-wfg-more' : ''}>${esc(miss.length > 2 && j === 1 ? r : fit(r, f.miss, room))}</tspan>`).join('')}</text>`
: '';
return `<a class="wfg-node${on ? ' is-sel' : ''}" href="${esc(flowHref(org, scope, name, n))}" data-wfg-step="${n}" data-focus-key="${esc(`wfg-step:${n}`)}" aria-label="${esc(label)}"${on ? ' aria-current="step"' : ''}>
<title>${esc(`${st.title ?? ''}${roles.length ? ` (${roles.join(', ')})` : ''}`)}</title><rect x="${x}" y="${y}" width="${W}" height="${H}" rx="10"/>
<clipPath id="wfg-clip-${n}"><rect x="${x + 2}" y="${y}" width="${W - 10}" height="${H}"/></clipPath>
<g clip-path="url(#wfg-clip-${n})">
<text class="wfg-meta" x="${x + 12}" y="${y + 22}">${esc(fit(`Step ${n} · ${stateLabel(st)}`, f.miss, room))}</text>
<text class="wfg-title" data-wfg-title x="${x + 12}" y="${y + 46}">${lines.map((l, j) => `<tspan x="${x + 12}" dy="${j ? 19 : 0}">${esc(l)}${j < lines.length - 1 ? ' ' : ''}</tspan>`).join('')}</text>
<text class="wfg-roles" data-wfg-roles x="${x + 12}" y="${y + rolesY(st)}">${roleText}</text>
${missText}</g></a>`;
}).join('');
return `<svg class="wfg-graph${vertical ? ' is-vertical' : ''}" data-wfg-graph role="group" aria-label="${esc(`${count(steps.length)}, in order`)}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs><marker id="wfg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto"><path class="wfg-arrow" d="M0 0L10 5L0 10z"/></marker></defs>${edges}${nodes}</svg>`;
}
// ---------- SP10 (#1873): editing a workflow in its graph ----------
// A change is held in the page, not written, until Save: the graph draws the pending workflow, the bar says
// Unsaved, and Save sends one POST with the revision the page read (saveFlow, the form's write). A refusal is
// shown in words and the pending change kept. The poll redraws from the pending workflow, never over it.
// When the file changes while a change is pending: if this workflow is as the page read it, the new revision
// is taken (a workflow write sets only this workflow, so nothing else is overwritten); if this workflow
// changed, the bar says so and offers Discard. Drafts live per organization until saved or discarded, and
// the organization-switch and restart guards ask before dropping one (hasDraft).
const drafts = new Map(), notes = new Map(), newForms = new Map();
const dkey = (org, scope, name) => JSON.stringify([org, scope, name]);
const norm = w => ({
title: typeof w?.title === 'string' ? w.title : '', description: typeof w?.description === 'string' ? w.description : '',
steps: stepsOf(w).map(st => ({ title: String(st.title ?? ''), state: st.state, roles: rolesOf(st) })),
});
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const changed = d => d.isNew || !same(d.flow, d.base);
const editDirty = e => !!e && !same({ title: e.title, state: e.state, roles: e.roles }, e.orig);
const READ_ONLY = 'Only the owner, on a Console started with send, can change workflows.';
// The graph's own words for a refusal where the form's would mislead: the change is kept, not lost.
const kept = {
'revision-conflict': 'Not saved: the organization file changed since this page read it. Your change is kept. Discard to load the current version.',
'organization-busy': 'Not saved: the organization is busy with another write. Your change is kept. Try Save again shortly.',
'no-bus-host': 'Not saved: the organization bus is not running. Your change is kept. Save again once it runs.',
'invalid-request': 'Not saved: the bus refused the workflow. Check each step has a title, a state and one to eight distinct roles. Your change is kept.',
};
const say = text => { const live = document.getElementById('announce'); if (live) { live.textContent = ''; live.textContent = text; } };
// A pending change measured against the fresh read; drops one with nothing left to keep.
function settle(org, s, scope, name, fresh) {
const key = dkey(org, scope, name), d = drafts.get(key);
if (!d) return null;
if (d.busy) return d;
const now = fresh ? norm(fresh) : null;
if (d.isNew) { d.stale = !!fresh; if (!fresh) d.revision = s.data.revision; }
else if (now && same(now, d.base)) { d.revision = s.data.revision; d.stale = false; }
else if (now && !changed(d)) {
Object.assign(d, { base: now, flow: structuredClone(now), revision: s.data.revision, stale: false });
if (d.edit?.mode === 'edit' && d.edit.i >= now.steps.length) d.edit = null;
} else d.stale = true;
if (!changed(d) && !d.edit && !d.confirm && !d.status) { drafts.delete(key); return null; }
return d;
}
function draftFor(org, s, scope, name) {
const key = dkey(org, scope, name);
if (drafts.has(key)) return drafts.get(key);
const fresh = flowsIn(s, scope)?.[name];
if (!fresh) return null;
const base = norm(fresh), d = { isNew: false, base, flow: structuredClone(base), revision: s.data.revision, edit: null, confirm: false, status: '', busy: false, stale: false };
drafts.set(key, d);
notes.delete(key);
return d;
}
// The role picker: human, the organization's roles, the role templates, and first any role the step names
// that resolves to none of those (checked and marked, so leaving it in is a choice the save check names).
function rolePick(s, chosen, prefix) {
const defs = [...(s.data.roles.definitions || [])].sort(), tpl = [...(s.data.roles.templates || [])].filter(r => !defs.includes(r)).sort();
const odd = chosen.filter(r => !s.known.has(r));
const box = (r, mark = '') => `<label class="wfg-pick"><input type="checkbox" name="role" value="${esc(r)}"${chosen.includes(r) ? ' checked' : ''} data-focus-key="${esc(`${prefix}:role:${r}`)}"> <code>${esc(r)}</code>${mark}</label>`;
const group = (title, list, mark) => (list.length ? `<p class="wfg-pick-h">${title}</p>${list.map(r => box(r, mark)).join('')}` : '');
return `<fieldset class="wfg-roles-pick" data-wfg-roles-pick><legend>Roles, one to eight</legend>
${group('Named in this step', odd, ` <span class="wf-missing" data-wfg-pick-missing>${MISSING}</span>`)}${box('human', ' <span class="muted">a person</span>')}
${group('This organization\'s roles', defs.filter(r => r !== 'human'), '')}${group('Role templates', tpl.filter(r => r !== 'human'), '')}</fieldset>`;
}
const stateSelect = (value, key) => `<label>State<select name="state" data-focus-key="${esc(key)}">${STATES.map(([v, l]) => `<option value="${v}"${value === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
function stepForm(org, s, scope, name, d) {
const e = d.edit, n = e.i + 1;
return `<form class="wfg-form" data-wfg-form data-live-key="${esc(`wfg-form:${org}:${scope}:${name}:${e.mode}:${e.i}`)}" data-live-hold novalidate>
<h2 data-wfg-form-title>${e.mode === 'add' ? `New step ${n}` : `Edit step ${n}`}</h2>
<label>Title<input name="title" maxlength="120" value="${esc(e.title)}" autocomplete="off" data-focus-key="wfg-f-title"></label>
${stateSelect(e.state, 'wfg-f-state')}${rolePick(s, e.roles, 'wfg-f')}
<p class="wfg-why" data-wfg-why>${esc(e.why ?? '')}</p>
<p class="wfg-form-acts"><button type="submit" class="ws-btn primary" data-wfg-apply data-focus-key="wfg-f-apply">${e.mode === 'add' ? 'Add step' : 'Apply'}</button> <button type="button" class="ws-btn" data-wfg-cancel data-focus-key="wfg-f-cancel">Cancel</button></p>
<p class="note">Apply changes the graph on this page. Save writes it to the organization file.</p></form>`;
}
function bar(org, s, scope, name, d) {
if (!s.data.writable) return `<p class="note wfg-ro" data-wfg-readonly>${READ_ONLY}</p>`;
const dirty = !!d && changed(d), busy = d?.busy ? ' disabled' : '';
const status = d?.status || notes.get(dkey(org, scope, name)) || '';
const stale = d?.stale ? `<p class="wfg-stale" data-wfg-stale>${d.isNew ? `A workflow named ${esc(name)} now exists here. Save will be refused. Discard to drop this one.` : 'The organization file changed this workflow since the page read it. Save will be refused. Discard to load the current version.'}</p>` : '';
const acts = d?.confirm
? `<span class="wfg-confirm" data-wfg-confirm>Delete ${esc(name)} from the organization file? <button type="button" class="ws-btn danger" data-wfg-delete-yes data-focus-key="wfg-delete-yes"${busy}>Delete workflow</button> <button type="button" class="ws-btn" data-wfg-delete-no data-focus-key="wfg-delete-no"${busy}>Keep</button></span>`
: `${dirty ? `<button type="button" class="ws-btn" data-wfg-discard data-focus-key="wfg-discard"${busy}>Discard</button> <button type="button" class="ws-btn primary" data-wfg-save data-focus-key="wfg-save"${busy}>${d.busy ? 'Saving…' : 'Save'}</button>` : ''}${d?.isNew ? '' : ` <button type="button" class="ws-btn danger" data-wfg-delete data-focus-key="wfg-delete"${busy}>Delete workflow</button>`}`;
return `<div class="wfg-bar${dirty ? ' is-dirty' : ''}" data-wfg-bar>${dirty ? `<span class="wfg-badge" data-wfg-unsaved>Unsaved${d.isNew ? ', not in the file yet' : ''}</span>` : ''}<span class="wfg-acts">${acts}</span>
${status ? `<p class="wfg-status" data-wfg-status>${esc(status)}</p>` : ''}${stale}</div>`;
}
const stepRef = (org, scope, name, steps, n) => `<a href="${esc(flowHref(org, scope, name, n))}" data-wfg-go="${n}">Step ${n}, ${esc(steps[n - 1].title)}</a>`;
function actions(steps, sel, d) {
const busy = d?.busy ? ' disabled' : '';
if (!sel) return `<p class="wfg-step-acts" data-wfg-actions><button type="button" class="ws-btn" data-wfg-add data-focus-key="wfg-add"${busy}>Add a step at the end</button></p>`;
const dis = c => (c || d?.busy ? ' disabled' : '');
return `<p class="wfg-step-acts" data-wfg-actions><button type="button" class="ws-btn" data-wfg-edit data-focus-key="wfg-edit"${busy}>Edit step</button>
<button type="button" class="ws-btn" data-wfg-add data-focus-key="wfg-add"${dis(steps.length >= 20)}>Add a step after</button>
<button type="button" class="ws-btn" data-wfg-up data-focus-key="wfg-up"${dis(sel === 1)}>Move earlier</button>
<button type="button" class="ws-btn" data-wfg-down data-focus-key="wfg-down"${dis(sel === steps.length)}>Move later</button>
<button type="button" class="ws-btn danger" data-wfg-remove data-focus-key="wfg-remove"${dis(steps.length === 1)}>Remove step</button></p>`;
}
function detail(org, s, scope, name, steps, sel, d) {
const writable = !!s.data.writable;
if (writable && d?.edit) return `<section class="panel wfg-detail" data-wfg-detail>${stepForm(org, s, scope, name, d)}</section>`;
if (!sel) return `<section class="panel wfg-detail" data-wfg-detail><h2>Step details</h2><p class="note">Select a step to see its roles, the state it puts a task in and the step it hands on to.</p>${writable ? actions(steps, 0, d) : ''}</section>`;
const st = steps[sel - 1], roles = rolesOf(st);
const roleList = roles.map(r => r === 'human' ? '<code>human</code> (a person)' : s.known.has(r) ? `<code>${esc(r)}</code>` : `<code>${esc(r)}</code> <span class="wf-missing" data-wfg-missing>${MISSING}</span>`).join(', ');
return `<section class="panel wfg-detail" data-wfg-detail><h2 data-wfg-detail-title>${esc(st.title)}</h2><dl class="wfg-dl">
<dt>Position</dt><dd data-wfg-field="position">Step ${sel} of ${steps.length}</dd>
<dt>Roles</dt><dd data-wfg-field="roles">${roleList || '<span class="muted">None recorded</span>'}</dd>
<dt>Puts the task in</dt><dd data-wfg-field="state">${esc(stateLabel(st))}</dd>
<dt>Comes after</dt><dd data-wfg-field="after">${sel > 1 ? stepRef(org, scope, name, steps, sel - 1) : 'Nothing: this is the first step'}</dd>
<dt>Hands on to</dt><dd data-wfg-field="next">${sel < steps.length ? stepRef(org, scope, name, steps, sel + 1) : 'Nothing: this is the last step'}</dd>
</dl>${writable ? actions(steps, sel, d) : ''}<p class="note">The workflow file records no gate or inputs for a step, and no read says which step a task is on now.</p></section>`;
}
function graphBody(org, s, name, q) {
const scope = q.get('project') || '';
if (scope && !Object.hasOwn(s.data.projects, scope)) return { title: esc(name), sub: '', body: `<p class="note" data-wfg-unknown>No code project named ${esc(scope)} in the organization file.</p>` };
const flows = flowsIn(s, scope), fresh = plain(flows) && Object.hasOwn(flows, name) ? flows[name] : null;
const d = s.data.writable ? settle(org, s, scope, name, fresh) : null;
const note = notes.get(dkey(org, scope, name));
if (!fresh && !d) return { title: esc(name), sub: '', body: `${note ? `<p class="wfg-status" data-wfg-status>${esc(note)}</p>` : ''}<p class="note" data-wfg-unknown>No workflow named ${esc(name)} in ${scope ? `project ${esc(scope)}` : 'the organization'}.</p>` };
const w = d ? d.flow : norm(fresh), steps = w.steps;
const raw = Number(q.get('step')), sel = Number.isInteger(raw) && raw >= 1 && raw <= steps.length ? raw : 0;
const sub = `${esc(scopeLabel(scope))}, <code>${esc(name)}</code>, ${count(steps.length)}.${w.description ? ` ${esc(w.description)}` : ''}`;
const body = steps.length
? `${bar(org, s, scope, name, d)}<div class="wfg-frame" data-wfg-frame>${graph(org, s, scope, name, steps, sel)}</div>${detail(org, s, scope, name, steps, sel, d)}`
: `${bar(org, s, scope, name, d)}<p class="note">This workflow has no steps.</p>`;
return { title: esc(w.title || name), sub, body };
}
// The list page's New workflow form: where, a name, and the first step. Create opens it in the graph, unsaved.
function newForm(org, s) {
const f = newForms.get(org);
if (!f) return `<p class="wfg-new"><button type="button" class="ws-btn" data-wfg-new data-focus-key="wfg-new">New workflow</button></p>`;
return `<form class="wfg-form wfg-newform panel" data-wfg-form data-wfg-newform data-live-key="${esc(`wfg-new:${org}`)}" data-live-hold novalidate>
<h2>New workflow</h2>
<label>Where<select name="scope" data-focus-key="wfg-n-scope">${scopes(s).map(([v, l]) => `<option value="${esc(v)}"${f.scope === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
<label>Name<input name="name" maxlength="64" value="${esc(f.name)}" autocomplete="off" placeholder="lowercase letters, digits, dashes" data-focus-key="wfg-n-name"></label>
<p class="note">${NAME_RULE}</p>
<h3>First step</h3>
<label>Title<input name="title" maxlength="120" value="${esc(f.title)}" autocomplete="off" data-focus-key="wfg-n-title"></label>
${stateSelect(f.state, 'wfg-n-state')}${rolePick(s, f.roles, 'wfg-n')}
<p class="wfg-why" data-wfg-why>${esc(f.why ?? '')}</p>
<p class="wfg-form-acts"><button type="submit" class="ws-btn primary" data-wfg-create data-focus-key="wfg-n-create">Create</button> <button type="button" class="ws-btn" data-wfg-new-cancel data-focus-key="wfg-n-cancel">Cancel</button></p>
<p class="note">Create opens it in its graph. It is written to the organization file when you save it there.</p></form>`;
}
// The rows each scope lists: the file's workflows, and any new one not saved yet.
function rowsWithDrafts(org, s) {
return listed(s).map(([scope, label, rows]) => {
const names = new Set(rows.map(([n]) => n));
const fresh = [...drafts.entries()].map(([k, d]) => [JSON.parse(k), d]).filter(([[o, sc, n], d]) => o === org && sc === scope && d.isNew && !names.has(n)).map(([[, , n], d]) => [n, d.flow]);
return [scope, label, [...rows, ...fresh].sort(([p], [q]) => (p < q ? -1 : p > q ? 1 : 0))];
});
}
function listBody(org, s, label) {
const writable = !!s.data.writable, rows = writable ? rowsWithDrafts(org, s) : listed(s);
const top = `${notes.has(dkey(org, '', '')) ? `<p class="wfg-status" data-wfg-status>${esc(notes.get(dkey(org, '', '')))}</p>` : ''}${writable ? newForm(org, s) : `<p class="note wfg-ro" data-wfg-readonly>${READ_ONLY}</p>`}`;
if (!rows.some(([, , r]) => r.length)) return `${top}${none(org, s, label)}`;
return top + rows.filter(([scope, , r]) => !scope || r.length).map(([scope, label2, r]) => `<section class="wfg-scope" data-wfg-scope-list="${esc(scope)}"><h2>${esc(label2)}</h2>${r.length ? `<ul class="wfg-list">${r.map(([name, w]) => {
const steps = stepsOf(w), d = drafts.get(dkey(org, scope, name)), pending = d && changed(d);
return `<li class="wfg-item" data-wfg-item="${esc(name)}" data-wfg-scope="${esc(scope)}"><div class="wfg-item-h"><a href="${esc(flowHref(org, scope, name))}" data-wfg-open data-focus-key="${esc(`wfg-open:${scope}:${name}`)}">${esc(w?.title || name)}</a>${w?.title ? ` <code>${esc(name)}</code>` : ''} <span class="muted">${count(steps.length)}</span>${pending ? ` <span class="wfg-badge" data-wfg-unsaved>Unsaved${d.isNew ? ', not in the file yet' : ''}</span>` : ''}</div>
${w?.description ? `<p class="wfg-desc">${esc(w.description)}</p>` : ''}<ol class="wfg-chain" data-wfg-chain aria-label="Steps in order">${steps.map(st => `<li>${esc(st.title)}</li>`).join('')}</ol></li>`;
}).join('')}</ul>` : '<p class="note">None at the organization level.</p>'}</section>`).join('');
}
// The page for #/orgs/<org>/workflows (name null) or #/orgs/<org>/workflows/<name>. page() reads; compose()
// draws from what was read, so an edit on the page redraws at once (paint) without waiting for a read.
const shown = { org: null, name: null, info: null };
function compose(org, name, q, info) {
const attr = name === null ? 'data-wfg-list' : 'data-wfg-page';
const wrapIt = (title, sub, body) => `<article class="ws wfg" ${attr} data-wfg-org="${esc(org)}"${name === null ? '' : ` data-wfg-name="${esc(name)}"`}>${head(title, sub)}${info.unread}${body}</article>`;
// Review P3: a change held for this organization that the page cannot draw now is said to be kept.
const held = [...drafts].filter(([k, d]) => JSON.parse(k)[0] === org && (changed(d) || editDirty(d.edit))).map(([k]) => JSON.parse(k)[2]);
const keptNote = held.length ? `<p class="note" data-wfg-held>Unsaved change to ${held.map(n => esc(n)).join(', ')} is kept on this page. It comes back when the workflows can be read.</p>` : '';
// 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)}.`, keptNote);
const s = states.get(org);
if (!s?.data) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, `<p class="note" data-wfg-error>${esc(s?.error ?? 'Workflows could not be read.')}</p>${keptNote}`);
if (name === null) return wrapIt('Workflows', `The steps a task goes through in ${esc(info.label)}, in order, with the roles that do each.${s.data.writable ? ' Open one to change it.' : ''}`, listBody(org, s, info.label));
const g = graphBody(org, s, name, q);
return wrapIt(g.title, g.sub, g.body);
}
async function page(org, name, q) {
const info = await orgOf(org);
if (!info.row) return `<article class="ws wfg" ${name === null ? 'data-wfg-list' : 'data-wfg-page'} data-wfg-org="${esc(org)}">${head('Organization unavailable', info.list ? 'This organization is not on your list.' : 'The organization list could not be read.')}</article>`;
if (!info.stopped) await load(org);
Object.assign(shown, { org, name, info });
return compose(org, name, q, info);
}
const query = () => new URLSearchParams(location.hash.split('?')[1] || '');
// Redraw the open workflow page from the page's state, then put focus on `focus` (a data-focus-key).
function paint(focus) {
const el = document.querySelector('#bus-view [data-wfg-list], #bus-view [data-wfg-page]');
if (!el || !shown.info || el.dataset.wfgOrg !== shown.org || (el.dataset.wfgName ?? null) !== shown.name) return;
el.outerHTML = compose(shown.org, shown.name, query(), shown.info);
const el2 = focus && document.querySelector(`#bus-view [data-focus-key="${CSS.escape(focus)}"]`);
if (!el2) return;
el2.focus();
// A text field takes focus with the caret after its text, ready to change it.
if (el2.matches('input:not([type=checkbox])')) el2.setSelectionRange(el2.value.length, el2.value.length);
}
// The open graph page: its organization, scope, name, read and pending change.
function here(el) {
const root = el.closest('[data-wfg-page]');
if (!root || root.dataset.wfgOrg !== shown.org) return null;
const org = shown.org, name = shown.name, scope = query().get('project') || '', s = states.get(org);
if (!s?.data?.writable) return null;
return { org, name, scope, s, key: dkey(org, scope, name) };
}
const selected = () => { const n = Number(query().get('step')); return Number.isInteger(n) && n > 0 ? n : 0; };
const select = step => WS()?.setQuery?.({ step: step || null }, false);
function openForm(d, mode, i, st) {
const roles = st ? [...st.roles] : [];
d.edit = { mode, i, title: st?.title ?? '', state: st?.state ?? 'todo', roles, why: '', orig: { title: st?.title ?? '', state: st?.state ?? 'todo', roles: [...roles] } };
}
function readForm(form, into) {
into.title = form.elements.title.value;
into.state = form.elements.state.value;
into.roles = [...form.querySelectorAll('input[name="role"]:checked')].map(i => i.value);
if (form.elements.scope) into.scope = form.elements.scope.value;
if (form.elements.name) into.name = form.elements.name.value.trim();
}
function why(form, into, text) {
into.why = text;
// Direct text: the form may be the held node a repaint grafted back.
const p = form.querySelector('[data-wfg-why]');
if (p) p.textContent = text;
say(text);
}
document.addEventListener('click', async ev => {
const btn = ev.target.closest('[data-wfg-new],[data-wfg-new-cancel],[data-wfg-edit],[data-wfg-add],[data-wfg-up],[data-wfg-down],[data-wfg-remove],[data-wfg-cancel],[data-wfg-discard],[data-wfg-save],[data-wfg-delete],[data-wfg-delete-no],[data-wfg-delete-yes]');
if (!btn || btn.disabled) return;
if (btn.matches('[data-wfg-new],[data-wfg-new-cancel]')) {
const org = btn.closest('[data-wfg-list]')?.dataset.wfgOrg;
if (!org || org !== shown.org) return;
if (btn.hasAttribute('data-wfg-new')) { newForms.set(org, { scope: '', name: '', title: '', state: 'todo', roles: [], why: '' }); notes.delete(dkey(org, '', '')); paint('wfg-n-name'); }
else { newForms.delete(org); paint('wfg-new'); }
return;
}
const at = here(btn);
if (!at) return;
const { org, name, scope, s, key } = at;
const sel = selected();
if (btn.hasAttribute('data-wfg-discard')) {
const d = drafts.get(key);
drafts.delete(key);
if (d?.isNew) { location.hash = `${orgHash(org)}/workflows`; return; }
say(`Discarded the change to ${name}.`);
return paint('wfg-delete');
}
if (btn.hasAttribute('data-wfg-save')) return save(at, btn);
if (btn.matches('[data-wfg-delete-yes]')) return remove(at);
const d = draftFor(org, s, scope, name) ?? drafts.get(key);
if (!d) return;
d.status = '';
const steps = d.flow.steps;
if (btn.hasAttribute('data-wfg-edit') && steps[sel - 1]) { openForm(d, 'edit', sel - 1, steps[sel - 1]); return paint('wfg-f-title'); }
if (btn.hasAttribute('data-wfg-add')) { openForm(d, 'add', sel ? sel : steps.length, null); return paint('wfg-f-title'); }
if (btn.hasAttribute('data-wfg-cancel')) { const back = d.edit?.mode === 'edit' ? d.edit.i + 1 : sel; d.edit = null; select(back); return paint(back ? `wfg-step:${back}` : 'wfg-add'); }
if (btn.matches('[data-wfg-up],[data-wfg-down]') && steps[sel - 1]) {
const to = btn.hasAttribute('data-wfg-up') ? sel - 1 : sel + 1;
if (to < 1 || to > steps.length) return;
[steps[sel - 1], steps[to - 1]] = [steps[to - 1], steps[sel - 1]];
select(to);
say(`Moved step ${sel} to position ${to}.`);
const key2 = btn.hasAttribute('data-wfg-up') ? (to > 1 ? 'wfg-up' : 'wfg-down') : (to < steps.length ? 'wfg-down' : 'wfg-up');
return paint(key2);
}
if (btn.hasAttribute('data-wfg-remove') && steps[sel - 1] && steps.length > 1) {
const [gone] = steps.splice(sel - 1, 1);
const next = Math.min(sel, steps.length);
select(next);
say(`Removed step ${sel}, ${gone.title}.`);
return paint(`wfg-step:${next}`);
}
if (btn.hasAttribute('data-wfg-delete')) { d.confirm = true; return paint('wfg-delete-no'); }
if (btn.hasAttribute('data-wfg-delete-no')) { d.confirm = false; return paint('wfg-delete'); }
});
async function save({ org, name, scope, s, key }) {
const d = drafts.get(key);
if (!d || d.busy) return;
// Review P2: the open step form's edits are not in the workflow yet; saving now would drop them unsaid.
const refused = (editDirty(d.edit) ? `Not saved: step ${d.edit.i + 1} has edits not applied. Apply or cancel the step first.` : '')
|| refusal(s, { isNew: d.isNew, scope, name, steps: d.flow.steps });
if (refused) { d.status = refused; say(refused); return paint('wfg-save'); }
d.busy = true; d.status = 'Saving…'; paint();
try {
await saveFlow(org, s, { scope, name, revision: d.revision, workflow: shape(d.flow) });
drafts.delete(key);
notes.set(key, `Saved ${name}.`);
try { await read(org, states.get(org) ?? s); } catch { /* the next poll reads it */ }
say(`Saved ${name}.`);
paint(selected() ? `wfg-step:${selected()}` : 'wfg-delete');
} catch (error) {
d.busy = false;
d.status = kept[error.code] ?? `Not saved: ${error.message} Your change is kept.`;
say(d.status);
paint('wfg-save');
}
}
async function remove({ org, name, scope, s, key }) {
const d = drafts.get(key);
if (!d || d.busy) return;
d.busy = true; paint();
try {
// The revision of the workflow the page shows (settle moves it on only while this workflow is unchanged):
// a change made to it since is refused, not deleted over.
await removeFlow(org, s, { scope, name, revision: d.revision });
drafts.delete(key);
notes.set(dkey(org, '', ''), `Deleted ${name}.`);
say(`Deleted ${name}.`);
try { await read(org, s); } catch { /* the next poll reads it */ }
location.hash = `${orgHash(org)}/workflows`;
} catch (error) {
d.busy = false; d.confirm = false;
// Recheck B: Discard shows only with a pending change; without one the next read draws the current version.
d.status = error.code === 'revision-conflict' ? `Not deleted: the organization file changed this workflow since the page read it. ${changed(d) ? 'Discard to load the current version.' : 'The page shows the current version at its next read.'}` : `Not deleted: ${error.message}`;
say(d.status);
paint('wfg-delete');
}
}
// Every change in a form goes into the page's state at once, so any repaint draws what was entered.
document.addEventListener('input', ev => {
const form = ev.target.closest('[data-wfg-form]');
if (!form) return;
if (form.matches('[data-wfg-newform]')) { const f = newForms.get(shown.org); if (f) readForm(form, f); return; }
const at = here(form), d = at && drafts.get(at.key);
if (d?.edit) readForm(form, d.edit);
});
document.addEventListener('submit', ev => {
const form = ev.target.closest?.('[data-wfg-form]');
if (!form) return;
ev.preventDefault();
if (form.matches('[data-wfg-newform]')) {
const org = shown.org, s = states.get(org), f = newForms.get(org);
if (!f || !s?.data?.writable) return;
readForm(form, f);
const step = { title: f.title, state: f.state, roles: f.roles };
const refused = refusal(s, { isNew: true, scope: f.scope, name: f.name, steps: [step] })
|| (drafts.get(dkey(org, f.scope, f.name))?.isNew ? `A new workflow named ${f.name} is already open here, not saved yet.` : '');
if (refused) return why(form, f, refused);
drafts.set(dkey(org, f.scope, f.name), { isNew: true, base: null, flow: { title: '', description: '', steps: [{ ...step, title: step.title.trim() }] }, revision: s.data.revision, edit: null, confirm: false, status: '', busy: false, stale: false });
newForms.delete(org);
notes.delete(dkey(org, f.scope, f.name));
location.hash = flowHref(org, f.scope, f.name, 1);
return;
}
const at = here(form), d = at && drafts.get(at.key);
if (!d?.edit) return;
readForm(form, d.edit);
const e = d.edit, st = { title: e.title.trim(), state: e.state, roles: e.roles };
const refused = stepRefusal(st, e.i + 1);
if (refused) return why(form, e, refused);
if (e.mode === 'add') d.flow.steps.splice(e.i, 0, st); else d.flow.steps[e.i] = st;
d.edit = null; d.status = '';
select(e.i + 1);
say(`${e.mode === 'add' ? 'Added' : 'Changed'} step ${e.i + 1}. Not saved yet.`);
paint('wfg-save');
});
// Escape in a step form is Cancel.
document.addEventListener('keydown', ev => {
if (ev.key !== 'Escape') return;
const form = ev.target.closest?.('[data-wfg-form]:not([data-wfg-newform])');
form?.querySelector('[data-wfg-cancel]')?.click();
});
// The organization-switch and restart guards (display.js, console-restart.js) ask before these are lost.
function hasDraft() {
return [...drafts.values()].some(d => changed(d) || editDirty(d.edit))
|| [...newForms.values()].some(f => f.name || f.title.trim() || f.roles.length);
}
function forget() { drafts.clear(); newForms.clear(); }
// 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 = `<a href="${esc(`${orgHash(org)}/workflows`)}" data-wfg-all data-focus-key="wfg-all">All workflows</a>`;
const h = note => `<div class="ws-sec-h"><h2>Workflows</h2>${note ? `<span class="note">${note}</span>` : ''}${all}</div>`;
if (stopped) return `<div class="wfg-card" data-wfg-card data-bus-stopped>${h('')}<p class="ws-panel tight note">Not read: the bus is stopped.</p></div>`;
const s = states.get(org);
if (!s?.data) return `<div class="wfg-card" data-wfg-card>${h('')}<p class="ws-panel tight note" data-wfg-error>${esc(s?.error ?? 'Workflows could not be read.')}</p></div>`;
const rows = listed(s).flatMap(([scope, , list]) => list.map(([name, w]) => [scope, name, w]));
if (!rows.length) return `<div class="wfg-card" data-wfg-card>${h('none')}<div class="ws-panel tight">${none(org, s, label)}</div></div>`;
return `<div class="wfg-card" data-wfg-card>${h(String(rows.length))}<ul class="ws-panel tight wfg-cardlist">${rows.map(([scope, name, w]) => `<li><a href="${esc(flowHref(org, scope, name))}" data-wfg-card-item data-focus-key="${esc(`wfg-card:${scope}:${name}`)}"><strong>${esc(w?.title || name)}</strong> <span class="muted">${count(stepsOf(w).length)}${scope ? `, project ${esc(scope)}` : ''}</span></a></li>`).join('')}</ul></div>`;
}
// 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, hasDraft, forget };
})();
File diff suppressed because it is too large. Load diff
+10 -5
View File
@@ -4,7 +4,7 @@ import { proxyAdminHTTP } from './proxy-admin.mjs';
import { createServer } from 'node:http';
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
import { timingSafeEqual, randomUUID } from 'node:crypto';
import { timingSafeEqual, randomUUID, createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
@@ -65,12 +65,12 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'panes.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'assistant.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
['/files.css', ['files.css', 'text/css; charset=utf-8']],
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css', 'panes.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css', 'assistant.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']],
['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']],
@@ -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']]),
]);
@@ -250,6 +251,10 @@ const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.header
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
// so it carries `sso: true` and the public origin. The header-preview signer is read-only.
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
// SP14 (#1873): who is signed in, as an opaque stable name the page keys what it stores per person by
// (the Assistant's kept pane messages). A hash of the session's subject, never the subject itself. A
// Console with no sign-in has one fixed local viewer.
const viewerOf = session => (typeof session?.sub === 'string' && session.sub ? createHash('sha256').update(JSON.stringify([String(session.provider ?? ''), String(session.iss ?? ''), session.sub])).digest('hex').slice(0, 32) : 'local');
const ownOrigin = p => p.origin ?? `https://${p.host}`;
async function busSend(req, res, { send, preview, log }) {
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
@@ -1085,7 +1090,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 });
@@ -1234,7 +1239,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, viewer: viewerOf(currentSession), ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
@@ -0,0 +1,373 @@
// SP12 (#1873): selecting an agent on #/agents or in an organization page's Active agent sessions
// table opens a detail drawer at the right of the page; the page under it stays. The address carries
// the selection (?agent=<org>:<name>), so a reload reopens it and Back closes it. Escape and Close
// close it and put focus back on the row. Every field comes from a read the Console already makes; a
// field no read gives is left out. Actions show only where the agent's own organization config says
// this Console writes there, and a refused action says why next to its button.
// Every bus is a fake: acme and beta answer. The real GET /api/workspace and per-organization routes
// serve the page; only the per-organization config and the agent writes are stubbed in the page.
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 waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b';
const THREAD = '7b1e2c3d-4f50-4a61-8b72-9c83d4e5f6a7';
function fixture() {
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt' },
{ id: 'beta', label: 'Beta Works', theme: 'grove' },
];
const task = (n, title, extra = {}) => ({ task_ref: `store:${n}`, source: 'store', at: at(20 + n), fields: { title, state: 'in-progress', group: 'p-site', assignees: ['coder'], ...extra } });
const DATA = {
acme: {
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
// Seven tasks name coder: six open, one done. The drawer lists five open ones and links the rest.
tasks: [task(1, 'Hero copy'), task(2, 'Footer links'), task(3, 'Pricing table'), task(4, 'Contact form'), task(5, 'Blog index'), task(6, 'Search box'), task(7, 'Old banner', { state: 'done', done: true })],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40), messages: 2 } }],
// Two decisions wait on a human: one coder raised, one chief raised.
inbox: [
{ id: 'dec-1', question: 'Ship the hero copy?', class: 'DECISION', raised_by_role: 'coder', task_ref: 'store:1', at: at(10) },
{ id: 'dec-2', question: 'Hire a designer?', class: 'DECISION', raised_by_role: 'chief', task_ref: 'store:2', at: at(12) },
],
claims: [{ thread: THREAD, instance: 'assistant', holder_run: 'run-3', harness: 'claude', at: at(5) }],
threads: [{ id: THREAD, project: 'p-site', name: 'Launch notes' }],
instances: {
coder: { definition: 'coder', running: ['run-1'] },
chief: { definition: 'chief-of-staff', running: [] },
assistant: { definition: 'assistant', running: ['run-3'] },
},
model: { coder: 'claude-opus-5-5' },
},
beta: {
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
tasks: [], inbox: [], claims: [], threads: [],
agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
instances: { pm: { definition: 'pm', running: ['run-9'] }, 'research-2': { definition: `org:hire-${HIRE}`, hireDecision: HIRE, running: [], pack: { id: 'coding', label: 'Coding' } } },
model: {},
},
};
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') },
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
});
const member = org => ({ id: org.id, doc: { appearance: { theme: org.theme } }, status: async () => ({ healthy: true, pid: 4242, boot: null }), options: () => opts(org.id) });
return {
list: async () => LIST.map(o => ({ ...o, busHealthy: true })),
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
const d = DATA[id];
return {
busHealthy: true,
reads: {
business: async () => ({ business: { id, human: 'jason', projects: d.projects.map(p => p.repo), appearance: { theme: org.theme }, instances: Object.entries(d.model).map(([instance, model]) => ({ instance, vars: { model } })) } }),
settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}),
},
bus: {
messages: async () => ({ messages: [], unread: 0 }), agents: async () => d.agents, 'thread.claims': async () => d.claims, inbox: async () => d.inbox, tasks: async () => d.tasks,
instances: async () => ({ revision: 'a'.repeat(64), definitions: {}, instances: d.instances }),
},
chat: { projects: async () => d.projects, threads: async () => d.threads },
};
},
};
}
// Runs before the page's scripts. Acme's config says this Console sends there (chat and settings);
// beta's says it only reads. Agent writes are recorded and answered from a queue; nothing is written.
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__posts = []; window.__answers = [];
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
window.fetch = async (url, init = {}) => {
const u = String(url);
const cfg = u.match(/^\\/api\\/orgs\\/([^/]+)\\/config$/);
if (cfg) {
const c = await (await real(url, init)).json();
const writes = cfg[1] === 'acme';
return answer(200, { ...c, chatSend: writes, settingsSend: writes, decide: writes });
}
if (!/\\/agent\\//.test(u) || (init.method || 'GET') === 'GET' && !u.includes('/agent/request')) return real(url, init);
window.__posts.push({ url: u, method: init.method || 'GET', body: init.body ?? null });
const a = window.__answers.shift();
if (!a) throw new TypeError('no answer queued');
return answer(a.status ?? 200, a.body);
};
})();`;
async function setup(t, { viewport = [1440, 900], hash = '#/agents', stub = true } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture(), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText.replace(/\\s+/g, " ").trim() ?? null`);
const attr = (sel, name) => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.getAttribute(${JSON.stringify(name)}) ?? null`);
const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`);
return { b, base, wait: waitFor(b), text, attr, has };
}
const drawer = key => `document.querySelector('[data-ws-drawer="${key}"]')`;
const open = key => `!!${drawer(key)} && ${drawer(key)}.querySelector("h2")?.textContent.length > 0`;
const noDrawer = '!document.querySelector("[data-ws-drawer]")';
const agentParam = 'new URLSearchParams(location.hash.split("?")[1] || "").get("agent")';
const rowLink = key => `[data-ws-agent="${key}"] a[data-ws-drawer-open]`;
const sessionLink = key => `[data-ws-session-agent="${key}"] a[data-ws-drawer-open]`;
test('a row on #/agents opens the drawer: a region with a heading, the page stays under it', async t => {
const { b, wait, text, attr, has } = await setup(t);
await wait(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))})`);
assert.equal(await attr(rowLink('acme:coder'), 'href'), '#/agents?agent=acme%3Acoder');
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))}).click()`);
await wait(open('acme:coder'));
assert.equal(await b.evaluate(agentParam), 'acme:coder');
// A region, not a modal: no dialog role, no aria-modal, labelled by its heading.
const d = '[data-ws-drawer="acme:coder"]';
assert.equal(await attr(d, 'role'), 'region');
assert.equal(await attr(d, 'aria-modal'), null);
assert.equal(await b.evaluate(`document.getElementById(document.querySelector('${d}').getAttribute("aria-labelledby"))?.textContent`), 'coder');
// The page under it stays: the agents table is still drawn and its heading is the page's.
assert.equal(await has('[data-ws-agent-list]'), true);
assert.equal(await text('#bus-view h1'), 'Agents');
// At the right of the page.
const box = await b.evaluate(`(() => { const r = document.querySelector('${d}').getBoundingClientRect(); return [Math.round(r.left), Math.round(r.right), innerWidth]; })()`);
assert.ok(box[0] > box[2] / 2, `drawer left edge ${box[0]} should be in the right half of ${box[2]}`);
assert.ok(box[1] >= box[2] - 20, `drawer right edge ${box[1]} should reach the right of ${box[2]}`);
// At 1440 wide nothing on the page sits under it: the agents table ends left of the drawer.
const table = await b.evaluate(`Math.round(document.querySelector('[data-ws-agent-list]').getBoundingClientRect().right)`);
assert.ok(table <= box[0], `the agents table right edge ${table} should be left of the drawer at ${box[0]}`);
});
test('the drawer shows each field from its read: name, organization, role and source, state, model, project, task, open tasks and decisions', async t => {
const { b, wait, text, attr, has } = await setup(t, { hash: '#/agents?agent=acme%3Acoder' });
await wait(open('acme:coder'));
const d = '[data-ws-drawer="acme:coder"]';
assert.equal(await text(`${d} h2`), 'coder');
assert.equal(await text(`${d} [data-ws-orgchip]`), 'Acme Studio');
assert.match(await text(`${d} [data-drawer-role]`), /coder/);
assert.match(await text(`${d} [data-drawer-source]`), /Organization agent.*definition coder/);
assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)working$/);
assert.match(await text(`${d} [data-drawer-model]`), /claude-opus-5-5/);
// Project and task are links to their pages; the project is the task's, and says so.
assert.equal(await attr(`${d} [data-drawer-project] a`, 'href'), '#/orgs/acme/projects/p-site');
assert.match(await text(`${d} [data-drawer-project]`), /Website.*from its task/);
assert.equal(await attr(`${d} [data-drawer-task] a`, 'href'), '#/orgs/acme/tasks?task=store%3A1');
assert.match(await text(`${d} [data-drawer-task]`), /Hero copy/);
// Open tasks assigned to it: five of six, newest first is not claimed; the done one is never listed.
const tasks = await b.evaluate(`[...document.querySelectorAll('${d} [data-drawer-tasks] li a')].map(a => [a.textContent.trim(), a.getAttribute("href")])`);
assert.equal(tasks.length, 5);
assert.ok(tasks.every(([, href]) => /^#\/orgs\/acme\/tasks\?task=store%3A[1-6]$/.test(href)), JSON.stringify(tasks));
assert.ok(!tasks.some(([title]) => title === 'Old banner'));
assert.equal(await attr(`${d} [data-drawer-tasks-all]`, 'href'), '#/tasks?org=acme&assignee=coder');
assert.match(await text(`${d} [data-drawer-tasks-all]`), /6/);
// Decisions waiting on a human that it raised, each into the Inbox. Chief's is not its own.
const decisions = await b.evaluate(`[...document.querySelectorAll('${d} [data-drawer-decisions] li a')].map(a => [a.textContent.trim(), a.getAttribute("href")])`);
assert.deepEqual(decisions, [['Ship the hero copy?', '#/inbox?d=acme%3Adec-1']]);
// No read gives the time of an agent's last activity. The sessions read's `since` is when its
// current state began, which is not the same thing, so the drawer draws no time.
assert.equal(await has(`${d} [data-drawer-activity]`), false);
// The Tasks board link opens the board filtered to the agent.
await b.evaluate(`document.querySelector('${d} [data-drawer-tasks-all]').click()`);
await wait(`document.querySelector('[data-tasks-board] select[data-tb-filter="assignee"]')?.value === 'coder'`);
// When the address drops the assignee, the board drops the filter it took from the address.
await b.evaluate('location.hash = "#/tasks"');
await wait(`document.querySelector('[data-tasks-board] select[data-tb-filter="assignee"]')?.value === ''`);
});
test('a hired agent shows where its role came from; a not-running agent says not running', async t => {
const { wait, text, has } = await setup(t, { hash: '#/agents?agent=beta%3Aresearch-2' });
await wait(open('beta:research-2'));
const d = '[data-ws-drawer="beta:research-2"]';
assert.match(await text(`${d} [data-drawer-role]`), /Hired from pack Coding/);
assert.match(await text(`${d} [data-drawer-source]`), new RegExp(`hire decision ${HIRE}`));
assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)not running$/);
assert.equal(await has(`${d} [data-drawer-model]`), false);
});
test('a field with no read is left out', async t => {
// beta:pm: beta's business read names no model for it, and it holds no task, so no project either.
const { wait, has, text } = await setup(t, { hash: '#/agents?agent=beta%3Apm' });
await wait(open('beta:pm'));
const d = '[data-ws-drawer="beta:pm"]';
assert.match(await text(`${d} [data-drawer-state]`), /waiting on you/);
for (const field of ['model', 'project', 'task', 'activity']) assert.equal(await has(`${d} [data-drawer-${field}]`), false, `${field} should be absent`);
// An empty list still says so, since the read answered: no open tasks, no decisions.
assert.match(await text(`${d} [data-drawer-tasks]`), /No open tasks/);
assert.match(await text(`${d} [data-drawer-decisions]`), /No decisions/);
});
test('the address round trip: Back closes the drawer, Forward and a reload open it again', async t => {
const { b, base, wait } = await setup(t);
await wait(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))})`);
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))}).click()`);
await wait(open('beta:pm'));
await b.evaluate('history.back()');
await wait(`${noDrawer} && location.hash === "#/agents"`);
assert.equal(await b.evaluate('!!document.querySelector("[data-ws-agent-list]")'), true);
await b.evaluate('history.forward()');
await wait(open('beta:pm'));
const hash = await b.evaluate('location.hash');
await b.navigate(`${base}/${hash}`);
await wait(open('beta:pm'));
// The other filters stay in the address beside it.
await b.navigate(`${base}/#/agents?org=beta&agent=beta%3Apm`);
await wait(`${open('beta:pm')} && document.querySelectorAll("[data-ws-agent]").length === 2`);
// An agent no read has says so, with Close; a malformed value opens nothing.
await b.navigate(`${base}/#/agents?agent=beta%3Anobody`);
await wait('document.querySelector("[data-ws-drawer] [data-drawer-missing]")');
await b.navigate(`${base}/#/agents?agent=not-an-agent`);
await wait(`document.querySelector("[data-ws-agent-list]") && ${noDrawer}`);
});
test('the drawer holds at most one history entry: Close after switching agents, or after a shared link, stays in the Console', async t => {
const { b, base, wait } = await setup(t);
await wait(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))})`);
const before = await b.evaluate('history.length');
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))}).click()`);
await wait(open('acme:coder'));
// Another row while the drawer is open switches it in place: no second entry.
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))}).click()`);
await wait(`${open('beta:pm')} && !${drawer('acme:coder')}`);
assert.equal(await b.evaluate(agentParam), 'beta:pm');
assert.equal(await b.evaluate('history.length'), before + 1);
// The open row again does nothing to the address.
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('beta:pm'))}).click()`);
assert.equal(await b.evaluate('history.length'), before + 1);
// One Close goes back to the page without the drawer.
await b.evaluate(`${drawer('beta:pm')}.querySelector("[data-drawer-close]").click()`);
await wait(`${noDrawer} && location.hash === "#/agents"`);
// A shared link opened after another site: clicking its row and then Close stays here.
await b.navigate('about:blank');
await b.navigate(`${base}/#/agents?agent=acme%3Acoder`);
await wait(open('acme:coder'));
await b.evaluate(`document.querySelector(${JSON.stringify(rowLink('acme:coder'))}).click()`);
await b.evaluate(`${drawer('acme:coder')}.querySelector("[data-drawer-close]").click()`);
await wait(`location.href.startsWith(${JSON.stringify(base)}) && ${noDrawer} && location.hash === "#/agents"`);
// Escape on a shared link does the same.
await b.navigate(`${base}/#/agents?agent=beta%3Apm`);
await wait(open('beta:pm'));
await b.key('Escape');
await wait(`location.href.startsWith(${JSON.stringify(base)}) && ${noDrawer} && location.hash === "#/agents"`);
});
test('the keyboard path: Tab reaches the row, Enter opens, Escape closes and focus returns to the row', async t => {
const { b, wait } = await setup(t);
const link = JSON.stringify(rowLink('acme:coder'));
await wait(`document.querySelector(${link})`);
// Focus the control just before the row's link, then Tab onto it.
await b.evaluate(`(() => { const all = [...document.querySelectorAll('a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])')].filter(e => e.getClientRects().length); const i = all.indexOf(document.querySelector(${link})); all[i - 1].focus(); })()`);
await b.key('Tab');
assert.equal(await b.evaluate(`document.activeElement === document.querySelector(${link})`), true);
await b.key('Enter');
await wait(open('acme:coder'));
// Focus moves into the drawer, onto its heading.
await wait(`document.activeElement === ${drawer('acme:coder')}.querySelector("h2")`);
await b.key('Escape');
await wait(`${noDrawer} && document.activeElement === document.querySelector(${link})`);
assert.equal(await b.evaluate(agentParam), null);
// Close does the same.
await b.key('Enter');
await wait(open('acme:coder'));
await b.evaluate(`${drawer('acme:coder')}.querySelector("[data-drawer-close]").click()`);
await wait(`${noDrawer} && document.activeElement === document.querySelector(${link})`);
});
test('from an organization page\'s Active agent sessions the drawer opens over that page', async t => {
const { b, base, wait, text, attr } = await setup(t, { hash: '#/orgs/acme/organization' });
await wait(`document.querySelector(${JSON.stringify(sessionLink('acme:coder'))})`);
assert.equal(await attr(sessionLink('acme:coder'), 'href'), '#/orgs/acme/organization?agent=acme%3Acoder');
await b.evaluate(`document.querySelector(${JSON.stringify(sessionLink('acme:coder'))}).click()`);
await wait(open('acme:coder'));
// The organization page stays under it.
assert.equal(await text('[data-org-dashboard] h1'), 'Acme Studio');
assert.match(await text('[data-ws-drawer="acme:coder"] [data-drawer-model]'), /claude-opus-5-5/);
await b.evaluate('history.back()');
await wait(`${noDrawer} && location.hash === "#/orgs/acme/organization"`);
assert.equal(await text('[data-org-dashboard] h1'), 'Acme Studio');
await b.navigate(`${base}/#/orgs/acme/organization?agent=acme%3Aassistant`);
await wait(open('acme:assistant'));
});
test('Message opens the agent\'s thread in an Assistant pane; Stop asks first and a refusal says why', async t => {
const { b, wait, text, attr, has } = await setup(t, { hash: '#/agents?agent=acme%3Aassistant' });
await wait(`${open('acme:assistant')} && document.querySelector('[data-ws-drawer="acme:assistant"] [data-drawer-message]')`);
assert.equal(await attr('[data-ws-drawer="acme:assistant"] [data-drawer-message]', 'href'), `#/assistant?add=acme%3A${THREAD}`);
// A role agent holds no thread to open, so it has no Message; it can still be stopped.
await b.evaluate('location.hash = "#/agents?agent=acme%3Acoder"');
await wait(`${open('acme:coder')} && document.querySelector('[data-ws-drawer="acme:coder"] [data-drawer-stop]')`);
assert.equal(await has('[data-ws-drawer="acme:coder"] [data-drawer-message]'), false);
const d = '[data-ws-drawer="acme:coder"]';
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
assert.match(await text(`${d} [data-drawer-stop-confirm]`), /Stop coder in Acme Studio\?/);
// Cancel: nothing sent, focus back on Stop.
await b.evaluate(`document.querySelector('${d} [data-drawer-stop-no]').click()`);
await wait(`document.activeElement === document.querySelector('${d} [data-drawer-stop]')`);
assert.deepEqual(await b.evaluate('window.__posts'), []);
// Yes: one terminate for this one agent, refused by the bus, in words next to the button.
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
await b.evaluate('window.__answers.push({ status: 409, body: { error: "instance-protected", message: "instance-protected: coder is protected from ending. Nothing changed.", sent: false } })');
await b.evaluate(`document.querySelector('${d} [data-drawer-stop-yes]').click()`);
await wait(`/coder is protected/.test(document.querySelector('${d} [data-drawer-status]')?.textContent ?? "")`);
assert.match(await text(`${d} [data-drawer-status]`), /^Not stopped: instance-protected: coder is protected from ending\. Nothing changed\.$/);
const posts = await b.evaluate('window.__posts');
assert.deepEqual(posts.map(p => [p.url, p.method]), [['/api/orgs/acme/agent/terminate', 'POST']]);
assert.deepEqual(JSON.parse(posts[0].body), { instance: 'coder', reason: 'Stopped from the agent drawer.' });
// The status sits beside the Stop button, in the same action group.
assert.equal(await b.evaluate(`document.querySelector('${d} [data-drawer-status]').closest('[data-drawer-actions]') === document.querySelector('${d} [data-drawer-stop]').closest('[data-drawer-actions]')`), true);
// A read-only refusal with no message from the server still says why.
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
await b.evaluate('window.__answers.push({ status: 403, body: { error: "read-only", sent: false } })');
await b.evaluate(`document.querySelector('${d} [data-drawer-stop-yes]').click()`);
await wait(`/read-only|only reads/.test(document.querySelector('${d} [data-drawer-status]')?.textContent ?? "")`);
assert.match(await text(`${d} [data-drawer-status]`), /^Not stopped: this Console only reads agents in Acme Studio \(read-only\)\.$/);
// Escape while it asks cancels only the question: the drawer stays and focus is back on Stop.
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
await wait(`document.querySelector('${d} [data-drawer-stop-no]') === document.activeElement`);
await b.key('Escape');
await wait(`!document.querySelector('${d} [data-drawer-stop-confirm]') && document.activeElement === document.querySelector('${d} [data-drawer-stop]')`);
assert.equal(await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').disabled`), false);
assert.equal(await b.evaluate(agentParam), 'acme:coder');
assert.equal(await b.evaluate('window.__posts').then(p => p.length), 2);
// A Stop the bus takes: the request is followed until it closes, then the drawer says it stopped.
const REQ = '0f0e0d0c-0b0a-4908-8706-050403020100';
await b.evaluate(`window.__answers.push({ status: 200, body: { sent: true, request: '${REQ}' } }, { status: 200, body: { request: '${REQ}', state: 'closed' } })`);
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
await b.evaluate(`document.querySelector('${d} [data-drawer-stop-yes]').click()`);
await wait(`/^Stopped coder\.$/.test(document.querySelector('${d} [data-drawer-status]')?.textContent ?? "")`);
assert.deepEqual((await b.evaluate('window.__posts')).slice(2).map(p => [p.url, p.method]), [['/api/orgs/acme/agent/terminate', 'POST'], [`/api/orgs/acme/agent/request?id=${REQ}`, 'GET']]);
// A not-running agent has nothing to stop.
await b.evaluate('location.hash = "#/agents?agent=acme%3Achief"');
await wait(open('acme:chief'));
assert.equal(await has('[data-ws-drawer="acme:chief"] [data-drawer-stop]'), false);
// Where Stop is not offered, the drawer says why beside the actions.
assert.match(await text('[data-ws-drawer="acme:chief"] [data-drawer-actions] [data-drawer-nostop]'), /^chief is one of Acme Studio’s protected agents, so it is not stopped from here\.$/);
});
test('no action is shown where the Console cannot write for the agent\'s organization', async t => {
const { wait, has, text } = await setup(t, { hash: '#/agents?agent=beta%3Apm' });
await wait(`${open('beta:pm')} && document.querySelector('[data-ws-drawer="beta:pm"] [data-drawer-readonly]')`);
const d = '[data-ws-drawer="beta:pm"]';
for (const action of ['message', 'stop', 'restart']) assert.equal(await has(`${d} [data-drawer-${action}]`), false, `${action} should be absent`);
assert.match(await text(`${d} [data-drawer-readonly]`), /only reads Beta Works/);
});
test('at 390 wide the drawer is a full-width sheet', async t => {
const { b, wait } = await setup(t, { viewport: [390, 844], hash: '#/agents?agent=acme%3Acoder' });
await wait(open('acme:coder'));
const box = await b.evaluate(`(() => { const r = ${drawer('acme:coder')}.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.width), document.documentElement.clientWidth, document.documentElement.scrollWidth]; })()`);
assert.equal(box[0], 0);
assert.equal(box[1], box[2]);
assert.ok(box[3] <= box[2], `no horizontal page scroll: ${box[3]} > ${box[2]}`);
});
@@ -0,0 +1,579 @@
// SP6 (#1873): the Assistant is one system page. Up to three chat threads from any organization
// open next to each other, each the chat thread view pinned to its own organization.
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 P = { alpha: '10000000-0000-4000-8000-00000000000a', beta: '10000000-0000-4000-8000-00000000000b', home: '10000000-0000-4000-8000-00000000000c' };
const T = {
a1: '20000000-0000-4000-8000-0000000000a1', a2: '20000000-0000-4000-8000-0000000000a2',
b1: '20000000-0000-4000-8000-0000000000b1', h1: '20000000-0000-4000-8000-0000000000c1',
};
const NEW = '20000000-0000-4000-8000-0000000000b9';
// Three organizations. `state.down` names the ones whose bus is stopped; `state.sent` and
// `state.created` record every chat write with the organization whose transport received it.
function orgs(state) {
const ORGS = [
{ id: 'alpha', label: 'Alpha', theme: 'rosewood' },
{ id: 'beta', label: 'Beta', theme: 'cobalt' },
{ id: 'home', label: 'Home', theme: 'grove' },
];
const PROJECTS = { alpha: [{ id: P.alpha, name: 'Spring launch' }], beta: [{ id: P.beta, name: 'API gateway' }], home: [{ id: P.home, name: 'Garden' }] };
const THREADS = {
alpha: [{ id: T.a1, project: P.alpha, name: 'Launch email', instance: 'assistant' }, { id: T.a2, project: P.alpha, name: 'Kickoff notes', instance: 'assistant' }],
beta: [{ id: T.b1, project: P.beta, name: 'Gateway auth', instance: 'assistant' }],
home: [{ id: T.h1, project: P.home, name: 'Seed order', instance: 'assistant' }],
};
const MESSAGES = { [T.a1]: 'Alpha draft is ready.', [T.a2]: 'Kickoff agenda.', [T.b1]: 'Beta tokens rotate nightly.', [T.h1]: 'Seeds arrive Monday.' };
const down = () => { throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); };
return {
list: async () => ORGS.filter(o => !state.gone?.has(o.id)).map(o => ({ ...o, busHealthy: !state.down.has(o.id), ...(state.down.has(o.id) ? { busStopped: true } : {}) })),
resolve: async id => {
const org = ORGS.find(o => o.id === id); if (!org || state.gone?.has(id)) return null;
if (state.down.has(id)) return { busHealthy: false, reads: null, bus: {}, chat: { projects: down, threads: down, sessions: down, shares: down, messages: down } };
const threads = () => [...THREADS[id], ...state.created.filter(c => c.org === id).map(c => ({ id: c.id, project: c.project, name: c.name, instance: c.instance }))];
return {
busHealthy: true,
send: async () => ({}),
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: {}, ...(state.run?.[id] ? { running: state.run[id] } : {}) } } }),
},
chat: {
projects: async () => PROJECTS[id],
threads: async ({ shared } = {}) => (shared ? [] : threads()),
sessions: async ({ thread }) => [{ id: thread, name: 'default', default: true }],
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.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 }) => {
const sent = { org: id, thread, body, id: `40000000-0000-4000-8000-${String(state.sent.length + 1).padStart(12, '0')}` };
state.sent.push(sent);
return { id: sent.id };
},
createThread: async ({ project, name, instance }) => {
state.created.push({ org: id, project, name, instance, id: NEW });
return { id: NEW, instance };
},
},
};
},
};
}
async function open(t, { width = 1440, height = 900, hash, state = { down: new Set(['home']), sent: [], created: [] }, sso = createDevOwner() } = {}) {
const organizations = orgs(state);
const server = await startServer({ port: 0, sso, organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(width, height);
const base = `http://127.0.0.1:${server.address().port}/`;
await b.navigate(base + hash);
const wait = (expression, tries = 200) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
return { b, base, wait, state };
}
const PANE = sel => `document.querySelector('[data-assist-pane="${sel}"]')`;
const panes = `[...document.querySelectorAll('[data-assist-pane]')].map(p => p.dataset.assistPane)`;
const shown = `[...document.querySelectorAll('[data-assist-pane]')].filter(p => p.getClientRects().length).map(p => p.dataset.assistPane)`;
const query = `new URLSearchParams(location.hash.split('?')[1] || '').get('open')`;
const ready = sel => `!!${PANE(sel)}?.querySelector('.chat-log li') && !!${PANE(sel)}?.querySelector('[data-ws-orgchip]')`;
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
const SENT = '/Accepted message|Message shown in this conversation/';
const pick = (b, value) => b.evaluate(`(() => { const s = document.querySelector('[data-assist-open]'); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error('no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
test('the sidebar Assistant opens one system page, and two threads from two organizations open side by side with their chips', async t => {
const { b, wait } = await open(t, { hash: '#/orgs/alpha/tasks' });
await wait(`!!document.querySelector('#sections a[href="#/assistant"]')`);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('#sections a')].map(a => a.textContent.trim())`).then(v => v.includes('Side by side')), false, 'no Side by side item');
await b.evaluate(`document.querySelector('#sections a[href="#/assistant"]').click()`);
await wait(`location.hash.startsWith('#/assistant') && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`);
assert.equal(await b.evaluate('document.querySelector(\'#sections a[href="#/assistant"]\').getAttribute("aria-current")'), 'page');
// SP7: the page name shows once, as the heading.
assert.equal(await b.evaluate(trail), null);
await wait(`!!document.querySelector('[data-assist-open] optgroup[label^="Beta"] option[value="beta:${T.b1}"]')`);
await pick(b, `alpha:${T.a1}`);
await pick(b, `beta:${T.b1}`);
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`]);
assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`, `beta:${T.b1}`], 'both panes show at 1440');
const head = sel => b.evaluate(`(() => { const p = ${PANE(sel)}; return { title: p.querySelector('[data-assist-title]').textContent, org: p.querySelector('[data-ws-orgchip]').textContent, project: p.querySelector('[data-ws-projchip]')?.textContent ?? null, close: !!p.querySelector('button[data-assist-close]') }; })()`);
assert.deepEqual(await head(`alpha:${T.a1}`), { title: 'Launch email', org: 'Alpha', project: 'Spring launch', close: true });
assert.deepEqual(await head(`beta:${T.b1}`), { title: 'Gateway auth', org: 'Beta', project: 'API gateway', close: true });
assert.match(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Alpha draft is ready\./);
assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./);
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Beta tokens/);
// One level-1 heading on the page; each pane's own form, field and ids are its own.
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view h1').length`), 1);
const ids = await b.evaluate(`[...document.querySelectorAll('#bus-view [id]')].map(e => e.id)`);
assert.equal(new Set(ids).size, ids.length, 'no duplicate ids across panes');
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view form textarea').length`), 2);
});
test('the open set and its order live in the address, so a reload or a pasted address restores the same panes', async t => {
const hash = `#/assistant?open=beta:${T.b1},alpha:${T.a2}`;
const { b, base, wait } = await open(t, { hash });
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)}`);
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`]);
await pick(b, `alpha:${T.a1}`);
await wait(`${ready(`alpha:${T.a1}`)}`);
assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a2},alpha:${T.a1}`);
const address = await b.evaluate('location.hash');
await b.navigate('about:blank');
await b.navigate(base + address);
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)} && ${ready(`alpha:${T.a1}`)}`);
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`, `alpha:${T.a1}`]);
// A hand-edited address is checked before use: bad selectors, repeats and a fourth pane are dropped.
await b.navigate('about:blank');
await b.navigate(base + `#/assistant?open=alpha:${T.a1},alpha:${T.a1},nope,../x:${T.b1},beta:not-a-uuid,beta:${T.b1},alpha:${T.a2},home:${T.h1}`);
await wait(`document.querySelectorAll('[data-assist-pane]').length === 3`);
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]);
await wait(`${query} === 'alpha:${T.a1},beta:${T.b1},alpha:${T.a2}'`);
// With three open the picker offers no fourth.
assert.equal(await b.evaluate(`document.querySelector('[data-assist-open]').disabled`), true);
});
test('a send in organization B\'s pane reaches B\'s thread while organization A was opened last', async t => {
const state = { down: new Set(['home']), sent: [], created: [] };
const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks', state });
await wait(`window.MosaicOrg?.id === 'beta'`);
await b.evaluate(`location.hash = '#/orgs/alpha/tasks'`);
await wait(`window.MosaicOrg?.id === 'alpha'`);
await b.evaluate(`location.hash = '#/assistant?open=beta:${T.b1},alpha:${T.a1}'`);
await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a1}`)}`);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'alpha', 'alpha is the organization opened last');
const field = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
await wait(`!${field}.disabled`);
await b.evaluate(`(() => { const el = ${field}; el.value = 'rotate the beta key'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
await wait(`${SENT}.test(${PANE(`beta:${T.b1}`)}.innerText)`);
assert.deepEqual(state.sent.map(({ org, thread, body }) => ({ org, thread, body })), [{ org: 'beta', thread: T.b1, body: 'rotate the beta key' }]);
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Accepted message|Message shown in this conversation/, 'the other pane shows no receipt');
assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').value`), '', 'the other pane keeps its own draft');
// A send in A's pane reaches A.
const other = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`;
await b.evaluate(`(() => { const el = ${other}; el.value = 'alpha copy'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`);
assert.deepEqual(state.sent.map(({ org, thread }) => `${org}:${thread}`), [`beta:${T.b1}`, `alpha:${T.a1}`]);
// Live updates: a refresh shows the sent message in its own pane only.
await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`/rotate the beta key/.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`);
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /rotate the beta key/);
});
test('closing a pane updates the address and leaves the others', async t => {
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('button[data-assist-close]').click()`);
await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`);
assert.equal(await b.evaluate(query), `beta:${T.b1}`);
assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./);
await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('button[data-assist-close]').click()`);
await wait(`document.querySelectorAll('[data-assist-pane]').length === 0`);
assert.equal(await b.evaluate(`location.hash`), '#/assistant');
assert.match(await b.evaluate(`document.getElementById('bus-view').innerText`), /No thread is open/);
});
test('a stopped organization is named at the top, its pane says so, and the other panes keep working', async t => {
const state = { down: new Set(['home']), sent: [], created: [] };
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},home:${T.h1},beta:${T.b1}`, state });
await wait(`!!document.querySelector('#bus-view [data-ws-unread]') && ${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
assert.match(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Home/);
assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Alpha|Beta/);
await wait(`/bus is stopped/i.test(${PANE(`home:${T.h1}`)}?.querySelector('[data-assist-line]')?.textContent || '')`);
assert.equal(await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('[data-ws-orgchip]').textContent`), 'Home');
assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').disabled`), false);
// Beta's bus stops while its pane is open, with an unsent message in it: that pane says so and
// only so, and holds the message; Alpha's still sends.
const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
await wait(`!!${betaField} && !${betaField}.disabled`);
await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
state.down.add('beta');
await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`/bus is stopped/i.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && /Beta/.test(document.querySelector('#bus-view [data-ws-unread]')?.textContent || '')`);
await wait(`!${betaField}`);
const betaBody = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`);
assert.match(betaBody, /Nothing is read for this thread while Beta's bus is stopped\. Your unsent message is kept/);
assert.doesNotMatch(betaBody, /Refresh to try again|Read again|Conversation unavailable/, 'no line that says a refresh would help');
assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true, 'the held message counts as a draft');
const field = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`;
assert.equal(await b.evaluate(`${field}.disabled`), false);
await b.evaluate(`(() => { const el = ${field}; el.value = 'still here'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`);
await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`);
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here']);
// The picker names the stopped organizations and offers none of their threads.
assert.match(await b.evaluate(`[...document.querySelectorAll('[data-assist-open] optgroup')].map(g => g.label).join('|')`), /Home · bus stopped/);
// Beta's bus starts again: its pane reads again and the held message is back in its composer.
state.down.delete('beta');
await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`${ready(`beta:${T.b1}`)} && ${betaField}?.value === 'beta draft'`);
assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here'], 'the held message is not sent by itself');
});
test('a pane whose organization leaves the list keeps its unsent message and does not promise it comes back', async t => {
const state = { down: new Set(['home']), gone: new Set(), sent: [], created: [] };
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`;
await wait(`!!${betaField} && !${betaField}.disabled`);
await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
state.gone.add('beta');
await b.evaluate('window.MosaicAssistant.refresh()');
await wait(`/not on your organization list/.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && !${betaField}`);
const body = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`);
assert.match(body, /Your unsent message is kept while this pane stays open\./);
assert.doesNotMatch(body, /comes back/, 'no promise that an organization off the list reads again');
assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true);
assert.deepEqual(state.sent, []);
});
test('an open pane shows a new reply on its own, with no refresh', async t => {
const state = { down: new Set(['home']), sent: [], created: [], replies: [] };
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
state.replies.push({ org: 'beta', thread: T.b1, body: 'Rotation finished.' });
// The page polls every 10 s; wait up to 15 s without calling refresh.
await wait(`/Rotation finished\./.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`, 500);
assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /Rotation finished/);
});
test('the old Side by side address lands on the Assistant page', async t => {
const { b, wait } = await open(t, { hash: '#/orgs/alpha/side-by-side?a=alpha/organization&b=beta/organization&w=50&show=a' });
await wait(`location.hash === '#/assistant' && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`);
assert.equal(await b.evaluate(`!!window.MosaicPanes`), false, 'the Side by side module is gone');
await b.evaluate(`location.hash = '#/side-by-side'`);
await wait(`location.hash === '#/assistant'`);
// The old per-organization Assistant address, the messages page, still opens.
await b.evaluate(`location.hash = '#/orgs/alpha/messages'`);
await wait(`document.querySelector('#bus-view h1')?.textContent === 'Messages'`);
});
test('at 390 one pane shows at a time with a tab strip to switch', async t => {
const { b, base, wait } = await open(t, { width: 390, height: 844, hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
await wait(`${ready(`alpha:${T.a1}`)} && document.querySelectorAll('[data-assist-tabs] [data-assist-show]').length === 2`);
assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`]);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-tabs] [data-assist-show]')].map(x => [x.textContent.trim(), x.getAttribute('aria-pressed')])`), [['Launch email', 'true'], ['Gateway auth', 'false']]);
await b.evaluate(`document.querySelector('[data-assist-tabs] [data-assist-show="beta:${T.b1}"]').click()`);
await wait(`${ready(`beta:${T.b1}`)}`);
assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]);
assert.equal(await b.evaluate(`new URLSearchParams(location.hash.split('?')[1]).get('show')`), `beta:${T.b1}`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, 'no horizontal page scroll'); // A pasted address names the shown pane however its id is cased.
await b.navigate('about:blank');
await b.navigate(base + `#/assistant?open=alpha:${T.a1},beta:${T.b1}&show=beta:${T.b1.toUpperCase()}`);
await wait(`${ready(`beta:${T.b1}`)}`);
assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]);
});
test('"Open thread" starts a new thread in a chosen organization through that organization\'s own route', async t => {
const state = { down: new Set(['home']), sent: [], created: [] };
const { b, wait } = await open(t, { hash: '#/assistant', state });
await wait(`!!document.querySelector('[data-assist-open] option[value="new:beta"]')`);
await pick(b, 'new:beta');
await wait(`!!document.querySelector('[data-assist-new] select[name=project] option[value="${P.beta}"]') && !!document.querySelector('[data-assist-new] select[name=instance] option[value=assistant]')`);
await b.evaluate(`(() => { const f = document.querySelector('[data-assist-new]'); f.elements.name.value = 'Rate limits'; f.requestSubmit(); })()`);
await wait(`${ready(`beta:${NEW}`)} || !!${PANE(`beta:${NEW}`)}?.querySelector('form textarea')`);
assert.deepEqual(state.created.map(({ org, project, name, instance }) => ({ org, project, name, instance })), [{ org: 'beta', project: P.beta, name: 'Rate limits', instance: 'assistant' }]);
assert.equal(await b.evaluate(query), `beta:${NEW}`);
});
// SP7: a thread link anywhere opens that thread as a pane here; a typed per-organization address
// still opens that organization's own chat view.
const follow = (b, href) => b.evaluate(`(() => { const a = document.createElement('a'); a.href = ${JSON.stringify(href)}; a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
const type = (b, sel, text) => b.evaluate(`(() => { const el = ${PANE(sel)}.querySelector('form textarea'); el.value = ${JSON.stringify(text)}; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
const field = sel => `${PANE(sel)}?.querySelector('form textarea')`;
test('SP7: a thread link on a project page opens the thread as a pane on the Assistant page, added to the open set', async t => {
const { b, wait } = await open(t, { hash: `#/assistant?open=beta:${T.b1}` });
await wait(ready(`beta:${T.b1}`));
await b.evaluate(`location.hash = '#/orgs/alpha/projects/${P.alpha}'`);
await wait(`!!document.querySelector('#bus-view a[href$="/threads/${T.a1}"]')`);
await b.evaluate(`document.querySelector('#bus-view a[href$="/threads/${T.a1}"]').click()`);
await wait(`location.hash.startsWith('#/assistant') && ${ready(`alpha:${T.a1}`)}`);
assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a1}`]);
assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a1}`);
// A per-organization chat address typed or pasted still opens that organization's chat view.
await b.evaluate(`location.hash = '#/orgs/beta/projects/threads/${T.b1}'`);
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
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 });
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)} && !${field(`alpha:${T.a1}`)}.disabled`);
await type(b, `alpha:${T.a1}`, 'alpha draft');
// On the Assistant page a thread link adds a pane in place: the typed message stays.
await follow(b, `#/orgs/alpha/projects/threads/${T.a2}`);
await wait(ready(`alpha:${T.a2}`));
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]);
assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft');
// A fourth: Alpha's first pane holds a message, so Beta's pane, the first without one, gives way.
await follow(b, `#/orgs/home/projects/threads/${T.h1}`);
await wait(ready(`home:${T.h1}`));
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `home:${T.h1}`, `alpha:${T.a2}`]);
assert.equal(await b.evaluate(query), `alpha:${T.a1},home:${T.h1},alpha:${T.a2}`);
assert.equal(await b.evaluate(`${field(`alpha:${T.a1}`)}.value`), 'alpha draft');
// All three hold a message: nothing is replaced and the page says why.
await wait(`!${field(`home:${T.h1}`)}.disabled && !${field(`alpha:${T.a2}`)}.disabled`);
await type(b, `home:${T.h1}`, 'home draft');
await type(b, `alpha:${T.a2}`, 'kickoff draft');
await follow(b, `#/orgs/beta/projects/threads/${T.b1}`);
await wait(`/not opened/.test(document.querySelector('[data-assist-note]').textContent)`);
assert.match(await b.evaluate(`document.querySelector('[data-assist-note]').textContent`), /all 3 panes hold an unsent message/);
assert.equal(await b.evaluate(`document.querySelector('[data-assist-note]').hasAttribute('data-refused')`), true);
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. 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}`);
await wait(ready(`beta:${T.b1}`));
assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`]);
assert.deepEqual(state.sent, []);
});
test('SP7: each pane names its own organization\'s Messages page, and the bar no longer names the organization opened last', async t => {
const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks' });
await wait(`window.MosaicOrg?.id === 'beta'`);
await b.evaluate(`location.hash = '#/assistant?open=alpha:${T.a1},beta:${T.b1}'`);
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-messages]'); return a && [a.getAttribute('href'), a.getAttribute('aria-label'), a.textContent.trim()]; })`),
[['#/orgs/alpha/messages', 'Messages in Alpha', 'Messages'], ['#/orgs/beta/messages', 'Messages in Beta', 'Messages']]);
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, []);
});
// SP14 (#1873): kept pane messages belong to the person who typed them. They are stored with who is
// signed in, and a load under anyone else, or a store that names no one (the SP11 form), opens empty.
// Two people sign in, one after the other, on one tab; the same person keeps theirs across a reload.
test('SP14: a kept pane message is shown again only to the person who typed it, on the same tab', async t => {
const state = { down: new Set(), sent: [], created: [] };
const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`;
const dev = createDevOwner();
const person = sub => ({ ...dev.session(), provider: 'sso', iss: 'https://id.example.test', sub, name: sub });
let who = person('person-a');
const { b, base, wait } = await open(t, { hash, state, sso: { ...dev, session: () => who } });
const stored = () => b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts') || ''`);
const leave = async () => { await b.evaluate(`location.hash = '#/tasks'`); await wait(`!document.querySelector('[data-assist-pane]') && !!document.querySelector('[data-tasks-board]')`); };
const load = async () => { await b.navigate(`${base}?n=${Math.random().toString(36).slice(2)}${hash}`); await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); };
await wait(`${ready(a1)} && !${field(a1)}.disabled`);
await type(b, a1, 'note from a');
await leave();
assert.match(await stored(), /note from a/);
// Stored with an opaque name for the person, never their subject.
assert.doesNotMatch(await stored(), /person-a/);
// Person B signs in on the same tab: the pane opens empty and A's message is gone from the store.
who = person('person-b');
await load();
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
assert.doesNotMatch(await stored(), /note from a/);
await type(b, a1, 'note from b');
await leave();
assert.match(await stored(), /note from b/);
// A again: B's message is not shown to A either.
who = person('person-a');
await load();
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
assert.doesNotMatch(await stored(), /note from b/);
// The same person keeps their own message across a load.
await type(b, a1, 'a again');
await leave();
await load();
assert.equal(await b.evaluate(`${field(a1)}.value`), 'a again');
// A store in the form kept before SP14, which names no one, is dropped.
await leave();
await b.evaluate(`sessionStorage.setItem('mosaic-assistant-drafts', JSON.stringify({ ${JSON.stringify(a1)}: 'unowned note' })), true`);
await load();
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
assert.doesNotMatch(await stored(), /unowned note/);
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 => {
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` });
await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane]')].map(p => { const a = p.querySelector('.assist-pane-h a[data-assist-pane-full]'); return a && [a.getAttribute('href'), a.textContent.trim(), a.hasAttribute('data-full-view')]; })`),
[[`#/orgs/alpha/projects/threads/${T.a1}`, 'Full view', true], [`#/orgs/beta/projects/threads/${T.b1}`, 'Full view', true]]);
// Each pane's Full view names its thread and organization, so a screen reader can tell three apart.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-pane-full]')].map(a => a.getAttribute('aria-label'))`),
['Full view of Launch email, Alpha', 'Full view of Gateway auth, Beta']);
await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('a[data-assist-pane-full]').click()`);
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`);
});
test('SP7: a link marked as the full view stays in the organization\'s own view, from any page', async t => {
const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1}` });
await wait(ready(`alpha:${T.a1}`));
// On the Assistant page itself: no pane is added, the browser follows the link.
await b.evaluate(`(() => { const a = document.createElement('a'); a.href = '#/orgs/beta/projects/threads/${T.b1}'; a.setAttribute('data-full-view', ''); a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
await wait(`location.hash === '#/orgs/beta/projects/threads/${T.b1}'`);
await wait(`!document.querySelector('[data-assistant]') && /Beta tokens rotate nightly/.test(document.getElementById('bus-view').innerText)`);
// From another page: the same.
await b.evaluate(`(() => { const a = document.createElement('a'); a.href = '#/orgs/alpha/projects/threads/${T.a1}'; a.setAttribute('data-full-view', ''); a.textContent = 'thread'; document.body.append(a); a.click(); a.remove(); })()`);
await wait(`location.hash === '#/orgs/alpha/projects/threads/${T.a1}' && !document.querySelector('[data-assistant]')`);
});
// SP7 review shots: the title and the pane's tools (Full view, Messages, close) share the header's
// first row, the organization and project chips sit under it, and nothing spills out of the pane.
const headerRows = `[...document.querySelectorAll('[data-assist-pane]')].map(p => {
const h = p.querySelector('.assist-pane-h').getBoundingClientRect();
const r = s => p.querySelector(s).getBoundingClientRect();
const mid = q => Math.round(q.top + q.height / 2);
const row = ['[data-assist-title]', '[data-assist-pane-full]', '[data-assist-pane-messages]', '[data-assist-close]'].map(s => mid(r(s)));
const inside = [...p.querySelectorAll('.assist-pane-h > *, .assist-pane-h a, .assist-pane-h button')].every(e => { const q = e.getBoundingClientRect(); return q.left >= h.left - 1 && q.right <= h.right + 1; });
return { oneRow: Math.max(...row) - Math.min(...row) <= 4, chipsBelow: r('[data-assist-chips]').top >= r('[data-assist-title]').bottom - 1, inside };
})`;
// At 800 three panes are too narrow for one row: the tools wrap, and none of them leaves the pane.
const THREE = [`alpha:${T.a1}`, `alpha:${T.a2}`, `beta:${T.b1}`];
for (const [width, height, keys, oneRow] of [[1440, 900, THREE, true], [800, 900, THREE, null], [761, 900, THREE, null], [390, 844, [`beta:${T.b1}`], true]]) {
test(`SP7: a pane header keeps its tools inside the pane${oneRow ? ', title and tools on one row with the chips below' : ''}, at ${width}`, async t => {
const { b, wait } = await open(t, { width, height, hash: `#/assistant?open=${keys.join(',')}` });
await wait(keys.map(k => ready(k)).join(' && '));
await wait(`[...document.querySelectorAll('[data-assist-pane] [data-ws-orgchip]')].length === ${keys.length}`);
const rows = await b.evaluate(headerRows);
assert.deepEqual(rows.map(r => (oneRow ? r : { ...r, oneRow: null })), keys.map(() => ({ oneRow, chipsBelow: true, inside: true })));
});
}
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) added panes.js (Side by side) just before bus.js; SP6 replaces it with assistant.js (the Assistant page), which bus.js mounts.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/assistant.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
@@ -60,7 +60,9 @@ const org = {
bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
chat: { projects: async () => [], threads: async () => [] },
};
for (const [width, route, where] of [[1440, 'orgs/acme/organization', 'the organization page'], [390, 'orgs/acme/organization', 'the organization page'], [1440, 'orgs/acme/side-by-side?a=acme/organization&b=acme/agents', 'a pane'], [390, 'orgs/acme/side-by-side?a=acme/agents&b=acme/organization&show=b', 'a pane']]) {
// SP6 (#1873) removed Side by side, whose panes also held the organization page; the Assistant
// page's panes hold chat threads only, so the organization page itself is the case left.
for (const [width, route, where] of [[1440, 'orgs/acme/organization', 'the organization page'], [390, 'orgs/acme/organization', 'the organization page']]) {
test(`E2E6 P2-13: Restart bus and Stop bus line up on ${where} at ${width}`, { timeout: 60000 }, async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: { list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], resolve: async () => org } });
t.after(() => close(server));
@@ -0,0 +1,281 @@
// SP8 (#1873, single pane slice 8): the Dashboard leads with one row of KPI tiles, each built from a
// number an existing read returns and each a link to the page that lists what it counts, with the
// filter in the address where that page has one. A tile whose number some organization could not give
// names that organization; it never shows a lower number as if it were complete. An organization's
// page leads with the same tiles scoped to it, each linking to the system page filtered to it.
// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace and
// the real per-organization routes serve the pages.
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 waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
// fail: { org: [read names] } makes those reads refuse; `none` empties the organization list.
// 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 },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
];
const decision = (id, question, role, minutes) => ({ id, question, class: 'within-role', blocking: false, raised_by_role: role, at: at(minutes), options: [{ key: 'yes', text: 'Yes' }] });
const DATA = {
acme: {
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-old', repo: 'old', name: 'Old', archived: true, at: at(9000) }],
tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false } }],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
inbox: [decision('dec-acme-1', 'Ship the hero?', 'coder', 10)],
instances: { coder: { definition: 'coder', running: ['run-1'] }, chief: { definition: 'chief-of-staff', running: [] } },
},
beta: {
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
tasks: [
{ task_ref: 'local:7', project_key: 'shop', at: at(50), fields: { title: 'Price list', done: false } },
{ task_ref: 'local:8', project_key: 'shop', at: at(70), fields: { title: 'Cart copy', done: false } },
{ task_ref: 'local:9', project_key: 'shop', at: at(60 * 24), fields: { title: 'Logo', done: true } },
],
agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
inbox: [decision('dec-beta-1', 'Pick a launch price', 'pm', 5), decision('dec-beta-2', 'Name the shop', 'pm', 90)],
instances: { pm: { definition: 'pm', running: ['run-9'] }, writer: { definition: 'writer', running: [] } },
},
};
const value = (id, name) => (fail[id]?.includes(name) ? Promise.reject(Object.assign(new Error('off'), { code: 'channel-disabled' })) : 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') },
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme } },
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: () => opts(org.id),
});
return {
list: async () => LIST,
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
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 () => { 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 () => [] },
};
},
};
}
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);
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const tileKeys = scope => `[...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => e.dataset.wsTile)`;
const hrefs = scope => `Object.fromEntries([...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => [e.dataset.wsTile, e.tagName === 'A' ? e.getAttribute('href') : null]))`;
const big = (scope, key) => `document.querySelector(${JSON.stringify(`${scope} [data-ws-tile=${key}] .ws-big`)})?.textContent.replace(/\\s+/g, ' ').trim() ?? null`;
const homeReady = 'document.querySelector("[data-ws-home] [data-ws-tiles] [data-ws-tile=agents] .ws-big")';
test('The Dashboard tiles add both answering organizations\' numbers, one row, each tile a link to the page that lists it', async t => {
const { b, wait, text } = await setup(t);
await wait(homeReady);
// One row: no second tile group anywhere on the page, and the row leads the page under its heading.
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] [data-ws-tiles]").length'), 1);
assert.equal(await b.evaluate('document.querySelector("[data-ws-home] .ws-vh").nextElementSibling.hasAttribute("data-ws-tiles")'), true);
assert.deepEqual(await b.evaluate(tileKeys('[data-ws-home]')), ['agents', 'orgs', 'needs', 'tasks', 'done', 'projects']);
// Agents working of total: acme's coder works; beta's pm waits; chief and writer run nothing.
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 4');
assert.equal(await b.evaluate(big('[data-ws-home]', 'orgs')), '2 of 3 running');
assert.equal(await b.evaluate(big('[data-ws-home]', 'needs')), '3 items');
assert.equal(await b.evaluate(big('[data-ws-home]', 'tasks')), '3');
assert.equal(await b.evaluate(big('[data-ws-home]', 'done')), '1');
assert.equal(await b.evaluate(big('[data-ws-home]', 'projects')), '2 active');
// Each organization's share, named for a screen reader.
assert.match(await text('[data-ws-tile=needs] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*2/);
assert.match(await text('[data-ws-tile=tasks] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*2/);
assert.match(await text('[data-ws-tile=agents] .ws-by'), /Acme Studio\s*1[\s\S]*Beta Works\s*0/);
// Every tile is a link, with the filter in the address where the page has one.
const links = await b.evaluate(hrefs('[data-ws-home]'));
assert.equal(links.agents, '#/agents?state=working');
assert.equal(links.orgs, '#/dashboard');
assert.equal(links.needs, '#/inbox');
assert.equal(links.tasks, '#/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);
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-tile=spend],[data-ws-tile=sessions]").length'), 0);
// The Open tasks link under Tasks by state goes to the system board too.
assert.equal(await b.evaluate('document.querySelector("[data-focus-key=home-tasks]").getAttribute("href")'), '#/tasks');
});
test('A stopped organization is named on every tile its numbers would have joined, and on none it counted', async t => {
const { b, wait, text } = await setup(t);
await wait(homeReady);
for (const key of ['agents', 'needs', 'tasks', 'done', 'projects']) {
assert.match(await text(`[data-ws-tile=${key}] [data-ws-miss=${key}]`) ?? '', /Not read in Gamma Home \(bus stopped\)/, key);
}
// The Organizations tile counts it as not running and says which one.
assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=orgs] [data-ws-miss]")'), null);
assert.match(await text('[data-ws-tile=orgs]'), /Gamma Home bus stopped/);
});
test('Following a tile lands on its page showing what the tile counted', async t => {
const { b, wait, text } = await setup(t);
await wait(homeReady);
await b.evaluate('document.querySelector("[data-ws-tile=agents]").click()');
await wait('location.hash === "#/agents?state=working" && document.querySelector("[data-ws-agent]")');
await wait('JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent)) === JSON.stringify(["acme:coder"])');
assert.match(await text('[data-ws-astate=""] .n'), /^4$/);
await b.evaluate('location.hash = "#/dashboard"');
await wait(homeReady);
await b.evaluate('document.querySelector("[data-ws-tile=needs]").click()');
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]")');
assert.equal(await b.evaluate('location.hash'), '#/dashboard');
});
test('An answering organization whose agent read failed is named on the Agents tile, never counted as none', async t => {
const { b, wait, text } = await setup(t, { fail: { beta: ['instances'] } });
await wait(homeReady);
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), '1 of 2');
assert.match(await text('[data-ws-tile=agents] [data-ws-miss=agents]'), /Beta Works/);
assert.doesNotMatch(await text('[data-ws-tile=agents] .ws-by') ?? '', /Beta Works/);
});
test('When no answering organization could read its agents, the Agents tile says Not read instead of a number', async t => {
const { b, wait, text } = await setup(t, { fail: { acme: ['instances'], beta: ['instances'] } });
await wait('document.querySelector("[data-ws-tile=agents]")');
assert.equal(await b.evaluate(big('[data-ws-home]', 'agents')), null);
assert.match(await text('[data-ws-tile=agents]'), /Not read/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-tile=agents]").getAttribute("href")'), '#/agents?state=working');
});
// 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()))]'), ['–']);
});
test('An organization page leads with its own tiles, each linking to the system page filtered to it', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/beta' });
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big")');
// The tiles come straight after the header, before the docs row.
assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] .ws-orghead").nextElementSibling.hasAttribute("data-org-tiles")'), true);
assert.deepEqual(await b.evaluate(tileKeys('[data-org-tiles]')), ['agents', 'tasks', 'needs', 'done']);
assert.equal(await b.evaluate(big('[data-org-tiles]', 'agents')), '0 of 2');
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&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);
// Following Needs you shows beta's two items on the system Inbox.
await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=needs]").click()');
await wait('location.hash === "#/inbox?org=beta" && document.querySelectorAll("[data-ws-item]").length === 2');
// Acme's own page counts only acme.
await b.evaluate('location.hash = "#/orgs/acme"');
await wait('document.querySelector("[data-org-dashboard] [data-org-tiles] [data-ws-tile=agents] .ws-big") && location.hash.startsWith("#/orgs/acme")');
await wait(`${big('[data-org-tiles]', 'agents')} === "1 of 2"`);
assert.equal(await b.evaluate(big('[data-org-tiles]', 'needs')), '1 item');
assert.equal(await b.evaluate(big('[data-org-tiles]', 'done')), '0');
await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=agents]").click()');
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]")');
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'), 0);
for (const key of ['agents', 'tasks', 'needs', 'done']) assert.match(await text(`[data-org-tiles] [data-ws-tile=${key}]`), /Not read: the bus is stopped/, key);
assert.equal(await b.evaluate('document.querySelector("[data-org-tiles] [data-ws-tile=needs]").getAttribute("href")'), '#/inbox?org=gamma');
});
test('At 390 wide the tiles sit two to a row with no sideways scroll, on the Dashboard and an organization page', async t => {
const { b, wait } = await setup(t, { viewport: [390, 844] });
await wait(homeReady);
const cols = scope => b.evaluate(`new Set([...document.querySelectorAll(${JSON.stringify(`${scope} [data-ws-tile]`)})].map(e => Math.round(e.getBoundingClientRect().left))).size`);
assert.equal(await cols('[data-ws-home]'), 2);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'dashboard');
await b.evaluate('location.hash = "#/orgs/acme"');
await wait('document.querySelector("[data-org-tiles] [data-ws-tile=agents] .ws-big")');
assert.equal(await cols('[data-org-tiles]'), 2);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'organization page');
});
@@ -81,7 +81,8 @@ test('archive does not announce success for an unexpected response', { timeout:
test('archiving the current organization switches to the remaining one', { timeout: 45000 }, async t => {
const f = await fixture(t); await f.open('alpha');
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`window.MosaicOrg?.id === 'beta' && document.querySelector('header')?.textContent.includes('beta')`);
// #1873 SP1: the breadcrumb, not the header, names the open organization.
await f.wait(`window.MosaicOrg?.id === 'beta' && /beta/i.test(document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent ?? '')`);
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'alpha').state, 'archived');
});
@@ -188,6 +189,8 @@ test('fresh archived organization page offers owner restore while writes stay re
const f = await fixture(t); await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!document.querySelector('#org-archived').hidden`);
// #1873 SP1: another organization's address no longer reloads the page, so a fresh page is loaded on purpose.
await f.b.navigate('about:blank');
await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`);
await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`);
assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`), false);
@@ -200,6 +203,16 @@ test('fresh archived organization page offers owner restore while writes stay re
assert.equal((await registry.listOrgs(f.ctx)).find(e => e.id === 'beta').state, 'active');
});
// #1873 SP1: opening the archived organization's page in place (no reload) shows its restore command.
test('an archived organization opened in place shows its restore command', { timeout: 45000 }, async t => {
const f = await fixture(t); await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!document.querySelector('#org-archived').hidden`);
await f.b.evaluate(`window.__sameDocument = true; location.hash = '#/orgs/beta/settings/organizations?org=beta'`);
await f.wait(`window.MosaicOrg?.id === 'beta' && /mosaic org restore beta --if-revision \\d+/.test(document.querySelector('#org-archived')?.innerText ?? '')`);
assert.equal(await f.b.evaluate(`window.__sameDocument`), true);
});
test('owner can start a down bus after setup recorded done and sees recovery guidance on refusal', { timeout: 45000 }, async t => {
const f = await fixture(t);
await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});if(u.endsWith('/setup/bus') && init.method==='POST')return new Response(JSON.stringify({error:'stale-lock',message:'stale bus host lock'}),{status:409});return old(input,init)}})()`);
@@ -233,6 +246,8 @@ for (const capability of [{ user:true, orgSetup:false }, { tester:true, orgSetup
await f.b.evaluate(`document.querySelector('#org-archive-open').click();document.querySelector('#org-archive-final').click()`);
await f.wait(`!document.querySelector('#org-archived').hidden`);
await f.b.call('Page.addScriptToEvaluateOnNewDocument', {source:`{const old=window.fetch;window.fetch=(input,init)=>input instanceof URL && input.pathname==='/api/config'?Promise.resolve(new Response(JSON.stringify(${JSON.stringify(capability)}),{status:200})):old(input,init)}`});
// #1873 SP1: the stub applies to a new document; another organization's address alone no longer reloads.
await f.b.navigate('about:blank');
await f.b.navigate(`http://127.0.0.1:${f.port}/#/orgs/beta/settings/organizations?org=beta`);
await f.wait(`document.querySelector('#org-archived') && !document.querySelector('#org-archived').hidden`);
assert.equal(await f.b.evaluate(`document.querySelector('#org-restore').disabled`),true);
@@ -78,8 +78,9 @@ test('deleting the current organization switches the Console to a remaining orga
await b.evaluate(`document.querySelector('#org-open-form button').click()`);
await wait(`document.querySelector('#org-delete') && !document.querySelector('#org-delete').hidden`);
await b.evaluate(`document.querySelector('#org-delete-open').click();document.querySelector('#org-delete-continue').click();const i=document.querySelector('#org-delete-confirm');i.value='alpha';i.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-delete-final').click()`);
// #1873 SP1: the breadcrumb, not the header, names the open organization.
let switched = false;
for (let i = 0; i < 300 && !switched; i++) { try { switched = await b.evaluate(`window.MosaicOrg?.id === 'beta' && document.querySelector('header')?.textContent.includes('beta')`); } catch {} if (!switched) await new Promise(r => setTimeout(r, 30)); }
for (let i = 0; i < 300 && !switched; i++) { try { switched = await b.evaluate(`window.MosaicOrg?.id === 'beta' && (document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent ?? '').includes('beta')`); } catch {} if (!switched) await new Promise(r => setTimeout(r, 30)); }
assert.equal(switched, true); assert.deepEqual((await registry.listOrgs(ctx)).map(e => e.id), ['beta']);
});
@@ -1,5 +1,6 @@
// On a selected org's page, the header shows why the org list failed or which org is broken,
// and Retry re-reads the list and clears the banner once it recovers (polish review F3, P19 #868).
// #1873 SP1: the breadcrumb names the open organization; the header no longer does.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
@@ -35,9 +36,9 @@ test('a registry error shows on a selected org page and Retry recovers', async t
await wait(f.b, `(${bannerText}).includes('Organization registry could not be read') && document.querySelector('#held [data-retry]')`);
const before = f.requests(); await fs.writeFile(file, original);
await f.b.evaluate("document.querySelector('#held [data-retry]').click()");
await wait(f.b, "!!document.querySelector('#current-organization') && !document.getElementById('org-list-banner')");
await wait(f.b, "!!document.querySelector('#breadcrumbs [data-crumb-org]') && !document.getElementById('org-list-banner')");
assert.ok(f.requests() > before, 'Retry re-read the organization list');
assert.equal(await f.b.evaluate("document.querySelectorAll('#current-organization').length"), 1);
assert.equal(await f.b.evaluate("document.querySelectorAll('#breadcrumbs [data-crumb-org]').length"), 1);
});
test('a broken org banner is replaced, not stacked, and clears after repair', async t => {
@@ -52,5 +53,5 @@ test('a broken org banner is replaced, not stacked, and clears after repair', as
await fs.writeFile(file, original);
await f.b.evaluate("document.querySelector('#held [data-retry]').click()");
await wait(f.b, "!document.getElementById('org-list-banner')");
assert.equal(await f.b.evaluate("document.querySelectorAll('#current-organization').length"), 1);
assert.equal(await f.b.evaluate("document.querySelectorAll('#breadcrumbs [data-crumb-org]').length"), 1);
});
@@ -0,0 +1,329 @@
// SP13 (#1873): the organization page fits its width, and four things seen live.
// 1. At every content width from 360 px up, with and without the agent drawer, nothing in the Settings
// card is cut off by its card and the page does not scroll sideways.
// 2. `#/orgs/<id>?query` is the organization page with the query kept; `?agent=` opens the drawer.
// 3. The drawer never says "not running" before its instances read has answered: while it is open the
// state is being read, after it fails the state was not read, and the Stop note says the same.
// 4. (SP14 removed the Queue card; the organization page now makes no queue read.)
// Plus the test SP12 left out: a repaint while Stop asks its question keeps the question on screen.
// SP14 (#1873): the page has no Queue card and makes no queue read; an instances answer with no runs says
// running was not read wherever a running or working count would show; and the drawer's instances answer
// repaints only the drawer, with no page read made again.
// Every bus is a fake. The real GET /api/workspace and per-organization routes serve the page; only the
// per-organization config, the agent writes and (when a test asks) the drawer's instances read are
// stubbed in the page.
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 waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
function fixture({ counts = {} } = {}) {
const LIST = [{ id: 'acme', label: 'Acme Studio', theme: 'cobalt' }];
const d = {
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
tasks: [], inbox: [], threads: [],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40), messages: 2 } }],
claims: [],
instances: {
coder: { definition: 'coder', running: ['run-1'] },
chief: { definition: 'chief-of-staff', running: [] },
reviewer: { definition: 'reviewer', running: [] },
},
// Long values, so a narrow Settings card has something to cut.
model: { coder: 'claude-opus-5-5', chief: 'claude-sonnet-5-5', reviewer: 'gpt-5.5-codex-high-reasoning' },
};
const value = name => Promise.resolve(d[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
const opts = () => ({
bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'), usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') },
chat: { threads: () => value('threads'), projects: () => value('projects') },
});
const member = org => ({ id: org.id, doc: { appearance: { theme: org.theme } }, status: async () => ({ healthy: true, pid: 4242, boot: null }), options: opts });
return {
list: async () => LIST.map(o => ({ ...o, busHealthy: true })),
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
return {
busHealthy: true,
reads: {
business: async () => ({ business: { id, human: 'jason', projects: d.projects.map(p => p.repo), appearance: { theme: org.theme }, vars: { 'tracker.kind': 'gitea-issues-and-milestones' }, arbiters: { decisions: 'chief-of-staff', reviews: 'reviewer', merges: 'human-owner-of-the-organization' }, instances: Object.entries(d.model).map(([instance, model]) => ({ instance, vars: { model } })) } }),
settings: async () => ({}),
queue: async () => { counts.queue = (counts.queue ?? 0) + 1; return { rows: [] }; },
busStatus: async () => ({}),
},
bus: {
messages: async () => ({ messages: [], unread: 0 }), agents: async () => d.agents, 'thread.claims': async () => d.claims, inbox: async () => d.inbox, tasks: async () => d.tasks,
instances: async () => ({ revision: 'a'.repeat(64), definitions: {}, instances: structuredClone(d.instances) }),
},
chat: { projects: async () => d.projects, threads: async () => d.threads },
};
},
};
}
// Runs before the page's scripts. Acme's config says this Console sends there. `window.__inst` set by
// a test holds ('hold', released by window.__release()) or fails ('fail') the first bus instances read,
// which is the drawer's: it starts before the page's own reads. With `window.__heldIdle` the held
// answer says nothing runs.
// Every URL the page fetches, in order, for the tests that count reads.
const URLS = `(() => { const real = window.fetch; window.__urls = []; window.fetch = (url, init) => { window.__urls.push(String(url)); return real(url, init); }; })();`;
// A bus answer the bus did not add runs to (its async read path): no `running` key on any instance.
const STRIP = `(() => { const real = window.fetch; window.fetch = async (url, init) => { const r = await real(url, init); if (!/\\/bus\\/instances$/.test(String(url)) || !r.ok) return r; const v = await r.json(); for (const row of [v, ...(v.rows || [])]) for (const i of Object.values(row?.instances || {})) delete i.running; return new Response(JSON.stringify(v), { status: 200, headers: { 'content-type': 'application/json' } }); }; })();`;
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__posts = []; window.__answers = []; window.__instSeen = 0;
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
window.fetch = async (url, init = {}) => {
const u = String(url);
if (/\\/api\\/orgs\\/acme\\/bus\\/instances$/.test(u) && window.__instSeen++ === 0 && window.__inst) {
if (window.__inst === 'fail') return answer(502, { error: 'read-failed', message: 'the bus did not answer' });
await new Promise(done => { window.__release = done; });
if (!window.__heldIdle) return real(url, init);
// The held answer, when a test asks, says no instance runs anything.
const v = await (await real(url, init)).json();
for (const row of [v, ...(v.rows || [])]) for (const i of Object.values(row?.instances || {})) i.running = [];
return answer(200, v);
}
const cfg = u.match(/^\\/api\\/orgs\\/([^/]+)\\/config$/);
if (cfg) { const c = await (await real(url, init)).json(); return answer(200, { ...c, chatSend: true, settingsSend: true, decide: true }); }
if (!/\\/agent\\//.test(u) || (init.method || 'GET') === 'GET' && !u.includes('/agent/request')) return real(url, init);
window.__posts.push({ url: u, method: init.method || 'GET', body: init.body ?? null });
const a = window.__answers.shift();
if (!a) throw new TypeError('no answer queued');
return answer(a.status ?? 200, a.body);
};
})();`;
async function setup(t, { viewport = [1440, 900], hash = '#/orgs/acme/organization', inst = null, counts = {}, scripts = [] } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ counts }), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
if (inst) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__inst = ${JSON.stringify(inst)};` });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
for (const source of scripts) await b.call('Page.addScriptToEvaluateOnNewDocument', { source });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText.replace(/\\s+/g, " ").trim() ?? null`);
const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`);
return { b, base, wait: waitFor(b), text, has };
}
const drawer = key => `document.querySelector('[data-ws-drawer="${key}"]')`;
const open = key => `!!${drawer(key)} && ${drawer(key)}.querySelector("h2")?.textContent.length > 0`;
const noDrawer = '!document.querySelector("[data-ws-drawer]")';
const settled = 'document.querySelectorAll("[data-org-settings] [data-org-setting]").length > 10';
// Everything in the Settings card that is cut: a cell whose content is wider than its box, a cell that
// reaches past the card's edge or, from 1200 px, under the drawer, and a page that scrolls sideways.
const CLIPPED = `(() => {
const card = document.querySelector('[data-org-settings]'), r = card.getBoundingClientRect();
// From 1200 px the page keeps clear of the drawer (SP12); below that the drawer covers the page by design.
const d = innerWidth >= 1200 ? document.querySelector('[data-ws-drawer]')?.getBoundingClientRect() : null;
const edge = Math.min(r.right, d && d.left > 0 ? d.left : Infinity);
const out = [];
for (const li of card.querySelectorAll('[data-org-setting]')) {
for (const el of li.querySelectorAll('.k,.v,.src-tag,.go')) {
if (!el.textContent.trim()) continue;
const b = el.getBoundingClientRect();
if (el.scrollWidth > el.clientWidth + 1 || b.right > edge + 1 || b.left < r.left - 1) out.push(li.dataset.orgSetting + ' ' + el.className + ' ' + el.scrollWidth + '/' + el.clientWidth + ' right ' + Math.round(b.right) + '/' + Math.round(edge));
}
}
// The sessions table beside it scrolls only as a last resort; with these rows the columns stack first.
for (const tb of document.querySelectorAll('[data-org-dashboard] .ws-split .ws-tbl')) if (tb.scrollWidth > tb.clientWidth + 1) out.push('sessions table ' + tb.scrollWidth + '/' + tb.clientWidth);
const page = document.documentElement;
if (page.scrollWidth > page.clientWidth) out.push('page ' + page.scrollWidth + '/' + page.clientWidth);
return out;
})()`;
test('the Settings card fits at 1440 with the drawer open, at 735 and 390 without it, and at every width between', async t => {
const { b, base, wait } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder' });
await wait(`${settled} && ${open('acme:coder')}`);
assert.deepEqual(await b.evaluate(CLIPPED), [], 'at 1440 with the drawer open');
// Each Open link keeps its whole word.
assert.ok(await b.evaluate(`[...document.querySelectorAll('[data-org-settings] a.go')].every(a => a.innerText.trim() === 'Open' && a.scrollWidth <= a.clientWidth + 1)`));
// Without the drawer: 735 and 390 wide, and a content width of about 735 (1023 with the sidebar).
for (const [w, h] of [[735, 900], [390, 844], [1023, 900]]) {
await b.viewport(w, h);
await b.navigate(`${base}/#/orgs/acme/organization`);
await wait(`${settled} && ${noDrawer}`);
assert.deepEqual(await b.evaluate(CLIPPED), [], `at ${w} without the drawer`);
}
// Every 40 px from 360 to 1440, with and without the drawer.
for (const hash of ['#/orgs/acme/organization', '#/orgs/acme/organization?agent=acme%3Acoder']) {
await b.navigate(`${base}/${hash}`);
await wait(`${settled} && ${hash.includes('agent') ? open('acme:coder') : noDrawer}`);
for (let w = 360; w <= 1440; w += 40) {
await b.viewport(w, 900);
await b.evaluate('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))');
assert.deepEqual(await b.evaluate(CLIPPED), [], `at ${w}${hash.includes('agent') ? ' with the drawer' : ''}`);
}
}
// Side by side, both columns' first headings start level (the Workflows card wraps its heading).
await b.navigate(`${base}/#/orgs/acme/organization`);
await wait(`${settled} && ${noDrawer}`);
const tops = await b.evaluate(`[...document.querySelectorAll('#bus-view .ws-dash>.ws-split>section')].map(s => [Math.round(s.getBoundingClientRect().left), Math.round(s.querySelector('h2').getBoundingClientRect().top)])`);
assert.equal(tops.length, 2);
assert.notEqual(tops[0][0], tops[1][0], 'the columns sit side by side at 1440');
assert.equal(tops[0][1], tops[1][1], 'the first headings start level');
});
test('the bare organization address with a query is the organization page, query kept; ?agent= opens the drawer', async t => {
const { b, base, wait, text } = await setup(t, { hash: '#/orgs/acme?agent=acme%3Acoder' });
await wait(`document.querySelector('[data-org-dashboard] h1') && ${open('acme:coder')}`);
assert.equal(await text('[data-org-dashboard] h1'), 'Acme Studio');
assert.equal(await b.evaluate('!!document.querySelector("[data-org-unavailable]")'), false);
assert.equal(await b.evaluate('new URLSearchParams(location.hash.split("?")[1] || "").get("agent")'), 'acme:coder');
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/acme\/organization\?agent=acme%3Acoder$/);
// The reads are scoped to acme, never to "acme?agent=…".
assert.equal(await b.evaluate('window.MosaicOrg?.id'), 'acme');
// Another query, no drawer: the same page with the query kept.
await b.navigate(`${base}/#/orgs/acme?tab=settings`);
await wait(`document.querySelector('[data-org-dashboard] h1')?.innerText === 'Acme Studio'`);
assert.equal(await b.evaluate('location.hash'), '#/orgs/acme/organization?tab=settings');
assert.equal(await b.evaluate('!!document.querySelector("[data-ws-drawer]")'), false);
// Typed into the address bar of a page already open.
await b.evaluate('location.hash = "#/orgs/acme?agent=acme%3Achief"');
await wait(open('acme:chief'));
assert.equal(await text('[data-org-dashboard] h1'), 'Acme Studio');
});
test('while the drawer\'s instances read is open it says the state is being read, never not running, then shows it', async t => {
const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold', scripts: [URLS] });
const d = '[data-ws-drawer="acme:coder"]';
await wait(`${settled} && ${open('acme:coder')}`);
assert.equal(await has(`${d}[data-drawer-pending]`), true);
assert.equal(await text(`${d} [data-drawer-state]`), 'Reading state…');
assert.doesNotMatch(await text(d), /not running/);
// Stop is not offered yet, and its note says why without saying not running.
assert.equal(await has(`${d} [data-drawer-stop]`), false);
assert.match(await text(`${d} [data-drawer-nostop]`), /^Its state is being read\./);
// The page under it painted from its own reads: coder is a running session there.
assert.match(await text('[data-ws-session-agent="acme:coder"]'), /working/);
// When the read answers, the drawer repaints with the state, and Stop. SP14: only the drawer repaints,
// from the reads it already has: the page and its reads are left alone, and focus stays where it was.
await b.evaluate(`document.querySelector('[data-org-dashboard]').dataset.before = '1'; document.querySelector('${d} [data-drawer-close]').focus(); window.__urls.length = 0`);
await b.evaluate('window.__release()');
await wait(`!document.querySelector('${d}[data-drawer-pending]') && document.querySelector('${d} [data-drawer-stop]')`);
assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)working$/);
assert.equal(await has(`${d} [data-drawer-nostop]`), false);
await b.evaluate('new Promise(r => setTimeout(r, 400))');
assert.deepEqual(await b.evaluate(`window.__urls.filter(u => /^\\/api\\/(workspace|orgs\\/)/.test(u))`), [], 'no read is made again after the drawer\'s read answers');
assert.equal(await b.evaluate(`document.querySelector('[data-org-dashboard]').dataset.before`), '1', 'the page under the drawer was not repainted');
assert.equal(await b.evaluate('document.activeElement?.dataset?.focusKey'), 'drawer-close');
});
test('SP14 review: another agent opened while the drawer\'s instances read is open repaints when that read answers', async t => {
const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold' });
await wait(`${settled} && document.querySelector('[data-ws-drawer="acme:coder"][data-drawer-pending]')`);
// Chief opens before the held read answers; its drawer waits on the same read.
await b.evaluate('location.hash = "#/orgs/acme/organization?agent=acme%3Achief"');
const d = '[data-ws-drawer="acme:chief"]';
await wait(`document.querySelector('${d}[data-drawer-pending]')`);
assert.equal(await text(`${d} [data-drawer-state]`), 'Reading state…');
// The read answers. Chief's drawer repaints from it at once, not at the next poll.
await b.evaluate('window.__release()');
await b.evaluate('new Promise(r => setTimeout(r, 1000))');
assert.equal(await has(`${d}[data-drawer-pending]`), false, 'chief\'s drawer still says Reading state… after its read answered');
assert.match(await text(`${d} [data-drawer-state]`), /not running/);
assert.equal(await has('[data-ws-drawer="acme:coder"]'), false);
});
test('a drawer closed before its instances read answers keeps no answer for the next open', async t => {
const idle = 'window.__heldIdle = true;';
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold', scripts: [idle] });
const d = '[data-ws-drawer="acme:coder"]';
await wait(`${settled} && document.querySelector('${d}[data-drawer-pending]')`);
await b.evaluate(`document.querySelector('${d} [data-drawer-close]').click()`);
await wait(noDrawer);
// The held read answers after the drawer closed, saying coder runs nothing.
await b.evaluate('window.__release()');
await b.evaluate('new Promise(r => setTimeout(r, 300))');
// Reopened from its row, the drawer reads again and shows coder working, not the closed drawer's answer.
await b.evaluate(`document.querySelector('[data-ws-drawer-open="acme:coder"]').click()`);
await wait(`${open('acme:coder')} && !document.querySelector('${d}[data-drawer-pending]') && document.querySelector('${d} [data-drawer-state]')`);
assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)working$/);
assert.doesNotMatch(await text(d), /not running/);
});
test('after the drawer\'s instances read fails it says the state was not read, and Stop says so too', async t => {
const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Areviewer', inst: 'fail' });
const d = '[data-ws-drawer="acme:reviewer"]';
await wait(`${settled} && ${open('acme:reviewer')}`);
assert.equal(await has(`${d}[data-drawer-pending]`), false);
assert.match(await text(`${d} [data-drawer-state]`), /^State not read: /);
assert.doesNotMatch(await text(d), /not running/);
assert.equal(await has(`${d} [data-drawer-stop]`), false);
assert.equal(await text(`${d} [data-drawer-nostop]`), 'Its state was not read, so Stop is not offered.');
// A later repaint reads again: reviewer runs nothing, so now it says not running, with that reason.
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait(`/not running/.test(document.querySelector('${d} [data-drawer-state]')?.innerText ?? '')`);
assert.equal(await text(`${d} [data-drawer-nostop]`), 'It is not running, so there is nothing to stop.');
});
test('an instances answer that carries no runs says state not read, in the drawer and on the Agents table', async t => {
const { wait, text, has } = await setup(t, { hash: '#/agents?agent=acme%3Acoder', scripts: [STRIP] });
const d = '[data-ws-drawer="acme:coder"]';
await wait(`${open('acme:coder')} && document.querySelector('[data-ws-agent="acme:coder"]')`);
assert.match(await text(`${d} [data-drawer-state]`), /state not read$/);
assert.equal(await text(`${d} [data-drawer-nostop]`), 'Its state was not read, so Stop is not offered.');
assert.match(await text('[data-ws-agent="acme:coder"]'), /state not read/);
assert.doesNotMatch(await text('[data-ws-agent-list]'), /not running/);
assert.equal(await has('[data-ws-astate="not-read"]'), true);
});
test('SP14: the organization page has no Queue card and makes no queue read', async t => {
const counts = {};
const { b, wait, has } = await setup(t, { counts, scripts: [URLS] });
await wait(`${settled} && document.querySelector('[data-ws-session-agent="acme:coder"]')`);
await b.evaluate('new Promise(r => setTimeout(r, 400))');
assert.equal(await has('[data-org-queue]'), false);
assert.equal(await has('[data-org-queue-why]'), false);
const heads = await b.evaluate(`[...document.querySelectorAll('[data-org-dashboard] h2, [data-org-dashboard] h3')].map(h => h.textContent.trim())`);
assert.ok(heads.includes('Active agent sessions'), heads.join(' | '));
assert.ok(!heads.some(h => /^Queue\b/.test(h)), heads.join(' | '));
assert.deepEqual(await b.evaluate('window.__urls.filter(u => /queue/i.test(u))'), []);
assert.equal(counts.queue ?? 0, 0, 'the server read no queue');
});
test('SP14: an instances answer with no runs says running was not read on the organization page and the Dashboard, never 0', async t => {
const { b, base, wait, text, has } = await setup(t, { scripts: [STRIP] });
await wait(`${settled} && document.querySelector('[data-org-dashboard] [data-ws-tile="agents"]')`);
const sec = await b.evaluate(`[...document.querySelectorAll('[data-org-dashboard] .ws-sec-h')].find(h => h.querySelector('h2')?.textContent === 'Active agent sessions')?.innerText.replace(/\\s+/g, ' ').trim()`);
assert.match(sec, /^Active agent sessions running not read /);
assert.doesNotMatch(sec, /none running|\b0 running/);
assert.equal(await text('[data-runs-unread]'), 'Not read: the instance read did not say which agents run.');
assert.equal(await text('[data-org-dashboard] [data-ws-tile="agents"]'), 'Agents working Not read: the instance read did not say which agents run');
assert.equal(await has('[data-org-dashboard] [data-ws-tile="agents"] .ws-big'), false);
assert.equal(await text('[data-org-setting="Agents and roles"] .v'), '3 agents · running not read');
// The Dashboard: the tile counts no organization whose runs were not read and names it; its row says so.
await b.navigate(`${base}/#/dashboard`);
await wait(`document.querySelector('[data-ws-tile="agents"]') && document.querySelector('[data-ws-orgrow="acme"]')`);
const tile = await text('[data-ws-tile="agents"]');
assert.match(tile, /^Agents working Not read Acme Studio \(running not read\)$/);
assert.equal(await has('[data-ws-tile="agents"] .ws-big'), false);
const row = await text('[data-ws-orgrow="acme"]');
assert.match(row, /3 agents \(running not read\)/);
assert.doesNotMatch(row, /\b0 running|0 working/);
});
test('a repaint while Stop asks its question keeps the question on screen and Stop disabled', async t => {
const { b, wait, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder' });
const d = '[data-ws-drawer="acme:coder"]';
await wait(`${open('acme:coder')} && document.querySelector('${d} [data-drawer-stop]')`);
await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').click()`);
await wait(`document.querySelector('${d} [data-drawer-stop-yes]')`);
// Mark the drawer, so the test knows the repaint replaced it.
await b.evaluate(`document.querySelector('${d}').dataset.before = '1'`);
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait(`document.querySelector('${d}') && !document.querySelector('${d}').dataset.before`);
assert.equal(await has(`${d} [data-drawer-stop-confirm]`), true);
assert.equal(await has(`${d} [data-drawer-stop-yes]`), true);
assert.equal(await b.evaluate(`document.querySelector('${d} [data-drawer-stop]').disabled`), true);
assert.deepEqual(await b.evaluate('window.__posts'), []);
});
@@ -92,7 +92,8 @@ test('creating another organization opens its setup, receipt, header and theme',
assert.ok(switched, 'new organization setup loaded after the switch');
await b.evaluate('window.MosaicAppearance.ready');
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/beta\/settings\/organizations\?org=beta&step=setup/);
assert.match(await b.evaluate('document.querySelector("header").textContent'), /beta/);
// SP1: the shell no longer names the organization in its header; the breadcrumb does.
assert.match(await b.evaluate('document.querySelector("#breadcrumbs [data-crumb-org]").textContent'), /beta/);
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'cobalt');
assert.match(await b.evaluate('document.querySelector("#org-receipt").textContent'), /Organization created.*beta/s);
});
@@ -142,18 +143,28 @@ test('sidebar lists organizations and navigating changes the route and header',
await b.viewport(390, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/business`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelectorAll('[data-workspace-org]').length===2 && document.querySelector('#settings-page-title'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organizations did not appear')})()`);
assert.match(await b.evaluate(`document.querySelector('header').textContent`), /Alpha/);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent==='Alpha')return;await new Promise(r=>setTimeout(r,30))}throw Error('organization crumb missing')})()`);
assert.equal(await b.evaluate(`document.querySelector('#current-organization')`), null, 'the header does not name an organization');
assert.equal(await b.evaluate(`document.querySelector('#settings-page-title').textContent`), 'Organization');
await b.evaluate('window.__sameDocument = true');
assert.equal(await b.evaluate(`!!document.querySelector('#org-picker')`), false);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(o=>o.dataset.workspaceOrg)`), ['alpha', 'beta']);
await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`);
let switched = false;
for (let i = 0; i < 150 && !switched; i++) {
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch { /* reload replaced the page */ }
// The sidebar opens the organization's own page: its breadcrumb is Dashboard / Beta.
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('#breadcrumbs')?.textContent === 'Dashboard/Beta'`); } catch { /* not ready */ }
if (!switched) await new Promise(r => setTimeout(r, 30));
}
assert.equal(switched, true, 'switch completed after reload');
assert.match(await b.evaluate(`document.querySelector('body').textContent`), /Bus unavailable/);
assert.equal(switched, true, 'switch completed in place');
assert.equal(await b.evaluate('window.__sameDocument'), true, 'the switch did not reload the page');
// #1873 SP2: the organization's own page says the bus is down once, in its own banner; the shell's banner gives way there.
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-down]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('down banner missing')})()`);
await new Promise(r => setTimeout(r, 400));
assert.equal(await b.evaluate(`document.querySelector('#bus-down').hidden`), true, 'the organization page shows one bus banner');
// On the organization's other pages the shell's banner still says it, and stays after the boot-status read.
await b.evaluate(`location.hash = '#/orgs/beta/tasks'`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(!document.querySelector('#bus-down').hidden)return;await new Promise(r=>setTimeout(r,30))}throw Error('offline banner missing')})()`);
await new Promise(r => setTimeout(r, 400));
assert.equal(await b.evaluate(`document.querySelector('#bus-down').hidden`), false, 'offline banner remains visible after the boot-status read');
assert.match(await b.evaluate(`document.querySelector('#bus-down').textContent`), /Bus unavailable/);
@@ -193,7 +204,7 @@ test('a chat draft is kept on Stay and discarded on an organization switch', asy
await b.evaluate(`document.querySelector('[data-discard]').click()`).catch(() => {});
let switched = false;
for (let i = 0; i < 150 && !switched; i++) {
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch {}
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent === 'Beta'`); } catch {}
if (!switched) await new Promise(r => setTimeout(r, 30));
}
assert.equal(switched, true);
@@ -212,15 +223,9 @@ test('organization switch isolates browser board preferences', async t => {
await b.navigate(base + '#/orgs/alpha/');
await b.evaluate(`document.querySelector('#show-machine').click()`);
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true);
// The switch reloads the page. The navigate helper's load checks can be in flight when that
// reload commits, so set the hash in the page and poll one short check at a time.
// #1873 SP1: the switch happens in place, with no reload, and beta's own board setting applies.
await b.evaluate(`location.hash = '#/orgs/beta/'`);
let loaded = false;
for (let i = 0; i < 150 && !loaded; i++) {
try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {}
if (!loaded) await new Promise(r => setTimeout(r, 30));
}
assert.equal(loaded, true, 'beta did not load');
await b.evaluate(`(async () => { for (let i = 0; i < 150; i++) { if (window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')) return; await new Promise(r => setTimeout(r, 30)); } throw Error('beta did not load'); })()`);
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false);
});
@@ -165,3 +165,9 @@ test('S4 fix stale project gather cannot enrich a newer request revision', async
const x=await b.evaluate(`({revision:document.querySelector('.project-request-intro').dataset.requestRevision,prd:document.querySelector('[data-work-prd]').textContent,files:document.querySelector('[data-work-files]').textContent})`);
assert.equal(x.revision,'2');assert.doesNotMatch(x.prd,/v3|Approved/);assert.doesNotMatch(x.files,/4 draft/);
});
// SP7 review: the requirement view sends the person to the conversation's Request details, which a pane
// does not have, so both of its "Open conversation" links are marked as the full view.
test('SP7 requirement view links the conversation\'s full view, not a pane',async t=>{const {b,wait}=await setup(t,'/projects/'+P+'/requirements');await wait(`document.querySelector('[data-prd]')`);const links=await b.evaluate(`[...document.querySelectorAll('[data-project-content] a')].filter(a=>a.textContent.trim()==='Open conversation').map(a=>a.hasAttribute('data-full-view'))`);assert.deepEqual(links,[true,true]);});
// SP7 re-check: the overview's "Review decision" and its two request counts lead to a request, which only
// the conversation's full view shows, so each link that names a conversation is marked as the full view.
test('SP7 overview decision and request links go to the conversation\'s full view',async t=>{const {b,wait}=await setup(t,'/projects');await b.evaluate(`reviewStates['request-1'].wait={kind:'decision'};document.querySelector('#refresh').click();location.hash='#/projects/${P}'`);await wait(`document.querySelector('.project-decision a')`);const got=await b.evaluate(`({decision:[...document.querySelectorAll('.project-decision a')].map(a=>[a.textContent.trim(),a.hasAttribute('data-full-view'),/\\/threads\\//.test(a.getAttribute('href'))]),metrics:[...document.querySelectorAll('.project-metrics a')].map(a=>[a.querySelector('span').textContent,/\\/threads\\//.test(a.getAttribute('href')),a.hasAttribute('data-full-view')])})`);assert.deepEqual(got,{decision:[['Review decision',true,true]],metrics:[['Requests in progress',true,true],['Needs your input',true,true],['Conversations',false,false],['Files',false,false]]});});
+7 -1
View File
@@ -68,7 +68,13 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });", "fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 11);
// Ruling 2026-11-14 (#1873): Fire on an Agents card is a second hire.start caller, {op:'fire'}.
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "'/api/decisions/hire/start'", 'url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']);
// SP3 (#1873): an answer from the system Inbox goes to its row's organization. decideBase is
// /api/orgs/<org> for such an answer and /api otherwise, so the route is still a decision's
// confirmation or resolve, only pinned to one organization.
// SP5 (#1873): Fire from the system Agents page goes to its row's organization the same way:
// decideBase({org}) is /api/orgs/<org>, and an agent card's Fire (no org) keeps /api.
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ["`/api/decisions/hire/${templatesOnly?'templates':'start'}`", "`${decideBase({ org: named })}/decisions/hire/start`", 'url', '`${decideBase(a)}/decisions/${enc(a.id)}/confirmation`', '`${decideBase(a)}/decisions/${enc(a.id)}/resolve`']);
assert.deepEqual([...read('bus.js').toString().matchAll(/const decideBase = [^;]*;/g)].map(m => m[0]), ["const decideBase = a => a.org ? `/api/orgs/${enc(a.org)}` : '/api';"]);
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
// Row 176 (#1674): the tester name form adds a second /api/name caller.
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'", "'/api/name'"]);
@@ -1,432 +0,0 @@
// U5 (#1873, sample 07): Side by side shows two panes, each holding one page of any organization.
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 waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
// Three organizations: two running buses and one stopped (home). `state.flip` changes what the
// running organizations report, so a refresh repaints both panes.
function orgs(state = { flip: false, seen: [] }) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
];
const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] };
const INSTANCES = { ddigital: ['chief', 'copywriter', 'producer', 'editor', 'researcher', 'designer', 'analyst', 'scheduler'], forge: ['chief', 'builder', 'tester', 'reviewer', 'operator', 'scribe'] };
const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'Launch email', project: 'spring-launch', instance: 'copywriter' }], forge: [{ id: 'f0', name: 'Gateway auth', project: 'api-gateway', instance: 'chief' }] };
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
instances: Object.fromEntries(INSTANCES[id].map((n, i) => [n, { definition: n, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [`r${i}`], alive: [] }])) });
const agents = id => INSTANCES[id].map((role, i) => ({ role, holder_run: `r${i}`, state: (i % 2 === 0) === state.flip ? 'working' : 'waiting' }));
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: {}, instances: INSTANCES[id].map(n => ({ instance: n, vars: {} })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
return {
list: async () => ORGS,
resolve: async id => {
state.seen.push(id);
const org = ORGS.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true,
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', launch: { limits: { total: 4 } } }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => agents(id), 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
chat: { projects: async () => PROJECTS[id], threads: async () => THREADS[id] },
projectFiles: async () => ({ head: 'a'.repeat(40), files: [] }),
};
},
};
}
async function open(t, { width = 1440, height = 900, hash = '#/orgs/ddigital/side-by-side', state } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs(state), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(width, height);
const base = `http://127.0.0.1:${server.address().port}/`;
await b.navigate(base + hash);
return { b, base, wait: waitFor(b) };
}
const text = key => `(document.querySelector("#pane-${key} [data-pane-body]")?.innerText || "")`;
const title = key => `(document.querySelector("#pane-${key} [data-pane-ttl]")?.textContent || "")`;
const query = '(() => Object.fromEntries(new URLSearchParams(location.hash.split("?")[1] || "")))()';
const pick = (b, key, value) => b.evaluate(`(() => { const s = document.querySelector("#pane-${key}-pick"); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error("no option " + ${JSON.stringify(value)}); s.dispatchEvent(new Event("change", { bubbles: true })); })()`);
test('side by side stays dark by default under light media, and applying system or light really draws light', async t => {
const { b, wait, base } = await open(t);
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
const drawn = () => b.evaluate(`({ mode: document.documentElement.getAttribute('data-mode'),
palette: document.documentElement.dataset.palette, canvas: getComputedStyle(document.body).backgroundColor,
source: window.MosaicAppearance.state().modeSource })`);
assert.deepEqual(await drawn(), { mode: 'dark', palette: 'rosewood', canvas: 'rgb(26, 20, 23)', source: 'default' });
for (const mode of ['system', 'light']) {
// U5's corrected shot recipe seeds the per-org key, then loads a fresh document.
const before = await drawn();
await b.evaluate(`localStorage.setItem('mosaic-console-appearance:ddigital', JSON.stringify({ mode: ${JSON.stringify(mode)} }))`);
assert.deepEqual(await drawn(), before, 'writing storage alone does not apply an override to this document');
await b.navigate('about:blank');
await b.navigate(`${base}?appearance=${mode}#/orgs/ddigital/side-by-side`);
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
const result = await drawn();
assert.deepEqual(result, { mode: mode === 'system' ? null : 'light', palette: 'rosewood',
canvas: 'rgb(246, 243, 245)', source: 'browser' });
t.diagnostic(JSON.stringify({ scheme: 'light', requested: mode, ...result }));
}
});
test('the Side by side nav item opens two panes with different organizations, and the URL restores both after a reload', async t => {
const state = { flip: false, seen: [] };
const { b, base, wait } = await open(t, { hash: '#/orgs/ddigital/organization', state });
await wait('document.querySelector("[data-org-dashboard]")');
await b.evaluate('[...document.querySelectorAll("#sections > li > a")].find(a => a.textContent.trim() === "Side by side").click()');
await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("Forge")`);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /Side by side is not built yet/i);
assert.equal(await b.evaluate('document.querySelector("#bus-view h1").textContent'), 'Side by side');
assert.equal(await b.evaluate('document.title'), 'Side by side · Mosaic Console');
assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/organization', w: '50', show: 'a' });
await wait(`${text('a')}.includes("Distinctly Digital") && ${text('b')}.includes("Forge")`);
// Each pane's links name its own organization.
assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/forge/"))'), true);
assert.equal(await b.evaluate('document.querySelectorAll("#pane-a [data-pane-body] h1[aria-level=\\"2\\"]").length > 0'), true);
// The picker lists every organization and its projects.
const groups = await b.evaluate('[...document.querySelectorAll("#pane-b-pick optgroup")].map(g => g.label)');
assert.deepEqual(groups, ['Distinctly Digital', 'Forge', 'Home · bus unavailable']);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#pane-b-pick option")].map(o => o.value).filter(Boolean)'),
['ddigital/organization', 'ddigital/agents', 'ddigital/projects/spring-launch', 'ddigital/projects/brand-refresh', 'forge/organization', 'forge/agents', 'forge/projects/api-gateway', 'home/organization', 'home/agents']);
// Pick a project in the right pane; the left keeps its page.
await pick(b, 'b', 'forge/projects/api-gateway');
await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway") && location.hash.includes("b=forge/projects/api-gateway")`);
assert.match(await b.evaluate(title('a')), /^Dashboard/);
// A project tab opens in the pane, not the full view.
await b.evaluate('[...document.querySelectorAll("#pane-b nav.project-tabs a")].find(a => /Tasks/.test(a.textContent)).click()');
await wait(`location.hash.includes("b=forge/projects/api-gateway/tasks") && ${title('b')}.includes("Tasks")`);
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/ddigital\/side-by-side\?/);
// Reload: both panes come back from the URL.
await b.call('Page.reload');
await wait('document.readyState === "complete"');
await wait(`${title('a')}.startsWith("Dashboard") && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("API gateway") && ${title('b')}.includes("Tasks")`);
assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/projects/api-gateway/tasks', w: '50', show: 'a' });
// A shared link opens exactly what it names, whatever this browser remembered.
await b.navigate(`${base}#/orgs/forge/side-by-side?a=forge/agents&b=ddigital/projects/spring-launch&w=40&show=b`);
await wait(`${title('a')}.startsWith("Agents") && ${title('a')}.includes("Forge") && ${title('b')}.startsWith("Spring launch")`);
assert.equal(await b.evaluate('document.querySelector("[data-sbs-divider]").getAttribute("aria-valuenow")'), '40');
// A link with a route a pane does not draw falls back to the remembered layout or the default.
await b.navigate(`${base}#/orgs/ddigital/side-by-side?a=ddigital/settings&b=nope!/organization`);
await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.length > 0 && !location.hash.includes("settings")`);
// Leaving the page stops both panes reading.
await b.evaluate('location.hash = "#/orgs/ddigital/agents"');
await wait('!document.querySelector("[data-side-by-side]") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Agents")');
assert.ok(state.seen.includes('forge') && state.seen.includes('ddigital'));
});
test('the divider resizes the panes by mouse and by keyboard', async t => {
const { b, wait } = await open(t);
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
const sep = '[role=separator][data-sbs-divider]';
assert.deepEqual(await b.evaluate(`(() => { const d = document.querySelector("${sep}"); return [d.getAttribute("aria-valuenow"), d.getAttribute("aria-valuemin"), d.getAttribute("aria-valuemax"), d.getAttribute("aria-orientation"), d.tabIndex]; })()`), ['50', '25', '75', 'vertical', 0]);
const ratio = `(() => { const a = document.getElementById("pane-a").getBoundingClientRect().width, b = document.getElementById("pane-b").getBoundingClientRect().width; return Math.round(a / (a + b) * 100); })()`;
// Mouse: drag the divider to 30% of the panes' width.
const box = await b.evaluate(`(() => { const p = document.querySelector("[data-sbs-panes]").getBoundingClientRect(), d = document.querySelector("${sep}").getBoundingClientRect(); return { left: p.left, width: p.width, x: d.left + d.width / 2, y: d.top + 200 }; })()`);
const target = box.left + box.width * 0.3;
await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y });
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: box.x, y: box.y, button: 'left', buttons: 1, clickCount: 1 });
for (const step of [0.25, 0.5, 0.75, 1]) await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x + (target - box.x) * step, y: box.y, button: 'left', buttons: 1 });
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: target, y: box.y, button: 'left', buttons: 0, clickCount: 1 });
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "30" && location.hash.includes("w=30")`);
assert.ok(Math.abs(await b.evaluate(ratio) - 30) <= 1);
// Keyboard: the separator has focus after the drag; arrows step by 5, Home and End reach the limits.
assert.equal(await b.evaluate('document.activeElement.getAttribute("role")'), 'separator');
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "35"`);
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "25"`);
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
assert.equal(await b.evaluate(`document.querySelector("${sep}").getAttribute("aria-valuenow")`), '25');
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'End', code: 'End', windowsVirtualKeyCode: 35 });
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'End', code: 'End', windowsVirtualKeyCode: 35 });
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "75" && location.hash.includes("w=75")`);
assert.ok(Math.abs(await b.evaluate(ratio) - 75) <= 1);
// Closing a pane goes back to one; the divider leaves with it. Add pane brings a second back.
await b.evaluate('document.querySelector("#pane-a [data-pane-close]").click()');
await wait(`document.getElementById("pane-b").hidden && document.querySelector("${sep}").hidden && !new URLSearchParams(location.hash.split("?")[1]).has("b") && ${title('a')}.includes("Forge")`);
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-pick');
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-close]").hidden'), true);
assert.match(await b.evaluate('document.querySelector("[data-sbs-note]").textContent'), /^1 pane ·/);
await b.evaluate('document.querySelector("[data-pane-add]").click()');
await wait(`!document.getElementById("pane-b").hidden && ${title('b')}.length > 0 && new URLSearchParams(location.hash.split("?")[1]).has("b")`);
assert.equal(await b.evaluate('document.querySelector("[data-pane-add]").disabled'), true);
});
test('each pane keeps its own focus and scroll through a refresh', async t => {
const state = { flip: false, seen: [] };
const { b, wait } = await open(t, { height: 520, state, hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' });
await wait(`${text('a')}.includes("copywriter") && ${text('b')}.includes("builder")`);
const scrollable = await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollHeight - el.clientHeight)');
assert.ok(scrollable.every(n => n > 60), `both panes scroll: ${scrollable}`);
const focused = await b.evaluate('(() => { const a = document.querySelector("#pane-a [data-pane-body] a[href]"); a.focus(); return a.getAttribute("href"); })()');
await b.evaluate('document.querySelector("#pane-a [data-pane-body]").scrollTop = 40; document.querySelector("#pane-b [data-pane-body]").scrollTop = 60;');
const before = await b.evaluate(`[${text('a')}, ${text('b')}]`);
state.flip = true;
await b.evaluate('document.getElementById("refresh").click()');
await wait(`${text('a')} !== ${JSON.stringify(before[0])} && ${text('b')} !== ${JSON.stringify(before[1])}`);
assert.equal(await b.evaluate('document.querySelector("#pane-a").contains(document.activeElement) && document.activeElement.getAttribute("href")'), focused);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]);
// Focus in the right pane stays there while the left repaints.
const right = await b.evaluate('(() => { const a = document.querySelector("#pane-b [data-pane-body] a[href]"); a.focus({ preventScroll: true }); return a.getAttribute("href"); })()');
state.flip = false;
await b.evaluate('document.getElementById("refresh").click()');
await wait(`${text('a')} === ${JSON.stringify(before[0])}`);
assert.equal(await b.evaluate('document.querySelector("#pane-b").contains(document.activeElement) && document.activeElement.getAttribute("href")'), right);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]);
});
test('a pane whose organization is down shows its state and the other pane still works', async t => {
const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=home/organization&b=forge/organization' });
await wait(`${title('a')}.includes("Home") && ${title('b')}.includes("Forge") && ${text('b')}.includes("Forge")`);
await wait('/Home: bus unavailable/.test(document.querySelector("#pane-a [data-pane-line]").textContent)');
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-line]").getAttribute("role")'), 'status');
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-state]").innerText.trim().length > 0'), true);
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-pane-line]").hidden'), true);
await pick(b, 'b', 'forge/agents');
await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`);
await pick(b, 'b', 'forge/projects/api-gateway');
await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway")`);
assert.match(await b.evaluate(title('a')), /Home/);
// An organization that is not on the list is one honest line; nothing is read for it.
await b.evaluate('location.hash = "#/orgs/ddigital/side-by-side?a=ghost/organization&b=forge/agents"');
await wait(`/ghost is not on your organization list/.test(document.querySelector("#pane-a [data-pane-line]").textContent) && ${text('b')}.includes("builder")`);
});
test('on a phone one pane shows at a time, switched from the bar, with no horizontal scroll', async t => {
const { b, wait } = await open(t, { width: 390, height: 844 });
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0 && ${text('a')}.length > 0`);
const visible = '[...document.querySelectorAll(".panes > .pane")].filter(p => p.getBoundingClientRect().height > 0).map(p => p.id)';
assert.deepEqual(await b.evaluate(visible), ['pane-a']);
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-tabs]")).display'), 'none');
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-divider]")).display'), 'none');
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-pane-add]")).display'), 'none');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['true', 'false']);
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll');
await b.evaluate('document.querySelector("[data-sbs-tabs] [data-pane-show=b]").click()');
await wait(`JSON.stringify(${visible}) === '["pane-b"]' && location.hash.includes("show=b")`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['false', 'true']);
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on the second pane');
await pick(b, 'b', 'ddigital/projects/spring-launch');
await wait(`${text('b')}.includes("Spring launch")`);
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on a project page');
});
// Review fixes (#1873 U5 review, findings 1-4). `hold(url)` makes the page's next read of that URL
// wait until `release()`; the test then changes the pane while the read is pending.
const hold = url => `(() => { const real = window.fetch; window.held = { url: ${JSON.stringify(url)}, pending: false };
window.fetch = async (u, o) => { if (window.held?.url === u && !window.held.done) { window.held.pending = true; await new Promise(r => { window.held.release = () => { window.held.done = true; r(); }; }); } return real(u, o); }; })()`;
const settled = b => b.evaluate('new Promise(r => setTimeout(r, 400))');
test('a pane closed and opened again rewrites new links for its current organization only', async t => {
const { b, wait } = await open(t);
await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`);
await b.evaluate('document.querySelector("#pane-b [data-pane-close]").click()');
await wait('document.querySelector("#pane-b").hidden && !document.querySelector("[data-pane-add]").disabled');
await b.evaluate('document.querySelector("[data-pane-add]").click()');
await wait(`!document.querySelector("#pane-b").hidden && ${title('b')}.length > 0`);
await pick(b, 'b', 'ddigital/organization');
await wait(`${title('b')}.includes("Distinctly Digital") && location.hash.includes("b=ddigital/organization")`);
// Content a page appends after its own reads (projects.js does this) gets the pane's organization.
await b.evaluate(`document.querySelector("#pane-b [data-pane-body]").insertAdjacentHTML("beforeend", '<a data-late href="#/docs/example">late</a><a data-late-api href="/api/files/x">file</a>')`);
await wait('document.querySelector("#pane-b [data-late]").getAttribute("href").startsWith("#/orgs/")');
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late]").getAttribute("href")'), '#/orgs/ddigital/docs/example');
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late-api]").getAttribute("href")'), '/api/orgs/ddigital/files/x');
// Leaving the page stops every pane observer: later content in the old element is left alone.
await b.evaluate('location.hash = "#/orgs/ddigital/agents"');
await wait('!document.querySelector("[data-side-by-side]")');
});
test('a slow project read never paints over the page or organization chosen since', async t => {
const { b, wait } = await open(t);
await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`);
// Page change while the project's thread read is pending.
await b.evaluate(hold('/api/orgs/forge/chat/threads'));
await pick(b, 'b', 'forge/projects/api-gateway');
await wait('window.held.pending');
await pick(b, 'b', 'forge/agents');
await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`);
await b.evaluate('window.held.release()');
await settled(b);
assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old project did not replace Agents');
assert.match(await b.evaluate(title('b')), /^Agents/);
// Organization change while the read is pending: the old organization's project is not painted.
await b.evaluate(hold('/api/orgs/forge/chat/threads'));
await pick(b, 'b', 'forge/projects/api-gateway');
await wait('window.held.pending');
await pick(b, 'b', 'ddigital/projects/spring-launch');
await wait(`${title('b')}.startsWith("Spring launch") && ${text('b')}.includes("Spring launch")`);
await b.evaluate('window.held.release()');
await settled(b);
assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old organization did not replace Spring launch');
assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/orgs/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/ddigital/"))'), true);
});
test('the same project in both panes keeps a request choice per pane', async t => {
const { b, wait } = await open(t);
await wait(`${title('b')}.includes("Forge")`);
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
// The fixture answers the project reads of both organizations; the organization list stays real.
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
await b.evaluate('window.MosaicPanes.refresh()');
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`);
const values = `[...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value)`;
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`);
assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']);
await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
await wait(`${values}[0] === 'request-2'`);
// The redrawn select gets focus back through the pane's own id.
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-project-request-select');
await b.evaluate('window.MosaicPanes.refresh()');
await settled(b);
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`);
assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']);
});
test('a pane keeps its request choice and PRD disclosure when a new conversation repaints the project', async t => {
const { b, wait } = await open(t);
await wait(`${title('b')}.includes("Forge")`);
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
await b.evaluate('window.MosaicPanes.refresh()');
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
// The same organization and project in both panes.
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`);
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`);
const state = `({ selected: [...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`;
await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
await wait(`document.querySelector('#pane-a-project-request-select').value === 'request-2'`);
await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`);
await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`);
// A new conversation changes the header's count, so each pane replaces its project content.
const before = await b.evaluate(`document.querySelector('#pane-a .project-header p')?.textContent || ''`);
await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`);
await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`);
await settled(b);
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`);
assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] });
});
test('a pane navigation that finishes late does not take focus from the other pane', async t => {
const { b, wait } = await open(t);
await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]') && document.querySelector('#pane-b-pick')?.options.length > 4`);
await b.evaluate(hold('/api/orgs/ddigital/bus/instances'));
await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`);
await wait('window.held.pending');
await b.evaluate(`document.querySelector('#pane-b-pick').focus(); window.held.release()`);
await wait(`${title('a')}.startsWith('Agents')`);
await settled(b);
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-b-pick');
// Without a move elsewhere, the same navigation still lands on the new page's heading.
await pick(b, 'a', 'ddigital/organization');
await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]')`);
await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`);
await wait(`${title('a')}.startsWith('Agents') && document.activeElement?.tagName === 'H1' && document.querySelector('#pane-a').contains(document.activeElement)`);
});
// A1's "Available in a project" chooser, with an Agents pane per organization: each pane's choice
// redraws its own table and survives a refresh, keyed by that pane's organization.
test('each Agents pane keeps its own project choice for its organization', async t => {
const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' });
const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
const set = agents => ({ state: 'ok', agents, excluded: [] });
const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [
{ id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { 'spring-launch': [{ name: 'social-scheduler', form: 'define' }] },
projects: { 'spring-launch': set([a('chief'), a('social-scheduler', 'project', 'coder')]), 'brand-refresh': set([a('chief')]) } },
{ id: 'forge', state: 'ok', base: set([a('chief')]), defined: { 'api-gateway': [{ name: 'load-tester', form: 'define' }] },
projects: { 'api-gateway': set([a('chief'), a('load-tester', 'project', 'coder')]), ops: set([a('chief')]) } },
] };
await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`);
await b.evaluate('window.MosaicPanes.refresh()');
const select = key => `document.querySelector('#pane-${key} [data-agents-project]')`;
const rows = key => `[...document.querySelectorAll('#pane-${key} [data-agents-available] tr[data-agent]')].map(r => r.dataset.agent)`;
await wait(`${select('a')} && ${select('b')} && ${rows('a')}.includes('social-scheduler') && ${rows('b')}.includes('load-tester')`);
await b.evaluate(`(() => { const s = ${select('b')}; s.value = 'ops'; s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
await wait(`!${rows('b')}.includes('load-tester')`);
assert.equal(await b.evaluate(`${rows('a')}.includes('social-scheduler')`), true);
assert.equal(await b.evaluate(`${rows('a')}.includes('load-tester')`), false);
await b.evaluate('window.MosaicPanes.refresh()');
await settled(b);
await wait(`${select('a')} && ${select('b')}`);
assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']);
assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true);
});
// Polish 1 (#1873): every id a pane draws is its own. With the same page in both panes no id
// appears twice, and every label, ARIA reference and in-page link resolves inside its own pane.
const idAudit = `(() => {
const count = new Map();
for (const el of document.querySelectorAll('[id]')) count.set(el.id, (count.get(el.id) || 0) + 1);
const duplicates = [...count].filter(([, n]) => n > 1).map(([id]) => id).sort();
const stray = [];
for (const pane of document.querySelectorAll('.panes > .pane:not([hidden])')) {
for (const attr of ['for', 'aria-labelledby', 'aria-describedby', 'aria-controls']) {
for (const el of pane.querySelectorAll('[' + attr + ']')) {
for (const id of el.getAttribute(attr).split(/\\s+/).filter(Boolean)) {
const target = document.getElementById(id);
if (!target || !pane.contains(target)) stray.push(pane.id + ' ' + attr + '=' + id);
}
}
}
for (const a of pane.querySelectorAll('a[href^="#"]:not([href^="#/"])')) {
const id = decodeURIComponent(a.getAttribute('href').slice(1));
const target = id && document.getElementById(id);
if (id && (!target || !pane.contains(target))) stray.push(pane.id + ' href=#' + id);
}
}
return { duplicates, stray: stray.sort() };
})()`;
test('the same project, dashboard and Agents page in both panes draw no duplicate ids, and every reference stays in its pane', async t => {
const { b, wait } = await open(t);
await wait(`${title('b')}.includes("Forge")`);
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
const set = agents => ({ state: 'ok', agents, excluded: [] });
const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [
{ id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { [P]: [{ name: 'social-scheduler', form: 'define' }] },
projects: { [P]: set([a('chief'), a('social-scheduler', 'project', 'coder')]) } },
] };
await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`);
await b.evaluate('window.MosaicPanes.refresh()');
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`;
const pages = [
['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-org-settings]').length === 2`],
['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`],
[`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
[`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`],
[`/projects/${P}/tasks`, `${title('a')}.includes('Tasks') && ${title('b')}.includes('Tasks') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
[`/projects/${P}/requirements`, `document.querySelectorAll('[data-pane-body] [data-prd]').length === 2 && document.querySelectorAll('[data-pane-body] select').length >= 2`],
[`/projects/${P}/files`, `${title('a')}.includes('Files') && ${title('b')}.includes('Files') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
];
const found = {}, drawn = {};
for (const [route, ready] of pages) {
await b.evaluate(both(route));
await wait(ready);
await settled(b);
const page = route.replace(P, 'P');
found[page] = await b.evaluate(idAudit);
drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`);
}
assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }])));
// The audit is not vacuous: the dashboard draws its reload note and the requirements page its
// request select, north star and PRD headings, each with the pane's prefix.
assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why']);
assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']);
});
@@ -0,0 +1,435 @@
// SP1 (#1873, single pane slice 1): #/dashboard is the system landing route, an organization page is a
// drill-in route inside the same shell (no reload, the sidebar and top bar stay), and an organization
// whose bus is down says so with its reason. Every bus is a fake: acme and beta answer, gamma's bus is
// stopped. The real GET /api/orgs, /api/workspace and /api/agents/levels serve the page.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
async function fixture({ empty = false, bootless = false } = {}) {
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) };
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true },
];
const ROLES = {
acme: { chief: { definition: 'chief-of-staff' }, coder: { definition: 'coder' }, reviewer: { definition: 'reviewer' } },
beta: { chief: { definition: 'chief-of-staff' } },
gamma: { chief: { definition: 'chief-of-staff' }, researcher: { definition: 'research' } },
};
const DATA = {
acme: {
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }, { id: 'p-zeta', repo: 'zeta', name: 'Zeta API', archived: false, at: at(300) }],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
inbox: [{ id: 'dec-acme-1', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', at: at(10), options: [{ key: 'yes', text: 'Ship it' }] }],
},
beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] },
};
// Launch settings differ per organization so a value carried from one to the other shows.
const LAUNCH = id => ({ business: id, revision: (id === 'acme' ? 'a' : 'b').repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: id === 'acme' ? 4 : 7 }, instances: {}, usage: { total: 0, providers: {}, models: {} } });
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') },
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: ROLES[org.id], projects: {} },
status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : bootless ? { healthy: false, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
options: () => opts(org.id),
});
const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }]));
const organizations = {
list: async () => (empty ? [] : LIST),
members: async () => (empty ? [] : LIST.map(member)),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({ business: id, release: 'fixture', notifier: {}, launch: LAUNCH(id) }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, '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, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) },
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
},
};
return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } };
}
async function setup(t, { viewport = [1440, 900], path = '/', empty = false, init = null, bootless = false } = {}) {
const { organizations, agentLevels } = await fixture({ empty, bootless });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
// `init` runs before the page's own scripts, under the display.js fetch wrapper, so it sees the scoped URL.
if (init) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: init });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}${path}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const rowText = org => `document.querySelector("[data-ws-orgrow=${org}]")?.innerText.replace(/\\s+/g, " ").trim() ?? ""`;
test('the bare address lands on #/dashboard without a reload and lists every organization with only the figures read', async t => {
const { b, wait, text } = await setup(t);
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
// One page load: the landing route is set in place, never by location.reload().
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
assert.equal(await b.evaluate('performance.getEntriesByType("navigation")[0].type'), 'navigate');
assert.equal(await text('#bus-view h1'), 'Dashboard');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/dashboard');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(r => r.dataset.wsOrgrow)'), ['acme', 'beta', 'gamma']);
// Agents from the levels read, projects and needs-you from the workspace read.
await wait(`${rowText('acme')}.includes("3 agents")`);
const acme = await b.evaluate(rowText('acme'));
assert.match(acme, /Acme Studio/);
assert.match(acme, /bus running/);
assert.match(acme, /2 projects/);
assert.match(acme, /1 needs you/);
assert.match(await b.evaluate(rowText('beta')), /1 agent\b/);
// gamma: down with its reason; its agent count comes from its file, the bus figures are not read.
const gamma = await b.evaluate(rowText('gamma'));
assert.match(gamma, /bus stopped/);
assert.match(gamma, /stopped by the owner/);
assert.match(gamma, /2 agents/);
assert.doesNotMatch(gamma, /\d+ projects?|\d+ needs you/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a")?.getAttribute("href")'), '#/orgs/beta/organization');
// A system route marks no organization in the sidebar and shows no breadcrumb.
assert.equal(await b.evaluate('document.querySelectorAll("#workspace-nav .sel, #workspace-nav [aria-current]").length'), 0);
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")'), null);
// The top bar names no organization.
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
});
test('an organization opens inside the same shell with a breadcrumb, and its pages keep reading that organization', async t => {
const { b, wait, text } = await setup(t);
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await b.evaluate('window.__marker = 1; window.__bar = document.querySelector(".cmdbar"); window.__side = document.getElementById("workspace-nav")');
await b.evaluate('document.querySelector("[data-ws-orgrow=acme] a").click()');
await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
assert.equal(await b.evaluate('window.__marker'), 1);
assert.equal(await b.evaluate('window.__bar === document.querySelector(".cmdbar") && window.__side === document.getElementById("workspace-nav")'), true);
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
assert.equal(await b.evaluate('document.querySelector(".ws-crumb")?.innerText.replace(/\\s+/g, " ").trim()'), 'Dashboard › Acme Studio');
assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard');
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
// The sidebar marks the open organization.
await wait('document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"');
// The sidebar opens another organization in place; its page reads that organization's routes.
await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()');
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
assert.equal(await b.evaluate('window.__marker'), 1);
// #1873 SP5: #/agents is the system Agents page, not beta's; it keeps the address and opens no organization.
await b.evaluate('location.hash = "#/agents"');
await wait('location.hash === "#/agents" && document.querySelector("[data-ws-agents]:not([data-ws-scope])")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
// A page inside beta scopes its reads to beta.
await b.evaluate('location.hash = "#/orgs/beta/agents"');
await wait('location.hash === "#/orgs/beta/agents" && document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
assert.equal(await b.evaluate('window.__marker'), 1);
// The organization's control board is named as such after the organization, not as a second Dashboard.
await b.evaluate('location.hash = "#/orgs/beta/board"');
await wait('/^Dashboard\\/.+\\/Control board$/.test(document.getElementById("breadcrumbs")?.textContent ?? "")');
assert.equal(await b.evaluate('document.querySelector("#breadcrumbs [data-crumb-org]")?.getAttribute("href")'), '#/orgs/beta/organization');
// The breadcrumb goes back to the system dashboard, still without a reload.
await b.evaluate('location.hash = "#/orgs/beta/organization"');
await wait('document.querySelector(".ws-crumb a")');
await b.evaluate('document.querySelector(".ws-crumb a").click()');
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
assert.equal(await b.evaluate('window.__marker'), 1);
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
assert.equal(await text('#bus-view h1'), 'Dashboard');
});
test('an old #/orgs/<id>/... link still opens that page', async t => {
const { b, wait } = await setup(t, { path: '/#/orgs/beta/agents' });
await wait('document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
});
test('a stopped organization says so on its page and on its agents, with the reason', async t => {
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization' });
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
assert.match(down, /The bus is not running/);
assert.match(down, /stopped by the owner/);
await b.evaluate('location.hash = "#/orgs/gamma/agents"');
await wait('document.querySelector("[data-org-agents] [data-org=gamma] [data-agents-why]")');
assert.equal(await b.evaluate('document.querySelector("[data-org=gamma] [data-agents-why]").innerText.trim()'), 'Agents not read: the bus is stopped');
const agents = await b.evaluate('document.querySelector("[data-org-agents]").innerText.replace(/\\s+/g, " ")');
assert.doesNotMatch(agents, /Agents unavailable/);
});
test('at 390 px the dashboard lists the organizations without a horizontal scroll', async t => {
const { b, wait } = await setup(t, { viewport: [390, 844] });
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true);
});
// Review round finding 1: with no organization yet the page keeps its first-run addresses, so the
// wordmark (href "#/") opens the first-run board, not a #/dashboard the first-run page has no view for.
test('first run: the wordmark opens the first-run board, not a Not found page', async t => {
const { b, wait } = await setup(t, { path: '/#/settings/appearance', empty: true });
await wait('document.querySelector("#bus-view h1")');
await b.evaluate('document.querySelector(".wordmark").click()');
// The board route empties the page view; give the router and render time to settle before checking.
await wait('location.hash !== "#/settings/appearance"');
await new Promise(r => setTimeout(r, 800));
assert.equal(await b.evaluate('location.hash'), '#/');
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .nf")'), false);
assert.equal(await b.evaluate('window.MosaicOrg'), undefined);
});
test('a switch stops following an agent request: no poll reaches the next organization and its page shows no receipt', async t => {
const ID = '11111111-2222-4333-8444-555555555555';
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
const init = `window.__reads = []; window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __posts.push(s); x = { request: '${ID}', at: 'x' }; return ${json}; }
if (/\\/agent\\/request\\?/.test(s)) { __reads.push(s); x = { request: '${ID}', action: 'launch', instance: 'reviewer', state: 'open', done: null, cancelled: null }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/agents', init });
const launch = '#agents-section [data-agent-action=launch][data-agent-name=reviewer]';
await wait(`document.querySelector(${JSON.stringify(launch)}) && !document.querySelector(${JSON.stringify(launch)}).disabled`);
await b.evaluate(`document.querySelector(${JSON.stringify(launch)}).click()`);
await wait('window.__reads.length >= 1');
assert.deepEqual(await b.evaluate('window.__posts'), ['/api/orgs/acme/agent/launch']);
assert.match(await b.evaluate('window.__reads[0]'), /^\/api\/orgs\/acme\/agent\/request\?id=/);
await b.evaluate('location.hash = "#/orgs/beta/settings/agents"');
await wait('location.hash === "#/orgs/beta/settings/agents" && document.querySelector("#agents-section [data-agent-name=chief]") && !document.querySelector("#agents-section [data-agent-name=reviewer]")');
const seen = await b.evaluate('window.__reads.length');
// Two poll periods pass on beta's page.
await new Promise(r => setTimeout(r, 4500));
const reads = await b.evaluate('window.__reads');
assert.equal(reads.filter(u => u.includes('/orgs/beta/')).length, 0, reads.join(' '));
assert.equal(reads.length, seen, 'the poll stopped at the switch');
assert.equal(await b.evaluate('document.querySelector("#agents-receipt")?.textContent.trim() ?? ""'), '');
assert.equal(await b.evaluate('document.querySelector("#agents-section [data-agent-action=launch][data-agent-name=chief]")?.disabled'), false);
});
test('a save still in flight at a switch: its answer is not drawn on the next organization and its Save is not held', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// acme's settings POST is held until the test releases it, then answers with acme's saved values.
const init = `window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
if (options.method === 'POST' && s === '/api/orgs/acme/settings') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
x = { business: 'acme', revision: 'c'.repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: 5 }, instances: {}, usage: { total: 0, providers: {}, models: {} } }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
const total = '#launch-settings-form input[data-setting=\'["total"]\']';
const submit = '#launch-settings-form button[type=submit]';
await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4" && !document.querySelector(${JSON.stringify(submit)}).disabled`);
await b.evaluate(`(() => { const i = document.querySelector(${JSON.stringify(total)}); i.value = "5"; i.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector(${JSON.stringify(submit)}).click(); })()`);
await wait('typeof window.__release === "function"');
assert.equal(await b.evaluate('window.__posts[0]?.revision'), 'a'.repeat(64));
await b.evaluate('location.hash = "#/orgs/beta/settings/limits"');
await wait(`location.hash === "#/orgs/beta/settings/limits" && document.querySelector(${JSON.stringify(total)})?.value === "7"`);
// beta's Save is its own: not held by acme's write.
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
await b.evaluate('window.__release()');
await new Promise(r => setTimeout(r, 700));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value`), '7');
assert.equal(await b.evaluate('document.getElementById("launch-settings-receipt")?.textContent.trim() ?? ""'), '');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
});
test('a down banner does not repeat its heading as the reason', async t => {
// No boot record: the server's reason is its fallback, "the bus is not running", the heading's own words.
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', bootless: true });
await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
assert.match(down, /The bus is not running\./);
assert.equal((down.match(/the bus is not running/gi) || []).length, 1, down);
assert.doesNotMatch(down, /Reason:|No reason was read/);
});
test('a restart started on one organization finishes on that organization after a switch', { timeout: 60000 }, async t => {
const END = '11111111-2222-4333-8444-5555555555e1', LAUNCHED = '11111111-2222-4333-8444-5555555555a1';
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// Every agent request read and write is recorded, whichever organization it names; the bus answers
// are stubs (terminate, closed requests, one launch). Only acme's instances read is stubbed and
// recorded (the session gone): beta's own page reads beta's instances, which is not the chain.
const init = `window.__chain = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (options.method === 'POST' && /\\/agent\\/terminate$/.test(s)) { __chain.push(s); x = { request: '${END}' }; return ${json}; }
if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __chain.push(s); x = { request: '${LAUNCHED}' }; return ${json}; }
if (/\\/agent\\/request\\?/.test(s)) { __chain.push(s.split('?')[0]); const id = new URL(s, location.href).searchParams.get('id');
x = { request: id, instance: 'coder', action: id === '${END}' ? 'end' : 'launch', state: 'closed', done: { run: id === '${END}' ? null : 'run-9' }, cancelled: null }; return ${json}; }
if (s === '/api/orgs/acme/bus/instances' && window.__restarting) { __chain.push(s); x = { rows: [{ instances: { coder: { running: [] } } }] }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/organization', init });
await wait('window.MosaicAgents && document.querySelector("[data-org-dashboard] h1")');
// The button the Routing receipt offers (agents.js restartMarkup), placed on acme's page.
await b.evaluate('window.__restarting = true; document.querySelector("[data-org-dashboard]").insertAdjacentHTML("beforeend", window.MosaicAgents.restartMarkup("coder")); document.querySelector("[data-agent-restart=coder]").click()');
await wait('window.__chain.length >= 1');
await b.evaluate('location.hash = "#/orgs/beta/organization"');
await wait('location.hash === "#/orgs/beta/organization"');
// Done when the chain has launched and read the launch request back, or as soon as any of it names beta.
await wait('window.__chain.some(u => u.includes("/orgs/beta/")) || (window.__chain.some(u => /\\/agent\\/launch$/.test(u)) && window.__chain.filter(u => /\\/agent\\/request$/.test(u)).length >= 2)', 30000);
const chain = await b.evaluate('window.__chain');
assert.deepEqual([...new Set(chain)], ['/api/orgs/acme/agent/terminate', '/api/orgs/acme/agent/request', '/api/orgs/acme/bus/instances', '/api/orgs/acme/agent/launch'], chain.join(' '));
// beta's page draws nothing of acme's chain.
assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false);
});
test('a file save still in flight at a switch: its outcome is not announced on the next organization', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
// acme's file reads are stubbed; its save POST is held until the test releases it, then answers with a commit.
const init = `window.__posts = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true, edit: true }; return ${json}; }
if (s.startsWith('/api/orgs/acme/project-file?')) { x = { path: 'docs/a.md', commit: 'a'.repeat(40), content: '# A\\n', at: new Date().toISOString() }; return ${json}; }
if (options.method === 'POST' && s === '/api/orgs/acme/project-file') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
x = { path: 'docs/a.md', commit: 'c'.repeat(40) }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: `/#/orgs/acme/docs/site/${encodeURIComponent('docs/a.md')}`, init });
await wait('document.querySelector("#doc-form")');
if (!await b.evaluate('!!document.querySelector("#doc-text")')) await b.evaluate('document.querySelector("#doc-mode-source").click(), true');
await wait('document.querySelector("#doc-text")?.value === "# A\\n"');
await b.evaluate('(() => { const a = document.querySelector("#doc-text"); a.value = "# A\\n\\nmore\\n"; a.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#doc-form").requestSubmit(); })()');
await wait('typeof window.__release === "function"');
assert.equal(await b.evaluate('window.__posts[0]?.base'), 'a'.repeat(40));
await b.evaluate('location.hash = "#/orgs/beta/organization"');
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
await b.evaluate('window.__release()');
await new Promise(r => setTimeout(r, 700));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/organization');
assert.doesNotMatch(await b.evaluate('document.getElementById("announce")?.textContent ?? ""'), /Saved as commit|Draft kept/);
});
// SP2 item 1: an organization's own bare address is its page, not the system dashboard.
test('a bare #/orgs/<id> opens that organization\'s page', async t => {
const { b, wait } = await setup(t, { path: '/#/orgs/beta' });
await wait('location.hash === "#/orgs/beta/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Beta Works"');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
// In place too, from another organization's page.
await b.evaluate('location.hash = "#/orgs/acme"');
await wait('location.hash === "#/orgs/acme/organization" && document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
assert.equal(await b.evaluate('performance.getEntriesByType("navigation").length'), 1);
// The old per-organization dashboard address is still the system dashboard.
await b.evaluate('location.hash = "#/orgs/acme/dashboard"');
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
});
// SP2 item 2: the system dashboard shows the system theme, never the organization opened last.
test('the dashboard keeps the system theme after an organization with its own theme was opened', async t => {
const { b, base, wait } = await setup(t, { path: '/#/orgs/acme/organization' });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio" && document.documentElement.dataset.palette === "cobalt"');
await b.evaluate('location.hash = "#/dashboard"');
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await wait('document.documentElement.dataset.palette === "harbor"');
assert.equal(await b.evaluate('window.MosaicAppearance.state().organization'), null);
assert.equal(await b.evaluate('document.getElementById("org-cue").hidden'), true);
// A fresh load of the bare address, with acme stored as the last organization shown, starts on the system theme.
await b.navigate(`${base}/`);
await wait('location.hash === "#/dashboard" && document.querySelectorAll("[data-ws-orgrow]").length === 3');
await new Promise(r => setTimeout(r, 500));
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
// A choice made on the dashboard is the system's own: acme keeps its theme, and the dashboard keeps the choice.
await b.evaluate('window.MosaicAppearance.set({ mode: "light" })');
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'light');
await b.evaluate('location.hash = "#/orgs/acme/organization"');
await wait('document.documentElement.dataset.palette === "cobalt"');
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'dark');
await b.evaluate('location.hash = "#/dashboard"');
await wait('document.documentElement.dataset.palette === "harbor" && document.documentElement.dataset.mode === "light"');
});
// SP2 item 3: a stopped organization's page shows one bus banner, with the restart action and what can't be read.
test('a stopped organization\'s page shows one bus banner, with the restart action', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
const init = `const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (/^\\/api\\/(orgs\\/[^/]+\\/)?config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, orgSetup: true }; return ${json}; }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', init });
await wait('document.querySelector("[data-org-dashboard] [data-org-down] [data-bus-restart]")');
// Both shell painters (display.js on the list read, app.js on its own read) have had their turn.
await b.evaluate('window.MosaicOrg.list');
await new Promise(r => setTimeout(r, 800));
const banners = 'Array.from(document.querySelectorAll(".banner")).filter(e => !e.hidden && e.getClientRects().length && /bus/i.test(e.textContent)).map(e => e.innerText.replace(/\\s+/g, " ").trim())';
const shown = await b.evaluate(banners);
assert.equal(shown.length, 1, shown.join(' | '));
assert.match(shown[0], /The bus is not running\. Nothing about Gamma Home's agents or packs can be read until it starts\./);
assert.match(shown[0], /Restart the bus/);
assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true);
// SP7 item 7: one restart control on the page, the banner's; the header draws no second one.
const restarts = 'Array.from(document.querySelectorAll("[data-bus-restart], [data-bus-restart-off]")).filter(e => e.getClientRects().length).map(e => e.closest("[data-org-down]") ? "banner" : "header")';
assert.deepEqual(await b.evaluate(restarts), ['banner']);
assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] .ws-orghead [data-dashboard-bus]")'), null);
// The organization's other pages keep the shell's banner, with its restart.
await b.evaluate('location.hash = "#/orgs/gamma/agents"');
await wait('!document.getElementById("bus-down").hidden && /Bus unavailable/.test(document.getElementById("bus-down").textContent)');
// And back on the organization page it gives way again.
await b.evaluate('location.hash = "#/orgs/gamma/organization"');
await wait('document.querySelector("[data-org-dashboard] [data-org-down]") && document.getElementById("bus-down").hidden');
});
// SP2 item 4: the dashboard row for a stopped organization says it once.
test('a stopped organization\'s dashboard row does not repeat its state as the reason', async t => {
const { b, wait } = await setup(t, { bootless: true });
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await wait(`${rowText('gamma')}.includes("bus stopped")`);
const gamma = await b.evaluate(rowText('gamma'));
assert.doesNotMatch(gamma, /the bus is not running/i, gamma);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=gamma] .why")'), null);
});
// SP2 item 6: no status-badge glyph ("?") is drawn before the live dot in the dashboard's Active agents.
test('the dashboard\'s active agents draw no stray glyph before the status dot', async t => {
const { b, wait } = await setup(t);
await wait('document.querySelector("[data-ws-sessions] tbody tr")');
const cells = await b.evaluate('Array.from(document.querySelectorAll("[data-ws-sessions] tbody td")).map(td => getComputedStyle(td, "::before").content)');
assert.ok(cells.length >= 2, String(cells));
for (const c of cells) assert.ok(c === 'none' || c === 'normal', `generated content ${c}`);
});
// SP2 item 7 (the test SP1 lacks): a read still in flight when the organization switches keeps nothing.
// acme's settings read is held across a switch to beta and then answers with a figure only it has (99);
// beta's own read then fails, so beta's page draws from what was kept. Nothing of acme's may be there.
test('a read still in flight at a switch: its answer is not kept for the next organization', async t => {
const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
const init = `window.__acme = []; window.__beta = []; const original = window.fetch;
window.fetch = async (url, options = {}) => { const s = String(url); let x;
if (s === '/api/orgs/acme/settings' && !options.method && window.__holdAcme) { await new Promise(r => window.__acme.push(r));
const r = await original(url, options), body = await r.json(); x = { ...body, launch: { ...body.launch, limits: { total: 99 } } }; return ${json}; }
if (s === '/api/orgs/beta/settings' && !options.method && window.__holdBeta) { await new Promise(r => window.__beta.push(r));
return new Response(JSON.stringify({ error: 'unavailable', message: 'beta settings read failed' }), { status: 500, headers: { 'content-type': 'application/json' } }); }
return original(url, options); };`;
const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
const total = '#launch-settings-form input[data-setting=\'["total"]\']';
await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4"`);
// A fresh read of acme's settings starts and is held.
await b.evaluate('window.__holdAcme = true; location.hash = "#/orgs/acme/settings/tokens"');
await wait('window.__acme.length >= 1');
await b.evaluate('window.__holdBeta = true; location.hash = "#/orgs/beta/settings/limits"');
await wait('location.hash === "#/orgs/beta/settings/limits" && window.__beta.length >= 1');
// acme answers after the switch; then beta's read fails.
await b.evaluate('window.__acme.forEach(r => r())');
await new Promise(r => setTimeout(r, 400));
await b.evaluate('window.__beta.forEach(r => r())');
await wait('document.querySelector("#bus-view .banner")');
await new Promise(r => setTimeout(r, 400));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value ?? null`), null, 'acme\'s held answer was drawn on beta');
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .s1-stale")'), false);
assert.match(await b.evaluate('document.getElementById("bus-view").innerText'), /beta settings read failed/);
});
@@ -0,0 +1,278 @@
// SP5 (#1873, single pane slice 5): one Agents page for the whole system at #/agents. Every
// organization that answers adds its agents to one table: name, role, state, what it works on, its
// organization chip and a project chip when the work has a project. Filters by organization and state
// live in the hash query. Fire on a row goes to the row's own organization, never to the one opened
// last, and is offered only where that organization's own config says this Console decides. A stopped
// organization is named at the top with its reason. #/orgs/<id>/agents shows the same table, scoped.
// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace and
// the real per-organization routes serve the page.
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 waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
const HIRE = '0f3c1a2b-4d5e-4f60-8a7b-9c0d1e2f3a4b';
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 },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false },
];
const DATA = {
acme: {
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
// A built-in store task assigned to coder, filed under the Website project.
tasks: [{ task_ref: 'store:1', source: 'store', at: at(20), fields: { title: 'Hero copy', state: 'in-progress', group: 'p-site', assignees: ['coder'] } }],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40), messages: 2 } }],
instances: { coder: { definition: 'coder', running: ['run-1'] }, chief: { definition: 'chief-of-staff', running: [] } },
},
beta: {
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
instances: { pm: { definition: 'pm', running: ['run-9'] }, 'research-2': { definition: `org:hire-${HIRE}`, hireDecision: HIRE, running: [], pack: { id: 'coding', label: 'Coding' } } },
},
};
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') },
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme } },
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: () => opts(org.id),
});
return {
list: async () => LIST,
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
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 () => [],
instances: async () => ({ revision: 'a'.repeat(64), definitions: {}, instances: DATA[id].instances }),
},
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
},
};
}
// Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. Every
// organization's config says this Console decides there, except those named in `noDecide`; decision
// POSTs are recorded and answered from a queue, so no real write happens.
const stubFor = (noDecide = []) => `window.__noDecide = ${JSON.stringify(noDecide)};\n${STUB}`;
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__posts = []; window.__answers = [];
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
window.fetch = async (url, init = {}) => {
const u = String(url);
if (/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) {
const c = await (await real(url, init)).json();
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
return answer(200, { ...c, send: true, decide: !window.__noDecide.includes(org) });
}
if (!u.includes('/decisions/')) return real(url, init);
window.__posts.push({ url: u, method: init.method, body: init.body });
const a = window.__answers.shift();
if (!a) throw new TypeError('no answer queued');
return answer(a.status ?? 200, a.body);
};
})();`;
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);
if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor(noDecide) });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const rows = 'JSON.stringify([...document.querySelectorAll("[data-ws-agent]")].map(r => r.dataset.wsAgent))';
const are = list => `${rows} === ${JSON.stringify(JSON.stringify(list))}`;
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
const ALL = ['acme:chief', 'acme:coder', 'beta:pm', 'beta:research-2'];
test('#/agents lists both organizations\' agents in one table, each row with its organization chip', async t => {
const { b, wait, text } = await setup(t);
await wait(are(ALL));
// A system route: the address keeps no organization and the sidebar Agents link is this page.
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/agents');
assert.equal(await text('#bus-view h1'), 'Agents');
// SP7: the page name shows once, as the heading; a trail with nothing above it is not drawn.
assert.equal(await b.evaluate(trail), null);
assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-orgchip]'), 'Acme Studio');
assert.equal(await text('[data-ws-agent="beta:pm"] [data-ws-orgchip]'), 'Beta Works');
// State from the bus, and what the agent works on: its task, with the task's project chip.
assert.match(await text('[data-ws-agent="acme:coder"]'), /working/);
assert.match(await text('[data-ws-agent="beta:pm"]'), /waiting on you/);
assert.match(await text('[data-ws-agent="acme:coder"] [data-ws-work]'), /Hero copy/);
assert.equal(await text('[data-ws-agent="acme:coder"] [data-ws-projchip]'), 'Website');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-ws-projchip]\')'), null);
// A hired agent reads by its name and role; the definition id stays as secondary text.
const hired = await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] .t1\')?.firstChild?.textContent');
assert.equal(hired, 'research-2');
assert.match(await text('[data-ws-agent="beta:research-2"] [data-ws-role]'), /Hired.*Coding/);
assert.match(await text('[data-ws-agent="beta:research-2"] .t2'), new RegExp(`org:hire-${HIRE}`));
assert.match(await text('[data-ws-agent="acme:chief"] [data-ws-role]'), /Chief of staff/);
// Each row opens the agent in its own organization.
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));
await b.evaluate('document.querySelector("[data-ws-agents] [data-ws-org=beta]").click()');
await wait(`location.hash.startsWith("#/agents?") && new URLSearchParams(location.hash.split("?")[1]).get("org") === "beta" && ${are(['beta:pm', 'beta:research-2'])}`);
await b.navigate(`${base}/${await b.evaluate('location.hash')}`);
await wait(`${are(['beta:pm', 'beta:research-2'])} && document.querySelector("[data-ws-org=beta]")?.getAttribute("aria-pressed") === "true"`);
// State: one chip per state the rows carry.
await b.evaluate('document.querySelector("[data-ws-agents] [data-ws-org=\'\']").click()');
await wait(are(ALL));
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-astate]")].map(x => x.dataset.wsAstate)'), ['', 'working', 'waiting', 'not-running']);
await b.evaluate('document.querySelector("[data-ws-astate=not-running]").click()');
await wait(`new URLSearchParams(location.hash.split("?")[1]).get("state") === "not-running" && ${are(['acme:chief', 'beta:research-2'])}`);
await b.navigate(`${base}/${await b.evaluate('location.hash')}`);
await wait(`${are(['acme:chief', 'beta:research-2'])} && document.querySelector("[data-ws-astate=not-running]")?.getAttribute("aria-pressed") === "true"`);
// A value the page does not know is dropped, never read as a filter.
await b.evaluate('location.hash = "#/agents?state=bogus&org=Not%20An%20Org"');
await wait(are(ALL));
await b.evaluate('location.hash = "#/agents?org=beta&state=working"');
await wait('document.querySelector("[data-ws-agent-list]")?.textContent === "Nothing matches these filters"');
});
test('firing beta\'s agent from #/agents while acme was opened last reaches beta', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization', stub: true });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/agents"');
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="beta:pm"] [data-agent-fire]')`);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
// Never on the chief.
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="acme:chief"] [data-agent-fire]\')'), null);
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire]\').click()');
await wait('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\')');
await b.evaluate('window.__answers.push({ body: { id: "dec-fire-1", op: "resolved", choice: "yes" } })');
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-yes]\').click()');
await wait('/Fired pm/.test(document.querySelector("[data-ws-agents] [data-agents-receipt]")?.textContent ?? "")');
// The receipt names the organization by its label, not its id.
assert.match(await text('[data-ws-agents] [data-agents-receipt]'), /^Fired pm in Beta Works\./);
const posts = await b.evaluate('window.__posts');
assert.deepEqual(posts.map(p => p.url), ['/api/orgs/beta/decisions/hire/start']);
assert.deepEqual(JSON.parse(posts[0].body), { business: 'beta', hire: { op: 'fire', level: 'org', name: 'pm', reason: 'Fired from the Agents page.' } });
assert.match(await text('[data-ws-agents] [data-agents-receipt]'), /dec-fire-1/);
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/agents');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
});
test('Fire follows each organization\'s own config, not the organization opened last', async t => {
// Acme is opened last and this Console decides there; beta's own config says it does not.
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true, noDecide: ['beta'] });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/agents"');
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="acme:coder"] [data-agent-fire]')`);
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire]\')'), null);
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-agent-fire]\')'), null);
});
test('a stopped organization is named at the top with its reason', async t => {
const { b, wait, text } = await setup(t);
await wait(are(ALL));
const note = await text('[data-ws-unread]');
assert.match(note, /Gamma Home/);
assert.match(note, /stopped by the owner/);
assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector("[data-ws-agent-list]")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
});
test('with no agents anywhere the table says so and still names the stopped organization', async t => {
const { wait, text } = await setup(t, { empty: true });
await wait('document.querySelector("[data-ws-agent-list]")');
assert.equal(await text('[data-ws-agent-list]'), 'No agents');
assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s);
});
test('#/orgs/<id>/agents shows that organization\'s rows only, with a link to every organization', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/beta/agents' });
await wait(are(['beta:pm', 'beta:research-2']));
assert.match(await text('[data-ws-agents] .sub'), /Beta Works/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] a[href=\'#/agents\']") !== null'), true);
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents');
// SP7: a drill-in keeps its trail back to the dashboard and the organization.
await wait(`${trail} === 'Dashboard / Beta Works / Agents'`);
// The organization's own levels grid and held roles still follow, under the one page heading.
await wait('document.querySelector("[data-org-agents]")');
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view h1").length'), 1);
});
test('Open addresses the agent: the scoped page puts it first, marks it and draws no Open of its own', async t => {
const { b, wait } = await setup(t);
await wait(are(ALL));
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-ws-open]\').getAttribute("href")'), '#/orgs/beta/agents?agent=research-2');
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"] [data-ws-open]\').click()');
await wait(are(['beta:research-2', 'beta:pm']));
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/agents?agent=research-2');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:research-2"]\').getAttribute("aria-current")'), 'true');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"]\').hasAttribute("aria-current")'), false);
// No self-link on the scoped page.
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-agents] [data-ws-open]").length'), 0);
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-agents] thead th").length'), 4);
// An agent the organization does not have marks nothing.
await b.evaluate('location.hash = "#/orgs/beta/agents?agent=nobody"');
await wait(`${are(['beta:pm', 'beta:research-2'])} && !document.querySelector('[data-ws-agents] [aria-current]')`);
});
test('Cancel puts focus back on the row\'s Fire, which is labelled with the organization\'s name', async t => {
const { b, wait } = await setup(t, { stub: true });
await wait(`${are(ALL)} && document.querySelector('[data-ws-agent="beta:pm"] [data-agent-fire]')`);
const fire = '\'[data-ws-agent="beta:pm"] [data-agent-fire]\'';
assert.equal(await b.evaluate(`document.querySelector(${fire}).getAttribute('aria-label')`), 'Fire pm in Beta Works');
await b.evaluate(`document.querySelector(${fire}).click()`);
await wait('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-no]\')');
assert.equal(await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-confirm]\').getAttribute("aria-label")'), 'Fire pm in Beta Works?');
await b.evaluate('document.querySelector(\'[data-ws-agent="beta:pm"] [data-agent-fire-no]\').click()');
await wait(`document.activeElement === document.querySelector(${fire})`);
assert.deepEqual(await b.evaluate('window.__posts'), []);
});
test('a picked agent stays in the address through the state filter, and a scope that is not an organization id is refused', async t => {
const { b, wait } = await setup(t, { hash: '#/orgs/beta/agents?agent=research-2' });
await wait(`${are(['beta:research-2', 'beta:pm'])} && document.querySelector('[data-ws-agents] [data-ws-astate="waiting"]')`);
await b.evaluate('document.querySelector(\'[data-ws-agents] [data-ws-astate="waiting"]\').click()');
await wait(are(['beta:pm']));
const q = new URLSearchParams((await b.evaluate('location.hash')).split('?')[1]);
assert.equal(q.get('agent'), 'research-2');
assert.equal(q.get('state'), 'waiting');
assert.equal(await b.evaluate('document.querySelector("[data-ws-agents] [aria-current]")'), null);
await b.evaluate('document.querySelector(\'[data-ws-agents] [data-ws-astate=""]\').click()');
await wait(`${are(['beta:research-2', 'beta:pm'])} && document.querySelector('[data-ws-agent="beta:research-2"][aria-current="true"]')`);
const refused = await b.evaluate('window.MosaicWorkspace.agentsPage(new URLSearchParams(), { scope: "../beta" })');
assert.match(refused, /data-ws-failed/);
assert.doesNotMatch(refused, /data-ws-agent=/);
});
@@ -0,0 +1,265 @@
// SP3 (#1873, single pane slice 3): one Inbox for the whole system at #/inbox. Every organization
// that answers adds its items to one list, newest first, each row with its organization chip and a
// project chip when it has one. Filters by organization and type live in the hash query. An answer
// given from the list goes to the row's own organization, never to the one opened last. A stopped
// organization is named at the top with its reason. #/orgs/<id>/inbox is the same list, filtered.
// Every bus is a fake: acme and beta answer, gamma's bus is stopped. The real GET /api/workspace
// and the real per-organization routes serve the page.
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 waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
const ACME_DECISION = 'dec-acme-1';
const BETA_DECISION = 'dec-beta-1';
const DIGEST = 'd'.repeat(64);
function fixture({ empty = false } = {}) {
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false },
];
const DATA = {
acme: {
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false, bucket: 'Doing' } }],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
inbox: [{ id: ACME_DECISION, question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'local:1', at: at(10), options: [{ key: 'yes', text: 'Ship it' }, { key: 'no', text: 'Hold' }] }],
},
beta: {
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }],
// Eligible for an answer in the Console: the bus marks it with its `console` field.
inbox: [{ id: BETA_DECISION, authorization: { action: 'x.y', target: 'shop', approvalChoice: 'high' }, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], console: { kind: 'decision', options: [{ key: 'low', text: 'Low' }, { key: 'high', text: 'High' }], digest: DIGEST } }],
},
};
if (empty) { DATA.acme.inbox = []; DATA.beta.inbox = []; }
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') },
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme } },
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: () => opts(org.id),
});
return {
list: async () => LIST,
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
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 () => ({}) },
// The broker's inbox row: the decisions row, so `route_to`, plus `authorization`.
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', route_to: 'human', authorization: d.authorization ?? null })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
},
};
}
// Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL. Every
// organization's config says this Console answers decisions, except those named in `noDecide`;
// decision POSTs are recorded and answered from a queue, so no real write happens.
const stubFor = (noDecide = []) => `window.__noDecide = ${JSON.stringify(noDecide)};\n${STUB}`;
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__posts = []; window.__answers = [];
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
window.fetch = async (url, init = {}) => {
const u = String(url);
if (/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) {
const c = await (await real(url, init)).json();
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
return answer(200, { ...c, send: true, decide: !window.__noDecide.includes(org) });
}
if (!u.includes('/decisions/')) return real(url, init);
window.__posts.push({ url: u, method: init.method, body: init.body });
const a = window.__answers.shift();
if (!a) throw new TypeError('no answer queued');
return answer(a.status ?? 200, a.body);
};
})();`;
async function setup(t, { viewport = [1440, 900], hash = '#/inbox', empty = false, stub = false, noDecide = [] } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ empty }), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
if (stub) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor(noDecide) });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
const items = 'JSON.stringify([...document.querySelectorAll("[data-ws-item]")].map(r => r.dataset.wsItem))';
test('#/inbox lists both organizations in one list, newest first, each row with its organization and project chips', async t => {
const { b, wait, text } = await setup(t);
await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`, `acme:${ACME_DECISION}`]))}`);
// A system route: the address keeps no organization and the sidebar Inbox is this page.
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/inbox');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/inbox');
assert.equal(await text('#bus-view h1'), 'Inbox');
// SP7: the page name shows once, as the heading.
assert.equal(await b.evaluate(trail), null);
// Organization chips on every row; the project chip only where the item has a project.
assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-orgchip]`), 'Acme Studio');
assert.equal(await text(`[data-ws-item="beta:${BETA_DECISION}"] [data-ws-orgchip]`), 'Beta Works');
assert.equal(await text(`[data-ws-item="acme:${ACME_DECISION}"] [data-ws-projchip]`), 'Website');
assert.equal(await b.evaluate(`document.querySelector('[data-ws-item="beta:${BETA_DECISION}"] [data-ws-projchip]')`), null);
// SP7: the class chip says the decision class in words, from the one map of class words.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-ws-item]')].map(r => r.querySelector('td.hide-m .ws-chip')?.textContent)`), ['Within role', 'Gated']);
// The stopped organization is named at the top, before the list, with its reason.
const note = await text('[data-ws-unread]');
assert.match(note, /Gamma Home/);
assert.match(note, /stopped by the owner/);
assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector("[data-ws-items]")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
// The sidebar Inbox count and the dashboard read the same figure.
await wait('document.querySelector("#sections a[href=\'#/inbox\'] .ws-n")?.textContent === "2"');
await b.evaluate('location.hash = "#/dashboard"');
await wait('document.querySelector("[data-ws-tile=needs]")');
assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i);
const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => a.getAttribute("href"))');
assert.ok(needs.includes(`#/inbox?d=${encodeURIComponent(`beta:${BETA_DECISION}`)}`), JSON.stringify(needs));
assert.ok(await b.evaluate('[...document.querySelectorAll("[data-ws-home] a")].some(a => a.getAttribute("href") === "#/inbox")'));
});
test('the organization and type filters live in the hash and survive a reload', async t => {
const { b, base, wait } = await setup(t);
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
await wait(`location.hash.startsWith("#/inbox?") && new URLSearchParams(location.hash.split("?")[1]).get("org") === "beta" && ${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))}`);
const filtered = await b.evaluate('location.hash');
await b.navigate(`${base}/${filtered}`);
await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))} && document.querySelector("[data-ws-org=beta]")?.getAttribute("aria-pressed") === "true"`);
// Type: the classes the read carries, each a chip; choosing one narrows the list.
await b.evaluate('document.querySelector("[data-ws-org=\'\']").click()');
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-type]")].map(x => x.dataset.wsType)'), ['', 'gated', 'within-role']);
await b.evaluate('document.querySelector("[data-ws-type=gated]").click()');
await wait(`new URLSearchParams(location.hash.split("?")[1]).get("type") === "gated" && ${items} === ${JSON.stringify(JSON.stringify([`acme:${ACME_DECISION}`]))}`);
await b.navigate(`${base}/${await b.evaluate('location.hash')}`);
await wait(`${items} === ${JSON.stringify(JSON.stringify([`acme:${ACME_DECISION}`]))} && document.querySelector("[data-ws-type=gated]")?.getAttribute("aria-pressed") === "true"`);
// Both at once with nothing left says so plainly.
await b.evaluate('location.hash = "#/inbox?org=beta&type=gated"');
await wait('document.querySelector("[data-ws-items]")?.textContent === "Nothing matches these filters"');
});
test('answering beta\'s row from the list while acme was opened last reaches beta', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/acme/organization', stub: true });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/inbox"');
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`);
await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}" && document.querySelector("#decide-section [data-decide]")`);
assert.equal(await text('[data-ws-insp] header .ws-chip'), 'Within role');
// The item also opens in its own organization.
assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] [data-ws-open]")?.getAttribute("href")'), `#/orgs/beta/inbox/${BETA_DECISION}`);
await b.evaluate(`window.__answers.push({ body: { id: '${BETA_DECISION}', action: 'answer', choice: 'high', text: 'High', confirmation: 'c-1', kind: 'decision' } }, { body: { id: '${BETA_DECISION}', op: 'resolved', choice: 'high' } })`);
await b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].find(x => x.textContent === "High").click()');
await wait('document.querySelector("#decide-section [data-decide=confirm]")');
// The confirm step names who raised the item, from beta's bus row, as the decision page does.
const confirm = await text('#decide-section');
assert.match(confirm, /Raised by pm · run run/);
assert.match(confirm, /Decision routed to: human/);
assert.doesNotMatch(confirm, /date unknown/);
assert.match(confirm, /Affected object: shop/);
await b.evaluate('document.querySelector("#decide-section [data-decide=confirm]").click()');
await wait('/Recorded/.test(document.getElementById("decide-status")?.textContent ?? "")');
const posts = await b.evaluate('window.__posts');
assert.deepEqual(posts.map(p => p.url), [`/api/orgs/beta/decisions/${BETA_DECISION}/confirmation`, `/api/orgs/beta/decisions/${BETA_DECISION}/resolve`]);
assert.deepEqual(JSON.parse(posts[1].body), { choice: 'high', confirmation: 'c-1' });
// Still a system route, still scoped to acme for everything else.
assert.equal(await b.evaluate('location.hash.split("?")[0]'), '#/inbox');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
});
test('#/orgs/<id>/inbox is the same list filtered to that organization', async t => {
const { b, wait, text } = await setup(t, { hash: '#/orgs/beta/inbox' });
await wait(`${items} === ${JSON.stringify(JSON.stringify([`beta:${BETA_DECISION}`]))}`);
assert.match(await text('[data-ws-inbox] .sub'), /Beta Works/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-inbox] a[href=\'#/inbox\']") !== null'), true);
// SP7: a drill-in keeps its trail.
await wait(`${trail} === 'Dashboard / Beta Works / Inbox'`);
// Selecting a row keeps the organization address.
await b.evaluate(`document.querySelector('[data-ws-select="beta:${BETA_DECISION}"]').click()`);
await wait(`location.hash.startsWith("#/orgs/beta/inbox?") && document.querySelector("[data-ws-insp]")?.dataset.wsInsp === "beta:${BETA_DECISION}"`);
});
test('with nothing waiting the list says so and still names the stopped organization', async t => {
const { wait, text } = await setup(t, { empty: true });
await wait('document.querySelector("[data-ws-items]")');
assert.equal(await text('[data-ws-items]'), 'Nothing needs you');
assert.match(await text('[data-ws-unread]'), /Gamma Home.*stopped by the owner/s);
});
test('the hire, fire and change form is reached by clicks from #/inbox, one link per answering organization', async t => {
const { b, wait } = await setup(t, { stub: true });
await wait('document.querySelector("[data-ws-hire-in]")');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].map(a => [a.textContent, a.getAttribute("href")])'), [['Acme Studio', '#/orgs/acme/inbox'], ['Beta Works', '#/orgs/beta/inbox']]);
// The system list has no form of its own: a hire goes to one organization's bus.
assert.equal(await b.evaluate('document.getElementById("hire-start")'), null);
await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].find(a => a.textContent === "Beta Works").click()');
await wait('location.hash === "#/orgs/beta/inbox" && document.getElementById("hire-start")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'beta');
assert.equal(await b.evaluate('document.querySelector("#hire-start input[readonly]")?.value'), 'beta');
});
test('the hire links follow each organization\'s own config, not the organization opened last', async t => {
// Acme is opened last and this Console does not decide there; beta's config still says it does.
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization', stub: true, noDecide: ['acme'] });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/inbox"');
await wait('document.querySelector("[data-ws-hire-in]")');
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-hire-in] a")].map(a => a.getAttribute("href"))'), ['#/orgs/beta/inbox']);
});
test('with no organization deciding here, #/inbox shows no hire line', async t => {
const { b, wait } = await setup(t, { stub: true, noDecide: ['acme', 'beta'] });
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
assert.equal(await b.evaluate('document.querySelector("[data-ws-hire-in]")'), null);
});
test('the sidebar Inbox count on an organization\'s board is every organization\'s, as #/inbox lists', async t => {
const { b, wait } = await setup(t, { hash: '#/orgs/acme/board' });
await wait('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent === "2"');
// Held across a second board render, which reads only acme's inbox.
const reads = 'performance.getEntriesByType("resource").filter(e => new URL(e.name).pathname === "/api/orgs/acme/bus/inbox").length';
const before = await b.evaluate(reads);
assert.ok(before >= 1, String(before));
await b.evaluate('window.dispatchEvent(new HashChangeEvent("hashchange"))');
await wait(`${reads} > ${before}`);
await new Promise(r => setTimeout(r, 200));
assert.equal(await b.evaluate('document.querySelector("#sections a[href$=\'/inbox\'] .ws-n")?.textContent'), '2');
});
// SP2 gave the system dashboard the system theme. #/inbox is a system page too: it shows the system
// theme, and a choice made there is the system's own, never written to the organization opened last.
test('#/inbox keeps the system theme, and a choice made there stays off the organization opened last', async t => {
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' });
await wait('document.documentElement.dataset.palette === "cobalt"');
await b.evaluate('location.hash = "#/inbox"');
await wait(`${items} !== "[]" && document.documentElement.dataset.palette === "harbor"`);
assert.equal(await b.evaluate('window.MosaicAppearance.state().organization'), null);
assert.equal(await b.evaluate('document.getElementById("org-cue").hidden'), true);
await b.evaluate('window.MosaicAppearance.set({ mode: "light" })');
assert.equal(await b.evaluate('document.documentElement.dataset.mode'), 'light');
assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-appearance:acme")'), null);
// The organization's own Inbox is its page: its theme, and not the system's choice.
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
await wait('document.documentElement.dataset.palette === "cobalt"');
assert.notEqual(await b.evaluate('document.documentElement.dataset.mode'), 'light');
await b.evaluate('location.hash = "#/inbox"');
await wait('document.documentElement.dataset.palette === "harbor" && document.documentElement.dataset.mode === "light"');
});
@@ -0,0 +1,330 @@
// SP4 (#1873, single pane slice 4): one task board for the whole system at #/tasks. Every
// organization that answers adds its tasks to one board, in the per-organization page's columns,
// each card with its organization chip and its project chip. The organization and project filters
// live in the hash query. A move goes to the row's own organization, offered only where that
// organization's own config takes task changes, never judged by the organization opened last.
// A stopped organization is named at the top with its reason. #/orgs/<id>/tasks is the same board,
// scoped to one organization, and a card opened from the system board opens there.
// Every bus is a fake: acme and beta keep their tasks in the built-in store, gamma's bus is stopped.
// The real GET /api/workspace and the real POST /api/workspace/tasks serve the page.
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 waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
const D = n => String(n).repeat(64);
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 },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false },
];
const PROJECTS = {
acme: [{ id: 'site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
beta: [{ id: 'shop', repo: 'shop', name: 'Shop', archived: false, at: at(60) }, { id: 'ads', repo: 'ads', name: 'Ads', archived: false, at: at(90) }],
};
const STORE = {
acme: [{ n: 1, title: 'Hero copy', state: 'todo', group: 'site', assignees: ['coder'] }],
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(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 => ({
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => tasks(id), messages: async () => ({ messages: [], unread: 0 }) },
chat: { projects: async () => PROJECTS[id] || [], threads: async () => [] },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: { coder: {} } },
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: () => opts(org.id),
});
return {
writes,
list: async () => LIST,
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] }, taskWrite: taskWrite(id) };
return {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: PROJECTS[id].map(p => p.repo), appearance: { theme: org.theme } } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
...opts(id),
bus: { ...opts(id).bus, instances: async () => ({ business: id, instances: {} }) },
taskWrite: taskWrite(id),
};
},
};
}
// Runs before the page's scripts, so display.js wraps it and it sees each request's scoped URL.
// Every organization's config says this Console takes task changes, except those named in `noWrite`.
// The real server sets orgSetup for the whole Console, so this per-organization difference is faked:
// it proves the board asks each row's own organization, which matters where one config read fails.
const STUB = noWrite => `window.__noWrite = ${JSON.stringify(noWrite)};
(() => {
const real = window.fetch.bind(window);
window.fetch = async (url, init = {}) => {
const u = String(url);
if (!/^\\/api\\/(?:orgs\\/[^/]+\\/)?config$/.test(u)) return real(url, init);
const c = await (await real(url, init)).json();
const org = (u.match(/^\\/api\\/orgs\\/([^/]+)\\//) || [])[1];
return new Response(JSON.stringify({ ...c, orgSetup: c.orgSetup === true && !window.__noWrite.includes(org) }), { status: 200, headers: { 'content-type': 'application/json' } });
};
})();`;
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());
await b.viewport(...viewport);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB(noWrite) });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/${hash}`);
return { b, base, orgs, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
const trail = '(document.getElementById("breadcrumbs").getClientRects().length ? document.getElementById("breadcrumbs").innerText.replace(/\\s+/g, " ").trim() : null)';
const cards = 'JSON.stringify([...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask).sort())';
const ALL = JSON.stringify(JSON.stringify(['acme|task:1', 'beta|task:2', 'beta|task:3']));
const BETA = JSON.stringify(JSON.stringify(['beta|task:2', 'beta|task:3']));
test('#/tasks shows both organizations\' tasks on one board, each card with its organization and project chips', async t => {
const { b, wait, text } = await setup(t);
await wait(`${cards} === ${ALL}`);
// A system route: the address keeps no organization and the sidebar Tasks is this page.
assert.equal(await b.evaluate('location.hash'), '#/tasks');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
assert.equal(await text('#bus-view h1'), 'Tasks');
// SP7: the page name shows once, as the heading.
assert.equal(await b.evaluate(trail), null);
// The per-organization page's columns.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done']);
// One chip names the organization, with its avatar in place of the dot.
assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="acme|task:1"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Acme Studio');
assert.equal(await b.evaluate('[...document.querySelectorAll(\'[data-tb-task="beta|task:2"] [data-ws-orgchip]\')].map(c => [!!c.querySelector(".ws-av"), c.lastChild.textContent]).join("|")'), 'true,Beta Works');
assert.equal(await text('[data-tb-task="acme|task:1"] [data-ws-projchip]'), 'Website');
assert.equal(await text('[data-tb-task="beta|task:3"] [data-ws-projchip]'), 'Ads');
assert.equal(await b.evaluate('document.getElementById("bus-down").hidden'), true);
// The first load draws the board once, with no "last read failed" banner from a render the next one replaced.
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}`);
await b.evaluate('document.querySelector("[data-tb-org=beta]").click()');
await wait(`location.hash === "#/tasks?org=beta" && ${cards} === ${BETA}`);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "beta:shop"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait(`location.hash === "#/tasks?org=beta&project=beta%3Ashop" && ${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
await b.navigate(`${base}/?reload=1#/tasks?org=beta&project=beta%3Ashop`);
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=beta]").getAttribute("aria-pressed")'), 'true');
assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop');
// A hand-edited value that is not an organization id or a project is ignored, not read as a filter.
await b.navigate(`${base}/?reload=2#/tasks?org=Not%20An%20Id&project=nope`);
await wait(`${cards} === ${ALL}`);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed")'), 'true');
// An organization or project that is not on the list falls back to all, and the address drops it.
await b.navigate(`${base}/?reload=3#/tasks?org=gone&project=beta%3Agone`);
await wait(`${cards} === ${ALL} && location.hash === "#/tasks"`);
});
test('a move on beta\'s row while acme was opened last reaches beta, and acme\'s own config keeps its rows read only', async t => {
const { b, wait, orgs } = await setup(t, { hash: '#/orgs/acme/organization', noWrite: ['acme'] });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/tasks"');
await wait(`${cards} === ${ALL}`);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")');
// Acme's config takes no task changes, so its row has no State control and its card does not drag.
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=acme]")'), null);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/moved to Review in Beta Works/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes, [['beta', 'tasks.edit', { task_ref: 'task:2', expect: D(2), state: 'in-review' }]]);
assert.equal(await b.evaluate('window.MosaicOrg.id'), 'acme');
});
test('a stopped organization is named at the top of the board with its reason', async t => {
const { b, wait, text } = await setup(t);
await wait(`${cards} === ${ALL}`);
const note = await text('[data-ws-unread]');
assert.match(note, /Gamma Home/);
assert.match(note, /stopped by the owner/);
assert.equal(await b.evaluate('!!(document.querySelector("[data-ws-unread]").compareDocumentPosition(document.querySelector(".tb-toolbar")) & Node.DOCUMENT_POSITION_FOLLOWING)'), true);
});
test('with no tasks anywhere the board says so and still names the stopped organization', async t => {
const { b, wait, text } = await setup(t, { empty: true });
await wait('/No tasks yet/.test(document.querySelector("[data-tb-results]")?.textContent || "")');
assert.match(await text('[data-tb-results]'), /Gamma Home could not be read/);
assert.match(await text('[data-ws-unread]'), /Gamma Home/);
});
test('#/orgs/beta/tasks shows beta\'s tasks only, with a link to every organization', async t => {
const { b, base, wait } = await setup(t, { hash: '#/orgs/beta/tasks' });
await wait(`${cards} === ${BETA}`);
assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/tasks');
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.tbScope'), 'beta');
// SP7: a drill-in keeps its trail.
await wait(`${trail} === 'Dashboard / Beta Works / Tasks'`);
// No filter is on, so no "match the filters" line: the scope is not a filter.
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .tb-count")'), null);
assert.equal(await b.evaluate('document.querySelector("[data-tb-org]")'), null);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /Tasks from Beta Works only/);
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board] .sub a")?.getAttribute("href")'), '#/tasks');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]")?.getAttribute("href")'), '#/tasks');
// The sidebar Tasks link opens the system board.
await b.evaluate('document.querySelector("#sections a[href=\'#/tasks\']").click()');
await wait(`location.hash === "#/tasks" && ${cards} === ${ALL}`);
// An address that names no possible organization is not the system board.
await b.navigate(`${base}/?reload=1#/orgs/Not%20An%20Id/tasks`);
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
assert.equal(await b.evaluate(cards), '[]');
});
test('a card opened from the system board opens the task in its organization', async t => {
const { b, wait } = await setup(t, { hash: '#/orgs/acme/organization' });
await wait('document.querySelector("[data-org-dashboard] h1")?.textContent === "Acme Studio"');
await b.evaluate('location.hash = "#/tasks"');
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-open=\'beta|task:3\']").click()');
await wait('document.querySelector("#tb-dialog[open] [data-tb-goto]")');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").getAttribute("href")'), '#/orgs/beta/tasks?task=task%3A3');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").textContent'), 'Open in Beta Works');
await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]").click()');
await wait('location.hash.startsWith("#/orgs/beta/tasks") && window.MosaicOrg.id === "beta" && document.querySelector("[data-tasks-board]")?.dataset.tbScope === "beta"');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")?.dataset.tbKey === "beta|task:3"');
// The scoped page offers no second hop to the organization it is already in.
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-goto]")'), null);
// Closing the task takes it off the address, so a reload does not open it again.
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
await wait('location.hash === "#/orgs/beta/tasks" && !document.querySelector("#tb-dialog").open');
});
// Holds each task board read for ?hold= ms (or window.__hold, set later) and counts the reads that
// started and the ones that came back, so a test can act while a read is in flight. A task write is
// held for window.__holdW ms and counted the same way.
const HOLD = `window.__hold = Number(new URLSearchParams(location.search).get('hold')) || 0; window.__started = 0; window.__landed = 0;
window.__holdW = 0; window.__wStarted = 0; window.__wLanded = 0;
(() => {
const real = window.fetch.bind(window);
window.fetch = async (url, init = {}) => {
if (String(url) === '/api/workspace/tasks' && init.method === 'POST' && window.__holdW) {
window.__wStarted++;
await new Promise(r => setTimeout(r, window.__holdW));
try { return await real(url, init); } finally { window.__wLanded++; }
}
if (!/^\\/api\\/workspace\\?sections=tasks(?:%2C|,)projects/.test(String(url))) return real(url, init);
window.__started++;
if (window.__hold) await new Promise(r => setTimeout(r, window.__hold));
try { return await real(url, init); } finally { window.__landed++; }
};
})();`;
const settle = b => b.evaluate('new Promise(r => setTimeout(r, 200))');
test('a project filter stays in the address while its organization does not answer', async t => {
const { b, orgs, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop' });
const SHOP = JSON.stringify(JSON.stringify(['beta|task:2']));
await wait(`${cards} === ${SHOP}`);
const list = await orgs.list();
list[1].busHealthy = false;
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")');
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
list[1].busHealthy = true;
await b.evaluate('document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait(`${cards} === ${SHOP} && !/Beta Works/.test(document.querySelector("[data-tasks-board] [data-ws-unread]")?.textContent || "")`);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
assert.equal(await b.evaluate('document.querySelector("[data-tb-filter=project]").value'), 'beta:shop');
});
test('a gone filter is cleaned from the tasks address only, never from a page opened while it was read', async t => {
const { b, base, wait } = await setup(t);
await wait(`${cards} === ${ALL}`);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?hold=800#/tasks?org=gone`);
await wait('window.__started >= 1');
await b.evaluate('location.hash = "#/inbox?org=acme"');
await wait('window.__landed >= 1');
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/inbox?org=acme');
});
test('a read in flight does not paint the board over the page for an address no organization matches', async t => {
const { b, base, wait } = await setup(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?reload=1#/tasks`);
await wait(`${cards} === ${ALL}`);
await b.evaluate('window.__hold = 800; window.__started = 0; window.__landed = 0');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "todo"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__started >= 1');
await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"');
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
await wait('window.__landed >= window.__started');
await settle(b);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/);
assert.equal(await b.evaluate(cards), '[]');
});
test('a move still writing when the not-on-your-list page opens does not paint the board over it', async t => {
const { b, base, wait } = await setup(t);
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: HOLD });
await b.navigate(`${base}/?reload=1#/tasks`);
await wait(`${cards} === ${ALL}`);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'beta|task:2\']")');
await b.evaluate('window.__holdW = 800; window.__started = 0; window.__landed = 0');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'beta|task:2\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__wStarted >= 1');
await b.evaluate('location.hash = "#/orgs/Not%20An%20Id/tasks"');
await wait('/not on your list/.test(document.querySelector("[data-tasks-board]")?.textContent || "")');
await wait('window.__wLanded >= 1 && window.__landed >= window.__started');
await settle(b);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]")?.textContent || ""'), /not on your list/);
assert.equal(await b.evaluate(cards), '[]');
});
test('an archived project keeps its filter while it has tasks, and while another filter matches none of them', async t => {
const { b, wait } = await setup(t, { hash: '#/tasks?project=beta%3Ashop', archived: ['beta:shop'] });
await wait(`${cards} === ${JSON.stringify(JSON.stringify(['beta|task:2']))}`);
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
// No shop task is done: the read comes back empty because of the state filter, not because shop is gone.
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait(`${cards} === "[]" && /No tasks match/.test(document.querySelector("[data-tb-results]")?.textContent || "")`);
await settle(b);
assert.equal(await b.evaluate('location.hash'), '#/tasks?project=beta%3Ashop');
});
@@ -73,7 +73,7 @@ async function open(t, { refuse = null, tasksFail = [], send = true, width = 144
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(width, height);
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
const url = `http://127.0.0.1:${server.address().port}/#/tasks`;
await b.navigate(url);
const wait = waitFor(b);
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
@@ -68,7 +68,7 @@ async function open(t, fixture = orgs()) {
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close());
await b.viewport(1440, 900);
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
const url = `http://127.0.0.1:${server.address().port}/#/tasks`;
await b.navigate(url);
const wait = waitFor(b);
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=forge]")');
@@ -90,14 +90,14 @@ test('workspace navigation exposes all organizations and remembers project group
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
});
test('sidebar follows sample 01 and Queue opens from the organization dashboard, not the sidebar', async t => {
test('sidebar follows sample 01; the Queue is in neither the sidebar nor the organization dashboard, and #/queue still opens', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelector("[data-org-dashboard] [data-org-settings]") && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Side by side', 'Agents', 'Assistant']);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Agents', 'Assistant']);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections-foot a")].map(e => e.textContent.trim())'), ['Settings']);
assert.match(await b.evaluate('document.querySelector("[data-org-toggle]").textContent.replace(/\\s+/g, " ").trim()'), /^Organizations\s*3$/);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-workspace-org]")].map(li => [li.dataset.workspaceOrg, !!li.querySelector(".ws-av"), [...li.querySelector(".ws-dot").classList].find(c => c.startsWith("s-"))])'),
@@ -114,12 +114,13 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard,
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#console-nav a[href]")].filter(a => /\\/(queue|docs)$/.test(a.getAttribute("href"))).length'), 0);
// Settings stays pinned to the foot of the sidebar.
assert.equal(await b.evaluate('(() => { const nav = document.getElementById("console-nav").getBoundingClientRect(), foot = document.getElementById("sections-foot").getBoundingClientRect(); return foot.bottom >= nav.bottom - 24; })()'), true);
const queue = await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-queue]").getAttribute("href")');
assert.equal(queue, '#/queue');
await b.evaluate('document.querySelector("[data-org-queue]").click()');
// SP14 (#1873): the organization dashboard has no Queue card; the Queue page stays at its address.
assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] a[href=\'#/queue\'], [data-org-queue]")'), null);
await b.evaluate('location.hash = "#/orgs/ddigital/queue"');
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
// Every sidebar view is built: Inbox (U3, workspace-pages-browser.test.mjs), Tasks (U4b,
// tasks-board-browser.test.mjs) and Side by side (U5, side-by-side-browser.test.mjs).
// tasks-board-browser.test.mjs) and Assistant (SP6, assistant-threads-browser.test.mjs), which
// replaced Side by side.
});
test('organization dashboard renders every section from the per-organization reads', async t => {
@@ -135,9 +136,10 @@ test('organization dashboard renders every section from the per-organization rea
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
// Docs come from the project files reads: only documents, named by project.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
// KPIs and metrics: one honest empty tile each, with no invented number.
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [t.querySelectorAll(".ws-tile").length, t.querySelector(".ws-tile").classList.contains("is-empty"), t.innerText.replace(/\\s+/g, " ").trim()])');
assert.deepEqual(tiles, [[1, true, 'No KPIs defined in the organization file'], [1, true, 'No metrics recorded yet']]);
// SP8 (#1873): one row of figure tiles. This fixture gives no cross-organization read (no members),
// so each tile says it was not read and shows no number.
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [[...t.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile), t.querySelectorAll(".ws-big").length, t.innerText.match(/Not read: the read did not answer/g)?.length ?? 0])');
assert.deepEqual(tiles, [[['agents', 'tasks', 'needs', 'done'], 0, 4]]);
// Active agent sessions: the running instances, the thread their live run holds (chief's older
// Kickoff notes is not it), the model with its Change link, and the state the bus reports. With no
// reported state (montage-producer) the row says only running.
@@ -150,7 +152,7 @@ test('organization dashboard renders every section from the per-organization rea
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl a.ws-change")].map(a => [a.getAttribute("href"), a.getAttribute("aria-label")])'),
['chief', 'copywriter', 'montage-producer'].map(n => ['#/settings/routing', `Change the model for ${n}`]));
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl thead th")].map(th => th.textContent)'), ['Agent', 'Project and task', 'Model', 'Status']);
assert.match(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Queue\s+3 rows, 2 not done\s+Open queue/);
assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Open queue|\d+ rows?, \d+ not done/);
// Settings: one row per setting, each with an icon, a value, a source tag and, where a page exists, Open.
const settings = await b.evaluate(`[...document.querySelectorAll('[data-org-settings] > li')].map(li => ({ name: li.dataset.orgSetting, icon: !!li.querySelector('svg'), value: li.querySelector('.v').textContent, tag: li.querySelector('.src-tag').textContent, href: li.querySelector('a.go')?.getAttribute('href') ?? null, danger: li.classList.contains('danger') }))`);
assert.deepEqual(settings.map(s => s.name), SETTINGS);
@@ -176,7 +178,7 @@ test('organization dashboard empty states say so in one short line', async t =>
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
for (const line of [/Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
for (const line of [/Docs\s+No docs yet/i, /Agents working\s+Not read/, /Done this week\s+Not read/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Tracker: not set/])
assert.match(text, line);
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
});
@@ -188,9 +190,9 @@ test('organization dashboard presents sections and honest unavailable controls',
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-dashboard]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('dashboard missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
for(const label of ['Docs','KPIs','Metrics','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i'));
for(const label of ['Docs','Agents working','Open tasks','Needs you','Done this week','Active agent sessions','Settings']) assert.match(text,new RegExp(label,'i'));
assert.match(text,/reload organization file/i);
assert.match(text,/no metrics recorded yet/i);
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'),0);
assert.match(text,/bus unavailable, read \d/);
assert.match(text,/Sessions unavailable/);
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
@@ -209,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 });
@@ -299,11 +302,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', '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);
@@ -315,7 +319,7 @@ test('keyboard focus follows visual order and survives a data refresh', async t
await b.viewport(1440, 900);
// A refresh repaints the dashboard, the sidebar and the restart button; focus stays on the same control.
for (const key of ['set:Projects', 'queue-open', 'doc:spring-launch:docs/launch-plan.md']) {
for (const key of ['set:Projects', 'agents-all', 'doc:spring-launch:docs/launch-plan.md']) {
await wait(`document.querySelector('[data-focus-key="${key}"]')`);
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-focus-key="${key}"]').focus(); document.getElementById('refresh').click()`);
await wait('document.querySelector("[data-org-dashboard]") && document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
@@ -370,7 +374,7 @@ test('an unavailable selected bus does not hide other organizations on Agents',
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/beta/agents`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-agents]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('organization agent grid missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-agents]").innerText');
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/); assert.match(text,/Agents unavailable/);
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/); assert.match(text,/Agents not read: the bus is not answering/); assert.doesNotMatch(text,/Agents unavailable/);
});
test('a failed conversation read says unavailable, never that none exists', async t => {
@@ -484,7 +488,8 @@ test('agents grid shows each agent\'s level and where it is defined, per project
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /social-scheduler \(Spring launch only\), copywriter \(excluded in this project\) and every Forge or Home agent/);
// Home, where the reviewer is turned off: the table says so.
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/home/agents`);
await waitFor(b)('document.querySelector("[data-org-agents] h2")?.innerText === "Agents in Home"');
// #1873 SP5: the scoped page puts the levels grid under its own "Agents by level" h2; this is the section's title.
await waitFor(b)('document.querySelector("[data-org-agents] .ws-split-agents h2")?.innerText === "Agents in Home"');
assert.match(await b.evaluate('document.querySelector("[data-agents-available]").innerText'), /reviewer \(turned off for Home\)/);
});
+1 -1
View File
@@ -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'}});
};
})();` });
@@ -0,0 +1,669 @@
// Single pane SP9 (#1873): an organization's workflows, read only. #/orgs/<id>/workflows lists them,
// #/orgs/<id>/workflows/<name> 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/<id>/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.
// SP10 (#1873): the graph page edits the workflow. The change is held in the page until Save, which sends one
// POST with the revision the page read; a refusal is shown in words and the change kept; the poll never draws
// over a pending change. The fixture's write stub keeps the organization file and its revision, as the bus does.
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 = '<img src=x onerror=window.__sp9pwn=1>';
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 },
];
// The organization files as the bus holds them: each organization's document and revision, the writes the
// page sent, and a refusal to give the next write. A write with another revision is a revision-conflict.
const hex = n => n.toString(16).padStart(64, '0');
function store(writable) {
const st = { writable, docs: structuredClone(DOCS), revs: { acme: 'a'.repeat(64), bare: 'b'.repeat(64), gamma: 'c'.repeat(64) }, writes: [], refuse: null, n: 0 };
const fail = code => Object.assign(new Error(code), { code });
st.write = (id, verb, args) => {
st.writes.push({ id, verb, args: structuredClone(args) });
if (verb !== 'workflow.set.console') throw fail('not-configured');
if (st.refuse) { const code = st.refuse; st.refuse = null; throw fail(code); }
if (args.revision !== st.revs[id]) throw fail('revision-conflict');
const doc = st.docs[id], scope = args.project ? doc.projects[args.project] : doc;
scope.workflows ??= {};
if (args.workflow === null) delete scope.workflows[args.name]; else scope.workflows[args.name] = structuredClone(args.workflow);
st.revs[id] = hex(++st.n);
return { revision: st.revs[id], added: [] };
};
// Another writer changes the file outside the page.
st.change = (id, fn) => { fn(st.docs[id]); st.revs[id] = hex(1000 + ++st.n); };
return st;
}
function organizations(reads, st) {
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); if (st.failRead) throw Object.assign(new Error('org-unavailable'), { code: 'org-unavailable' }); return { revision: st.revs[id], document: structuredClone(st.docs[id] ?? {}), templates: ['writer'] }; };
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, workflowsRead, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true, workflowsRead, packsWrite: st.writable ? async (verb, args) => st.write(id, verb, args) : null, 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, writable = true } = {}) {
const reads = [], st = store(writable);
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: organizations(reads, st) });
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, st, 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']);
// 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]);
// sp9 follow-up: the missing role's whole name shows at 1440, on its own line under "No current role:".
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-step="4"] [data-wfg-missing] tspan')].map(t=>t.textContent)`), ['No current role:', 'merge-gate']);
// 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 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`);
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('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: its drawn extent, not what the clip path lets show, ends left
// of the clip edge (8px inside the box) and above the box bottom.
const out = await b.evaluate(`${boxes}.flatMap(g=>{const r=g.querySelector('rect'),right=r.x.baseVal.value+r.width.baseVal.value-8,bottom=r.y.baseVal.value+r.height.baseVal.value;
return [...g.querySelectorAll('text')].flatMap(t=>{const spans=[...t.querySelectorAll('tspan')];return (spans.length?spans:[t]).filter(e=>e.getNumberOfChars()>0).filter(e=>{let n=e.getNumberOfChars();while(n>1&&/\\s/.test(e.textContent[n-1]))n--;const z=e.getExtentOfChar(n-1),a=e.getExtentOfChar(0);return z.x+z.width>right+0.5||z.y+z.height>bottom+0.5||a.x<r.x.baseVal.value}).map(e=>e.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');
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<innerHeight})()`, 'packs in view');
assert.equal(await b.evaluate(`location.hash`), '#/orgs/bare/organization');
});
test('the organization page card lists each workflow as a link to its graph', { timeout: 60000 }, async t => {
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');
});
}
});
// ---------- SP10: editing a workflow in its graph ----------
const REV_A = 'a'.repeat(64);
const titles = `${boxes}.map(g=>g.querySelector('[data-wfg-title]').textContent)`;
const press = sel => `(()=>{const e=document.querySelector(${JSON.stringify(sel)});if(!e)throw Error('no '+${JSON.stringify(sel)});if(e.disabled)throw Error('disabled '+${JSON.stringify(sel)});e.click();return true})()`;
const pick = step => `document.querySelector('[data-wfg-step="${step}"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}))`;
// Sets a form field as typing would: the value, then the input event the page listens for.
const fill = (sel, value) => `(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`;
const role = (form, name) => `${form} input[name="role"][value="${name}"]`;
const text = sel => `(document.querySelector(${JSON.stringify(sel)})?.textContent ?? '').replace(/\\s+/g,' ').trim()`;
test('editing a step changes the graph on the page, and Save sends one POST with the revision the page read', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=4' });
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve the merge' && document.querySelector('[data-wfg-edit]')`, 'step 4 with Edit');
// Nothing pending: no Unsaved, no Save.
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-unsaved],[data-wfg-save],[data-wfg-discard]')`), null);
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form] [data-wfg-form-title]')?.textContent === 'Edit step 4'`, 'step form');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-f-title');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]').value`), 'Approve the merge');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] select[name="state"]').value`), 'done');
// The picker offers human, the organization's roles and the templates; the step's unknown role is first, checked and marked.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-form] input[name="role"]')].map(i=>[i.value,i.checked])`), [['merge-gate', true], ['human', true], ['coder', false], ['pm', false], ['reviewer', false], ['writer', false]]);
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-form] [data-wfg-pick-missing]')`));
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Approve and merge'));
await b.evaluate(press(role('[data-wfg-form]', 'merge-gate')));
await b.evaluate(press(role('[data-wfg-form]', 'reviewer')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[3] === 'Approve and merge' && document.querySelector('[data-wfg-unsaved]')`, 'pending change drawn');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-step="4"] [data-wfg-roles] tspan')].map(t=>t.textContent)`), ['human', 'reviewer']);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="4"] [data-wfg-missing]')`), null);
assert.equal(await b.evaluate(text('[data-wfg-unsaved]')), 'Unsaved');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-save');
assert.equal(st.writes.length, 0, 'Apply writes nothing');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')}) && !document.querySelector('[data-wfg-unsaved]')`, 'saved');
assert.equal(st.writes.length, 1);
assert.deepEqual(st.writes[0], { id: 'acme', verb: 'workflow.set.console', args: { revision: REV_A, name: 'change', workflow: {
description: 'One change from task to merge.',
steps: [
{ title: 'Write the task', state: 'todo', roles: ['pm'] },
{ title: 'Implement the change', state: 'in-progress', roles: ['coder'] },
{ title: 'Review the change', state: 'in-review', roles: ['reviewer'] },
{ title: 'Approve and merge', state: 'done', roles: ['human', 'reviewer'] },
] } } });
// The page now draws the file as saved, read again.
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve and merge'`, 'detail after save');
assert.deepEqual(st.docs.acme.workflows.change.steps[3], { title: 'Approve and merge', state: 'done', roles: ['human', 'reviewer'] });
});
test('add, move, remove and discard change only the page until Save', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=2' });
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'step 2');
// Add after the selected step.
await b.evaluate(press('[data-wfg-add]'));
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'New step 3'`, 'add form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Test the change'));
await b.evaluate(`(()=>{const e=document.querySelector('[data-wfg-form] select[name="state"]');e.value='in-review';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
// Apply with no role is refused in words, and the form stays.
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${text('[data-wfg-why]')} === 'Step 3 needs at least one role.'`, 'no role refused');
await b.evaluate(press(role('[data-wfg-form]', 'coder')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${boxes}.length === 5 && ${titles}[2] === 'Test the change'`, 'step added');
assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=3');
assert.equal(await b.evaluate(text('[data-wfg-field="state"]')), 'Review');
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-unsaved]')`));
// Move earlier and later.
await b.evaluate(press('[data-wfg-up]'));
await wait(`${titles}[1] === 'Test the change' && /step=2/.test(location.hash)`, 'moved earlier');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-up');
await b.evaluate(press('[data-wfg-down]'));
await wait(`${titles}[2] === 'Test the change' && /step=3/.test(location.hash)`, 'moved later');
// Selecting another step goes through a full render (a read); the pending change stays.
await b.evaluate(pick(1));
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Write the task' && ${boxes}.length === 5`, 'step 1, change kept');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-up]').disabled`), true, 'the first step cannot move earlier');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 4`, 'step removed');
assert.deepEqual(await b.evaluate(titles), ['Implement the change', 'Test the change', 'Review the change', 'Approve the merge']);
// With no step selected, Add puts a step at the end.
await b.evaluate(`location.hash = '#/orgs/acme/workflows/change'`);
await wait(`${text('[data-wfg-add]')} === 'Add a step at the end'`, 'add at end');
await b.evaluate(press('[data-wfg-add]'));
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'New step 5'`, 'add-at-end form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Announce it'));
await b.evaluate(press(role('[data-wfg-form]', 'pm')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${boxes}.length === 5 && ${titles}[4] === 'Announce it' && /step=5/.test(location.hash)`, 'added at end');
// Discard drops all of it; nothing was written.
await b.evaluate(press('[data-wfg-discard]'));
await wait(`${boxes}.length === 4 && !document.querySelector('[data-wfg-unsaved]')`, 'discarded');
assert.deepEqual(await b.evaluate(titles), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']);
assert.equal(st.writes.length, 0);
});
test('a refused save says why next to Save and keeps the pending change', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?step=1' });
await wait(`document.querySelector('[data-wfg-edit]')`, 'step 1');
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Write the brief'));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[0] === 'Write the brief'`, 'pending');
// Step 4 still names merge-gate: the page refuses before sending, in the same words as the form editor.
await b.evaluate(press('[data-wfg-save]'));
await wait(`${text('[data-wfg-status]')} === 'Step 4: merge-gate names no current role. Use an organization role, a role template or human.'`, 'unknown role refused');
assert.equal(st.writes.length, 0);
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]')`));
// Fix step 4, then the bus is down.
await b.evaluate(pick(4));
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'step 4');
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'Edit step 4'`, 'form 4');
await b.evaluate(press(role('[data-wfg-form]', 'merge-gate')));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`!document.querySelector('[data-wfg-step="4"] [data-wfg-missing]')`, 'step 4 fixed');
st.refuse = 'no-bus-host';
await b.evaluate(press('[data-wfg-save]'));
await wait(`/bus is not running/.test(${text('[data-wfg-status]')})`, 'bus down refused');
assert.match(await b.evaluate(text('[data-wfg-status]')), /Your change is kept/);
assert.equal(st.writes.length, 1);
assert.equal(st.writes[0].args.revision, REV_A);
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]') && !!document.querySelector('[data-wfg-save]')`));
assert.equal(st.docs.acme.workflows.change.steps[0].title, 'Write the task', 'nothing written');
// The status sits in the bar with Save.
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-bar] [data-wfg-status]') && !!document.querySelector('[data-wfg-bar] [data-wfg-save]')`));
// Another writer changes this workflow: the save is refused as stale, the change kept.
st.change('acme', doc => { doc.workflows.change.description = 'Changed elsewhere.'; });
await b.evaluate(press('[data-wfg-save]'));
await wait(`/changed since this page read it/.test(${text('[data-wfg-status]')})`, 'stale refused');
assert.match(await b.evaluate(text('[data-wfg-status]')), /Your change is kept\. Discard to load the current version\./);
assert.ok(await b.evaluate(`${titles}[0] === 'Write the brief' && !!document.querySelector('[data-wfg-unsaved]')`));
assert.equal(st.writes.length, 2);
});
test('the poll does not draw over a pending change, takes a new revision when only other workflows changed, and says when this one changed', { timeout: 60000 }, async t => {
const { b, wait, st, reads } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=2' });
// The site project's workflow names only current roles, so a save goes to the bus.
await wait(`document.querySelector('[data-wfg-edit]')`, 'step 2');
const repaint = async () => { const n = reads.length; await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`); await wait(`true`); for (let i = 0; i < 100 && reads.length === n; i++) await new Promise(r => setTimeout(r, 30)); await new Promise(r => setTimeout(r, 150)); };
// A form being typed in survives a repaint as typed.
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Ship the release'));
await repaint();
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]')?.value`), 'Ship the release');
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[1] === 'Ship the release' && document.querySelector('[data-wfg-unsaved]')`, 'pending');
await repaint();
assert.equal(await b.evaluate(`${titles}[1]`), 'Ship the release', 'the poll keeps the pending change');
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-unsaved]')`));
// Another workflow changes in the file: this one is as read, so the page takes the new revision and no warning shows.
st.change('acme', doc => { doc.workflows.intake.title = 'Intake, renamed'; });
const fresh = st.revs.acme;
await repaint();
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-stale]')`), null);
assert.equal(await b.evaluate(`${titles}[1]`), 'Ship the release');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved on the new revision');
assert.equal(st.writes.at(-1).args.revision, fresh);
assert.equal(st.docs.acme.workflows.intake.title, 'Intake, renamed', 'the other workflow is untouched');
// Now this workflow changes underneath a pending change: the page says so and offers Discard.
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form again');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Build it'));
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[1] === 'Build it'`, 'pending again');
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed elsewhere.'; });
await repaint();
await wait(`!!document.querySelector('[data-wfg-stale]')`, 'stale note');
assert.match(await b.evaluate(text('[data-wfg-stale]')), /changed this workflow since the page read it.*Discard to load the current version/);
assert.equal(await b.evaluate(`${titles}[1]`), 'Build it');
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-discard]')`));
await b.evaluate(press('[data-wfg-discard]'));
await wait(`${titles}[1] === 'Ship the release' && !document.querySelector('[data-wfg-stale],[data-wfg-unsaved]')`, 'discarded to current');
assert.match(await b.evaluate(`document.querySelector('#bus-view .sub').textContent`), /Changed elsewhere\./);
});
test('a reader who cannot write sees no edit controls and one line saying who can', { timeout: 60000 }, async t => {
const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows/change?step=2', writable: false });
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'step 2');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-bar],[data-wfg-actions],[data-wfg-edit],[data-wfg-add],[data-wfg-save],[data-wfg-delete],[data-wfg-form]')`), null);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('#bus-view [data-wfg-readonly]')].map(n=>n.textContent)`), ['Only the owner, on a Console started with send, can change workflows.']);
assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view button').length`), 0);
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
await wait(`document.querySelector('[data-wfg-list] [data-wfg-item]')`, 'list');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-new]')`), null);
assert.ok(await b.evaluate(`!!document.querySelector('[data-wfg-list] [data-wfg-readonly]')`));
});
test('a new workflow starts on the list page, opens in its graph unsaved, saves, and deletes after a confirm', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows' });
await wait(`document.querySelector('[data-wfg-new]')`, 'New workflow');
await b.evaluate(press('[data-wfg-new]'));
await wait(`document.querySelector('[data-wfg-newform]')`, 'new form');
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-n-name');
const create = async () => b.evaluate(press('[data-wfg-create]'));
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'Release Plan'));
await create();
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'A workflow name is 1 to 64 lowercase letters, digits or dashes, starting with a letter.'`, 'name refused');
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'change'));
await create();
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'A workflow named change already exists here. Edit it instead.'`, 'taken refused');
await b.evaluate(fill('[data-wfg-newform] input[name="name"]', 'release'));
await b.evaluate(fill('[data-wfg-newform] input[name="title"]', 'Cut the release'));
await create();
await wait(`${text('[data-wfg-newform] [data-wfg-why]')} === 'Step 1 needs at least one role.'`, 'role refused');
await b.evaluate(press(role('[data-wfg-newform]', 'human')));
await create();
await wait(`location.hash === '#/orgs/acme/workflows/release?step=1' && ${boxes}.length === 1`, 'opens in the graph');
assert.equal(await b.evaluate(text('[data-wfg-unsaved]')), 'Unsaved, not in the file yet');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-delete]')`), null, 'nothing to delete yet');
assert.equal(st.writes.length, 0);
// The list shows it, marked.
await b.evaluate(`location.hash = '#/orgs/acme/workflows'`);
await wait(`document.querySelector('[data-wfg-item="release"] [data-wfg-unsaved]')`, 'listed unsaved');
await b.evaluate(press('[data-wfg-item="release"] a[data-wfg-open]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-save]')`, 'back in the graph');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved release\\./.test(${text('[data-wfg-status]')}) && document.querySelector('[data-wfg-delete]')`, 'saved');
assert.deepEqual(st.writes[0].args, { revision: REV_A, name: 'release', workflow: { steps: [{ title: 'Cut the release', state: 'todo', roles: ['human'] }] } });
// Delete asks first; Keep keeps it.
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-confirm]')`, 'confirm');
assert.match(await b.evaluate(text('[data-wfg-confirm]')), /Delete release from the organization file\?/);
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey`), 'wfg-delete-no');
await b.evaluate(press('[data-wfg-delete-no]'));
await wait(`!document.querySelector('[data-wfg-confirm]') && document.activeElement?.dataset.focusKey === 'wfg-delete'`, 'kept');
assert.equal(st.writes.length, 1);
const saved = st.revs.acme;
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm again');
await b.evaluate(press('[data-wfg-delete-yes]'));
await wait(`location.hash === '#/orgs/acme/workflows' && /Deleted release\\./.test(${text('[data-wfg-list] [data-wfg-status]')}) && !document.querySelector('[data-wfg-item="release"]')`, 'deleted');
assert.deepEqual(st.writes[1].args, { revision: saved, name: 'release', workflow: null });
assert.equal(Object.hasOwn(st.docs.acme.workflows, 'release'), false);
});
test('the keyboard alone opens a step, edits it and saves', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site' });
await wait(`${boxes}.length === 2`, 'graph');
await b.evaluate(`document.querySelector('[data-wfg-step="1"]').focus();true`);
await b.key('Enter');
await wait(`document.querySelector('[data-wfg-detail-title]')?.textContent === 'Build' && document.activeElement?.dataset.focusKey === 'wfg-step:1'`, 'Enter opens step 1');
const tabTo = async (sel, what) => {
for (let i = 0; i < 20; i++) { if (await b.evaluate(`document.activeElement?.matches(${JSON.stringify(sel)}) ?? false`)) return; await b.key('Tab'); }
throw new Error(`Tab never reached ${what}`);
};
await tabTo('[data-wfg-edit]', 'Edit step');
await b.key('Enter');
await wait(`document.querySelector('[data-wfg-form-title]')?.textContent === 'Edit step 1' && document.activeElement?.dataset.focusKey === 'wfg-f-title'`, 'form with focus on the title');
await b.call('Input.insertText', { text: ' now' });
await tabTo(`[data-wfg-form] input[name="role"][value="reviewer"]`, 'the reviewer box');
await b.key(' ', 'Space');
await tabTo('[data-wfg-apply]', 'Apply');
await b.key('Enter');
await wait(`${titles}[0] === 'Build now' && document.activeElement?.dataset.focusKey === 'wfg-save'`, 'applied, focus on Save');
await b.key('Enter');
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved');
assert.deepEqual(st.writes[0].args, { revision: REV_A, name: 'change', project: 'site', workflow: { title: 'Site release', steps: [
{ title: 'Build now', state: 'in-progress', roles: ['coder', 'reviewer'] }, { title: 'Ship', state: 'done', roles: ['human'] }] } });
});
test('switching organization with a pending workflow change asks first, through the existing draft guard', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
await wait(`document.querySelector('[data-wfg-edit]') && document.querySelector('#breadcrumbs [data-crumb-org]')`, 'step 1');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), true);
await b.evaluate(`location.hash = '#/orgs/bare/workflows'`);
await wait(`document.querySelector('dialog[aria-label="Unsaved draft"][open]')`, 'guard dialog');
await b.evaluate(press('dialog [data-stay]'));
await wait(`!document.querySelector('dialog[open]') && /#\\/orgs\\/acme\\/workflows\\/change/.test(location.hash) && ${boxes}.length === 1`, 'stayed with the change');
await b.evaluate(`location.hash = '#/orgs/bare/workflows'`);
await wait(`document.querySelector('dialog[aria-label="Unsaved draft"][open]')`, 'guard dialog again');
await b.evaluate(press('dialog [data-discard]'));
await wait(`location.hash === '#/orgs/bare/workflows' && document.querySelector('[data-wfg-list][data-wfg-org="bare"]')`, 'switched');
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), false);
assert.equal(st.writes.length, 0);
});
test('Save with a step form holding edits not applied refuses in words and sends nothing; after Apply it sends them', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=2' });
await wait(`document.querySelector('[data-wfg-up]')`, 'step 2');
// One change pending, so Save shows; then a step form is opened and typed in, not applied.
await b.evaluate(press('[data-wfg-up]'));
await wait(`${titles}[0] === 'Ship' && document.querySelector('[data-wfg-save]')`, 'moved');
await b.evaluate(press('[data-wfg-edit]'));
await wait(`document.querySelector('[data-wfg-form]')`, 'form');
await b.evaluate(fill('[data-wfg-form] input[name="title"]', 'Ship the release'));
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Apply or cancel the step first/.test(${text('[data-wfg-status]')})`, 'save refused');
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not saved: step 1 has edits not applied. Apply or cancel the step first.');
assert.equal(st.writes.length, 0);
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-form] input[name="title"]')?.value`), 'Ship the release', 'the typed title stays');
await b.evaluate(press('[data-wfg-apply]'));
await wait(`${titles}[0] === 'Ship the release'`, 'applied');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-status]')`), null, 'Apply clears the refusal');
await b.evaluate(press('[data-wfg-save]'));
await wait(`/Saved change\\./.test(${text('[data-wfg-status]')})`, 'saved');
assert.equal(st.writes.length, 1);
assert.deepEqual(st.writes[0].args.workflow.steps.map(s => s.title), ['Ship the release', 'Build']);
});
test('Delete on a view another writer has changed is refused, not deleted over', { timeout: 60000 }, async t => {
const { b, wait, st, reads } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
await wait(`document.querySelector('[data-wfg-remove]')`, 'step 1');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed elsewhere.'; });
const n = reads.length;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`!!document.querySelector('[data-wfg-stale]')`, 'stale note');
assert.ok(reads.length > n);
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm');
await b.evaluate(press('[data-wfg-delete-yes]'));
await wait(`/^Not deleted/.test(${text('[data-wfg-status]')})`, 'delete refused');
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not deleted: the organization file changed this workflow since the page read it. Discard to load the current version.');
assert.equal(st.writes.length, 1);
assert.equal(st.writes[0].args.revision, REV_A);
assert.equal(st.writes[0].args.workflow, null);
assert.equal(st.docs.acme.projects.site.workflows.change.description, 'Changed elsewhere.', 'the newer workflow is still in the file');
assert.ok(await b.evaluate(`${boxes}.length === 1 && !!document.querySelector('[data-wfg-unsaved]')`), 'the pending change stays');
// With no pending change there is no Discard, so the refusal does not point at one.
await b.evaluate(press('[data-wfg-discard]'));
await wait(`${boxes}.length === 2 && !document.querySelector('[data-wfg-unsaved]')`, 'discarded');
await b.evaluate(press('[data-wfg-delete]'));
await wait(`document.querySelector('[data-wfg-delete-yes]')`, 'confirm again');
st.change('acme', doc => { doc.projects.site.workflows.change.description = 'Changed again.'; });
await b.evaluate(press('[data-wfg-delete-yes]'));
await wait(`/^Not deleted/.test(${text('[data-wfg-status]')})`, 'second delete refused');
assert.equal(await b.evaluate(text('[data-wfg-status]')), 'Not deleted: the organization file changed this workflow since the page read it. The page shows the current version at its next read.');
assert.equal(await b.evaluate(`document.querySelector('[data-wfg-discard]')`), null);
assert.equal(st.docs.acme.projects.site.workflows.change.description, 'Changed again.');
});
test('a failed read says a pending change is kept, and it comes back when the read works', { timeout: 60000 }, async t => {
const { b, wait, st } = await setup(t, { path: '#/orgs/acme/workflows/change?project=site&step=1' });
await wait(`document.querySelector('[data-wfg-remove]')`, 'step 1');
await b.evaluate(press('[data-wfg-remove]'));
await wait(`${boxes}.length === 1 && document.querySelector('[data-wfg-unsaved]')`, 'pending removal');
st.failRead = true;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`!!document.querySelector('[data-wfg-error]') && !!document.querySelector('[data-wfg-held]')`, 'error with kept note');
assert.equal(await b.evaluate(text('[data-wfg-held]')), 'Unsaved change to change is kept on this page. It comes back when the workflows can be read.');
assert.equal(await b.evaluate(`window.MosaicWorkflows.hasDraft()`), true);
st.failRead = false;
await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`);
await wait(`${boxes}.length === 1 && !!document.querySelector('[data-wfg-unsaved]') && !document.querySelector('[data-wfg-held]')`, 'back');
assert.equal(st.writes.length, 0);
});
@@ -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,35 +72,35 @@ 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);
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/#/orgs/acme${hash}`);
// SP3: a hash that starts with # is a whole address (#/inbox is a system route).
await b.navigate(`${base}/${hash.startsWith('#') ? hash : `#/orgs/acme${hash}`}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
test('Dashboard renders every section across organizations, and a stopped one shows its state while the others render', async t => {
const { b, wait, text } = await setup(t);
await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgcard]").length === 3');
await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgrow]").length === 3');
// The Dashboard nav item is this page.
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Dashboard');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/dashboard');
assert.equal(await text('#bus-view h1'), 'Dashboard');
// Tiles: needs you, sessions, open tasks, projects, and an honest line for spend.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['needs', 'sessions', 'tasks', 'projects', 'spend']);
// SP8 (#1873): one row of figure tiles, none for spend or any figure no read returns.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['agents', 'orgs', 'needs', 'tasks', 'done', 'projects']);
assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i);
assert.match(await text('[data-ws-tile=projects]'), /Projects\s*4\s*3 active/i);
assert.match(await text('[data-ws-tile=spend]'), /No spend figures yet/);
assert.doesNotMatch(await text('[data-ws-tile=spend]'), /\$|\d/);
// Organization cards: two running with their read, the stopped one with its state and the time it was read.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgcard]")].map(e => [e.dataset.wsOrgcard, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]);
assert.match(await text('[data-ws-orgcard=gamma]'), /Gamma Home/);
assert.match(await text('[data-ws-orgcard=gamma]'), /down/);
assert.match(await text('[data-ws-orgcard=gamma]'), /stopped by the owner/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").getAttribute("href")'), '#/orgs/beta/organization');
assert.match(await text('[data-ws-tile=projects]'), /Projects\s*3 active[\s\S]*1 archived/i);
assert.doesNotMatch(await text('[data-ws-tiles]'), /spend|\$/i);
// SP1: one organization row each: two running, the stopped one with its state and reason.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgrow]")].map(e => [e.dataset.wsOrgrow, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]);
assert.match(await text('[data-ws-orgrow=gamma]'), /Gamma Home/);
assert.match(await text('[data-ws-orgrow=gamma]'), /bus down/);
assert.match(await text('[data-ws-orgrow=gamma]'), /stopped by the owner/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a").getAttribute("href")'), '#/orgs/beta/organization');
assert.match(await text('[data-ws-home] .sub'), /2 of 3 organizations answered/);
assert.match(await text('[data-ws-home] .sub'), /Gamma Home down, read \d\d:\d\d/);
// Recent project activity across both running organizations, newest first, each linking into its org.
@@ -107,7 +110,7 @@ test('Dashboard renders every section across organizations, and a stopped one sh
// Needs you lists both organizations' decisions, newest raised first as the read orders them.
const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => [a.textContent, a.getAttribute("href")])');
assert.equal(needs.length, 2);
assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/orgs/acme/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs));
assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs));
assert.ok(needs.some(([q]) => q === 'Pick a launch price'));
// Sessions now: the working and the waiting role, each with its organization.
assert.match(await text('[data-ws-sessions]'), /coder/);
@@ -179,7 +182,7 @@ test('Projects sorts and filters by each key, remembers the grouping, and rows o
const location = hash => decodeURIComponent(hash);
test('Inbox lists both organizations, copies a command with --org and the full id, and deciding needs human proof', async t => {
const { b, base, wait, text } = await setup(t, { hash: '/inbox' });
const { b, base, wait, text } = await setup(t, { hash: '#/inbox' });
await wait('document.querySelectorAll("[data-ws-item]").length === 2 && document.querySelector("[data-ws-insp]")');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Inbox');
assert.equal(await b.evaluate('document.querySelector("[data-not-built]")'), null);
@@ -187,13 +190,15 @@ test('Inbox lists both organizations, copies a command with --org and the full i
assert.deepEqual(items.map(([k]) => k).sort(), [`acme:${ACME_DECISION}`, `beta:${BETA_DECISION}`]);
// Each row names its organization, project and session.
const acme = items.find(([k]) => k.startsWith('acme'))[1];
assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /gated/);
assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /Gated/);
const beta = items.find(([k]) => k.startsWith('beta'))[1];
assert.match(beta, /Beta Works/); assert.match(beta, /No project/); assert.match(beta, /pm/);
assert.match(beta, /Beta Works/); assert.match(beta, /pm/);
// SP3: a project chip only where the item has a project.
assert.equal(await b.evaluate(`document.querySelector('[data-ws-item="beta:${BETA_DECISION}"] [data-ws-projchip]')`), null);
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
// Tabs the data can't back say so.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-inbox] .ws-seg button")].map(x => [x.textContent, x.disabled])'), [['Needs you', false], ['Seen', true], ['Answered', true]]);
assert.match(await text('[data-ws-inbox]'), /not built yet/);
// SP3: the prototype's Org and Type filter rows; the Seen and Answered placeholders are gone.
assert.equal(await b.evaluate('document.querySelector("[data-ws-inbox] .ws-seg")'), null);
assert.ok(await b.evaluate('document.querySelectorAll("[data-ws-type]").length > 1'));
// Select the acme decision; the inspector shows it, and the selection stays in the hash.
// A pointer click focuses the link before it activates; .click() alone does not.
await b.evaluate(`(() => { const a = document.querySelector('[data-ws-select="acme:${ACME_DECISION}"]'); a.focus(); a.click(); })()`);
@@ -216,12 +221,42 @@ test('Inbox lists both organizations, copies a command with --org and the full i
await wait('[...document.querySelectorAll("#bus-view h1")].length > 0 && !document.querySelector("#bus-view .loading")');
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-decide]")'), null);
// Filter to one organization.
await b.navigate(`${base}/#/orgs/acme/inbox`);
await b.navigate(`${base}/#/inbox`);
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
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');
@@ -243,18 +278,18 @@ test('A poll repaints in place: focus, caret and scroll stay where they were', a
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.selectionStart]'), ['proj-name', 2]);
// The Dashboard and the Inbox repaint the same way.
await b.evaluate('location.hash = "#/orgs/acme/"');
await wait('document.querySelectorAll("[data-ws-orgcard]").length === 3');
await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").focus()');
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
await b.evaluate('document.querySelector("[data-ws-orgrow=beta] a").focus()');
await b.evaluate('window.__marker = document.querySelector("[data-ws-orgs]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('document.querySelector("[data-ws-orgs]") !== window.__marker');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgcard:beta');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgrow:beta');
// No loading view replaced the page during that repaint.
assert.equal(await b.evaluate('document.querySelector("#bus-view .loading")'), null);
});
test('Phone width: no horizontal scroll on any of the three pages, and the drawer opens and closes', async t => {
const { b, wait } = await setup(t, { viewport: [390, 844] });
for (const [hash, ready] of [['/', '[data-ws-orgcard]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) {
for (const [hash, ready] of [['/', '[data-ws-orgrow]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) {
await b.evaluate(`location.hash = "#/orgs/acme${hash}"`);
await wait(`document.querySelector(${JSON.stringify(ready)})`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, hash);
@@ -270,18 +305,20 @@ test('Phone width: no horizontal scroll on any of the three pages, and the drawe
await b.evaluate('document.querySelector("#sections a[href=\'#/projects\']").click()');
await wait('location.hash === "#/orgs/acme/projects" && document.querySelector("[data-ws-project]")');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
// An inbox row at phone width puts its source on its own line above the question.
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
await wait('document.querySelector("[data-ws-item] .ws-msrc")');
assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('.ws-msrc').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.bottom <= q.top + 1; })()`), true, 'source above question');
// An inbox row at phone width puts its organization chip on its own line below the question.
await b.evaluate('location.hash = "#/inbox"');
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('[data-ws-orgchip]').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.top >= q.bottom - 1; })()`), true, 'organization below question');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, 'system inbox');
});
test('A read that an answering organization could not make says not read, never zero', async t => {
// beta answers, but its project and inbox reads refuse.
const { b, wait, text } = await setup(t, { fail: { beta: ['projects', 'inbox'] } });
await wait('document.querySelector("[data-ws-tiles]")');
assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Beta Works \(the chat channel is off\)/);
assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in Beta Works/);
// The tiles also name Gamma Home, whose bus is down.
assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Gamma Home \(bus down\) and Beta Works \(the chat channel is off\)/);
assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in .*Beta Works/);
assert.match(await text('[data-ws-miss=recent]'), /Not read in Beta Works/);
assert.match(await text('[data-ws-miss=needs]:not([data-ws-tile] *)'), /Not read in Beta Works/);
assert.doesNotMatch(await text('.ws-home .sub'), /and \d+ projects?/, 'no project total when a project read failed');
@@ -297,7 +334,7 @@ test('A read that an answering organization could not make says not read, never
await wait('document.querySelector("[data-ws-miss=projects]")');
assert.match(await text('[data-ws-miss=projects]'), /Not read in Beta Works/);
// Inbox: the note names it, and the chip carries no count.
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
await b.evaluate('location.hash = "#/inbox"');
await wait('document.querySelector("[data-ws-miss=inbox]")');
assert.match(await text('[data-ws-miss=inbox]'), /Not read in Beta Works \(the chat channel is off\)/);
assert.match(await text('[data-ws-org=beta] .n'), /not read/i);
@@ -325,10 +362,10 @@ test('Project rows and per-organization figures say not read when the reads behi
// Dashboard: the per-organization figures leave Beta out instead of showing its 0.
await b.evaluate('location.hash = "#/orgs/acme/"');
await wait('document.querySelector("[data-ws-tiles]")');
for (const tile of ['needs', 'sessions', 'tasks']) {
for (const tile of ['needs', 'agents', 'tasks', 'done']) {
const by = await b.evaluate(`document.querySelector('[data-ws-tile=${tile}] .ws-by')?.textContent ?? ''`);
assert.doesNotMatch(by, /Beta Works/, tile);
assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Not read in Beta Works/, tile);
assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Beta Works \(the chat channel is off\)/, tile);
}
assert.doesNotMatch(await b.evaluate(`[...document.querySelectorAll('[data-ws-states] .ws-srow')].find(r => /Done this week/.test(r.textContent))?.querySelector('.ws-by')?.textContent ?? ''`), /Beta Works/);
});
@@ -336,13 +373,15 @@ test('Project rows and per-organization figures say not read when the reads behi
test('With every organization down the dashboard and inbox show no totals and no empty-list claims', async t => {
const { b, wait, text } = await setup(t, { down: ['acme', 'beta'] });
await wait('document.querySelector("[data-ws-tiles]")');
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] .ws-big").length'), 0);
assert.match(await text('[data-ws-tiles]'), /No organization answered/);
// Only the Organizations tile has a figure, and it is complete: none of the three runs.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-home] .ws-big")].map(e => e.closest("[data-ws-tile]").dataset.wsTile)'), ['orgs']);
assert.match(await text('[data-ws-tile=orgs] .ws-big'), /^0 of 3 running$/);
for (const tile of ['agents', 'needs', 'tasks', 'done', 'projects']) assert.match(await text(`[data-ws-tile=${tile}]`), /Not read[\s\S]*Acme Studio \(bus down\), Beta Works \(bus down\) and Gamma Home \(bus /, tile);
assert.equal(await text('[data-ws-needs]'), 'Decisions not read');
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
assert.equal(await text('[data-ws-recent]'), 'Projects not read');
assert.equal(await text('[data-ws-sessions]'), 'Sessions not read');
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
await b.evaluate('location.hash = "#/inbox"');
await wait('document.querySelector("[data-ws-items]")');
assert.equal(await text('[data-ws-items]'), 'Decisions not read');
await b.evaluate('location.hash = "#/orgs/acme/projects?grouping=flat"');