polish(ui): redesign composer invocation menus

This commit is contained in:
vyctorbrzezowski
2026-08-21 05:27:07 -03:00
parent a7acd21a50
commit 82d1fdbf47
4 changed files with 236 additions and 97 deletions
+23
View File
@@ -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>
`;
}
+72 -37
View File
@@ -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 {