Skip to content

feat(workspace): reply to a message from inside the chat (abilityai/trinity-enterprise#738) - #3168

Merged
vybe merged 7 commits into
devfrom
feature/738-in-chat-reply
Oct 2, 2026
Merged

vybe merged 7 commits into
devfrom
feature/738-in-chat-reply

Conversation

@webmixgamer

Copy link
Copy Markdown
Contributor

Summary

  • You can now reply to a specific agent message inside the 1:1 Workspace chat, not only from the Inbox.
    • Every saved agent message gets a Reply button in its action row: Copy · Reply · 👍 👎.
    • Clicking it shows the same "replying to" chip above the composer that the Inbox uses, and puts the cursor in the composer.
    • The send carries reply_to_message_id, and the sent bubble shows the quote. Both behave exactly as when the reply starts in the Inbox.
  • Nothing changes on the backend: no new endpoint, no migration. The send, the server check on the reply id, and the error path when a reply is refused are all reused unchanged.
  • Esc closes the innermost thing first:
    • with the @/ picker open, the first Esc closes the picker;
    • otherwise Esc clears the chip;
    • only then does Esc stop a running turn.
  • A chip never follows you into another chat.

Changes

  • PortalAgentBubble.vue owns Reply (replyLabel, replyDisabled, reply event).
    • Copy and Reply share one button style at the thumbs' shade: gray-500 light, gray-400 dark. Copy's old light gray-400 was 2.54:1 on white, below the 3:1 an icon needs.
    • The colour baseline is re-frozen +1 in its own commit, with a note.
  • PortalInboxPane.vue: the per-message arrow is now the bubble's own Reply. The event it sends is unchanged.
  • PortalConversation.vue:
    • Reply appears only on saved rows, and is disabled during a voice call without moving the row.
    • The reply uses the chat id the conversation resolved itself, not the one from the URL: a link that doesn't name the chat has no id in the URL.
    • Focus moves to the composer within the tap.
    • The textarea is aria-describedby the chip's "Replying to: …" text. PortalReplyChip gains a textId prop so the description leaves out the remove button's name.
  • portalUtils.js: resolveComposerKey gains hasReply and returns 'drop-reply'. The composer then calls preventDefault, which makes the turn-cancel listener ignore that Escape.
  • Portal.vue:
    • setReplyTarget handles the new event.
    • A new watcher on 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.
  • Docs:
    • the requirement in core-agent.md;
    • architecture/workspace.md (the second way in);
    • three feature flows;
    • a learnings fragment;
    • the cso --diff report (no findings).

Decisions taken with the product owner

  • Touch target: Reply is 22px like its neighbours. The issue's "≥44px on touch" line is moved to the whole action row as one change on trinity#3056; scope added in a comment there.
    • Three 44px hit areas can't fit around 22px buttons 4px apart without overlapping.
    • Meanwhile the row passes WCAG 2.5.8 through spacing: button centres are 26px apart.
  • Follow-up: abilityai/trinity-enterprise#746 (incubating). It would save the reply link on the stored message so the quote survives a reload.

Test Plan

  • portalInChatReply.mount.spec.js (new, mounts the real conversation, bubble, chip and Inbox pane):
    • Reply appears only on saved agent messages.
    • The reply names the chat the conversation resolved, not the URL's.
    • The cursor lands in the composer.
    • The chip appears, and the textarea's aria-describedby points at its text.
    • The send carries the id, and the sent bubble shows the quote.
    • The chip's × returns focus to the composer.
    • Reply is disabled during a voice call.
    • Esc closes the innermost thing first: picker, then chip, then turn. The turn case includes a positive control that the second Esc does stop it.
    • A reply aimed at another chat never swallows the turn's Esc.
    • An Esc an overlay already claimed leaves the chip alone.
    • The bubble's row order and the shared style.
    • The Inbox pane's Reply still opens the chat at that message.
  • portalInboxShell.mount.spec.js (the real Portal.vue):
    • reply sets the target, and a target with no message id is ignored.
    • Leaving a chat drops the target, and coming back doesn't restore it.
    • New chat on a link that doesn't name the chat drops it.
    • The Inbox path keeps its target through its own navigation.
  • resolveComposerKey truth table: drop-reply only with the picker closed, never during IME composition.
  • 18 call-site mutations, each wiring line broken from a scratch copy: all red.
  • Full vitest run, the raw-colour, loading-gate and source-text ratchets, and check:tokens pass. Three failures are local-environment only and touch no file in this diff:
    • the two room specs fail at load on the host (known);
    • markdownCodeBlocks.spec.js reads the installed DOMPurify, which resolved to a newer version than the lockfile in a local pnpm install. CI's npm ci decides that one.
  • tests/unit/test_ent610_reply_to_message.py: 18 passed. This is the server-side check on the client-chosen id.
  • Manual: checked on localhost by the product owner (Reply, the chip, Esc order, chat switch, the Inbox pane, 375px, both themes).

Fixes abilityai/trinity-enterprise#738

🤖 Generated with Claude Code

webmixgamer and others added 4 commits October 1, 2026 15:24
…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>
@webmixgamer webmixgamer added the ui PR touches the frontend UI — triggers Playwright e2e tests label Oct 1, 2026
webmixgamer and others added 2 commits October 1, 2026 18:58
…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>
@vybe

vybe commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

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 src/frontend/src/components/portal/PortalConversation.vue, so it rides the next train once #3156 is on dev and this branch is merged up. After merge, trinity-enterprise#738 needs status-in-dev set by hand (cross-repo keyword).

@AndriiPasternak31 AndriiPasternak31 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.

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 build exits 0.
  • Mutation battery: I broke 8 wiring lines one at a time (hasReply, the defaultPrevented guard, the session the reply names, focusComposer, the convKey watcher ×2, the chip's Esc preventDefault, and reply-disabled). 8 of 8 turned a test red.
  • Not verified: a live browser in both themes or at 375px.

Before merge

  1. It conflicts with dev. The only clash is in PortalConversation.vue, in defineEmits: #3156 added 'thread-missing' and this PR adds 'reply'. Keep both, then re-run CI. The current green runs predate the conflict.
  2. Collision with #3181 (medium; whichever PR lands second fixes it):
    • #3181 renders its "Send as answer" row between PortalReplyChip and the composer box, so the chip, which is styled as a tab fused to the composer, ends up floating.
    • answerDiscussedAsk neither reads nor clears replyTo. 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.

Low / nits

  • Reply is never an ask decision: no operator_queue write, so the single-row CAS is untouched. Replying "yes" to the message that raised an ask leaves the ask pending. Worth one line in workspace.md.
  • This is pre-existing, but the PR widens it: channel_completion_report stores a delegated child's result as an assistant row, and reply_context quotes it as "your earlier message". Reply now puts that one click away on every report bubble. A follow-up could frame completion:* rows differently.
  • PortalAgentBubble.vue:46 hard-codes data-testid="portal-message-reply", so the Inbox's per-message inbox-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_BUTTON uses focus:ring-2 where the old Inbox arrow used focus-visible:, so a mouse click now shows a ring.
  • drop-reply also 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
@vybe
vybe merged commit 1268c73 into dev Oct 2, 2026
26 checks passed
dolho added a commit that referenced this pull request Oct 5, 2026
…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>
vybe pushed a commit that referenced this pull request Oct 6, 2026
…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>
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