From 7b3fb426171924b4e69447e2a7a39e71752dcdbd Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Wed, 26 Aug 2026 03:18:47 -0300 Subject: [PATCH] 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 --- ui/src/components/icons.ts | 10 + ui/src/e2e/chat-skill-references.e2e.test.ts | 214 +++++++++++++++- .../chat/chat-responsive.browser.test.ts | 39 ++- ui/src/pages/chat/chat-view.test.ts | 157 +++++++++++- .../chat/components/chat-composer-dom.ts | 50 +++- .../chat/components/chat-composer-keydown.ts | 10 +- .../components/chat-composer-plus-menu.ts | 43 ++-- .../components/chat-composer-skill-menu.ts | 216 +++++++++++++++- .../components/chat-composer-slash-menu.ts | 176 ++++++++----- .../chat/components/chat-composer-view.ts | 42 +++- .../chat/components/chat-effort-picker.ts | 36 +-- .../chat/components/chat-picker-overlay.ts | 31 ++- ui/src/styles/chat/grouped.css | 1 - ui/src/styles/chat/layout.css | 237 ++++++++++-------- ui/src/styles/chat/progress-card.css | 3 - ui/src/styles/chat/question-card.css | 2 - ui/src/styles/chat/sidebar.css | 1 - ui/src/styles/chat/split-view.css | 1 - ui/src/styles/chat/text.css | 4 - ui/src/styles/chat/tool-cards.css | 31 --- ui/src/styles/theme-contrast.test.ts | 2 +- 21 files changed, 1025 insertions(+), 281 deletions(-) diff --git a/ui/src/components/icons.ts b/ui/src/components/icons.ts index 4ae6f94486ec..b058ca97df4a 100644 --- a/ui/src/components/icons.ts +++ b/ui/src/components/icons.ts @@ -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" /> `), + pencilSparkles: strokeIcon(svg` + + + + + + + `), hourglass: strokeIcon(svg` diff --git a/ui/src/e2e/chat-skill-references.e2e.test.ts b/ui/src/e2e/chat-skill-references.e2e.test.ts index a350ef86ab88..81b350091778 100644 --- a/ui/src/e2e/chat-skill-references.e2e.test.ts +++ b/ui/src/e2e/chat-skill-references.e2e.test.ts @@ -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(".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); }, ); }); diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index b6112b9779a8..62700dba80ae 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -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(` +
+
+ ${iconSvg()} + + /session idle max-age <duration|off> + Manage session-level settings (for example /session idle). + +
+
+ `); + + const geometry = await page.locator(".slash-menu-copy").evaluate((copy) => { + const name = copy.querySelector(".slash-menu-name")!; + const description = copy.querySelector(".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 `
- - ${iconSvg()} - $skill_${index + 1} - + ${iconSvg()} + $skill_${index + 1}
`; }).join(""); await page.setContent(` diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 45145e9cc989..f83eef1ef794 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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("[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(".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("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", () => { diff --git a/ui/src/pages/chat/components/chat-composer-dom.ts b/ui/src/pages/chat/components/chat-composer-dom.ts index b38232b25fee..51b2f6e591f5 100644 --- a/ui/src/pages/chat/components/chat-composer-dom.ts +++ b/ui/src/pages/chat/components/chat-composer-dom.ts @@ -124,6 +124,18 @@ export function replaceComposerPopoverAnchor( return next; } +function syncTextareaOverlay(el: HTMLTextAreaElement): HTMLElement | null { + const overlay = el.parentElement?.querySelector( + ".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(".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( + ".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}`; } diff --git a/ui/src/pages/chat/components/chat-composer-keydown.ts b/ui/src/pages/chat/components/chat-composer-keydown.ts index c9df51d1d9d4..e73b3e9e370b 100644 --- a/ui/src/pages/chat/components/chat-composer-keydown.ts +++ b/ui/src/pages/chat/components/chat-composer-keydown.ts @@ -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; } diff --git a/ui/src/pages/chat/components/chat-composer-plus-menu.ts b/ui/src/pages/chat/components/chat-composer-plus-menu.ts index 8b533aaeb102..31dae0297956 100644 --- a/ui/src/pages/chat/components/chat-composer-plus-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-plus-menu.ts @@ -126,11 +126,14 @@ function renderCapabilityToggleRow(options: { title: string | null | undefined; icon?: TemplateResult; note?: TemplateResult | typeof nothing; + checkbox?: boolean; }) { return html` @@ -139,15 +142,17 @@ function renderCapabilityToggleRow(options: { ${options.label} ${options.note ?? nothing} - + ${options.checkbox + ? nothing + : html``} `; } @@ -211,17 +216,15 @@ function renderRootView(props: ChatComposerPlusMenuProps) { > - - - ${t("chat.composer.menu.webSearch")} - + ${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()} diff --git a/ui/src/pages/chat/components/chat-composer-skill-menu.ts b/ui/src/pages/chat/components/chat-composer-skill-menu.ts index ce69a3facd1c..2557df85bdbb 100644 --- a/ui/src/pages/chat/components/chat-composer-skill-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-skill-menu.ts @@ -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`${name.slice(0, matchLength)}${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 = []; + let cursor = 0; + for (const token of tokens) { + content.push( + value.slice(cursor, token.start), + html`${icons.pencilSparkles}${getSkillDisplayName(token.command)}`, + ); + 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``; +} + function selectSkillMention( command: SlashCommandDef, state: SkillMenuState, @@ -288,7 +480,14 @@ export function renderSkillMenu( role="listbox" aria-label=${t("chat.skills.menu")} > -
+
+ syncComposerMenuScroll( + event.currentTarget instanceof Element ? event.currentTarget : undefined, + )} + > ${state.skillCommandRefreshPending || state.skillMenuItems.length === 0 ? html`
${t("chat.skills.loading")}
@@ -311,11 +510,14 @@ export function renderSkillMenu( requestUpdate(); }} > - - ${icons.zap} - ${getSkillDisplayName(command)} - - + ${icons.pencilSparkles} + + ${renderSkillName( + getSkillDisplayName(command), + state.skillMenuTarget?.query ?? "", + )} ${getSlashCommandDescription(command)}
diff --git a/ui/src/pages/chat/components/chat-composer-slash-menu.ts b/ui/src/pages/chat/components/chat-composer-slash-menu.ts index 7b1d556bc70b..7372bfcace89 100644 --- a/ui/src/pages/chat/components/chat-composer-slash-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-slash-menu.ts @@ -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`${name.slice(0, matchLength)}${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` +
event.preventDefault()} + @click=${() => selectSlashCommand(cmd, state, host, requestUpdate)} + @mouseenter=${() => { + state.slashMenuIndex = index; + requestUpdate(); + }} + > + ${cmd.source === "skill" + ? icons.pencilSparkles + : cmd.icon + ? renderSlashIcon(cmd.icon) + : icons.terminal} + + /${renderMatchedName(cmd.name, query)}${cmd.args + ? html` ${cmd.args}` + : nothing} + ${getSlashCommandDescription(cmd)} + +
+ `; +} + export function renderSlashMenu( state: SlashMenuState, host: SlashMenuHost, @@ -361,7 +420,11 @@ export function renderSlashMenu( role="listbox" aria-label=${t("chat.commands.arguments")} > -
+
/${state.slashMenuCommand.name} ${getSlashCommandDescription(state.slashMenuCommand)} @@ -381,15 +444,13 @@ export function renderSlashMenu( requestUpdate(); }} > - - ${state.slashMenuCommand?.icon - ? renderSlashIcon(state.slashMenuCommand.icon) - : nothing} + ${state.slashMenuCommand?.icon + ? renderSlashIcon(state.slashMenuCommand.icon) + : icons.terminal} + ${arg} - - /${state.slashMenuCommand?.name} ${arg}
@@ -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` -
-
${getSlashCommandCategoryLabel(category)}
- ${entries.map( - ({ cmd, globalIdx }) => html` -
selectSlashCommand(cmd, state, host, requestUpdate)} - @mouseenter=${() => { - state.slashMenuIndex = globalIdx; - requestUpdate(); - }} - > - - ${cmd.icon ? renderSlashIcon(cmd.icon) : nothing} - /${cmd.name} - ${cmd.args ? html`${cmd.args}` : nothing} - - - ${getSlashCommandDescription(cmd)} - ${host.resolveArgOptions(cmd).length - ? html`${t("chat.commands.optionCount", { - count: String(host.resolveArgOptions(cmd).length), - })}` - : nothing} - -
- `, - )} -
- `, - ); - return html`
-
${sections}
+
+ ${groups.map( + ([category, entries]) => html`
+
${getSlashCommandCategoryLabel(category)}
+ ${entries.map(({ command, index }) => + renderSlashCommandOption({ + cmd: command, + index, + query, + requestUpdate, + host, + state, + }), + )} +
`, + )} + ${skills.length > 0 + ? html`
+
${t("chat.skills.label")}
+ ${skills.map((cmd, index) => + renderSlashCommandOption({ + cmd, + index: commands.length + index, + query, + requestUpdate, + host, + state, + }), + )} +
` + : nothing} +
`; } diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index 934c99247e66..dc1294de0155 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -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`
${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) {
+ ${skillDraftOverlay} ${t("chat.modelControls.effort")} - - - ${sliderStops.length > 1 - ? html` - - ${reasoningValueText} - - ${sliderStops.map( - (stop, index) => html` - - `, - )} - ` - : reasoningValueText} - + + ${reasoningValueText} + ${sliderStops.map( + (stop, index) => html``, + )}
${sliderStops.length > 1 ? html` -
+ ` : onlyStop ? html` diff --git a/ui/src/pages/chat/components/chat-picker-overlay.ts b/ui/src/pages/chat/components/chat-picker-overlay.ts index 07dd8a947d00..b464676108ac 100644 --- a/ui/src/pages/chat/components/chat-picker-overlay.ts +++ b/ui/src/pages/chat/components/chat-picker-overlay.ts @@ -6,6 +6,7 @@ const MOBILE_COMPOSER_OVERLAY_QUERY = const pointerOpenedDropdowns = new WeakSet(); 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( + ".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(".agent-chat__input > :is(.slash-menu, .skill-menu)") + ?.closest(".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(".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; } diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 90cdcf37a5c0..d5a8c0526891 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -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; } diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 1be085ad0f0e..feabc19f74bc 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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) */ diff --git a/ui/src/styles/chat/progress-card.css b/ui/src/styles/chat/progress-card.css index 3102cc97415c..8f961bf76c9c 100644 --- a/ui/src/styles/chat/progress-card.css +++ b/ui/src/styles/chat/progress-card.css @@ -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; diff --git a/ui/src/styles/chat/question-card.css b/ui/src/styles/chat/question-card.css index ffdd69668e9a..80dcdf91961c 100644 --- a/ui/src/styles/chat/question-card.css +++ b/ui/src/styles/chat/question-card.css @@ -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; diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index a122172e35f0..8749720ec76d 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -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); diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index 97acf7d27a78..485f9f1c86b6 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -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); diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 579c682ae7e3..7910e5dba291 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -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; diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 2fb3a350e252..71440672d19d 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -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; diff --git a/ui/src/styles/theme-contrast.test.ts b/ui/src/styles/theme-contrast.test.ts index 5ce96400b1d3..f7a0d129665e 100644 --- a/ui/src/styles/theme-contrast.test.ts +++ b/ui/src/styles/theme-contrast.test.ts @@ -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\)/);