Skip to content

feat(workspace): the Workspace Inbox — Action, Unread, All, a reading pane (trinity-enterprise#610 PR A) - #3054

Merged
vybe merged 81 commits into
devfrom
AndriiPasternak31/ent610
Oct 1, 2026
Merged

vybe merged 81 commits into
devfrom
AndriiPasternak31/ent610

Conversation

@AndriiPasternak31

@AndriiPasternak31 AndriiPasternak31 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Refs abilityai/trinity-enterprise#610

Base: dev. #3049 (PR A0, asks honesty) has merged, so the diff is this PR's own changes only. The latest origin/dev merge (ffee9d15b) resolved conflicts in client_portal/asks/router.py, PortalAsks.vue, stores/clientPortal.js, workspaceAsks.spec.js, test_ent610_asks_unavailable.py and three docs (architecture/api-endpoints.md, feature-flows/workspace-agent-page.md, requirements/security.md). This branch predated A0's final commits (the 503 Retry-After, a 401 ending the session, a late read being dropped); the resolution keeps those and this PR's asksAbsent, askIds/excludeIds/testidPrefix and the session-list sign-out reset.

Scope of this PR: §3g L1–L5 (+A13, C10, F6 partial), plus Andrii's sign-off rounds 4–8 (below). With B1a #3065 underneath, an ask is answerable in the Inbox pane (bdb2418e7). What's left of L6 (the rest of the asks UI, including the "Unconfirmed" chip), L7 (E1 context) and L8 (the E2/E3 brief) moves to a follow-up, PR A2, stacked on this one. L8 adds a DB column on both migration tracks plus a platform-prompt change, which is a separate review.

Sign-off rounds 4–8 (commits f890260a8..4db80224e, all red-first):

  • Round 4: ask bodies render through PortalMarkdown; the pane header stops repeating the title; the list no longer stretches the page; a chat pins only its own asks.
  • Round 5: an ask shows in one place. A chat folds its asks into one closed row, Work skips the ask the pane shows, and Info stops pointing at the asks.
    Round 5 (Work = the asks' home, the pinned chat ask strip) is superseded by the 09-30 ruling on abilityai/trinity-enterprise#610; A2 feat(workspace): Inbox asks UI + an ask's context (ent#610 PR A2: §3g L6 + L7) #3101 carries the change.
  • Round 6: "Open in chat" lands with a fading tint and scrolls only the thread. An Inbox message's arrow replies to it.
  • Round 7: "Show all" no longer blanks agent names (pre-existing bug(workspace): sidebar dates are not flush right — an always-reserved availability slot sits after them and takes width from the agent name #2641 bug); the availability chip moves under the name.
  • Round 8, a server-side change worth reviewing: reply to one message with a chip. reply_to_message_id is resolved server-side by client_portal/service.reply_context() (uniform 422) and quoted into the agent's prompt, on both resumed and cold prompts. Proven end to end by reading the agent's transcript inside the container.
  • Also the Inbox light/dark switch (543f71d9b), and the ask card's "Open the conversation" line (4db80224e).

What this is

The Workspace Inbox (PR A of four: A0 → A → B after #3028 → C for rooms). Bare /workspace now lands on /workspace/inbox, which has three tabs:

  • Action: asks addressed to you;
  • Unread: chats with new arrivals, where an arrival is a message or an addressed deliverable;
  • All: 30 days, bounded to 50, with ended asks kept for 7 days.

There is also a reading pane and a pinned sidebar row with two counts. The design adds no new router, store, table or migration.

Backend

  • count_unread_by_session gains a deliverable arm: a report addressed to you and stamped to a chat you own. There is one _UNREAD_ARRIVALS fragment.
  • GET /chat-state?previews=true gives each unread chat its latest arrival and its first unread message id. Both come from the same statement as the count (client_portal/chat_previews.py, db.unread_arrivals_with_latest).
  • Excerpts are credential-sanitised, markdown-stripped and at most 160 chars. outcome comes only from the platform-written completion:* source marker.
  • PortalChatArrival has no cost and no execution id (AC 7).
  • client_portal/service.py gains only an optional unread= parameter.
  • Report stamp (D4): an addressed report lands in the addressee's own in-flight chat. Otherwise, only when the agent itself publishes, it lands in the addressee's Main. The chat is touched after the insert, with added=0.

Frontend

  • The pure rules live in portalInbox.js, with a property test over 500 fleets: Σ Unread rows == pinned count == Σ agent-row unread == totalUnread, over the same sidebarThreadsOf projection.
  • New components: PortalInbox, PortalInboxList, PortalInboxPane and PortalInboxRow.
  • Portal.vue changes:
    • the Inbox stage branch sits after rooms and before the conversation, and renders only on a ready stage (the ent#253 class);
    • the landing replace is awaited before the first read, so there is no conversation flash;
    • selecting an item never writes activeAgentName, so it mints no Main.
  • PortalAsks gains askIds and testidPrefix, and the default ids are byte-identical.
  • PortalConversation gets data-message-id and a one-shot ?anchor=m:|d:, via useConversationAnchor and useStickToBottom.detach().

AC scorecard

AC State
1 Landing + pinned row, two counts from the same feed Met
2 Action / Unread / All Partly met. Unread holds only what was addressed into the Workspace: replies, delivered completions, addressed reports and briefs. Scheduled or webhook runs with no delivery address are not in Unread (A8). Room events come in PR C.
3 Item → chat, pane, Open in chat anchor, rail follows item Partly met. Reply-in-chat navigates and focuses the composer. Open canvas is not built, because the shell cannot cheaply know about a live canvas.
4 Approval card = existing ask anatomy + read-only proposal Partly met. The anatomy is reused as-is; proposal comes in PR B, after #3028.
5 Finished run renders as its deliverable via ReportRenderer Partly met. Deliverables render through ReportRenderer; a run's own message shows its done/failed outcome. There are no run pills, because no message→execution link exists (D15).
6 No new store, generalise #557 Met: one cursor, one fragment, one projection
7 No cost / execution detail on the portal door Met for everything this PR adds (tested: no cost key anywhere). The existing /agents/{name}/history leak predates this PR and is a follow-up.
8 Honest empty / expired / failed states, phone width Met: LoadFailed, stale banner, empty only after a good read; ended asks stay in place; 375px checked

A9: a platform owner is never the addressee of asks or deliverables (include_owned=False), so their Action tab is empty by construction. Its empty state links to Operations.

Evidence

  • /review: 0 blockers. It found one should-fix (the pane lost "first unread" on All after a refresh) and one nit (the stamp touched before the insert). Both are fixed with red-first tests.
  • /cso --diff: one MEDIUM. A human sharer of an agent could stamp a deliverable into another person's Main. Fixed: the Main fallback is now for the agent's own publish only, with the gate as a required keyword. Everything else checked clean: no path to an arbitrary email, roster-scoped previews, no new v-html, and no selector injection through ?anchor=.
  • Mutations: each went red, and each was restored from a scratch copy.
    • Backend (9): roster filter, the unread= passthrough, cost, latest order, body parsing, first-unread order, redaction, the agent-only gate, touch-before-insert.
    • Frontend (15+): archived-in-Unread, the testid prefix, detach, the held snapshot, previewsFailed, focus-after-Back, deep-link hold, payload unwrap, total-line reserve, plus eight from the build.
  • verify-local (--skip-agent --skip-unit, run twice, the second at the final backend): build and import-smoke OK, boot and health OK, integration 70 passed / 13 skipped / 2 known-false-fail deselected.
  • Unit island (py3.13, same venv, no:randomly): the branch has 40 failed / 18576 passed. Pristine origin/dev 4114d3d has the identical 40 failures (ent477, ent666, retention_floor — order pollution), so no new failures.
  • Frontend: test:unit 3808/3808; build, check:tokens and both ratchets green. Measured per file against dev, no existing file grew. The new files carry gray classes only.
  • Real browser (Chromium, light and dark, 1280 and 375) against the real components with a stubbed store:
    • no horizontal overflow; Enter and Space open a row; phone Back and Esc return focus to the row;
    • pinned badges measured with getComputedStyle: ask count 5.18:1 and unread count 7.90:1 in both themes.
  • Live stack (--keep + host Vite, seeded rows, no LLM agent):
    • lands on the Inbox; pinned counts equal the agent row;
    • an ask answered in the pane stays in place, then shows "Answered · you" in All;
    • Unread shows "3 new", the excerpt and "Didn't finish"; opening it clears the count on both the Inbox and the sidebar;
    • ?agent= and ?new=1 are unchanged;
    • chat-state?previews=true returns no cost key.
  • e2e smoke on that stack: 89 passed, 2 failed.
    • One is agent-detail-request-dedupe, which fails identically on dev.
    • The other is this PR's "does not shift" arm. The live run found a 36px shift and a missing roster skip. Both are fixed, but the shift is not re-measured live.
  • Not verified: a real agent turn or wake (no LLM key or agent container); the anchor's scroll position in a live page; PostgreSQL execution of the new window-function statement (standard SQL, aliased subqueries, not run on PG).

Round 3 — §3g build reviewed and fixed (2026-09-29, head d3202301f)

§3g L1–L5 (+A13, C10, F6 partial) is built. L6, L7 and L8 moved to PR A2 (see Scope at the top). Their gates, B1a #3065 and #3028, have both merged.

Nine report-only reviewers ran in parallel on a reseeded live stack: 56 chats, 279 messages, and 24 pending asks, all filed through ingest_ask so chat_id is real. The reviewers were /review, /cso --diff, design review, plan-design-review (build vs §3g), desktop QA, 768/375 dark QA, benchmark, Codex GPT-6-Sol and the frontend gates. Triage: 31 findings; see the commits below.

Round-1 items re-checked live, FIXED: A1–A13, B6, B7a/b, S3, S4, D-4. A12: poll CLS 0.19 → 0.000. A row read elsewhere stays in place as a ghost, and no other row moves.

Fixed in this round (red test first, mutation recorded in each commit):

Commit What
ca002345d /cso P2: a human sharer could quote another client's LIVE turn (listed by GET /executions) and plant a report card, now an unread Inbox arrival, in that client's chat. agent_publish now gates both branches; a human publish places no card.
9ca24c470 P1 (/review + Codex, independently): reopening or re-clicking an already-rendered chat marked it read before the fresh load, so a failed reload still read it.
439e2152c P1 (benchmark): list flipped 320→384→320 on every load at ~1400–1440px (the rail enters from 0 while the allowance went to 0); load CLS 0.0529 → 0.0069. P1 (design): at 1280 an open rail with nothing selected vanished and could not be closed; it now keeps its column (PortalRailPlaceholder) with the collapse control.
036f21b1c Stacked pane header: the title gets its own line; at 375 it gets 343px (was 73). At 390 zoomed to 200% it gets 163px (was 0, with More off-screen — WCAG 1.4.10). Also 44px targets; Esc in More closes only More; focus after Mark read; reserved split header; the phone drawer closes on its Inbox row; the ConfirmDialog info variant; phone subtitle.
48d8e783f List: a deep link past row 50 scrolls into view and the window stays wide; the expiry clock never stalls (nextExpiryEntry); minutes floored; a vanished ask's ghost reads "No longer available"; exact count behind "99+"; the agent name keeps its width; stacked reading width; empty tab is one column; dark "Read" badge; OverflowTabs menu label; pane-Back / RO-disconnect / split-Back tests.
2341f6c3e Pre-existing: signOut clears lastSessions; tab title sums sidebarThreads (D13); dark primary button 600/700 (was 4.47:1 / ~2.9:1).
d3202301f A deliverable preview's title is redacted like its excerpt.

Verification this round

  • Frontend: test:unit 3974/3974, build and check:tokens green. Both ratchets are exact per file vs dev; PortalSidebar gray fell 43→35, and no file grew.
  • Backend: test_ent610_inbox + test_ent365_report_audience 86/86.
  • /verify-local (global script, py3.13, --skip-agent; docker/base-image untouched):
    • Unit: 18,630 passed and 40 failed. All 40 are in test_ent477_*, test_ent666_objective_join and test_retention_floor, files this PR doesn't touch. The same files pass 119/119 in isolation: order pollution, the same 40 as on pristine dev.
    • Re-run with --skip-unit: build + import main OK, boot + health OK, integration 70 passed / 13 skipped / 2 known-false-fail deselected → pass.
  • Live (Chromium, both themes):
    • 1440 load: list 319 from first paint.
    • 1280 + open rail: stacked + placeholder → collapse → split + strip.
    • 375 / 195 CSS: no horizontal overflow.
    • Deep link to row 61: in view.
    • Open canvas (C10) live for the first time: the ask header holds at 52px while it lands, and it opens the rail on the canvas.
  • CI on d3202301f: pending at the time of writing.
  • Not run this round: the Playwright e2e specs (workspace-inbox, contrast-ratchet), and /ui-sweep.
  • workspace-inbox e2e in CI: the roster-gated tests self-skip there by construction. frontend-e2e.yml pins zero user agents, and its admin has no email, so /my-agents is empty (learnings.md, refactor(agent-detail): retire the chat-panel voice overlay — the Talk button opens voice in the Workspace #2559). Seeding one is a workflow change, so a green CI run is not evidence for those arms (spec header, ec5bdc00a).

Open, needs a decision: a read row in All grows 43→86px when an arrival lands, pushing the rows below it 43px. Either reserve a line on read rows, or accept it (the arrival is the news). An insertion at the top at scrollTop 0 shifts 91px; accepted as the §3g.4 known risk, and scroll anchoring covers a scrolled list.

Deferred to PR A2: the "Unconfirmed" chip (L6 B5) and the rest of L6. Answer controls in the pane are live here (B1a + bdb2418e7).

Follow-ups

🤖 Generated with Claude Code

…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.
…er [] (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.
…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).
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.
…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.
…(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.
…box (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.
…(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.
…essee (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.
…s 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.
…10 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.
… (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.
…g 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.
…0 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.
…s 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.
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.
…t#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.
…t#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.
…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.
… (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.
…d 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.
…nt#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.
…ts 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.
…#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.
…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.
@AndriiPasternak31 AndriiPasternak31 added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 28, 2026
…gn-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>.
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Nightly unit-suite clean when this PR is merged into dev, all 3 seeds (head_sha: ffee9d15b5f8f5d3f44a15e17bd9103582056b54).

Brings in 58 commits from dev. Eight conflicts resolved:

- client_portal/asks/{service,router}.py — #3059's paged read (limit/cursor,
  X-Total-Count, SQL-side roster set) combined with A0's fail-loud contract:
  the roster check stays strict, and an unreadable queue or roster raises
  AsksUnavailable -> 503 asks_unavailable instead of dev's empty page (which
  the client would render as "nothing is waiting"). Mutation-checked: dev's
  `return AsksPage()` turns 4 A0 tests red.
- PortalAsks.vue — the markdown body (sign-off) plus #3028's QueueProposal.
- PortalConversation.vue — useStickToBottom's `enabled` option (7c1c6dc)
  plus the Inbox anchor's `detach`.
- Portal.vue — #3060's railSizedOpen, with the Inbox placeholder rail counted
  as visible.
- api-endpoints.md / operating-room.md / tests/registry.json — both sides kept;
  the asks row now states the 503.

Test updates the merge requires:
- test_ent610_asks_unavailable.py faults all three queue reads #3059 split
  the read into (the first runs before any early return).
- test_3064_portal_unread_index.py builds agent_reports too: ent#610 D3's
  unread count reads it, so the fresh track could not run the real query.
  All 4 pass, including its negative control.

Verified: backend unit 19074 passed / 42 failed — 40 are the known
order-dependent set (ent477 x2, ent666, retention_floor; files identical to
dev, 119/119 in isolation) and the 2 test_3064 are fixed above; frontend 4090
passed + build; alembic single head; live stack: every sign-off behaviour
re-probed, a reply's quote confirmed in the agent's prompt.
Comment thread src/frontend/tests/unit/portalAvailabilityChip.spec.js Fixed
… 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.
@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

…ility-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.
vybe pushed a commit to harimaruthachalam/trinity that referenced this pull request Sep 29, 2026
…left open doesn't jump on load (Abilityai#3060)

Abilityai#2711 reserves the rail column while the Workspace stage loads, so the
conversation lands on the footprint it keeps. But the column's width comes from
useColumnResize, which was told the rail is open only when it is VISIBLE, and
the rail is never visible while the stage loads. A rail the person left open
was reserved at the 48px collapsed width and then jumped to its open width
(384px by default) the moment the stage was ready, moving the conversation's
right edge 336px in one frame. That is the "collapses and re-opens" in Abilityai#3060.

- portalRail.railSizedOpen({open, visible, reserved}) is a pure rule: size the
  column open when the persisted state is open AND the rail is visible OR its
  column is reserved. It reads booleans strictly.
- Portal.vue feeds it to useColumnResize in place of `open && visible`.
  enforceFit only runs from onMounted, so the auto-collapse on a narrow viewport
  now decides during the load too, before the rail lands.
- The collapsed default is unchanged (48px reserved, 48px landed). An
  unfulfilled reservation is still given back in one frame.

Measured (1440x900, /workspace?agent=… and bare /workspace, rail persisted
open): before, the column went 48 → 384px and the stage's right edge
1377 → 1041px. After, it is 384px from the first reserved frame, with the
right edge fixed at 1041px.

Tests:
- The railSizedOpen unit cases were red before the fix.
- A new e2e arm persists the rail open and asserts the column width and the
  stage edge are each a single value across the load (relative geometry only).
  It fails with the fix reverted ("48 → 384px") and passes with it; the other
  eight Abilityai#2711 arms still pass.

The Inbox skeleton and the late tab badges in Abilityai#3060 live on the Inbox route
that Abilityai#3054 adds and are not on dev. They are handed to that PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vybe pushed a commit to harimaruthachalam/trinity that referenced this pull request Sep 29, 2026
…ityai#3059)

GET /api/enterprise/client-portal/asks stopped at 200 rows and returned no
total. A viewer with more than 200 asks silently lost the oldest ended ones;
past 200 pending, pending ones went too. Nothing on screen said the list was
cut.

Non-breaking: the body stays a list of WorkspaceAsk. Paging is opt-in: `limit`
(1–200, default 200) plus an opaque `cursor`. Every response carries
X-Total-Count, and X-Next-Cursor while more remain. A bad cursor is a named
422 invalid_cursor.

The total is honest because every visibility filter now runs in SQL before the
limit. The visible kinds (a new `types` filter) and the read-time roster
re-check used to run in Python on the result, so a page of 200 rows could
render fewer, and no SQL count could be the viewer's real total. The roster
predicate itself is unchanged: `_on_roster` is asked once per agent the
viewer's asks span, fails closed, and its answer becomes the SQL access set.

db/operator_queue.py:
- the filter conditions move into one `_list_conditions` helper shared by
  list_items and the new count_items / list_item_agent_names, so a page, its
  total and its agents can't drift apart;
- `id` is the last sort key, so rows that tie page without overlap or skip.
  Pending asks still sort first, so none is dropped while ended ones are shown.

Rule Abilityai#1: requirements (security.md OPS-001 surfaces) and a new api-endpoints
row are updated.

Tests: test_3059_asks_paging covers:
- 230 pending asks: 200 + 30, every ask visited exactly once;
- invisible kinds, other addressees and off-roster agents neither take a page
  slot nor inflate the total;
- pending before ended on page 1;
- tie-break stability with identical timestamps;
- the roster asked once per agent;
- list_asks keeps its list contract;
- the route's headers, the 422 cursors and the limit bounds.

It was red before the change (13 of 14). The operator-queue and asks suites
(53 files, 1877 tests) pass.

The Inbox "Show more" and the removal of Abilityai#3054's honest-cap footer belong to
Abilityai#3054, which can now read X-Total-Count / X-Next-Cursor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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.
…ox'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.
dev carried PR A0's final commits (#3049: the 503 Retry-After, a 401 ends the
session, a late read is dropped) that this branch predated. Resolved as the
union: A0-final plus the Inbox's asksAbsent verdict, excludeIds/askIds/
testidPrefix props and the session-list sign-out reset. The A0 unit test
takes dev's parametrized every-queue-read version.
@vybe

vybe commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

merge-train: not on this train. It rides the next one once the two items below are settled. /validate-pr and /review found no criticals, the ratchets are exact, and the portal-capability invariant holds. Both items change behaviour, so they're your call.

  1. The completion marker changes the next resumed turn. services/channel_completion_report.py now writes source=_portal_source(status) (completion:done|failed), and the comment calls that inert. It isn't:
    • client_portal/db.py:230 _TYPED = "source IS NULL" feeds the typed-row window (db.py:263) and the resumed-turn cursor in get_platform_rows_since_last_reply (db.py:309-312).
    • A completion row therefore stops being the cursor, and it is replayed into the next resumed turn as platform context.
    • That may be the right behaviour. Either way, pick it, fix the comment, and pin it with a test.
  2. A refused reply has no way out.
    • The 422 says "Remove the reply and send again".
    • But PortalConversation.vue:2289-2296 clears the input and emits reply-done before the send.
    • The failed message keeps its replyTo, and Retry (:2392) resends the same id, so every retry gets the same 422. The chip isn't removable (removable=false, :328).

Mechanical, to fold in at the same time:

  • title/display_hint are selected by db.unread_arrivals_with_latest and emitted by chat_previews._project, but no Inbox component reads them.
  • portalUtils.js:975 reservesAvailabilitySlot now has only a spec caller.
  • The workspace-inbox e2e tests self-skip in CI when there is no roster agent, so they barely ran there.

Clears the 8 conflicts #3054 had against dev @ b09d454. Resolutions:

- client_portal/service.py: ent#661's `turn_prefix` and PR A's reply-to
  `reply_prefix` both ride both arms — delta/history → turn context → canvas →
  manifest → reply quote → message. test_ent555 and dev's test_ent661 wiring
  pins updated to the union.
- PortalAsks.vue: #3115's AskMarkdown is THE ask renderer (title inline, body
  block) — it replaces PR A's PortalMarkdown body; PR A's testid prefix is
  kept on both (`${prefix}-title-`/`-question-`), so the Inbox pane's copy
  never shares an address with the chat's.
- PortalConversation.vue: PR A's chat-scoped strip stays (splitChatAsks + one
  closed row + pinnedAskIds). It is the ent#610 ruling #3115 stood in for:
  `pinnedAskIds` holds only asks seen PENDING, and dev's `pending-only` rides
  the mount. The auto-merge had dropped `agentAsks` (dev removed it; PR A
  still reads it) — restored. Emits: union (`reply-done` + `open-project`).
- PortalSidebar.vue / Portal.vue: Inbox and Projects both — the emits, the
  sidebar events, the two stage branches (Inbox, then Projects + its
  unavailable state), both sets of route computeds.
- Specs: portalThemeSwitch counts 5 header-end fills (+Projects);
  portalAgentPageUx keeps PR A's gate pin plus #3115's pending-only pin;
  portalAskSingleSource pins the one store read + `:ask-ids`;
  portalAskStripPendingOnly's wiring test mounts a chat with a pending and an
  ended ask and asserts pending-only + only the pending id (dropping
  `pending-only` turns it and the page-UX pin red); portalAsksTestidPrefix
  lists #3115's title ids.

FE 4295/4295 + build + check:tokens; backend 1794 passed across 75
client_portal-adjacent files (seed 12345).
…e 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.
…(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 always removable → red (the delivered case)

portalReplyTo.spec.js is still green (7/7 across both files).
…y_hint nobody reads (ent#610 PR A, #3054 review mechanical a)

`db.unread_arrivals_with_latest` selected `display_hint`, and
`chat_previews._project` emitted `title` and `display_hint` on a deliverable's
`latest`. No Inbox component reads either:
- the list renders `latest.excerpt`, which for a deliverable already IS the
  sanitised title, plus a "Deliverable" badge;
- the pane's `display_hint` comes from the deliverables read, not from `latest`.
Plan §3 only listed them in the payload, and §3g names no use. So they are
dropped:
- `_project` stops emitting them;
- `PortalChatArrival` loses both fields;
- the SQL stops selecting `display_hint` (`r2.title` stays, since it is the
  excerpt's source).

This also removes the second copy of the title that /cso round 3 had to
redact. The excerpt is now the only copy and is still redacted, as the test
pins.

Red first: the two preview tests now assert the exact `latest` shape and that
neither key is present. Both failed before the change. Mutation (re-emit
`title`) → 2 red, restored from a scratch cp. test_ent610_inbox +
test_models_centralized: 67 passed.
…er was its spec (ent#610 PR A, #3054 review mechanical b)

Sign-off round 7 (7fe36ca) moved the availability chip under the agent's
name. After that, the sidebar template no longer spent #2641's list-level
reservation, so `portalUtils.js::reservesAvailabilitySlot` was called only by
its own spec.

Removed:
- the helper;
- its #2641 predicate block in portalSidebarDateFlushRight.spec.js.
`availabilityChip`'s own states are covered in portalAvailabilityChip.spec.js.

The spec's remaining source pins stay: where the chip renders, and #2580's
date column. workspace-sidebar-ia.md now says the chip reserves nothing, and
keeps the #2641 reasoning as history.

portalSidebarDateFlushRight + sourceTextRatchet + portalAvailabilityChip:
33 passed. `grep reservesAvailabilitySlot src` finds no code reference.
…0 PR A, #3054 review mechanical c)

In CI, every roster-gated test in workspace-inbox.spec.js self-skips, and
that can't be fixed from inside this spec:
- frontend-e2e.yml pins the baseline at zero user agents, which other
  @smoke specs and the contrast baseline depend on;
- the CI admin has no email, so portal_auth 403s it and /my-agents is empty
  anyway (learnings.md, trinity#2559).

Seeding a roster agent is a workflow change: an admin email plus an owned
agent row that every other spec tolerates. The spec header now states this,
and that a green CI run is not evidence for those arms. The PR body says the
same. (The last CI run on 4b1499c reports 90 passed / 9 skipped / 1 flaky;
the reporter does not name the skipped tests.)

Comment-only change; no behaviour changes.
@AndriiPasternak31

Copy link
Copy Markdown
Contributor Author

Thanks. Both items and the three mechanical ones are on the branch now (head ec5bdc00a, normal commits, no rebase).

1. The completion marker changes the next resumed turn (a57820bda)

  • Decision: 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 was wrong beyond the comment: the replay reached the agent as You: **Finished** …, under a delta header that said every line was spoken in a voice call. Now the one renderer both paths share (_context_lines) tells it as a platform note: [Background task report: **Finished** …]. The header now says only (voice) lines were spoken.
  • The cold window already carried the row: it rides inside the typed span and does not take a typed slot.
  • Pinned in test_ent610_inbox.py, on real SQLite, driven by the real completion writer:
    • it is never the resumed-turn cursor (a spoken row before it stays in the delta);
    • a resumed turn is told it came back (done and failed);
    • the cold window carries it without spending a typed slot.
  • The "inert" comment in channel_completion_report.py and the workspace.md line now say what actually happens.
  • Mutations (each red): cursor ignores _TYPED (3), _TYPED counts completion:% (4), renderer branch removed (3).

2. A refused reply has no way out (ce47a7d74)

  • A failed message's reply chip is now removable. Removing it drops replyTo, and Retry sends the text as an ordinary turn. A delivered message's chip stays fixed.
  • PortalReplyChip gains placement, so the removable chip on a message keeps the message look.
  • Red first: portalReplyRefused.mount.spec.js mounts PortalConversation with the real chip and runs 422 → remove the reply → Retry, which must succeed with replyToMessageId: null.
  • Mutations (each red): chip never removable, dropReply a no-op, chip always removable.

Mechanical

  • title/display_hint on latest (b49847b58): dropped. Nothing reads them. The list shows latest.excerpt, which for a deliverable already is the sanitised title, and the pane's display_hint comes from the deliverables read. Removed from _project, PortalChatArrival and the SQL (display_hint only; r2.title feeds the excerpt). This also removes the second copy of the title that /cso round 3 had to redact.
  • reservesAvailabilitySlot (0df8248e9): removed with its spec block. It lost its caller when round 7 moved the chip under the name.
  • workspace-inbox e2e (ec5bdc00a): not seeded. It can't run in CI without a workflow change, for two reasons:

Checks: backend targeted (ent610/2694/ent457/ent358/…) 323 passed on seeds 1/12345/99999 (py3.13); FE test:unit 4291/4291 incl. both ratchets, build, check:tokens OK; sys.modules lint clean; gitlinks = origin/dev. /review on the five commits: no criticals.

Also in the body: Round 5 (Work = the asks' home, the pinned chat ask strip) is superseded by the 09-30 ruling on trinity-enterprise#610; A2 #3101 carries that change.

Ready for the next train.

@vybe

vybe commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

merge-train (#3143): on this train, merging first. /validate-pr came back READY with no criticals. Both items from the 09-30 train are settled: the completion-marker replay and the removable chip on a refused reply each fail a test when I revert them.

Edited for you: the PR body's trinity-enterprise#N references are now fully qualified (abilityai/trinity-enterprise#N) so GitHub links them. No code was pushed.

Non-blocking, for a follow-up:

  • The Inbox e2e roster checks still never run in CI. Seeding a roster agent means changing frontend-e2e.yml, so CI green isn't evidence for those checks.
  • (Low confidence) latestTypedReply treats any assistant row whose source isn't voice as a typed reply. If a completion row is written with the assistant role, it could be taken as the turn's answer. This was true before this PR, so it isn't a regression.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/#3143 (all Tier 1 + Tier 2 green)

@vybe
vybe merged commit a0cb1b3 into dev Oct 1, 2026
33 of 35 checks passed
vybe pushed a commit that referenced this pull request Oct 1, 2026
#3054 and #3137 landed as squashes, so dev re-carries content this branch
already contains. Each conflict resolved to this branch's side as integrated on
train #3143; the result tree is byte-identical to the CI-green train tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

4 participants