mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
committed by
GitHub
parent
4e4d595476
commit
7b3fb42617
@@ -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" />
|
||||
|
||||
@@ -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 <duration|off></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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
Reference in New Issue
Block a user