diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 99894ac68166..162be1fbf0df 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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("[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", diff --git a/ui/src/pages/chat/components/chat-composer-skill-menu.ts b/ui/src/pages/chat/components/chat-composer-skill-menu.ts index ce69a3facd1c..992dbd7120af 100644 --- a/ui/src/pages/chat/components/chat-composer-skill-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-skill-menu.ts @@ -1,4 +1,5 @@ import { html, nothing, type TemplateResult } from "lit"; +import { ref } from "lit/directives/ref.js"; import { icons } from "../../../components/icons.ts"; import { t } from "../../../i18n/index.ts"; import { @@ -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`${name.slice(0, matchLength)}${name.slice(matchLength)}`; +} + type SkillMentionTarget = { start: number; end: number; @@ -288,7 +298,11 @@ export function renderSkillMenu( role="listbox" aria-label=${t("chat.skills.menu")} > -
+
syncComposerMenuScroll(event.currentTarget as Element)} + > ${state.skillCommandRefreshPending || state.skillMenuItems.length === 0 ? html`
${t("chat.skills.loading")}
@@ -313,11 +327,14 @@ export function renderSkillMenu( > ${icons.zap} - ${getSkillDisplayName(command)} - - - ${getSlashCommandDescription(command)} + ${renderSkillName( + getSkillDisplayName(command), + state.skillMenuTarget?.query ?? "", + )} + ${getSlashCommandDescription(command)}
`, )} diff --git a/ui/src/pages/chat/components/chat-composer-slash-menu.ts b/ui/src/pages/chat/components/chat-composer-slash-menu.ts index 01a4bdbe1a4e..cdf993ae53c3 100644 --- a/ui/src/pages/chat/components/chat-composer-slash-menu.ts +++ b/ui/src/pages/chat/components/chat-composer-slash-menu.ts @@ -1,4 +1,5 @@ import { html, nothing, type TemplateResult } from "lit"; +import { ref } from "lit/directives/ref.js"; import { icons, type IconName } from "../../../components/icons.ts"; import { t } from "../../../i18n/index.ts"; import { @@ -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`${name.slice(0, matchLength)}${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` +
event.preventDefault()} + @click=${() => selectSlashCommand(cmd, props, requestUpdate)} + @mouseenter=${() => { + state.slashMenuIndex = index; + requestUpdate(); + }} + > + + ${cmd.icon ? renderSlashIcon(cmd.icon) : icons.terminal} + /${renderMatchedName(cmd.name, query)}${cmd.args + ? html` ${cmd.args}` + : nothing} + + ${getSlashCommandDescription(cmd)} +
+ `; +} + export function renderSlashMenu( requestUpdate: () => void, props: ChatComposerProps, @@ -282,7 +344,11 @@ export function renderSlashMenu( role="listbox" aria-label=${t("chat.commands.arguments")} > -
+
syncComposerMenuScroll(event.currentTarget as Element)} + >
/${state.slashMenuCommand.name} ${getSlashCommandDescription(state.slashMenuCommand)} @@ -310,9 +376,7 @@ export function renderSlashMenu( > ${arg} - - /${state.slashMenuCommand?.name} ${arg} - + /${state.slashMenuCommand?.name} ${arg}
`, )} @@ -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` -
-
${getSlashCommandCategoryLabel(category)}
- ${entries.map( - ({ cmd, globalIdx }) => html` -
selectSlashCommand(cmd, props, requestUpdate)} - @mouseenter=${() => { - state.slashMenuIndex = globalIdx; - requestUpdate(); - }} - > - - ${cmd.icon ? renderSlashIcon(cmd.icon) : nothing} - /${cmd.name} - ${cmd.args ? html`${cmd.args}` : nothing} - - - ${getSlashCommandDescription(cmd)} - ${cmd.argOptions?.length - ? html`${t("chat.commands.optionCount", { - count: String(cmd.argOptions.length), - })}` - : nothing} - -
- `, - )} -
- `, - ); + const renderEntries = (entries: SlashCommandDef[], offset: number) => + entries.map((cmd, index) => + renderSlashCommandOption({ + cmd, + index: offset + index, + query, + requestUpdate, + props, + state, + }), + ); return html`
-
${sections}
+
syncComposerMenuScroll(event.currentTarget as Element)} + > + ${commandGroups.map( + ([category, entries]) => html`
+
${getSlashCommandCategoryLabel(category)}
+ ${entries.map(({ command, index }) => + renderSlashCommandOption({ + cmd: command, + index, + query, + requestUpdate, + props, + state, + }), + )} +
`, + )} + ${skills.length > 0 + ? html`
+
${t("chat.skills.label")}
+ ${renderEntries(skills, commands.length)} +
` + : nothing} +
`; } diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 5c4e974bfef0..69eb31060647 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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 {