From 236d1b2484ca49cc3225cb0e6cd8eb5cd6ab6c52 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 01:42:17 -0700 Subject: [PATCH] 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 --- docs/docs_map.md | 1 + docs/tools/btw.md | 16 +- ui/src/lib/chat/side-question.test.ts | 46 +++++ ui/src/lib/chat/side-question.ts | 41 +++++ ui/src/lib/chat/side-result.ts | 25 +++ ui/src/pages/chat/chat-composer.test.ts | 43 +++++ ui/src/pages/chat/chat-gateway.test.ts | 102 +++++++++++ ui/src/pages/chat/chat-gateway.ts | 50 ++++++ ui/src/pages/chat/chat-history.ts | 4 +- ui/src/pages/chat/chat-pane.ts | 7 + ui/src/pages/chat/chat-send.ts | 54 +++++- ui/src/pages/chat/chat-state.ts | 2 + ui/src/pages/chat/chat-view.ts | 13 +- ui/src/pages/chat/components/chat-composer.ts | 38 +++- .../components/chat-selection-popup.test.ts | 118 +++++++++++++ .../chat/components/chat-selection-popup.ts | 166 ++++++++++++++++++ ui/src/pages/chat/components/chat-thread.ts | 30 ++++ ui/src/styles/chat/layout.css | 39 ++++ ui/src/styles/components.css | 15 ++ 19 files changed, 799 insertions(+), 11 deletions(-) create mode 100644 ui/src/lib/chat/side-question.test.ts create mode 100644 ui/src/lib/chat/side-question.ts create mode 100644 ui/src/pages/chat/components/chat-selection-popup.test.ts create mode 100644 ui/src/pages/chat/components/chat-selection-popup.ts diff --git a/docs/docs_map.md b/docs/docs_map.md index a3e538696d96..4e393f885f97 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/tools/btw.md b/docs/tools/btw.md index 497fe682a7ec..198ac56574dd 100644 --- a/docs/tools/btw.md +++ b/docs/tools/btw.md @@ -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 diff --git a/ui/src/lib/chat/side-question.test.ts b/ui/src/lib/chat/side-question.test.ts new file mode 100644 index 000000000000..7d88e20704b9 --- /dev/null +++ b/ui/src/lib/chat/side-question.test.ts @@ -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(""); + }); +}); diff --git a/ui/src/lib/chat/side-question.ts b/ui/src/lib/chat/side-question.ts new file mode 100644 index 000000000000..314576bae99c --- /dev/null +++ b/ui/src/lib/chat/side-question.ts @@ -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(); +} diff --git a/ui/src/lib/chat/side-result.ts b/ui/src/lib/chat/side-result.ts index 34825fe0f6d1..c2cf2cb4eda3 100644 --- a/ui/src/lib/chat/side-result.ts +++ b/ui/src/lib/chat/side-result.ts @@ -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; +}) { + 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; diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index 94b6b7758de8..f1cddfc05be9 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -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(".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(".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."); + }); }); diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index cd9e0b47add3..8c7be82bc452 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -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", diff --git a/ui/src/pages/chat/chat-gateway.ts b/ui/src/pages/chat/chat-gateway.ts index ca1ac46994cd..60874deb1596 100644 --- a/ui/src/pages/chat/chat-gateway.ts +++ b/ui/src/pages/chat/chat-gateway.ts @@ -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; +} diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index 1e62c3b4c02b..53834adc2c2c 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -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; 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, diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 01c60c737c31..29b6604dee80 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -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, diff --git a/ui/src/pages/chat/chat-send.ts b/ui/src/pages/chat/chat-send.ts index 1961b264dca4..fea004cfba2d 100644 --- a/ui/src/pages/chat/chat-send.ts +++ b/ui/src/pages/chat/chat-send.ts @@ -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; }; type ChatAgentsListSnapshot = Partial> & { @@ -369,6 +378,7 @@ async function sendChatMessageWithGeneratedRunId( message: string, attachments?: ChatAttachment[], canApplyError: () => boolean = () => true, + runIdOverride?: string, ): Promise { 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 { - 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; } diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts index 7785ea039bad..5596a47d64a3 100644 --- a/ui/src/pages/chat/chat-state.ts +++ b/ui/src/pages/chat/chat-state.ts @@ -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(), chatRunStatus: null, compactionStatus: null, diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index b1f68ec44129..9ca26d7eb31a 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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(".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; diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index 943cd94446ae..c57b8a3145e3 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -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` +
+
+
+ BTW + Thinking… +
+ + + +
+
${pending.question}
+
+ `; } return html`
diff --git a/ui/src/pages/chat/components/chat-selection-popup.test.ts b/ui/src/pages/chat/components/chat-selection-popup.test.ts new file mode 100644 index 000000000000..9c986e3925df --- /dev/null +++ b/ui/src/pages/chat/components/chat-selection-popup.test.ts @@ -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(); + }); +}); diff --git a/ui/src/pages/chat/components/chat-selection-popup.ts b/ui/src/pages/chat/components/chat-selection-popup.ts new file mode 100644 index 000000000000..1528c44e52d8 --- /dev/null +++ b/ui/src/pages/chat/components/chat-selection-popup.ts @@ -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); +} diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 73a5c5589977..8a44af66dbc4 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -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)} >
${showLoadingSkeleton ? renderLoadingSkeleton() : nothing} diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 45238e1735a4..612d9fbdc383 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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); diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 202f93befe80..87fb5d1953bc 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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));