mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
2ed2424618
commit
236d1b2484
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user