mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
improve(ui): quiet slash command menu hierarchy (#122237)
* improve(ui): quiet slash command menu hierarchy * improve(ui): compact slash command menu * improve(ui): polish slash command rows * improve(ui): streamline slash command sheet * chore(ui): refresh command copy baseline * fix(ui): keep skill selection in view * test(ui): type skill menu viewport stub
This commit is contained in:
committed by
GitHub
parent
b529217878
commit
7630d060f8
Generated
-42
@@ -449,48 +449,6 @@
|
||||
"path": "ui/src/pages/channels/view.nostr.ts",
|
||||
"text": "NIP-05"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/chat/components/chat-composer-skill-menu.ts",
|
||||
"text": "Enter"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/chat/components/chat-composer-skill-menu.ts",
|
||||
"text": "Esc"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/chat/components/chat-composer-skill-menu.ts",
|
||||
"text": "Tab"
|
||||
},
|
||||
{
|
||||
"count": 2,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/chat/components/chat-composer-slash-menu.ts",
|
||||
"text": "Enter"
|
||||
},
|
||||
{
|
||||
"count": 2,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/chat/components/chat-composer-slash-menu.ts",
|
||||
"text": "Esc"
|
||||
},
|
||||
{
|
||||
"count": 2,
|
||||
"kind": "html-text",
|
||||
"name": "text",
|
||||
"path": "ui/src/pages/chat/components/chat-composer-slash-menu.ts",
|
||||
"text": "Tab"
|
||||
},
|
||||
{
|
||||
"count": 2,
|
||||
"kind": "html-text",
|
||||
|
||||
@@ -4789,15 +4789,7 @@ export const en: TranslationMap = {
|
||||
commands: {
|
||||
arguments: "Command arguments",
|
||||
menu: "Slash commands",
|
||||
instant: "instant",
|
||||
optionCount: "{count} options",
|
||||
showMoreOne: "Show 1 more command",
|
||||
showMoreMany: "Show {count} more commands",
|
||||
navigate: "navigate",
|
||||
fill: "fill",
|
||||
run: "run",
|
||||
select: "select",
|
||||
close: "close",
|
||||
clearDescription: "Clear chat history",
|
||||
redirectDescription: "Abort and restart with a new message",
|
||||
steerDescription: "Inject a message into the active run",
|
||||
|
||||
@@ -531,11 +531,6 @@ export function getSkillCommandCompletions(filter: string): SlashCommandDef[] {
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
/** Count of commands hidden by tier filtering (for "Show N more" UI). */
|
||||
export function getHiddenCommandCount(): number {
|
||||
return SLASH_COMMANDS.filter((cmd) => (cmd.tier ?? "standard") === "power").length;
|
||||
}
|
||||
|
||||
type ParsedSlashCommand = {
|
||||
command: SlashCommandDef;
|
||||
args: string;
|
||||
|
||||
@@ -3078,20 +3078,20 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
|
||||
it("scrolls the keyboard-active slash option into view in short landscape", async () => {
|
||||
const initiallyHidden = await page.evaluate(() => {
|
||||
const menu = document.querySelector<HTMLElement>(".slash-menu");
|
||||
const scrollRegion = document.querySelector<HTMLElement>(".slash-menu__scroll");
|
||||
const options = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(".slash-menu-item[role='option']"),
|
||||
);
|
||||
const hiddenOption = options.find((option) => {
|
||||
const menuRect = menu?.getBoundingClientRect();
|
||||
const menuRect = scrollRegion?.getBoundingClientRect();
|
||||
const optionRect = option.getBoundingClientRect();
|
||||
return Boolean(menuRect && optionRect.bottom > menuRect.bottom + 1);
|
||||
});
|
||||
if (!menu || !hiddenOption) {
|
||||
if (!scrollRegion || !hiddenOption) {
|
||||
throw new Error("Expected an initially hidden slash option");
|
||||
}
|
||||
menu.scrollTop = 0;
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
scrollRegion.scrollTop = 0;
|
||||
const menuRect = scrollRegion.getBoundingClientRect();
|
||||
const itemRect = hiddenOption.getBoundingClientRect();
|
||||
return {
|
||||
id: hiddenOption.id,
|
||||
@@ -3112,11 +3112,11 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
}, initiallyHidden.id);
|
||||
await page.waitForFunction((expectedId) => {
|
||||
const active = document.getElementById(expectedId);
|
||||
const menu = active?.closest<HTMLElement>(".slash-menu");
|
||||
if (!active || !menu) {
|
||||
const scrollRegion = active?.closest<HTMLElement>(".slash-menu__scroll");
|
||||
if (!active || !scrollRegion) {
|
||||
return false;
|
||||
}
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const menuRect = scrollRegion.getBoundingClientRect();
|
||||
const activeRect = active.getBoundingClientRect();
|
||||
return activeRect.top >= menuRect.top - 1 && activeRect.bottom <= menuRect.bottom + 1;
|
||||
}, initiallyHidden.id);
|
||||
@@ -3125,17 +3125,17 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
const input = document.querySelector<HTMLTextAreaElement>(
|
||||
".agent-chat__composer-combobox > textarea",
|
||||
);
|
||||
const menu = document.querySelector<HTMLElement>(".slash-menu");
|
||||
const scrollRegion = document.querySelector<HTMLElement>(".slash-menu__scroll");
|
||||
const active = document.querySelector<HTMLElement>(".slash-menu-item--active");
|
||||
if (!input || !menu || !active) {
|
||||
if (!input || !scrollRegion || !active) {
|
||||
throw new Error("Expected active slash option after keyboard navigation");
|
||||
}
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const menuRect = scrollRegion.getBoundingClientRect();
|
||||
const activeRect = active.getBoundingClientRect();
|
||||
return {
|
||||
activeDescendant: input.getAttribute("aria-activedescendant"),
|
||||
focusedTag: document.activeElement?.tagName,
|
||||
scrollTop: menu.scrollTop,
|
||||
scrollTop: scrollRegion.scrollTop,
|
||||
visible: activeRect.top >= menuRect.top - 1 && activeRect.bottom <= menuRect.bottom + 1,
|
||||
};
|
||||
});
|
||||
@@ -3147,6 +3147,52 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps overflowing skill suggestions on the nested scroll viewport", async () => {
|
||||
const page = await openBrowserPage(568, 320);
|
||||
try {
|
||||
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>
|
||||
</div>`;
|
||||
}).join("");
|
||||
await page.setContent(`<!doctype html><html><head><style>${readUiCss()}</style></head><body>
|
||||
<div class="slash-menu skill-menu" role="listbox">
|
||||
<div class="slash-menu__scroll">${items}</div>
|
||||
</div>
|
||||
</body></html>`);
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const active = document.querySelector<HTMLElement>(".slash-menu-item--active");
|
||||
const scrollRegion = active?.closest<HTMLElement>(".slash-menu__scroll");
|
||||
if (!active || !scrollRegion) {
|
||||
throw new Error("Expected an active skill inside the nested viewport");
|
||||
}
|
||||
const viewport = scrollRegion.getBoundingClientRect();
|
||||
const option = active.getBoundingClientRect();
|
||||
scrollRegion.scrollTop += option.bottom - viewport.bottom;
|
||||
const settledOption = active.getBoundingClientRect();
|
||||
const settledViewport = scrollRegion.getBoundingClientRect();
|
||||
return {
|
||||
outerScrollTop: active.closest<HTMLElement>(".skill-menu")?.scrollTop,
|
||||
scrollTop: scrollRegion.scrollTop,
|
||||
visible:
|
||||
settledOption.top >= settledViewport.top - 1 &&
|
||||
settledOption.bottom <= settledViewport.bottom + 1,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.outerScrollTop).toBe(0);
|
||||
expect(result.scrollTop).toBeGreaterThan(0);
|
||||
expect(result.visible).toBe(true);
|
||||
} finally {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the compact mobile grid when the agent filter is not rendered", async () => {
|
||||
const page = await openFixture(320, 568, { singleAgent: true });
|
||||
try {
|
||||
|
||||
@@ -3686,6 +3686,65 @@ describe("chat slash menu accessibility", () => {
|
||||
expect(container.querySelector(".skill-menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("scrolls the keyboard-active skill inside the nested menu viewport", () => {
|
||||
replaceSkillCommands(
|
||||
...Array.from({ length: 8 }, (_, index) => ({
|
||||
key: `skill_${index + 1}`,
|
||||
description: `Skill ${index + 1}.`,
|
||||
})),
|
||||
);
|
||||
const animationFrames: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
animationFrames.push(callback);
|
||||
return animationFrames.length;
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
const height = 28;
|
||||
let top = 0;
|
||||
if (this.classList.contains("slash-menu-item")) {
|
||||
const scrollRegion = this.closest<HTMLElement>(".slash-menu__scroll");
|
||||
const options = Array.from(
|
||||
scrollRegion?.querySelectorAll<HTMLElement>(".slash-menu-item") ?? [],
|
||||
);
|
||||
top = options.indexOf(this) * height - (scrollRegion?.scrollTop ?? 0);
|
||||
}
|
||||
const bottom = this.classList.contains("slash-menu__scroll") ? height * 2 : top + height;
|
||||
return {
|
||||
bottom,
|
||||
height: bottom - top,
|
||||
left: 0,
|
||||
right: 240,
|
||||
top,
|
||||
width: 240,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
},
|
||||
);
|
||||
const { container } = createReactiveDraftHarness();
|
||||
document.body.append(container);
|
||||
inputDraftAtEnd(container, "Use $");
|
||||
animationFrames.length = 0;
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
keydownComposer(container, "ArrowDown");
|
||||
}
|
||||
animationFrames.at(-1)?.(0);
|
||||
|
||||
const scrollRegion = container.querySelector<HTMLElement>(".skill-menu .slash-menu__scroll");
|
||||
const outerMenu = container.querySelector<HTMLElement>(".skill-menu");
|
||||
const activeOption = container.querySelector<HTMLElement>(".slash-menu-item--active");
|
||||
const viewportBounds = scrollRegion?.getBoundingClientRect();
|
||||
const optionBounds = activeOption?.getBoundingClientRect();
|
||||
expect(scrollRegion?.scrollTop).toBeGreaterThan(0);
|
||||
expect(outerMenu?.scrollTop).toBe(0);
|
||||
expect(optionBounds?.top).toBeGreaterThanOrEqual(viewportBounds?.top ?? 0);
|
||||
expect(optionBounds?.bottom).toBeLessThanOrEqual(viewportBounds?.bottom ?? 0);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("does not reopen a dismissed skill picker after a slow refresh", async () => {
|
||||
replaceSkillCommands({ key: "prose", description: "Prose skill." });
|
||||
const refresh = createDeferred();
|
||||
@@ -3985,6 +4044,47 @@ describe("chat slash menu accessibility", () => {
|
||||
expect(listbox?.querySelector(`#${activeId}`)?.getAttribute("role")).toBe("option");
|
||||
});
|
||||
|
||||
it("removes instant implementation badges without hiding option counts", () => {
|
||||
const harness = createSlashRerenderHarness();
|
||||
const container = harness.inputAndRender(harness.container, "/");
|
||||
const stopOption = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".slash-menu [role='option']"),
|
||||
).find((option) => option.querySelector(".slash-menu-name")?.textContent?.trim() === "/stop");
|
||||
|
||||
expect(stopOption).toBeDefined();
|
||||
expect(stopOption?.querySelector(".slash-menu-badge")).toBeNull();
|
||||
expect(container.querySelector(".slash-menu-badge")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows every command directly without an expander or keyboard footer", () => {
|
||||
replaceSlashCommands([
|
||||
{
|
||||
key: "standard-command",
|
||||
name: "standard-command",
|
||||
description: "Standard command.",
|
||||
tier: "standard",
|
||||
category: "session",
|
||||
},
|
||||
{
|
||||
key: "power-command",
|
||||
name: "power-command",
|
||||
description: "Power command.",
|
||||
tier: "power",
|
||||
category: "tools",
|
||||
},
|
||||
]);
|
||||
const harness = createSlashRerenderHarness();
|
||||
const container = harness.inputAndRender(harness.container, "/");
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll<HTMLElement>(".slash-menu [role='option']")).map(
|
||||
(option) => option.querySelector(".slash-menu-name")?.textContent?.trim(),
|
||||
),
|
||||
).toEqual(["/standard-command", "/power-command"]);
|
||||
expect(container.querySelector(".slash-menu-show-more")).toBeNull();
|
||||
expect(container.querySelector(".slash-menu-footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps filtered command DOM and keyboard order aligned with relevance", () => {
|
||||
replaceSlashCommands([
|
||||
{
|
||||
|
||||
@@ -188,16 +188,16 @@ export function scrollActiveSkillMenuOptionIntoView(
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const activeOption = document.getElementById(activeId);
|
||||
const menu = activeOption?.closest<HTMLElement>(".skill-menu");
|
||||
if (!activeOption || !menu) {
|
||||
const scrollRegion = activeOption?.closest<HTMLElement>(".slash-menu__scroll");
|
||||
if (!activeOption || !scrollRegion) {
|
||||
return;
|
||||
}
|
||||
const menuBounds = menu.getBoundingClientRect();
|
||||
const menuBounds = scrollRegion.getBoundingClientRect();
|
||||
const optionBounds = activeOption.getBoundingClientRect();
|
||||
if (optionBounds.top < menuBounds.top) {
|
||||
menu.scrollTop -= menuBounds.top - optionBounds.top;
|
||||
scrollRegion.scrollTop -= menuBounds.top - optionBounds.top;
|
||||
} else if (optionBounds.bottom > menuBounds.bottom) {
|
||||
menu.scrollTop += optionBounds.bottom - menuBounds.bottom;
|
||||
scrollRegion.scrollTop += optionBounds.bottom - menuBounds.bottom;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -254,38 +254,40 @@ export function renderSkillMenu(
|
||||
role="listbox"
|
||||
aria-label=${t("chat.skills.menu")}
|
||||
>
|
||||
${state.skillCommandRefreshPending || state.skillMenuItems.length === 0
|
||||
? html`<div class="slash-menu-group">
|
||||
<div class="slash-menu-group__label">${t("chat.skills.loading")}</div>
|
||||
</div>`
|
||||
: html`<div class="slash-menu-group">
|
||||
<div class="slash-menu-group__label">${t("chat.skills.label")}</div>
|
||||
${state.skillMenuItems.map(
|
||||
(command, index) => html`
|
||||
<div
|
||||
id=${skillOptionId(props.paneId, command)}
|
||||
class="slash-menu-item ${index === state.skillMenuIndex
|
||||
? "slash-menu-item--active"
|
||||
: ""}"
|
||||
role="option"
|
||||
aria-selected=${index === state.skillMenuIndex}
|
||||
@mousedown=${(event: MouseEvent) => event.preventDefault()}
|
||||
@click=${() => selectSkillMention(command, props, requestUpdate)}
|
||||
@mouseenter=${() => {
|
||||
state.skillMenuIndex = index;
|
||||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
<span class="slash-menu-icon">${icons.zap}</span>
|
||||
<span class="slash-menu-name">$${command.name}</span>
|
||||
<span class="slash-menu-desc">${getSlashCommandDescription(command)}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>`}
|
||||
<div class="slash-menu-footer">
|
||||
<kbd>↑↓</kbd> ${t("chat.commands.navigate")} <kbd>Tab</kbd> ${t("chat.commands.fill")}
|
||||
<kbd>Enter</kbd> ${t("chat.commands.select")} <kbd>Esc</kbd> ${t("chat.commands.close")}
|
||||
<div class="slash-menu__scroll">
|
||||
${state.skillCommandRefreshPending || state.skillMenuItems.length === 0
|
||||
? html`<div class="slash-menu-group">
|
||||
<div class="slash-menu-group__label">${t("chat.skills.loading")}</div>
|
||||
</div>`
|
||||
: html`<div class="slash-menu-group">
|
||||
<div class="slash-menu-group__label">${t("chat.skills.label")}</div>
|
||||
${state.skillMenuItems.map(
|
||||
(command, index) => html`
|
||||
<div
|
||||
id=${skillOptionId(props.paneId, command)}
|
||||
class="slash-menu-item ${index === state.skillMenuIndex
|
||||
? "slash-menu-item--active"
|
||||
: ""}"
|
||||
role="option"
|
||||
aria-selected=${index === state.skillMenuIndex}
|
||||
@mousedown=${(event: MouseEvent) => event.preventDefault()}
|
||||
@click=${() => selectSkillMention(command, props, requestUpdate)}
|
||||
@mouseenter=${() => {
|
||||
state.skillMenuIndex = index;
|
||||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
<span class="slash-menu-leading">
|
||||
<span class="slash-menu-icon">${icons.zap}</span>
|
||||
<span class="slash-menu-name">$${command.name}</span>
|
||||
</span>
|
||||
<span class="slash-menu-trailing">
|
||||
<span class="slash-menu-desc">${getSlashCommandDescription(command)}</span>
|
||||
</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { icons, type IconName } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import {
|
||||
SLASH_COMMANDS,
|
||||
getHiddenCommandCount,
|
||||
getSlashCommandCategoryLabel,
|
||||
getSlashCommandCompletions,
|
||||
getSlashCommandDescription,
|
||||
@@ -19,7 +18,6 @@ export function resetSlashMenuState(state: ChatComposerState): void {
|
||||
state.slashMenuCommand = null;
|
||||
state.slashMenuArgItems = [];
|
||||
state.slashMenuItems = [];
|
||||
state.slashMenuExpanded = false;
|
||||
}
|
||||
|
||||
function hasVisibleSlashMenuState(state: ChatComposerState): boolean {
|
||||
@@ -28,8 +26,7 @@ function hasVisibleSlashMenuState(state: ChatComposerState): boolean {
|
||||
state.slashMenuMode !== "command" ||
|
||||
state.slashMenuCommand !== null ||
|
||||
state.slashMenuArgItems.length > 0 ||
|
||||
state.slashMenuItems.length > 0 ||
|
||||
state.slashMenuExpanded
|
||||
state.slashMenuItems.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,9 +109,7 @@ export function updateSlashMenu(
|
||||
if (!opts.skipSlashIntent) {
|
||||
requestSlashCommandRefresh(value, props, requestUpdate, getCurrentValue);
|
||||
}
|
||||
const items = getSlashCommandCompletions(match[1] ?? "", {
|
||||
showAll: state.slashMenuExpanded,
|
||||
});
|
||||
const items = getSlashCommandCompletions(match[1] ?? "", { showAll: true });
|
||||
state.slashMenuItems = items;
|
||||
state.slashMenuOpen = items.length > 0;
|
||||
state.slashMenuIndex = 0;
|
||||
@@ -274,18 +269,18 @@ export function scrollActiveSlashMenuOptionIntoView(
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const activeOption = document.getElementById(activeId);
|
||||
const menu = activeOption?.closest<HTMLElement>(".slash-menu");
|
||||
if (!activeOption || !menu) {
|
||||
const scrollRegion = activeOption?.closest<HTMLElement>(".slash-menu__scroll");
|
||||
if (!activeOption || !scrollRegion) {
|
||||
return;
|
||||
}
|
||||
const menuBounds = menu.getBoundingClientRect();
|
||||
const menuBounds = scrollRegion.getBoundingClientRect();
|
||||
const optionBounds = activeOption.getBoundingClientRect();
|
||||
// scrollIntoView also moves the short-landscape composer and page. Keep
|
||||
// keyboard navigation owned by the menu so textarea focus stays stable.
|
||||
if (optionBounds.top < menuBounds.top) {
|
||||
menu.scrollTop -= menuBounds.top - optionBounds.top;
|
||||
scrollRegion.scrollTop -= menuBounds.top - optionBounds.top;
|
||||
} else if (optionBounds.bottom > menuBounds.bottom) {
|
||||
menu.scrollTop += optionBounds.bottom - menuBounds.bottom;
|
||||
scrollRegion.scrollTop += optionBounds.bottom - menuBounds.bottom;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -321,40 +316,41 @@ export function renderSlashMenu(
|
||||
role="listbox"
|
||||
aria-label=${t("chat.commands.arguments")}
|
||||
>
|
||||
<div class="slash-menu-group">
|
||||
<div class="slash-menu-group__label">
|
||||
/${state.slashMenuCommand.name} ${getSlashCommandDescription(state.slashMenuCommand)}
|
||||
<div class="slash-menu__scroll">
|
||||
<div class="slash-menu-group">
|
||||
<div class="slash-menu-group__label">
|
||||
/${state.slashMenuCommand.name} ${getSlashCommandDescription(state.slashMenuCommand)}
|
||||
</div>
|
||||
${state.slashMenuArgItems.map(
|
||||
(arg, i) => html`
|
||||
<div
|
||||
id=${getSlashArgOptionId(props.paneId, state.slashMenuCommand?.name ?? "", arg)}
|
||||
class="slash-menu-item ${i === state.slashMenuIndex
|
||||
? "slash-menu-item--active"
|
||||
: ""}"
|
||||
role="option"
|
||||
aria-selected=${i === state.slashMenuIndex}
|
||||
@click=${() => selectSlashArg(arg, props, requestUpdate, true)}
|
||||
@mouseenter=${() => {
|
||||
state.slashMenuIndex = i;
|
||||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
<span class="slash-menu-leading">
|
||||
<span class="slash-menu-icon"
|
||||
>${state.slashMenuCommand?.icon
|
||||
? renderSlashIcon(state.slashMenuCommand.icon)
|
||||
: nothing}</span
|
||||
>
|
||||
<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>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${state.slashMenuArgItems.map(
|
||||
(arg, i) => html`
|
||||
<div
|
||||
id=${getSlashArgOptionId(props.paneId, state.slashMenuCommand?.name ?? "", arg)}
|
||||
class="slash-menu-item ${i === state.slashMenuIndex
|
||||
? "slash-menu-item--active"
|
||||
: ""}"
|
||||
role="option"
|
||||
aria-selected=${i === state.slashMenuIndex}
|
||||
@click=${() => selectSlashArg(arg, props, requestUpdate, true)}
|
||||
@mouseenter=${() => {
|
||||
state.slashMenuIndex = i;
|
||||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
${state.slashMenuCommand?.icon
|
||||
? html`<span class="slash-menu-icon"
|
||||
>${renderSlashIcon(state.slashMenuCommand.icon)}</span
|
||||
>`
|
||||
: nothing}
|
||||
<span class="slash-menu-name">${arg}</span>
|
||||
<span class="slash-menu-desc">/${state.slashMenuCommand?.name} ${arg}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<div class="slash-menu-footer">
|
||||
<kbd>↑↓</kbd> ${t("chat.commands.navigate")} <kbd>Tab</kbd> ${t("chat.commands.fill")}
|
||||
<kbd>Enter</kbd> ${t("chat.commands.run")} <kbd>Esc</kbd>
|
||||
${t("chat.commands.close")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -396,21 +392,23 @@ export function renderSlashMenu(
|
||||
requestUpdate();
|
||||
}}
|
||||
>
|
||||
${cmd.icon
|
||||
? html`<span class="slash-menu-icon">${renderSlashIcon(cmd.icon)}</span>`
|
||||
: nothing}
|
||||
<span class="slash-menu-name">/${cmd.name}</span>
|
||||
${cmd.args ? html`<span class="slash-menu-args">${cmd.args}</span>` : nothing}
|
||||
<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
|
||||
>`
|
||||
: cmd.executeLocal && !cmd.args
|
||||
? html` <span class="slash-menu-badge">${t("chat.commands.instant")}</span> `
|
||||
<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>
|
||||
`,
|
||||
)}
|
||||
@@ -418,31 +416,9 @@ export function renderSlashMenu(
|
||||
`,
|
||||
);
|
||||
|
||||
const hiddenCount = state.slashMenuExpanded ? 0 : getHiddenCommandCount();
|
||||
|
||||
return html`
|
||||
<div id=${listboxId} class="slash-menu" role="listbox" aria-label=${t("chat.commands.menu")}>
|
||||
${sections}
|
||||
${hiddenCount > 0
|
||||
? html`<button
|
||||
class="slash-menu-show-more"
|
||||
@click=${(event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
state.slashMenuExpanded = true;
|
||||
updateSlashMenu(draft, requestUpdate, props);
|
||||
}}
|
||||
>
|
||||
${hiddenCount === 1
|
||||
? t("chat.commands.showMoreOne")
|
||||
: t("chat.commands.showMoreMany", { count: String(hiddenCount) })}
|
||||
</button>`
|
||||
: nothing}
|
||||
<div class="slash-menu-footer">
|
||||
<kbd>↑↓</kbd> ${t("chat.commands.navigate")} <kbd>Tab</kbd> ${t("chat.commands.fill")}
|
||||
<kbd>Enter</kbd> ${t("chat.commands.select")} <kbd>Esc</kbd>
|
||||
${t("chat.commands.close")}
|
||||
</div>
|
||||
<div class="slash-menu__scroll">${sections}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ function createChatComposerState(): ChatComposerState {
|
||||
slashMenuMode: "command",
|
||||
slashMenuCommand: null,
|
||||
slashMenuArgItems: [],
|
||||
slashMenuExpanded: false,
|
||||
slashCommandRefreshPending: false,
|
||||
skillMenuOpen: false,
|
||||
skillMenuItems: [],
|
||||
|
||||
@@ -164,7 +164,6 @@ export type ChatComposerState = {
|
||||
slashMenuMode: "command" | "args";
|
||||
slashMenuCommand: SlashCommandDef | null;
|
||||
slashMenuArgItems: string[];
|
||||
slashMenuExpanded: boolean;
|
||||
slashCommandRefreshPending: boolean;
|
||||
skillMenuOpen: boolean;
|
||||
skillMenuItems: SlashCommandDef[];
|
||||
|
||||
@@ -3817,64 +3817,89 @@ button.chat-reply-preview--message:disabled {
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
max-height: 288px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--menu-radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 30;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.slash-menu__scroll {
|
||||
box-sizing: border-box;
|
||||
max-height: inherit;
|
||||
overflow-y: auto;
|
||||
padding: var(--menu-padding);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--muted) 35%, transparent) transparent;
|
||||
}
|
||||
|
||||
.slash-menu__scroll::-webkit-scrollbar-track,
|
||||
.slash-menu__scroll::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.slash-menu-group + .slash-menu-group {
|
||||
margin-top: 4px;
|
||||
margin-top: 2px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||
}
|
||||
|
||||
.slash-menu-group__label {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 8px 3px;
|
||||
font-size: calc(10px * var(--control-ui-text-scale));
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent);
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
column-gap: 16px;
|
||||
min-height: var(--menu-item-height);
|
||||
padding: 0 8px;
|
||||
border-radius: var(--menu-item-radius);
|
||||
transition: background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover,
|
||||
.slash-menu-item--active {
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover));
|
||||
background: color-mix(in srgb, var(--bg-hover) 52%, transparent);
|
||||
}
|
||||
|
||||
.slash-menu-leading,
|
||||
.slash-menu-trailing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.slash-menu-leading {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slash-menu-trailing {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slash-menu-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
opacity: 0.7;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.slash-menu-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
@@ -3882,100 +3907,45 @@ button.chat-reply-preview--message:disabled {
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.slash-menu-item--active .slash-menu-icon,
|
||||
.slash-menu-item:hover .slash-menu-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slash-menu-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--mono);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
max-width: calc(100% - 24px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--control-ui-text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slash-menu-args {
|
||||
font-size: 0.75rem;
|
||||
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);
|
||||
font-family: var(--mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slash-menu-desc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--control-ui-text-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.slash-menu-item--active .slash-menu-name {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.slash-menu-item--active .slash-menu-desc {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.slash-menu-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slash-menu-show-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.slash-menu-show-more:hover {
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.slash-menu-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 6px 10px 4px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.slash-menu-footer kbd {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--mono);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chat-attachments-preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user