Skip to content

Commit 3404667

Browse files
committed
feat: right-click and keyboard Reply affordance in Dashboard webchat
The Dashboard webchat had no Reply UX. Right-clicking a chat bubble opened the browser context menu and the keyboard shortcut (r) did nothing. Filed in #16896 with screenshots of competitor chat UIs that expose Reply directly on the bubble. Adds a Reply affordance reachable two ways: - right-click (contextmenu event) on a chat bubble opens a small inline action with one option: Reply - pressing 'r' on the currently-focused bubble selects it as the reply target Reply target threads through chat controller state into the input area as a preview ("Replying to user / assistant"), with X to cancel. Submitting prepends the quoted text via prependReplyQuote and clears the target. New types in chat-types.ts (ChatReplyTarget). CSS for the preview/menu lives in styles/chat/grouped.css. Tests cover the right- click, keyboard, cancel, and submit-clears-target paths plus the new grouped-render quote-prepend helper. Fixes #16896
1 parent 9afaec1 commit 3404667

7 files changed

Lines changed: 687 additions & 123 deletions

File tree

ui/src/styles/chat/grouped.css

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,8 @@ img.chat-avatar {
230230
}
231231

232232
.chat-copy-btn,
233-
.chat-expand-btn {
233+
.chat-expand-btn,
234+
.chat-reply-btn {
234235
background: var(--bg);
235236
color: var(--muted);
236237
}
@@ -243,7 +244,8 @@ img.chat-avatar {
243244
}
244245

245246
.chat-copy-btn__icon svg,
246-
.chat-expand-btn__icon svg {
247+
.chat-expand-btn__icon svg,
248+
.chat-reply-btn svg {
247249
width: 14px;
248250
height: 14px;
249251
}
@@ -286,7 +288,8 @@ img.chat-avatar {
286288
}
287289

288290
.chat-copy-btn:focus-visible,
289-
.chat-expand-btn:focus-visible {
291+
.chat-expand-btn:focus-visible,
292+
.chat-reply-btn:focus-visible {
290293
outline: 2px solid var(--accent);
291294
outline-offset: 2px;
292295
}
@@ -307,6 +310,110 @@ img.chat-avatar {
307310
background: var(--bg-hover);
308311
}
309312

313+
.chat-bubble:focus-within .chat-bubble-actions {
314+
opacity: 1;
315+
pointer-events: auto;
316+
}
317+
318+
.chat-reply-context-menu {
319+
position: fixed;
320+
z-index: 1000;
321+
min-width: 120px;
322+
padding: 4px;
323+
border: 1px solid var(--border);
324+
border-radius: var(--radius-md, 8px);
325+
background: var(--card);
326+
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
327+
}
328+
329+
.chat-reply-context-menu__item {
330+
display: flex;
331+
width: 100%;
332+
align-items: center;
333+
justify-content: flex-start;
334+
padding: 8px 10px;
335+
border: none;
336+
border-radius: var(--radius-sm, 4px);
337+
background: transparent;
338+
color: var(--text);
339+
font: inherit;
340+
font-size: 13px;
341+
cursor: pointer;
342+
}
343+
344+
.chat-reply-context-menu__item:hover,
345+
.chat-reply-context-menu__item:focus-visible {
346+
outline: none;
347+
background: var(--bg-hover);
348+
}
349+
350+
.agent-chat__reply-preview {
351+
display: flex;
352+
align-items: center;
353+
gap: 10px;
354+
margin: 8px 10px 0;
355+
padding: 8px 10px;
356+
border-left: 3px solid var(--accent);
357+
border-radius: var(--radius-sm, 4px);
358+
background: color-mix(in srgb, var(--accent) 10%, transparent);
359+
}
360+
361+
.agent-chat__reply-preview-body {
362+
display: flex;
363+
min-width: 0;
364+
flex: 1 1 auto;
365+
flex-direction: column;
366+
gap: 2px;
367+
}
368+
369+
.agent-chat__reply-preview-label {
370+
color: var(--muted);
371+
font-size: 11px;
372+
font-weight: 600;
373+
line-height: 1.2;
374+
}
375+
376+
.agent-chat__reply-preview-text {
377+
overflow: hidden;
378+
color: var(--text);
379+
font-size: 12px;
380+
line-height: 1.35;
381+
text-overflow: ellipsis;
382+
white-space: nowrap;
383+
}
384+
385+
.agent-chat__reply-preview-clear {
386+
display: inline-flex;
387+
width: 24px;
388+
height: 24px;
389+
flex: 0 0 auto;
390+
align-items: center;
391+
justify-content: center;
392+
padding: 0;
393+
border: none;
394+
border-radius: var(--radius-sm, 4px);
395+
background: transparent;
396+
color: var(--muted);
397+
cursor: pointer;
398+
}
399+
400+
.agent-chat__reply-preview-clear:hover,
401+
.agent-chat__reply-preview-clear:focus-visible {
402+
color: var(--text);
403+
outline: none;
404+
background: var(--bg-hover);
405+
}
406+
407+
.agent-chat__reply-preview-clear svg {
408+
width: 14px;
409+
height: 14px;
410+
fill: none;
411+
stroke: currentColor;
412+
stroke-width: 2;
413+
stroke-linecap: round;
414+
stroke-linejoin: round;
415+
}
416+
310417
/* User bubbles have different styling */
311418
.chat-group.user .chat-bubble {
312419
background: var(--accent-subtle);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/* @vitest-environment jsdom */
2+
3+
import { render } from "lit";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import type { MessageGroup } from "../types/chat-types.ts";
6+
import { renderMessageGroup } from "./grouped-render.ts";
7+
8+
function createGroup(): MessageGroup {
9+
return {
10+
kind: "group",
11+
key: "group:assistant:msg:1",
12+
role: "assistant",
13+
messages: [
14+
{
15+
key: "msg:1",
16+
message: {
17+
role: "assistant",
18+
content: [{ type: "text", text: "Reply to this" }],
19+
timestamp: 1000,
20+
},
21+
},
22+
],
23+
timestamp: 1000,
24+
isStreaming: false,
25+
};
26+
}
27+
28+
afterEach(() => {
29+
document.body.querySelector(".chat-reply-context-menu")?.remove();
30+
});
31+
32+
describe("grouped chat rendering", () => {
33+
it("opens a Reply menu on message contextmenu", async () => {
34+
const container = document.createElement("div");
35+
const onReply = vi.fn();
36+
render(
37+
renderMessageGroup(createGroup(), {
38+
onReply,
39+
showReasoning: false,
40+
}),
41+
container,
42+
);
43+
44+
container.querySelector<HTMLElement>(".chat-bubble")?.dispatchEvent(
45+
new MouseEvent("contextmenu", {
46+
bubbles: true,
47+
cancelable: true,
48+
clientX: 12,
49+
clientY: 18,
50+
}),
51+
);
52+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
53+
54+
const item = document.body.querySelector<HTMLButtonElement>(".chat-reply-context-menu__item");
55+
expect(item?.textContent).toBe("Reply");
56+
item?.click();
57+
58+
expect(onReply).toHaveBeenCalledWith({
59+
key: "msg:1",
60+
role: "assistant",
61+
text: "Reply to this",
62+
});
63+
expect(document.body.querySelector(".chat-reply-context-menu")).toBeNull();
64+
});
65+
66+
it("renders a keyboard-accessible Reply button for message bubbles", () => {
67+
const container = document.createElement("div");
68+
const onReply = vi.fn();
69+
render(
70+
renderMessageGroup(createGroup(), {
71+
onReply,
72+
showReasoning: false,
73+
}),
74+
container,
75+
);
76+
77+
const replyButton = container.querySelector<HTMLButtonElement>('button[aria-label="Reply"]');
78+
expect(replyButton).not.toBeNull();
79+
replyButton?.click();
80+
81+
expect(onReply).toHaveBeenCalledWith({
82+
key: "msg:1",
83+
role: "assistant",
84+
text: "Reply to this",
85+
});
86+
});
87+
});

ui/src/ui/chat/grouped-render.ts

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { icons } from "../icons.ts";
66
import { toSanitizedMarkdownHtml } from "../markdown.ts";
77
import { openExternalUrlSafe } from "../open-external-url.ts";
88
import { detectTextDirection } from "../text-direction.ts";
9-
import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
9+
import type { ChatReplyTarget, MessageGroup, ToolCard } from "../types/chat-types.ts";
1010
import { agentLogoUrl } from "../views/agents-utils.ts";
1111
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
1212
import {
@@ -114,6 +114,7 @@ export function renderMessageGroup(
114114
group: MessageGroup,
115115
opts: {
116116
onOpenSidebar?: (content: string) => void;
117+
onReply?: (target: ChatReplyTarget) => void;
117118
showReasoning: boolean;
118119
showToolCalls?: boolean;
119120
assistantName?: string;
@@ -168,6 +169,8 @@ export function renderMessageGroup(
168169
isStreaming: group.isStreaming && index === group.messages.length - 1,
169170
showReasoning: opts.showReasoning,
170171
showToolCalls: opts.showToolCalls ?? true,
172+
replyKey: item.key,
173+
onReply: opts.onReply,
171174
},
172175
opts.onOpenSidebar,
173176
),
@@ -669,9 +672,100 @@ function renderExpandButton(markdown: string, onOpenSidebar: (content: string) =
669672
`;
670673
}
671674

675+
let activeReplyMenu: HTMLDivElement | null = null;
676+
677+
function removeActiveReplyMenu(): void {
678+
activeReplyMenu?.remove();
679+
activeReplyMenu = null;
680+
document.removeEventListener("click", closeReplyMenuOnOutside, true);
681+
document.removeEventListener("keydown", closeReplyMenuOnEscape, true);
682+
}
683+
684+
function closeReplyMenuOnOutside(event: MouseEvent): void {
685+
if (activeReplyMenu?.contains(event.target as Node)) {
686+
return;
687+
}
688+
removeActiveReplyMenu();
689+
}
690+
691+
function closeReplyMenuOnEscape(event: KeyboardEvent): void {
692+
if (event.key === "Escape") {
693+
removeActiveReplyMenu();
694+
}
695+
}
696+
697+
function getReplyTarget(message: unknown, key: string | undefined): ChatReplyTarget | null {
698+
if (!key) {
699+
return null;
700+
}
701+
const text = extractTextCached(message)?.trim();
702+
if (!text) {
703+
return null;
704+
}
705+
const m = message as Record<string, unknown>;
706+
const role = typeof m.role === "string" ? normalizeRoleForGrouping(m.role) : "unknown";
707+
return { key, role, text };
708+
}
709+
710+
function openReplyContextMenu(
711+
event: MouseEvent,
712+
args: { onReply: (target: ChatReplyTarget) => void; replyTarget: ChatReplyTarget },
713+
): void {
714+
event.preventDefault();
715+
removeActiveReplyMenu();
716+
717+
const menu = document.createElement("div");
718+
menu.className = "chat-reply-context-menu";
719+
menu.setAttribute("role", "menu");
720+
menu.style.left = `${event.clientX}px`;
721+
menu.style.top = `${event.clientY}px`;
722+
723+
const reply = document.createElement("button");
724+
reply.className = "chat-reply-context-menu__item";
725+
reply.type = "button";
726+
reply.setAttribute("role", "menuitem");
727+
reply.textContent = "Reply";
728+
reply.addEventListener("click", () => {
729+
removeActiveReplyMenu();
730+
args.onReply(args.replyTarget);
731+
});
732+
733+
menu.append(reply);
734+
document.body.append(menu);
735+
activeReplyMenu = menu;
736+
requestAnimationFrame(() => {
737+
reply.focus();
738+
document.addEventListener("click", closeReplyMenuOnOutside, true);
739+
document.addEventListener("keydown", closeReplyMenuOnEscape, true);
740+
});
741+
}
742+
743+
function renderReplyButton(
744+
replyTarget: ChatReplyTarget,
745+
onReply: (target: ChatReplyTarget) => void,
746+
) {
747+
return html`
748+
<button
749+
class="btn btn--xs chat-reply-btn"
750+
type="button"
751+
title="Reply"
752+
aria-label="Reply"
753+
@click=${() => onReply(replyTarget)}
754+
>
755+
${icons.messageSquare}
756+
</button>
757+
`;
758+
}
759+
672760
function renderGroupedMessage(
673761
message: unknown,
674-
opts: { isStreaming: boolean; showReasoning: boolean; showToolCalls?: boolean },
762+
opts: {
763+
isStreaming: boolean;
764+
showReasoning: boolean;
765+
showToolCalls?: boolean;
766+
replyKey?: string;
767+
onReply?: (target: ChatReplyTarget) => void;
768+
},
675769
onOpenSidebar?: (content: string) => void,
676770
) {
677771
const m = message as Record<string, unknown>;
@@ -697,6 +791,7 @@ function renderGroupedMessage(
697791
const markdown = markdownBase;
698792
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
699793
const canExpand = role === "assistant" && Boolean(onOpenSidebar && markdown?.trim());
794+
const replyTarget = opts.onReply ? getReplyTarget(message, opts.replyKey) : null;
700795

701796
// Detect pure-JSON messages and render as collapsible block
702797
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
@@ -724,12 +819,22 @@ function renderGroupedMessage(
724819
const toolPreview =
725820
markdown && !toolSummaryLabel ? markdown.trim().replace(/\s+/g, " ").slice(0, 120) : "";
726821

727-
const hasActions = canCopyMarkdown || canExpand;
822+
const hasActions = Boolean(replyTarget) || canCopyMarkdown || canExpand;
728823

729824
return html`
730-
<div class="${bubbleClasses}">
825+
<div
826+
class="${bubbleClasses}"
827+
@contextmenu=${replyTarget
828+
? (event: MouseEvent) =>
829+
openReplyContextMenu(event, {
830+
replyTarget,
831+
onReply: opts.onReply!,
832+
})
833+
: undefined}
834+
>
731835
${hasActions
732836
? html`<div class="chat-bubble-actions">
837+
${replyTarget && opts.onReply ? renderReplyButton(replyTarget, opts.onReply) : nothing}
733838
${canExpand ? renderExpandButton(markdown!, onOpenSidebar!) : nothing}
734839
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
735840
</div>`

0 commit comments

Comments
 (0)