Skip to content

refactor(frontend): sweep cross-cutting + chat + file-mgr + process + misc (#554) - #610

Closed
oleksandr-korin wants to merge 1 commit into
feature/554-sweep-auth-channels-viewsfrom
feature/554-sweep-misc-cross-cutting
Closed

oleksandr-korin wants to merge 1 commit into
feature/554-sweep-auth-channels-viewsfrom
feature/554-sweep-misc-cross-cutting

Conversation

@oleksandr-korin

Copy link
Copy Markdown
Contributor

Summary

Slice 7, stacked on #609. Migrates 24 files spanning the entire pool of small-and-medium domain files. Net diff: +106 / -106 — every change is a 1:1 palette alias, byte-identical CSS.

⚠️ Stacked on #609 (slices 4+5+6 + Vite proxy fix). GitHub will auto-rebase to `dev` once #609 merges.

Subdomains migrated

Chat (4 files)

  • `ChatBubble`, `ChatPanel`, `ChatInput`, `ChatHistoryDropdown` — error states, success indicators, self-task purple accent panel

File manager (4 files)

  • `FileManager` — toast notifications, delete confirmation modal, error states
  • `FileTreeNode` — search-matched highlight (file-type icons stay raw — need decorative accent palette later)
  • `FilePreview` — preview-error indicator
  • `FileSharingPanel` — Revoke button

Process domain (3 files)

  • `TrendChart` — full chart series (success/failure bars, cost bar, success-rate threshold ladder)
  • `RoleMatrix` — no-executor row + badge
  • `TemplateSelector` — category badges (business→info, devops→accent-purple, support→urgent)

Cross-cutting / modals (10 files)

  • `NavBar` — Ops critical-pulse indicator, WebSocket connected dot
  • `GitConflictModal` — warning header + all destructive (red) options
  • `ReplayTimeline` — system-agent purple panel + badge, schedule arrow, live-feed dot, success-rate ladder
  • `UnifiedActivityPanel` — running/success/fail indicators in both live row + detail modal
  • `OnboardingChecklist` — full completed-state styling (ring, bg, indicator, line-through text)
  • `CreateAgentModal` — templates-error and general error states
  • `ConfirmDialog` — danger/warning variants (icons, text, confirm buttons)
  • `AvatarGenerateModal` — error text, remove-avatar button
  • `HelpChatWidget` — error banner + retry button
  • `ResourceModal` — amber notice deferred (palette shift)

Misc (4 files)

  • `YamlEditor` — error/warning counts, banners, success checkmark
  • `EditorHelpPanel` — required-field indicator
  • `TerminalPanelContent` — restart-required notice, start-agent button
  • `TagsEditor` — error message

Deferred (consistent with prior slices)

Pattern Files affected Token gap
Primary action buttons (indigo / bg-blue-600 / hover:bg-blue-700) many `action-primary`
Selected-state styling (NavBar tabs, OperatingRoom tabs) NavBar `state-selected`
Amber notices (ResourceModal, FileSharingPanel notice, RoleMatrix amber missing-role marker) 3 accent palette expansion
File-type icon colors in `FileTreeNode` (folder yellow, video purple, audio green, image blue, pdf red) 1 decorative `accent-{color}` family
Slack / Telegram / WhatsApp brand logo colors 3 `brand-*` extensions for non-runtime brands

Verification

```
npm run check:tokens ✓ (10 tokens equivalent; all references resolve)
npm run build ✓
npm run test:e2e:smoke ✓ 7/7 passed (7.9s)
```

Test plan

  • CI `frontend-build` passes
  • CI `frontend-e2e` (`@smoke`) passes
  • Visual smoke: NavBar Ops counter, GitConflictModal (open conflict), confirmation dialogs, OnboardingChecklist completed items

Refs #554

🤖 Generated with Claude Code

… misc (#554)

Slice 7 stacked on #609. Migrates 24 files spanning the small-domain pool:

  CHAT (4 files)
    ChatBubble  — copy-success checkmark, self-task accent panel (purple)
    ChatPanel   — agent-not-running warning state, error banner
    ChatInput   — file-remove button, voice-active recording indicator
    ChatHistoryDropdown — error state

  FILE MANAGER (4 files)
    FileManager        — notification toast (success/error), no-agents warning,
                         loading error, delete button + modal + confirm action
    FileTreeNode       — search-matched row highlight (file-type icons stay raw
                         decorative; need their own accent palette later)
    FilePreview        — preview-error icon + text
    FileSharingPanel   — Revoke button

  PROCESS (3 files)
    TrendChart        — completed/failed/cost bars (chart series + legend),
                         success-rate threshold ladder
    RoleMatrix        — no-executor row + badge
    TemplateSelector  — category badges (business/devops/support → status-info /
                         accent-purple / status-urgent)

  CROSS-CUTTING / MODALS (10 files)
    NavBar               — Ops critical-pulse + high indicator, WS connected dot
    GitConflictModal     — yellow warning header (×2), all destructive (red) options
    ReplayTimeline       — system-agent purple panel + badge, schedule-marker arrow,
                            live-feed dot, activity-state success rate ladder
    UnifiedActivityPanel — running/success/fail indicators (live + modal)
    OnboardingChecklist  — completed-state styling (ring, bg, indicator, text)
    CreateAgentModal     — templates-error + general error
    ConfirmDialog        — danger/warning variant icons, text, confirm buttons
    ResourceModal        — (no migrations — amber notice, deferred)
    AvatarGenerateModal  — error text, remove-avatar button
    HelpChatWidget       — error banner + retry button

  MISC (3 files)
    YamlEditor          — error and warning banners + counts + success checkmark
    EditorHelpPanel     — required-field indicator
    TerminalPanelContent — restart-required notice, start-agent button
    TagsEditor          — error message

Net diff: 24 files, +106 / -106 (1:1 palette aliases, byte-identical CSS).

Deferred (existing pattern):
  - Indigo / blue primary action buttons (Login & elsewhere)
  - Blue selected-state (NavBar tabs, OperatingRoom tabs)
  - Amber notices (ResourceModal, RoleMatrix amber missing-role marker —
    these still use the amber palette which differs from yellow)
  - File-type icon colors in FileTreeNode (decorative, need accent-yellow /
    accent-purple-blue / etc. — folder ≠ warning, video ≠ accent, etc.)
  - Slack/Telegram/WhatsApp logo brand colors

These map to the pending `action-primary`, `state-selected`, and accent-color-
expansion tickets.

Verified locally:
  - npm run check:tokens                         passes (10 tokens valid)
  - npm run build                                passes
  - npm run test:e2e:smoke (7 tests, 7.9s)       all green

Refs #554

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@oleksandr-korin oleksandr-korin added the ui PR touches the frontend UI — triggers Playwright e2e tests label Apr 30, 2026
@oleksandr-korin
oleksandr-korin deleted the branch feature/554-sweep-auth-channels-views May 1, 2026 10:32
@oleksandr-korin
oleksandr-korin deleted the feature/554-sweep-misc-cross-cutting branch May 1, 2026 10:39
AndriiPasternak31 added a commit that referenced this pull request Sep 28, 2026
…e's Main (ent#610 PR A)

/cso finding. POST /agents/{name}/reports is gated by AuthorizedAgent, so
every human the agent is shared with can publish as it. With the D4 stamp,
one sharer could address a report to another person on the roster and mint
and touch THAT person's Main: a badge, an excerpt and a deliverable card in
their Inbox that the agent never produced.

resolve_report_session gains a required allow_main keyword (no default, so
every call site states the gate); the route passes
current_user.agent_name == name. A human publish keeps the addressee-owned
in-flight chat and otherwise stays NULL, as before #610. Mutations: forcing
allow_main=True in the route, and dropping the gate in the service, each go
red.
vybe pushed a commit that referenced this pull request Oct 1, 2026
… pane (trinity-enterprise#610 PR A) (#3054)

* docs(asks): the Workspace asks read fails loud, never empty (ent#610 PR A0)

The requirement (security.md §26.8), the endpoint note and the agent-page
flow state the new contract before the code: 503 asks_unavailable on a queue
or roster read fault, and a store that keeps the last good list.

* fix(asks): an unreadable queue or roster is 503 asks_unavailable, never [] (ent#610 PR A0)

list_asks caught every error and returned [], and _on_roster turned an
unreadable roster into "not on the roster" — both made the Workspace say
"nothing needs you" during an outage (the #2915 class). The list now raises
AsksUnavailable (strict roster mode in list only) and the route answers 503.
A clean off-roster agent is still dropped; answer_ask keeps its uniform 404.

test_ent428's unreadable-roster case pinned the old == [] and is reversed
deliberately.

* fix(workspace): a failed asks read keeps the last good list and says so (ent#610 PR A0)

fetchAsks treated every error as absence: it cleared the list and set
asksAvailable=false, blanking every PortalAsks surface and zeroing the badge
on a 5xx. Now only 404/403 are absence; any other failure sets asksFailed,
keeps the list and leaves asksAvailable alone. asksLoaded latches on the first
success and asksLoadedAt feeds a stale banner.

workspaceAsks.spec.js's "clears the list rather than showing stale asks"
pinned the old behaviour and is reversed deliberately (ent#253).

* docs(flows): the agent-page flow lists the asks-honesty tests (ent#610 PR A0)

* test(registry): catalogue the asks-honesty tests (ent#610 PR A0)

* fix(workspace): a sign-out drops the kept asks list (ent#610 PR A0)

Keeping the last good list on a failed read made it session-scoped state,
but signOut() never cleared it, so on a shared browser the next client's
first failed read showed the previous client's asks and badge. signOut now
resets asks, asksAvailable, asksLoaded, asksFailed and asksLoadedAt. A 404
also resets asksLoaded: a surface that no longer exists has no verdict.

Found by /cso and /review. Mutations (drop the sign-out reset; drop the 404
reset) each turn one spec red.

* docs(tests): the suggestions case is a guard, not a regression test (ent#610 PR A0)

It passes on the pre-fix code too, since the old list_asks returned [].
The flow doc, registry entry and docstring now say so. The flow doc also
names the sign-out reset.

* docs(requirements): the asks-honesty files, tests and sign-out reset (ent#610 PR A0)

The #2915 entry's Files and Tests lines now name the asks router, the
store, and both A0 test files. The A0 bullet also names the sign-out reset.
Found by /validate-pr.

* docs(inbox): requirement, architecture and flows for the Workspace Inbox (ent#610 PR A)

Written before the code (Rule #1): core-agent.md §5.40, workspace.md's Inbox
section, the report-landing rule in observability.md, the frontend paragraph,
the chat-state and report-publish endpoint rows, the new workspace-inbox flow
and its index row, the sidebar/deliverables/agents-at-the-centre/operating-room
flow deltas, and Inbox variants on journeys J05 and J11.

* feat(workspace): a deliverable addressed to you is an unread arrival (ent#610 PR A)

count_unread_by_session stays the only unread function and gains a second
arm over one _UNREAD_ARRIVALS fragment: a report addressed to the viewer,
stamped to a session the viewer owns, after that chat's cursor or the
account baseline. Plain equality on a lowercased bind, so arm (ii) reads
idx_agent_reports_audience (EXPLAIN pinned). The #557 fixtures gain
agent_reports. Mutations (join, addressee, >=, lower()) each go red.

* feat(reports): an addressed report always lands in a chat of its addressee (ent#610 PR A)

report_service.resolve_report_session keeps the publishing turn's chat only
if the addressee owns it, else stamps the addressee's Main (ensure_main_session)
and touches it with added=0, so a report-only Main is listed and Reset still
reads it as untouched. The router calls it; _resolve_portal_session moves with
it (Invariant #1). A report addressed to X during Y's turn no longer lands in
Y's chat, where nobody's reader showed it.

The completion writer stamps source=completion:done|failed from the same
status that picks its wording — the Inbox's outcome pill reads that, never
the body. ensure_main_session's caller list is corrected. The ent365 and ent457
tests that addressed the moved helper / the old writer signature are
retargeted. Mutations (audience=None, owner check, touch, added, marker) red.

* feat(workspace): the Inbox's pure rules, route and chat-state previews read (ent#610 PR A)

portalInbox.js holds every Inbox rule as a pure function: the bootstrap
landing target, the Action/Unread/All item builders (archived chats stay in
Unread, rooms excluded, All windowed to 30 days and bounded to 50 with the
total stated, ended asks for 7 days), item keys and the two counts. The
counts read the same sidebarThreads projection the sidebar sums, so the
pinned row and the agent rows cannot disagree; a seeded property test over
500 fleets pins came == totalUnread == sum of unreadByAgent == sum of the
Unread rows. Mutation: dropping archived chats from Unread goes red.

The store's fetchChatState({previews}) returns {state, previews} (the no-arg
call is unchanged) and markChatReadStrict rethrows so Mark all read can count
its failures. /workspace/inbox is a new route on the same shell;
WORKSPACE_ROOT is unchanged. Badge wording moves from replies to new because
deliverables now count.

* feat(workspace): anchors into a chat and a namespaced ask card (ent#610 PR A)

PortalAsks gains askIds (one ask, drawn in place once it ends) and
testidPrefix over every id it emits, the static ones included; the default
prefix keeps every existing id byte-identical. A mount test puts the Work
tab's instance next to an inbox-ask one for the same asks and asserts the id
sets are disjoint.

PortalConversation carries data-message-id on its message wrappers and honours
a one-shot ?anchor=m:<id>|d:<reportId> through useConversationAnchor: detach
stick-to-bottom, scroll the target into view and outline it, or fall back to
the bottom with a 'further up' notice; the anchor key is stripped either way.
PortalDeliverables emits loaded and marks its cards with data-report-id.
useStickToBottom gains detach(). Mutations (always-prefixed sync badge, a
static root id, a no-op detach) each go red.

* feat(workspace): chat-state carries each unread chat's latest arrival (ent#610 PR A)

GET /chat-state?previews=true adds latest {kind, id, at, excerpt, outcome,
title?, display_hint?} and first_unread_message_id to each chat with
arrivals. The read lives in the new client_portal/chat_previews.py;
db.unread_arrivals_with_latest windows the SAME _UNREAD_ARRIVALS fragment the
count groups, and the counts it produces are passed into
service.get_chat_state(unread=...) so a badge and its preview come from one
statement. service.py gains only that optional parameter.

Previews attach only to roster agents (include_owned for platform users),
the 100 most recent. The excerpt is credential-sanitised, markdown-stripped
and capped at 160 chars; outcome comes only from the platform-written
completion:* source marker, never from the body. PortalChatArrival has no
cost and no execution id. Without the flag the payload is the old shape.

Tests: property n == count, latest iff n > 0, latest.at == max; no cost key
anywhere; off-roster; the marker through the real completion writer; a body
starting **Finished** without the marker gives no outcome. Mutations (roster
filter, unread passthrough, cost, latest order, body parsing, first-unread
order, redaction) each go red.

* feat(workspace): the Inbox — Action, Unread, All, a list and a reading pane (ent#610 PR A)

PortalInbox is the container: header with a ghost Mark all read (strict
per-chat reads under allSettled, the failed count named), Action/Unread/All
tabs with counts, list plus pane, and a phone collapse with Back and Esc that
returns focus to the row. Every tab has its own honest state: a skeleton
until roster, threads and asks have loaded, LoadFailed with no data, a stale
banner over data, and empty copy only after a successful read. Tab and
selection live in ?tab=&item=.

PortalInboxList rows are buttons with a stated total; a read chat and an
ended ask stay in place until the selection changes. PortalInboxPane renders
an ask through PortalAsks (inbox-ask ids), or a chat from its first unread
message within a 50-message window plus its deliverables through
ReportRenderer with the ReportSummary fallback; a failed deliverables read is
LoadFailed, not empty (fetchSessionDeliverablesStrict). No cost or execution
id is rendered. Desktop auto-selects the first row without marking it read.
The store gains asksAbsent so an instance without asks does not skeleton
forever. Open canvas is not built: the shell cannot cheaply know.

* feat(workspace): land on the Inbox, and pin it in the sidebar (ent#610 PR A)

Portal.vue gains the Inbox stage branch after the rooms and before the
conversation, rendered only on a ready stage, so a roster error or an empty
roster still shows its own copy (the ent#253 class). bootstrap() reads the
landing target before its first await and awaits the replace inside the try,
so bare /workspace never flashes agents[0]'s chat. On the Inbox route the
active agent comes from the selected item and activeAgentName is never
written, so selecting does not mint a Main. threadsLoaded latches on the
first good session read; previews are fetched only on the Inbox route; the
sidebar filter is now sidebarThreadsOf, the one projection both counts read.

The sidebar's head badges move to a pinned PortalInboxRow (brand links to
/workspace/inbox), keeping sidebar-ask-count and adding
sidebar-unread-count, white on a 700 ground. Four source pins changed on
purpose, each with its reason in the spec. e2e: a new @smoke inbox spec;
workspace-rail-reserved opens /workspace?agent=<first agent>; the contrast
ratchet measures /workspace?new=1 against its frozen baseline and holds
/workspace/inbox at zero.

* test(workspace): the previews route keeps the principal kind and fails loud on a roster outage (ent#610 PR A)

Two route cases the build left unpinned: GET /chat-state?previews=true must
pass the caller's own principal kind to the roster read (a hard-coded kind
went green before), and an unreadable roster must be a 5xx, never a 200
with the previews silently dropped. Each mutation (hard-coded kind, a
swallowing roster read) goes red.

* docs(inbox): make the Inbox docs match what was built (ent#610 PR A)

The docs commit was written ahead of the code. Corrections: the landing
replace is the first await; selection lives in ?item= (auto-select on
desktop is never a read); the pinned row is PortalInboxRow; anchors run
through useConversationAnchor; the pane's deliverables read is strict;
excerpts are credential-sanitised and a deliverable's excerpt is its title;
a chat without a preview omits the keys; a roster outage fails the previews
read; Open canvas is deferred. The flow's Files and Tests tables now list
every file.

* fix(workspace): a read chat on All keeps what was new in the pane (ent#610 PR A)

On All a read chat stays listed, so holdSelected returned the live row and
the next refresh rebuilt it without a preview: the pane fell back to the
last five messages under the reader and Open in chat lost its m: anchor.
While the selection is unchanged the live row now carries the snapshot it
was opened with (first unread message id and latest); another selection
releases it. Mutation (return the live list unmerged) goes red in the pure
and the mount spec.

* fix(workspace): a failed previews read on the Inbox is not silent (ent#610 PR A)

refreshThreads swallowed a failed chat-state read, so on the Inbox (where
the previews ride that read, and a roster outage deliberately fails it) the
list kept stale counts and previews with no signal. A previewsFailed flag
now joins the thread verdict the Inbox renders: before any good read it is
LoadFailed and threadsLoaded does not latch; after one it is the stale
banner, cleared by the next good read. Reset on sign-out. Mutation (drop
the flag from the verdict) goes red.

* fix(reports): only the agent's own publish falls back to the addressee's Main (ent#610 PR A)

/cso finding. POST /agents/{name}/reports is gated by AuthorizedAgent, so
every human the agent is shared with can publish as it. With the D4 stamp,
one sharer could address a report to another person on the roster and mint
and touch THAT person's Main: a badge, an excerpt and a deliverable card in
their Inbox that the agent never produced.

resolve_report_session gains a required allow_main keyword (no default, so
every call site states the gate); the route passes
current_user.agent_name == name. A human publish keeps the addressee-owned
in-flight chat and otherwise stays NULL, as before #610. Mutations: forcing
allow_main=True in the route, and dropping the gate in the service, each go
red.

* fix(reports): touch the stamped chat only after the report is written (ent#610 PR A)

/review finding. resolve_report_session minted AND touched the addressee's
Main before the report insert; the touch sets last_message_at, which is what
lists a report-only Main in the sidebar, so a failed insert left an empty
Main listed. Resolving now only mints (the id is needed for the column);
the route calls the new report_service.touch_report_session (added=0,
fail-soft with a WARNING) after create_report returns. Mutation: moving the
touch back before the insert goes red.

* fix(workspace): phone Back returns focus to the row, and a deep-linked selection is held (ent#610 PR A)

Back and Esc focused the row on a tick before the navigation had shown the
list column again, so in a real browser focus() hit a display:none element
and landed on the body; jsdom ignores the hidden class, so the mount spec
passed. Back now awaits the query replace and a tick, then focuses the row,
the row now in its place when the read chat has left Unread, else the list
column (tabindex=-1). A selection restored from the URL (reload, deep link)
is now held exactly as a clicked one, so an answered ask keeps its row in
place until the selection changes (D8).

Tests record at each focus() call whether the target sat under a hidden
column. Mutations: an un-awaited replace, and no URL hold, each go red.
Verified in Chromium at 375 in both themes.

* fix(workspace): on a phone the pane's actions wrap below its title (ent#610 PR A)

At 375px Back plus Reply in chat and Open in chat left the chat title about
four characters. The header now wraps: the title keeps at least 12rem and the
two actions move to their own line; from sm up they stay on one line.
Measured in Chromium, both themes: title 261px at 375, one line at 1280.

* fix(workspace): the Inbox pane renders a deliverable's payload, not its report row (ent#610 PR A)

fetchAgentReport returns the whole report ({id, title, payload, row_meta});
the pane handed that to ReportRenderer, so ReportSummary dumped Id / Agent
name / Report type / Title instead of the content. Found on a live stack.
The pane now unwraps full?.payload exactly as PortalDeliverables does. A
mount test asserts the renderer receives the payload; reverting the unwrap
goes red.

* fix(workspace): the Inbox list does not jump when its data lands (ent#610 PR A)

The list states its total on a line above the rows, and that line appeared
only with the rows, so the list moved ~36px down when the reads settled
(measured live by the e2e arm). The line's box is now reserved in the
loading, failed and empty states too. A mount test pins the reserve in each
state; dropping it goes red. The e2e arm now skips without a roster agent,
as arm 3 does: with no roster the Inbox never renders (D9), so there is no
list to measure.

The payload test from the previous commit is in this spec file too.

* docs(inbox): give the Inbox flow the standard feature-flow sections (ent#610 PR A)

/validate-pr §3.3: the new flow lacked User Story, Entry Points, the
Frontend/Backend Layer split, Side Effects, Error Handling, Security
Considerations, a Testing status and Related Flows. Content is reorganised
under those headings with file:line anchors; the error table and the
security notes (roster scoping, no cost, the agent-only Main stamp, URL
params) state what the code does.

* fix(workspace): an ask attached to another chat keeps its answer controls (#3055)

ent#429 put the "Open the conversation" button between the ending line and
the controls, so the controls' v-else-if bound to the link: whenever the link
rendered, the controls did not. Ingestion attaches every addressed ask to
Main, so every ask read outside Main (a non-Main chat, the rail's Work tab
"Waiting on you") showed a link and no way to answer.

The link gets its own v-if, the controls become <template v-if="!isEnded">,
and a threadLink prop (default true) lets a host that already sits beside
the ask's chat drop the link.

Red first on dev: portalAskCard.mount.spec.js failed 5/7 (options, Send,
answer box, Got it, and the PortalWork case missing beside the link).
Mutation: restoring only the v-else-if turns 4/7 red.

Fixes #3055
Refs trinity-enterprise#610

* fix(ui): the ghost button's dark ink clears AA (ent#610 PR A §3g B7b)

BaseButton's ghost variant inked dark mode with action-primary-500: 3.97:1 on
gray-900 and 3.29:1 on gray-800, under the contract's AA floor for every ghost
verb (the Inbox's "Mark all read" is where the UX review measured it). The 400
tier clears both grounds: 5.95 / 4.92. Light ink (600 on white, 6.29) is
unchanged. Hover over gray-800 (ink on action-primary-500/16) measures 4.13 —
recorded in design-system.md, not fixed: the hover is transient.

Blast radius, deliberately its own commit: 22 ghost sites in 17 files, dark
mode only — BaseSelect, DeclaredMetricsTiles, FirstRunOverlay, StepClaude,
StepSharing, PortalAgentDecisions, PortalConversation, PortalFilePreview,
PortalInbox, PortalInboxPane, PortalRoom, PortalSuggestions, PortalWork,
PortalWorkCard, PlatformKeyField, SkillRunnerPanel, SystemTeardownPanel.

Red first: baseButtonGhostInk.mount.spec.js mounts the button and failed 2/3
on the pre-change primitive (500 present, 400 absent); contrast.spec.js gains
the measured pairs and records the 500 numbers it replaced.

* feat(ui): OverflowTabs gains opt-in counter colours, a badge label and tab semantics (ent#610 PR A §3g A3a/A8c/B6a)

Three fields, each off by default:
- tab.badgeVariant: success (default, the tinted pill) | urgent | primary —
  the last two solid white on the 700 tier (5.18 / 7.90:1), the Inbox's two
  counters (A3: one colour per fact).
- tab.badgeLabel: the tab's aria-label when a bare count would be read as
  "Action 21" (A8c); the badge is then aria-hidden.
- tablistLabel: role=tablist/tab, aria-selected, roving tabindex,
  Arrow/Home/End with MANUAL activation; the More trigger stays outside the
  tablist (B6a).

Default byte-identical: a scratch mount of two strips (badge + pinned +
signal + draft; dense + fixedWidth) rendered the same html before and after
(cmp). The wrapper is a functional component that returns its slot bare when
off — `<component :is="Fragment">` rendered no children at all.

Red first: overflowTabsTablist.mount.spec.js, 5 of 8 red on the pre-change
primitive (the two default guards and the unknown-variant fallback pass by
design). Mutations: arrows also click → the manual-activation case red;
badgeTone ignoring the variant → the solid-counter case red.

Not changed, for the #3056 default flip: e2e specs that address these tabs as
buttons — smoke.spec.js:43 (Needs Response), loops-panel.spec.js:76,95,
workspace-compact-header.spec.js:207,212, workspace-drafts.spec.js:157,165.

* feat(ui): BaseBadge gains an additive primary variant (ent#610 PR A §3g L1)

The Inbox list's "N new" is a per-row fact in the action-primary hue — the
same family as the sidebar's solid "came back" counter it sums into. Tinted
on the BaseBadge recipe: action-primary-700 on 100 (6.41:1) light, 300 on
500/16 (7.53:1 over gray-900) dark, both measured in contrast.spec.js.
Additive: every existing variant renders as before.

Red first: baseBadgePrimary.mount.spec.js, 2 of 3 red on the pre-change
primitive (no primary classes; the validator rejected the variant).

* fix(workspace): Inbox rows keep their place for one tab visit (ent#610 PR A §3g S1: A1, A7, A12)

One rule replaces `holdSelected`, the held-row watcher and its three writes:
`stableRows(fresh, visit, live)` in portalInbox.js, pure and O(n) via a Map.
Within one tab visit a row that leaves stays in place as a ghost (a chat
drawn read, an ask drawn ended), a poll never re-sorts, a new key goes in
before its nearest following fresh neighbour, a ghost with a new arrival
lights up in place (T3), and a deleted chat drops. A new visit starts on a
tab change (the visit is keyed by tab), a click on the active tab, and a
completed Mark all read. The list head counts live rows only and reads
"All caught up" when only ghosts remain. `resolveItem` is the single
fallback for a selection outside the rows, so an old chat never shows
"Pick something on the left".

Fixes the three review findings:
- A1: clicking an Unread row whose read landed before the route lost the
  row, the selection and focus (a 40-day-old chat had no fallback at all).
- A7: answering an ask on All jumped it to the top (ended_at re-sorted it).
- A12: a poll that read a row elsewhere removed it mid-list (CLS 0.19).

Also: on a phone the columns follow the RESOLVED item, and an open chat
deleted elsewhere goes Back (focus on the row now in its place, else the
list) instead of leaving an empty column with no Back.

Red first: the A1/A7/A12 mount tests failed on the pre-change component
(row gone; order [a2,a1,t1]; [t1,t3]). Pure specs replace the four
holdSelected tests (incl. idempotence and a 300-run order property).
Updated deliberately for T1 (ghosts live until the tab is left, not until
the selection changes): the All snapshot test, the answer-in-pane test and
the phone Back test (the read row is now still there to focus).
Mutation: stableRows ignoring the visit → 14 red; restored.

* fix(workspace): the Inbox pane caps a long run of arrivals (ent#610 PR A §3g S2 / A2)

paneWindow returned every message from the first unread one onward, so a
chat with 41 new messages rendered 40 bubbles and no "earlier" line. It now
takes the last PANE_TAIL (5) of that run, and `earlier` counts the hidden
arrivals (assistant rows) — the unit the pane's "N earlier arrivals" line
names; a hidden user message is not counted.

Red first: two portalInbox.spec.js cases (12 arrivals → m8..m12, earlier 7;
a hidden user message → earlier 1) failed on the pre-change rule. The All
snapshot mount test is updated deliberately: its 7 new messages now render
m4..m8 with "1 earlier arrival".

* fix(workspace): the Inbox's All tab waits on the chats only (ent#610 PR A §3g S3 / A11)

All's state was viewState over BOTH verdicts, so a failed asks read showed
"Couldn't load your chats" over chats that had loaded fine, and a slow asks
read held every chat behind a skeleton. All now waits on the thread verdict
alone; its ask rows merge in when the asks read lands (stableRows inserts a
new key beside its neighbour), and a failed asks read is the
`inbox-asks-stale` InlineError above the chats — "Couldn't load your asks —
the chats below are current." with no ask data yet, the stale-refresh line
otherwise, with a retry that re-reads the asks.

Red first: three mount cases failed on the pre-change container (LoadFailed
instead of the row + banner; a skeleton until the asks verdict; no asks
banner beside a stale ask).

* fix(workspace): a failed read write rolls its optimistic zero back (ent#610 PR A §3g S4)

Portal.vue's markRead zeroed a chat's badge, then called store.markChatRead,
which swallows every error — so a failed write left the chat looking read
until the next poll, and on the Inbox dropped it from Unread. markRead is now
async: optimistic zero → await markChatReadStrict → on failure restore the
count only if the entry is still the zero THIS call wrote, and resolve false.
It never rejects: of its 7 callers (Portal.vue template :336 and :1521,
:1704, :1722, :1821, :2051, :2236 at the time of the plan), :1722 and :1821
chain .then(refreshThreads), which a rejection would skip.

The pure halves live in portalUtils (optimisticRead / rollbackRead). The
guard is identity, not value, so a refresh that replaced the state or a
second read's own zero is never clobbered by a late failure; the shell
compares against toRaw(chatState.value), because a reactive ref hands back a
proxy of the entry, never the object written (the first cut failed the shell
test on exactly that).

Red first: portalReadRollback.spec.js (helpers absent) and a shell mount
case (a failed write left unread 0, and markChatReadStrict was never
called). Mutations: drop the identity guard → 2 red; drop the rollback in
the shell → 1 red; both restored.

* fix(workspace): the Inbox reads a chat only after the pane has rendered it (ent#610 PR A §3g S5 / D-3)

The Inbox marked a chat read on the CLICK, before its content loaded — the
origin of A1 — and the desktop auto-selection wrote ?item=, so a reload
turned a preview into an "open". Now:
- the pane emits `rendered(key)` once the history, the strict deliverables
  list and EVERY deliverable payload have settled successfully (a retried
  payload that lands completes it);
- the container reads when readIntent (an explicit open, or the initial
  ?item= of a thread) equals the rendered key — so a failed payload leaves
  the chat unread, with its error in the card and a "Mark read" ghost in
  the header (shown while n > 0) to read it on demand;
- the shell's markRead arrives as a function prop (it resolves true/false,
  never rejects — S4), and a false result is `inbox-pane-read-error`;
- the desktop auto-selection is a local per-tab preview, never in the URL;
  it is emitted as `update:preview`, and Portal.vue's inboxSelection is
  `?item= || preview`, so the rail still follows it (T2).

Red first: five mount cases (read waits for history AND the payload; a
failed payload stays unread with Mark read; the preview is not ?item= and
not a read; a deep link reads after render; a false write shows the pane
error) and a shell case (the preview scopes the rail) failed on the
pre-change code. Updated deliberately: the read-once test (no ?item= from
the auto-selection; markRead is a prop) and the answer test's selection
check. Mutations: rendered before payloads settle → 2 red; read on click
→ 1 red; the preview writing ?item= → 3 red; all restored.

* fix(workspace): one colour per fact for every count (ent#610 PR A §3g A3b)

The UX review found the same "needs you" count in three colours — a
success-green Inbox tab badge, the urgent-700 pinned row, and an urgent-500
agent pill at 2.80:1 (fails AA) — and "new" in primary-700 on one surface
and primary-600 on the next. One rule now: needs you is white on
status-urgent-700 (5.18:1), new is white on action-primary-700 (7.90:1).
- the Inbox tabs: badgeVariant urgent / primary (the L1 OverflowTabs field);
- PortalSidebar's agent ask pill (500 → 700) and unread pill (600 → 700,
  gains data-testid agent-unread-count);
- PortalChatRow and PortalAgentDetails unread pills (600 → 700);
- the Inbox row's "N new" is BaseBadge primary — a tinted per-row fact, not
  a counter (design-system.md: counters solid, facts tinted).
The pinned Inbox row already carried both 700s.

Red first: portalCounterColours.mount.spec.js, 5/5 red on the pre-change
components. Reversed on purpose, reason stated in each:
portalAskDiscoverability.spec.js (urgent-500 → urgent-700) and
portalUnreadLiveness.spec.js (urgent-500 / primary-600 → the 700s).
Not yet done: the live-walk check that an inactive tab's solid counter does
not read as "selected" (step down to 600 if it does, and re-verify ≥ 4.5).

* fix(workspace): Inbox counts say what they count, cap at 99+, and are named once (ent#610 PR A §3g A8 / D-1 / B6b)

- A8: the line above a tab's rows was a bare number ("21", "23") and the
  unread tab counted messages while its list counted chats. listHeadLabel
  names the unit: "21 asks" / "1 ask", "15 chats · 70 new" / "1 chat ·
  1 new", "3 chats · 2 asks" on All, "All caught up" with no live rows.
  All is still bounded until L4, so its head says "latest N of M shown".
- D-1: one cap for every Workspace counter — utils/tabTitle.js::capCount
  ('99+' above 99, '' for zero), now used by the tab title, the Inbox tabs,
  the pinned row, the sidebar pills, PortalChatRow and PortalAgentDetails
  (which was uncapped).
- A8c / B6: the Inbox tabs are a labelled tablist (tablist-label="Inbox"),
  and each counted tab is named with the full number in words
  (badgeLabel from askBadgeTitle / unreadBadgeTitle) — "Unread, 157 new you
  haven't read" behind a "99+" badge.
- B6b: the pinned sidebar row is named once (inboxRowLabel: both counts in
  words) and its two badges are aria-hidden — it read "Inbox 3 5".

Red first: pure cases for listHeadLabel / inboxRowLabel / capCount, a mount
case for the capped-and-named tab and the unit head, and a sidebar mount case
for the row's name; all red on the pre-change code. Mutation: drop the
pinned row's aria-label → 1 red; restored. The counter-colour spec now finds
tabs by role=tab (the roving tabindex made its old tabindex filter wrong).

* fix(workspace): Mark all read names what it reads and asks first (ent#610 PR A §3g A9)

"Mark all read" sat on the Action tab too, and one click cleared every chat
with no count and no undo. Now it is on Unread and All only, labelled
markAllLabel(k) — "Mark 28 chats read" — and for k > 1 it opens a
ConfirmDialog: "Mark 28 chats read?", "157 new messages across 28 chats
will be marked read. You can't undo this.", Cancel focused, the confirm a
primary button (not danger). k = 1 writes directly. Each chat goes through
the shell's markRead (S4: its zero rolls back on failure, it resolves
false); all true → the toast "Marked 28 chats read" and a new tab visit, so
the tab shows "You're all caught up"; any false → the InlineError naming
the count, and those rows keep their count.

ConfirmDialog gains an opt-in `confirmVariant` (danger | primary, default
danger — every existing dialog is unchanged).

Red first: five mount cases (Action has no button + the label; confirm
first with the copy, Cancel focus and a non-danger confirm, then the toast
and the empty copy; Cancel reads nothing; k=1 direct; a partial failure)
replace the old Mark-all test and failed on the pre-change code.
Mutations: no confirm → 3 red; success without a new visit → 1 red.

* fix(workspace): an Inbox ask row says what differs — priority, expiry, kind by shape (ent#610 PR A §3g A10)

Every Action row carried "Waiting on you" — true of all of them, so it said
nothing — while a Critical ask sat at #10 of 21 looking like the rest, and
every ask wore the same orange question mark, ended ones included.
- "Waiting on you" is gone. Priority is said only when it changes what the
  reader does: Critical (danger badge), High (urgent); medium and low say
  nothing (T9).
- Expiry within the day is said: "Expires in 18m" as a warning under an
  hour, a NEUTRAL "Expires in 5h" for 1–24h (the reason for the C1 order),
  the absolute time and zone on hover. At most two badges.
- The countdown is a 30 s clock that runs only while a pending row has an
  expiry within the day, and stops when none does.
- Kind by shape, in gray-500/400 with the kind spoken: shield-check
  (approval), question-mark-circle (question), bell (anything else). An
  ended ask is no longer orange.
One module, portalAskUrgency.js, holds the priority/expiry/kind helpers for
A10 now and C1 / E1 later.

Red first: portalAskUrgency.spec.js (module absent) and three mount cases
(badges per priority/expiry with the warning/neutral grounds and the hover
time; distinct gray icons with the spoken kind; the 30 s clock moving the
label and stopping) — all red on the pre-change row. Raw-colour baseline
unchanged and exact for every touched file (measured against the scanner's
fresh output, not rewritten).

* fix(workspace): the sidebar's meta ink clears AA in both themes (ent#610 PR A §3g B7a)

Ten text sites in PortalSidebar — the Agents header, an agent's preview and
slug, the agent-row time column, the empty-search line, the search-results
header and snippets, Starred, the date groups and "Signed in" — were a bare
text-gray-400: 2.54:1 on white (the contract: gray-400 is not text) with no
dark half. They now share one META_INK = 'text-gray-500 dark:text-gray-400'
(4.83:1 light, 6.99:1 on gray-900), so the next site cannot drift. Icons
(the search glyph, the sign-out button) keep gray-400: decoration.

Raw-colour baseline lowered in the same commit, exactly: PortalSidebar.vue
raw_gray 43 → 35 (measured by a fresh scanner run to a scratch file, not by
letting it rewrite the committed baseline); totals.raw_gray moved by the same
−8 (totals are cosmetic — the ratchet never reads them — and were already
drifted before this branch).

Red first: portalSidebarMetaInk.mount.spec.js mounts the sidebar and found
six rendered offenders on the pre-change file. Mutation: META_INK back to
bare gray-400 → red; restored. portalSidebarDateFlushRight.spec.js's pin
now allows the column's `:class="META_INK"` binding — the column is still
unconditional with the v-if inside, which is what it pins.
Not yet run: e2e contrast-ratchet on a seeded stack (the §3g acceptance,
workspace-inbox = 0 in both themes) — that is L9's live verification.

* fix(workspace): the Inbox's All tab is literally all, and says what it leaves out (ent#610 PR A §3g D-4a/D-4b, T16)

All was "chats active in the last 30 days, newest 50" — so an old chat that
was unread showed in Unread but not in All, and row 51 onward was simply
gone. ALL_WINDOW_DAYS and ALL_LIMIT are deleted:
- All = every chat in sidebarThreads (D13) of any age — an unused Main is
  already dropped by inSidebar, rooms wait for PR C — plus pending asks and
  asks that ended in the last 7 days (the server window, with the client
  guard kept for an ask that expired while listed pending);
- an empty non-Main chat is ordered by created_at;
- the footer says what is NOT held: "Answered, expired and cancelled asks
  drop off after 7 days."; "Showing your 200 most recent asks." when the
  asks read stopped at its 200-row cap (T16; a server total is #3059);
  "Rooms aren't in the Inbox yet. Open them from the sidebar." only to a
  viewer who has rooms — in tertiary ink (gray-600/300);
- the empty state is "No chats or asks yet" / "Start a chat with one of your
  agents. Its replies and asks land here." with a New chat link.
The list is not bounded by this commit's rule; the next one pages it (50 +
Show more). The "latest N shown" head suffix and totalLabel go with the cap.

Red first: pure cases (any age, created_at order, unbounded, property
Unread ⊆ All over 300 fleets) and four mount cases (an old chat + the 7-day
footer, the rooms line only with rooms, the 200 cap line, the empty state)
all failed on the pre-change rules.

* feat(workspace): long Inbox tabs page — 50 rows, then Show more (ent#610 PR A §3g SM / C4)

With All unbounded (previous commit) and Unread/Action already unbounded, a
tab of 150+ rows rendered them all. pageWindow(items, limit, selectedKey) in
portalInbox.js renders the first 50 of the stable rows; the footer says
"Showing 50 of 212" (tabular-nums) with a secondary "Show more" that adds 50
and moves focus to the first new row, and disappears once every row is
shown. The limit is a component ref reset on a tab change, never by a poll,
and the window always widens to include a selected row (a ?item= at row 72
shows 72). Consistent with the contract's Tables rule; deliberately not
virtualisation — rows vary in height, and Tab order and ghosts must hold.

Red first: pageWindow unit cases and four mount cases (120 → 50 → 100 with
focus on row 51, surviving a poll; ?item= at row 72; a tab revisit resets
to 50; Unread with 80 rows, the paging line gone once all shown), all red on
the pre-change list. Mutations: a poll resetting the window → 1 red; the
window ignoring the selection → 1 red; both restored.

* fix(workspace): the Inbox splits or stacks by its own width, not the viewport (ent#610 PR A §3g A4)

The list/pane split keyed on the VIEWPORT (`sm:` / `lg:`, and a
max-width:639px query), so between 640 and 1023px — and at 200% zoom — the
pane got whatever the list left: ~150px at 768, 64px zoomed, an answer box
reading "Yo". Now:
- inboxLayout({width, allowance, phoneViewport, prev}) (portalInbox.js):
  split at ≥ 720px of CONTAINER width (a 320 list + a 400 pane), a 384 list
  from 1100, 16px of hysteresis, the viewport as the fallback while the
  container is unmeasured, a phone viewport always stacked;
- composables/useContainerWidth.js: the live content width (ResizeObserver,
  injectable; read from the global at mount);
- the allowance: Portal.vue's inboxRailAllowance is the rail's width while
  it is NOT yet a column (open width, or the 48px strip), 0 once the column
  or its reservation exists — so a preview that brings the rail in cannot
  flip the layout it was chosen in. At 1280 with the rail open (~608px) the
  Inbox stacks, by design;
- stacked: the list takes the width, an opened row's pane replaces it with
  Back, and nothing is previewed (supersedes D12's phone-only rule);
- a flip keeps an OPENED item and moves focus to it — split → stacked the
  pane heading, stacked → split the row — only when focus was inside.

Red first: an inboxLayout table (the 720 ± 16 edges, the allowance, the
viewport fallback), mounts with a fake ResizeObserver at 700 / 1280 / 1000
+ allowance and a flip, and a shell case for the allowance — red on the
pre-change code. Mutations: allowance ignored → 2 red; no hysteresis → 2
red; both restored. e2e: workspace-inbox.spec.js gains relative-geometry
cases at 768×900 and 640×400 (pane ≥ 95% of the Inbox) — written, NOT yet
run (needs the live stack; L9).

* fix(workspace): a phone reaches the menu from the Inbox, and the drawer closes on navigation (ent#610 PR A §3g A5 / F4)

The Inbox is the Workspace's landing, and on a phone it had no way to the
sidebar drawer — a dead end (A5). It now carries an sm:hidden, 44px Menu
button (inbox-menu) that emits open-menu, which Portal.vue handles as the
conversation's does (mobileNav = true). And the drawer did not close when
the pinned Inbox row was tapped (F4): that row is a router-link, not one of
the sidebar's emits that each close it by hand, so Portal.vue now closes
the drawer on ANY route change (watch(route.fullPath)).

Red first: a mount case (the button, its label, the 44px/sm:hidden classes,
the emit) and a shell case (open-menu opens the drawer; a navigation closes
it) — both red on the pre-change code.

* fix(workspace): a phone Back after opening an Inbox row stays in the Inbox (ent#610 PR A §3g A6)

Every open used router.replace, so on a phone — where the pane is a screen
of its own — the OS / browser Back skipped the list and left the Workspace.
Stacked, an open now PUSHES; split, it still replaces (Back must not step
through every row read). The pane's Back pops the entry our open pushed —
the same step the hardware Back takes — and otherwise (a deep link, a
reload) replaces the item away, so it never walks out of the Inbox. A
routeItem watcher restores focus to the row the reader came from on every
way back (the pane's button, the hardware Back, the open chat deleted).

Red first: a phone open followed by router.back() stayed on the item
(memory history) on the pre-change code; now it lands on the list with focus
on the row. Guards (pass before and after): split Back never lands on an
earlier-opened row; the pane's Back after a deep link stays in the Inbox.
Mutation: the stacked open replacing → the A6 case red; restored. e2e: a
390×844 case (open, goBack, still /workspace/inbox, the row focused, the
Menu button ≥ 44px) — written, NOT yet run (live stack, L9).

* fix(workspace): the Inbox pane's header fits a phone and never wraps (ent#610 PR A §3g L5 stacked chrome)

At 375px the pane header wrapped Back, the title, Reply and Open in chat
onto two lines (8f5d02fac kept the title 12rem that way), under the Inbox's
own title, subtitle and tabs. The header now never wraps:
- split: [title flex-1 truncate] … [Mark read][Reply][Open in chat] — the
  volatile actions leftmost in the group, so a late arrival pushes only
  the truncating title;
- stacked: [Back][title] … [Open in chat][More ▾], with Mark read and
  Reply in More — a plain disclosure (aria-expanded, Esc closes and returns
  focus to the trigger, a pointer outside closes it);
- over a stacked pane the Inbox's title, subtitle and tabs are hidden
  (about 130px back at 390).
Supersedes 8f5d02fac's wrap.

Red first: two mount cases (the stacked header's items in order, More's
contents and Esc, the Inbox chrome hidden; the split order, the truncating
title and no flex-wrap) — red on the pre-change pane. Mutation: the Inbox
chrome kept over the pane → red; restored.

* fix(workspace): the Inbox pane reads like the chat, not a log (ent#610 PR A §3g A13)

Every message in the pane carried its own uppercase "AGENT · time" header,
even five in a row from one sender, over bare markdown. paneRuns(shown)
(portalInbox.js) groups messages into runs of one sender — a system line is
always its own run, and a gap of more than 10 minutes starts a new one —
and the pane draws one 12.5px sentence-case header per run (relative time,
the absolute on hover), in the chat's own bubbles: the user's accent bubble,
PortalAvatar + PortalAgentBubble for the agent, system lines in meta ink.
The pane body is capped at the conversation's reading width
(max-w-[var(--ws-message-max,64rem)]).

Red first: paneRuns unit cases (one run; sender change / system / > 10 min
gap; system lines apart) and a mount case (3 agent messages → 1 header, 3
PortalAgentBubbles, 1 avatar, not uppercase, the hover time, the capped
body) — red on the pre-change pane.

* feat(workspace): the Inbox pane opens the agent's canvas (ent#610 PR A §3g C10, T6)

PR A deferred "Open canvas" for want of a cheap "has a visible canvas" fact.
The rail already loads the selected agent's canvases (stores/portalRailFeeds
.canvases), so the fact is there: inboxCanvasCount({tabs, canvases, agent})
in portalInbox.js is that agent's canvas count when the Canvas tab is one
this session has, else 0. Portal.vue passes it for the selected item's agent
(`inboxCanvases`), and the pane shows "Open canvas" only when it is > 0 — on
chats and asks alike (T6): split, leftmost of the actions (it can arrive
late; only the truncating title gives way); stacked, in More. It emits
open-canvas, which the shell handles with openRailOn('canvas').

Red first: pure inboxCanvasCount cases; mount cases (split order with Open
canvas leftmost + the emit + on an ask; absent at 0; in More when stacked);
a shell case (the count follows the feed; open-canvas puts the rail on
Canvas) — all red on the pre-change code.

* fix(workspace): the Inbox's phone touch targets are 44px (ent#610 PR A §3g F6, partial)

The review measured the Inbox's phone targets at 27–36px. The pane's Back
and Mark all read now carry max-sm:min-h-11 (44px below `sm`, unchanged
above), beside the 44px Menu button A5 added. The rest — tabs, ask chips,
the drawer row — is #3056.

Red first: a mount case asserting the class on both was red on the
pre-change components. e2e: the 390×844 case now also measures the pane's
Back ≥ 44 (with the Menu button) — written, NOT yet run (live stack, L9).

* fix(reports): a human publish places no card, not even in a live turn (ent#610 PR A, /cso round 3)

GET /api/agents/{a}/executions lists every running Workspace turn, with
its id, to anyone the agent is shared with. A human sharer could quote
ANOTHER client's live turn as execution_id and address the report to
that client: resolve_report_session kept the in-flight chat before the
Main gate was consulted, so the card landed in the client's live chat —
and with the Inbox it is now an unread arrival, badge and excerpt,
presented as the agent's. Card placement was pre-existing on dev; this
PR made it count.

The gate is now the publisher, for both branches: `allow_main` becomes
`agent_publish` (still no default), and a human publish is an
operator-only report. The agent's own publish is unchanged.

Red first: test_a_human_publish_is_not_stamped_even_into_the_addressees_own_inflight_chat
(flipped from ..._still_lands_in_..., which pinned the hole as intended).
Mutation: drop `or not agent_publish` -> 3 tests red; restored from a cp.

* fix(workspace): an open reads only what THIS open rendered (ent#610 PR A §3g S5, round 3)

`renderedKey` was set on every render and never reset, so an explicit open
of a chat the pane had already rendered read it at once, on a stale verdict:
- the desktop preview (row 1 rendered, a poll brings arrivals, the reader
  clicks row 1): the new messages were marked read without being drawn;
- open a chat, open an ask (renders nothing), reopen the chat: read before
  the new load settled, so a failed reload still read it — the exact D-3
  rule S5 exists for.
open() now clears the verdict, and a pane already showing the item (no
remount) reloads it through an exposed reload().

Red first (portalInbox.mount.spec.js, "round 3"): both failed on ad25fa585
(0 fresh history reads; 2 reads). Mutations, restored from a cp: drop the
reset -> both red; drop the reload -> the preview case red.
Found by /review F1 and Codex GPT-6-Sol F2 independently.

* fix(workspace): the Inbox's layout survives the rail's entrance, and an open rail with nothing selected stays closeable (ent#610 PR A §3g A4, round 3)

Two defects in one allowance:

1. The rail column enters from width 0 over 300ms, but the allowance went
   to 0 the moment the column existed. At ~1396–1444px the Inbox measured
   ~1147 mid-animation, picked the 384 list, then fell to 1099 and picked
   320 again: a 320 → 384 → 320 flip on every load (round-3 benchmark:
   load CLS 0.0093 → 0.0529). The allowance is now the width the column
   has NOT grown into yet (`inboxRailAllowance`: target − measured), read
   off the column by `useContainerWidth`, which now follows a late-mounted
   / v-if element. Live after: list 319 from first paint at 1400/1420/1440,
   load CLS 0.0069–0.0073.

2. At 1280 with the rail OPEN and nothing selected, the Inbox stacks, a
   stacked Inbox previews nothing, and with no agent the rail has no tabs —
   so the rail, its strip and the only control that closes it vanished,
   with `open` still saved (design review F1). The column now stays at the
   open width as PortalRailPlaceholder ("Open an item to see its agent's
   work here.") with the rail's collapse control. Live: stacked + 384
   placeholder → collapse → split, preview, 48px strip; both themes.

Red first: portalInbox.spec.js inboxRailAllowance (not a function) and two
portalInboxShell mounts (the column unobserved; no placeholder). The old
"0 once its column is there" assertion is superseded on purpose. Mutations,
restored from a cp: measured forced to 0 -> the allowance mount red; the
placeholder condition forced false -> the placeholder mount red; drop the
RO disconnect -> useContainerWidth.mount.spec red (review F8, new spec).
New component rather than markup in Portal.vue, so Portal.vue's gray
ratchet does not grow.

* fix(workspace): the stacked pane header reflows, Esc in More closes only More, and the phone drawer closes on its Inbox row (ent#610 PR A §3g L5/F4/F6, round 3)

Round-3 review findings (plan-design-review F1–F4/F6/F7, qa-mobile N1–N4,
design-review F2/F3, qa-desktop N2/N4), all introduced by PR A:
- Stacked pane: the title gets a line of its own below [Back] … [Open in
  chat][More] (by `order`, so it stays first in reading order) plus a
  `title`. Live: 73 -> 343px at 375; 0 -> 163px at 390@200% (More was
  pushed off-screen — WCAG 1.4.10); no horizontal overflow.
- Open in chat, More and every More item are 44px on a phone (were 27).
- Esc with More open: focus stays on the More button, so the menu's own
  handler never ran and Esc reached the Inbox's Back. Handled on the
  wrapper now; live: menu shut, pane kept, focus on More.
- Mark read unmounts its own button; focus went to <body>. The heading
  takes it first.
- Split header reserves its row (min-h 3.25rem): an ask pane grew 45 -> 52
  when Open canvas landed late, moving the card 7px.
- The drawer's Inbox row on the landing URL changes no route, so the
  fullPath watcher never closed the drawer: the sidebar says `open-inbox`.
  Live at 375: closed.
- Mark-all confirm: ConfirmDialog gains an additive `info` variant (i in a
  circle, primary ink; danger/warning byte-identical) — a warning triangle
  over a non-destructive primary confirm read as danger.
- Phone subtitle hidden (header wrapped to 108px).

Red first: 6 mounts in portalInbox.mount.spec.js + the drawer shell mount,
each failing on the prior commit for its stated reason; new
confirmDialogVariant.mount.spec.js.

* fix(workspace): Inbox list round-3 fixes — deep links scroll and stay wide, the expiry clock never stalls, rows keep the agent's name (ent#610 PR A §3g L3/L4, round 3)

Round-3 review findings, all introduced by PR A:
- A ?item= past row 50 widened the window but never scrolled to the row
  (it sat ~6,000px below the visible list; qa-desktop N1), and the widening
  collapsed back to 50 on the next click (Codex F3, review F4). The row is
  scrolled into view once drawn (not for a click), and the widened window
  is latched for the visit. Live: row 61 in view at 1440.
- The expiry clock only moved while a row was inside the day, so an ask
  25h out never got its badge as it crossed in, and a fresh ask was
  labelled against the mount time (review F2). `now` refreshes with the
  rows, and one timer (`nextExpiryEntry`) wakes the list at the next entry.
- "Expires in 60m" for 59m30s+ (review F7): minutes floored.
- A ghost of an ask that left the fetched list kept `pending` and looked
  answerable (Codex F4): it is `unavailable`, "No longer available".
- The head's "99+ new" hid the number (qa-desktop N3): `listHeadExact` as
  its title and sr-only text.
- Agent names truncated to 1–7 chars because both spans shrank (design
  F4): the agent keeps up to 60%; the chat title truncates. 12px text in
  the new files moved onto the six-size scale (12.5px; design F7).
- Stacked, the list ran 984px wide at 1280+rail (plan-design F5): capped
  at max-w-3xl. An empty tab was a 776px "Pick something on the left"
  beside nothing (design F6): one column.
- Dark: the "Read" badge on a selected row matched the row's gray-750
  fill (design F9): an inset ring.
- OverflowTabs: a tab moved into More lost its badgeLabel name (review F5).
- Tests: the phone pane-Back pop is now pinned by Forward reopening the
  item (the replace mutation survived round 3, review F3); the split-Back
  test asserts it stays on /workspace/inbox (Codex).

Red first: 5 node (portalAskUrgency, portalInbox) + 7 mount + 1
OverflowTabs, each failing for its stated reason. Mutations, restored from a
cp: latch off, scrollIntoView off, pane Back -> replace, ghost status
dropped -> each 1 red. The existing A10 fixture moves 18 -> 18.5 min: floor
semantics, with NOW taken at module load.

* fix(workspace): round-3 small fixes — sign-out drops the kept session list, the tab title sums what the sidebar sums, and the dark primary button clears AA (ent#610 PR A, round 3)

Pre-existing on dev, surfaced by the round-3 reviewers:
- /cso #2: `lastSessions` (the #2198 last-good list fetchAllSessions returns
  on a failed refresh) outlived signOut() — the asks list's exact leak,
  fixed for asks in A0. signOut() now clears it and `sessionsFailed`.
- Codex F5: the tab title summed raw `threads`, the sidebar and the Inbox
  `sidebarThreads` (D13). It sums `sidebarThreads` now; the source guard in
  portalUnreadLiveness.spec.js moves with it on purpose.
- design review F5: BaseButton primary in dark was white on primary-500
  (4.47:1) hovering to 400 (~2.9:1). Dark uses the light pair, 600 / 700
  (6.29 / 7.90). The ghost-ink spec's "unchanged" pin for primary moves
  with it on purpose. Not changed: danger's dark 500/400 has the same shape
  (white on red-500 ≈ 3.8:1) — out of this PR's scope, noted for a follow-up.
- review F6: the optimisticRead comment claimed more than the identity
  guard does; it now states the known ≤20 s mis-rollback.

Red first: workspaceAsks.spec.js (sign-out), portalInboxShell.mount.spec.js
(title 7 vs 2), baseButtonPrimaryDark.mount.spec.js + contrast pairs.

* fix(workspace): a deliverable preview's title is redacted like its excerpt (ent#610 PR A, /cso round 3)

`latest.excerpt` was the report title through `sanitize_text`, while
`latest.title` shipped the same string raw — the redaction undone inside
one object. No UI renders `latest.title` today (and /reports serves the
title raw to the same caller), so this is consistency, not a live leak.

Red first: test_a_deliverable_title_is_redacted_like_its_excerpt.

* test(workspace): an ask attached to a chat is answerable in the Inbox pane (ent#610 PR A §3g L0, #3055)

PR A now carries #3065's fix (merged in, like A0, ahead of its landing on
dev). Pins what the sign-off hit: with a real chat_id (ingestion attaches
every addressed ask to Main) the pane's card showed "Open the conversation"
and no controls. Both the answer controls and the link now render.

The pane keeps the link on purpose — §3g L0 said to pass threadLink=false,
but in an ASK pane that link is the only way to the ask's chat until E1
(L7) adds "Open the conversation" below the card; drop it then.

Mutation: the controls back on `v-else-if` -> both tests red; restored.

* fix(workspace): sign-off round 4 — ask bodies render markdown, the pane header stops repeating the title, the Inbox list no longer stretches the page, a chat pins only its own asks (ent#610 PR A)

1. PortalAsks printed an ask's body as plain text on every surface; it now
   goes through PortalMarkdown (DOMPurify), like an agent reply.
2. The Inbox pane's ask header was "<agent> asks: <title>" above a card
   showing the same title; it is now "<agent> · <kind>" (paneHeading).
3. The Inbox list's scroll <ul> was not a containing block, so each row's
   absolute sr-only kind escaped it and stretched the document to the list's
   full height (1749px at a 1000px viewport); `relative` contains them.
4. A chat pinned all of its agent's asks above the composer with no cap, and
   seven cards crushed the conversation. It pins only this chat's asks (Main
   also takes unattached ones) in a 33vh scroll box and names the rest with an
   "Open in Work" link (splitChatAsks).

* fix(workspace): an ask shows in one place — a chat folds its asks into one closed row, Work skips the ask the Inbox pane shows, Info stops pointing at the asks (ent#610 PR A, sign-off round 5)

- A chat shows "N asks waiting on you" as a native <details> row, closed by
  default, expanding inline into the capped box. An ask answered while the
  chat is on screen stays drawn, ended, until you leave it (pinnedAskIds).
- In the Inbox, Work leaves out the ask open in the pane (PortalWork
  excludeAskIds -> PortalAsks excludeIds).
- Info omits the "Answer what this agent asked you" suggestion
  (PortalSuggestions omitSources) and its dot ignores it (infoSignalFrom); a
  tab's dot now names its meaning on hover (OverflowTabs signalTitle).

* fix(workspace): "Open in chat" lands with a fading tint and scrolls only the thread; an Inbox message's arrow replies to it (ent#610 PR A, sign-off round 6)

- The anchor highlight was a 2px ring round the whole row. It is now a soft
  action-primary tint that holds 2.4s and fades over 1s (none under reduced
  motion).
- The anchor used scrollIntoView, which also scrolls the h-screen
  overflow-hidden shell: the whole Workspace slid up under a blank strip.
  scrollWithin() moves only the thread's scrollTop; the mount spec now fails if
  scrollIntoView is called (mutation-checked: 4 red).
- The Inbox pane's agent messages trade Copy for an arrow: the chat opens at
  that message with the composer prefilled with a quote of it
  (quoteForReply, messageReplyTarget; the "Ask about it" prefill path, never a
  send). The chat itself keeps Copy.

* fix(workspace): "Show all" no longer blanks the agent names — the availability chip moves under the name (ent#610 PR A, sign-off round 7)

#2641 reserved the #2196 chip's 72px on every row once any VISIBLE row could
show one. "Show all" revealed three stopped agents, and in a ~250px sidebar
(avatar + the 56px date + that strip + two count pills) every name got 0px.
The chip now renders on the subtitle line under the name, beside the
truncating preview; nothing after the date depends on availability, so a
start/stop cannot move the row's truncation point (the #2196 no-reflow
property, now by construction). Live: names 76-81px collapsed and expanded
(were 0 expanded). The #2641/#2196 template pins are rewritten to this rule;
reservesAvailabilitySlot and its unit tests are unchanged.

* feat(workspace): reply to one message with a Codex-style chip, quoted into the agent's prompt server-side; a wider, eased landing glow (ent#610 PR A, sign-off round 8)

Reply-to:
- The Inbox pane's arrow hands the shell {sessionId, messageId, excerpt};
  the chat shows it as PortalReplyChip on top of the composer (that chat
  only), a removable tab, instead of pasting "> message" into the draft. The
  chip leaves with the text at send; the sent message keeps its label and
  Retry resends the id.
- The turn carries ONLY reply_to_message_id (PortalChatRequest, both /chat
  and /chat/stream). service.reply_context() resolves it at the router before
  anything is written: the row must be this caller's, this agent's and this
  thread's, else one uniform 422 (loud — a dropped reply is context the person
  believes they gave). The quote is built server-side from the stored row,
  capped, and rides directly before the client's text on BOTH the resumed and
  the cold message; the stored user row stays what was typed.
- Proven live: the prompt Claude Code received in agent-legal-reviewer ends
  with the "[Client Portal] The user is replying to your earlier message"
  block; a bogus id returns 422.

Highlight: one `anchor-glow` animation (style.css) — a token tint that reaches
12px past the message via a box-shadow spread (no reflow), eases in, holds and
eases out over 3.2s; a still tint under reduced motion.

* fix(workspace): the Inbox has the light/dark switch (ent#610 PR A, sign-off)

The chat and the room render the shell's PortalThemeSwitch through their
header's `header-end` slot; the Inbox header (new in PR A) had no such slot,
so the Workspace's landing was the one stage with no theme control. The Inbox
exposes the same seam and the shell fills it. The theme-switch placement guard
now counts three fills (conversation, room, Inbox). Live: the switch renders
in the Inbox header and choosing Dark sets `.dark` on <html>.

* fix(workspace): an ask's "Open the conversation" sits on its own line in the card's ink (ent#610 PR A, sign-off)

It was an inline button followed by the inline "Got it", so the two ran
together on one line; and it was action-primary-600 with no dark half on the
amber ask card — low contrast, fighting the card. It is now a block box as
wide as its words (`flex w-fit`), in the card's own ink, underlined, with a
trailing arrow; the focus ring keeps the token. Live, both themes: link
bottom 274px, "Got it" top 282px, no overlap; ink gray-100 dark / gray-900
light. One component, so every surface (chat, Work tab, Inbox) gets it.

* test(workspace): strip template comments to completion in the availability-chip guard (ent#610 PR A)

The row-layout assertion scrubbed PortalSidebar.vue's comments with one
`replace(/<!--[\s\S]*?-->/g, '')`, which leaves a `<!--` assembled from the
halves of two removed spans. Use the index walk portalComposerAlignment.spec.js
already uses: no residue, and it clears CodeQL alert #379
(js/incomplete-multi-character-sanitization) on this PR.

* fix(workspace): the Inbox tabs reserve their counts' width, so Unread and All don't slide (#3060)

OverflowTabs gains an opt-in per-tab `badgeSlot`: the badge span is drawn
from the first frame at a tabular-nums min-width, `invisible` until the count
lands, in the visible row and the measuring mirror. The dropdown's
badge/signal chain is untouched (#2794), and a tab without the field renders
exactly what it did (pinned by class string). The Inbox sets it on Action and
Unread through a shared INBOX_TAB_SHELL. Measured: Unread's left edge went
77 -> 106px when the counts landed; now 111 -> 111.

* fix(workspace): the Inbox loads on its own skeleton, in the ready Inbox's footprint (#3060)

While the stage has no verdict, the Inbox route (and bare /workspace, which
lands there) drew the conversation skeleton. PortalInboxSkeleton draws the
Inbox frame instead: its header and theme switch, the same tab strip with the
badge slots reserved, the list column at the width PortalInbox picks
(inboxLayout over the same container width and rail allowance), and the pane
block. PortalInbox's own loading arm uses the same rows and pane placeholder,
so neither hand-off moves.

useContainerWidth now reads the content width at attach instead of waiting for
the observer's first report; the Inbox used to draw one frame as a 320px split
list before stacking, on every mount under 720px.

A mount spec compares the skeleton's frame classes with the Inbox's, and e2e
arms sample every animation frame of a delayed load (container-relative, the
#2711 method). Each arm goes red with its fix reverted.

* fix(workspace): a finished background task is told to the agent as the platform's note, never as its own words (ent#610 PR A, #3054 review item 1)

The completion marker (`source=completion:done|failed`) takes the row out of
`client_portal.db._TYPED`. The comment called that inert. It is not: the row
stops being the resumed-turn cursor and is replayed into the next resumed turn.

Decided (Andrii): keep the replay. The agent's live session never saw the
background run, and this is how it learns the task finished and what it found.

What changes:
- `_context_lines` (the one renderer for both the cold replay and the
  resumed delta) tells a completion row as `[Background task report: …]`.
  Before, it read `You: **Finished** …`, under a header that said every
  line was spoken in a voice call.
- `VOICE_DELTA_HEADER` now says only `(voice)` lines were spoken.
- The "inert" comment in channel_completion_report.py and the workspace.md
  line now say what actually happens.

Tests (test_ent610_inbox.py, real SQLite, driven by the real completion writer):
- a completion row is never the resumed-turn cursor; a spoken row before it
  stays in the delta;
- a resumed turn is told the task came back (done and failed);
- the cold window carries the completion without spending a typed slot.

Red first: the two rendering tests failed on the old `You:` line. The row
inclusion already held. Mutations, each run once and restored from a scratch cp:
- cursor subquery drops `_TYPED` → 3 red
- `_TYPED` counts `completion:%` → 4 red
- renderer branch removed → 3 red

Neighbours: inbox, 2694 delta and window, ent457, ent358 and reply-to are
174 passed.

* fix(workspace): a refused reply can drop the reply and be sent again (ent#610 PR A, #3054 review item 2)

The server refuses a reply target it cannot prove, with a 422: "Remove the
reply and send again." There was no way to do that:
- `send()` clears the composer and emits `reply-done` before the send;
- the failed message keeps its `replyTo`, and Retry re-sent the same id into
  the same 422;
- the message's chip was `removable=false`.

Now the sent message's chip is removable while that message is failed.
Removing it drops `replyTo` (`dropReply`), and Retry sends the text as an
ordinary turn. A delivered message's chip stays fixed. PortalReplyChip gains
`placement` ('composer' | 'message'), so a removable chip on a message keeps
the message look, not the composer tab.

Red first: portalReplyRefused.mount.spec.js mounts the real
PortalConversation with the real chip. The flow is 422, then remove the
reply, then Retry, and the resend must succeed with
`replyToMessageId: null`. It failed with "Cannot call trigger on an empty
DOMWrapper" (no remove control).

Mutations, each run once and restored from a scratch cp:
- chip never removable → red
- `dropReply` a no-op → red
- chip alw…
vybe pushed a commit that referenced this pull request Oct 1, 2026
… L6 + L7) (#3101)

* docs(asks): the Workspace asks read fails loud, never empty (ent#610 PR A0)

The requirement (security.md §26.8), the endpoint note and the agent-page
flow state the new contract before the code: 503 asks_unavailable on a queue
or roster read fault, and a store that keeps the last good list.

* fix(asks): an unreadable queue or roster is 503 asks_unavailable, never [] (ent#610 PR A0)

list_asks caught every error and returned [], and _on_roster turned an
unreadable roster into "not on the roster" — both made the Workspace say
"nothing needs you" during an outage (the #2915 class). The list now raises
AsksUnavailable (strict roster mode in list only) and the route answers 503.
A clean off-roster agent is still dropped; answer_ask keeps its uniform 404.

test_ent428's unreadable-roster case pinned the old == [] and is reversed
deliberately.

* fix(workspace): a failed asks read keeps the last good list and says so (ent#610 PR A0)

fetchAsks treated every error as absence: it cleared the list and set
asksAvailable=false, blanking every PortalAsks surface and zeroing the badge
on a 5xx. Now only 404/403 are absence; any other failure sets asksFailed,
keeps the list and leaves asksAvailable alone. asksLoaded latches on the first
success and asksLoadedAt feeds a stale banner.

workspaceAsks.spec.js's "clears the list rather than showing stale asks"
pinned the old behaviour and is reversed deliberately (ent#253).

* docs(flows): the agent-page flow lists the asks-honesty tests (ent#610 PR A0)

* test(registry): catalogue the asks-honesty tests (ent#610 PR A0)

* fix(workspace): a sign-out drops the kept asks list (ent#610 PR A0)

Keeping the last good list on a failed read made it session-scoped state,
but signOut() never cleared it, so on a shared browser the next client's
first failed read showed the previous client's asks and badge. signOut now
resets asks, asksAvailable, asksLoaded, asksFailed and asksLoadedAt. A 404
also resets asksLoaded: a surface that no longer exists has no verdict.

Found by /cso and /review. Mutations (drop the sign-out reset; drop the 404
reset) each turn one spec red.

* docs(tests): the suggestions case is a guard, not a regression test (ent#610 PR A0)

It passes on the pre-fix code too, since the old list_asks returned [].
The flow doc, registry entry and docstring now say so. The flow doc also
names the sign-out reset.

* docs(requirements): the asks-honesty files, tests and sign-out reset (ent#610 PR A0)

The #2915 entry's Files and Tests lines now name the asks router, the
store, and both A0 test files. The A0 bullet also names the sign-out reset.
Found by /validate-pr.

* docs(inbox): requirement, architecture and flows for the Workspace Inbox (ent#610 PR A)

Written before the code (Rule #1): core-agent.md §5.40, workspace.md's Inbox
section, the report-landing rule in observability.md, the frontend paragraph,
the chat-state and report-publish endpoint rows, the new workspace-inbox flow
and its index row, the sidebar/deliverables/agents-at-the-centre/operating-room
flow deltas, and Inbox variants on journeys J05 and J11.

* feat(workspace): a deliverable addressed to you is an unread arrival (ent#610 PR A)

count_unread_by_session stays the only unread function and gains a second
arm over one _UNREAD_ARRIVALS fragment: a report addressed to the viewer,
stamped to a session the viewer owns, after that chat's cursor or the
account baseline. Plain equality on a lowercased bind, so arm (ii) reads
idx_agent_reports_audience (EXPLAIN pinned). The #557 fixtures gain
agent_reports. Mutations (join, addressee, >=, lower()) each go red.

* feat(reports): an addressed report always lands in a chat of its addressee (ent#610 PR A)

report_service.resolve_report_session keeps the publishing turn's chat only
if the addressee owns it, else stamps the addressee's Main (ensure_main_session)
and touches it with added=0, so a report-only Main is listed and Reset still
reads it as untouched. The router calls it; _resolve_portal_session moves with
it (Invariant #1). A report addressed to X during Y's turn no longer lands in
Y's chat, where nobody's reader showed it.

The completion writer stamps source=completion:done|failed from the same
status that picks its wording — the Inbox's outcome pill reads that, never
the body. ensure_main_session's caller list is corrected. The ent365 and ent457
tests that addressed the moved helper / the old writer signature are
retargeted. Mutations (audience=None, owner check, touch, added, marker) red.

* feat(workspace): the Inbox's pure rules, route and chat-state previews read (ent#610 PR A)

portalInbox.js holds every Inbox rule as a pure function: the bootstrap
landing target, the Action/Unread/All item builders (archived chats stay in
Unread, rooms excluded, All windowed to 30 days and bounded to 50 with the
total stated, ended asks for 7 days), item keys and the two counts. The
counts read the same sidebarThreads projection the sidebar sums, so the
pinned row and the agent rows cannot disagree; a seeded property test over
500 fleets pins came == totalUnread == sum of unreadByAgent == sum of the
Unread rows. Mutation: dropping archived chats from Unread goes red.

The store's fetchChatState({previews}) returns {state, previews} (the no-arg
call is unchanged) and markChatReadStrict rethrows so Mark all read can count
its failures. /workspace/inbox is a new route on the same shell;
WORKSPACE_ROOT is unchanged. Badge wording moves from replies to new because
deliverables now count.

* feat(workspace): anchors into a chat and a namespaced ask card (ent#610 PR A)

PortalAsks gains askIds (one ask, drawn in place once it ends) and
testidPrefix over every id it emits, the static ones included; the default
prefix keeps every existing id byte-identical. A mount test puts the Work
tab's instance next to an inbox-ask one for the same asks and asserts the id
sets are disjoint.

PortalConversation carries data-message-id on its message wrappers and honours
a one-shot ?anchor=m:<id>|d:<reportId> through useConversationAnchor: detach
stick-to-bottom, scroll the target into view and outline it, or fall back to
the bottom with a 'further up' notice; the anchor key is stripped either way.
PortalDeliverables emits loaded and marks its cards with data-report-id.
useStickToBottom gains detach(). Mutations (always-prefixed sync badge, a
static root id, a no-op detach) each go red.

* feat(workspace): chat-state carries each unread chat's latest arrival (ent#610 PR A)

GET /chat-state?previews=true adds latest {kind, id, at, excerpt, outcome,
title?, display_hint?} and first_unread_message_id to each chat with
arrivals. The read lives in the new client_portal/chat_previews.py;
db.unread_arrivals_with_latest windows the SAME _UNREAD_ARRIVALS fragment the
count groups, and the counts it produces are passed into
service.get_chat_state(unread=...) so a badge and its preview come from one
statement. service.py gains only that optional parameter.

Previews attach only to roster agents (include_owned for platform users),
the 100 most recent. The excerpt is credential-sanitised, markdown-stripped
and capped at 160 chars; outcome comes only from the platform-written
completion:* source marker, never from the body. PortalChatArrival has no
cost and no execution id. Without the flag the payload is the old shape.

Tests: property n == count, latest iff n > 0, latest.at == max; no cost key
anywhere; off-roster; the marker through the real completion writer; a body
starting **Finished** without the marker gives no outcome. Mutations (roster
filter, unread passthrough, cost, latest order, body parsing, first-unread
order, redaction) each go red.

* feat(workspace): the Inbox — Action, Unread, All, a list and a reading pane (ent#610 PR A)

PortalInbox is the container: header with a ghost Mark all read (strict
per-chat reads under allSettled, the failed count named), Action/Unread/All
tabs with counts, list plus pane, and a phone collapse with Back and Esc that
returns focus to the row. Every tab has its own honest state: a skeleton
until roster, threads and asks have loaded, LoadFailed with no data, a stale
banner over data, and empty copy only after a successful read. Tab and
selection live in ?tab=&item=.

PortalInboxList rows are buttons with a stated total; a read chat and an
ended ask stay in place until the selection changes. PortalInboxPane renders
an ask through PortalAsks (inbox-ask ids), or a chat from its first unread
message within a 50-message window plus its deliverables through
ReportRenderer with the ReportSummary fallback; a failed deliverables read is
LoadFailed, not empty (fetchSessionDeliverablesStrict). No cost or execution
id is rendered. Desktop auto-selects the first row without marking it read.
The store gains asksAbsent so an instance without asks does not skeleton
forever. Open canvas is not built: the shell cannot cheaply know.

* feat(workspace): land on the Inbox, and pin it in the sidebar (ent#610 PR A)

Portal.vue gains the Inbox stage branch after the rooms and before the
conversation, rendered only on a ready stage, so a roster error or an empty
roster still shows its own copy (the ent#253 class). bootstrap() reads the
landing target before its first await and awaits the replace inside the try,
so bare /workspace never flashes agents[0]'s chat. On the Inbox route the
active agent comes from the selected item and activeAgentName is never
written, so selecting does not mint a Main. threadsLoaded latches on the
first good session read; previews are fetched only on the Inbox route; the
sidebar filter is now sidebarThreadsOf, the one projection both counts read.

The sidebar's head badges move to a pinned PortalInboxRow (brand links to
/workspace/inbox), keeping sidebar-ask-count and adding
sidebar-unread-count, white on a 700 ground. Four source pins changed on
purpose, each with its reason in the spec. e2e: a new @smoke inbox spec;
workspace-rail-reserved opens /workspace?agent=<first agent>; the contrast
ratchet measures /workspace?new=1 against its frozen baseline and holds
/workspace/inbox at zero.

* test(workspace): the previews route keeps the principal kind and fails loud on a roster outage (ent#610 PR A)

Two route cases the build left unpinned: GET /chat-state?previews=true must
pass the caller's own principal kind to the roster read (a hard-coded kind
went green before), and an unreadable roster must be a 5xx, never a 200
with the previews silently dropped. Each mutation (hard-coded kind, a
swallowing roster read) goes red.

* docs(inbox): make the Inbox docs match what was built (ent#610 PR A)

The docs commit was written ahead of the code. Corrections: the landing
replace is the first await; selection lives in ?item= (auto-select on
desktop is never a read); the pinned row is PortalInboxRow; anchors run
through useConversationAnchor; the pane's deliverables read is strict;
excerpts are credential-sanitised and a deliverable's excerpt is its title;
a chat without a preview omits the keys; a roster outage fails the previews
read; Open canvas is deferred. The flow's Files and Tests tables now list
every file.

* fix(workspace): a read chat on All keeps what was new in the pane (ent#610 PR A)

On All a read chat stays listed, so holdSelected returned the live row and
the next refresh rebuilt it without a preview: the pane fell back to the
last five messages under the reader and Open in chat lost its m: anchor.
While the selection is unchanged the live row now carries the snapshot it
was opened with (first unread message id and latest); another selection
releases it. Mutation (return the live list unmerged) goes red in the pure
and the mount spec.

* fix(workspace): a failed previews read on the Inbox is not silent (ent#610 PR A)

refreshThreads swallowed a failed chat-state read, so on the Inbox (where
the previews ride that read, and a roster outage deliberately fails it) the
list kept stale counts and previews with no signal. A previewsFailed flag
now joins the thread verdict the Inbox renders: before any good read it is
LoadFailed and threadsLoaded does not latch; after one it is the stale
banner, cleared by the next good read. Reset on sign-out. Mutation (drop
the flag from the verdict) goes red.

* fix(reports): only the agent's own publish falls back to the addressee's Main (ent#610 PR A)

/cso finding. POST /agents/{name}/reports is gated by AuthorizedAgent, so
every human the agent is shared with can publish as it. With the D4 stamp,
one sharer could address a report to another person on the roster and mint
and touch THAT person's Main: a badge, an excerpt and a deliverable card in
their Inbox that the agent never produced.

resolve_report_session gains a required allow_main keyword (no default, so
every call site states the gate); the route passes
current_user.agent_name == name. A human publish keeps the addressee-owned
in-flight chat and otherwise stays NULL, as before #610. Mutations: forcing
allow_main=True in the route, and dropping the gate in the service, each go
red.

* fix(reports): touch the stamped chat only after the report is written (ent#610 PR A)

/review finding. resolve_report_session minted AND touched the addressee's
Main before the report insert; the touch sets last_message_at, which is what
lists a report-only Main in the sidebar, so a failed insert left an empty
Main listed. Resolving now only mints (the id is needed for the column);
the route calls the new report_service.touch_report_session (added=0,
fail-soft with a WARNING) after create_report returns. Mutation: moving the
touch back before the insert goes red.

* fix(workspace): phone Back returns focus to the row, and a deep-linked selection is held (ent#610 PR A)

Back and Esc focused the row on a tick before the navigation had shown the
list column again, so in a real browser focus() hit a display:none element
and landed on the body; jsdom ignores the hidden class, so the mount spec
passed. Back now awaits the query replace and a tick, then focuses the row,
the row now in its place when the read chat has left Unread, else the list
column (tabindex=-1). A selection restored from the URL (reload, deep link)
is now held exactly as a clicked one, so an answered ask keeps its row in
place until the selection changes (D8).

Tests record at each focus() call whether the target sat under a hidden
column. Mutations: an un-awaited replace, and no URL hold, each go red.
Verified in Chromium at 375 in both themes.

* fix(workspace): on a phone the pane's actions wrap below its title (ent#610 PR A)

At 375px Back plus Reply in chat and Open in chat left the chat title about
four characters. The header now wraps: the title keeps at least 12rem and the
two actions move to their own line; from sm up they stay on one line.
Measured in Chromium, both themes: title 261px at 375, one line at 1280.

* fix(workspace): the Inbox pane renders a deliverable's payload, not its report row (ent#610 PR A)

fetchAgentReport returns the whole report ({id, title, payload, row_meta});
the pane handed that to ReportRenderer, so ReportSummary dumped Id / Agent
name / Report type / Title instead of the content. Found on a live stack.
The pane now unwraps full?.payload exactly as PortalDeliverables does. A
mount test asserts the renderer receives the payload; reverting the unwrap
goes red.

* fix(workspace): the Inbox list does not jump when its data lands (ent#610 PR A)

The list states its total on a line above the rows, and that line appeared
only with the rows, so the list moved ~36px down when the reads settled
(measured live by the e2e arm). The line's box is now reserved in the
loading, failed and empty states too. A mount test pins the reserve in each
state; dropping it goes red. The e2e arm now skips without a roster agent,
as arm 3 does: with no roster the Inbox never renders (D9), so there is no
list to measure.

The payload test from the previous commit is in this spec file too.

* docs(inbox): give the Inbox flow the standard feature-flow sections (ent#610 PR A)

/validate-pr §3.3: the new flow lacked User Story, Entry Points, the
Frontend/Backend Layer split, Side Effects, Error Handling, Security
Considerations, a Testing status and Related Flows. Content is reorganised
under those headings with file:line anchors; the error table and the
security notes (roster scoping, no cost, the agent-only Main stamp, URL
params) state what the code does.

* fix(workspace): an ask attached to another chat keeps its answer controls (#3055)

ent#429 put the "Open the conversation" button between the ending line and
the controls, so the controls' v-else-if bound to the link: whenever the link
rendered, the controls did not. Ingestion attaches every addressed ask to
Main, so every ask read outside Main (a non-Main chat, the rail's Work tab
"Waiting on you") showed a link and no way to answer.

The link gets its own v-if, the controls become <template v-if="!isEnded">,
and a threadLink prop (default true) lets a host that already sits beside
the ask's chat drop the link.

Red first on dev: portalAskCard.mount.spec.js failed 5/7 (options, Send,
answer box, Got it, and the PortalWork case missing beside the link).
Mutation: restoring only the v-else-if turns 4/7 red.

Fixes #3055
Refs trinity-enterprise#610

* fix(ui): the ghost button's dark ink clears AA (ent#610 PR A §3g B7b)

BaseButton's ghost variant inked dark mode with action-primary-500: 3.97:1 on
gray-900 and 3.29:1 on gray-800, under the contract's AA floor for every ghost
verb (the Inbox's "Mark all read" is where the UX review measured it). The 400
tier clears both grounds: 5.95 / 4.92. Light ink (600 on white, 6.29) is
unchanged. Hover over gray-800 (ink on action-primary-500/16) measures 4.13 —
recorded in design-system.md, not fixed: the hover is transient.

Blast radius, deliberately its own commit: 22 ghost sites in 17 files, dark
mode only — BaseSelect, DeclaredMetricsTiles, FirstRunOverlay, StepClaude,
StepSharing, PortalAgentDecisions, PortalConversation, PortalFilePreview,
PortalInbox, PortalInboxPane, PortalRoom, PortalSuggestions, PortalWork,
PortalWorkCard, PlatformKeyField, SkillRunnerPanel, SystemTeardownPanel.

Red first: baseButtonGhostInk.mount.spec.js mounts the button and failed 2/3
on the pre-change primitive (500 present, 400 absent); contrast.spec.js gains
the measured pairs and records the 500 numbers it replaced.

* feat(ui): OverflowTabs gains opt-in counter colours, a badge label and tab semantics (ent#610 PR A §3g A3a/A8c/B6a)

Three fields, each off by default:
- tab.badgeVariant: success (default, the tinted pill) | urgent | primary —
  the last two solid white on the 700 tier (5.18 / 7.90:1), the Inbox's two
  counters (A3: one colour per fact).
- tab.badgeLabel: the tab's aria-label when a bare count would be read as
  "Action 21" (A8c); the badge is then aria-hidden.
- tablistLabel: role=tablist/tab, aria-selected, roving tabindex,
  Arrow/Home/End with MANUAL activation; the More trigger stays outside the
  tablist (B6a).

Default byte-identical: a scratch mount of two strips (badge + pinned +
signal + draft; dense + fixedWidth) rendered the same html before and after
(cmp). The wrapper is a functional component that returns its slot bare when
off — `<component :is="Fragment">` rendered no children at all.

Red first: overflowTabsTablist.mount.spec.js, 5 of 8 red on the pre-change
primitive (the two default guards and the unknown-variant fallback pass by
design). Mutations: arrows also click → the manual-activation case red;
badgeTone ignoring the variant → the solid-counter case red.

Not changed, for the #3056 default flip: e2e specs that address these tabs as
buttons — smoke.spec.js:43 (Needs Response), loops-panel.spec.js:76,95,
workspace-compact-header.spec.js:207,212, workspace-drafts.spec.js:157,165.

* feat(ui): BaseBadge gains an additive primary variant (ent#610 PR A §3g L1)

The Inbox list's "N new" is a per-row fact in the action-primary hue — the
same family as the sidebar's solid "came back" counter it sums into. Tinted
on the BaseBadge recipe: action-primary-700 on 100 (6.41:1) light, 300 on
500/16 (7.53:1 over gray-900) dark, both measured in contrast.spec.js.
Additive: every existing variant renders as before.

Red first: baseBadgePrimary.mount.spec.js, 2 of 3 red on the pre-change
primitive (no primary classes; the validator rejected the variant).

* fix(workspace): Inbox rows keep their place for one tab visit (ent#610 PR A §3g S1: A1, A7, A12)

One rule replaces `holdSelected`, the held-row watcher and its three writes:
`stableRows(fresh, visit, live)` in portalInbox.js, pure and O(n) via a Map.
Within one tab visit a row that leaves stays in place as a ghost (a chat
drawn read, an ask drawn ended), a poll never re-sorts, a new key goes in
before its nearest following fresh neighbour, a ghost with a new arrival
lights up in place (T3), and a deleted chat drops. A new visit starts on a
tab change (the visit is keyed by tab), a click on the active tab, and a
completed Mark all read. The list head counts live rows only and reads
"All caught up" when only ghosts remain. `resolveItem` is the single
fallback for a selection outside the rows, so an old chat never shows
"Pick something on the left".

Fixes the three review findings:
- A1: clicking an Unread row whose read landed before the route lost the
  row, the selection and focus (a 40-day-old chat had no fallback at all).
- A7: answering an ask on All jumped it to the top (ended_at re-sorted it).
- A12: a poll that read a row elsewhere removed it mid-list (CLS 0.19).

Also: on a phone the columns follow the RESOLVED item, and an open chat
deleted elsewhere goes Back (focus on the row now in its place, else the
list) instead of leaving an empty column with no Back.

Red first: the A1/A7/A12 mount tests failed on the pre-change component
(row gone; order [a2,a1,t1]; [t1,t3]). Pure specs replace the four
holdSelected tests (incl. idempotence and a 300-run order property).
Updated deliberately for T1 (ghosts live until the tab is left, not until
the selection changes): the All snapshot test, the answer-in-pane test and
the phone Back test (the read row is now still there to focus).
Mutation: stableRows ignoring the visit → 14 red; restored.

* fix(workspace): the Inbox pane caps a long run of arrivals (ent#610 PR A §3g S2 / A2)

paneWindow returned every message from the first unread one onward, so a
chat with 41 new messages rendered 40 bubbles and no "earlier" line. It now
takes the last PANE_TAIL (5) of that run, and `earlier` counts the hidden
arrivals (assistant rows) — the unit the pane's "N earlier arrivals" line
names; a hidden user message is not counted.

Red first: two portalInbox.spec.js cases (12 arrivals → m8..m12, earlier 7;
a hidden user message → earlier 1) failed on the pre-change rule. The All
snapshot mount test is updated deliberately: its 7 new messages now render
m4..m8 with "1 earlier arrival".

* fix(workspace): the Inbox's All tab waits on the chats only (ent#610 PR A §3g S3 / A11)

All's state was viewState over BOTH verdicts, so a failed asks read showed
"Couldn't load your chats" over chats that had loaded fine, and a slow asks
read held every chat behind a skeleton. All now waits on the thread verdict
alone; its ask rows merge in when the asks read lands (stableRows inserts a
new key beside its neighbour), and a failed asks read is the
`inbox-asks-stale` InlineError above the chats — "Couldn't load your asks —
the chats below are current." with no ask data yet, the stale-refresh line
otherwise, with a retry that re-reads the asks.

Red first: three mount cases failed on the pre-change container (LoadFailed
instead of the row + banner; a skeleton until the asks verdict; no asks
banner beside a stale ask).

* fix(workspace): a failed read write rolls its optimistic zero back (ent#610 PR A §3g S4)

Portal.vue's markRead zeroed a chat's badge, then called store.markChatRead,
which swallows every error — so a failed write left the chat looking read
until the next poll, and on the Inbox dropped it from Unread. markRead is now
async: optimistic zero → await markChatReadStrict → on failure restore the
count only if the entry is still the zero THIS call wrote, and resolve false.
It never rejects: of its 7 callers (Portal.vue template :336 and :1521,
:1704, :1722, :1821, :2051, :2236 at the time of the plan), :1722 and :1821
chain .then(refreshThreads), which a rejection would skip.

The pure halves live in portalUtils (optimisticRead / rollbackRead). The
guard is identity, not value, so a refresh that replaced the state or a
second read's own zero is never clobbered by a late failure; the shell
compares against toRaw(chatState.value), because a reactive ref hands back a
proxy of the entry, never the object written (the first cut failed the shell
test on exactly that).

Red first: portalReadRollback.spec.js (helpers absent) and a shell mount
case (a failed write left unread 0, and markChatReadStrict was never
called). Mutations: drop the identity guard → 2 red; drop the rollback in
the shell → 1 red; both restored.

* fix(workspace): the Inbox reads a chat only after the pane has rendered it (ent#610 PR A §3g S5 / D-3)

The Inbox marked a chat read on the CLICK, before its content loaded — the
origin of A1 — and the desktop auto-selection wrote ?item=, so a reload
turned a preview into an "open". Now:
- the pane emits `rendered(key)` once the history, the strict deliverables
  list and EVERY deliverable payload have settled successfully (a retried
  payload that lands completes it);
- the container reads when readIntent (an explicit open, or the initial
  ?item= of a thread) equals the rendered key — so a failed payload leaves
  the chat unread, with its error in the card and a "Mark read" ghost in
  the header (shown while n > 0) to read it on demand;
- the shell's markRead arrives as a function prop (it resolves true/false,
  never rejects — S4), and a false result is `inbox-pane-read-error`;
- the desktop auto-selection is a local per-tab preview, never in the URL;
  it is emitted as `update:preview`, and Portal.vue's inboxSelection is
  `?item= || preview`, so the rail still follows it (T2).

Red first: five mount cases (read waits for history AND the payload; a
failed payload stays unread with Mark read; the preview is not ?item= and
not a read; a deep link reads after render; a false write shows the pane
error) and a shell case (the preview scopes the rail) failed on the
pre-change code. Updated deliberately: the read-once test (no ?item= from
the auto-selection; markRead is a prop) and the answer test's selection
check. Mutations: rendered before payloads settle → 2 red; read on click
→ 1 red; the preview writing ?item= → 3 red; all restored.

* fix(workspace): one colour per fact for every count (ent#610 PR A §3g A3b)

The UX review found the same "needs you" count in three colours — a
success-green Inbox tab badge, the urgent-700 pinned row, and an urgent-500
agent pill at 2.80:1 (fails AA) — and "new" in primary-700 on one surface
and primary-600 on the next. One rule now: needs you is white on
status-urgent-700 (5.18:1), new is white on action-primary-700 (7.90:1).
- the Inbox tabs: badgeVariant urgent / primary (the L1 OverflowTabs field);
- PortalSidebar's agent ask pill (500 → 700) and unread pill (600 → 700,
  gains data-testid agent-unread-count);
- PortalChatRow and PortalAgentDetails unread pills (600 → 700);
- the Inbox row's "N new" is BaseBadge primary — a tinted per-row fact, not
  a counter (design-system.md: counters solid, facts tinted).
The pinned Inbox row already carried both 700s.

Red first: portalCounterColours.mount.spec.js, 5/5 red on the pre-change
components. Reversed on purpose, reason stated in each:
portalAskDiscoverability.spec.js (urgent-500 → urgent-700) and
portalUnreadLiveness.spec.js (urgent-500 / primary-600 → the 700s).
Not yet done: the live-walk check that an inactive tab's solid counter does
not read as "selected" (step down to 600 if it does, and re-verify ≥ 4.5).

* fix(workspace): Inbox counts say what they count, cap at 99+, and are named once (ent#610 PR A §3g A8 / D-1 / B6b)

- A8: the line above a tab's rows was a bare number ("21", "23") and the
  unread tab counted messages while its list counted chats. listHeadLabel
  names the unit: "21 asks" / "1 ask", "15 chats · 70 new" / "1 chat ·
  1 new", "3 chats · 2 asks" on All, "All caught up" with no live rows.
  All is still bounded until L4, so its head says "latest N of M shown".
- D-1: one cap for every Workspace counter — utils/tabTitle.js::capCount
  ('99+' above 99, '' for zero), now used by the tab title, the Inbox tabs,
  the pinned row, the sidebar pills, PortalChatRow and PortalAgentDetails
  (which was uncapped).
- A8c / B6: the Inbox tabs are a labelled tablist (tablist-label="Inbox"),
  and each counted tab is named with the full number in words
  (badgeLabel from askBadgeTitle / unreadBadgeTitle) — "Unread, 157 new you
  haven't read" behind a "99+" badge.
- B6b: the pinned sidebar row is named once (inboxRowLabel: both counts in
  words) and its two badges are aria-hidden — it read "Inbox 3 5".

Red first: pure cases for listHeadLabel / inboxRowLabel / capCount, a mount
case for the capped-and-named tab and the unit head, and a sidebar mount case
for the row's name; all red on the pre-change code. Mutation: drop the
pinned row's aria-label → 1 red; restored. The counter-colour spec now finds
tabs by role=tab (the roving tabindex made its old tabindex filter wrong).

* fix(workspace): Mark all read names what it reads and asks first (ent#610 PR A §3g A9)

"Mark all read" sat on the Action tab too, and one click cleared every chat
with no count and no undo. Now it is on Unread and All only, labelled
markAllLabel(k) — "Mark 28 chats read" — and for k > 1 it opens a
ConfirmDialog: "Mark 28 chats read?", "157 new messages across 28 chats
will be marked read. You can't undo this.", Cancel focused, the confirm a
primary button (not danger). k = 1 writes directly. Each chat goes through
the shell's markRead (S4: its zero rolls back on failure, it resolves
false); all true → the toast "Marked 28 chats read" and a new tab visit, so
the tab shows "You're all caught up"; any false → the InlineError naming
the count, and those rows keep their count.

ConfirmDialog gains an opt-in `confirmVariant` (danger | primary, default
danger — every existing dialog is unchanged).

Red first: five mount cases (Action has no button + the label; confirm
first with the copy, Cancel focus and a non-danger confirm, then the toast
and the empty copy; Cancel reads nothing; k=1 direct; a partial failure)
replace the old Mark-all test and failed on the pre-change code.
Mutations: no confirm → 3 red; success without a new visit → 1 red.

* fix(workspace): an Inbox ask row says what differs — priority, expiry, kind by shape (ent#610 PR A §3g A10)

Every Action row carried "Waiting on you" — true of all of them, so it said
nothing — while a Critical ask sat at #10 of 21 looking like the rest, and
every ask wore the same orange question mark, ended ones included.
- "Waiting on you" is gone. Priority is said only when it changes what the
  reader does: Critical (danger badge), High (urgent); medium and low say
  nothing (T9).
- Expiry within the day is said: "Expires in 18m" as a warning under an
  hour, a NEUTRAL "Expires in 5h" for 1–24h (the reason for the C1 order),
  the absolute time and zone on hover. At most two badges.
- The countdown is a 30 s clock that runs only while a pending row has an
  expiry within the day, and stops when none does.
- Kind by shape, in gray-500/400 with the kind spoken: shield-check
  (approval), question-mark-circle (question), bell (anything else). An
  ended ask is no longer orange.
One module, portalAskUrgency.js, holds the priority/expiry/kind helpers for
A10 now and C1 / E1 later.

Red first: portalAskUrgency.spec.js (module absent) and three mount cases
(badges per priority/expiry with the warning/neutral grounds and the hover
time; distinct gray icons with the spoken kind; the 30 s clock moving the
label and stopping) — all red on the pre-change row. Raw-colour baseline
unchanged and exact for every touched file (measured against the scanner's
fresh output, not rewritten).

* fix(workspace): the sidebar's meta ink clears AA in both themes (ent#610 PR A §3g B7a)

Ten text sites in PortalSidebar — the Agents header, an agent's preview and
slug, the agent-row time column, the empty-search line, the search-results
header and snippets, Starred, the date groups and "Signed in" — were a bare
text-gray-400: 2.54:1 on white (the contract: gray-400 is not text) with no
dark half. They now share one META_INK = 'text-gray-500 dark:text-gray-400'
(4.83:1 light, 6.99:1 on gray-900), so the next site cannot drift. Icons
(the search glyph, the sign-out button) keep gray-400: decoration.

Raw-colour baseline lowered in the same commit, exactly: PortalSidebar.vue
raw_gray 43 → 35 (measured by a fresh scanner run to a scratch file, not by
letting it rewrite the committed baseline); totals.raw_gray moved by the same
−8 (totals are cosmetic — the ratchet never reads them — and were already
drifted before this branch).

Red first: portalSidebarMetaInk.mount.spec.js mounts the sidebar and found
six rendered offenders on the pre-change file. Mutation: META_INK back to
bare gray-400 → red; restored. portalSidebarDateFlushRight.spec.js's pin
now allows the column's `:class="META_INK"` binding — the column is still
unconditional with the v-if inside, which is what it pins.
Not yet run: e2e contrast-ratchet on a seeded stack (the §3g acceptance,
workspace-inbox = 0 in both themes) — that is L9's live verification.

* fix(workspace): the Inbox's All tab is literally all, and says what it leaves out (ent#610 PR A §3g D-4a/D-4b, T16)

All was "chats active in the last 30 days, newest 50" — so an old chat that
was unread showed in Unread but not in All, and row 51 onward was simply
gone. ALL_WINDOW_DAYS and ALL_LIMIT are deleted:
- All = every chat in sidebarThreads (D13) of any age — an unused Main is
  already dropped by inSidebar, rooms wait for PR C — plus pending asks and
  asks that ended in the last 7 days (the server window, with the client
  guard kept for an ask that expired while listed pending);
- an empty non-Main chat is ordered by created_at;
- the footer says what is NOT held: "Answered, expired and cancelled asks
  drop off after 7 days."; "Showing your 200 most recent asks." when the
  asks read stopped at its 200-row cap (T16; a server total is #3059);
  "Rooms aren't in the Inbox yet. Open them from the sidebar." only to a
  viewer who has rooms — in tertiary ink (gray-600/300);
- the empty state is "No chats or asks yet" / "Start a chat with one of your
  agents. Its replies and asks land here." with a New chat link.
The list is not bounded by this commit's rule; the next one pages it (50 +
Show more). The "latest N shown" head suffix and totalLabel go with the cap.

Red first: pure cases (any age, created_at order, unbounded, property
Unread ⊆ All over 300 fleets) and four mount cases (an old chat + the 7-day
footer, the rooms line only with rooms, the 200 cap line, the empty state)
all failed on the pre-change rules.

* feat(workspace): long Inbox tabs page — 50 rows, then Show more (ent#610 PR A §3g SM / C4)

With All unbounded (previous commit) and Unread/Action already unbounded, a
tab of 150+ rows rendered them all. pageWindow(items, limit, selectedKey) in
portalInbox.js renders the first 50 of the stable rows; the footer says
"Showing 50 of 212" (tabular-nums) with a secondary "Show more" that adds 50
and moves focus to the first new row, and disappears once every row is
shown. The limit is a component ref reset on a tab change, never by a poll,
and the window always widens to include a selected row (a ?item= at row 72
shows 72). Consistent with the contract's Tables rule; deliberately not
virtualisation — rows vary in height, and Tab order and ghosts must hold.

Red first: pageWindow unit cases and four mount cases (120 → 50 → 100 with
focus on row 51, surviving a poll; ?item= at row 72; a tab revisit resets
to 50; Unread with 80 rows, the paging line gone once all shown), all red on
the pre-change list. Mutations: a poll resetting the window → 1 red; the
window ignoring the selection → 1 red; both restored.

* fix(workspace): the Inbox splits or stacks by its own width, not the viewport (ent#610 PR A §3g A4)

The list/pane split keyed on the VIEWPORT (`sm:` / `lg:`, and a
max-width:639px query), so between 640 and 1023px — and at 200% zoom — the
pane got whatever the list left: ~150px at 768, 64px zoomed, an answer box
reading "Yo". Now:
- inboxLayout({width, allowance, phoneViewport, prev}) (portalInbox.js):
  split at ≥ 720px of CONTAINER width (a 320 list + a 400 pane), a 384 list
  from 1100, 16px of hysteresis, the viewport as the fallback while the
  container is unmeasured, a phone viewport always stacked;
- composables/useContainerWidth.js: the live content width (ResizeObserver,
  injectable; read from the global at mount);
- the allowance: Portal.vue's inboxRailAllowance is the rail's width while
  it is NOT yet a column (open width, or the 48px strip), 0 once the column
  or its reservation exists — so a preview that brings the rail in cannot
  flip the layout it was chosen in. At 1280 with the rail open (~608px) the
  Inbox stacks, by design;
- stacked: the list takes the width, an opened row's pane replaces it with
  Back, and nothing is previewed (supersedes D12's phone-only rule);
- a flip keeps an OPENED item and moves focus to it — split → stacked the
  pane heading, stacked → split the row — only when focus was inside.

Red first: an inboxLayout table (the 720 ± 16 edges, the allowance, the
viewport fallback), mounts with a fake ResizeObserver at 700 / 1280 / 1000
+ allowance and a flip, and a shell case for the allowance — red on the
pre-change code. Mutations: allowance ignored → 2 red; no hysteresis → 2
red; both restored. e2e: workspace-inbox.spec.js gains relative-geometry
cases at 768×900 and 640×400 (pane ≥ 95% of the Inbox) — written, NOT yet
run (needs the live stack; L9).

* fix(workspace): a phone reaches the menu from the Inbox, and the drawer closes on navigation (ent#610 PR A §3g A5 / F4)

The Inbox is the Workspace's landing, and on a phone it had no way to the
sidebar drawer — a dead end (A5). It now carries an sm:hidden, 44px Menu
button (inbox-menu) that emits open-menu, which Portal.vue handles as the
conversation's does (mobileNav = true). And the drawer did not close when
the pinned Inbox row was tapped (F4): that row is a router-link, not one of
the sidebar's emits that each close it by hand, so Portal.vue now closes
the drawer on ANY route change (watch(route.fullPath)).

Red first: a mount case (the button, its label, the 44px/sm:hidden classes,
the emit) and a shell case (open-menu opens the drawer; a navigation closes
it) — both red on the pre-change code.

* fix(workspace): a phone Back after opening an Inbox row stays in the Inbox (ent#610 PR A §3g A6)

Every open used router.replace, so on a phone — where the pane is a screen
of its own — the OS / browser Back skipped the list and left the Workspace.
Stacked, an open now PUSHES; split, it still replaces (Back must not step
through every row read). The pane's Back pops the entry our open pushed —
the same step the hardware Back takes — and otherwise (a deep link, a
reload) replaces the item away, so it never walks out of the Inbox. A
routeItem watcher restores focus to the row the reader came from on every
way back (the pane's button, the hardware Back, the open chat deleted).

Red first: a phone open followed by router.back() stayed on the item
(memory history) on the pre-change code; now it lands on the list with focus
on the row. Guards (pass before and after): split Back never lands on an
earlier-opened row; the pane's Back after a deep link stays in the Inbox.
Mutation: the stacked open replacing → the A6 case red; restored. e2e: a
390×844 case (open, goBack, still /workspace/inbox, the row focused, the
Menu button ≥ 44px) — written, NOT yet run (live stack, L9).

* fix(workspace): the Inbox pane's header fits a phone and never wraps (ent#610 PR A §3g L5 stacked chrome)

At 375px the pane header wrapped Back, the title, Reply and Open in chat
onto two lines (8f5d02fac kept the title 12rem that way), under the Inbox's
own title, subtitle and tabs. The header now never wraps:
- split: [title flex-1 truncate] … [Mark read][Reply][Open in chat] — the
  volatile actions leftmost in the group, so a late arrival pushes only
  the truncating title;
- stacked: [Back][title] … [Open in chat][More ▾], with Mark read and
  Reply in More — a plain disclosure (aria-expanded, Esc closes and returns
  focus to the trigger, a pointer outside closes it);
- over a stacked pane the Inbox's title, subtitle and tabs are hidden
  (about 130px back at 390).
Supersedes 8f5d02fac's wrap.

Red first: two mount cases (the stacked header's items in order, More's
contents and Esc, the Inbox chrome hidden; the split order, the truncating
title and no flex-wrap) — red on the pre-change pane. Mutation: the Inbox
chrome kept over the pane → red; restored.

* fix(workspace): the Inbox pane reads like the chat, not a log (ent#610 PR A §3g A13)

Every message in the pane carried its own uppercase "AGENT · time" header,
even five in a row from one sender, over bare markdown. paneRuns(shown)
(portalInbox.js) groups messages into runs of one sender — a system line is
always its own run, and a gap of more than 10 minutes starts a new one —
and the pane draws one 12.5px sentence-case header per run (relative time,
the absolute on hover), in the chat's own bubbles: the user's accent bubble,
PortalAvatar + PortalAgentBubble for the agent, system lines in meta ink.
The pane body is capped at the conversation's reading width
(max-w-[var(--ws-message-max,64rem)]).

Red first: paneRuns unit cases (one run; sender change / system / > 10 min
gap; system lines apart) and a mount case (3 agent messages → 1 header, 3
PortalAgentBubbles, 1 avatar, not uppercase, the hover time, the capped
body) — red on the pre-change pane.

* feat(workspace): the Inbox pane opens the agent's canvas (ent#610 PR A §3g C10, T6)

PR A deferred "Open canvas" for want of a cheap "has a visible canvas" fact.
The rail already loads the selected agent's canvases (stores/portalRailFeeds
.canvases), so the fact is there: inboxCanvasCount({tabs, canvases, agent})
in portalInbox.js is that agent's canvas count when the Canvas tab is one
this session has, else 0. Portal.vue passes it for the selected item's agent
(`inboxCanvases`), and the pane shows "Open canvas" only when it is > 0 — on
chats and asks alike (T6): split, leftmost of the actions (it can arrive
late; only the truncating title gives way); stacked, in More. It emits
open-canvas, which the shell handles with openRailOn('canvas').

Red first: pure inboxCanvasCount cases; mount cases (split order with Open
canvas leftmost + the emit + on an ask; absent at 0; in More when stacked);
a shell case (the count follows the feed; open-canvas puts the rail on
Canvas) — all red on the pre-change code.

* fix(workspace): the Inbox's phone touch targets are 44px (ent#610 PR A §3g F6, partial)

The review measured the Inbox's phone targets at 27–36px. The pane's Back
and Mark all read now carry max-sm:min-h-11 (44px below `sm`, unchanged
above), beside the 44px Menu button A5 added. The rest — tabs, ask chips,
the drawer row — is #3056.

Red first: a mount case asserting the class on both was red on the
pre-change components. e2e: the 390×844 case now also measures the pane's
Back ≥ 44 (with the Menu button) — written, NOT yet run (live stack, L9).

* fix(reports): a human publish places no card, not even in a live turn (ent#610 PR A, /cso round 3)

GET /api/agents/{a}/executions lists every running Workspace turn, with
its id, to anyone the agent is shared with. A human sharer could quote
ANOTHER client's live turn as execution_id and address the report to
that client: resolve_report_session kept the in-flight chat before the
Main gate was consulted, so the card landed in the client's live chat —
and with the Inbox it is now an unread arrival, badge and excerpt,
presented as the agent's. Card placement was pre-existing on dev; this
PR made it count.

The gate is now the publisher, for both branches: `allow_main` becomes
`agent_publish` (still no default), and a human publish is an
operator-only report. The agent's own publish is unchanged.

Red first: test_a_human_publish_is_not_stamped_even_into_the_addressees_own_inflight_chat
(flipped from ..._still_lands_in_..., which pinned the hole as intended).
Mutation: drop `or not agent_publish` -> 3 tests red; restored from a cp.

* fix(workspace): an open reads only what THIS open rendered (ent#610 PR A §3g S5, round 3)

`renderedKey` was set on every render and never reset, so an explicit open
of a chat the pane had already rendered read it at once, on a stale verdict:
- the desktop preview (row 1 rendered, a poll brings arrivals, the reader
  clicks row 1): the new messages were marked read without being drawn;
- open a chat, open an ask (renders nothing), reopen the chat: read before
  the new load settled, so a failed reload still read it — the exact D-3
  rule S5 exists for.
open() now clears the verdict, and a pane already showing the item (no
remount) reloads it through an exposed reload().

Red first (portalInbox.mount.spec.js, "round 3"): both failed on ad25fa585
(0 fresh history reads; 2 reads). Mutations, restored from a cp: drop the
reset -> both red; drop the reload -> the preview case red.
Found by /review F1 and Codex GPT-6-Sol F2 independently.

* fix(workspace): the Inbox's layout survives the rail's entrance, and an open rail with nothing selected stays closeable (ent#610 PR A §3g A4, round 3)

Two defects in one allowance:

1. The rail column enters from width 0 over 300ms, but the allowance went
   to 0 the moment the column existed. At ~1396–1444px the Inbox measured
   ~1147 mid-animation, picked the 384 list, then fell to 1099 and picked
   320 again: a 320 → 384 → 320 flip on every load (round-3 benchmark:
   load CLS 0.0093 → 0.0529). The allowance is now the width the column
   has NOT grown into yet (`inboxRailAllowance`: target − measured), read
   off the column by `useContainerWidth`, which now follows a late-mounted
   / v-if element. Live after: list 319 from first paint at 1400/1420/1440,
   load CLS 0.0069–0.0073.

2. At 1280 with the rail OPEN and nothing selected, the Inbox stacks, a
   stacked Inbox previews nothing, and with no agent the rail has no tabs —
   so the rail, its strip and the only control that closes it vanished,
   with `open` still saved (design review F1). The column now stays at the
   open width as PortalRailPlaceholder ("Open an item to see its agent's
   work here.") with the rail's collapse control. Live: stacked + 384
   placeholder → collapse → split, preview, 48px strip; both themes.

Red first: portalInbox.spec.js inboxRailAllowance (not a function) and two
portalInboxShell mounts (the column unobserved; no placeholder). The old
"0 once its column is there" assertion is superseded on purpose. Mutations,
restored from a cp: measured forced to 0 -> the allowance mount red; the
placeholder condition forced false -> the placeholder mount red; drop the
RO disconnect -> useContainerWidth.mount.spec red (review F8, new spec).
New component rather than markup in Portal.vue, so Portal.vue's gray
ratchet does not grow.

* fix(workspace): the stacked pane header reflows, Esc in More closes only More, and the phone drawer closes on its Inbox row (ent#610 PR A §3g L5/F4/F6, round 3)

Round-3 review findings (plan-design-review F1–F4/F6/F7, qa-mobile N1–N4,
design-review F2/F3, qa-desktop N2/N4), all introduced by PR A:
- Stacked pane: the title gets a line of its own below [Back] … [Open in
  chat][More] (by `order`, so it stays first in reading order) plus a
  `title`. Live: 73 -> 343px at 375; 0 -> 163px at 390@200% (More was
  pushed off-screen — WCAG 1.4.10); no horizontal overflow.
- Open in chat, More and every More item are 44px on a phone (were 27).
- Esc with More open: focus stays on the More button, so the menu's own
  handler never ran and Esc reached the Inbox's Back. Handled on the
  wrapper now; live: menu shut, pane kept, focus on More.
- Mark read unmounts its own button; focus went to <body>. The heading
  takes it first.
- Split header reserves its row (min-h 3.25rem): an ask pane grew 45 -> 52
  when Open canvas landed late, moving the card 7px.
- The drawer's Inbox row on the landing URL changes no route, so the
  fullPath watcher never closed the drawer: the sidebar says `open-inbox`.
  Live at 375: closed.
- Mark-all confirm: ConfirmDialog gains an additive `info` variant (i in a
  circle, primary ink; danger/warning byte-identical) — a warning triangle
  over a non-destructive primary confirm read as danger.
- Phone subtitle hidden (header wrapped to 108px).

Red first: 6 mounts in portalInbox.mount.spec.js + the drawer shell mount,
each failing on the prior commit for its stated reason; new
confirmDialogVariant.mount.spec.js.

* fix(workspace): Inbox list round-3 fixes — deep links scroll and stay wide, the expiry clock never stalls, rows keep the agent's name (ent#610 PR A §3g L3/L4, round 3)

Round-3 review findings, all introduced by PR A:
- A ?item= past row 50 widened the window but never scrolled to the row
  (it sat ~6,000px below the visible list; qa-desktop N1), and the widening
  collapsed back to 50 on the next click (Codex F3, review F4). The row is
  scrolled into view once drawn (not for a click), and the widened window
  is latched for the visit. Live: row 61 in view at 1440.
- The expiry clock only moved while a row was inside the day, so an ask
  25h out never got its badge as it crossed in, and a fresh ask was
  labelled against the mount time (review F2). `now` refreshes with the
  rows, and one timer (`nextExpiryEntry`) wakes the list at the next entry.
- "Expires in 60m" for 59m30s+ (review F7): minutes floored.
- A ghost of an ask that left the fetched list kept `pending` and looked
  answerable (Codex F4): it is `unavailable`, "No longer available".
- The head's "99+ new" hid the number (qa-desktop N3): `listHeadExact` as
  its title and sr-only text.
- Agent names truncated to 1–7 chars because both spans shrank (design
  F4): the agent keeps up to 60%; the chat title truncates. 12px text in
  the new files moved onto the six-size scale (12.5px; design F7).
- Stacked, the list ran 984px wide at 1280+rail (plan-design F5): capped
  at max-w-3xl. An empty tab was a 776px "Pick something on the left"
  beside nothing (design F6): one column.
- Dark: the "Read" badge on a selected row matched the row's gray-750
  fill (design F9): an inset ring.
- OverflowTabs: a tab moved into More lost its badgeLabel name (review F5).
- Tests: the phone pane-Back pop is now pinned by Forward reopening the
  item (the replace mutation survived round 3, review F3); the split-Back
  test asserts it stays on /workspace/inbox (Codex).

Red first: 5 node (portalAskUrgency, portalInbox) + 7 mount + 1
OverflowTabs, each failing for its stated reason. Mutations, restored from a
cp: latch off, scrollIntoView off, pane Back -> replace, ghost status
dropped -> each 1 red. The existing A10 fixture moves 18 -> 18.5 min: floor
semantics, with NOW taken at module load.

* fix(workspace): round-3 small fixes — sign-out drops the kept session list, the tab title sums what the sidebar sums, and the dark primary button clears AA (ent#610 PR A, round 3)

Pre-existing on dev, surfaced by the round-3 reviewers:
- /cso #2: `lastSessions` (the #2198 last-good list fetchAllSessions returns
  on a failed refresh) outlived signOut() — the asks list's exact leak,
  fixed for asks in A0. signOut() now clears it and `sessionsFailed`.
- Codex F5: the tab title summed raw `threads`, the sidebar and the Inbox
  `sidebarThreads` (D13). It sums `sidebarThreads` now; the source guard in
  portalUnreadLiveness.spec.js moves with it on purpose.
- design review F5: BaseButton primary in dark was white on primary-500
  (4.47:1) hovering to 400 (~2.9:1). Dark uses the light pair, 600 / 700
  (6.29 / 7.90). The ghost-ink spec's "unchanged" pin for primary moves
  with it on purpose. Not changed: danger's dark 500/400 has the same shape
  (white on red-500 ≈ 3.8:1) — out of this PR's scope, noted for a follow-up.
- review F6: the optimisticRead comment claimed more than the identity
  guard does; it now states the known ≤20 s mis-rollback.

Red first: workspaceAsks.spec.js (sign-out), portalInboxShell.mount.spec.js
(title 7 vs 2), baseButtonPrimaryDark.mount.spec.js + contrast pairs.

* fix(workspace): a deliverable preview's title is redacted like its excerpt (ent#610 PR A, /cso round 3)

`latest.excerpt` was the report title through `sanitize_text`, while
`latest.title` shipped the same string raw — the redaction undone inside
one object. No UI renders `latest.title` today (and /reports serves the
title raw to the same caller), so this is consistency, not a live leak.

Red first: test_a_deliverable_title_is_redacted_like_its_excerpt.

* test(workspace): an ask attached to a chat is answerable in the Inbox pane (ent#610 PR A §3g L0, #3055)

PR A now carries #3065's fix (merged in, like A0, ahead of its landing on
dev). Pins what the sign-off hit: with a real chat_id (ingestion attaches
every addressed ask to Main) the pane's card showed "Open the conversation"
and no controls. Both the answer controls and the link now render.

The pane keeps the link on purpose — §3g L0 said to pass threadLink=false,
but in an ASK pane that link is the only way to the ask's chat until E1
(L7) adds "Open the conversation" below the card; drop it then.

Mutation: the controls back on `v-else-if` -> both tests red; restored.

* fix(workspace): sign-off round 4 — ask bodies render markdown, the pane header stops repeating the title, the Inbox list no longer stretches the page, a chat pins only its own asks (ent#610 PR A)

1. PortalAsks printed an ask's body as plain text on every surface; it now
   goes through PortalMarkdown (DOMPurify), like an agent reply.
2. The Inbox pane's ask header was "<agent> asks: <title>" above a card
   showing the same title; it is now "<agent> · <kind>" (paneHeading).
3. The Inbox list's scroll <ul> was not a containing block, so each row's
   absolute sr-only kind escaped it and stretched the document to the list's
   full height (1749px at a 1000px viewport); `relative` contains them.
4. A chat pinned all of its agent's asks above the composer with no cap, and
   seven cards crushed the conversation. It pins only this chat's asks (Main
   also takes unattached ones) in a 33vh scroll box and names the rest with an
   "Open in Work" link (splitChatAsks).

* fix(workspace): an ask shows in one place — a chat folds its asks into one closed row, Work skips the ask the Inbox pane shows, Info stops pointing at the asks (ent#610 PR A, sign-off round 5)

- A chat shows "N asks waiting on you" as a native <details> row, closed by
  default, expanding inline into the capped box. An ask answered while the
  chat is on screen stays drawn, ended, until you leave it (pinnedAskIds).
- In the Inbox, Work leaves out the ask open in the pane (PortalWork
  excludeAskIds -> PortalAsks excludeIds).
- Info omits the "Answer what this agent asked you" suggestion
  (PortalSuggestions omitSources) and its dot ignores it (infoSignalFrom); a
  tab's dot now names its meaning on hover (OverflowTabs signalTitle).

* fix(workspace): "Open in chat" lands with a fading tint and scrolls only the thread; an Inbox message's arrow replies to it (ent#610 PR A, sign-off round 6)

- The anchor highlight was a 2px ring round the whole row. It is now a soft
  action-primary tint that holds 2.4s and fades over 1s (none under reduced
  motion).
- The anchor used scrollIntoView, which also scrolls the h-screen
  overflow-hidden shell: the whole Workspace slid up under a blank strip.
  scrollWithin() moves only the thread's scrollTop; the mount spec now fails if
  scrollIntoView is called (mutation-checked: 4 red).
- The Inbox pane's agent messages trade Copy for an arrow: the chat opens at
  that message with the composer prefilled with a quote of it
  (quoteForReply, messageReplyTarget; the "Ask about it" prefill path, never a
  send). The chat itself keeps Copy.

* fix(workspace): "Show all" no longer blanks the agent names — the availability chip moves under the name (ent#610 PR A, sign-off round 7)

#2641 reserved the #2196 chip's 72px on every row once any VISIBLE row could
show one. "Show all" revealed three stopped agents, and in a ~250px sidebar
(avatar + the 56px date + that strip + two count pills) every name got 0px.
The chip now renders on the subtitle line under the name, beside the
truncating preview; nothing after the date depends on availability, so a
start/stop cannot move the row's truncation point (the #2196 no-reflow
property, now by construction). Live: names 76-81px collapsed and expanded
(were 0 expanded). The #2641/#2196 template pins are rewritten to this rule;
reservesAvailabilitySlot and its unit tests are unchanged.

* feat(workspace): reply to one message with a Codex-style chip, quoted into the agent's prompt server-side; a wider, eased landing glow (ent#610 PR A, sign-off round 8)

Reply-to:
- The Inbox pane's arrow hands the shell {sessionId, messageId, excerpt};
  the chat shows it as PortalReplyChip on top of the composer (that chat
  only), a removable tab, instead of pasting "> message" into the draft. The
  chip leaves with the text at send; the sent message keeps its label and
  Retry resends the id.
- The turn carries ONLY reply_to_message_id (PortalChatRequest, both /chat
  and /chat/stream). service.reply_context() resolves it at the router before
  anything is written: the row must be this caller's, this agent's and this
  thread's, else one uniform 422 (loud — a dropped reply is context the person
  believes they gave). The quote is built server-side from the stored row,
  capped, and rides directly before the client's text on BOTH the resumed and
  the cold message; the stored user row stays what was typed.
- Proven live: the prompt Claude Code received in agent-legal-reviewer ends
  with the "[Client Portal] The user is replying to your earlier message"
  block; a bogus id returns 422.

Highlight: one `anchor-glow` animation (style.css) — a token tint that reaches
12px past the message via a box-shadow spread (no reflow), eases in, holds and
eases out over 3.2s; a still tint under reduced motion.

* fix(workspace): the Inbox has the light/dark switch (ent#610 PR A, sign-off)

The chat and the room render the shell's PortalThemeSwitch through their
header's `header-end` slot; the Inbox header (new in PR A) had no such slot,
so the Workspace's landing was the one stage with no theme control. The Inbox
exposes the same seam and the shell fills it. The theme-switch placement guard
now counts three fills (conversation, room, Inbox). Live: the switch renders
in the Inbox header and choosing Dark sets `.dark` on <html>.

* fix(workspace): an ask's "Open the conversation" sits on its own line in the card's ink (ent#610 PR A, sign-off)

It was an inline button followed by the inline "Got it", so the two ran
together on one line; and it was action-primary-600 with no dark half on the
amber ask card — low contrast, fighting the card. It is now a block box as
wide as its words (`flex w-fit`), in the card's own ink, underlined, with a
trailing arrow; the focus ring keeps the token. Live, both themes: link
bottom 274px, "Got it" top 282px, no overlap; ink gray-100 dark / gray-900
light. One component, so every surface (chat, Work tab, Inbox) gets it.

* test(workspace): strip template comments to completion in the availability-chip guard (ent#610 PR A)

The row-layout assertion scrubbed PortalSidebar.vue's comments with one
`replace(/<!--[\s\S]*?-->/g, '')`, which leaves a `<!--` assembled from the
halves of two removed spans. Use the index walk portalComposerAlignment.spec.js
already uses: no residue, and it clears CodeQL alert #379
(js/incomplete-multi-character-sanitization) on this PR.

* fix(workspace): the Inbox tabs reserve their counts' width, so Unread and All don't slide (#3060)

OverflowTabs gains an opt-in per-tab `badgeSlot`: the badge span is drawn
from the first frame at a tabular-nums min-width, `invisible` until the count
lands, in the visible row and the measuring mirror. The dropdown's
badge/signal chain is untouched (#2794), and a tab without the field renders
exactly what it did (pinned by class string). The Inbox sets it on Action and
Unread through a shared INBOX_TAB_SHELL. Measured: Unread's left edge went
77 -> 106px when the counts landed; now 111 -> 111.

* fix(workspace): the Inbox loads on its own skeleton, in the ready Inbox's footprint (#3060)

While the stage has no verdict, the Inbox route (and bare /workspace, which
lands there) drew the conversation skeleton. PortalInboxSkeleton draws the
Inbox frame instead: its header and theme switch, the same tab strip with the
badge slots reserved, the list column at the width PortalInbox picks
(inboxLayout over the same container width and rail allowance), and the pane
block. PortalInbox's own loading arm uses the same rows and pane placeholder,
so neither hand-off moves.

useContainerWidth now reads the content width at attach instead of waiting for
the observer's first report; the Inbox used to draw one frame as a 320px split
list before stacking, on every mount under 720px.

A mount spec compares the skeleton's frame classes with the Inbox's, and e2e
arms sample every animation frame of a delayed load (container-relative, the
#2711 method). Each arm goes red with its fix reverted.

* fix(workspace): an ask card keeps the person's pick, offers a question's options, and hides "Unconfirmed" (ent#610 PR A2 §3g L6 B2/B3/B5)

- B2: picking an approval option left focus on the chip (a type=button), so
  the Enter that followed re-clicked it and UNSELECTED the option. A pick now
  moves focus to the note field (not on a coarse pointer, where it would pop
  the keyboard), and Enter there sends. Still pick, then Send (#2375): the
  #2375 source guard now pins `pick()` and that it never sends.
- B3: a question the agent offered options for showed a bare text box. The
  options render as quick picks (questionQuickPicks, <= 500 chars — the answer
  field's limit; a longer one is left out, never cut) that FILL the answer and
  never send. queueResponseKind is unchanged (desktop and /m rely on it).
- B5: "Unconfirmed" is the platform's bookkeeping (no confirming poll yet);
  workspaceAskBadge = queueSyncBadge minus it, falling through to "Waiting"
  when the ask is also aging. Desktop and /m keep the full rule.
- After Send the controls unmount and focus fell to <body>; it lands on the
  answered card (tabindex=-1).

The raw-colour count of PortalAsks.vue is unchanged: the chip-at-rest classes
are one CHIP_IDLE constant shared by the approval's unpicked arm and the pick.

Red first: portalAskAnswerFlow.mount.spec.js, 6 of 9 red on the parent for
the stated reasons (helpers absent; badge shown; focus on <body>).
Mutations (each run once, restored from a scratch cp):
  M1 unconfirmed kept -> 2 red · M2 no focus on pick -> 1 red ·
  M3 a quick pick sends -> 1 red · M4 no card focus after Send -> 1 red ·
  M5 coarse pointer ignored -> 1 red.

* fix(workspace): the Inbox's Action tab is ordered by urgency, not recency (ent#610 PR A2 §3g L6 C1)

Critical asks sat at #10 and #20 of 21 because Action sorted newest-first.
actionItems(openAsks, now) now orders by askUrgencyCompare (Andrii
2026-09-29: a 24h bucket plus the A10 badge):
  1. asks expiring within 24h, soonest first — the row's "Expires in …"
     badge (A10) is the reason it sits there;
  2. priority critical > high > medium > low, an unknown one as medium;
  3. the longest-waiting first; ties on id, so a poll never swaps two rows.
The order is computed at a visit's start; stableRows still holds rows in
place for the visit (S1).

Red first: portalInboxActionSort.spec.js (4 red on the parent) and the
reversed "newest first" case in portalInbox.spec.js. The D8 deep-link mount
case's fixture swaps its two created_at so its ask stays second.
Mutations (run once, restored from a scratch cp): no 24h bucket -> 2 red ·
priority ignored -> 2 red · newest-first among equals -> 3 red · no id
tie-break -> 1 red.

* feat(workspace): the Inbox's Action can be narrowed to one agent (ent#610 PR A2 §3g L6 C2)

With 21 asks from 7 agents, "what does scout need from me" had no answer
short…
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.

1 participant