mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
polish(ui): redesign composer invocation menus
This commit is contained in:
@@ -3563,6 +3563,29 @@ 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[0]?.querySelector(".slash-menu-scope")).toBeNull();
|
||||
expect(options.at(-1)?.querySelector(".slash-menu-scope")).toBeNull();
|
||||
});
|
||||
|
||||
it("fills a selected $ skill without submitting the surrounding prompt", async () => {
|
||||
replaceSkillCommands({
|
||||
key: "prose_writer",
|
||||
|
||||
@@ -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 {
|
||||
@@ -8,9 +9,18 @@ import {
|
||||
type SlashCommandDef,
|
||||
} from "../../../lib/chat/commands.ts";
|
||||
import { paneDomId, scrollActiveMenuOptionIntoView } from "./chat-composer-dom.ts";
|
||||
import { syncComposerMenuScroll } from "./chat-composer-slash-menu.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;
|
||||
if (matchLength === 0) {
|
||||
return html`${name}`;
|
||||
}
|
||||
return html`<mark>${name.slice(0, matchLength)}</mark>${name.slice(matchLength)}`;
|
||||
}
|
||||
|
||||
type SkillMentionTarget = {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -288,7 +298,11 @@ 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 as Element)}
|
||||
>
|
||||
${state.skillCommandRefreshPending || state.skillMenuItems.length === 0
|
||||
? html`<div class="slash-menu-group">
|
||||
<div class="slash-menu-group__label">${t("chat.skills.loading")}</div>
|
||||
@@ -313,11 +327,14 @@ export function renderSkillMenu(
|
||||
>
|
||||
<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-desc">${getSlashCommandDescription(command)}</span>
|
||||
<span class="slash-menu-name"
|
||||
>${renderSkillName(
|
||||
getSkillDisplayName(command),
|
||||
state.skillMenuTarget?.query ?? "",
|
||||
)}</span
|
||||
>
|
||||
</span>
|
||||
<span class="slash-menu-desc">${getSlashCommandDescription(command)}</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 {
|
||||
@@ -201,6 +202,22 @@ function slashOptionIdSegment(value: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function getSlashCommandOptionId(paneId: string, cmd: SlashCommandDef): string {
|
||||
return paneDomId(paneId, `slash-option-command-${slashOptionIdSegment(cmd.name)}`);
|
||||
}
|
||||
@@ -259,6 +276,51 @@ 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;
|
||||
if (matchLength === 0) {
|
||||
return html`${name}`;
|
||||
}
|
||||
return html`<mark>${name.slice(0, matchLength)}</mark>${name.slice(matchLength)}`;
|
||||
}
|
||||
|
||||
function renderSlashCommandOption(params: {
|
||||
cmd: SlashCommandDef;
|
||||
index: number;
|
||||
query: string;
|
||||
requestUpdate: () => void;
|
||||
props: ChatComposerProps;
|
||||
state: ChatComposerState;
|
||||
}): TemplateResult {
|
||||
const { cmd, index, query, requestUpdate, props, state } = params;
|
||||
return html`
|
||||
<div
|
||||
id=${getSlashCommandOptionId(props.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, props, requestUpdate)}
|
||||
@mouseenter=${() => {
|
||||
state.slashMenuIndex = index;
|
||||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
<span class="slash-menu-leading">
|
||||
<span class="slash-menu-icon"
|
||||
>${cmd.icon ? renderSlashIcon(cmd.icon) : icons.terminal}</span
|
||||
>
|
||||
<span class="slash-menu-name"
|
||||
>/${renderMatchedName(cmd.name, query)}${cmd.args
|
||||
? html`<span class="slash-menu-args"> ${cmd.args}</span>`
|
||||
: nothing}</span
|
||||
>
|
||||
</span>
|
||||
<span class="slash-menu-desc">${getSlashCommandDescription(cmd)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderSlashMenu(
|
||||
requestUpdate: () => void,
|
||||
props: ChatComposerProps,
|
||||
@@ -282,7 +344,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=${(event: Event) => syncComposerMenuScroll(event.currentTarget as Element)}
|
||||
>
|
||||
<div class="slash-menu-group">
|
||||
<div class="slash-menu-group__label">
|
||||
/${state.slashMenuCommand.name} ${getSlashCommandDescription(state.slashMenuCommand)}
|
||||
@@ -310,9 +376,7 @@ export function renderSlashMenu(
|
||||
>
|
||||
<span class="slash-menu-name">${arg}</span>
|
||||
</span>
|
||||
<span class="slash-menu-trailing">
|
||||
<span class="slash-menu-desc">/${state.slashMenuCommand?.name} ${arg}</span>
|
||||
</span>
|
||||
<span class="slash-menu-desc">/${state.slashMenuCommand?.name} ${arg}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
@@ -326,65 +390,65 @@ export function renderSlashMenu(
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const groups: Array<[SlashCommandCategory, Array<{ cmd: SlashCommandDef; globalIdx: number }>]> =
|
||||
[];
|
||||
for (const [globalIdx, cmd] of state.slashMenuItems.entries()) {
|
||||
const category = cmd.category ?? "session";
|
||||
const query = draft.slice(1);
|
||||
const commands = state.slashMenuItems.filter((command) => command.source !== "skill");
|
||||
const skills = state.slashMenuItems.filter((command) => command.source === "skill");
|
||||
const commandGroups: Array<
|
||||
[SlashCommandCategory, Array<{ command: SlashCommandDef; index: number }>]
|
||||
> = [];
|
||||
for (const [index, command] of commands.entries()) {
|
||||
const category = command.category ?? "session";
|
||||
const group =
|
||||
draft === "/" ? groups.find(([groupCategory]) => groupCategory === category) : groups.at(-1);
|
||||
draft === "/"
|
||||
? commandGroups.find(([groupCategory]) => groupCategory === category)
|
||||
: commandGroups.at(-1);
|
||||
if (group?.[0] === category) {
|
||||
group[1].push({ cmd, globalIdx });
|
||||
group[1].push({ command, index });
|
||||
} else {
|
||||
groups.push([category, [{ cmd, globalIdx }]]);
|
||||
commandGroups.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(props.paneId, cmd)}
|
||||
class="slash-menu-item ${globalIdx === state.slashMenuIndex
|
||||
? "slash-menu-item--active"
|
||||
: ""}"
|
||||
role="option"
|
||||
aria-selected=${globalIdx === state.slashMenuIndex}
|
||||
@click=${() => selectSlashCommand(cmd, props, 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>
|
||||
${cmd.argOptions?.length
|
||||
? html`<span class="slash-menu-badge"
|
||||
>${t("chat.commands.optionCount", {
|
||||
count: String(cmd.argOptions.length),
|
||||
})}</span
|
||||
>`
|
||||
: nothing}
|
||||
</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
);
|
||||
const renderEntries = (entries: SlashCommandDef[], offset: number) =>
|
||||
entries.map((cmd, index) =>
|
||||
renderSlashCommandOption({
|
||||
cmd,
|
||||
index: offset + index,
|
||||
query,
|
||||
requestUpdate,
|
||||
props,
|
||||
state,
|
||||
}),
|
||||
);
|
||||
|
||||
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=${(event: Event) => syncComposerMenuScroll(event.currentTarget as Element)}
|
||||
>
|
||||
${commandGroups.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,
|
||||
props,
|
||||
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>
|
||||
${renderEntries(skills, commands.length)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -4817,25 +4817,65 @@ 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);
|
||||
border-radius: var(--menu-radius);
|
||||
box-shadow: var(--overlay-shadow);
|
||||
background: var(--chat-composer-surface);
|
||||
border: 1px solid var(--chat-composer-hairline);
|
||||
z-index: 30;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.agent-chat__input > .slash-menu {
|
||||
border-radius: calc(20px * var(--openclaw-corner-radius-scale));
|
||||
corner-shape: superellipse(1.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;
|
||||
scrollbar-color: color-mix(in srgb, var(--text-strong) 24%, transparent) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.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(to bottom, black 0, black calc(100% - 10px), transparent);
|
||||
}
|
||||
|
||||
.slash-menu__scroll[data-scrollable="true"][data-at-start="false"][data-at-end="false"] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
black 10px,
|
||||
black calc(100% - 10px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.slash-menu__scroll[data-scrollable="true"][data-at-start="false"][data-at-end="true"] {
|
||||
mask-image: linear-gradient(to bottom, transparent, black 10px, black 100%);
|
||||
}
|
||||
|
||||
.slash-menu__scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.slash-menu__scroll::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text-strong) 24%, transparent);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.slash-menu-group + .slash-menu-group {
|
||||
@@ -4852,14 +4892,15 @@ button.chat-pr__diff {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
.slash-menu .slash-menu-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
min-height: var(--menu-item-height);
|
||||
column-gap: 12px;
|
||||
min-height: 34px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--menu-item-radius);
|
||||
border-radius: calc(11px * var(--openclaw-corner-radius-scale));
|
||||
corner-shape: superellipse(1.5);
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
@@ -4873,30 +4914,22 @@ button.chat-pr__diff {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.slash-menu-leading {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slash-menu-trailing {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.slash-menu-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
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;
|
||||
@@ -4910,8 +4943,8 @@ button.chat-pr__diff {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4920,8 +4953,8 @@ button.chat-pr__diff {
|
||||
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);
|
||||
color: var(--chat-composer-tertiary);
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -4935,12 +4968,14 @@ button.chat-pr__diff {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.slash-menu-badge {
|
||||
font-size: var(--control-ui-text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
@media (max-width: 640px) {
|
||||
.slash-menu-item {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.slash-menu-desc {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-attachments-preview {
|
||||
|
||||
Reference in New Issue
Block a user