feat(control-ui): add right-click Reply in Dashboard webchat#92654
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a “reply to message” UX to chat by letting users pick a message via right-click, showing a reply preview above the composer, and prefixing outgoing messages with a quoted block.
Changes:
- Add reply-target props + UI preview bar in the chat composer
- Add a custom right-click context menu on chat bubbles to set the reply target
- Prefix sent messages with a Markdown quote of the selected message; add CSS for preview/menu styling
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 11 comments.
| File | Description |
|---|---|
| ui/src/ui/views/chat.ts | Adds context-menu selection of a reply target and renders the reply preview in the composer. |
| ui/src/ui/app-chat.ts | Stores reply target in the host and prepends a quoted reply block to outgoing messages. |
| ui/src/styles/chat/layout.css | Styles the reply preview and custom context menu; includes a few unrelated style tweaks. |
| const handleChatContextMenu = (e: MouseEvent, p: ChatProps) => { | ||
| const bubble = (e.target as HTMLElement).closest(".chat-bubble"); | ||
| // Always suppress native menu on chat bubbles; we show our own Reply menu. | ||
| if (!bubble) return; | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| if (typeof p.onSetReply !== "function") return; |
| document.addEventListener( | ||
| "keydown", | ||
| (ev) => { | ||
| if (ev.key === "Escape") removeReplyContextMenu(); | ||
| }, | ||
| { once: true }, | ||
| ); |
| const senderLabel = senderEl?.textContent?.trim() ?? undefined; | ||
| const text = bubble.textContent?.trim().slice(0, 500) ?? ""; | ||
| if (!text) return; | ||
| const messageId = `msg-${Date.now()}`; |
| const menu = document.createElement("div"); | ||
| menu.className = "chat-reply-context-menu"; | ||
| menu.innerHTML = `<button type="button"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" stroke="none"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> Reply</button>`; |
| document.body.appendChild(menu); | ||
| requestAnimationFrame(() => { | ||
| document.addEventListener("click", removeReplyContextMenu, { once: true }); |
| .chat-settings-popover__label { | ||
| color: var(--muted); | ||
| font-size: 12px; /* was 11px */ | ||
| font-size: 11px; |
| .agent-chat__talk-options input:focus, | ||
| .agent-chat__talk-options select:focus { |
| .chat-session-picker__option-meta { | ||
| color: var(--muted); | ||
| font-size: 12px; /* was 11px */ | ||
| font-size: 11px; |
| padding: 4px 8px 3px; | ||
| color: var(--muted); | ||
| font-size: 12px; /* was 10px */ | ||
| font-size: 10px; |
| display: inline-block; | ||
| padding: 1px 6px; | ||
| font-size: 12px; /* was 11px */ | ||
| font-size: 11px; |
|
Codex review: needs changes before merge. Reviewed June 29, 2026, 2:36 AM ET / 06:36 UTC. Summary PR surface: Source +330, Tests +238. Total +568 across 10 files. Reproducibility: yes. by source inspection: set chatReplyTarget, send while isChatBusy(host) is true, and handleSendChat enqueues effectiveMessage then returns before the only reply-target clear branch can run. Review metrics: 1 noteworthy metric.
Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Copy recommended automerge instructionNext step before merge
Security Review findings
Review detailsBest possible solution: Clear the captured reply target once a quoted message is successfully queued or accepted, preserve failed pre-acceptance retry behavior, and keep structured reply metadata and reactions in separate product/API work. Do we have a high-confidence way to reproduce the issue? Yes by source inspection: set chatReplyTarget, send while isChatBusy(host) is true, and handleSendChat enqueues effectiveMessage then returns before the only reply-target clear branch can run. Is this the best way to solve the issue? No, not quite yet: the UI-only quoted-Markdown approach is a reasonable narrow first step, but the cleanup point misses the queued busy-send path. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 68ddb9744f78. Label changesLabel justifications:
Evidence reviewedPR surface: Source +330, Tests +238. Total +568 across 10 files. View PR surface stats
Acceptance criteria:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
a81ec1f to
476e8df
Compare
2ef647e to
984bcfe
Compare
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
cf77fcb to
35c1b28
Compare
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
| const replyTarget = host.chatReplyTarget; | ||
| const effectiveMessage = replyTarget ? prependReplyQuote(message, replyTarget) : message; | ||
| host.chatReplyTarget = null; |
| requestAnimationFrame(() => { | ||
| document.addEventListener("click", removeReplyContextMenu, { once: true }); | ||
| const handleKeydown = (ev: KeyboardEvent) => { | ||
| if (ev.key === "Escape") { | ||
| removeReplyContextMenu(); | ||
| } | ||
| }; | ||
| contextMenuKeydownHandler = handleKeydown; | ||
| document.addEventListener("keydown", handleKeydown); | ||
| }); |
| removeReplyContextMenu(); | ||
| const menu = document.createElement("div"); | ||
| menu.className = "chat-reply-context-menu"; | ||
| menu.innerHTML = `<button type="button"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" stroke="none"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> Reply</button>`; |
| menu.style.left = `${e.clientX}px`; | ||
| menu.style.top = `${e.clientY}px`; |
| if (!text) { | ||
| return; | ||
| } | ||
| const messageId = `msg-${Date.now()}`; |
| menu.style.left = `${e.clientX}px`; | ||
| menu.style.top = `${e.clientY}px`; | ||
| menu.querySelector("button")?.addEventListener("click", () => { | ||
| p.onSetReply?.({ messageId, text, senderLabel }); |
35c1b28 to
4a4ef0b
Compare
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review |
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
Summary
How it works
Three new optional props on
ChatProps(replyTarget,onSetReply,onClearReply) wired throughapp-render.tsintorenderChat. A@state() chatReplyTargeton the app class holds the transient reply target. On send,handleSendChatreads the target, prepends a Markdown blockquote viaprependReplyQuote, and clears the state.Quote format (what the model receives)
hello worldgot it> **User:** hello worldgot itline oneline twook> **User:**> line one> line twookLinked context
Closes #16896
Real behavior proof
cd ui && pnpm dev> **User:** ...Markdown blockquote>prefix.Files changed
Risk checklist
bubble.textContentextraction for quote text; mitigated by.trim().slice(0, 500)