Skip to content

DO NOT MERGE — merge train: 2778,2780,2781,2782,2783,2784,2785,2787,2922,2920,2924,2927 - #2928

Closed
dolho wants to merge 40 commits into
devfrom
train/20260921-1149-dolho
Closed

dolho wants to merge 40 commits into
devfrom
train/20260921-1149-dolho

Conversation

@dolho

@dolho dolho commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2778, #2780, #2781, #2782, #2783, #2784, #2785, #2787, #2922, #2920, #2924, #2927. Never merged; members merge individually — and only after @vybe's review — once this is green.

Assembled from each member's head with --no-ff (one merge commit per member, bisectable). Local gate on the combined tree: alembic heads 1 (0067_agent_role_readiness), vitest 3408/3408, tokens OK, build OK, the members' backend suites 219/219.

What the dry run already caught and fixed on member branches (announced on each PR):

Members: #2778 → #2780 → #2781 → #2782 → #2783 → #2784 → #2785 → #2787 (UI stack, retarget each to dev as its parent lands) · #2922 · #2920 → #2924 → #2927 (schema stack).

dolho and others added 30 commits September 21, 2026 10:48
…name (#2914)

Assigning a library skill whose name matched a `.claude/skills/<name>/` the
agent wrote itself overwrote the agent's copy, gitignored and untracked the
directory, and buried an `unmanaged_dir_overwritten` warning in the assignment
response. The library and the agent's repo share one flat namespace on the
agent side, so a name match is not proof of the same skill — the library
silently won.

The inject path now refuses a directory that exists without the platform's
`.trinity-skill.json` marker before a byte is staged: no archive, no restore,
no `.gitignore` line, no untracking, not listed under CLAUDE.md Platform
Skills. `force` (manual Sync) does not override it; a platform-managed dir
keeps upgrading in place; an unreadable probe keeps the pre-existing fail-open
direction. The per-skill status is `conflict` — neither `injected` nor
`failed` (overall `success` stays true so the fleet re-inject does not alarm on
a standing conflict every auto-sync); `skills_conflict` + `conflicts[]` carry
it, and `deliver_assigned` reports `conflict` / `partial` + `conflicts[]`.

The verdict is recorded on the assignment row (`agent_skills.delivery_status`,
SQLite `agent_skills_delivery_status` + Alembic `0065`), carried across the
bulk-replace PUT for retained names, cleared by the next injection where the
name lands, and gone on unassign. The Skills tab reads it off the rows so the
badge, the explanation (which skill, the agent's own copy is what runs) and an
inline "Unassign library skill" action show on a fresh load; `inject()` re-reads
the rows so a resolved conflict clears without a reload. MCP passes the
delivery block through, `sync_agent_skills` names conflicts on its success
branch, and `get_agent_skills` carries `delivery_status` per row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce-text specs over components (#2918)

Two PRs ejected from the 2026-09-20 merge train and three from the previous
one landed safety-critical UI logic — a fleet-delete confirmation's
`canRemove`, a modal's Esc/focus-trap contract — in the one tier with no
executable coverage: regexes over the component's source, which an inverted
predicate passes byte-identically. Each justified itself with "this repo's
vitest is node-only, nothing can mount", which has been false since
`vitest.config.js` grew `plugins: [vue()]` + jsdom + @vue/test-utils for a
per-file `// @vitest-environment jsdom` opt-in (precedent:
portalThemeSwitch.spec.js). The capability was never the gap; its
discoverability at the decision point was.

Three things close it:

1. The design-system contract (read before any src/frontend change) gains a
   "Testing a component — the harness mounts" section and a PR self-check
   item naming the opt-in, the copyable precedent, and the bar: source-text-
   only coverage of a predicate that gates a destructive verb, a keyboard
   contract or a store write is not acceptable. design-system.md §9 and
   docs/testing/STRATEGY.md carry the same rule.
2. The methodology names the tell (.claude submodule bump → trinity-dev
   5b09829 + 437674f): /validate-pr §5.4, /review Step 2.5 and the /implement
   checklist state the verdict rule and the escape, and merge-train lists the
   new baseline among the regenerable ratchets.
3. A ratchet, the same shape as raw colours and loading gates:
   scripts/scan-source-text-specs.mjs counts, per spec, non-import `.vue`
   literals under components/ or views/ in a spec that calls readFileSync;
   source-text-baseline.json freezes today's 86 specs / 271 reads;
   tests/unit/sourceTextRatchet.spec.js fails when a spec grows, when a NEW
   spec reads SFC source without a `@source-text-pin: <reason>` marker (the
   reviewable escape for an AST-shaped call-site guard or a parity table), or
   when an entry is stale. The failure message names the harness and the
   precedent — the point-of-decision hint the issue asked for. Negative-
   controlled: a new unpinned spec fails, the same spec pinned passes, and a
   +1 read in a baseline spec fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…; delivery copy and draft fixes (#2914)

Merge-train review on #2920: marker-absence was read as "the agent authored
this" and refused, but the platform itself wrote marker-less directories on
two live paths — the pre-#384 legacy fallback (SKILL.md only) and a restore
that dropped the meta member — so each would have turned its own package
into a permanent, unrepairable conflict on the next sync (AC#6 violated).

Both are closed at the write: `_legacy_fallback` writes the marker beside
SKILL.md (manifest = what was actually written), and `_restore_skill` writes
the marker back directly when the agent's `restored` list lacks it
(`marker_written_directly`). If the marker cannot be written the injection
is reported `failed` (`marker_not_written`; the legacy write is rolled back)
rather than a half-managed directory. Five tests cover the two paths, their
failure arms, and the legacy-marker round trip that closes AC#6.

Also from the review: `deliveryText` grammar ("its own a skill") with a real
plural arm; the `not_delivered` arm now names conflicts beside the failure
it retries; the Skills tab resets its draft only when the assigned SET
changes, so a Sync (which re-reads the rows for the verdict) no longer
wipes unsaved ticks; the conflict copy says "a directory the platform did
not create — usually a skill it authored" rather than asserting authorship.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…urn (#1923)

Six bespoke overlays each re-implemented the same markup and each omitted the
same two behaviours: Esc did nothing, and Tab walked out behind the overlay.
Verified still true on `dev` before starting — **zero** `Escape` references
across all six files (the `@keydown` hits there are Enter-to-submit), no shell
primitive, no focus trap anywhere.

`BaseModal` owns exactly four things — overlay, Esc, focus, scroll lock — and
nothing about content, so adopting it is deleting two wrapper divs rather than
rewriting a dialog. It teleports to `<body>`: several of these modals are
declared inside panels that establish a stacking context, where a `z-50`
overlay nested in one renders BEHIND its siblings.

**The decidable half is a separate pure module, and that is the point.** This
repo's vitest runs `environment: 'node'` with no jsdom/happy-dom and no
`@vue/test-utils` — I checked, because a focus trap written entirely inside an
SFC would be a rule no unit test could reach, and source-text assertions would
prove only that it had been TYPED. `utils/focusTrap.js` holds every decision as
a function over plain data (tabbable filtering, the Tab wrap, the dismiss-key
predicate, safe-action selection, backdrop identity) with 25 tests. What that
leaves uncovered is the wiring itself — listener attachment, the focus() calls —
which needs a browser and belongs to e2e. Said plainly rather than implied by a
green suite.

Three decisions worth naming:

* initial focus goes to the SAFE action, and destructiveness is DECLARED
  (`data-destructive`) rather than guessed from label text, which would be
  wrong in every language but English. A dialog that opens with Delete focused
  turns a reflexive Enter into data loss.
* backdrop dismissal compares identity against the overlay node, not a
  rectangle — a rectangle test mis-fires for a select popup or date picker
  rendered at the document root and closes the modal under the user.
* a modified Escape (Ctrl/Cmd/Alt/Shift) does not dismiss; that is a browser or
  OS gesture, not an intent to close.

Migrated in this commit: `SystemViewEditor.vue` and `NavBar.vue`'s build-info
modal. The remaining four files are enumerated in the PR with their exact
overlay bounds; they are 80-230 line tag surgeries and are deliberately left
for a reviewed pass rather than done blind in one go.

One acceptance-criteria item is stale: `views/Agents.vue` was deleted by
ent#260 (the Agents page folded into the Dashboard list view), so its
bulk-tag popover no longer exists.

Related to #1923

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…y, and a mounted spec for the wiring (#1923)

Merge-train review on #2778, twice: the shell's rules were tested, its
wiring was not, and the docblocks justified that with a claim that was
false on this head — the repo mounts components (per-file jsdom opt-in,
@vue/test-utils, #2918). Two defects sat in exactly that untested wiring.

1. The scroll lock was global state written per instance: `immediate: true`
   ran the else-branch on every mount and `onBeforeUnmount` cleared
   unconditionally, so a nested dialog (the #2780 shape — a ConfirmDialog
   inside another modal's slot) unlocked the page the moment the outer
   opened, and any modal's unmount clobbered another component's lock.
   Now `utils/focusTrap.js::createScrollLock` is a ref count over one
   value (first holder saves and hides, last release restores) and every
   BaseModal shares `bodyScrollLock`, taking and releasing exactly its own
   count.
2. The overlay had no `tabindex="-1"`, so `overlay.focus()` was a no-op and
   Esc did nothing once focus left a control (a click on dialog text lands
   focus on the nearest focusable ancestor, which was <body>).

`tests/unit/baseModal.spec.js` mounts the shell: initial focus on the safe
control, Tab wraps, Esc emits close and focus returns, the overlay is
focusable, a modal with no tabbable child is still dismissable, backdrop
semantics, the lock is taken/restored, two nested modals share one lock,
and unmount releases only what the instance held. Mutation-checked: gutting
@keydown, the focus return, the tabindex or the lock each turns it red.

Also: the false "node-only vitest" claims are rewritten in all three
docblocks; the overlay's p-4 now IS the 16px phone gutter (the two adopted
panels drop their mx-4, which had doubled it to 32); the design-system docs
name BaseModal as the modal shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…irm() sites move onto it (#1924)

Stacked on #1923 — `ConfirmDialog` adopts the `BaseModal` shell built there.

The issue reads as "replace confirm() calls", but the thread rules otherwise
(webmixgamer, 2026-08-21), and I re-verified it still holds on dev:

  ConfirmDialog.vue has no focus trap, no initial focus on the safe action,
  no Esc handler, and its DOM order is confirm-first — so this issue's AC
  "Initial focus lands on the safe action" needs the primitive itself fixed,
  not just the call sites.

Confirmed: zero Escape / @keydown / focus() / role="dialog" in the file, and
`variant="danger"` at :58 ahead of `variant="secondary"` at :66.

**The confirm-first DOM order is fixed WITHOUT reordering the buttons.** The
confirm is marked `data-destructive`, which is what `focusTrap.initialFocusIndex`
skips, so focus lands on Cancel while the DOM order and `sm:flex-row-reverse`
stay exactly as they were and nothing moves on screen. Reordering was the
obvious fix and the worse one: it would have changed the rendered layout of a
dialog seven components already use.

Adopting BaseModal gives all seven existing callers Esc-to-close, a focus trap
and focus return at once — none of them change.

Every `data-testid` is preserved. `confirm-dialog-backdrop` is the one that
goes (BaseModal owns the overlay now); checked first — no test or component
referenced it, and the two that ARE used (`confirm-dialog`,
`confirm-dialog-confirm`) are untouched.

Call sites migrated here: `SystemViewEditor` delete-view and `GitPanel`
clear-PAT. Each names the verb ("Delete view", "Clear token"), restates the
consequence, and offers a named safe action instead of "Cancel/OK".

Remaining on the issue and NOT done here: Settings.vue (3) and
SchedulesPanel.vue (3), plus MobileAdmin's bespoke overlay and the two
unconfirmed single-click actions.

Not touched, by the thread's ruling: `/m`'s approval submit step (#2370) is an
inline p19-shaped flow by design and must not be re-modalised.

**Baseline edited by hand, not regenerated.** ConfirmDialog IMPROVED (raw_gray
9 -> 7 — its bespoke backdrop and the gray-500/gray-900 overlay went away), and
a stale ceiling fails the guard too. A wholesale `--baseline` run deleted the
entire `refrozen` provenance block and absorbed canvas/CanvasDocument.vue and
views/SharedCanvas.vue — two files that landed on dev after this branch was cut
— so the edit is scoped to the two entries that actually moved, per the rule
the 2638 note already states.

Related to #1924

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
… and NavBar (#1925)

Three audited surfaces hand-rolled a tab/nav strip instead of using the
`OverflowTabs` primitive (#1114). Settings and Operations now render their tabs
through it; NavBar gets the same measured priority+ split without mounting it,
because its items are `<router-link>`s that must keep a real href and the
primitive's are `<button>`s that emit a selection.

NavBar is the case the audit called worst. Its link row was an `overflow-x-auto`
scroller with the scrollbar suppressed inside the 64px bar (#1789), so an
overflowed link stayed reachable but became invisible and undiscoverable — the
bar gave no signal that more navigation existed. It now renders the links that
fit plus a counted "N more ▾" disclosure, with the active state reflected on the
trigger when the current route's link is one of the hidden ones.

The fit arithmetic is SHARED, not re-derived: `utils/overflowFit.js` holds the
rule verbatim from `OverflowTabs::recompute`, and both surfaces call it. It also
gained a `gap` term, which NavBar needs and the tab strip does not — the tabs are
padding-spaced, while the nav row carries a real flex gap that is invisible to
the per-item rects, and a gap-blind sum hides ~120px on a six-link row (one whole
link) and so keeps the last one inline to clip.

Both new modules are pure because vitest runs `environment: 'node'` with no mount
harness, so a rule inside an SFC is one no test can reach (the ent#392
precedent). `utils/navLinks.js` likewise derives the link set once — a strip that
can hide a link renders the same link three times (inline, menu, hidden mirror),
and three hand-written copies is three places for an active predicate or a badge
to drift.

Colours: the new code is semantic-token only, so the active underline moves from
raw `blue-500/400` to `action-primary` (the primitive's own active token) and the
Enterprise PRO pill from raw `purple-*` to `accent-purple-*`. Visible as a slight
indigo shift on the nav's active indicator.

Triage of the audit's nine scanner candidates: every one is a bounded wide table
or a preformatted code block, where horizontal scroll inside the container is the
correct treatment (design-system principle 7) — collapsing columns into a "More"
menu would hide data, not navigation. None migrate; each is marked in place so a
later audit does not re-flag it. The advisory two-pill strip in Audit.vue is a
segmented control, a different primitive, and is recorded as such.

`workspaceNewTab.spec.js` (ent#456) asserted `target`/`rel` against NavBar's
template text; that truth moved into `navLinks.js`, so the NavBar half now drives
the data and additionally pins that the strip binds both fields onto its links —
the half a data-driven refactor can get wrong is correct data that never reaches
the DOM.

Baseline: NavBar raw_nongray 35 -> 19, raw_gray 125 -> 105. Edited by hand rather
than regenerated, per the `refrozen` note — a wholesale run deletes that block.

Fixes #1925

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
Caught by a live check, not by the suite: at 1440px the strip hid four of five
links behind "4 more". The row and its parent cluster sized to content, so
`clientWidth` reported the row's own min-content — and because the inner nav is
`overflow-hidden`, that is near zero — making the fit rule correct about a
container that was lying to it.

The old #1789 row never needed this: a row that SCROLLS sizes to its content and
lets the overflow happen, while a row that COLLAPSES has to be told how much
space it actually has. `flex-1` on both supplies it; `justify-between` plus the
controls' `flex-shrink-0` keeps the right-hand cluster where it was.

Verified against the local dev instance across 1440/1300/1200/1100/1000/900/800/700
and back up again: one row at every width, never clipped, collapsing at 800 and
700 and re-expanding on the way back up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…ll row (#1925)

`navbar-overflow.spec.js` is #1789's guard, and its third test asserts
`overflow-x: auto` on the link row — the hidden-scrollbar scroller this issue
deliberately replaced. It went red the moment the strip started collapsing, and
I missed it because I grepped `src/` for that pattern and never `e2e/`.

Confirmed it is this branch and not the tree: the spec passes against plain
`origin/dev` built and served the same way, and fails here.

Properties 1 and 2 — links never collide with the controls, controls stay
on-screen and the user menu stays hit-testable — are unchanged and still pass;
they were never about the mechanism. Property 3 now pins what #1925 actually
guarantees: the links that fit render inline, the rest collapse into a counted
"N more" disclosure, the count agrees with what the menu holds, every hidden
link is a real href, and Escape closes it.

It also pins the ABSENCE of the old mechanism — no `overflow-x: auto`, no
scrollWidth past the box. A future refactor reintroducing the scroll row would
restore exactly the undiscoverable-link failure this issue removed, and this is
where that gets caught.

`settleNav` is not politeness: the strip re-measures on a rAF after a resize, so
a snapshot taken in the same tick as `setViewportSize` reports the previous split
— "it fits" one frame before the links move into the menu. That is how the first
version of this rewrite failed, and the fix is to sample until two reads agree
rather than to widen a timeout.

Verified against this branch's build: 4/4 pass, and the collapsed branch is
genuinely exercised — at 640px the trigger reads "4 more" with 1 link inline and
4 in the menu, at 1440px there is no trigger and 5 links inline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…anical, per the merge-train note on the PR

CodeQL js/incomplete-multi-character-sanitization (train #2896, alert 364)
flagged the one-shot replace in a source-text guard. A fixpoint loop is the
shape the rule accepts; behaviour on every real input is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…md (#2914)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ds and writes their memory (Abilityai/trinity-enterprise#637)

A role companion's proactive brief is a scheduled run, and a scheduled run
could not touch an individual user's memory: `write_user_memory` refused every
`schedule` trigger, so a brief could not carry the open loops and commitments
that make the next brief better than the last. That gap is what forced "one
agent per seat"; operator ruling R26 removed it.

The seat is ent#498's address, read off the execution row, never sent. The
ent#498 stamp already writes `source_channel='portal'` +
`source_channel_client=<email>` after the roster and block checks;
`services/schedule_seat_memory.seat_for_execution` reads it back for a
`schedule`-triggered row and nothing else, and `routers/public_memory.py`
accepts the write for exactly that case. `source_user_email` is deliberately
not stamped — `client_portal/work` reads it as "work I started" and two
stream-ownership checks key on it. No address → refused as before, now saying
"names no one".

The run reads before it writes: the tool is whole-blob replace, so the
internal dispatch composes the seat's MEM-001 memory block plus a seat note
into `execute_task(system_prompt=…)` on both branches, only after the stamp
landed. The public persona prompt is not folded in.

One write boundary, now with history: `db.write_public_user_memory_agent_notes`
replaces the notes and records `public_user_memory_writes` (previous + new,
execution, trigger, schedule) in one transaction — ent#419's screen lands
there once, and its rollback layer is this table. The Workspace agent details
gain "What it remembers about you" (`PortalAgentMemory.vue`,
`GET /agents/{name}/memory`) listing the writes with schedule name and time,
and Undo (`POST …/memory/writes/{id}/undo`) — latest-first, a named 409 for
a later write, uniform 404 for a foreign id. Dual-track migration (SQLite
`public_user_memory_writes_table` + Alembic `0065`), CASCADE in AGENT_REFS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erve the same seat (Abilityai/trinity-enterprise#637)

The manual trigger stamps triggered_by='manual' with the operator's own
source_user_email (#1970); the brief is still addressed to the seat, so the
seat resolver keys on a real schedule_id + the ent#498 portal stamp across
all three fire paths and never consults source_user_email.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…bilityai/trinity-enterprise#637)

Found live: the Workspace projection keyed the write's kind on
triggered_by == 'schedule', and a Run-now fire is 'manual', so the seat's
own write rendered as "In a conversation". The boundary records schedule_id
only for a seat run, so that is the key. Pinned with the projection test
and the named-refusal test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the time and the undone mark (Abilityai/trinity-enterprise#637)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…065 — the three 2026-09-21 schema PRs are stacked (Abilityai/trinity-enterprise#637)

Three open PRs each added a 0065_* off 0064, which alembic-heads reads as
three heads and `upgrade head` would apply nothing. #2924 now stacks on
#2920 (base branch fix/2914-skill-name-conflict) and its revision is
0066 off 0065_agent_skills_delivery_status. The migrations.py append
collision is resolved by keeping both entries, #2920's first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…th metric freshness, readiness (Abilityai/trinity-enterprise#527)

When a companion has a role (Tandem, ent#497), the Info rail shows a Role
card: the role it fills, the objectives it owns or supports with each
metric's value / target / freshness, the viewer's relationship, and its
readiness. Framework §8's organisation UI shrunk to one agent — files are
truth, the card is a projection. The Role + Readiness half; the
relationship line reads ent#500's assignment when it lands and states "no
assignment recorded" until then.

Files are truth, read through the platform, never a second store:
`client_portal/role_card.py` reads template.yaml (`x-role`, `x-canon`),
`<canon>/roles/<id>.yaml`, `<canon>/objectives/*.yaml` and the agent's own
`/api/metrics` through the agent door on every request; ids and paths that
reach a read are validated, text is bounded, and every failure is named
(`role.error`, `unavailable`) — no `x-role` means no card at all. A metric
is stale when its value is missing, unstamped, or older than the
framework's 30-day bound, and stale renders as stale, never as current.

Readiness is the agent owner's stamp (#663, ruled 2026-09-20).
`x-role.status` is agent-writable, so `agent_role_readiness` (dual-track:
SQLite + Alembic 0065, CASCADE in AGENT_REFS) holds the state, when it
changed and who flipped it; a template that says `ready` with no stamp is
shown as calibrating with the note that no owner stamped it. The flip is
`POST …/role/readiness`, gated on the platform's owner of the agent record
(named 403 for everyone else, no route for the agent), behind a confirm
that says it does not switch any schedule on. While calibrating the card
shows the viewer's own walkthrough count (asks in their Main, capped at
ten, and their thumbs-down).

`PortalAgentRole.vue` is mounted-tested (#2918); the backend card is
driven through a fake agent door; the stamp against a real SQLite file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…) — mechanical, per the merge-train note on the PR

The train (#2922's source-text ratchet + this stack) found the joint break:
this spec reads 20 SFCs by text and is a NEW spec with no baseline entry.
It is a legitimate pin — an adoption guard over a set of call sites a mount
cannot prove — so it declares itself one, per the ratchet's escape hatch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g the page (#2197)

Three rigid flex rows scrolled the page body below 1024px, against the
design-system rule that wide content scrolls inside its own container.

The report blamed the tab strips, and that is not what was wrong. `OverflowTabs`
was collapsing correctly the whole time, at 375px included: the hidden mirror row
it measures against lives in a 0x0 `overflow: hidden` box and is invisible to the
user and to `scrollWidth` alike, but a probe that does not skip clipped subtrees
reports it as the widest thing on the page — which is how a measurement sweep
came to name `NAV.-mb-px.flex` and the "More" button. Measured with clipped
subtrees excluded, the real offenders are:

* **Agent Detail** — three `AgentHeader` rows, each a non-wrapping flex row of
  two rigid clusters: identity + Workspace/Talk/Running, toggles + Tags + the
  live CPU/MEM/uptime readout, and the lifetime-cost line. Together ~790px of
  min-content, so the page widened at every width below ~1024px (+496px at 375).
* **Settings** — the Default Model `<select>`, `flex-1` with the default
  `min-width: auto`. A select's min-content is its widest OPTION ("Claude Opus 5
  — Most capable Opus (latest) (recommended)", ~417px), so one control scrolled
  the page by 157px at 375. `min-w-0` is the whole fix.

The Dashboard overlap is a different failure with the same root shape. #1830 made
the stats cluster elastic so it degrades instead of being clipped — but elastic
with no floor means it collapses to ZERO, so the pressure never reached the
controls and the row never wrapped. Measured at 640px: stats 0px wide (its
clipped children still sitting under the controls' first button — the 8px the
spec reports), controls pinned at their 692px max-content and running ~70px past
the row's right edge. The row now wraps, the stats cluster carries the ladder's
own agents-only floor (~71px) so the overflow becomes a wrap, and the controls
may shrink and wrap their own buttons rather than being pinned wider than the row.

Rendering above ~900px is unchanged — measured single 30px row at 1600/1440/1280/
1024/900, wrapping to 91px at 768 and below, zero overlap at every width.

`space-x-*` becomes `gap-*` on each row that can now wrap: a wrapped `space-x`
row mis-indents every line after the first.

Verified against a local dev instance in BOTH themes at 375 and 640 (light and
dark, `dark` class asserted): `scrollWidth <= clientWidth` on Settings, Agent
Detail, Dashboard, Operations and Library at 375/640/768/1024.

`e2e/dashboard-stats-overflow.spec.js` (#1830), red on `dev`, is green. The new
`e2e/body-horizontal-overflow.spec.js` measures the one thing the contract states
— the document scroll width — and names the widest UNCLIPPED element on failure,
because an element-shaped assertion is exactly what produced a report naming the
wrong element. Proven to bite: with these three fixes reverted it fails Settings
at +157px naming the `<select>`, and Agent Detail at +496px naming the action
cluster.

Its `ready` predicates are load-bearing, not politeness. The widest markup on
both pages is gated behind a later render — Settings' admin-only `<select>`
behind `/api/users/me`, Agent Detail's live stats behind the stats poll — and on
a fixed settle the Settings arm PASSED with the fix reverted. That vacuous green
was caught by running the negative control, not by reading the test.

Fixes #2197

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
A `table` block's cells were plain text, so `**Deploy**`, `` `done` `` and
`[runbook](https://example.com)` — what agents routinely put in status and
reference columns — showed their literal characters. The same canvas rendered a
GFM pipe table written in `markdown` prose correctly, so an agent saw two tables
behaving differently, and the one the MCP tool guide recommends was the broken
one. Both structured paths are fixed at once: `CanvasBlock` delegates `table` to
`ReportRenderer`, and a ```table fence inside a `markdown` block delegates to the
same component.

Cells render INLINE markdown only. `marked.parseInline` never emits a block
element, and the cell policy drops the ones raw HTML could smuggle in, so a
heading or a list in a cell degrades to its own text (DOMPurify's `KEEP_CONTENT`)
rather than breaking the row the #2583 gallery pins.

The split is about testability, not taste. `markdown.js` cannot be imported
without a DOM — DOMPurify's DOM-less stub has no `addHook` — and vitest runs
`environment: 'node'`, so anything decided inside it is unreachable by a unit
test. `utils/inlineMarkdown.js` holds the decidable half (the parse, the
markdown-vs-text decision, the escape, the allowlist) and is executed by
`inlineMarkdown.spec.js`; `renderInlineMarkdown` stays in `markdown.js` beside
every other DOMPurify call, on the SAME instance and hooks, so a cell link
inherits the app-wide `target="_blank"` / `rel="noopener noreferrer"` hardening
and there is no second sanitizer (H-005).

Only strings are parsed. A number, boolean or object keeps exactly its
pre-#2771 rendering (`JSON.stringify` for an object, `String()` otherwise) and is
escaped rather than parsed — running a JSON blob through a markdown parser would
let its own `*` and `_` italicise a value nobody wrote as prose.

Headers render on the same terms as cells: an agent that bolds a column name and
bolds the values under it should not get two behaviours.

`ReportTable` is shared with reports (ent#537 / #1535), so report tables gain the
same rendering — the same defect, fixed once. `ReportRenderer.vue` is untouched,
so the `display_hint` / `shapeOk` pins in `test_1535_report_prompt_guidance.py`
are unaffected.

Verified: 17 unit cases over the real configured parser; a new e2e seeds a canvas
through the real `PUT .../canvas/{id}` route and asserts the rendered DOM — bold,
code, a hardened link, em/del, the fenced path, and the half a unit test
structurally cannot reach: a `<script>` + `<img onerror>` payload in a cell
leaves `window.__2771` undefined with zero `script`/`img` nodes in the table.
Red without the fix (the `<strong>` never appears), green with it. Full frontend
unit suite 133 files / 2933 tests green.

Fixes #2771

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
#2202)

Two papercuts found together, on the one page a 31-page sweep flagged as the
only source of a page-level console error — and it produced one on every tab.

**The 404.** Settings read `public_chat_url` through the generic
`GET /api/settings/{key}`, which answers 404 for a key nobody has written. The
store already treated 404 as "unset", so nothing was broken; what was lost was
the signal — a real failure on that call looked exactly like the ordinary case —
and no client-side handling can suppress the browser's own network log, which is
why the fix is a route and not a try/catch. `GET /api/settings/public-chat-url`
answers 200 with `value: null` when unset, following the `/mcp-url` precedent:
a named route for a named setting, declared above `/{key}` (Invariant #4). The
generic route's 404 is deliberately unchanged — it is the documented contract for
every other key and is read outside this repo.

Writing the spec found a SECOND key with the same defect, unnamed in the issue:
`platform_default_model`, 404ing eight times per Settings load. It needed no new
route — `/api/settings/feature-flags` already carries the resolved value — so the
page now reads it from there, which is also more correct: the control shows what
the platform will actually use instead of blank.

**The unbounded list.** MCP Keys rendered every key an instance had ever minted:
measured 306, of which 294 revoked (96%), ~71KB of DOM text, no filter, no bound.
Agent keys accumulate structurally — one per agent, one per #1854 rotation, one
per ephemeral ghost — so the page grows for the life of the instance and the 12
keys that still work are buried in the 294 that do not. Revoked keys are now
hidden behind an explicit toggle that STATES the count, the list is searchable by
name/prefix/agent, and rendering is bounded at 25 rows with a "Show more". The
rules are pure (`utils/mcpKeyList.js`) because vitest runs `environment: 'node'`;
the non-admin agent-key filter is carried through unchanged and asserted, since
it is an access rule wearing a filter's clothes.

Three empties, three next actions — "No API keys" was a lie to an operator
holding 294 revoked ones — but ONE piece of chrome: the wording is computed and
only the action row branches, because three copies of the markup would have
tripled this file's palette-class count.

The new controls are built from the Base* primitives (#2122), and the two
container borders they need are paid for by converting the create form's
hand-rolled name input and description textarea to `BaseInput`/`BaseTextarea` in
the same component: `McpKeysTab.vue` raw_gray 97 -> 83. Baseline edited by hand,
not regenerated.

Verified against a local dev instance (a key created and revoked to exercise the
toggle, removed afterwards): every tab loads with zero 404s and zero console
errors, the list renders 2 of 3 keys with "Show revoked (1)" and "2 active", the
toggle reveals the revoked row while staying bounded, search narrows to a named
empty state that offers the way back, and the converted create form renders and
binds in both themes. Red without the fix on both halves. Frontend unit suite 134
files / 2949 tests green.

Fixes #2202

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…route-set guard (#2202) — mechanical, per the merge-train note on the PR

Train #2896's regression diff: the four route tests raised 'no current event
loop' on the CI interpreter (get_event_loop() on a fresh main thread), and
test_1028_settings_package pinned the post-split route set, so the new
GET /api/settings/public-chat-url read as a route invented by the split.
Neither could surface on this PR's own CI — a PR against a feature branch
does not run backend-unit-test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On every cold load the conversation column rendered full width, then lost the
rail's width the moment the roster arrived — sliding the composer, the thread and
the header left. Since #2676 that is a 300ms animated slide rather than a
one-frame jump, which is prettier and still a shift: the contract's
layout-stability rule is that loading and loaded share ONE footprint and nothing
moves on arrival.

Measured on a local instance before the fix, the Send button at 1440px goes
1327 → 1300 (27px) as the roster lands, and 916 → 884 (32px) at 1024px.

`railVisibleFor` answers "may the rail RENDER", and false for a non-ready stage
is right — its tabs need the roster. But the column's WIDTH does not need the
roster: it comes from the persisted rail state and is known synchronously at
first paint. So the wrapper now renders on `railHasColumn || railColumnReserved`
and gates only `PortalRail` inside. Space is reserved; content is not faked.

`railColumnReservedFor` is deliberately NARROWER than `railVisibleFor`'s route
set, and the exclusions are the interesting part:

  * an agent page never carries a rail, so reserving there would invent a gap;
  * a ROOM route is excluded even though a ready room usually has a rail,
    because that depends on `roomsAvailable`, which arrives ON the roster
    payload (#2128). Reserving against a capability we have not been told about
    yet would trade this shift for the opposite one on every install without
    rooms.

So it reserves for exactly the case the bug is about: a 1:1 conversation route,
mid-load. If the rail then turns out not to render, the column leaves through the
existing width transition — a shrink, not a jump.

Nothing about the motion changes: the `<Transition>` classes, the #2676
voice-canvas swap and `motion-reduce` are untouched, and the reserved column is
present from the first frame so its enter never runs.

Verified: the composer's x is stable within 1px across 24 samples through roster
arrival at 1440/1024/768/640, the column keeps the same width when the rail
lands, and `workspace-model-choice.spec.js` — the spec whose measurements the
#2676 transition split — stays green. Reverted, the same spec fails with the
27px/32px slides above, so it bites. Unit suite 135 files / 2957 tests.

One e2e case was written and then removed rather than left skipping: "an agent
page carries no rail column" asserted a premise the router does not hold —
`/workspace/a/:name` REDIRECTS into a conversation, which legitimately has a
rail. The rule it meant to check is covered in the unit spec, which does not
depend on which URL the router settles on.

Fixes #2711

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
#2711)

Review finding, reproduced: the reservation fires for any non-room, non-agent-
page route while the stage loads, but `stageZone` keys on the ROSTER — so a
caller with no rostered agents settles `empty` and a failed roster fetch settles
`failed`. In both the rail never arrives, the reservation drops, and the column
left through the 300ms width transition:

    EMPTY roster   ->  none -> reserved(48) -> reserved(22) -> reserved(3) -> none
    FAILED roster  ->  none -> reserved(48) -> reserved(10) -> reserved(1) -> none

A 48px animated shift on first run — nobody has shared an agent yet — and on
every roster error, landing beside an error state. That is the same defect this
PR removes, handed back, and it is exactly the hazard the docblock reasons about
for ROOMS without applying it to `empty` / `failed`.

The fix is not to reserve less. Narrowing to `/workspace/c/:id` would protect the
AC's route and leave the common path — the nav's `/workspace`, which redirects
into a conversation — shifting as before, because the redirect happens after the
roster lands. Instead the LEAVE is animated only when the column actually held a
rail (`railEverHeldRail`): a reservation that paid off animates as before, one
that did not is given back in a single frame. Measured after:

    EMPTY roster   ->  none -> reserved(48) -> none
    FAILED roster  ->  none -> reserved(48) -> none

and the normal load is unchanged: `reserved(48) -> rail(48)`, no shift.

The ENTER keeps its transition unconditionally — it only ever runs for the
voice-canvas swap (#2676), since a reserved column is present from the first
frame and never enters. The flag resets per route, because the question is about
THIS stage: navigating from a conversation to an empty roster must not inherit
the conversation's verdict.

Two spec arms added for the second finding — the suite measured bare
`/workspace`, not the `/workspace/c/<session>` the AC names, so it was right
only by accident of the fixture:

  * a direct load of `/workspace/c/<id>` (resolved from the sessions route)
    keeps Send within 1px;
  * an empty and a failed roster give the column back with NO intermediate
    width. The assertion is about the shape of the removal, not its speed:
    every width observed must be the full reserved width or nothing, because an
    intermediate width IS an animation frame. Negative control: forcing the old
    unconditional leave fails it with "animated away through 6,22px".

`portalVoiceLayoutMotion.spec.js` pinned the literal `leave-to-class="!w-0"`,
which is now a binding. Updated rather than deleted: the #2676 property it
protects is intact, since the canvas only ever takes the row from a rail that was
on screen, so `railEverHeldRail` is true wherever that spec cares.

Frontend unit suite 135 files / 2957 tests; the rail and model-choice e2e suites
12/12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
)

Caught by chasing a red CI run rather than dismissing it. The reset watcher
added with the reservation read `route.value.fullPath`, but `useRoute()` returns
a reactive OBJECT, not a ref — every other route read in this 2,000-line file is
the plain form, and this line was the only `route.value.` in it.

It is not a silent no-op, it is a silent THROW: Vue routes a watch-getter error
to its error handler instead of aborting setup, so the page rendered, the rail
e2e passed 12/12, the unit suite passed 2957, and the watcher was dead the whole
time while every Workspace load logged

    TypeError: Cannot read properties of undefined (reading 'fullPath')

Verified in a browser before and after: the error is on the previous build and
gone from this one. The measured rail behaviour is unchanged (`reserved(48) ->
none` on an empty or failed roster, `reserved(48) -> rail(48)` normally) because
the flag is only ever SET by the other watcher on a cold load; what was broken is
the per-route reset, which is the in-session case.

Guarded rather than just fixed: `portalRailReserve.spec.js` now fails on any
`route.value.` in the shell, and the guard was proven to bite by reintroducing
the bug (it fails) — a dead watcher is invisible to every test that does not read
the console, so the spelling is worth pinning in the one file that mixes it.

Unit suite 135 files / 2959 tests. The three workspace e2e suites CI flagged —
rail-reserved, model-choice, stick-to-bottom — 15/15 locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…urce-text-pin (#2711) — mechanical, per the merge-train note on the PR

The train (#2922's source-text ratchet + this stack) found the joint break:
the `route.value` spelling guard reads Portal.vue by text and this is a NEW
spec with no baseline entry. It is a legitimate pin — only the text proves a
spelling is absent — so it declares itself one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… rest (#2201)

Several recurring treatments fell below WCAG AA. Fixed at the tier, not per
component, and the tiers are now measured rather than asserted in prose.

**The inverted tertiary pair.** `text-gray-400 dark:text-gray-500` is below AA on
BOTH sides — 2.54:1 on white, 2.13:1 on gray-700 — and it is the contract's own
ladder written backwards: light tertiary is gray-500, dark tertiary gray-400,
which is what 890 other call sites already use. 187 occurrences across 62 files
swapped. The class COUNT per file is unchanged, so the raw-colour ratchet does
not move.

**The warm families' light text tier.** On a light surface the 600 tier fails for
success (3.30), warning (2.94), autonomous (3.19) and urgent (3.56) while the
cool families pass (danger 4.83, info 5.17, purple 5.38, primary 6.29) — so the
rule is per TIER, not per family: light text is 700 (4.92–7.90 across all nine),
dark text is 400 (4.92–9.59 on gray-800). 128 paired occurrences across 52 files.

**The Operations count badge.** White ink on a 500 solid is 2.80:1 (urgent) and
3.76:1 (danger). Now urgent-700 (5.18) and danger-600 (4.83). It is small,
high-salience and carries a number someone is meant to read, so AA-normal is the
bar rather than the 3:1 large-text allowance.

**Host telemetry percentages** are read as numbers, so they are text: the 500
tier they used measures 2.28 (success) through 3.56 (urgent) on white. Raised to
700/400. The meter separators between them are decoration and are now marked
`aria-hidden` rather than darkened — styling a dot nobody reads would be the
wrong fix — and their one class string is hoisted, which pays for the dark half
the no-data placeholder was missing: HostTelemetry raw_gray 7 -> 4.

**The mechanical half (AC 6), in two layers.** `utils/contrast.js` is pure WCAG
arithmetic — linearized, because a channel-average scan is wrong in both
directions, which is why this issue's own first numbers had to be recalculated.
`tests/unit/contrast.spec.js` drives it over the REAL Tailwind palette the tokens
alias, so a palette bump or a token remap turns red instead of silently darkening
the app below AA; it pins both ink ladders, all nine status families at both
tiers, the badge recipe, and — deliberately — the two grounds the tertiary tier
does NOT clear, so a future palette change that fixes them prompts a revisit
instead of leaving a stale prohibition in the doc.

`e2e/contrast-ratchet.spec.js` is the page-level layer: distinct failing text
treatments per page and theme against a checked-in baseline, counted per
TREATMENT rather than per node so the number does not move with how much data an
instance holds. A page with no entry is held to zero, and an improvement that is
not banked fails as a stale ceiling — the #2605 rules.

What it freezes rather than fixes, stated plainly: a long tail of `text-gray-400`
written with no `dark:` sibling. Adding the missing half at ~1,500 sites would
grow the raw-colour ratchet by ~1,500 raw classes, so the contrast guard and the
palette guard pull against each other and the way out is a semantic ink token,
not a baseline bump. That is written into the contract beside the measured
ladders.

Verified in both themes against a local instance. Reverting the sweeps grows the
ratchet on all ten page/theme pairs (light dashboard 13 -> 16, settings 6 -> 10,
dark settings 2 -> 5), so it bites. Frontend unit suite 136 files / 2973 tests.

Fixes #2201

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
@dolho dolho added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 21, 2026
@dolho

dolho commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Train green on the combined tree — 27 pass, 3 skipped by design (auto-merge, pytest (push, absolute failures), e2e report), 0 failures. pytest (base, seed 67890) was a stalled xdist worker (cancelled + rerun, passed in 12 min); regression diff clean: no new failures introduced by the batch.

What the batch gate paid for, both fixed on member branches before this run: the three-way Alembic fork (#2920/#2924/#2927 → stacked, 0065 → 0066 → 0067, pg-migrations green here) and #2922's ratchet vs two new source-text specs in the UI stack (#2781, #2785 → @source-text-pin declared).

Members merge individually after @vybe's review — this PR is never merged. Suggested order: #2922 · #2920 → #2924 → #2927 · #2778 → #2780 → … → #2787 (retarget each stack member to dev as its parent lands).

@vybe

vybe commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Superseded by the 2026-09-21 afternoon train (#2939) — closing

Your three live roots have landed:

They were re-validated against a dev that had moved six commits past this train's base, re-gated on a fresh full-suite run alongside two PRs this train did not carry (#2935 and #2756), and merged individually. Two things only that combination could show:

The nine stacked descendants (#2780→#2787, #2924→#2927) are untouched and still yours — each retargets to dev as its parent lands, per the stack you set up. #2924/#2927's 0066/0067 chain still sits correctly behind the 0065 that just merged.

Closing this so the review queue does not carry two DO NOT MERGE drafts. Nothing here was merged, which is as designed.

@vybe vybe closed this Sep 21, 2026
@vybe
vybe deleted the train/20260921-1149-dolho branch September 21, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants