Skip to content

feat(workspace): Inbox asks UI + an ask's context (ent#610 PR A2: §3g L6 + L7) - #3101

Merged
vybe merged 118 commits into
devfrom
AndriiPasternak31/ent610-a2
Oct 1, 2026
Merged

vybe merged 118 commits into
devfrom
AndriiPasternak31/ent610-a2

Conversation

@AndriiPasternak31

@AndriiPasternak31 AndriiPasternak31 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes abilityai/trinity-enterprise#734

Stacked on #3054. Review only the commits after ec5bdc00a (PR A head). Merges after #3054.
Also stacked on #3137 (ent#734: an ask carries chat_id + raised_in_turn), merged in at 744b28219, and again at its re-review nits. Merges with or after #3137.

Refs abilityai/trinity-enterprise#610

PR A2 of the Workspace Inbox. Scope: §3g L6 (asks UI) + L7 (E1, an ask's context) + the 2026-09-30 ruling on ent#610 (vybe, 14:16, amended 14:29). L8 (E2/E3: the agent-authored brief column and prompt) moved to its own follow-up PR — it needs #3021's migration rechain. OSS core, ungated (the Workspace ruling, ent#356).

L6 reconcile (done before building)

PR A's sign-off rounds 4–8 had already done part of L6, so each item was checked against origin/AndriiPasternak31/ent610 first:

§3g L6 item On PR A Here
B1 a chat pins only its own asks partly (f890260a8, 722911577) round 1 fixed the "N more asks" link for clients; the 09-30 ruling then removed the pinned box and that line (below)
B1 TASTE-C: Work scoped to the chat superseded not built; superseded again by the 09-30 ruling below (Work draws no asks)
B2 a pick moves focus to the note; Enter sends not done ✅
B3 a question's options become quick picks not done ✅
B4 ask body through PortalMarkdown done (f890260a8) —
B5 hide "Unconfirmed" not done ✅ workspaceAskBadge
After Send: focus lands on the card partly (the aria-live confirmation existed) ✅ focus
C1 urgency sort not done (the A10 badge was done) ✅
C2 filter Action by agent (?from=) not done ✅
E2 render seam QueueBrief.vue not done ✅ fixture-tested; L8 supplies the data

What changed

  • Ask card (every surface): picking an approval option moves focus to the note, so Enter sends. Before, Enter re-clicked the chip and unselected it. It is still pick, then Send (bug: Workspace asks panel sends a typed answer as response_text (the agent reads response: "") and answers approval asks one-tap — same class as #2370 #2375). A question's offered options become quick picks that fill the answer and never send. "Unconfirmed" is hidden in the Workspace. After Send, focus lands on the answered card.
  • Action order: asks expiring within 24h come first (soonest first), then priority, then the oldest. Ties break on id.
  • Action by agent: a second dense strip under the tabs shows "All agents" plus one facet per agent, when 2 or more agents are waiting. The choice is kept in ?from=<agent>. The filter holds while that agent's just-answered ask is on screen, then clears itself. A tab change drops it.
  • Brief seam: the order is body → proposal → Why now → "{agent} recommends" → options → answer row → "If you don't answer by …". When the brief gives an option an impact, the options stack, with the impact as each button's aria-describedby hint. Agent text goes through the one sanitised ask renderer (bug(operator-queue): markdown in approval/question asks renders as raw text on some surfaces (portal asks, options, resolved history) #3115's AskMarkdown, since the dev merge).
  • L7, GET /api/enterprise/client-portal/asks/{id}/context, a new read reachable with a portal token:
    • _owned_ask is extracted from answer_ask and shared by both routes, so the 404 body is identical whatever the cause. For the context read, a roster outage is 503.
    • A run is shown only if it belongs to the ask's agent, was live when the ask was filed (timestamps compared parsed, 300 s grace), and was a schedule run or the viewer's own (a manual run shows only to whoever started it — round 1). The execution_id it comes from is written by the agent.
    • The schedule's name is shown to platform users only (T12).
    • The origin excerpt appears only from a thread verified as the viewer's. Otherwise the pane says "Filed in your Main chat".
    • No cost and no execution_id in the body. Rate limit 120/min per viewer. asks/router.py gets its # mcp: none header.
  • PortalAskContext.vue sits below the card, so the controls never move, and they render even when the read fails. One meta line, then: where it came from → what was delivered in that chat → your recent answers. The pane's card link is now off (threadLink=false); "Open the conversation" lives in the context.

Behaviour change to note: an addressed ask whose kind no list shows now gets a 404 on answer too. It could not be reached from any UI before.

Review round 1 (2026-09-30)

Eight reviewers ran in parallel, report-only, against the live stack: /review, /cso --diff, a live design review, a plan-vs-build design review, desktop QA, 768/375 dark QA, a Codex GPT-6-Sol second opinion, and frontend gates per file vs dev. No P0. Fixed here, each red-first, with the mutations named in the commit bodies:

Commit What
831aa9256 Backend (E1). A manual run now shows only to whoever started it (manual is any accessor's /task, not "the owner's" — /cso + Codex). A queue-read failure is the promised 503, not a 500. "Your recent answers" no longer empties behind 100+ pending asks (one SQL read per answered status). The operator-answer privacy filter, the sort and the catch-all now have tests. 7/7 mutations red.
214ddd298 Agent filter. Counts are gray (OverflowTabs gains a neutral badge tone), not success-green beside the urgent Action count, and are named ("Relay Bot, 2 asks"). The strip no longer vanishes at zero asks. Switching agent closes another agent's open ask. The list head names the filter, since at 375 the chip sits in More.
3447d8a10 Card + context. In the pane the priority and expiry badges sit in the card header, not below Send. Quick-picks show as chosen and don't pop the phone keyboard. Design-system focus rings, a 3:1 dark border, 44px phone targets, a double-Enter guard. The context paints once (deliverables are read first; their failure gets its own retry), and the skeleton is the loaded shape. Main is called "Main". A verified origin that isn't where the ask was filed says both ("Filed in your Main chat", "Open “Acme MSA redlines”"). A failed read still links to the chat. The meta line says "started 09:00". One wording for "N more asks from this agent". The stale-response guard has a test.
b5a29b21f Raw-colour baseline: OverflowTabs raw_gray +4 (the neutral tone), PortalAsks −1. Hand-edited, named in refrozen._ent610_a2_note.
7e8b9ac21 Merge of PR A's dev merge (4b1499c8f on #3054: #3115 AskMarkdown, ent#661 Projects, ent#720). Option labels and quick-picks render through AskMarkdown.
96dbc82b5 Desktop QA re-run (0 P0 / 0 P1): the facet order holds for the visit (it reordered under the reader while answering), a stray ?from= on other tabs leaves the URL, and the question Send has a testid.

Deferred, for review: the context isn't cached in the store (re-select refetches); the ent#468 confirmation above the card still shifts it (pre-existing); the chips stay hand-rolled, as before; the B1 link in the chat is covered by a source-text pin only (mounting needs the whole chat stack). From PR A: "Expires in 1h" at 1h55m, and an answered card still headed "Needs approval". Full triage is local.

The 09-30 ruling (as amended) — built on #3137's data

Every ask carries chat_id and raised_in_turn (true only when a Workspace chat turn raised it; only the literal true counts). One commit per item, red test first, every named mutation run once and restored from a scratch copy (counts in the commit bodies).

Item Commit What
1. Chat-turn ask → a tile in its chat 5566f42c6, f06809f2f portalChatAsks: a raised_in_turn ask draws in the chat its chat_id names, as a row of the thread placed by time among the messages; while it waits it is the same PortalAsks card, answerable in place. An ask seen waiting on this visit keeps its card after it ends (the ent#468 "Sent."); any other ended ask is one muted row: kind · title · ending with who · when — since round 2 kept for the queue's retention via the chat's own read (?chat_id=), not the Inbox's 7 days. Nothing sits above the composer (principle 30): the pinned splitChatAsks box and the "N more asks" line are removed. 6 mutations; the follow-up gives the tile its own testid namespace
2. Background ask → Inbox only 6ae00b1f0 A schedule / loop / gate ask draws in no chat (Main included) and no card offers "Open the conversation" for it (askThreadLink now needs raised_in_turn). The Inbox's Action lists it; All is its history. 3 mutations
3. Work → Now / Earlier + one line 5e0493cd3 Work draws no ask. While something waits: "N waiting on you · Open in Inbox" → /workspace/inbox?tab=action&from=<agent> (whole tab when 2+ agents wait), on the Now heading row, so it moves nothing. Round 5's "Work is the asks' home" and "Work skips the pane's ask" are undone (excludeAskIds / excludeIds removed); the briefing's asks suggestion and Info's asks link go to the same Inbox route. C2's filter kept. 5 mutations
4. "Needs you" mark on agent rows 8667a672c The #2424 per-agent ask pill IS the mark: openAsks, the same feed the pinned Inbox row counts. Pinned by mount: marks sum to the Inbox row for any mix of chat-turn and background asks, pending only, beside (before) the unread mark; it gains the Inbox row's hover words. 3 mutations
Docs 24916544b feature flows (agents-at-the-centre, work, sidebar-ia), requirements AC-4, architecture/workspace.md

Specs for the removed pieces were rewritten for the new homes, not deleted (round 4 §4, round 5, #3115 strip, B1 residual, single-source, agent-page-ux, #3055 Work case, Work pending-only).

Live proof (the sign-off stack reseeded; a new seed raises asks through ask_service.raise_ask with a RUNNING portal turn as platform_execution_id, so #3137's own path stamps the chat and the flag): (a) a chat-turn ask in a non-Main chat, (b) one in Main, (c) a schedule-run ask, (d) an ended chat-turn ask. Light + dark, 1440 + 390: tiles sit between the turn's message and its reply in both chats; finance Main (the schedule ask's reply target) draws nothing; reading 24 rows up, a tile arriving moves the read position 0 px; following at the bottom it stays 0 px from the bottom; answered in place the card keeps "Sent." and is a muted row on revisit; Work reads "8 waiting on you · Open in Inbox" and lands on ?tab=action&from=research-agent. No page errors.

Review round 2 (2026-09-30 → 10-01): the ruling build

Nine reviewers in parallel, report-only, against the reseeded live stack — the first round to walk as an external CLIENT (client@example.com, no platform account, on the roster of 2 agents via POST /api/agents/{a}/share, signed in through the portal code flow, asks raised by raise_ask with a running portal turn): /review, /cso --diff, a live design review, a plan-vs-build design review, desktop QA as admin, QA as the client, 768/375 dark QA, a blind Codex GPT-6-Sol pass, frontend gates per file vs dev. No P0; no disclosure (/cso: nothing at the gate; every client probe of an admin ask, chat or ?from=/?item= is a uniform 404 or resolves to nothing). One ruling check failed: 3, for the client — Work is a platform-only tab, so a client never saw the line.

Commit What
c589535fe Backend. GET /asks?chat_id=<id>: one chat's chat-turn asks (platform-stamped in-turn, ent#734), pending and ended, cleared kept, no 7-day window — the ruling puts the ended tile under the queue's retention (trinity#1142); SQL prefilter on the stored context (LIKE-escaped), the projection's top-level keys decide, so a look-alike key an agent nests never counts. _origin: only a chat turn verifies a thread, and the ask's own chat is named only when in-turn — a schedule delivering into the Workspace had its ask read "came from Main" with Main's unrelated messages (Codex C4). 7 mutations red.
78e4f4442 Tiles. An answered tile kept its card only if no chat switch happened within one poll (two watchers ran add-then-reset — /review P1): one watcher over (chat, waiting ids). Placement compares server times only — a second live turn's ask sat above the first reply, and a browser clock ahead lifted an ask above its message (sent rows are local and carry the newest server time before them; live replies carry their stored created_at). The chat reads its own asks and merges them under the store's fresher rows. The asks are read when a reply lands (plan-design P1: the tile waited for the 20 s poll), and a new tile counts toward "jump to latest". History row gray-500 in light (was 2.54:1 — four reviewers), full title as title, tile capped at 40rem. 13 mutations red.
34328d665 Client check 3 + Inbox + touch. PortalAsksWaitingLine: the one line, mounted by Work and Info for every principal (Info is on a client's rail), "1 ask / N asks waiting on you", same top row empty or not. Inbox: the agent strip from one agent (it appearing at the second pushed the list — Codex C5); a ?from= present at open is checked (the watcher was not immediate — the list silently showed everyone) and the head says "Nothing waiting from X · …"; arriving by the link focuses the list. The pane's failed-read link needs raised_in_turn. Pills carry sr-only text. 44px on any coarse pointer (28–34px at 768); the agent band steps aside on a short phone viewport so the keyboard leaves the tile's note visible. Dead chatId / open-rail-tab removed. 12 mutations red.
c2bbe45b8 Docs: requirements (core-agent, security §26), architecture (workspace, api-endpoints), 6 feature flows, 6 user docs that still said asks sit above the composer or in Work.
5e1b6f1ae From the live re-check: at 375 the ended row's title had 122 of 351px and clipped even wrapped — it now takes its own line (measured live: 351px, not clipped, both themes).

Live re-check after the fixes (admin + client, light/dark, 1440/768/375): 10/10 — the gray-500 row at 4.83:1; the client's Info line → ?tab=action&from=research-agent with focus on the list; Work's line at the same y empty or loaded (0px); strip at one agent and the dropped-filter head; a background ask shows no origin while an in-turn one shows its verified chat; sr-only pill text, marks sum to the Inbox (31 admin / 2 client); 44px with a coarse pointer at 768; the band hidden at 375×400 (thread 122px vs 19); tile order unchanged; 0 JS errors, 0 responses ≥ 400.

Decided, for review: an answered tile stays a card with "Sent." until you leave the chat (deliberate: the ent#468 confirmation and focus live there). A file-protocol ask raised mid-turn gets no tile (#3137's data half passes no turn on that path; fails safe to the Inbox). Pre-existing, not this PR — issues to file: the client stats band's "1%" for 12/12 (PortalAgentBand ratio not ×100, same on dev), the agent page's run list reaching a client with other people's run ids, a foreign chat id opening an empty chat with a live composer.

Sign-off follow-ups (2026-10-01)

Commit What
361164d54 The team's ruling on the answered tile: follow the 09-30 ruling — when the answer is recorded the card collapses right away into the muted row (kind · title · ending · who · when). The row carries the confirmation ("Answered by you · just now"), announced via a persistent aria-live region; focus moves to the row (tabindex=-1), never to body and never out of the composer; a failed answer leaves the card open with its error (the store writes only on success). Live: messages above 0px, composer 0px, the next message up exactly the card's own height change (170→16px); survives a chat switch. 5 mutations red.
0298a23c7 An ask's fenced code block was unreadable in the light theme: the inline-code pill (prose-code:bg-gray-100) also landed on the <code> inside typography's dark <pre>. Pre-existing on dev (#3115); the block's code now sits on the block.
78ff20202 Docs for the above.
dc55c2d54 Andrii's call on ?from= for an agent with nothing waiting: a ?from= the page opens on is kept (URL, facet), and the list says "Nothing is waiting on you from X." with "Show all agents"; a filter answered down to nothing during the visit still clears. 4 mutations red.

Pre-existing bugs found by round 2, filed: #3138 (agent band 1% for a 100% rate), #3139 (a client's agent page lists other people's runs), #3140 (a URL you can't open still shows a composer), #3141 (Work labels another person's turn "You asked"), #3142 (polish). Feature asked at the sign-off, filed separately: abilityai/trinity-enterprise#738 (reply to a message from inside the chat — reuses the Inbox's reply chip and reply_to_message_id).

Evidence for round 2 (HEAD 5e1b6f1ae)

  • CI: 23 pass / 5 skip / 0 fail on 5e1b6f1ae — incl. pytest (head, seed 12345) + regression diff, e2e, journey-smoke, prod-image-smoke, schema-parity, pg-migrations, CodeQL ×2. dev-ci success.
  • /verify-local (VERIFY_PYTHON=python3.13 --skip-agent, sibling project trinity-ent610-test, split in two runs): build + import-smoke ✓, boot + health ✓, integration 70 passed / 13 skipped / 2 deselected (the registry's known false-fails) → those stages PASS. The unit stage: 19979 passed / 40 failed, the same four files as round 1 and PR A's pristine dev (ent477 ×2, ent666, retention_floor — 24/16/61/18 pass in isolation), none touched here → order pollution, pre-existing.
  • FE: test:unit 4423/4423 (both ratchets, the source-text ratchet) · build ✓ · check:tokens ✓; per touched file no raw-palette, hex or loading-gate change vs dev.
  • Backend (py3.13): the asks set (10 files, incl. the new test_ent610_chat_turn_asks.py) 491 passed on seeds 1 / 12345 / 99999; lint_sys_modules at its baseline.
  • Mutations: backend 7, frontend 26 — every one red (three first-pass survivors got stronger tests, then red).
  • Gitlinks match origin/dev; enterprise-docs guard clean; no secrets, emails, IPs, host paths or mode changes in the diff.

Evidence for the ruling rework (HEAD 24916544b)

Evidence for round 1 (HEAD 96dbc82b5)

  • CI: 23 pass, 5 skip, 0 fail. Includes pytest (head, seed 12345) + regression diff, e2e, journey-smoke, prod-image-smoke, schema-parity, CodeQL. dev-ci success.
  • /verify-local (VERIFY_PYTHON=python3.13 --skip-agent, since docker/base-image is unchanged): build + import-smoke ✓, boot + health ✓, integration 70 passed / 13 skipped → PASS. The unit stage fails with 40 failures, all in test_ent477_definitions_endpoint (5), test_ent477_git_refresh_hook (10), test_ent666_objective_join (21) and test_retention_floor (4). That is the same set PR A recorded on pristine dev; all four files pass in isolation (24/16/61/18), and none is touched here. So it is order pollution, pre-existing.
  • FE: test:unit 4364/4364, both ratchets and the source-text ratchet included · build ✓ · check:tokens ✓.
  • Backend (py3.13): test_ent610_ask_context.py 22/22. 1499 passed across the client_portal-adjacent files (seed 99999); 583 passed on the asks set (seeds 1 and 99999). lint_sys_modules clean.
  • Live (the sign-off stack, rebuilt from scratch): the desktop QA re-run walked every item above in light and dark. It includes a real Enter in the note, sending one POST.
  • Gitlinks match origin/dev. The enterprise-docs guard is clean. Structural /review and /cso --diff ran as round-1 reviewers; they are not posted on GitHub.

Still open: Andrii's click-through of round 2 (admin + client, light/dark, phone); L8 → its own follow-up PR; the .claude/agents/test-runner.md catalog rows (private submodule).

🤖 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.
… ask (ent#734 r2)

After ent#734 a chat-turn ask raised in MAIN and a background ask (schedule /
loop / gate) both carry chat_id = Main, but the ent#610 amendment draws the
first as a tile in Main and the second in no chat, so #3101 could not tell
them apart.

The row now carries one more platform-written fact,
context.workspace_raised_in_turn = true, written only when the raising turn's
chat matched (`_workspace_attachment` returns `(chat, raised_in_turn)`;
`_workspace_thread_for` stays as the file path's no-turn view). The client
projection names it as WorkspaceAsk.raised_in_turn (only a literal true
counts).

Both workspace keys are now `_PLATFORM_CONTEXT_KEYS`: stripped from
agent-authored context on the native and the file path, and ignored by the
replay `differs` and the #2915 file-sync comparison, so a planted flag is
neither honoured nor read as a rewrite. 8 named mutations, each red.
…ent610-a2 (ent#610 PR A2 stacks on ent#734)

# Conflicts:
#	docs/memory/feature-flows/workspace-agents-at-the-centre.md
…no frontend reads raised_in_turn yet, drop a repeated clause, _comparable_context docstring names both platform keys
…hread; nothing sits above the composer (ent#610 PR A2, 09-30 ruling item 1)

The ruling as amended the same day, on ent#734's data (#3137): an ask with
raised_in_turn === true is drawn in the chat its chat_id names, as a row of
the thread placed by time among the messages (portalChatAsks.placeAsksInThread:
before the first row strictly newer than the ask; a row with no time of its
own is a reply just received, so the newest; the person's own message is
stamped as it is sent). While it waits it is the one PortalAsks card,
answerable in place (thread link off: it IS in its chat). An ask seen waiting
on this visit keeps its card after it ends (the ent#468 confirmation); any
other ended ask is one muted history row: kind · title · ending with who ·
when. It stays for as long as the asks read returns it (7 days).

A background ask (raised_in_turn false: schedule, loop, gate) draws in no
chat, Main included; an unattached ask no longer falls to Main.

Removed: the pinned splitChatAsks box above the composer and the "N more
asks" line (principle 30), with splitChatAsks / pinnedAskIds /
chatAsksLabel / chatAsksElsewhere. Their specs are rewritten for the new
homes (round 4 §4, round 5, #3115 strip, B1 residual, single-source,
agent-page-ux), not deleted.

Mutations, each run once and restored from a scratch copy:
  M1 raised_in_turn truthy instead of === true   -> 1 red
  M2 a tie places the ask before the row         -> 1 red
  M3 an undated row is the oldest, not newest    -> 1 red
  M4 seen-waiting ignored (ended = row at once)  -> 3 red
  M5 the tile keeps "Open the conversation"      -> 1 red
  M6 history rows lose their persisted time      -> 2 red
FE unit 4375/4375.
…raws it, no card points at Main for it (ent#610 PR A2, 09-30 ruling item 2)

The amendment's second home: an ask raised by a schedule, loop or gate
(raised_in_turn not the literal true) keeps chat_id = Main as a fact on the
row — the Inbox pane's reply lands there — but renders in no chat: no tile in
Main, no history row. The Inbox's Action tab lists it while it waits and its
All tab is its history.

Item 1's chatTurnAsks already keeps it out of every thread; this pins that
for Main by mount, and closes the one pointer left: askThreadLink offered
"Open the conversation" (to Main) on any card with the link on, so a
background ask sent the reader to a chat where the ask is not. It now links
only an ask a chat turn raised. The link fixtures that model a chat-turn ask
say so (raised_in_turn: true); workspaceAsks gains the background case.

Mutations, each once, restored from a scratch copy:
  M1 askThreadLink accepts a truthy raised_in_turn   -> 1 red
  M2 askThreadLink ignores raised_in_turn            -> 2 red
  M3 chatTurnAsks ignores raised_in_turn (Main)      -> 1 red
FE unit 4380/4380.
…pen in Inbox" line; every asks door goes to the Inbox (ent#610 PR A2, 09-30 ruling item 3)

The ruling's item 2: an agent's asks home is the Inbox filtered to that
agent, on every door, for platform users and clients alike. Sign-off round
5's "Work is the asks' home" is reversed:

- Work draws no ask. While any of the chat's agents waits on you it shows
  ONE line, "N waiting on you · Open in Inbox", to asksHomeRoute(agent) =
  /workspace/inbox?tab=action&from=<agent> (§3g C2's filter, kept); a room
  with asks from two agents links to the whole Action tab. It counts
  portal.openAsks (the list the sidebar and the pinned Inbox row read), so
  it never counts an ended ask.
- The line sits on the Now heading's row, which is always drawn, so it
  arriving or leaving moves nothing (principle 30); an empty Work carries
  it under the empty copy.
- Round 5's "Work skips the pane's ask" is undone: nothing is drawn to skip,
  so PortalWork's excludeAskIds, the shell's inboxOpenAskIds and PortalAsks'
  excludeIds are removed, and the line counts the pane's ask too.
- The two other doors that sent "the asks" to Work — the new-chat briefing's
  asks suggestion (Portal.vue openSuggestionSection) and Info's section link
  (PortalAgentDetails openSection) — push asksHomeRoute instead.

Specs rewritten for the new home, not deleted: #3055's Work-rail case is now
a card read from another chat; the Work pending-only case counts the line;
round 5's exclude pair asserts the list is gone; portalWork's source guard
asserts no PortalAsks. New portalWorkAsksLine.spec.js mounts Work with a
real router (hrefs asserted).

Mutations, each once, restored from a scratch copy:
  M1 always narrow to the first agent        -> 1 red
  M2 asksHomeRoute drops ?from=              -> 2 red
  M3 count portal.asks (ended included)      -> 4 red
  M4 the empty state loses the line          -> 5 red
  M5 the asks suggestion opens Work again    -> 1 red
FE unit 4391/4391.
…eds you" mark — one feed with the Inbox row, pinned (ent#610 PR A2, 09-30 ruling item 4)

The ruling's item 3: agent rows carry a "needs you" mark beside the unread
mark, from the same feed the pinned Inbox row counts. The pill predates the
ruling (#2424, coloured by §3g A3b) and already reads openAsks; what was
missing is the contract on it, now pinned by mount:

- one feed: the rows' marks sum to the Inbox row's number for any mix of
  chat-turn and background asks (both need you; only their homes differ);
- pending only: an answered ask leaves the row mark and the Inbox row at
  once;
- it sits beside the unread mark, ask first;
- it names itself on hover in the Inbox row's words (askBadgeTitle: "N asks
  are waiting on your answer") — the one code change; it had no title.

Mutations, each once, restored from a scratch copy:
  M1 the pill loses its title                  -> 1 red
  M2 the rows count store.asks (ended too)     -> 2 red
  M3 the ask pill moves after the unread pill  -> 1 red
FE unit 4394/4394.
…t the retired box's (ent#610 PR A2, item 1 follow-up)

With testid-prefix "portal-chat-ask", PortalAsks names its root
`portal-chat-asks` — the address of the pinned box item 1 removed. The live
probe's "is anything still pinned above the composer?" query matched every
tile. The mount spec could not see it: shallowMount stubs PortalAsks. The
tile is now `portal-tile-ask` (root `portal-tile-asks`), and the spec pins
the prefix prop.

Live-proved on the seeded stack (light + dark, 1440 + 390): tiles sit by
time among the messages in a non-Main chat and in Main; a schedule ask
draws nothing in finance Main; reading 24 rows up, a tile arriving moves
the read position 0px; following at the bottom, it stays 0px from the
bottom; answered in place, the card stays with "Sent." and is a muted row
on revisit; Work reads "8 waiting on you · Open in Inbox" and lands on
/workspace/inbox?tab=action&from=research-agent.
…the 09-30 ruling rework (ent#610 PR A2)

- workspace-agents-at-the-centre: a chat draws only its chat-turn asks, as
  thread rows by time (portalChatAsks); the pinned box and the "N more
  asks" line are gone; the thread link is for chat-turn asks only.
- workspace-work: Work = Now · Earlier + one "N waiting on you · Open in
  Inbox" line (asksHomeRoute); test rows updated.
- workspace-sidebar-ia: the agent row's ask pill is the ruling's "needs
  you" mark, one feed with the Inbox row.
- architecture/workspace.md and requirements/core-agent.md AC-4: Work
  draws no ask.
Disclosure guard clean. Index unchanged (no new flow; 468 lines, pre-existing).
…queue's retention; a background ask names no chat origin (ent#610 PR A2, round 2)

The 09-30 ruling (amended): an ask a chat turn raised stays in that chat's
history once ended, "subject to the same retention as the queue (trinity#1142)";
a background ask lives in the Inbox only.

- GET /asks?chat_id=<id>: the viewer's asks the platform stamped as raised in a
  turn of that chat (ent#734), pending and ended, cleared kept, NO ended window.
  The Inbox read's 7 days and first page of 200 no longer decide what a chat's
  history shows (review I3, codex C3). SQL prefilter on the stored context
  (LIKE-escaped, so `_`/`%` in an id match only themselves); the projection's
  top-level keys decide, so a look-alike key an agent nests never counts.
- _origin: only a chat TURN (triggered_by=public) verifies a thread — a schedule
  that delivers into the Workspace stamps the portal channel and Main on its run,
  so its ask read "came from Main" with Main's unrelated messages (codex C4, QA
  P2-2, cso I3). The ask's own chat is named only when the platform stamped it
  in-turn; a background ask has no chat origin (its run still says where it
  came from).

Tests: test_ent610_chat_turn_asks.py (10, red first) + 2 in
test_ent610_ask_context.py (red first; the unverified-fallback case now carries
the in-turn stamp). Mutations, each red once: any trigger verifies; fallback
without the in-turn stamp; no top-level post-filter; no LIKE escape (total);
chat read drops cleared; chat read keeps the window (via include_ended); no SQL
prefilter.
…it by server time, stay in history past 7 days and arrive with the turn (ent#610 PR A2, round 2)

Round-2 review of the 09-30 ruling build (item 1, the tile in its chat):
- C1 (review P1): answering a tile within one poll of switching to its chat
  dropped its card and ent#468 confirmation — the reset watcher ran after the
  ids watcher. One watcher over (chat, waiting ids).
- C2 (review P2, codex C6/V1): a second live turn's ask sat above the first
  reply, and a browser clock ahead lifted an ask above the message that raised
  it. Only server times are compared now: a sent message is `local` and sits at
  the newest server time before it; a live reply carries its stored
  `created_at` (replyFromHistory → `at`).
- I3 / codex C3: the chat also reads its own chat-turn asks
  (`fetchChatTurnAsks`, GET /asks?chat_id=, no ended window); the store's
  fresher row wins for the same id; a read for a chat already left is dropped;
  a failed read keeps the store's tiles and warns.
- plan-design P1-B: the asks are read when a turn's reply lands, not left to
  the 20 s poll; a new waiting tile counts toward "jump to latest" (QA mobile F3).
- I5: while earlier messages are not shown, an ask older than the first row
  shown is left out instead of stacked at the top.
- design/QA P1: the history row was gray-400 on white (2.54:1) — META_INK_CLASS
  is gray-500 in light (4.83:1), which also fixes its two voice uses; the row's
  title wraps to two lines on a phone and carries its full text as a title;
  the tile is capped at a readable 40rem.

Tests: 13 new in portalChatAskTiles.spec.js (red first) + the reply's `at` in
portalReplyRateable.spec.js. Mutations, each red once (13): the reset watcher
back; local row as newest; no truncated drop; chat read ignored; no stale-chat
guard; chat read wins; gray-400 back; a reply without its time; reply drops
`at`; refresh does nothing; no arrival count; no hover title; send stamps the
browser clock.
…ts it too; Inbox strip, dropped filter, focus, touch and keyboard (ent#610 PR A2, round 2)

Round-2 review of the 09-30 ruling build (items 2–4, and the pane):
- Client QA check 3 FAIL: Work is a platform-only tab, so a client never saw
  "N waiting on you · Open in Inbox" — the ruling gives platform users and
  clients the same answer. The line is one component (PortalAsksWaitingLine),
  mounted by Work and by Info for every principal (Info is on a client's rail).
- Wording names the noun: "1 ask / 5 asks waiting on you" (plan-design G).
- Work's empty state keeps the line in the same top row as the loaded state
  (it sat last under the empty copy and jumped ~181px — design F2, QA N2).
- Inbox: the agent strip draws from ONE agent, so a second agent's ask no
  longer pushes the list down (codex C5); a ?from= the page opens with is
  checked (the strip watcher was not immediate — QA mobile F5's silent
  everyone-list) and the head says "Nothing waiting from X · …"; arriving by an
  "Open in Inbox" link focuses the list, not the body (QA P2-3).
- Pane: a background ask's failed-read fallback no longer offers "Open the
  conversation" into Main (codex C4, frontend half).
- Sidebar: the ask and unread pills each say what they count to a screen
  reader (design F3 / plan-design F).
- Touch: the ask card's chips, note and Send are 44px on any coarse pointer,
  not only below sm (QA mobile F2: 28–34px at 768); the agent band steps aside
  on a short phone viewport so the keyboard leaves the tile visible (QA mobile F1).
- Dead code (review I2): PortalWork's `chatId` prop (no reader; the rail store
  scopes the children) and PortalAgentDetails' never-emitted `open-rail-tab`;
  stale "Waiting on you" comments. Review I1: the excludeAskIds case (a prop
  that no longer existed) and the comment-anchored guard are rewritten.

Tests: portalAsksWaitingLine.mount.spec.js + portalRound2Touch.spec.js (new),
cases added in portalWorkAsksLine / portalInbox.mount / portalInboxAgentFilter /
portalAskContext.mount / portalAgentNeedsYouMark.mount (red first); old pins
updated in portalWork / portalAskEndings / portalSignoffRound5. FE 4422/4422,
build, check:tokens, both ratchets. Mutations, each red once (12): strip only
at 2 agents; dropped filter not remembered; strip watcher not immediate; no
focus on arrival; fallback link for background asks; no sr text on the ask
pill; Send not 44 on coarse; band never steps aside; wording without a noun;
Info has no line; empty-state line gone from the top row; head never says the
filter was dropped.
…e line on Info, Inbox strip (ent#610 PR A2)

Review I4 drift fixed (core-agent Work description + AC-4, the dismantled page's asks row, workspace.md pending-only/7-day, workspace-agent-page, sidebar-ia 'sum' → same feed + sr labels). The round-2 behaviour documented where it is described: GET /asks?chat_id= (api-endpoints, security §26, workspace.md), the chat-turn-only origin (core-agent, security, workspace-inbox), tile placement by server time + the chat read (agents-at-the-centre), PortalAsksWaitingLine on Work and Info, the Inbox strip from one agent / dropped ?from= / focus. User docs that still said asks render above the composer or under Work's 'Waiting on you' now say where an ask lives per the 09-30 ruling.
…n line (ent#610 PR A2, round-2 re-check)

The live re-check measured the title at 122 of the row's 351px at 375 — the
kind, ending and time are shrink-0 — so even wrapped to two lines it clipped.
Below sm the row wraps: kind · ending · when on one line, the title full width
under it (the dot between kind and title is hidden there). Measured live at 375
in both themes: title 351px, not clipped.

Test: portalRound2Touch.spec.js (red first; declared source-text pin — a
responsive class jsdom does not evaluate). Mutation (drop max-sm:flex-wrap): red.
… row, announced, with focus on the row (ent#610 PR A2, team ruling 2026-10-01)

The team's ruling on #3101 (follow the 09-30 ruling): when the answer is
recorded the card collapses right away into the muted row — kind · title ·
ending · who · when. Round 2 had kept the answered card, with its "Sent.",
until you left the chat.
- askTileMode: pending → card, anything else → row (the seen-set exemption is
  gone). A failed answer leaves the ask pending in the store (answerAsk writes
  only on success), so its card stays open with PortalAsks' error.
- The row's confirmation ("Answered by you · just now") is announced through a
  persistent aria-live region; an ask already ended when the chat opened is not.
- Focus moves to the row (tabindex=-1, the design-system ring) when the card
  took it down with it; it never leaves the composer or anywhere else it sits.
- Live (1440, light, admin, a real answer through the tile): messages above
  0px, composer 0px, the next message up 154px = the card's own 170→16px
  change; focus on the row; announced; the row survives a chat switch; 0
  console errors.

Tests (red first): portalChatAskTiles.spec.js — collapse + announce + focus on
the row, no focus steal from a focused field, no announcement on open, the
switch-then-answer case (it must still be seen to be announced); the unit and
portalSignoffRound5 askTileMode cases reversed. Mutations, each red once: an
answered card stays a card; nothing announced; focus stolen from anywhere;
focus never moved; row not focusable. (Announce-on-first-paint is equivalent:
nothing leaves a chat's pending set on its first paint.)
…me (ent#610 PR A2 sign-off)

AskMarkdown's inline-code pill (prose-code:bg-gray-100) also landed on the
<code> inside typography's dark <pre> (light text), so a block read
light-on-light — Andrii's sign-off screenshot, the Inbox pane, light only
(dark's pill is gray-700). The pill is for inline code; a block's code now
sits on the block: [&_pre_code]:bg-transparent [&_pre_code]:p-0.
Live, light: code rgba(0,0,0,0) on the pre's rgb(31,41,55), text
rgb(229,231,235).

Test: askMarkdownCodeBlock.mount.spec.js (mounted, red first). Mutation (drop
the two classes): red. Pre-existing on dev (#3115); ChatBubble.vue carries the
same pill class but with indigo text, which stays readable — left alone.
…unced, focused row (ent#610 PR A2, team ruling 2026-10-01)
…hat agent has nothing waiting (ent#610 PR A2 sign-off)

Andrii's sign-off (2026-10-01): /workspace/inbox?tab=action&from=acme-sage
rewrote itself to ?tab=action and listed everyone's asks, saying so only in the
11px uppercase list head — it read as a silent redirect. His call: keep the
filter, show it empty.
- Arrival (the page opens on a ?from=, or the ?from= changes): kept even with
  nothing waiting — the URL keeps it, the facet stays chosen, the list says
  "Nothing is waiting on you from X." with "Show all agents" (→ ?tab=action).
- A filter answered down to nothing DURING the visit still clears (round 1),
  the head saying "Nothing waiting from X · …".
- listHeadLabel: an empty filtered Action says "Nothing waiting from X", not
  "All caught up".
Live (typed URL, light): URL kept, the empty copy and link, Show all → 31
rows, 0 page errors.

Tests (red first): portalInbox.mount — arrival keeps it (URL, no rows, copy,
link href, facet chosen); a mid-visit drain still clears with the head notice;
portalInboxAgentFilter — the head wording. Mutations, each red once: never
kept; a drained filter never clears; generic empty copy; generic head.
@vybe

vybe commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

merge-train (#3143): on this train, merging third, after #3054 and #3137. /validate-pr and /review came back READY with no criticals, the heads check passes with a single head, and the full vitest suite passes.

Edited for you: I added Fixes abilityai/trinity-enterprise#734 to the body. #3137 delivers that issue's AC 1/4/5 and this PR delivers AC 2/3, so this is the PR that completes it. Refs …#610 stays because L8 is still to come.

Non-blocking:

  • QueueBrief / briefOf render nothing in production until L8 supplies brief.
  • The comment near PortalConversation.vue:1099 still says an answered ask "keeps its card … until you leave the chat". That stopped being true with 361164d.
  • The ?chat_id= total counts SQL prefilter hits before _in_chat, so it can overcount. Nothing reads it today.
  • fetchChatTurnAsks ignores next_cursor.

vybe pushed a commit that referenced this pull request Oct 1, 2026
…at chat (ent#734) (#3137)

* feat(asks): an ask raised during a Workspace chat turn attaches to that chat (Abilityai/trinity-enterprise#734)

Every addressed ask was filed under the pair's Main chat because nothing at
raise time knew which execution raised it. The native raise now hands the
platform-injected execution id (#2392, validated as the agent's own) to
`_workspace_thread_for`; `client_portal.service.chat_for_execution` returns
that turn's chat iff the row is a Workspace chat turn (`triggered_by="public"`,
`source_channel="portal"`) of the same agent and addressee and the session
belongs to that pair. The link is the one both portal creation sites already
stamp (ent#457/#2426), so no column and no migration.

Everything else keeps Main, unchanged: schedules (including one delivering
into the Workspace, which carries the portal stamp), loops, rooms, delegated
children, gate raises, no/unknown/foreign executions, another addressee's
chat, and every file-ingested ask (an agent-cited execution id is never
read). Fail-soft: a lookup failure attaches to Main with a warning. The
ent#429 strip of an agent-authored workspace_session_id stays.

Data half only (AC 1, 4, 5); the chat tile and the ended marker (AC 2/3)
belong to #3101.

* fix(asks): only a running chat turn can place an ask in its chat (ent#734 review r1)

cso/review r1: the X-Trinity-Execution-Id header is platform-set, but the
agent's own process holds its key and can send any of its executions' ids.
chat_for_execution accepted any of them, so a finished turn of another chat
(same addressee) could file an ask there. It now requires the turn to be
RUNNING. The remaining bound — another live turn of the same agent for the
same addressee — is the #2392 trust level turn_audience already accepts, and
the docstrings/requirement no longer claim more than that.

An archived (reset) Main is deliberately NOT excluded: it stays listed,
readable and resumable, and Reset is refused mid-turn, so a running turn there
is a person talking in a chat they can see. Pinned, with the email-case match
(both mutation-checked).

* feat(asks): mark an ask raised in a chat turn apart from a background ask (ent#734 r2)

After ent#734 a chat-turn ask raised in MAIN and a background ask (schedule /
loop / gate) both carry chat_id = Main, but the ent#610 amendment draws the
first as a tile in Main and the second in no chat, so #3101 could not tell
them apart.

The row now carries one more platform-written fact,
context.workspace_raised_in_turn = true, written only when the raising turn's
chat matched (`_workspace_attachment` returns `(chat, raised_in_turn)`;
`_workspace_thread_for` stays as the file path's no-turn view). The client
projection names it as WorkspaceAsk.raised_in_turn (only a literal true
counts).

Both workspace keys are now `_PLATFORM_CONTEXT_KEYS`: stripped from
agent-authored context on the native and the file path, and ignored by the
replay `differs` and the #2915 file-sync comparison, so a planted flag is
neither honoured nor read as a rewrite. 8 named mutations, each red.

* docs(asks): ent#734 re-review nits — name _workspace_attachment, say no frontend reads raised_in_turn yet, drop a repeated clause, _comparable_context docstring names both platform keys

---------

Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com>
#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>
@vybe

vybe commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

merge-train: I pushed 72e4067da to this branch, a merge of dev after #3054 and #3137 landed. Those two arrived on dev as squashes, which this branch's history doesn't recognise, so 26 files conflicted even though this branch already contained their final content. I resolved each one to this branch's side as integrated on train #3143. The resulting tree is byte-identical to the train's tree, which passed CI. No content changed; the diff against dev is this PR's own 74 files again.

@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); post-squash dev merge is tree-identical to the train

@vybe
vybe merged commit 33d09ac into dev Oct 1, 2026
28 checks passed
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.

3 participants