feat(workspace): Inbox asks UI + an ask's context (ent#610 PR A2: §3g L6 + L7) - #3101
Conversation
…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.
# Conflicts: # tests/registry.json
…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.
…10-a0 # Conflicts: # tests/registry.json
…o AndriiPasternak31/ent610
… 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.
…2 (ent#610 PR A2)
…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).
…riiPasternak31/ent610-a2
…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.
|
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 Non-blocking:
|
…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>
|
merge-train: I pushed |
Fixes abilityai/trinity-enterprise#734
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/ent610first:f890260a8,722911577)f890260a8)workspaceAskBadge?from=)QueueBrief.vueWhat changed
?from=<agent>. The filter holds while that agent's just-answered ask is on screen, then clears itself. A tab change drops it.aria-describedbyhint. 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).GET /api/enterprise/client-portal/asks/{id}/context, a new read reachable with a portal token:_owned_askis extracted fromanswer_askand shared by both routes, so the 404 body is identical whatever the cause. For the context read, a roster outage is 503.execution_idit comes from is written by the agent.costand noexecution_idin the body. Rate limit 120/min per viewer.asks/router.pygets its# mcp: noneheader.PortalAskContext.vuesits 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:
831aa9256manualrun now shows only to whoever started it (manualis 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.214ddd298OverflowTabsgains aneutralbadge 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.3447d8a10b5a29b21fOverflowTabsraw_gray +4 (the neutral tone),PortalAsks−1. Hand-edited, named inrefrozen._ent610_a2_note.7e8b9ac214b1499c8fon #3054: #3115 AskMarkdown, ent#661 Projects, ent#720). Option labels and quick-picks render through AskMarkdown.96dbc82b5?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_idandraised_in_turn(true only when a Workspace chat turn raised it; only the literaltruecounts). One commit per item, red test first, every named mutation run once and restored from a scratch copy (counts in the commit bodies).5566f42c6,f06809f2fportalChatAsks: araised_in_turnask draws in the chat itschat_idnames, 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 pinnedsplitChatAsksbox and the "N more asks" line are removed. 6 mutations; the follow-up gives the tile its own testid namespace6ae00b1f0askThreadLinknow needsraised_in_turn). The Inbox's Action lists it; All is its history. 3 mutations5e0493cd3/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/excludeIdsremoved); the briefing's asks suggestion and Info's asks link go to the same Inbox route. C2's filter kept. 5 mutations8667a672copenAsks, 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 mutations24916544bSpecs 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_askwith a RUNNING portal turn asplatform_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 viaPOST /api/agents/{a}/share, signed in through the portal code flow, asks raised byraise_askwith 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.c589535feGET /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.78e4f4442localand carry the newest server time before them; live replies carry their storedcreated_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 astitle, tile capped at 40rem. 13 mutations red.34328d665PortalAsksWaitingLine: 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 needsraised_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. DeadchatId/open-rail-tabremoved. 12 mutations red.c2bbe45b85e1b6f1aeLive 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-agentwith 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 (
PortalAgentBandratio 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)
361164d54aria-liveregion; 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.0298a23c7prose-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.78ff20202dc55c2d54?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)5e1b6f1ae— incl.pytest (head, seed 12345)+regression diff,e2e,journey-smoke,prod-image-smoke,schema-parity,pg-migrations, CodeQL ×2.dev-cisuccess.VERIFY_PYTHON=python3.13 --skip-agent, sibling projecttrinity-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.test:unit4423/4423 (both ratchets, the source-text ratchet) ·build✓ ·check:tokens✓; per touched file no raw-palette, hex or loading-gate change vs dev.test_ent610_chat_turn_asks.py) 491 passed on seeds 1 / 12345 / 99999;lint_sys_modulesat its baseline.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)test:unit4394/4394 (both colour/gate ratchets and the source-text ratchet; raw-colour counts for every touched file are exact vs the baseline) ·build✓ ·check:tokens✓.test_ent734_ask_raising_chat.pyincluded, 511 passed on seeds 1 / 12345 / 99999.origin/dev; enterprise-docs guard clean. Not run for the rework: /verify-local, /review, /cso --diff, CI (pending on push) — that is review round 2.Evidence for round 1 (HEAD
96dbc82b5)pytest (head, seed 12345)+regression diff,e2e,journey-smoke,prod-image-smoke,schema-parity, CodeQL.dev-cisuccess.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 intest_ent477_definitions_endpoint(5),test_ent477_git_refresh_hook(10),test_ent666_objective_join(21) andtest_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.test:unit4364/4364, both ratchets and the source-text ratchet included ·build✓ ·check:tokens✓.test_ent610_ask_context.py22/22. 1499 passed across the client_portal-adjacent files (seed 99999); 583 passed on the asks set (seeds 1 and 99999).lint_sys_modulesclean.origin/dev. The enterprise-docs guard is clean. Structural/reviewand/cso --diffran 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.mdcatalog rows (private submodule).🤖 Generated with Claude Code