improve(control-ui): redesign composer slash and skill invocations (#129707)

* improve(control-ui): align composer invocations with current main

* fix(ui): close composer invocation regressions

* test(ui): follow grouped slash hint rule

* fix(ui): preserve reasoning reset cleanup after rebase

* fix(ui): keep checkbox capability rows switch-free

The unified capability toggle row embedded a wa-switch even in checkbox
mode, reintroducing a hidden-but-real switch control inside the
new-session composer (breaking the rail-owns-switches contract asserted
by new-session-page.places e2e) and an aria-hidden focusable control.
Checkbox rows rely on the dropdown item's own checked state.

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Vyctor H. Brzezowski
2026-08-26 03:18:47 -03:00
committed by GitHub
parent 4e4d595476
commit 7b3fb42617
21 changed files with 1025 additions and 281 deletions
+10
View File
@@ -220,6 +220,16 @@ export const icons = {
d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
/>
<path d="m15 5 4 4" />`),
pencilSparkles: strokeIcon(svg` <path d="M10 3H8" />
<path d="m15.007 5.008 3.987 3.986" />
<path d="M20 15v4" />
<path
d="M21.174 6.813a2.82 2.82 0 0 0-3.986-3.987L3.842 16.175a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
/>
<path d="M22 17h-4" />
<path d="M4 5v4" />
<path d="M6 7H2" />
<path d="M9 2v2" />`),
hourglass: strokeIcon(svg` <path d="M5 22h14" />
<path d="M5 2h14" />
<path d="M17 22v-4.2a4 4 0 0 0-1.2-2.8L12 11l-3.8 4A4 4 0 0 0 7 17.8V22" />
+213 -1
View File
@@ -1,5 +1,6 @@
// Control UI E2E tests cover composable skill references in the chat composer.
import path from "node:path";
import type { Locator } from "playwright";
import { expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@@ -8,6 +9,22 @@ const suite = createControlUiE2eSuite({
name: "Control UI skill references",
});
async function setComposerCaret(composer: Locator, caret: number) {
await composer.evaluate((element, position) => {
if (!(element instanceof HTMLTextAreaElement)) {
throw new Error("Chat composer is not a textarea");
}
element.setSelectionRange(position, position);
}, caret);
}
type FriendlyTokenGeometry = {
clipped: number;
label?: string;
overlapsNextWord: boolean;
overflow: number;
};
suite.define(() => {
it("references multiple skills inside a normal prompt and sends the visible tokens", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
@@ -30,6 +47,16 @@ suite.define(() => {
skillModelVisible: true,
textAliases: ["/autoreview"],
},
{
acceptsArgs: true,
description: "Exercise friendly token labels.",
name: "bench_skill_01",
skillDisplayName: "Bench Skill 01",
scope: "both",
source: "skill",
skillModelVisible: true,
textAliases: ["/bench_skill_01"],
},
{
acceptsArgs: true,
description: "Build and review technical documentation.",
@@ -40,6 +67,26 @@ suite.define(() => {
skillModelVisible: true,
textAliases: ["/technical_documentation"],
},
{
acceptsArgs: true,
description: "Prepare a detailed status report.",
name: "status_report",
skillDisplayName: "Status Report",
scope: "both",
source: "skill",
skillModelVisible: true,
textAliases: ["/status_report"],
},
{
acceptsArgs: true,
description: "Exercise wrapped token geometry.",
name: "wrap",
skillDisplayName: "Extremely Detailed Wrapped Composition Helper",
scope: "both",
source: "skill",
skillModelVisible: true,
textAliases: ["/wrap"],
},
{
acceptsArgs: false,
description: "Show gateway status.",
@@ -112,7 +159,172 @@ suite.define(() => {
await expect.poll(() => picker.count()).toBe(0);
await composer.fill("/");
await page.getByRole("listbox", { name: "Slash commands" }).waitFor({ state: "visible" });
await expect.poll(() => page.getByRole("option", { name: /\/status/u }).count()).toBe(1);
await expect.poll(() => page.getByRole("option", { name: /\/status/u }).count()).toBe(2);
const slashOptions = page
.getByRole("listbox", { name: "Slash commands" })
.getByRole("option");
await composer.fill("/sta");
await expect
.poll(async () => {
const names = await slashOptions.locator(".slash-menu-name").allTextContents();
return { first: names[0], last: names.at(-1) };
})
.toEqual({ first: "/status", last: "/status_report" });
await composer.press("Tab");
await expect.poll(() => composer.inputValue()).toBe("/status");
const readFriendlyTokenGeometry = () =>
page
.locator(".agent-chat__composer-draft-overlay")
.evaluate((element): FriendlyTokenGeometry[] => {
return Array.from(
element.querySelectorAll<HTMLElement>(".agent-chat__skill-token"),
).map((token) => {
let sibling: ChildNode | null = token.nextSibling;
while (sibling && (!(sibling instanceof Text) || !/\S/u.test(sibling.data))) {
sibling = sibling.nextSibling;
}
if (!(sibling instanceof Text)) {
throw new Error("Expected text after skill token");
}
const nextWordStart = sibling.data.search(/\S/u);
const wordRange = document.createRange();
wordRange.setStart(sibling, nextWordStart);
wordRange.setEnd(sibling, nextWordStart + 1);
const wordRect = wordRange.getBoundingClientRect();
const tokenRect = token.getBoundingClientRect();
const horizontalOverlap =
Math.min(tokenRect.right, wordRect.right) -
Math.max(tokenRect.left, wordRect.left);
const verticalOverlap =
Math.min(tokenRect.bottom, wordRect.bottom) -
Math.max(tokenRect.top, wordRect.top);
return {
clipped: token.scrollWidth - token.clientWidth,
label: token.textContent?.trim(),
overlapsNextWord: horizontalOverlap > 0 && verticalOverlap > 0,
overflow:
Math.max(0, element.getBoundingClientRect().left - tokenRect.left) +
Math.max(0, tokenRect.right - element.getBoundingClientRect().right),
};
});
});
await composer.fill("Use $bench_skill_01 next and $autoreview later");
await expect
.poll(readFriendlyTokenGeometry)
.toSatisfy(
(tokens) =>
tokens?.length === 2 &&
tokens.every(
(token: FriendlyTokenGeometry) =>
token.clipped <= 1 &&
!token.overlapsNextWord &&
token.overflow <= 1 &&
["Bench Skill 01", "Auto Review"].includes(token.label ?? ""),
),
);
await page.setViewportSize({ width: 390, height: 844 });
await composer.fill(
"A mobile line that wraps before $bench_skill_01 next and $autoreview later",
);
await expect
.poll(readFriendlyTokenGeometry)
.toSatisfy(
(tokens) =>
tokens?.length === 2 &&
tokens.every(
(token: FriendlyTokenGeometry) =>
token.clipped <= 1 && !token.overlapsNextWord && token.overflow <= 1,
),
);
await composer.fill("שלום עם $bench_skill_01 הבא ואז $autoreview מאוחר יותר");
await expect.poll(() => composer.getAttribute("dir")).toBe("rtl");
await expect
.poll(readFriendlyTokenGeometry)
.toSatisfy(
(tokens) =>
tokens?.length === 2 &&
tokens.every(
(token: FriendlyTokenGeometry) =>
token.clipped <= 1 && !token.overlapsNextWord && token.overflow <= 1,
),
);
await page.setViewportSize({ width: 420, height: 780 });
await composer.fill(
"A wrapped prompt leading into $wrap: then following copy that must share the native textarea wrap.",
);
const overlay = page.locator(".agent-chat__composer-draft-overlay");
const token = overlay.locator(".agent-chat__skill-token");
await token.waitFor({ state: "visible" });
await expect.poll(() => token.getAttribute("data-raw")).toBe("$wrap");
await expect
.poll(() => overlay.textContent())
.toSatisfy((text) =>
text
?.replace(/\s+/gu, " ")
.includes(
"A wrapped prompt leading into Extremely Detailed Wrapped Composition Helper: then",
),
);
const [textareaGeometry, overlayGeometry] = await Promise.all([
composer.evaluate((element) => {
const style = getComputedStyle(element);
return {
clientWidth: element.clientWidth,
font: style.font,
lineHeight: style.lineHeight,
overflowWrap: style.overflowWrap,
paddingInline: style.paddingInline,
whiteSpace: style.whiteSpace,
};
}),
overlay.evaluate((element) => {
const style = getComputedStyle(element);
return {
clientWidth: element.clientWidth,
font: style.font,
lineHeight: style.lineHeight,
overflowWrap: style.overflowWrap,
paddingInline: style.paddingInline,
whiteSpace: style.whiteSpace,
};
}),
]);
expect(overlayGeometry).toEqual(textareaGeometry);
await expect
.poll(() => overlay.evaluate((element) => getComputedStyle(element).whiteSpace))
.toBe("pre-wrap");
if (artifactDir) {
await page.locator(".agent-chat__composer-shell").screenshot({
path: path.join(artifactDir, "skill-reference-wrapped.png"),
});
}
const rtlDraft = "שלום עם $wrap: וטקסט נוסף שצריך להישאר מיושר עם שדה הכתיבה.";
const rtlTokenStart = rtlDraft.indexOf("$wrap");
const rtlTokenEnd = rtlTokenStart + "$wrap".length;
const rtlDraftWithoutToken = `${rtlDraft.slice(0, rtlTokenStart)}${rtlDraft.slice(
rtlTokenEnd,
)}`;
await composer.fill(rtlDraft);
await expect.poll(() => composer.getAttribute("dir")).toBe("rtl");
await expect.poll(() => overlay.getAttribute("dir")).toBe("rtl");
await token.waitFor({ state: "visible" });
await setComposerCaret(composer, rtlTokenEnd);
await composer.press("Backspace");
await expect.poll(() => composer.inputValue()).toBe(rtlDraftWithoutToken);
await composer.fill(rtlDraft);
await setComposerCaret(composer, rtlTokenStart);
await composer.press("Delete");
await expect.poll(() => composer.inputValue()).toBe(rtlDraftWithoutToken);
},
);
});
@@ -3611,6 +3611,39 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
}
});
it("keeps mobile slash command copy in separate grid tracks", async () => {
const page = await openBrowserPage(390, 844);
try {
await page.setContent(`<!doctype html><html><head><style>${readUiCss()}</style></head><body>
<div class="slash-menu" style="position: relative">
<div class="slash-menu-item" role="option">
<span class="slash-menu-icon">${iconSvg()}</span>
<span class="slash-menu-copy">
<span class="slash-menu-name">/session <span class="slash-menu-args">idle max-age &lt;duration|off&gt;</span></span>
<span class="slash-menu-desc">Manage session-level settings (for example /session idle).</span>
</span>
</div>
</div>
</body></html>`);
const geometry = await page.locator(".slash-menu-copy").evaluate((copy) => {
const name = copy.querySelector<HTMLElement>(".slash-menu-name")!;
const description = copy.querySelector<HTMLElement>(".slash-menu-desc")!;
const nameRect = name.getBoundingClientRect();
const descriptionRect = description.getBoundingClientRect();
return {
copyOverflow: copy.scrollWidth - copy.clientWidth,
gap: descriptionRect.left - nameRect.right,
};
});
expect(geometry.copyOverflow).toBeLessThanOrEqual(1);
expect(geometry.gap).toBeGreaterThanOrEqual(8);
} finally {
await closeBrowserPage(page);
}
});
describe("slash command keyboard navigation", () => {
let page: Page;
@@ -3705,10 +3738,8 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
const items = Array.from({ length: 16 }, (_, index) => {
const active = index === 15 ? " slash-menu-item--active" : "";
return `<div class="slash-menu-item${active}" role="option">
<span class="slash-menu-leading">
<span class="slash-menu-icon">${iconSvg()}</span>
<span class="slash-menu-name">$skill_${index + 1}</span>
</span>
<span class="slash-menu-icon">${iconSvg()}</span>
<span class="slash-menu-copy"><span class="slash-menu-name">$skill_${index + 1}</span></span>
</div>`;
}).join("");
await page.setContent(`<!doctype html><html><head><style>${readUiCss()}</style></head><body>
+150 -7
View File
@@ -615,11 +615,7 @@ function getThinkingReasoningValueLabel(container: Element): string {
"[data-chat-thinking-preview-committed]:not([hidden]), " +
"[data-chat-thinking-preview-index]:not([hidden])",
);
return (
preview?.textContent?.trim() ??
container.querySelector(".chat-controls__reasoning-value")?.textContent?.trim() ??
""
);
return preview?.textContent?.trim() ?? "";
}
function requireElement(container: Element, selector: string, label: string): Element {
@@ -3857,6 +3853,153 @@ describe("chat slash menu accessibility", () => {
expect(onSlashIntent).toHaveBeenCalledOnce();
});
it("shows skills after commands in the slash picker and highlights typed prefixes", () => {
replaceSkillCommands({
key: "status_report",
skillDisplayName: "Status Report",
description: "Prepare a detailed status report.",
});
const { container } = createReactiveDraftHarness();
inputDraftAtEnd(container, "/sta");
const options = Array.from(container.querySelectorAll<HTMLElement>("[role='option']"));
const skillHeader = container.querySelector(
".slash-menu-group--skills .slash-menu-group__label",
);
expect(options.length).toBeGreaterThan(1);
expect(options[0]?.textContent).toContain("/status");
expect(options.at(-1)?.textContent).toContain("/status_report");
expect(skillHeader?.textContent).toBe("Skills");
expect(options[0]?.querySelector("mark")?.textContent).toBe("sta");
expect(options.at(-1)?.querySelector("mark")?.textContent).toBe("sta");
});
it("uses the grouped slash menu order for keyboard selection", () => {
replaceSlashCommands([
{
key: "status-report",
name: "status-report",
description: "Prepare a status report.",
source: "skill",
skillModelVisible: true,
},
{
key: "status-check",
name: "status-check",
description: "Check current status.",
source: "plugin",
},
]);
const harness = createSlashRerenderHarness();
let container = harness.inputAndRender(harness.container, "/status");
const optionNames = () =>
Array.from(container.querySelectorAll<HTMLElement>(".slash-menu [role='option']")).map(
(option) => option.querySelector(".slash-menu-name")?.textContent?.trim(),
);
expect(optionNames()).toEqual(["/status-check", "/status-report"]);
keydownComposer(container, "Enter");
container = harness.renderCurrent();
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("/status-check ");
});
it("dismisses invocation sheets on an outside pointer press", () => {
const { container } = createReactiveDraftHarness();
document.body.append(container);
inputDraftAtEnd(container, "/sta");
expect(container.querySelector(".slash-menu")).not.toBeNull();
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
expect(container.querySelector(".slash-menu")).toBeNull();
container.remove();
});
it("renders confirmed skills as atomic tokens for pointer and arrow navigation", () => {
replaceSkillCommands({
key: "prose_writer",
skillDisplayName: "Prose Writer",
description: "Draft polished prose.",
});
const { container } = createReactiveDraftHarness();
inputDraftAtEnd(container, "Use $prose_writer: next");
const textarea = getComposerTextarea(container);
const token = container.querySelector(".agent-chat__skill-token");
expect(token?.textContent).toContain("Prose Writer");
expect(token?.getAttribute("data-raw")).toBe("$prose_writer");
expect(
container
.querySelector(".agent-chat__composer-draft-overlay")
?.textContent?.replace(/\s+/gu, " ")
.includes("Use Prose Writer: next"),
).toBe(true);
expect(textarea.classList.contains("agent-chat__composer-textarea--rich")).toBe(true);
textarea.setSelectionRange(8, 8);
textarea.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
expect(textarea.selectionStart).toBe(4);
textarea.setSelectionRange("Use $prose_writer".length, "Use $prose_writer".length);
keydownComposer(container, "ArrowLeft");
expect(textarea.selectionStart).toBe(4);
textarea.setSelectionRange(4, 4);
keydownComposer(container, "ArrowRight");
expect(textarea.selectionStart).toBe("Use $prose_writer".length);
const tokenStart = 4;
const tokenEnd = "Use $prose_writer".length;
textarea.setSelectionRange(tokenEnd, tokenEnd);
const selectBackward = keydownComposer(container, "ArrowLeft", { shiftKey: true });
expect(selectBackward.defaultPrevented).toBe(true);
expect([textarea.selectionStart, textarea.selectionEnd, textarea.selectionDirection]).toEqual([
tokenStart,
tokenEnd,
"backward",
]);
const contractForward = keydownComposer(container, "ArrowRight", { shiftKey: true });
expect(contractForward.defaultPrevented).toBe(true);
expect([textarea.selectionStart, textarea.selectionEnd]).toEqual([tokenEnd, tokenEnd]);
textarea.setSelectionRange(tokenStart, tokenStart);
const selectForward = keydownComposer(container, "ArrowRight", { shiftKey: true });
expect(selectForward.defaultPrevented).toBe(true);
expect([textarea.selectionStart, textarea.selectionEnd, textarea.selectionDirection]).toEqual([
tokenStart,
tokenEnd,
"forward",
]);
});
it.each([
{ key: "Backspace", boundary: "end" },
{ key: "Delete", boundary: "start" },
] as const)("handles $key as an atomic skill-token deletion", ({ key, boundary }) => {
replaceSkillCommands({
key: "prose_writer",
skillDisplayName: "Prose Writer",
description: "Draft polished prose.",
});
const { container } = createReactiveDraftHarness();
const draft = "Use $prose_writer: next";
inputDraftAtEnd(container, draft);
const textarea = getComposerTextarea(container);
const tokenStart = draft.indexOf("$prose_writer");
const tokenEnd = tokenStart + "$prose_writer".length;
const caret = boundary === "start" ? tokenStart : tokenEnd;
textarea.setSelectionRange(caret, caret);
const event = keydownComposer(container, key);
expect(event.defaultPrevented).toBe(true);
expect(textarea.value).toBe("Use : next");
expect(textarea.selectionStart).toBe(tokenStart);
expect(container.querySelector(".agent-chat__skill-token")).toBeNull();
});
it("fills a selected $ skill without submitting the surrounding prompt", async () => {
replaceSkillCommands({
key: "prose_writer",
@@ -4325,7 +4468,7 @@ describe("chat slash menu accessibility", () => {
expect(listbox?.querySelector(`#${activeId}`)?.getAttribute("role")).toBe("option");
});
it("removes instant implementation badges without hiding option counts", () => {
it("removes secondary implementation and option-count badges", () => {
const harness = createSlashRerenderHarness();
const container = harness.inputAndRender(harness.container, "/");
const stopOption = Array.from(
@@ -4334,7 +4477,7 @@ describe("chat slash menu accessibility", () => {
expect(stopOption).toBeDefined();
expect(stopOption?.querySelector(".slash-menu-badge")).toBeNull();
expect(container.querySelector(".slash-menu-badge")).not.toBeNull();
expect(container.querySelector(".slash-menu-badge")).toBeNull();
});
it("shows every command directly without an expander or keyboard footer", () => {
@@ -124,6 +124,18 @@ export function replaceComposerPopoverAnchor(
return next;
}
function syncTextareaOverlay(el: HTMLTextAreaElement): HTMLElement | null {
const overlay = el.parentElement?.querySelector<HTMLElement>(
".agent-chat__composer-draft-overlay",
);
if (!overlay) {
return null;
}
overlay.scrollTop = el.scrollTop;
overlay.scrollLeft = el.scrollLeft;
return overlay;
}
function updateTextareaOverflow(el: HTMLTextAreaElement) {
const scrollable = el.scrollHeight > el.clientHeight + 1;
// Two 16px fades need enough vertical runway not to overlap into a narrow
@@ -134,9 +146,26 @@ function updateTextareaOverflow(el: HTMLTextAreaElement) {
el.style.overflowY = scrollable ? "auto" : "hidden";
el.toggleAttribute("data-scroll-fade-top", fadeTop);
el.toggleAttribute("data-scroll-fade-bottom", fadeBottom);
const overlay = syncTextareaOverlay(el);
overlay?.toggleAttribute("data-scroll-fade-top", fadeTop);
overlay?.toggleAttribute("data-scroll-fade-bottom", fadeBottom);
}
export function adjustTextareaHeight(el: HTMLTextAreaElement) {
// A surface that declares the compact shape is a fixed CSS box: it holds one
// line whatever the draft is, so an inline height left by an earlier measured
// pass would silently outrank the stylesheet. Which shape a composer is in is
// declared in its markup, never inferred here from how much text it holds.
if (el.closest('[data-composer-layout="single-line"]')) {
el.style.height = "";
el.style.overflowY = "";
el.removeAttribute("data-scroll-fade-top");
el.removeAttribute("data-scroll-fade-bottom");
const overlay = syncTextareaOverlay(el);
overlay?.removeAttribute("data-scroll-fade-top");
overlay?.removeAttribute("data-scroll-fade-bottom");
return;
}
const thread = el.closest(".chat")?.querySelector<HTMLElement>(".chat-thread") ?? null;
const preserveBottomAnchor = thread
? captureChatSessionScrollPosition(thread).anchorToEnd
@@ -150,7 +179,10 @@ export function adjustTextareaHeight(el: HTMLTextAreaElement) {
const computedMaxHeight = getComputedStyle(el).maxHeight.trim();
const pixelMaxHeight = /^(\d+(?:\.\d+)?)px$/u.exec(computedMaxHeight);
const maxHeight = pixelMaxHeight ? Number(pixelMaxHeight[1]) : 150;
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
const overlay = el.parentElement?.querySelector<HTMLElement>(
".agent-chat__composer-draft-overlay",
);
el.style.height = `${Math.min(Math.max(el.scrollHeight, overlay?.scrollHeight ?? 0), maxHeight)}px`;
updateTextareaOverflow(el);
// Once capped, the textarea can perturb the sibling transcript without
// resizing its viewport, so ResizeObserver has no correction to apply.
@@ -297,6 +329,22 @@ export function scrollActiveMenuOptionIntoView(activeId: string | null): void {
});
}
export function syncComposerMenuScroll(element: Element | undefined): void {
if (!(element instanceof HTMLElement)) {
return;
}
const sync = () => {
const scrollable = element.scrollHeight > element.clientHeight + 1;
element.dataset.scrollable = String(scrollable);
element.dataset.atStart = String(!scrollable || element.scrollTop <= 1);
element.dataset.atEnd = String(
!scrollable || element.scrollTop + element.clientHeight >= element.scrollHeight - 1,
);
};
sync();
requestAnimationFrame(sync);
}
export function paneDomId(paneId: string, suffix: string): string {
return `chat-${encodeURIComponent(paneId)}-${suffix}`;
}
@@ -1,7 +1,11 @@
import type { ChatSendShortcut } from "../../../app/settings.ts";
import { steerableQueuedMessage } from "../chat-queue.ts";
import { restoreHistoryCaret } from "./chat-composer-dom.ts";
import { handleSkillMenuKeydown, type SkillMenuHost } from "./chat-composer-skill-menu.ts";
import {
handleSkillMenuKeydown,
handleSkillTokenKeydown,
type SkillMenuHost,
} from "./chat-composer-skill-menu.ts";
import { handleSlashMenuKeydown, type SlashMenuHost } from "./chat-composer-slash-menu.ts";
import type { ChatComposerProps, ChatComposerState } from "./chat-composer-types.ts";
@@ -43,6 +47,10 @@ export function createComposerKeyDownHandler({
return;
}
if (handleSkillTokenKeydown(event)) {
return;
}
if (props.connected && handleSkillMenuKeydown(event, state, skillMenuHost, requestUpdate)) {
return;
}
@@ -126,11 +126,14 @@ function renderCapabilityToggleRow(options: {
title: string | null | undefined;
icon?: TemplateResult;
note?: TemplateResult | typeof nothing;
checkbox?: boolean;
}) {
return html`
<wa-dropdown-item
class="agent-chat__capability-menu-item agent-chat__capability-menu-toggle"
value=${options.value}
type=${options.checkbox ? "checkbox" : "normal"}
.checked=${options.checked}
?disabled=${options.disabled}
title=${options.title ?? ""}
>
@@ -139,15 +142,17 @@ function renderCapabilityToggleRow(options: {
<span>${options.label}</span>
${options.note ?? nothing}
</span>
<wa-switch
slot="details"
class="agent-chat__capability-menu-switch"
size="s"
tabindex="-1"
.checked=${options.checked}
?disabled=${options.disabled}
aria-label=${options.label}
></wa-switch>
${options.checkbox
? nothing
: html`<wa-switch
slot="details"
class="agent-chat__capability-menu-switch"
size="s"
tabindex="-1"
.checked=${options.checked}
?disabled=${options.disabled}
aria-label=${options.label}
></wa-switch>`}
</wa-dropdown-item>
`;
}
@@ -211,17 +216,15 @@ function renderRootView(props: ChatComposerPlusMenuProps) {
>
</span>
</wa-dropdown-item>
<wa-dropdown-item
class="agent-chat__capability-menu-item"
type="checkbox"
value="toggle-web-search"
.checked=${webSearchEnabled}
?disabled=${props.mutationBlockedReason !== null}
title=${props.mutationBlockedReason ?? ""}
>
<span slot="icon" aria-hidden="true">${icons.globe}</span>
<span>${t("chat.composer.menu.webSearch")}</span>
</wa-dropdown-item>
${renderCapabilityToggleRow({
value: "toggle-web-search",
label: t("chat.composer.menu.webSearch"),
checked: webSearchEnabled,
disabled: props.mutationBlockedReason !== null,
title: props.mutationBlockedReason,
icon: icons.globe,
checkbox: true,
})}
${menuDivider()}
<wa-dropdown-item class="agent-chat__capability-menu-item" value="manage-plugins">
<span slot="icon" aria-hidden="true">${icons.puzzle}</span>
@@ -1,4 +1,5 @@
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { icons } from "../../../components/icons.ts";
import { t } from "../../../i18n/index.ts";
import {
@@ -7,16 +8,36 @@ import {
getSlashCommandDescription,
type SlashCommandDef,
} from "../../../lib/chat/commands.ts";
import { paneDomId, scrollActiveMenuOptionIntoView } from "./chat-composer-dom.ts";
import {
paneDomId,
scrollActiveMenuOptionIntoView,
syncComposerMenuScroll,
} from "./chat-composer-dom.ts";
const SKILL_MENTION_CHAR = /[-a-zA-Z0-9_:]/u;
function renderSkillName(name: string, query: string): TemplateResult {
const matchLength = name.toLowerCase().startsWith(query.toLowerCase()) ? query.length : 0;
return matchLength === 0
? html`${name}`
: html`<mark>${name.slice(0, matchLength)}</mark>${name.slice(matchLength)}`;
}
type SkillMentionTarget = {
start: number;
end: number;
query: string;
};
type SkillDraftToken = {
command: SlashCommandDef;
end: number;
raw: string;
start: number;
};
type SkillDraftRange = { start: number; end: number; navigationEnd: number };
export type SkillMenuState = {
skillMenuOpen: boolean;
skillMenuItems: SlashCommandDef[];
@@ -194,6 +215,177 @@ export function getActiveSkillMenuOptionLabel(state: SkillMenuState): string {
return command ? `${getSkillDisplayName(command)} ${getSlashCommandDescription(command)}` : "";
}
function parseSkillDraftTokens(value: string): SkillDraftToken[] {
const tokens: SkillDraftToken[] = [];
const referencePattern = /\$([-a-zA-Z0-9_:]+)/gu;
for (const match of value.matchAll(referencePattern)) {
const start = match.index;
const matchedName = match[1] ?? "";
const name = matchedName.replace(/:+$/u, "");
if (start === undefined || isEscapedReference(value, start)) {
continue;
}
const command = getSkillCommandCompletions(name).find((candidate) => candidate.name === name);
if (!command) {
continue;
}
const raw = `$${name}`;
tokens.push({ command, end: start + raw.length, raw, start });
}
return tokens;
}
function skillDraftRanges(value: string): SkillDraftRange[] {
const ranges: SkillDraftRange[] = [];
for (const match of value.matchAll(/\$([-a-zA-Z0-9_:]+)/gu)) {
const start = match.index;
const name = (match[1] ?? "").replace(/:+$/u, "");
if (start === undefined || isEscapedReference(value, start)) {
continue;
}
if (getSkillCommandCompletions(name).some((candidate) => candidate.name === name)) {
const end = start + name.length + 1;
ranges.push({ start, end, navigationEnd: /\s/u.test(value[end] ?? "") ? end + 1 : end });
}
}
return ranges;
}
export function normalizeSkillTokenSelection(target: HTMLTextAreaElement): boolean {
const { selectionStart, selectionEnd } = target;
let nextStart = selectionStart;
let nextEnd = selectionEnd;
for (const range of skillDraftRanges(target.value)) {
if (
selectionStart === selectionEnd &&
selectionStart > range.start &&
selectionStart < range.navigationEnd
) {
const fromStart = selectionStart - range.start;
const fromEnd = range.navigationEnd - selectionStart;
nextStart = fromStart < fromEnd ? range.start : range.navigationEnd;
nextEnd = nextStart;
break;
}
if (selectionStart > range.start && selectionStart < range.end) {
nextStart = range.start;
}
if (selectionEnd > range.start && selectionEnd < range.end) {
nextEnd = range.end;
}
}
if (nextStart === selectionStart && nextEnd === selectionEnd) {
return false;
}
target.setSelectionRange(nextStart, nextEnd, target.selectionDirection);
return true;
}
export function handleSkillTokenKeydown(event: KeyboardEvent): boolean {
if (
!["ArrowLeft", "ArrowRight", "Backspace", "Delete"].includes(event.key) ||
event.altKey ||
event.ctrlKey ||
event.metaKey
) {
return false;
}
const target = event.target;
if (!(target instanceof HTMLTextAreaElement)) {
return false;
}
if (event.shiftKey) {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return false;
}
const direction = target.selectionDirection;
const caret = direction === "backward" ? target.selectionStart : target.selectionEnd;
const anchor = direction === "backward" ? target.selectionEnd : target.selectionStart;
for (const range of skillDraftRanges(target.value)) {
const nextCaret =
event.key === "ArrowLeft" && caret > range.start && caret <= range.end
? range.start
: event.key === "ArrowRight" && caret >= range.start && caret < range.end
? range.end
: null;
if (nextCaret === null) {
continue;
}
event.preventDefault();
target.setSelectionRange(
Math.min(anchor, nextCaret),
Math.max(anchor, nextCaret),
nextCaret < anchor ? "backward" : "forward",
);
return true;
}
return false;
}
if (target.selectionStart !== target.selectionEnd) {
return false;
}
const caret = target.selectionStart;
for (const range of skillDraftRanges(target.value)) {
const deletesBackward = event.key === "Backspace" && caret === range.end;
const deletesForward = event.key === "Delete" && caret === range.start;
if (deletesBackward || deletesForward) {
event.preventDefault();
target.setRangeText("", range.start, range.end, "end");
// Reuse the composer's input owner so the controlled draft, picker, and
// token overlay observe the atomic replacement together.
target.dispatchEvent(
new InputEvent("input", {
bubbles: true,
inputType: deletesBackward ? "deleteContentBackward" : "deleteContentForward",
}),
);
return true;
}
const movesLeft =
event.key === "ArrowLeft" && caret > range.start && caret <= range.navigationEnd;
const movesRight =
event.key === "ArrowRight" && caret >= range.start && caret < range.navigationEnd;
if (movesLeft || movesRight) {
event.preventDefault();
const nextCaret = movesLeft ? range.start : range.navigationEnd;
target.setSelectionRange(nextCaret, nextCaret);
return true;
}
}
return false;
}
export function renderSkillDraftOverlay(
value: string,
direction: "ltr" | "rtl",
): TemplateResult | typeof nothing {
const tokens = parseSkillDraftTokens(value);
if (tokens.length === 0) {
return nothing;
}
const content: Array<string | TemplateResult> = [];
let cursor = 0;
for (const token of tokens) {
content.push(
value.slice(cursor, token.start),
html`<span class="agent-chat__skill-token" data-raw=${token.raw}
><span class="agent-chat__skill-token-icon">${icons.pencilSparkles}</span
>${getSkillDisplayName(token.command)}</span
>`,
);
cursor = token.end;
}
content.push(value.slice(cursor));
// Template whitespace changes the mirrored draft's line breaks. Keep text
// segments adjacent to inline tokens so only presented content drives layout.
// oxfmt-ignore
return html`<div
class="agent-chat__composer-draft-overlay"
aria-hidden="true"
dir=${direction}
>${content}</div>`;
}
function selectSkillMention(
command: SlashCommandDef,
state: SkillMenuState,
@@ -288,7 +480,14 @@ export function renderSkillMenu(
role="listbox"
aria-label=${t("chat.skills.menu")}
>
<div class="slash-menu__scroll">
<div
class="slash-menu__scroll"
${ref(syncComposerMenuScroll)}
@scroll=${(event: Event) =>
syncComposerMenuScroll(
event.currentTarget instanceof Element ? event.currentTarget : undefined,
)}
>
${state.skillCommandRefreshPending || state.skillMenuItems.length === 0
? html`<div class="slash-menu-group">
<div class="slash-menu-group__label">${t("chat.skills.loading")}</div>
@@ -311,11 +510,14 @@ export function renderSkillMenu(
requestUpdate();
}}
>
<span class="slash-menu-leading">
<span class="slash-menu-icon">${icons.zap}</span>
<span class="slash-menu-name">${getSkillDisplayName(command)}</span>
</span>
<span class="slash-menu-trailing">
<span class="slash-menu-icon">${icons.pencilSparkles}</span>
<span class="slash-menu-copy">
<span class="slash-menu-name"
>${renderSkillName(
getSkillDisplayName(command),
state.skillMenuTarget?.query ?? "",
)}</span
>
<span class="slash-menu-desc">${getSlashCommandDescription(command)}</span>
</span>
</div>
@@ -1,4 +1,5 @@
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import { icons, type IconName } from "../../../components/icons.ts";
import { t } from "../../../i18n/index.ts";
import {
@@ -9,7 +10,11 @@ import {
type SlashCommandCategory,
type SlashCommandDef,
} from "../../../lib/chat/commands.ts";
import { paneDomId, scrollActiveMenuOptionIntoView } from "./chat-composer-dom.ts";
import {
paneDomId,
scrollActiveMenuOptionIntoView,
syncComposerMenuScroll,
} from "./chat-composer-dom.ts";
export type SlashMenuState = {
slashMenuOpen: boolean;
@@ -144,7 +149,10 @@ export function updateSlashMenu(
const items = getSlashCommandCompletions(match[1] ?? "", { showAll: true }).filter(
(command) => host.commandFilter?.(command) ?? true,
);
state.slashMenuItems = items;
state.slashMenuItems = [
...items.filter((command) => command.source !== "skill"),
...items.filter((command) => command.source === "skill"),
];
state.slashMenuOpen = items.length > 0;
state.slashMenuIndex = 0;
state.slashMenuMode = "command";
@@ -283,6 +291,9 @@ function slashOptionIdSegment(value: string): string {
);
}
function syncComposerMenuScrollEvent(event: Event): void {
syncComposerMenuScroll(event.currentTarget instanceof Element ? event.currentTarget : undefined);
}
function getSlashCommandOptionId(paneId: string, cmd: SlashCommandDef): string {
return paneDomId(paneId, `slash-option-command-${slashOptionIdSegment(cmd.name)}`);
}
@@ -338,6 +349,54 @@ function renderSlashIcon(name: string) {
return icons[name as IconName] ?? icons.terminal;
}
function renderMatchedName(name: string, query: string): TemplateResult {
const matchLength = name.toLowerCase().startsWith(query.toLowerCase()) ? query.length : 0;
return matchLength === 0
? html`${name}`
: html`<mark>${name.slice(0, matchLength)}</mark>${name.slice(matchLength)}`;
}
function renderSlashCommandOption(params: {
cmd: SlashCommandDef;
index: number;
query: string;
requestUpdate: () => void;
host: SlashMenuHost;
state: SlashMenuState;
}): TemplateResult {
const { cmd, index, query, requestUpdate, host, state } = params;
return html`
<div
id=${getSlashCommandOptionId(host.paneId, cmd)}
class="slash-menu-item ${index === state.slashMenuIndex ? "slash-menu-item--active" : ""}"
role="option"
aria-selected=${index === state.slashMenuIndex}
@mousedown=${(event: MouseEvent) => event.preventDefault()}
@click=${() => selectSlashCommand(cmd, state, host, requestUpdate)}
@mouseenter=${() => {
state.slashMenuIndex = index;
requestUpdate();
}}
>
<span class="slash-menu-icon"
>${cmd.source === "skill"
? icons.pencilSparkles
: cmd.icon
? renderSlashIcon(cmd.icon)
: icons.terminal}</span
>
<span class="slash-menu-copy">
<span class="slash-menu-name"
>/${renderMatchedName(cmd.name, query)}${cmd.args
? html`<span class="slash-menu-args"> ${cmd.args}</span>`
: nothing}</span
>
<span class="slash-menu-desc">${getSlashCommandDescription(cmd)}</span>
</span>
</div>
`;
}
export function renderSlashMenu(
state: SlashMenuState,
host: SlashMenuHost,
@@ -361,7 +420,11 @@ export function renderSlashMenu(
role="listbox"
aria-label=${t("chat.commands.arguments")}
>
<div class="slash-menu__scroll">
<div
class="slash-menu__scroll"
${ref(syncComposerMenuScroll)}
@scroll=${syncComposerMenuScrollEvent}
>
<div class="slash-menu-group">
<div class="slash-menu-group__label">
/${state.slashMenuCommand.name} ${getSlashCommandDescription(state.slashMenuCommand)}
@@ -381,15 +444,13 @@ export function renderSlashMenu(
requestUpdate();
}}
>
<span class="slash-menu-leading">
<span class="slash-menu-icon"
>${state.slashMenuCommand?.icon
? renderSlashIcon(state.slashMenuCommand.icon)
: nothing}</span
>
<span class="slash-menu-icon"
>${state.slashMenuCommand?.icon
? renderSlashIcon(state.slashMenuCommand.icon)
: icons.terminal}</span
>
<span class="slash-menu-copy">
<span class="slash-menu-name">${arg}</span>
</span>
<span class="slash-menu-trailing">
<span class="slash-menu-desc">/${state.slashMenuCommand?.name} ${arg}</span>
</span>
</div>
@@ -405,65 +466,60 @@ export function renderSlashMenu(
return nothing;
}
const groups: Array<[SlashCommandCategory, Array<{ cmd: SlashCommandDef; globalIdx: number }>]> =
const query = draft.slice(1);
const commands = state.slashMenuItems.filter((command) => command.source !== "skill");
const skills = state.slashMenuItems.filter((command) => command.source === "skill");
const groups: Array<[SlashCommandCategory, Array<{ command: SlashCommandDef; index: number }>]> =
[];
for (const [globalIdx, cmd] of state.slashMenuItems.entries()) {
const category = cmd.category ?? "session";
for (const [index, command] of commands.entries()) {
const category = command.category ?? "session";
const group =
draft === "/" ? groups.find(([groupCategory]) => groupCategory === category) : groups.at(-1);
if (group?.[0] === category) {
group[1].push({ cmd, globalIdx });
group[1].push({ command, index });
} else {
groups.push([category, [{ cmd, globalIdx }]]);
groups.push([category, [{ command, index }]]);
}
}
const sections = groups.map(
([category, entries]) => html`
<div class="slash-menu-group">
<div class="slash-menu-group__label">${getSlashCommandCategoryLabel(category)}</div>
${entries.map(
({ cmd, globalIdx }) => html`
<div
id=${getSlashCommandOptionId(host.paneId, cmd)}
class="slash-menu-item ${globalIdx === state.slashMenuIndex
? "slash-menu-item--active"
: ""}"
role="option"
aria-selected=${globalIdx === state.slashMenuIndex}
@click=${() => selectSlashCommand(cmd, state, host, requestUpdate)}
@mouseenter=${() => {
state.slashMenuIndex = globalIdx;
requestUpdate();
}}
>
<span class="slash-menu-leading">
<span class="slash-menu-icon"
>${cmd.icon ? renderSlashIcon(cmd.icon) : nothing}</span
>
<span class="slash-menu-name">/${cmd.name}</span>
${cmd.args ? html`<span class="slash-menu-args">${cmd.args}</span>` : nothing}
</span>
<span class="slash-menu-trailing">
<span class="slash-menu-desc">${getSlashCommandDescription(cmd)}</span>
${host.resolveArgOptions(cmd).length
? html`<span class="slash-menu-badge"
>${t("chat.commands.optionCount", {
count: String(host.resolveArgOptions(cmd).length),
})}</span
>`
: nothing}
</span>
</div>
`,
)}
</div>
`,
);
return html`
<div id=${listboxId} class="slash-menu" role="listbox" aria-label=${t("chat.commands.menu")}>
<div class="slash-menu__scroll">${sections}</div>
<div
class="slash-menu__scroll"
${ref(syncComposerMenuScroll)}
@scroll=${syncComposerMenuScrollEvent}
>
${groups.map(
([category, entries]) => html`<div class="slash-menu-group">
<div class="slash-menu-group__label">${getSlashCommandCategoryLabel(category)}</div>
${entries.map(({ command, index }) =>
renderSlashCommandOption({
cmd: command,
index,
query,
requestUpdate,
host,
state,
}),
)}
</div>`,
)}
${skills.length > 0
? html`<div class="slash-menu-group slash-menu-group--skills">
<div class="slash-menu-group__label">${t("chat.skills.label")}</div>
${skills.map((cmd, index) =>
renderSlashCommandOption({
cmd,
index: commands.length + index,
query,
requestUpdate,
host,
state,
}),
)}
</div>`
: nothing}
</div>
</div>
`;
}
@@ -25,12 +25,23 @@ import { focusComposerFromChrome, paneDomId } from "./chat-composer-dom.ts";
import { renderChatGoal } from "./chat-composer-goal.ts";
import { renderChatComposerPlusMenu } from "./chat-composer-plus-menu.ts";
import { renderChatQueue } from "./chat-composer-queue.ts";
import { renderSkillMenu, type SkillMenuHost } from "./chat-composer-skill-menu.ts";
import { renderSlashMenu, type SlashMenuHost } from "./chat-composer-slash-menu.ts";
import {
normalizeSkillTokenSelection,
resetSkillMenuState,
renderSkillDraftOverlay,
renderSkillMenu,
type SkillMenuHost,
} from "./chat-composer-skill-menu.ts";
import {
renderSlashMenu,
resetSlashMenuState,
type SlashMenuHost,
} from "./chat-composer-slash-menu.ts";
import { commitComposerDraft } from "./chat-composer-state.ts";
import { renderCompactionIndicator, renderFallbackIndicator } from "./chat-composer-status.ts";
import type { ChatComposerProps, ChatComposerState } from "./chat-composer-types.ts";
import {
ensureChatComposerPickerDismissal,
handleChatComposerDropdownShow,
markPointerOpenedChatComposerDropdown,
restorePointerOpenedChatComposerTrigger,
@@ -120,6 +131,9 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
slashMenuListboxId,
slashMenuAnnouncementId,
} = context;
if (slashMenuVisible || skillMenuVisible) {
ensureChatComposerPickerDismissal();
}
const disabledBanner = props.disabledBanner
? html`
<div
@@ -236,6 +250,8 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
state.dictationSelection?.end ?? visibleDraft.length,
).value
: visibleDraft;
const draftDirection = detectTextDirection(dictationPreviewDraft);
const skillDraftOverlay = renderSkillDraftOverlay(dictationPreviewDraft, draftDirection);
const progressCard = props.progressCard
? html`<div class="agent-chat__progress-float">
${renderSessionProgressCard(props.progressCard, "composer", props.onDismissProgressCard)}
@@ -246,8 +262,8 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
${renderChatGoal(state, activeSession.goal, {
canAct: props.connected && canCompose,
onGoalCommand: props.onGoalCommand,
onGoalEdit: (goal) => {
commitComposerDraft(props, `/goal edit ${goal.objective}`);
onGoalEdit: (updatedGoal) => {
commitComposerDraft(props, `/goal edit ${updatedGoal.objective}`);
requestUpdate();
queueMicrotask(() => state.composerTextarea?.focus({ preventScroll: true }));
},
@@ -290,6 +306,12 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
: ""}${dictation?.active ? " agent-chat__input--dictating" : ""}"
@wa-show=${handleChatComposerDropdownShow}
@wa-after-show=${restorePointerOpenedChatComposerTrigger}
@openclaw-composer-dismiss-invocations=${() => {
state.slashMenuOpen = false;
resetSlashMenuState(state);
resetSkillMenuState(state);
requestUpdate();
}}
@click=${(event: MouseEvent) => focusComposerFromChrome(event, canCompose)}
@pointerdown=${(event: PointerEvent) => {
markPointerOpenedChatComposerDropdown(event);
@@ -401,8 +423,11 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
<div class="agent-chat__composer-combobox">
<textarea
${ref(state.textareaRef ?? undefined)}
class=${skillDraftOverlay === nothing
? ""
: "agent-chat__composer-textarea--rich"}
.value=${dictationPreviewDraft}
dir=${detectTextDirection(dictationPreviewDraft)}
dir=${draftDirection}
?disabled=${!canCompose}
?readonly=${dictation?.locksComposer === true}
aria-autocomplete="list"
@@ -423,6 +448,12 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
@beforeinput=${handleBeforeInput}
@input=${handleInput}
@select=${handleSelect}
@pointerup=${(event: PointerEvent) => {
if (event.currentTarget instanceof HTMLTextAreaElement) {
normalizeSkillTokenSelection(event.currentTarget);
}
handleSelect(event);
}}
@compositionstart=${(event: CompositionEvent) => {
state.composerComposing = true;
state.composingDraft = {
@@ -441,6 +472,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
placeholder=${dictation?.active ? "" : placeholder}
rows="1"
></textarea>
${skillDraftOverlay}
<span
id=${slashMenuAnnouncementId}
class="sr-only"
@@ -164,35 +164,17 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
<span class="chat-controls__effort-heading">
${t("chat.modelControls.effort")}
</span>
<span class="chat-controls__reasoning-state">
<span
class="chat-controls__reasoning-value ${hasThinkingOverride
? ""
: "chat-controls__reasoning-value--inherit"}"
>
${sliderStops.length > 1
? html`
<span data-chat-thinking-preview-committed>
${reasoningValueText}
</span>
${sliderStops.map(
(stop, index) => html`
<span data-chat-thinking-preview-index=${index} hidden>
${formatEffortLabel(stop.label)}
</span>
`,
)}
`
: reasoningValueText}
</span>
<span class="sr-only">
<span data-chat-thinking-preview-committed>${reasoningValueText}</span>
${sliderStops.map(
(stop, index) => html`<span data-chat-thinking-preview-index=${index} hidden
>${formatEffortLabel(stop.label)}</span
>`,
)}
</span>
</div>
${sliderStops.length > 1
? html`
<div class="chat-controls__effort-scale" aria-hidden="true">
<span>${t("chat.modelControls.faster")}</span>
<span>${t("chat.modelControls.smarter")}</span>
</div>
<div class="chat-controls__reasoning-slider">
<div class="chat-controls__reasoning-dots" aria-hidden="true">
${sliderStops.map(
@@ -231,6 +213,10 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
resetSliderPreview(event.currentTarget as HTMLInputElement, true)}
/>
</div>
<div class="chat-controls__effort-scale" aria-hidden="true">
<span>${t("chat.modelControls.faster")}</span>
<span>${t("chat.modelControls.smarter")}</span>
</div>
`
: onlyStop
? html`
@@ -6,6 +6,7 @@ const MOBILE_COMPOSER_OVERLAY_QUERY =
const pointerOpenedDropdowns = new WeakSet<HTMLElement>();
const POINTER_RESTORED_FOCUS_ATTRIBUTE = "data-chat-pointer-restored-focus";
const POINTER_OPENED_PICKER_ATTRIBUTE = "data-chat-pointer-opened-picker";
const CHAT_COMPOSER_DISMISS_INVOCATIONS_EVENT = "openclaw-composer-dismiss-invocations";
let composerPickerDismissalInstalled = false;
@@ -50,6 +51,15 @@ function dismissChatComposerPickersOutside(event: PointerEvent): void {
closeComposerPicker(picker);
}
}
for (const menu of document.querySelectorAll<HTMLElement>(
".agent-chat__input > :is(.slash-menu, .skill-menu)",
)) {
if (!path.includes(menu)) {
menu
.closest(".agent-chat__input")
?.dispatchEvent(new CustomEvent(CHAT_COMPOSER_DISMISS_INVOCATIONS_EVENT));
}
}
}
function dismissChatComposerPickersOnEscape(event: KeyboardEvent): void {
@@ -57,21 +67,26 @@ function dismissChatComposerPickersOnEscape(event: KeyboardEvent): void {
return;
}
const pickers = openChatComposerPickers();
if (pickers.length === 0) {
const invocationComposer = document
.querySelector<HTMLElement>(".agent-chat__input > :is(.slash-menu, .skill-menu)")
?.closest<HTMLElement>(".agent-chat__input");
if (pickers.length === 0 && !invocationComposer) {
return;
}
event.preventDefault();
event.stopPropagation();
const lastPicker = pickers.at(-1);
if (!lastPicker) {
return;
}
const trigger = pickerTrigger(lastPicker);
const trigger = pickers.at(-1);
pickers.forEach(closeComposerPicker);
trigger?.focus({ preventScroll: true });
invocationComposer?.dispatchEvent(new CustomEvent(CHAT_COMPOSER_DISMISS_INVOCATIONS_EVENT));
invocationComposer
?.querySelector<HTMLTextAreaElement>(".agent-chat__composer-combobox > textarea")
?.focus({ preventScroll: true });
if (trigger) {
pickerTrigger(trigger)?.focus({ preventScroll: true });
}
}
function ensureChatComposerPickerDismissal(): void {
export function ensureChatComposerPickerDismissal(): void {
if (composerPickerDismissalInstalled || typeof document === "undefined") {
return;
}
-1
View File
@@ -654,7 +654,6 @@ img.chat-avatar.chat-avatar--logo {
transition: border-color var(--duration-fast) ease-out;
width: auto;
max-width: 100%;
box-sizing: border-box;
min-width: 0;
overflow-wrap: break-word;
}
+139 -98
View File
@@ -299,7 +299,6 @@ openclaw-chat-page {
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
box-sizing: border-box;
width: calc(100% - 36px);
max-width: var(--chat-thread-max-width, 48rem);
min-height: 52px;
@@ -553,7 +552,6 @@ openclaw-chat-page {
}
.chat-thread-inner {
box-sizing: border-box;
/* Shared gutter plus the responsive side inset that used to be
.chat-thread's horizontal padding (moved off the scroll container so
overlay scrollbars sit flush at the pane edge). */
@@ -563,7 +561,6 @@ openclaw-chat-page {
}
.chat-inline-approval {
box-sizing: border-box;
flex: 0 0 auto;
width: calc(100% - 2 * var(--chat-thread-gutter) - 2 * var(--chat-thread-side-inset));
max-width: var(--chat-thread-max-width);
@@ -1094,7 +1091,6 @@ openclaw-chat-page {
position: relative;
z-index: 0;
box-sizing: border-box;
width: calc(100% - 24px);
margin: 0 12px calc(0px - var(--chat-composer-shell-gap) - var(--chat-goal-underlap));
}
@@ -1102,7 +1098,6 @@ openclaw-chat-page {
.agent-chat__goal-float .agent-chat__goal {
width: 100%;
max-width: none;
box-sizing: border-box;
margin: 0;
padding-top: 0;
padding-bottom: var(--chat-goal-underlap);
@@ -1543,7 +1538,6 @@ openclaw-chat-video-player {
.chat-assistant-attachment-card__preview-text {
width: 100%;
max-height: 174px;
box-sizing: border-box;
margin: 0;
overflow: auto;
padding: 9px 10px;
@@ -1817,7 +1811,6 @@ openclaw-chat-video-player {
align-items: center;
gap: 9px;
min-height: 36px;
box-sizing: border-box;
padding: 7px 10px 7px 12px;
margin: 0 0 6px;
background: color-mix(in srgb, var(--text-strong) 4%, transparent);
@@ -2074,7 +2067,6 @@ button.chat-reply-preview--message:disabled {
display: flex;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
width: calc(100% - 36px);
max-width: var(--chat-thread-max-width, 48rem);
margin-inline: auto;
@@ -2402,7 +2394,6 @@ button.chat-reply-preview--message:disabled {
.task-suggestion__instruction-body code {
position: relative;
z-index: 0;
box-sizing: border-box;
width: 100%;
max-width: 100%;
padding: 7px 10px 10px;
@@ -2566,7 +2557,6 @@ button.chat-reply-preview--message:disabled {
position: relative;
display: grid;
gap: 8px;
box-sizing: border-box;
width: 100%;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
@@ -3324,7 +3314,6 @@ button.chat-pr__diff {
position: relative;
z-index: 0;
box-sizing: border-box;
width: 100%;
margin: calc(0px - var(--chat-composer-shell-gap) - var(--chat-composer-bottom-underlap)) 0 0;
/* Keep the shell gap outside the row's centering box; only the intentional
@@ -3363,7 +3352,6 @@ button.chat-pr__diff {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 100%;
min-height: var(--chat-composer-underlap-row-height);
padding: 8px 14px;
@@ -3473,13 +3461,66 @@ button.chat-pr__diff {
background: transparent;
color: var(--text-strong);
font-size: var(--chat-composer-editor-size);
font-family: inherit;
line-height: var(--chat-composer-editor-line);
outline: none;
box-sizing: border-box;
}
.agent-chat__composer-combobox > textarea:is([data-scroll-fade-top], [data-scroll-fade-bottom]) {
.agent-chat__composer-draft-overlay {
position: absolute;
inset: 0;
z-index: 3;
padding-block: var(--chat-composer-editor-inset-block);
overflow: hidden;
color: var(--text-strong);
font-size: var(--chat-composer-editor-size);
line-height: var(--chat-composer-editor-line);
overflow-wrap: break-word;
pointer-events: none;
white-space: pre-wrap;
}
.agent-chat__composer-combobox > textarea.agent-chat__composer-textarea--rich {
position: relative;
z-index: 1;
caret-color: var(--text-strong);
color: transparent;
-webkit-text-fill-color: transparent;
}
.agent-chat__composer-combobox > textarea.agent-chat__composer-textarea--rich::selection {
color: transparent;
-webkit-text-fill-color: transparent;
}
.agent-chat__skill-token {
display: inline-flex;
align-items: baseline;
gap: 0.22em;
background: var(--chat-composer-surface);
color: var(--info);
vertical-align: baseline;
white-space: nowrap;
}
.agent-chat__skill-token-icon {
display: inline-flex;
width: 1em;
height: 1em;
align-items: center;
justify-content: center;
align-self: center;
}
.agent-chat__skill-token-icon svg {
width: 1em;
height: 1em;
}
.agent-chat__composer-combobox
> :is(textarea, .agent-chat__composer-draft-overlay):is(
[data-scroll-fade-top],
[data-scroll-fade-bottom]
) {
--chat-composer-scroll-mask-top: currentColor;
--chat-composer-scroll-mask-bottom: currentColor;
@@ -3499,11 +3540,13 @@ button.chat-pr__diff {
);
}
.agent-chat__composer-combobox > textarea[data-scroll-fade-top] {
.agent-chat__composer-combobox
> :is(textarea, .agent-chat__composer-draft-overlay)[data-scroll-fade-top] {
--chat-composer-scroll-mask-top: transparent;
}
.agent-chat__composer-combobox > textarea[data-scroll-fade-bottom] {
.agent-chat__composer-combobox
> :is(textarea, .agent-chat__composer-draft-overlay)[data-scroll-fade-bottom] {
--chat-composer-scroll-mask-bottom: transparent;
}
@@ -3639,7 +3682,6 @@ button.chat-pr__diff {
.agent-chat__disabled-banner {
width: 100%;
box-sizing: border-box;
}
/* A replacement banner is the composer surface for a terminal session state,
@@ -3714,7 +3756,6 @@ button.chat-pr__diff {
grid-template-columns: minmax(0, auto) minmax(0, 1fr);
align-items: center;
flex-shrink: 0;
box-sizing: border-box;
column-gap: 5px;
min-height: var(--chat-composer-control-size);
margin-top: auto;
@@ -3921,7 +3962,6 @@ button.chat-pr__diff {
}
.agent-chat__attach-menu-option {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--chat-composer-menu-row-gap);
@@ -3990,7 +4030,6 @@ button.chat-pr__diff {
--wa-color-neutral-fill-normal: var(--bg-hover);
--wa-color-text-normal: var(--text);
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--chat-composer-menu-row-gap);
@@ -4105,6 +4144,10 @@ button.chat-pr__diff {
pointer-events: none;
}
.agent-chat__capability-menu-toggle[type="checkbox"]::part(checkmark) {
display: none;
}
.agent-chat__capability-menu-subrow::part(base) {
padding-left: 28px;
}
@@ -4147,7 +4190,6 @@ button.chat-pr__diff {
}
.agent-chat__composer-errors {
box-sizing: border-box;
width: 100%;
background: color-mix(in srgb, var(--danger) 8%, var(--chat-composer-surface));
color: color-mix(in srgb, var(--danger) 78%, var(--text));
@@ -4166,7 +4208,6 @@ button.chat-pr__diff {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
min-height: var(--chat-composer-underlap-row-height, 44px);
padding: 8px 10px 8px 14px;
font-size: var(--control-ui-text-sm);
@@ -5316,7 +5357,6 @@ button.chat-pr__diff {
.agent-chat__input .chat-controls__model-menu,
.agent-chat__input .chat-controls__effort-menu {
box-sizing: border-box;
width: calc(100vw - max(12px, var(--safe-area-left)) - max(12px, var(--safe-area-right)));
max-height: min(440px, var(--auto-size-available-height, calc(100dvh - 116px)));
}
@@ -5376,7 +5416,6 @@ button.chat-pr__diff {
}
.context-usage__popover {
box-sizing: border-box;
position: fixed;
left: max(12px, var(--safe-area-left));
right: max(12px, var(--safe-area-right));
@@ -5388,25 +5427,45 @@ button.chat-pr__diff {
}
.slash-menu {
--slash-menu-max-height: min(42vh, 336px);
position: absolute;
bottom: 100%;
bottom: calc(100% + 10px);
left: 0;
right: 0;
max-height: 288px;
max-height: var(--slash-menu-max-height);
overflow: hidden;
background: var(--bg-elevated);
border: 1px solid var(--overlay-border);
background: var(--chat-composer-surface);
border: 1px solid var(--chat-composer-hairline);
border-radius: var(--menu-radius);
box-shadow: var(--overlay-shadow);
z-index: 30;
margin-bottom: 4px;
}
.agent-chat__input:has(> .slash-menu) {
z-index: 5;
}
.slash-menu__scroll {
box-sizing: border-box;
max-height: inherit;
max-height: calc(var(--slash-menu-max-height) - 12px);
margin: 6px;
overflow-y: auto;
padding: var(--menu-padding);
padding-right: 2px;
}
.slash-menu__scroll[data-scrollable="true"] {
padding-right: 6px;
}
.slash-menu__scroll[data-scrollable="true"][data-at-start="true"][data-at-end="false"] {
mask-image: linear-gradient(black calc(100% - 10px), transparent);
}
.slash-menu__scroll[data-scrollable="true"][data-at-start="false"][data-at-end="false"] {
mask-image: linear-gradient(transparent, black 10px calc(100% - 10px), transparent);
}
.slash-menu__scroll[data-scrollable="true"][data-at-start="false"][data-at-end="true"] {
mask-image: linear-gradient(transparent, black 10px);
}
.slash-menu-group + .slash-menu-group {
@@ -5425,10 +5484,10 @@ button.chat-pr__diff {
.slash-menu-item {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
grid-template-columns: 15px minmax(0, 1fr);
align-items: center;
column-gap: 16px;
min-height: var(--menu-item-height);
column-gap: 10px;
min-height: 34px;
padding: 0 8px;
border-radius: var(--menu-item-radius);
transition: background var(--duration-fast) ease;
@@ -5436,38 +5495,30 @@ button.chat-pr__diff {
.slash-menu-item:hover,
.slash-menu-item--active {
background: color-mix(in srgb, var(--bg-hover) 52%, transparent);
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
}
.slash-menu-leading,
.slash-menu-trailing {
display: flex;
.slash-menu-copy {
display: grid;
grid-template-columns: minmax(0, auto) minmax(0, 1fr);
align-items: center;
min-width: 0;
}
.slash-menu-leading {
gap: 8px;
}
.slash-menu-trailing {
justify-content: flex-end;
gap: 8px;
gap: 10px;
width: 100%;
}
.slash-menu-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--muted);
width: 15px;
height: 15px;
color: var(--chat-composer-secondary);
}
.slash-menu-icon svg {
width: 13px;
height: 13px;
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
@@ -5476,42 +5527,42 @@ button.chat-pr__diff {
}
.slash-menu-name {
flex-shrink: 0;
max-width: calc(100% - 24px);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--control-ui-text-sm);
font-weight: 500;
color: var(--text);
font-weight: 550;
color: var(--text-strong);
white-space: nowrap;
}
.slash-menu-args {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--control-ui-text-xs);
/* Full-opacity --muted keeps slash-arg hints at WCAG AA on dark surfaces. */
color: var(--muted);
white-space: nowrap;
.slash-menu-name mark {
color: inherit;
background: transparent;
}
.slash-menu-args,
.slash-menu-desc {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
font-size: var(--control-ui-text-sm);
color: var(--muted);
white-space: nowrap;
}
.slash-menu-badge {
.slash-menu-args {
font-size: var(--control-ui-text-xs);
font-weight: 500;
color: var(--muted);
white-space: nowrap;
flex-shrink: 0;
}
.slash-menu-desc {
text-align: right;
font-size: var(--control-ui-text-sm);
}
@media (max-width: 640px) {
.slash-menu-copy {
gap: 8px;
}
}
.chat-attachments-preview {
@@ -5607,7 +5658,6 @@ button.chat-pr__diff {
max-width: min(300px, calc(100vw - 64px));
min-width: 0;
height: 56px;
box-sizing: border-box;
background: transparent;
}
@@ -5741,7 +5791,6 @@ button.chat-pr__diff {
.chat-attachment-file {
display: flex;
box-sizing: border-box;
align-items: center;
justify-content: flex-start;
gap: 9px;
@@ -5940,7 +5989,6 @@ button.chat-pr__diff {
/* Composer controls are quiet text controls: no borders/fills at rest so the
composer frame stays the only chrome; hover/open get a soft wash. */
.chat-controls__inline-select-trigger {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: flex-start;
@@ -6123,7 +6171,6 @@ button.chat-pr__diff {
}
.chat-controls__permission-option {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--chat-composer-menu-row-gap);
@@ -6603,27 +6650,24 @@ button.chat-pr__diff {
outline: none;
}
.chat-controls__reasoning-state {
.chat-controls__model-reset {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
padding-right: 6px;
}
.chat-controls__reasoning-value {
overflow: hidden;
color: var(--text-strong);
justify-content: center;
min-height: 24px;
padding: 0 7px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: var(--cursor-action);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Muted value identifies the inherited model default. */
.chat-controls__reasoning-value--inherit {
color: var(--muted);
font-weight: 500;
.chat-controls__model-reset:hover:not(:disabled) {
background: color-mix(in srgb, var(--border) 45%, transparent);
color: var(--text);
}
.chat-controls__effort-heading {
@@ -6820,7 +6864,6 @@ button.chat-pr__diff {
.chat-controls__speed-toggle {
position: relative;
display: block;
box-sizing: border-box;
width: 36px;
height: 22px;
padding: 0;
@@ -6964,7 +7007,6 @@ button.chat-pr__diff {
.chat-controls__session select,
.chat-controls__agent select {
box-sizing: border-box;
height: 36px;
min-height: 36px;
padding: 0 34px 0 12px;
@@ -7308,7 +7350,6 @@ button.chat-pr__diff {
.chat-loading-skeleton .chat-bubble {
width: 100%;
box-sizing: border-box;
}
/* Welcome state (new session) */
-3
View File
@@ -2,7 +2,6 @@
--session-progress-neutral: var(--muted);
--session-progress-marker-size: 14px;
box-sizing: border-box;
min-width: 0;
color: var(--text);
}
@@ -193,7 +192,6 @@
.session-hovercard__creator-avatar,
.session-hovercard__creator-avatar .viewer-avatar {
display: block;
box-sizing: border-box;
width: var(--session-hovercard-context-icon-size);
height: var(--session-hovercard-context-icon-size);
aspect-ratio: 1;
@@ -493,7 +491,6 @@
.session-progress-card__current-marker,
.session-progress-card__step-marker {
display: inline-flex;
box-sizing: border-box;
width: var(--session-progress-marker-size);
height: var(--session-progress-marker-size);
align-items: center;
-2
View File
@@ -9,7 +9,6 @@ openclaw-chat-question-panel {
.chat-question-panel {
display: grid;
gap: 12px;
box-sizing: border-box;
max-height: min(58vh, 36rem);
overflow-y: auto;
padding: 14px;
@@ -280,7 +279,6 @@ openclaw-chat-question-panel {
}
.chat-question-panel__other {
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 24px;
-1
View File
@@ -140,7 +140,6 @@ openclaw-chat-sidebar-region,
/* Canonical rail header contract. The header owns geometry; actions are bare
glyphs with an invisible hit area so generic button chrome cannot leak in. */
.rail-header {
box-sizing: border-box;
display: flex;
height: var(--rail-header-height);
min-height: var(--rail-header-height);
-1
View File
@@ -111,7 +111,6 @@ openclaw-chat-pane {
.chat-split-view__drop-indicator {
position: absolute;
z-index: 20;
box-sizing: border-box;
pointer-events: none;
border: 1px solid color-mix(in srgb, var(--accent) 65%, transparent);
border-radius: var(--radius-sm);
-4
View File
@@ -166,7 +166,6 @@
.markdown-table__viewport {
max-width: 100%;
overflow-x: auto;
scrollbar-width: thin;
}
.markdown-table--can-scroll-right .markdown-table__viewport {
@@ -300,7 +299,6 @@
color-mix(in srgb, var(--accent) 6%, var(--secondary))
);
color: var(--text-strong);
font-family: var(--font-body);
font-weight: 600;
}
@@ -417,7 +415,6 @@
}
:is(.chat-text, .chat-thinking) :where(details) {
box-sizing: border-box;
width: 100%;
padding: var(--space-4);
border: 1px solid var(--border);
@@ -724,7 +721,6 @@
:is(.chat-text, .sidebar-markdown) .markdown-external-image {
display: flex;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
-31
View File
@@ -12,7 +12,6 @@
width: fit-content;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 4px 0;
border: 0;
border-radius: var(--radius-sm);
@@ -74,7 +73,6 @@
.chat-activity-group__summary {
--chat-tool-activity-color: color-mix(in srgb, var(--muted) 88%, transparent);
font-family: var(--font-body);
font-size: calc(var(--control-ui-text-sm) + 1px);
font-weight: 400;
line-height: 1.5;
@@ -519,32 +517,6 @@
color: inherit;
}
/* ── Command token colors (display-only highlighting) ── */
.chat-cmd--name {
color: inherit;
font: inherit;
}
.chat-cmd--str {
font: inherit;
color: inherit;
}
.chat-cmd--num {
font: inherit;
color: inherit;
}
.chat-cmd--flag {
font: inherit;
color: inherit;
}
.chat-cmd--op {
font: inherit;
color: inherit;
}
/* ── Key-value args (generic tools) ── */
.chat-tool-kv {
display: flex;
@@ -579,7 +551,6 @@
/* ── Expanded tool row body ── */
.chat-tool-msg-body {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
margin-top: 5px;
@@ -621,7 +592,6 @@
}
.chat-tool-card {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
}
@@ -822,7 +792,6 @@
}
.chat-tool-card__block-content {
box-sizing: border-box;
max-width: 100%;
min-width: 0;
margin: 0;
+1 -1
View File
@@ -110,7 +110,7 @@ describe("Control UI theme contrast", () => {
requireCssColor(dark, "card"),
];
const timestampRule = readRuleBody(groupedCss, ".chat-group-timestamp");
const slashArgsRule = readRuleBody(chatLayoutCss, ".slash-menu-args");
const slashArgsRule = readRuleBody(chatLayoutCss, ".slash-menu-args,\n.slash-menu-desc");
expect(timestampRule).toMatch(/color:\s*var\(--muted\)/);
expect(slashArgsRule).toMatch(/color:\s*var\(--muted\)/);