feat(control-ui): selection popup with More details and Ask in side chat via /btw (#104205)

* feat(control-ui): add chat selection popup with More details and Ask in side chat

* fix(control-ui): keep BTW pending card visible and clear it on resultless terminal runs

* fix(control-ui): route failed BTW runs to an error side-result card and ignore stale side results

* fix(control-ui): correlate BTW pending cards by pre-generated run id and suppress superseded runs

* fix(control-ui): retire abandoned BTW runs so late side events never reach the transcript

* chore(docs): regenerate docs map for btw selection-popup section; fix selection-popup test lint
This commit is contained in:
Peter Steinberger
2026-07-11 01:42:17 -07:00
committed by GitHub
parent 2ed2424618
commit 236d1b2484
19 changed files with 799 additions and 11 deletions
+1
View File
@@ -9150,6 +9150,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: What it does not do
- H2: Delivery model
- H2: Surface behavior
- H2: Selection popup (Control UI)
- H2: When to use it
- H2: Related
+15 -1
View File
@@ -60,7 +60,21 @@ disappears after reload.
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TUI | Rendered inline in the chat log, visibly distinct from a normal reply, dismissible with `Enter` or `Esc`. |
| External channels | Delivered as a clearly labeled one-off reply (Telegram, WhatsApp, Discord have no local ephemeral overlay). |
| Control UI / web | Gateway emits `chat.side_result` correctly and it is excluded from `chat.history`, but Control UI has no consumer yet to render it live in the browser. |
| Control UI / web | Rendered as a dismissible BTW card above the composer, with a pending placeholder while the side question runs. Dismiss with the close button or `Esc`. |
## Selection popup (Control UI)
Highlighting text inside a chat message in the Control UI opens a small
selection popup with two actions:
- **More details** immediately sends an implicit `/btw` question asking the
model to explain the highlighted text in the context of the current
session. The answer arrives as a live side result above the composer.
- **Ask in side chat** pre-fills the composer with a `/btw` draft quoting the
highlighted text so you can type your own question about it.
Both actions follow normal `/btw` semantics: the question and answer stay out
of session history and the main run is left untouched.
## When to use it
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
buildMoreDetailsSideCommand,
buildSideChatComposerDraft,
CHAT_SELECTION_SNIPPET_MAX_CHARS,
collapseChatSelectionSnippet,
extractSideQuestionDisplayText,
} from "./side-question.ts";
describe("collapseChatSelectionSnippet", () => {
it("collapses newlines and runs of whitespace into single spaces", () => {
expect(collapseChatSelectionSnippet("Let's Encrypt cert\n is valid\tfor both")).toBe(
"Let's Encrypt cert is valid for both",
);
});
it("caps overlong selections", () => {
const collapsed = collapseChatSelectionSnippet("x".repeat(5000));
expect(collapsed.length).toBeLessThanOrEqual(CHAT_SELECTION_SNIPPET_MAX_CHARS);
});
});
describe("side question builders", () => {
it("builds a single-line /btw command quoting the selection", () => {
expect(buildMoreDetailsSideCommand("Let's Encrypt cert\nis valid")).toBe(
`/btw Explain "Let's Encrypt cert is valid" from this conversation in more detail.`,
);
});
it("builds a composer draft that leaves room for the user's question", () => {
expect(buildSideChatComposerDraft("cron scan job")).toBe(`/btw Regarding "cron scan job": `);
});
it("returns null for whitespace-only selections", () => {
expect(buildMoreDetailsSideCommand(" \n\t ")).toBeNull();
expect(buildSideChatComposerDraft("")).toBeNull();
});
});
describe("extractSideQuestionDisplayText", () => {
it("drops the /btw and /side prefixes", () => {
expect(extractSideQuestionDisplayText("/btw what changed?")).toBe("what changed?");
expect(extractSideQuestionDisplayText("/side: what changed?")).toBe("what changed?");
expect(extractSideQuestionDisplayText("/btw")).toBe("");
});
});
+41
View File
@@ -0,0 +1,41 @@
// Builders for selection-driven /btw side questions (chat selection popup).
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
/** Cap quoted selection snippets so the /btw command stays bounded. */
export const CHAT_SELECTION_SNIPPET_MAX_CHARS = 600;
/**
* /btw questions are single-line: command normalization keeps only the first
* line, so newlines in the quoted selection must collapse to spaces before
* the snippet is embedded in the command text.
*/
export function collapseChatSelectionSnippet(text: string): string {
const collapsed = text.replace(/\s+/g, " ").trim();
return truncateUtf16Safe(collapsed, CHAT_SELECTION_SNIPPET_MAX_CHARS);
}
/** Implicit "More details" prompt sent immediately as a /btw side question. */
export function buildMoreDetailsSideCommand(selection: string): string | null {
const snippet = collapseChatSelectionSnippet(selection);
if (!snippet) {
return null;
}
return `/btw Explain "${snippet}" from this conversation in more detail.`;
}
/** Composer draft for "Ask in side chat": user types the question after the quote. */
export function buildSideChatComposerDraft(selection: string): string | null {
const snippet = collapseChatSelectionSnippet(selection);
if (!snippet) {
return null;
}
return `/btw Regarding "${snippet}": `;
}
/** Human-readable question for the pending side-result card (drops the /btw prefix). */
export function extractSideQuestionDisplayText(message: string): string {
return message
.trim()
.replace(/^\/(?:btw|side)(?::\s*|\s+|$)/i, "")
.trim();
}
+25
View File
@@ -1,5 +1,14 @@
import { normalizeOptionalString } from "../string-coerce.ts";
/** Local-only placeholder shown while a sent /btw side question awaits its result. */
export type ChatSideResultPending = {
question: string;
ts: number;
/** Detached send run id, set once the send is acked; used to drop the card
* when the run terminates without ever emitting a chat.side_result. */
runId?: string;
};
export type ChatSideResult = {
kind: "btw";
runId: string;
@@ -11,6 +20,22 @@ export type ChatSideResult = {
ts: number;
};
/**
* Drops the pending BTW card without consuming its run. The run id is
* recorded in the suppression set so late side_result/terminal events from
* the abandoned run cannot reach the side-result card or the transcript.
*/
export function retirePendingChatSideQuestion(state: {
chatSideResultPending?: ChatSideResultPending | null;
chatSideResultTerminalRuns?: Set<string>;
}) {
const runId = state.chatSideResultPending?.runId;
if (runId) {
state.chatSideResultTerminalRuns?.add(runId);
}
state.chatSideResultPending = null;
}
export function parseChatSideResult(payload: unknown): ChatSideResult | null {
if (!payload || typeof payload !== "object") {
return null;
+43
View File
@@ -787,6 +787,7 @@ describe("side result render", () => {
isError: false,
ts: 2,
},
null,
onDismissSideResult,
),
container,
@@ -835,4 +836,46 @@ describe("side result render", () => {
expect(errorResult).toBeInstanceOf(HTMLElement);
expect([...errorResult!.classList]).toEqual(["chat-side-result", "chat-side-result--error"]);
});
it("renders a pending placeholder until the side result arrives", () => {
const container = document.createElement("div");
const onDismissSideResult = vi.fn();
render(
renderSideResult(null, { question: "what changed?", ts: 1 }, onDismissSideResult),
container,
);
const pending = container.querySelector<HTMLElement>(".chat-side-result--pending");
expect(pending).toBeInstanceOf(HTMLElement);
expect(pending!.querySelector(".chat-side-result__meta")?.textContent).toBe("Thinking…");
expect(pending!.querySelector(".chat-side-result__question")?.textContent).toBe(
"what changed?",
);
expect(pending!.querySelector(".chat-side-result__body")).toBeNull();
const dismiss = pending!.querySelector<HTMLButtonElement>(".chat-side-result__dismiss");
dismiss?.click();
expect(onDismissSideResult).toHaveBeenCalledTimes(1);
// A delivered result replaces the placeholder even when pending state lingers.
render(
renderSideResult(
{
kind: "btw",
runId: "btw-run-2",
sessionKey: "main",
question: "what changed?",
text: "Answer.",
isError: false,
ts: 2,
},
{ question: "what changed?", ts: 1 },
onDismissSideResult,
),
container,
);
expect(container.querySelector(".chat-side-result--pending")).toBeNull();
expect(container.querySelector(".chat-side-result__body")?.textContent?.trim()).toBe("Answer.");
});
});
+102
View File
@@ -1,5 +1,6 @@
// Control UI tests cover chat behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { retirePendingChatSideQuestion } from "../../lib/chat/side-result.ts";
import {
registerChatAttachmentPayload,
resetChatAttachmentPayloadStoreForTest,
@@ -214,6 +215,107 @@ describe("chat side result gateway events", () => {
expect(state.chatSideResultTerminalRuns?.has("btw-main-global")).toBe(false);
});
it("clears the pending side question when its result arrives", () => {
const state = createState();
state.chatSideResultPending = { question: "what changed?", ts: 1, runId: "btw-run-1" };
handleChatSideResultGatewayEvent(state, {
kind: "btw",
runId: "btw-run-1",
sessionKey: "main",
question: "what changed?",
text: "Answer.",
ts: 123,
});
expect(state.chatSideResultPending).toBeNull();
expect(state.chatSideResult).not.toBeNull();
});
it("converts a resultless terminal BTW run into an error card and swallows the event", () => {
const state = createState();
state.chatSideResultPending = { question: "what changed?", ts: 1, runId: "btw-run-3" };
const result = handleChatGatewayEvent(state, {
runId: "btw-run-3",
sessionKey: "main",
state: "final",
message: {
role: "assistant",
content: [
{ type: "text", text: "⚠️ /btw requires an active session with existing context." },
],
},
});
expect(result).toBeNull();
expect(state.chatSideResultPending).toBeNull();
expect(state.chatSideResult).toMatchObject({
kind: "btw",
runId: "btw-run-3",
question: "what changed?",
text: "⚠️ /btw requires an active session with existing context.",
isError: true,
});
// Swallowed: the detached failure must not be adopted into the transcript.
expect(state.chatMessages).toEqual([]);
});
it("ignores side results from retired (superseded or dismissed) runs", () => {
const state = createState();
// A newer question retired the old pending run before its result arrived.
state.chatSideResultPending = { question: "older question", ts: 1, runId: "btw-run-old" };
retirePendingChatSideQuestion(state);
state.chatSideResultPending = { question: "newer question", ts: 2, runId: "btw-run-new" };
expect(
handleChatSideResultGatewayEvent(state, {
kind: "btw",
runId: "btw-run-old",
sessionKey: "main",
question: "older question",
text: "Stale answer.",
ts: 123,
}),
).toBe(true);
expect(state.chatSideResult).toBeNull();
expect(state.chatSideResultPending).toMatchObject({ runId: "btw-run-new" });
// The entry stays so the retired run's terminal chat event is swallowed too.
expect(state.chatSideResultTerminalRuns?.has("btw-run-old")).toBe(true);
});
it("keeps a dismissed pending run's terminal reply out of the transcript", () => {
const state = createState();
state.chatSideResultPending = { question: "dismissed question", ts: 1, runId: "btw-run-5" };
retirePendingChatSideQuestion(state);
expect(state.chatSideResultPending).toBeNull();
const result = handleChatGatewayEvent(state, {
runId: "btw-run-5",
sessionKey: "main",
state: "final",
message: { role: "assistant", content: [{ type: "text", text: "Late reply." }] },
});
expect(result).toBeNull();
expect(state.chatMessages).toEqual([]);
expect(state.chatSideResult).toBeNull();
});
it("keeps the pending side question when an unrelated run terminates", () => {
const state = createState();
state.chatSideResultPending = { question: "what changed?", ts: 1, runId: "btw-run-4" };
handleChatGatewayEvent(state, {
runId: "main-run-9",
sessionKey: "main",
state: "final",
});
expect(state.chatSideResultPending).toMatchObject({ runId: "btw-run-4" });
});
it("ignores tracked BTW terminal events without touching the active run", () => {
const state = createState({
chatRunId: "main-run-1",
+50
View File
@@ -280,6 +280,29 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
}
export function handleChatGatewayEvent(state: ChatState, payload?: ChatEventPayload) {
// A BTW run that fails before seeding context terminates with a plain chat
// event and never emits chat.side_result. Convert the failure into an error
// side-result card and swallow the event: detached BTW runs must not reach
// normal chat handling, where an idle pane would adopt them into the
// transcript. Successful runs clear pending via the side_result handler
// before their terminal chat event arrives.
if (
isTerminalChatState(payload?.state) &&
typeof payload?.runId === "string" &&
state.chatSideResultPending?.runId === payload.runId
) {
state.chatSideResult = {
kind: "btw",
runId: payload.runId,
sessionKey: payload.sessionKey ?? state.sessionKey,
question: state.chatSideResultPending.question,
text: extractBtwFailureText(payload) ?? "The side question ended without a result.",
isError: true,
ts: Date.now(),
};
state.chatSideResultPending = null;
return null;
}
if (
isTerminalChatState(payload?.state) &&
typeof payload?.runId === "string" &&
@@ -307,7 +330,34 @@ export function handleChatSideResultGatewayEvent(state: ChatState, payload: unkn
if (!chatScopedEventSessionMatches(state, sideResult.sessionKey, sideResult.agentId)) {
return false;
}
// Runs retired before display (superseded by a newer question or dismissed)
// enter chatSideResultTerminalRuns via retirePendingChatSideQuestion before
// their side_result can arrive; live runs only enter the set below. A
// retired run's late result must not replace the current card, and its
// entry stays so the trailing terminal chat event is still swallowed.
if (state.chatSideResultTerminalRuns?.has(sideResult.runId)) {
return true;
}
state.chatSideResult = sideResult;
state.chatSideResultPending = null;
state.chatSideResultTerminalRuns?.add(sideResult.runId);
return true;
}
function extractBtwFailureText(payload: ChatEventPayload): string | null {
if (typeof payload.errorMessage === "string" && payload.errorMessage.trim()) {
return payload.errorMessage;
}
const message = payload.message as { content?: unknown } | undefined;
const blocks = Array.isArray(message?.content) ? message.content : [];
const text = blocks
.map((block) =>
block && typeof block === "object" && (block as { type?: unknown }).type === "text"
? (block as { text?: unknown }).text
: null,
)
.filter((entry): entry is string => typeof entry === "string")
.join("\n")
.trim();
return text || null;
}
+3 -1
View File
@@ -20,7 +20,7 @@ import {
stripHeartbeatTokenForDisplay,
} from "../../lib/chat/heartbeat-display.ts";
import { extractText } from "../../lib/chat/message-extract.ts";
import type { ChatSideResult } from "../../lib/chat/side-result.ts";
import type { ChatSideResult, ChatSideResultPending } from "../../lib/chat/side-result.ts";
import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
@@ -447,6 +447,7 @@ export type ChatState = {
lastError: string | null;
chatError?: string | null;
chatSideResult?: ChatSideResult | null;
chatSideResultPending?: ChatSideResultPending | null;
chatSideResultTerminalRuns?: Set<string>;
chatReplyTarget?: unknown;
agentsError?: string | null;
@@ -837,6 +838,7 @@ export async function clearChatHistory(
}
state.chatMessages = [];
state.chatSideResult = null;
state.chatSideResultPending = null;
state.chatReplyTarget = null;
reconcileChatRunLifecycle(state, {
outcome: hadActiveRun ? "interrupted" : undefined,
+7
View File
@@ -31,6 +31,7 @@ import {
import { icons } from "../../components/icons.ts";
import "../../components/tooltip.ts";
import { t } from "../../i18n/index.ts";
import { retirePendingChatSideQuestion } from "../../lib/chat/side-result.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
import { resolveSessionKey, scopedAgentParamsForSession } from "../../lib/sessions/index.ts";
@@ -1312,6 +1313,7 @@ class ChatPane extends OpenClawLightDomElement {
fallbackStatus: state.fallbackStatus,
messages: state.chatMessages,
sideResult: state.chatSideResult,
sideResultPending: state.chatSideResultPending,
toolMessages: state.chatToolMessages,
streamSegments: state.chatStreamSegments,
stream: state.chatStream,
@@ -1428,6 +1430,7 @@ class ChatPane extends OpenClawLightDomElement {
onRevealWorkspaceFile: (path) => revealSessionWorkspaceFile(state, path),
onRefresh: () => {
state.chatSideResult = null;
retirePendingChatSideQuestion(state);
state.resetToolStream();
void refreshPageChat(state, { awaitHistory: true, scheduleScroll: false });
},
@@ -1467,8 +1470,12 @@ class ChatPane extends OpenClawLightDomElement {
onQueueRetry: (id) => void state.retryQueuedChatMessage(id),
onQueueSteer: (id) => void state.steerQueuedChatMessage(id),
onGoalCommand: (command) => void state.handleSendChat(command),
onSideQuestion: (command) => void state.handleSendChat(command),
onDismissSideResult: () => {
state.chatSideResult = null;
// Retire (not just clear) so a dismissed question's still-running
// detached run cannot leak its late reply into the transcript.
retirePendingChatSideQuestion(state);
state.requestUpdate?.();
},
replyTarget: state.chatReplyTarget ?? null,
+50 -4
View File
@@ -14,6 +14,11 @@ import type {
ChatQueueSkillWorkshopRevision,
} from "../../lib/chat/chat-types.ts";
import { parseSlashCommand } from "../../lib/chat/commands.ts";
import { extractSideQuestionDisplayText } from "../../lib/chat/side-question.ts";
import {
retirePendingChatSideQuestion,
type ChatSideResultPending,
} from "../../lib/chat/side-result.ts";
import { isSessionRunActive } from "../../lib/session-run-state.ts";
import {
scopedAgentIdForSession,
@@ -125,6 +130,10 @@ export type ChatHost = ChatInputHistoryState &
agentsList?: ChatAgentsListSnapshot | null;
/** Selected message to reply to (right-click / keyboard shortcut). */
chatReplyTarget?: { messageId: string; text: string; senderLabel?: string | null } | null;
/** Placeholder for an in-flight /btw side question awaiting chat.side_result. */
chatSideResultPending?: ChatSideResultPending | null;
/** Retired/handled BTW run ids whose late events must not reach the transcript. */
chatSideResultTerminalRuns?: Set<string>;
};
type ChatAgentsListSnapshot = Partial<Omit<AgentsListResult, "agents">> & {
@@ -369,6 +378,7 @@ async function sendChatMessageWithGeneratedRunId(
message: string,
attachments?: ChatAttachment[],
canApplyError: () => boolean = () => true,
runIdOverride?: string,
): Promise<ChatSendAck | null> {
if (!state.client || !state.connected) {
return null;
@@ -381,7 +391,7 @@ async function sendChatMessageWithGeneratedRunId(
if (canApplyError()) {
setChatError(state, null);
}
const runId = generateUUID();
const runId = runIdOverride ?? generateUUID();
try {
return await requestChatSend(state, { message: msg, attachments, runId });
} catch (err) {
@@ -396,8 +406,9 @@ export async function sendDetachedChatMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
runId?: string,
): Promise<ChatSendAck | null> {
return sendChatMessageWithGeneratedRunId(state, message, attachments);
return sendChatMessageWithGeneratedRunId(state, message, attachments, () => true, runId);
}
export async function sendSteerChatMessage(
@@ -1297,12 +1308,14 @@ async function sendDetachedCommandMessage(
previousDraft?: string;
attachments?: ChatAttachment[];
previousAttachments?: ChatAttachment[];
runId?: string;
},
) {
const ack = await sendDetachedChatMessage(
host as unknown as ChatState,
message,
opts?.attachments,
opts?.runId,
);
const ok = isAcceptedChatSendAck(ack);
if (!ok && opts?.previousDraft != null) {
@@ -1321,7 +1334,7 @@ async function sendDetachedCommandMessage(
);
releaseChatAttachmentPayloads(excludeComposerAttachments(host, opts?.attachments));
}
return ok;
return ack;
}
export async function steerQueuedChatMessage(host: ChatHost, id: string) {
@@ -2175,11 +2188,44 @@ export async function handleSendChat(
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
}
await sendDetachedCommandMessage(host, message, {
// BTW runs detached and delivers via chat.side_result only; show a
// pending card immediately so the send has visible feedback. The run
// id is generated upfront so the card is correlatable before the ack
// returns. A new question also supersedes any still-displayed
// previous answer — renderSideResult prefers results, so a stale one
// would hide the card.
const btwPending = isBtwCommand(message)
? {
question: extractSideQuestionDisplayText(message),
ts: Date.now(),
runId: generateUUID(),
}
: null;
if (btwPending) {
// The superseded run loses its pending record; retire it so its
// late side_result/terminal events cannot reach the card or the
// transcript.
retirePendingChatSideQuestion(host);
host.chatSideResult = null;
host.chatSideResultPending = btwPending;
host.requestUpdate?.();
}
const ack = await sendDetachedCommandMessage(host, message, {
previousDraft: cleared.previousDraft,
attachments: hasAttachments ? attachmentsToSend : undefined,
previousAttachments: cleared.previousAttachments,
runId: btwPending?.runId,
});
// Touch only this send's card: a side_result (or a newer question)
// may already have replaced it while the ack was in flight.
if (
btwPending &&
host.chatSideResultPending === btwPending &&
!isAcceptedChatSendAck(ack)
) {
host.chatSideResultPending = null;
host.requestUpdate?.();
}
});
return;
}
+2
View File
@@ -556,6 +556,7 @@ export function resetChatStateForRouteSession(
state.chatSending = false;
state.chatSendingScopeKey = null;
state.chatSideResult = null;
state.chatSideResultPending = null;
state.lastError = null;
state.chatError = null;
state.chatAvatarUrl = null;
@@ -1270,6 +1271,7 @@ export function createPageState(
agentsError: null,
chatStreamSegments: [] as Array<{ text: string; ts: number }>,
chatSideResult: null,
chatSideResultPending: null,
chatSideResultTerminalRuns: new Set<string>(),
chatRunStatus: null,
compactionStatus: null,
+11 -2
View File
@@ -14,7 +14,7 @@ import type {
ChatQueueItem,
ChatStreamSegment,
} from "../../lib/chat/chat-types.ts";
import type { ChatSideResult } from "../../lib/chat/side-result.ts";
import type { ChatSideResult, ChatSideResultPending } from "../../lib/chat/side-result.ts";
import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts";
import type { ProviderUsageDisplayProps } from "../../lib/provider-quota-summary.ts";
import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts";
@@ -73,6 +73,7 @@ export type ChatProps = {
fallbackStatus?: FallbackStatus | null;
messages: unknown[];
sideResult?: ChatSideResult | null;
sideResultPending?: ChatSideResultPending | null;
toolMessages: unknown[];
streamSegments: ChatStreamSegment[];
stream: string | null;
@@ -138,6 +139,8 @@ export type ChatProps = {
onQueueRetry?: (id: string) => void;
onQueueSteer?: (id: string) => void;
onGoalCommand?: (command: string) => void;
/** Sends a detached /btw side question (chat selection popup). */
onSideQuestion?: (command: string) => void;
onDismissSideResult?: () => void;
onNewSession: () => void;
onClearHistory?: () => void;
@@ -235,6 +238,7 @@ export function renderChat(props: ChatProps) {
onDraftChange: props.onDraftChange,
onSend: props.onSend,
onSetReply: props.onSetReply,
onSideQuestion: props.onSideQuestion,
onFocusComposer: () =>
chatSection
?.querySelector<HTMLTextAreaElement>(".agent-chat__composer-combobox > textarea")
@@ -256,6 +260,7 @@ export function renderChat(props: ChatProps) {
messages: props.messages,
stream: props.stream,
sideResult: props.sideResult,
sideResultPending: props.sideResultPending,
queue: props.queue,
draft: props.draft,
sessions: props.sessions,
@@ -314,7 +319,11 @@ export function renderChat(props: ChatProps) {
props.onClearReply?.();
return;
}
if (event.key === "Escape" && props.sideResult && !isChatThreadSearchOpen(props.paneId)) {
if (
event.key === "Escape" &&
(props.sideResult || props.sideResultPending) &&
!isChatThreadSearchOpen(props.paneId)
) {
event.preventDefault();
props.onDismissSideResult?.();
return;
+35 -3
View File
@@ -20,7 +20,7 @@ import {
type SlashCommandCategory,
type SlashCommandDef,
} from "../../../lib/chat/commands.ts";
import type { ChatSideResult } from "../../../lib/chat/side-result.ts";
import type { ChatSideResult, ChatSideResultPending } from "../../../lib/chat/side-result.ts";
import { formatCompactTokenCount, formatCost } from "../../../lib/format.ts";
import { isMonitoredAuthProvider } from "../../../lib/model-auth.ts";
import {
@@ -92,6 +92,7 @@ type ChatComposerProps = {
messages: unknown[];
stream: string | null;
sideResult?: ChatSideResult | null;
sideResultPending?: ChatSideResultPending | null;
queue: ChatQueueItem[];
draft: string;
sessions: SessionsListResult | null;
@@ -1029,10 +1030,41 @@ export function renderChatQueue(props: ChatQueueProps) {
export function renderSideResult(
sideResult: ChatSideResult | null | undefined,
pending?: ChatSideResultPending | null,
onDismiss?: () => void,
): TemplateResult | typeof nothing {
if (!sideResult) {
return nothing;
// A fresh side result always supersedes the pending placeholder; the
// pending card only bridges the gap until chat.side_result arrives.
if (!pending) {
return nothing;
}
return html`
<section
class="chat-side-result chat-side-result--pending"
role="status"
aria-live="polite"
aria-label="BTW side question pending"
>
<div class="chat-side-result__header">
<div class="chat-side-result__label-row">
<span class="chat-side-result__label">BTW</span>
<span class="chat-side-result__meta">Thinking</span>
</div>
<openclaw-tooltip content="Dismiss">
<button
class="btn chat-side-result__dismiss"
type="button"
aria-label="Dismiss BTW question"
@click=${() => onDismiss?.()}
>
${icons.x}
</button>
</openclaw-tooltip>
</div>
<div class="chat-side-result__question">${pending.question}</div>
</section>
`;
}
return html`
<section
@@ -2410,7 +2442,7 @@ export function renderChatComposer(props: ChatComposerProps) {
onQueueSteer: props.connected && canCompose ? props.onQueueSteer : undefined,
onQueueRemove: props.onQueueRemove,
})}
${renderSideResult(props.sideResult, props.onDismissSideResult)}
${renderSideResult(props.sideResult, props.sideResultPending, props.onDismissSideResult)}
${props.showNewMessages
? html`
<button class="chat-new-messages" type="button" @click=${props.onScrollToBottom}>
@@ -0,0 +1,118 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { handleChatSelectionPointerUp, removeChatSelectionPopup } from "./chat-selection-popup.ts";
// jsdom Ranges have no layout (and no getBoundingClientRect at all); stub the
// rect the popup positions against and remove the stub afterwards.
beforeAll(() => {
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
configurable: true,
value: () =>
({ top: 100, left: 100, bottom: 120, right: 200, width: 100, height: 20 }) as DOMRect,
});
});
afterAll(() => {
delete (Range.prototype as { getBoundingClientRect?: unknown }).getBoundingClientRect;
});
function buildThreadWithBubble(text: string) {
const thread = document.createElement("div");
thread.className = "chat-thread";
const bubble = document.createElement("div");
bubble.className = "chat-bubble";
const body = document.createElement("div");
body.className = "chat-text";
body.textContent = text;
bubble.appendChild(body);
thread.appendChild(bubble);
document.body.appendChild(thread);
return { thread, textNode: body.firstChild as Text };
}
function selectRange(node: Text, start: number, end: number) {
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function pointerUp(thread: HTMLElement) {
handleChatSelectionPointerUp({ currentTarget: thread } as unknown as PointerEvent, {
onMoreDetails: onMoreDetailsSpy,
onAskSideChat: onAskSideChatSpy,
});
vi.runAllTimers();
}
const onMoreDetailsSpy = vi.fn();
const onAskSideChatSpy = vi.fn();
describe("chat selection popup", () => {
afterEach(() => {
removeChatSelectionPopup();
window.getSelection()?.removeAllRanges();
document.body.innerHTML = "";
onMoreDetailsSpy.mockReset();
onAskSideChatSpy.mockReset();
vi.useRealTimers();
});
it("shows the toolbar over bubble selections and fires the actions", () => {
vi.useFakeTimers();
const { thread, textNode } = buildThreadWithBubble("Let's Encrypt cert is valid");
selectRange(textNode, 0, 18);
pointerUp(thread);
const popup = document.body.querySelector(".chat-selection-popup");
expect(popup).not.toBeNull();
const buttons = [...(popup?.querySelectorAll("button") ?? [])];
expect(buttons.map((button) => button.textContent)).toEqual([
"More details",
"Ask in side chat",
]);
buttons[0]?.click();
expect(onMoreDetailsSpy).toHaveBeenCalledWith("Let's Encrypt cert");
expect(document.body.querySelector(".chat-selection-popup")).toBeNull();
});
it("routes the second button to the side-chat action", () => {
vi.useFakeTimers();
const { thread, textNode } = buildThreadWithBubble("cron scan job is installed");
selectRange(textNode, 0, 13);
pointerUp(thread);
const buttons = document.body.querySelectorAll(".chat-selection-popup button");
(buttons[1] as HTMLButtonElement | undefined)?.click();
expect(onAskSideChatSpy).toHaveBeenCalledWith("cron scan job");
expect(onMoreDetailsSpy).not.toHaveBeenCalled();
});
it("ignores selections outside chat bubbles and collapsed selections", () => {
vi.useFakeTimers();
const { thread } = buildThreadWithBubble("bubble text");
const outside = document.createElement("p");
outside.textContent = "outside text";
document.body.appendChild(outside);
selectRange(outside.firstChild as Text, 0, 7);
pointerUp(thread);
expect(document.body.querySelector(".chat-selection-popup")).toBeNull();
window.getSelection()?.removeAllRanges();
pointerUp(thread);
expect(document.body.querySelector(".chat-selection-popup")).toBeNull();
});
it("dismisses when the selection collapses", () => {
vi.useFakeTimers();
const { thread, textNode } = buildThreadWithBubble("dismiss me later");
selectRange(textNode, 0, 7);
pointerUp(thread);
expect(document.body.querySelector(".chat-selection-popup")).not.toBeNull();
window.getSelection()?.removeAllRanges();
document.dispatchEvent(new Event("selectionchange"));
expect(document.body.querySelector(".chat-selection-popup")).toBeNull();
});
});
@@ -0,0 +1,166 @@
// Floating toolbar over selected chat text: "More details" fires an implicit
// /btw side question; "Ask in side chat" pre-fills the composer with a /btw
// draft quoting the selection. Mirrors the imperative reply-context-menu
// pattern in chat-thread.ts (body-portaled fixed div, document-level dismiss).
export type ChatSelectionPopupActions = {
onMoreDetails: (selection: string) => void;
onAskSideChat: (selection: string) => void;
};
let activeSelectionPopup: HTMLDivElement | null = null;
let removeDismissListeners: (() => void) | null = null;
export function removeChatSelectionPopup() {
activeSelectionPopup?.remove();
activeSelectionPopup = null;
removeDismissListeners?.();
removeDismissListeners = null;
}
function selectionTextWithinChatBubble(
selection: Selection,
threadRoot: HTMLElement,
): string | null {
if (selection.isCollapsed || selection.rangeCount === 0) {
return null;
}
const container = selection.getRangeAt(0).commonAncestorContainer;
const element = container instanceof Element ? container : container.parentElement;
// Cross-bubble selections resolve to a thread-level ancestor and bail here;
// a quote spanning multiple messages makes a poor single side question.
const bubble = element?.closest(".chat-bubble");
if (!bubble || !threadRoot.contains(bubble)) {
return null;
}
const text = selection.toString();
return text.trim() ? text : null;
}
function createSelectionPopupButton(
label: string,
iconPath: string,
onActivate: () => void,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.setAttribute("aria-label", label);
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("width", "14");
icon.setAttribute("height", "14");
icon.setAttribute("fill", "none");
icon.setAttribute("stroke", "currentColor");
icon.setAttribute("stroke-width", "2");
icon.setAttribute("stroke-linecap", "round");
icon.setAttribute("stroke-linejoin", "round");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", iconPath);
icon.appendChild(path);
const text = document.createElement("span");
text.textContent = label;
button.append(icon, text);
// pointerdown would collapse the selection before click fires; the popup
// must keep the selection alive until the action reads it.
button.addEventListener("pointerdown", (event) => event.preventDefault());
button.addEventListener("click", onActivate);
return button;
}
function showChatSelectionPopup(
selectionRect: DOMRect,
selectionText: string,
actions: ChatSelectionPopupActions,
) {
removeChatSelectionPopup();
const popup = document.createElement("div");
popup.className = "chat-selection-popup";
popup.setAttribute("role", "toolbar");
popup.setAttribute("aria-label", "Selection actions");
popup.addEventListener("pointerdown", (event) => event.preventDefault());
const activate = (action: (selection: string) => void) => {
removeChatSelectionPopup();
window.getSelection()?.removeAllRanges();
action(selectionText);
};
popup.append(
createSelectionPopupButton(
"More details",
"M12 3v2m0 14v2M5.6 5.6l1.5 1.5m9.8 9.8 1.5 1.5M3 12h2m14 0h2M5.6 18.4l1.5-1.5m9.8-9.8 1.5-1.5",
() => activate(actions.onMoreDetails),
),
createSelectionPopupButton(
"Ask in side chat",
"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z",
() => activate(actions.onAskSideChat),
),
);
document.body.appendChild(popup);
activeSelectionPopup = popup;
const popupRect = popup.getBoundingClientRect();
let left = selectionRect.left + selectionRect.width / 2 - popupRect.width / 2;
let top = selectionRect.top - popupRect.height - 8;
if (top < 8) {
top = selectionRect.bottom + 8;
}
left = Math.min(Math.max(8, left), window.innerWidth - popupRect.width - 8);
popup.style.left = `${left}px`;
popup.style.top = `${top}px`;
const handlePointerDown = (event: PointerEvent) => {
if (!popup.contains(event.target as Node | null)) {
removeChatSelectionPopup();
}
};
const handleSelectionChange = () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
removeChatSelectionPopup();
}
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
removeChatSelectionPopup();
}
};
// The popup is position:fixed against a since-scrolled selection rect;
// dismiss instead of chasing the text.
const handleScroll = () => removeChatSelectionPopup();
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("selectionchange", handleSelectionChange);
document.addEventListener("keydown", handleKeydown);
document.addEventListener("scroll", handleScroll, { capture: true, passive: true });
removeDismissListeners = () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("selectionchange", handleSelectionChange);
document.removeEventListener("keydown", handleKeydown);
document.removeEventListener("scroll", handleScroll, { capture: true });
};
}
export function handleChatSelectionPointerUp(
event: PointerEvent,
actions: ChatSelectionPopupActions,
) {
const threadRoot = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (!threadRoot) {
return;
}
// Defer one tick so the browser finalizes the selection for this pointerup.
window.setTimeout(() => {
const selection = window.getSelection();
const text = selection ? selectionTextWithinChatBubble(selection, threadRoot) : null;
if (!text || !selection) {
removeChatSelectionPopup();
return;
}
showChatSelectionPopup(selection.getRangeAt(0).getBoundingClientRect(), text, actions);
}, 0);
}
@@ -17,6 +17,10 @@ import {
import { CHAT_HISTORY_RENDER_LIMIT } from "../../../lib/chat/chat-types.ts";
import type { ChatQueueItem, ChatStreamSegment } from "../../../lib/chat/chat-types.ts";
import { extractTextCached } from "../../../lib/chat/message-extract.ts";
import {
buildMoreDetailsSideCommand,
buildSideChatComposerDraft,
} from "../../../lib/chat/side-question.ts";
import type { EmbedSandboxMode } from "../../../lib/chat/tool-display.ts";
import {
areUiSessionKeysEquivalent,
@@ -45,6 +49,7 @@ import {
renderStreamGroup,
} from "./chat-message.ts";
import { renderRealtimeTalkConversation } from "./chat-realtime-controls.ts";
import { handleChatSelectionPointerUp } from "./chat-selection-popup.ts";
import type { SidebarContent } from "./chat-sidebar.ts";
import { renderWelcomeState, resolveAssistantDisplayAvatar } from "./chat-welcome.ts";
@@ -122,6 +127,8 @@ type ChatThreadProps = {
onSend: () => void;
onSetReply?: (target: ReplyTarget) => void;
onFocusComposer?: () => void;
/** Sends a detached /btw side question built from the selection popup. */
onSideQuestion?: (command: string) => void;
};
type ChatPinnedMessagesProps = Pick<
@@ -559,6 +566,28 @@ function createReplyContextMenuButton(onClick: () => void): HTMLButtonElement {
return button;
}
function handleChatThreadSelectionPointerUp(event: PointerEvent, props: ChatThreadProps) {
if (typeof props.onSideQuestion !== "function") {
return;
}
handleChatSelectionPointerUp(event, {
onMoreDetails: (selection) => {
const command = buildMoreDetailsSideCommand(selection);
if (command) {
props.onSideQuestion?.(command);
}
},
onAskSideChat: (selection) => {
const draft = buildSideChatComposerDraft(selection);
if (draft) {
props.onDraftChange(draft);
props.onRequestUpdate?.();
props.onFocusComposer?.();
}
},
});
}
function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) {
const bubble = (event.target as HTMLElement).closest(".chat-bubble");
if (!bubble || typeof props.onSetReply !== "function") {
@@ -782,6 +811,7 @@ export function renderChatThread(props: ChatThreadProps) {
}
}}
@contextmenu=${(event: MouseEvent) => handleChatContextMenu(event, props)}
@pointerup=${(event: PointerEvent) => handleChatThreadSelectionPointerUp(event, props)}
>
<div class="chat-thread-inner">
${showLoadingSkeleton ? renderLoadingSkeleton() : nothing}
+39
View File
@@ -919,6 +919,45 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
height: 16px;
flex-shrink: 0;
}
.chat-selection-popup {
position: fixed;
z-index: 9999;
display: flex;
gap: 2px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 3px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fade-in 0.12s var(--ease-out);
}
.chat-selection-popup button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: none;
background: none;
color: var(--fg);
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.chat-selection-popup button:hover,
.chat-selection-popup button:focus-visible {
background: color-mix(in srgb, var(--accent) 12%, transparent);
outline: none;
}
.chat-selection-popup button svg {
width: 14px;
height: 14px;
flex-shrink: 0;
color: var(--muted);
}
.agent-chat__composer-shell {
display: grid;
grid-template-columns: minmax(0, 1fr);
+15
View File
@@ -2173,6 +2173,21 @@
animation: fade-in 0.2s var(--ease-out);
}
.chat-side-result--pending .chat-side-result__meta {
animation: chat-side-result-pending-pulse 1.4s ease-in-out infinite;
}
@keyframes chat-side-result-pending-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.chat-side-result--error {
border-color: rgba(239, 68, 68, 0.28);
background: linear-gradient(180deg, rgba(239, 68, 68, 0.08), rgba(239, 68, 68, 0.03));