Skip to content

Commit 05cf497

Browse files
programmingWTFopenclaw-clownfish[bot]vincentkoc
authored
feat(control-ui): add right-click Reply in Dashboard webchat (#92654)
* fix(control-ui): right-click Reply icon, dismiss button, quote format, and queue ordering * fix(control-ui): harden Dashboard webchat reply menu * fix(control-ui): satisfy reply menu lint --------- Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Co-authored-by: Vincent Koc <[email protected]>
1 parent 78b03fb commit 05cf497

10 files changed

Lines changed: 573 additions & 5 deletions

File tree

ui/src/styles/chat/layout.css

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,112 @@
504504
height: 14px;
505505
}
506506

507+
/* Reply preview bar above composer */
508+
.chat-reply-preview {
509+
display: flex;
510+
align-items: center;
511+
gap: 8px;
512+
padding: 6px 12px;
513+
margin-bottom: 4px;
514+
background: color-mix(in srgb, var(--accent) 8%, transparent);
515+
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
516+
border-radius: 8px;
517+
font-size: 12px;
518+
color: var(--muted);
519+
}
520+
521+
.chat-reply-preview__icon {
522+
display: inline-flex;
523+
width: 16px;
524+
height: 16px;
525+
flex-shrink: 0;
526+
}
527+
528+
.chat-reply-preview__icon svg {
529+
width: 16px;
530+
height: 16px;
531+
}
532+
533+
.chat-reply-preview__label {
534+
font-weight: 600;
535+
flex-shrink: 0;
536+
color: var(--accent);
537+
}
538+
539+
.chat-reply-preview__text {
540+
flex: 1;
541+
overflow: hidden;
542+
text-overflow: ellipsis;
543+
white-space: nowrap;
544+
color: var(--fg);
545+
}
546+
547+
.chat-reply-preview__dismiss {
548+
display: inline-flex;
549+
align-items: center;
550+
justify-content: center;
551+
width: 20px;
552+
height: 20px;
553+
padding: 0;
554+
border: none;
555+
background: none;
556+
color: var(--muted);
557+
cursor: pointer;
558+
border-radius: 4px;
559+
flex-shrink: 0;
560+
}
561+
562+
.chat-reply-preview__dismiss:hover {
563+
color: var(--fg);
564+
background: color-mix(in srgb, var(--fg) 10%, transparent);
565+
}
566+
567+
.chat-reply-preview__dismiss svg {
568+
width: 16px;
569+
height: 16px;
570+
stroke: currentColor;
571+
fill: none;
572+
stroke-width: 2px;
573+
}
574+
575+
/* Right-click reply context menu */
576+
.chat-reply-context-menu {
577+
position: fixed;
578+
z-index: 9999;
579+
background: var(--bg);
580+
border: 1px solid var(--border);
581+
border-radius: 8px;
582+
padding: 4px;
583+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
584+
min-width: 160px;
585+
}
586+
587+
.chat-reply-context-menu button {
588+
display: flex;
589+
align-items: center;
590+
gap: 8px;
591+
width: 100%;
592+
padding: 8px 12px;
593+
border: none;
594+
background: none;
595+
color: var(--fg);
596+
cursor: pointer;
597+
border-radius: 6px;
598+
text-align: left;
599+
}
600+
601+
.chat-reply-context-menu button:hover,
602+
.chat-reply-context-menu button:focus-visible {
603+
background: color-mix(in srgb, var(--accent) 12%, transparent);
604+
outline: none;
605+
}
606+
607+
.chat-reply-context-menu button svg {
608+
width: 16px;
609+
height: 16px;
610+
flex-shrink: 0;
611+
}
612+
507613
/* Compose input row - horizontal layout */
508614
.chat-compose__row {
509615
display: flex;

ui/src/ui/app-chat.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2445,6 +2445,59 @@ describe("handleSendChat", () => {
24452445
expect(userMessage.role).toBe("user");
24462446
});
24472447

2448+
it("escapes reply sender labels and clears reply state after chat.send is acknowledged", async () => {
2449+
const sent = createDeferred<unknown>();
2450+
const request = vi.fn((method: string) => {
2451+
if (method === "chat.send") {
2452+
return sent.promise;
2453+
}
2454+
throw new Error(`Unexpected request: ${method}`);
2455+
});
2456+
const host = makeHost({
2457+
client: { request } as unknown as ChatHost["client"],
2458+
chatMessage: "continue",
2459+
chatReplyTarget: {
2460+
messageId: "reply-source-1",
2461+
text: "quoted body",
2462+
senderLabel: "A *B* [C]",
2463+
},
2464+
});
2465+
2466+
const send = handleSendChat(host);
2467+
await Promise.resolve();
2468+
2469+
expect(host.chatReplyTarget?.messageId).toBe("reply-source-1");
2470+
expect(host.chatQueue[0]?.text).toBe("> **A \\*B\\* \\[C\\]:** quoted body\n\ncontinue");
2471+
2472+
sent.resolve({ runId: host.chatQueue[0]?.sendRunId, status: "started" });
2473+
await send;
2474+
2475+
expect(host.chatReplyTarget).toBeNull();
2476+
});
2477+
2478+
it("keeps reply state when chat.send fails before acceptance", async () => {
2479+
const request = vi.fn((method: string) => {
2480+
if (method === "chat.send") {
2481+
return Promise.resolve({ runId: "run-failed", status: "error" });
2482+
}
2483+
throw new Error(`Unexpected request: ${method}`);
2484+
});
2485+
const host = makeHost({
2486+
client: { request } as unknown as ChatHost["client"],
2487+
chatMessage: "retry this",
2488+
chatReplyTarget: {
2489+
messageId: "reply-source-2",
2490+
text: "quoted body",
2491+
senderLabel: "User",
2492+
},
2493+
});
2494+
2495+
await handleSendChat(host);
2496+
2497+
expect(host.chatReplyTarget?.messageId).toBe("reply-source-2");
2498+
expect(host.chatMessage).toBe("retry this");
2499+
});
2500+
24482501
it("routes queued Skill Workshop revisions through the proposal request RPC", async () => {
24492502
const sent = createDeferred<unknown>();
24502503
const request = vi.fn((method: string) => {

ui/src/ui/app-chat.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ export type ChatHost = ChatInputHistoryState & {
137137
tab?: string;
138138
/** Callback for slash-command side effects that need app-level access. */
139139
onSlashAction?: (action: string) => void | Promise<void>;
140+
/** Selected message to reply to (right-click / keyboard shortcut). */
141+
chatReplyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null;
140142
};
141143

142144
type ChatAgentsListSnapshot = Partial<Omit<AgentsListResult, "agents">> & {
@@ -1872,11 +1874,14 @@ export async function handleSendChat(
18721874
}
18731875
}
18741876

1877+
const replyTarget = host.chatReplyTarget;
1878+
const effectiveMessage = replyTarget ? prependReplyQuote(message, replyTarget) : message;
1879+
18751880
const refreshSessions = shouldInterpretChatCommands && isChatResetCommand(message);
18761881
const submitKey = chatSubmitKey(
18771882
host,
18781883
"message",
1879-
message,
1884+
effectiveMessage,
18801885
attachmentsToSend,
18811886
skillWorkshopRevision,
18821887
);
@@ -1896,7 +1901,7 @@ export async function handleSendChat(
18961901
const waitingForModel = modelSwitchReady !== true;
18971902
const queued = enqueuePendingSendMessage(
18981903
host,
1899-
message,
1904+
effectiveMessage,
19001905
hasAttachments ? attachmentsToSend : undefined,
19011906
refreshSessions,
19021907
submittedAtMs,
@@ -1906,7 +1911,6 @@ export async function handleSendChat(
19061911
if (!queued) {
19071912
return;
19081913
}
1909-
19101914
if (modelSwitchReady !== true && !(await modelSwitchReady)) {
19111915
if (host.sessionKey === submittedSessionKey) {
19121916
cancelPendingSendBeforeRequest(host, queued, {
@@ -1943,7 +1947,7 @@ export async function handleSendChat(
19431947
return;
19441948
}
19451949

1946-
await sendChatMessageNow(host, message, {
1950+
const accepted = await sendChatMessageNow(host, effectiveMessage, {
19471951
queueItemId: queued.id,
19481952
previousDraft: cleared.previousDraft,
19491953
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
@@ -1953,13 +1957,41 @@ export async function handleSendChat(
19531957
refreshSessions,
19541958
submittedAtMs,
19551959
});
1960+
if (
1961+
accepted &&
1962+
replyTarget &&
1963+
host.chatReplyTarget?.messageId === replyTarget.messageId &&
1964+
host.sessionKey === submittedSessionKey
1965+
) {
1966+
host.chatReplyTarget = null;
1967+
}
19561968
});
19571969
}
19581970

19591971
function shouldQueueLocalSlashCommand(name: string): boolean {
19601972
return !["stop", "export-session", "steer", "redirect", "new"].includes(name);
19611973
}
19621974

1975+
function prependReplyQuote(
1976+
message: string,
1977+
replyTarget: NonNullable<ChatHost["chatReplyTarget"]>,
1978+
): string {
1979+
const label = escapeMarkdownInline(replyTarget.senderLabel ?? "User");
1980+
const text = replyTarget.text.trim();
1981+
if (!text.includes("\n")) {
1982+
return `> **${label}:** ${text}\n\n${message}`;
1983+
}
1984+
const quoted = text
1985+
.split("\n")
1986+
.map((line) => `> ${line}`)
1987+
.join("\n");
1988+
return `> **${label}:**\n${quoted}\n\n${message}`;
1989+
}
1990+
1991+
function escapeMarkdownInline(value: string): string {
1992+
return value.replace(/([\\`*_{}[\]()#+\-.!|>])/g, "\\$1");
1993+
}
1994+
19631995
// ── Slash Command Dispatch ──
19641996

19651997
async function dispatchSlashCommand(
@@ -2068,6 +2100,7 @@ async function clearChatHistory(host: ChatHost) {
20682100
host.chatMessages = [];
20692101
clearCachedChatMessagesForSession(host, host.sessionKey);
20702102
host.chatSideResult = null;
2103+
host.chatReplyTarget = null;
20712104
reconcileChatRunLifecycle(host as unknown as Parameters<typeof reconcileChatRunLifecycle>[0], {
20722105
outcome: hadActiveRun ? "interrupted" : undefined,
20732106
sessionStatus: "killed",

ui/src/ui/app-render.helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
169169
chatSessionState.reconnectResumeSessionId = null;
170170
state.chatMessage = "";
171171
state.chatAttachments = [];
172+
state.chatReplyTarget = null;
172173
state.chatMessages = restoreChatMessagesForSession(state, sessionKey);
173174
state.chatToolMessages = [];
174175
state.activityEntries = [];

ui/src/ui/app-render.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3851,6 +3851,15 @@ export function renderApp(state: AppViewState) {
38513851
onDismissSideResult: () => {
38523852
state.chatSideResult = null;
38533853
},
3854+
replyTarget: state.chatReplyTarget ?? null,
3855+
onClearReply: () => {
3856+
state.chatReplyTarget = null;
3857+
requestHostUpdate?.();
3858+
},
3859+
onSetReply: (target) => {
3860+
state.chatReplyTarget = target;
3861+
requestHostUpdate?.();
3862+
},
38543863
onNewSession: () => void createChatSession(state, { source: "user" }),
38553864
onClearHistory: runUiTask(async () => {
38563865
if (!state.client || !state.connected) {
@@ -3867,6 +3876,7 @@ export function renderApp(state: AppViewState) {
38673876
sessionKey: state.sessionKey,
38683877
});
38693878
state.chatSideResult = null;
3879+
state.chatReplyTarget = null;
38703880
reconcileChatRunLifecycle(
38713881
state as unknown as Parameters<typeof reconcileChatRunLifecycle>[0],
38723882
{

ui/src/ui/app-view-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export type AppViewState = {
101101
chatSending: boolean;
102102
chatMessage: string;
103103
chatAttachments: ChatAttachment[];
104+
chatReplyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null;
104105
chatMessages: unknown[];
105106
chatToolMessages: unknown[];
106107
activityEntries: ActivityEntry[];

ui/src/ui/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,11 @@ export class OpenClawApp extends LitElement {
320320
@state() chatQueueBySession: Record<string, ChatQueueItem[]> = {};
321321
@state() chatMessagesBySession: ChatMessageCache = new Map();
322322
@state() chatAttachments: ChatAttachment[] = [];
323+
@state() chatReplyTarget: {
324+
messageId: string;
325+
text: string;
326+
senderLabel?: string | null;
327+
} | null = null;
323328
@state() realtimeTalkActive = false;
324329
@state() realtimeTalkStatus: RealtimeTalkStatus = "idle";
325330
@state() realtimeTalkDetail: string | null = null;

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1772,7 +1772,11 @@ function renderGroupedMessage(
17721772
const duplicateCount = Math.max(1, Math.floor(opts.duplicateCount ?? 1));
17731773

17741774
return html`
1775-
<div class="${bubbleClasses}">
1775+
<div
1776+
class="${bubbleClasses}"
1777+
data-message-id=${messageKey}
1778+
data-message-text=${extractedText || nothing}
1779+
>
17761780
${renderReplyPill(normalizedMessage.replyTarget)}
17771781
${hasActions
17781782
? html`<div class="chat-bubble-actions">

0 commit comments

Comments
 (0)