Repository navigation
feat(workspace): reply to a message from inside the chat (abilityai/trinity-enterprise#738) - #3168
Conversation
…rinity-enterprise#738) Until now a reply to one message could only start in the Inbox. Every persisted agent message in the 1:1 chat now has a Reply action in its own row (Copy · Reply · thumbs). It sets the same replyTarget the Inbox sets, so the composer chip, the send (reply_to_message_id) and the 422 path are unchanged. - PortalAgentBubble owns Reply (replyLabel / replyDisabled / reply). Copy and Reply share one recipe at the thumbs' ink: gray-500 light, gray-400 dark. Copy's light gray-400 was 2.54:1 on white. - The Inbox pane's arrow is now the same action; its emit is unchanged. - PortalConversation emits reply with currentSessionId, never the prop: on a URL with no :sessionId, that is where the resolved chat lives. Focus moves inside the tap. Reply is disabled in place during a voice call. - Portal.vue: setReplyTarget, plus a convKey clear. On a URL that does not name the chat, New chat or an agent switch left the target hidden, and it came back with that chat. - Esc drops the chip, innermost first: typeahead, then chip, then turn. resolveComposerKey returns 'drop-reply' when a chip is on screen, and the preventDefault makes the turn-cancel listener yield. - The textarea is aria-describedby the chip's "Replying to: …" text. PortalReplyChip gains textId so the description excludes the remove button's name. Reply is 22px like its row. The 44px touch floor moves row-wide to trinity#3056 (user ruling on ent#738). Keeping the quote after a reload is ent#746. Tests: portalInChatReply.mount.spec.js (new), portalInboxShell.mount.spec.js (the real Portal.vue), and resolveComposerKey cases. 18 call-site mutations, all red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bilityai/trinity-enterprise#738) The message action row's Copy and Reply share one recipe at the rating thumbs' ink, gray-500 light / gray-400 dark. Copy had no dark half, and that added `dark:text-gray-400` is the +1. Gray is the neutral ink ladder; no semantic token covers it. Edited by hand for this one entry, with the reason under refrozen._ent738_note, not regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nterprise#738) No findings. Frontend only, with no new endpoint or agent-context ingress. The client-chosen reply id is challenged against reply_context's uniform 422 (test_ent610_reply_to_message.py, 18 passed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rn (Abilityai/trinity-enterprise#738) Review of #3168. Innermost-first held only in the textarea. Escape with focus on the chip's own × reached the document-level turn-cancel, so it stopped a running turn and left the chip. PortalReplyChip now claims Escape itself when removable (`preventDefault` + `remove`), the same `ownsEscape` protocol the textarea's drop-reply uses. - The shared action-row hover is `enabled:` only. `:hover` still matched the Reply disabled during a voice call, and a `disabled:hover:` reset would tie `dark:hover:` on specificity (#2662). No raw-colour count moves. - Tests: - Esc on × with a turn in flight: no cancel, chip gone, caret back, and a second Esc stops the turn (red without the handler, red without its preventDefault). - The rating sits in the real conversation's row after Reply. - No Reply while the chat has no session. - A second Reply moves the chip and keeps the draft. - An agent switch on a URL that does not name the chat drops the target. - Dropped the click-a-disabled-button checks: VTU's trigger skips disabled elements, so they tested the library. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eld (Abilityai/trinity-enterprise#738) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
merge-train: validated READY: no criticals, and the mount specs fail for each wiring line I reverted. It's held only because it conflicts with #3156 (on this train) in |
AndriiPasternak31
left a comment
There was a problem hiding this comment.
Approve with nits. Reviewed at 96ab54389.
I found no correctness, auth or prompt-safety defect. The change is frontend-only. reply_context still re-proves the agent, email and session server-side, returns one uniform 422, and builds the quote from the DB, bounded at 2000 chars. Esc order, the clear-on-chat-switch watcher and focus behaviour are all covered.
Verified locally
vitest: the PR's 4 specs plus the raw-colour, loading-gate and source-text ratchets pass.vite buildexits 0.- Mutation battery: I broke 8 wiring lines one at a time (
hasReply, thedefaultPreventedguard, the session the reply names,focusComposer, the convKey watcher ×2, the chip's EscpreventDefault, andreply-disabled). 8 of 8 turned a test red. - Not verified: a live browser in both themes or at 375px.
Before merge
- It conflicts with dev. The only clash is in
PortalConversation.vue, indefineEmits: #3156 added'thread-missing'and this PR adds'reply'. Keep both, then re-run CI. The current green runs predate the conflict. - Collision with #3181 (medium; whichever PR lands second fixes it):
- #3181 renders its "Send as answer" row between
PortalReplyChipand the composer box, so the chip, which is styled as a tab fused to the composer, ends up floating. answerDiscussedAskneither reads nor clearsreplyTo. In a discussion chat, Reply then Send as answer sends the answer without the quote, and the chip survives, so the next ordinary message silently becomes a reply.- Suggested fix: render that row above the chip, have a successful Send as answer clear the reply (or make the two mutually exclusive), and add a mount test with both present.
- Note: #3181's diff doesn't actually use this PR's reply entry point. Apart from this, the two PRs are independent.
- #3181 renders its "Send as answer" row between
Low / nits
- Reply is never an ask decision: no
operator_queuewrite, so the single-row CAS is untouched. Replying "yes" to the message that raised an ask leaves the ask pending. Worth one line inworkspace.md. - This is pre-existing, but the PR widens it:
channel_completion_reportstores a delegated child's result as anassistantrow, andreply_contextquotes it as "your earlier message". Reply now puts that one click away on every report bubble. A follow-up could framecompletion:*rows differently. PortalAgentBubble.vue:46hard-codesdata-testid="portal-message-reply", so the Inbox's per-messageinbox-pane-reply-to-<id>is gone. There's no live collision, because the panes are mutually exclusive, but tests lose a per-message address.ACTION_BUTTONusesfocus:ring-2where the old Inbox arrow usedfocus-visible:, so a mouse click now shows a ring.drop-replyalso fires on Shift/Ctrl/Meta+Esc (portalUtils.js:1281).
ent#738 acceptance criteria: all met, except the 44px touch target, which is partial. That deviation is documented: the product-owner ruling moved it row-wide to #3056.
# Conflicts: # src/frontend/src/components/portal/PortalConversation.vue
…rite errors F1: an answered ask's resume run reports into the ask's DISCUSSION chat only. The attached-chat fallback (Main for a background ask) widened every answered ask's audience to the client — verbatim final reply, shared files, inherited by delegated children. The run is now told its reply goes to the person. Store watch narrowed to match. Tests pin turn_audience and delegation inheritance for the stamped run. F2: the discussion-link write tolerates only SQLite's busy snapshot (OperationalError). Any other failure, or a busy write with no racing link while the ask is still pending, is a retryable 503 instead of "409 This ask is already pending". F3: the discussion's system notice no longer quotes the agent-written title. F4: options are truncated before JSON encoding, keeping the closing quote. F5: Dismiss refuses alerts (422), like Discuss and the card. F7: real TestClient tests through get_portal_principal (agent key 403) and the live in-process limiter (429). Send as answer stands down while a reply chip or attachment is in the composer (#3168 collision). tables.py disposition comment lists dismissed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…prise#747, #748) (#3181) * feat(workspace): Discuss and Dismiss on asks (ent#747, ent#748) Dismiss (Abilityai/trinity-enterprise#748): one click ends a pending question or approval as disposition=dismissed (status stays cancelled) through the ask ending sink — audit, thin broadcast, and the filer's wake with its own framing. 5s client-side Undo window; nothing is sent until it lapses. Addressee only (person gate, uniform 404); a dismiss that loses a race, or lands past the deadline, is a no-op. Discuss (Abilityai/trinity-enterprise#747): opens one chat per ask with the asking agent (CAS link in a new platform-only context key), titled after the ask, with the ask as its tile; a turn-context provider puts the ask (id, kind, live status, options) in every turn there. The ask stays one pending row; a question can be answered from the composer with "Send as answer" (written as response). Also renders agent markdown in the ask context's "Your recent answers" list, the surface #3115 missed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): Discuss opens its new chat instead of "isn't available" The Inbox turned Discuss's open-thread into a bare /workspace/c/<id> push, and the #3140 guard read the just-created chat (not yet in the thread list) as not the viewer's. Route it through the shell's openThread, which adopts the id, and refresh the list. The chat tile now forwards open-thread too — its Discuss did nothing before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(workspace): an answered ask's result comes back to its chat (ent#747) An answer that woke an opted-in agent left the woken run's output in the execution history only — the person saw the agent start and never saw what it did. The resume run now carries the Workspace destination (the ask's discussion chat, else its attached chat; only when the answerer is the addressee), so the ent#457 completion report posts the result there as an agent message. The open chat watches for it for up to 5 minutes, since it has no history poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): address #3181 review — discussed-ask audience, link-write errors F1: an answered ask's resume run reports into the ask's DISCUSSION chat only. The attached-chat fallback (Main for a background ask) widened every answered ask's audience to the client — verbatim final reply, shared files, inherited by delegated children. The run is now told its reply goes to the person. Store watch narrowed to match. Tests pin turn_audience and delegation inheritance for the stamped run. F2: the discussion-link write tolerates only SQLite's busy snapshot (OperationalError). Any other failure, or a busy write with no racing link while the ask is still pending, is a retryable 503 instead of "409 This ask is already pending". F3: the discussion's system notice no longer quotes the agent-written title. F4: options are truncated before JSON encoding, keeping the closing quote. F5: Dismiss refuses alerts (422), like Discuss and the card. F7: real TestClient tests through get_portal_principal (agent key 403) and the live in-process limiter (429). Send as answer stands down while a reply chip or attachment is in the composer (#3168 collision). tables.py disposition comment lists dismissed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(workspace): correct #3181 review I1/I2 doc staleness operating-room.md: an answered ask's result returns to its discussion chat only; the attached chat is not a destination (F1), so an undiscussed answer's run stays owner-only. backend.md: turn_context now has one OSS provider, the discussed-ask line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
reply_to_message_id, and the sent bubble shows the quote. Both behave exactly as when the reply starts in the Inbox.Changes
PortalAgentBubble.vueowns Reply (replyLabel,replyDisabled,replyevent).PortalInboxPane.vue: the per-message arrow is now the bubble's own Reply. The event it sends is unchanged.PortalConversation.vue:aria-describedbythe chip's "Replying to: …" text.PortalReplyChipgains atextIdprop so the description leaves out the remove button's name.portalUtils.js:resolveComposerKeygainshasReplyand returns'drop-reply'. The composer then callspreventDefault, which makes the turn-cancel listener ignore that Escape.Portal.vue:setReplyTargethandles the new event.convKey, the conversation's identity, drops the reply target when the conversation changes. Before this, New chat or switching agent on a link that didn't name the chat left the target hidden, and it came back when you reopened that chat.core-agent.md;architecture/workspace.md(the second way in);cso --diffreport (no findings).Decisions taken with the product owner
Test Plan
portalInChatReply.mount.spec.js(new, mounts the real conversation, bubble, chip and Inbox pane):aria-describedbypoints at its text.portalInboxShell.mount.spec.js(the realPortal.vue):replysets the target, and a target with no message id is ignored.resolveComposerKeytruth table:drop-replyonly with the picker closed, never during IME composition.vitest run, the raw-colour, loading-gate and source-text ratchets, andcheck:tokenspass. Three failures are local-environment only and touch no file in this diff:markdownCodeBlocks.spec.jsreads the installed DOMPurify, which resolved to a newer version than the lockfile in a localpnpm install. CI'snpm cidecides that one.tests/unit/test_ent610_reply_to_message.py: 18 passed. This is the server-side check on the client-chosen id.Fixes abilityai/trinity-enterprise#738
🤖 Generated with Claude Code