mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat(frontend): display Agent Skills and Commands in slash menu (#12982)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import {
|
||||
SlashCommandMenu,
|
||||
getSkillDescription,
|
||||
stripMarkdown,
|
||||
} from "#/components/features/chat/components/slash-command-menu";
|
||||
import { SlashCommandItem } from "#/hooks/chat/use-slash-command";
|
||||
|
||||
// jsdom does not implement scrollIntoView
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
const makeItem = (
|
||||
name: string,
|
||||
command: string,
|
||||
content: string = "",
|
||||
): SlashCommandItem => ({
|
||||
skill: {
|
||||
name,
|
||||
type: "agentskills" as const,
|
||||
content,
|
||||
triggers: [command],
|
||||
},
|
||||
command,
|
||||
});
|
||||
|
||||
const defaultItems: SlashCommandItem[] = [
|
||||
makeItem("code-search", "/code-search", "Search code semantically."),
|
||||
makeItem("random-number", "/random-number", "Generate a random number."),
|
||||
makeItem(
|
||||
"init",
|
||||
"/init",
|
||||
"---\nname: init\ndescription: Initialize a project\n---\n## Usage\nRun /init to start.",
|
||||
),
|
||||
];
|
||||
|
||||
describe("SlashCommandMenu", () => {
|
||||
it("renders nothing when items is empty", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<SlashCommandMenu items={[]} selectedIndex={0} onSelect={vi.fn()} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders all items with slash commands as primary text", () => {
|
||||
renderWithProviders(
|
||||
<SlashCommandMenu
|
||||
items={defaultItems}
|
||||
selectedIndex={0}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("/code-search")).toBeInTheDocument();
|
||||
expect(screen.getByText("/random-number")).toBeInTheDocument();
|
||||
expect(screen.getByText("/init")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the selected item with aria-selected", () => {
|
||||
renderWithProviders(
|
||||
<SlashCommandMenu
|
||||
items={defaultItems}
|
||||
selectedIndex={1}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0]).toHaveAttribute("aria-selected", "false");
|
||||
expect(options[1]).toHaveAttribute("aria-selected", "true");
|
||||
expect(options[2]).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
it("calls onSelect on mouseDown", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<SlashCommandMenu
|
||||
items={defaultItems}
|
||||
selectedIndex={0}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
await user.click(options[1]);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(defaultItems[1]);
|
||||
});
|
||||
|
||||
it("displays skill descriptions", () => {
|
||||
renderWithProviders(
|
||||
<SlashCommandMenu
|
||||
items={defaultItems}
|
||||
selectedIndex={0}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// First item: first-sentence extraction
|
||||
expect(screen.getByText("Search code semantically.")).toBeInTheDocument();
|
||||
|
||||
// Third item: frontmatter description extraction
|
||||
expect(screen.getByText("Initialize a project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has an accessible listbox role and translated aria-label", () => {
|
||||
renderWithProviders(
|
||||
<SlashCommandMenu
|
||||
items={defaultItems}
|
||||
selectedIndex={0}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox).toBeInTheDocument();
|
||||
// In test env, translation key is returned as-is
|
||||
expect(listbox).toHaveAttribute("aria-label", "CHAT_INTERFACE$COMMANDS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkillDescription", () => {
|
||||
it("extracts description from YAML frontmatter", () => {
|
||||
const content =
|
||||
"---\nname: test\ndescription: A test skill\n---\n## Usage\nDetails here.";
|
||||
expect(getSkillDescription(content)).toBe("A test skill");
|
||||
});
|
||||
|
||||
it("strips double quotes from frontmatter description", () => {
|
||||
const content = '---\ndescription: "Quoted description"\n---\nBody.';
|
||||
expect(getSkillDescription(content)).toBe("Quoted description");
|
||||
});
|
||||
|
||||
it("strips single quotes from frontmatter description", () => {
|
||||
const content = "---\ndescription: 'Single quoted'\n---\nBody.";
|
||||
expect(getSkillDescription(content)).toBe("Single quoted");
|
||||
});
|
||||
|
||||
it("falls back to first meaningful line when no frontmatter", () => {
|
||||
const content = "# Title\n\nThis is a description.";
|
||||
expect(getSkillDescription(content)).toBe("This is a description.");
|
||||
});
|
||||
|
||||
it("falls back to first sentence from body when frontmatter has no description", () => {
|
||||
const content =
|
||||
"---\nname: test\ntriggers: ['/test']\n---\nA helpful tool. It does things.";
|
||||
expect(getSkillDescription(content)).toBe("A helpful tool.");
|
||||
});
|
||||
|
||||
it("skips headers and empty lines", () => {
|
||||
const content = "\n\n# Header\n## Subheader\n\nActual content here";
|
||||
expect(getSkillDescription(content)).toBe("Actual content here");
|
||||
});
|
||||
|
||||
it("returns null for empty content", () => {
|
||||
expect(getSkillDescription("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for content with only headers", () => {
|
||||
expect(getSkillDescription("# Title\n## Subtitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the whole line when there is no sentence-ending punctuation", () => {
|
||||
const content = "A description without punctuation";
|
||||
expect(getSkillDescription(content)).toBe(
|
||||
"A description without punctuation",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips markdown from frontmatter description", () => {
|
||||
const content =
|
||||
'---\ndescription: "A **bold** and *italic* description"\n---\nBody.';
|
||||
expect(getSkillDescription(content)).toBe(
|
||||
"A bold and italic description",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips markdown from body fallback", () => {
|
||||
const content = "# Title\n\nUse `code` and [links](http://example.com).";
|
||||
expect(getSkillDescription(content)).toBe("Use code and links.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripMarkdown", () => {
|
||||
it("strips bold syntax", () => {
|
||||
expect(stripMarkdown("a **bold** word")).toBe("a bold word");
|
||||
});
|
||||
|
||||
it("strips italic syntax", () => {
|
||||
expect(stripMarkdown("an *italic* word")).toBe("an italic word");
|
||||
});
|
||||
|
||||
it("strips bold-italic syntax", () => {
|
||||
expect(stripMarkdown("***both***")).toBe("both");
|
||||
});
|
||||
|
||||
it("strips inline code", () => {
|
||||
expect(stripMarkdown("run `npm test` now")).toBe("run npm test now");
|
||||
});
|
||||
|
||||
it("strips links", () => {
|
||||
expect(stripMarkdown("see [docs](http://example.com)")).toBe("see docs");
|
||||
});
|
||||
|
||||
it("strips images", () => {
|
||||
expect(stripMarkdown("")).toBe("alt text");
|
||||
});
|
||||
|
||||
it("strips strikethrough", () => {
|
||||
expect(stripMarkdown("~~removed~~")).toBe("removed");
|
||||
});
|
||||
|
||||
it("strips underscore emphasis", () => {
|
||||
expect(stripMarkdown("__bold__ and _italic_")).toBe("bold and italic");
|
||||
});
|
||||
|
||||
it("returns plain text unchanged", () => {
|
||||
expect(stripMarkdown("plain text")).toBe("plain text");
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,10 @@ import { DragOver } from "../drag-over";
|
||||
import { UploadedFiles } from "../uploaded-files";
|
||||
import { ChatInputRow } from "./chat-input-row";
|
||||
import { ChatInputActions } from "./chat-input-actions";
|
||||
import { SlashCommandMenu } from "./slash-command-menu";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SlashCommandItem } from "#/hooks/chat/use-slash-command";
|
||||
|
||||
interface ChatInputContainerProps {
|
||||
chatContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -24,6 +26,10 @@ interface ChatInputContainerProps {
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
isSlashMenuOpen?: boolean;
|
||||
slashItems?: SlashCommandItem[];
|
||||
slashSelectedIndex?: number;
|
||||
onSlashSelect?: (item: SlashCommandItem) => void;
|
||||
}
|
||||
|
||||
export function ChatInputContainer({
|
||||
@@ -44,6 +50,10 @@ export function ChatInputContainer({
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
isSlashMenuOpen = false,
|
||||
slashItems = [],
|
||||
slashSelectedIndex = 0,
|
||||
onSlashSelect,
|
||||
}: ChatInputContainerProps) {
|
||||
const conversationMode = useConversationStore(
|
||||
(state) => state.conversationMode,
|
||||
@@ -65,19 +75,31 @@ export function ChatInputContainer({
|
||||
|
||||
<UploadedFiles />
|
||||
|
||||
<ChatInputRow
|
||||
chatInputRef={chatInputRef}
|
||||
disabled={disabled}
|
||||
showButton={showButton}
|
||||
buttonClassName={buttonClassName}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{/* Wrapper so the slash menu anchors just above the input row,
|
||||
not above the entire (possibly resized) container */}
|
||||
<div className="relative w-full">
|
||||
{isSlashMenuOpen && onSlashSelect && (
|
||||
<SlashCommandMenu
|
||||
items={slashItems}
|
||||
selectedIndex={slashSelectedIndex}
|
||||
onSelect={onSlashSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChatInputRow
|
||||
chatInputRef={chatInputRef}
|
||||
disabled={disabled}
|
||||
showButton={showButton}
|
||||
buttonClassName={buttonClassName}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatInputActions
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Text } from "#/ui/typography";
|
||||
import { SlashCommandItem } from "#/hooks/chat/use-slash-command";
|
||||
|
||||
/**
|
||||
* Strip common inline Markdown syntax so descriptions render as plain text.
|
||||
* Handles: bold, italic, inline code, links, and images.
|
||||
*/
|
||||
export function stripMarkdown(text: string): string {
|
||||
return (
|
||||
text
|
||||
// Images:  → alt
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
// Links: [text](url) → text
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
// Bold/italic: ***text***, **text**, *text*, ___text___, __text__, _text_
|
||||
.replace(/\*{3}(.+?)\*{3}/g, "$1")
|
||||
.replace(/\*{2}(.+?)\*{2}/g, "$1")
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/_{3}(.+?)_{3}/g, "$1")
|
||||
.replace(/_{2}(.+?)_{2}/g, "$1")
|
||||
.replace(/_(.+?)_/g, "$1")
|
||||
// Inline code: `text` → text
|
||||
.replace(/`(.+?)`/g, "$1")
|
||||
// Strikethrough: ~~text~~ → text
|
||||
.replace(/~~(.+?)~~/g, "$1")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a short description from skill content.
|
||||
* Tries YAML frontmatter "description:" first, then falls back
|
||||
* to the first meaningful line after headers and frontmatter.
|
||||
* Returns plain text with Markdown formatting stripped.
|
||||
*/
|
||||
export function getSkillDescription(content: string): string | null {
|
||||
let body = content;
|
||||
|
||||
// Try to extract description from YAML frontmatter
|
||||
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (frontmatterMatch) {
|
||||
const descMatch = frontmatterMatch[1].match(/^description:\s*(.+)$/m);
|
||||
if (descMatch) {
|
||||
let desc = descMatch[1].trim();
|
||||
// Strip surrounding quotes from YAML values
|
||||
if (
|
||||
(desc.startsWith('"') && desc.endsWith('"')) ||
|
||||
(desc.startsWith("'") && desc.endsWith("'"))
|
||||
) {
|
||||
desc = desc.slice(1, -1);
|
||||
}
|
||||
return stripMarkdown(desc);
|
||||
}
|
||||
// Skip frontmatter for body parsing
|
||||
body = content.slice(frontmatterMatch[0].length);
|
||||
}
|
||||
|
||||
// Fall back to first meaningful line (skip headers, empty lines, frontmatter delimiters)
|
||||
const meaningful = body
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && !line.startsWith("#") && line !== "---");
|
||||
|
||||
if (!meaningful) return null;
|
||||
|
||||
// Strip Markdown first so URLs inside links don't confuse sentence detection
|
||||
const stripped = stripMarkdown(meaningful);
|
||||
const sentence = stripped.match(/^[^.!?\n]*[.!?]/);
|
||||
return sentence?.[0] || stripped;
|
||||
}
|
||||
|
||||
interface SlashCommandMenuItemProps {
|
||||
item: SlashCommandItem;
|
||||
isSelected: boolean;
|
||||
onSelect: (item: SlashCommandItem) => void;
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
function SlashCommandMenuItem({
|
||||
item,
|
||||
isSelected,
|
||||
onSelect,
|
||||
ref,
|
||||
}: SlashCommandMenuItemProps) {
|
||||
const description = useMemo(
|
||||
() => (item.skill.content ? getSkillDescription(item.skill.content) : null),
|
||||
[item.skill.content],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-3 py-2.5 text-left transition-colors",
|
||||
isSelected ? "bg-[#383b45]" : "hover:bg-[#2a2d37]",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
// Use mouseDown instead of click to fire before input blur
|
||||
e.preventDefault();
|
||||
onSelect(item);
|
||||
}}
|
||||
>
|
||||
<Text className="font-semibold">{item.command}</Text>
|
||||
{description && (
|
||||
<Text className="text-xs text-[#9ca3af] mt-0.5 truncate block">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface SlashCommandMenuProps {
|
||||
items: SlashCommandItem[];
|
||||
selectedIndex: number;
|
||||
onSelect: (item: SlashCommandItem) => void;
|
||||
}
|
||||
|
||||
export function SlashCommandMenu({
|
||||
items,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
}: SlashCommandMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Keep refs array in sync with items length
|
||||
useEffect(() => {
|
||||
itemRefs.current = itemRefs.current.slice(0, items.length);
|
||||
}, [items.length]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const selectedItem = itemRefs.current[selectedIndex];
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t("CHAT_INTERFACE$COMMANDS")}
|
||||
className="absolute bottom-full left-0 w-full mb-1 bg-[#1e2028] border border-[#383b45] rounded-lg shadow-lg max-h-[300px] overflow-y-auto custom-scrollbar z-50"
|
||||
data-testid="slash-command-menu"
|
||||
>
|
||||
<div className="px-3 py-2 text-xs text-[#9ca3af] border-b border-[#383b45]">
|
||||
{t("CHAT_INTERFACE$COMMANDS")}
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<SlashCommandMenuItem
|
||||
key={item.command}
|
||||
item={item}
|
||||
isSelected={index === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
ref={(el) => {
|
||||
itemRefs.current[index] = el;
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useFileHandling } from "#/hooks/chat/use-file-handling";
|
||||
import { useGripResize } from "#/hooks/chat/use-grip-resize";
|
||||
import { useChatInputEvents } from "#/hooks/chat/use-chat-input-events";
|
||||
import { useChatSubmission } from "#/hooks/chat/use-chat-submission";
|
||||
import { useSlashCommand } from "#/hooks/chat/use-slash-command";
|
||||
import { ChatInputGrip } from "./components/chat-input-grip";
|
||||
import { ChatInputContainer } from "./components/chat-input-container";
|
||||
import { HiddenFileInput } from "./components/hidden-file-input";
|
||||
@@ -105,6 +106,16 @@ export function CustomChatInput({
|
||||
onBlur,
|
||||
);
|
||||
|
||||
const {
|
||||
isMenuOpen: isSlashMenuOpen,
|
||||
filteredItems: slashItems,
|
||||
selectedIndex: slashSelectedIndex,
|
||||
updateSlashMenu,
|
||||
selectItem: selectSlashItem,
|
||||
handleSlashKeyDown,
|
||||
closeMenu: closeSlashMenu,
|
||||
} = useSlashCommand(chatInputRef as React.RefObject<HTMLDivElement | null>);
|
||||
|
||||
// Cleanup: reset suggestions visibility when component unmounts
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -144,11 +155,24 @@ export function CustomChatInput({
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onInput={handleInput}
|
||||
onInput={() => {
|
||||
handleInput();
|
||||
updateSlashMenu();
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => handleKeyDown(e, isDisabled, handleSubmit)}
|
||||
onKeyDown={(e) => {
|
||||
if (handleSlashKeyDown(e)) return;
|
||||
handleKeyDown(e, isDisabled, handleSubmit);
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onBlur={() => {
|
||||
handleBlur();
|
||||
closeSlashMenu();
|
||||
}}
|
||||
isSlashMenuOpen={isSlashMenuOpen}
|
||||
slashItems={slashItems}
|
||||
slashSelectedIndex={slashSelectedIndex}
|
||||
onSlashSelect={selectSlashItem}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
247
frontend/src/hooks/chat/use-slash-command.ts
Normal file
247
frontend/src/hooks/chat/use-slash-command.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useConversationSkills } from "#/hooks/query/use-conversation-skills";
|
||||
import { Skill } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { Microagent } from "#/api/open-hands.types";
|
||||
|
||||
export type SlashCommandSkill = Skill | Microagent;
|
||||
|
||||
export interface SlashCommandItem {
|
||||
skill: SlashCommandSkill;
|
||||
/** The slash command string, e.g. "/random-number" */
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** Get the cursor's character offset within a contentEditable element. */
|
||||
function getCursorOffset(element: HTMLElement): number {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return -1;
|
||||
const range = selection.getRangeAt(0);
|
||||
const preRange = range.cloneRange();
|
||||
preRange.selectNodeContents(element);
|
||||
preRange.setEnd(range.startContainer, range.startOffset);
|
||||
return preRange.toString().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing slash command autocomplete in the chat input.
|
||||
* Detects when user types "/" and provides filtered skill suggestions.
|
||||
* Only skills with explicit "/" triggers (TaskTrigger) appear in the menu.
|
||||
*/
|
||||
export const useSlashCommand = (
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>,
|
||||
) => {
|
||||
const { data: skills } = useConversationSkills();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [filterText, setFilterText] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Build slash command items from skills:
|
||||
// - Skills with explicit "/" triggers use those triggers
|
||||
// - AgentSkills without "/" triggers get a derived "/<name>" command
|
||||
const slashItems = useMemo(() => {
|
||||
if (!skills) return [];
|
||||
const items: SlashCommandItem[] = [];
|
||||
skills.forEach((skill) => {
|
||||
const triggers = skill.triggers || [];
|
||||
const slashTriggers = triggers.filter((t) => t.startsWith("/"));
|
||||
|
||||
if (slashTriggers.length > 0) {
|
||||
// Skill has explicit slash triggers
|
||||
slashTriggers.forEach((trigger) => {
|
||||
items.push({ skill, command: trigger });
|
||||
});
|
||||
} else if (skill.type === "agentskills") {
|
||||
// AgentSkills without slash triggers get a derived command
|
||||
items.push({ skill, command: `/${skill.name}` });
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}, [skills]);
|
||||
|
||||
// Filter items based on user input after "/"
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!filterText) return slashItems;
|
||||
const lower = filterText.toLowerCase();
|
||||
return slashItems.filter(
|
||||
(item) =>
|
||||
item.command.slice(1).toLowerCase().includes(lower) ||
|
||||
item.skill.name.toLowerCase().includes(lower),
|
||||
);
|
||||
}, [slashItems, filterText]);
|
||||
|
||||
// Keep refs in sync so handleSlashKeyDown always reads the latest values,
|
||||
// avoiding stale closures from React's batched state updates.
|
||||
const isMenuOpenRef = useRef(isMenuOpen);
|
||||
isMenuOpenRef.current = isMenuOpen;
|
||||
const filteredItemsRef = useRef(filteredItems);
|
||||
filteredItemsRef.current = filteredItems;
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
// Reset selected index when the filter text changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filterText]);
|
||||
|
||||
// Track the character range of the current slash word so selectItem can
|
||||
// replace only that portion instead of wiping the entire input.
|
||||
const slashRangeRef = useRef<{ start: number; end: number } | null>(null);
|
||||
|
||||
// Detect a slash word at the cursor position.
|
||||
// Returns the filter text (characters after "/") and the range of the
|
||||
// slash word within the full input text, or null if no slash word found.
|
||||
const getSlashText = useCallback((): {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
} | null => {
|
||||
const element = chatInputRef.current;
|
||||
if (!element) return null;
|
||||
|
||||
// Strip trailing newlines that contentEditable can produce, but preserve
|
||||
// spaces so "/command " (after selection) won't re-trigger the menu.
|
||||
const text = (element.innerText || "").replace(/[\n\r]+$/, "");
|
||||
const cursor = getCursorOffset(element);
|
||||
if (cursor < 0) return null;
|
||||
|
||||
const textBeforeCursor = text.slice(0, cursor);
|
||||
// Match a "/" preceded by whitespace or at position 0, followed by
|
||||
// non-whitespace characters, ending right at the cursor.
|
||||
const match = textBeforeCursor.match(/(^|\s)(\/\S*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const slashWord = match[2]; // e.g. "/hel"
|
||||
const start = textBeforeCursor.length - slashWord.length;
|
||||
// The end of the slash word extends past the cursor to include any
|
||||
// contiguous non-whitespace characters (covers the case where the
|
||||
// cursor sits in the middle of a word).
|
||||
const afterCursor = text.slice(cursor);
|
||||
const trailing = afterCursor.match(/^\S*/);
|
||||
const end = cursor + (trailing ? trailing[0].length : 0);
|
||||
|
||||
return { text: slashWord.slice(1), start, end }; // strip leading "/"
|
||||
}, [chatInputRef]);
|
||||
|
||||
// Update the menu state based on current input
|
||||
const updateSlashMenu = useCallback(() => {
|
||||
const result = getSlashText();
|
||||
if (result !== null && slashItems.length > 0) {
|
||||
setFilterText(result.text);
|
||||
slashRangeRef.current = { start: result.start, end: result.end };
|
||||
setIsMenuOpen(true);
|
||||
} else {
|
||||
setIsMenuOpen(false);
|
||||
setFilterText("");
|
||||
slashRangeRef.current = null;
|
||||
}
|
||||
}, [getSlashText, slashItems.length]);
|
||||
|
||||
// Select an item and replace only the slash word with the command
|
||||
const selectItem = useCallback(
|
||||
(item: SlashCommandItem) => {
|
||||
const element = chatInputRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const slashRange = slashRangeRef.current;
|
||||
const currentText = (element.innerText || "").replace(/[\n\r]+$/, "");
|
||||
const replacement = `${item.command} `;
|
||||
|
||||
if (slashRange) {
|
||||
// Splice the command into the text, replacing only the slash word
|
||||
element.textContent =
|
||||
currentText.slice(0, slashRange.start) +
|
||||
replacement +
|
||||
currentText.slice(slashRange.end);
|
||||
|
||||
// Position cursor right after the inserted command + space
|
||||
const cursorPos = slashRange.start + replacement.length;
|
||||
const textNode = element.firstChild;
|
||||
if (textNode) {
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
const offset = Math.min(cursorPos, textNode.textContent!.length);
|
||||
range.setStart(textNode, offset);
|
||||
range.collapse(true);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
} else {
|
||||
// Fallback: replace everything (e.g. if range tracking failed)
|
||||
element.textContent = replacement;
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
|
||||
setIsMenuOpen(false);
|
||||
setFilterText("");
|
||||
setSelectedIndex(0);
|
||||
slashRangeRef.current = null;
|
||||
|
||||
// Trigger a native InputEvent so React's onInput fires (for smartResize etc.)
|
||||
element.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
||||
|
||||
// Restore focus so keyboard events (Enter to submit) work after selection
|
||||
element.focus();
|
||||
},
|
||||
[chatInputRef],
|
||||
);
|
||||
|
||||
// Handle keyboard navigation in the menu.
|
||||
// Uses refs to always read the latest state, avoiding stale closures.
|
||||
const handleSlashKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent): boolean => {
|
||||
const items = filteredItemsRef.current;
|
||||
if (!isMenuOpenRef.current || items.length === 0) return false;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
|
||||
return true;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
|
||||
return true;
|
||||
case "Enter":
|
||||
case "Tab": {
|
||||
const item = items[selectedIndexRef.current];
|
||||
if (!item) return false;
|
||||
e.preventDefault();
|
||||
selectItem(item);
|
||||
return true;
|
||||
}
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setIsMenuOpen(false);
|
||||
return true;
|
||||
// Cursor-movement keys: close the menu to avoid acting on a stale
|
||||
// slash-word range, but don't consume the event so the cursor moves.
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight":
|
||||
case "Home":
|
||||
case "End":
|
||||
setIsMenuOpen(false);
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[selectItem],
|
||||
);
|
||||
|
||||
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
|
||||
|
||||
return {
|
||||
isMenuOpen,
|
||||
filteredItems,
|
||||
selectedIndex,
|
||||
updateSlashMenu,
|
||||
selectItem,
|
||||
handleSlashKeyDown,
|
||||
closeMenu,
|
||||
};
|
||||
};
|
||||
@@ -305,6 +305,7 @@ export enum I18nKey {
|
||||
INVARIANT$SETTINGS_UPDATED_MESSAGE = "INVARIANT$SETTINGS_UPDATED_MESSAGE",
|
||||
CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE = "CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE",
|
||||
CHAT_INTERFACE$DISCONNECTED = "CHAT_INTERFACE$DISCONNECTED",
|
||||
CHAT_INTERFACE$COMMANDS = "CHAT_INTERFACE$COMMANDS",
|
||||
CHAT_INTERFACE$CONNECTING = "CHAT_INTERFACE$CONNECTING",
|
||||
CHAT_INTERFACE$STOPPED = "CHAT_INTERFACE$STOPPED",
|
||||
CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE = "CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE",
|
||||
|
||||
@@ -4879,6 +4879,22 @@
|
||||
"de": "Getrennt",
|
||||
"uk": "Від'єднано"
|
||||
},
|
||||
"CHAT_INTERFACE$COMMANDS": {
|
||||
"en": "Commands",
|
||||
"ja": "コマンド",
|
||||
"zh-CN": "命令",
|
||||
"zh-TW": "命令",
|
||||
"ko-KR": "명령어",
|
||||
"no": "Kommandoer",
|
||||
"ar": "أوامر",
|
||||
"de": "Befehle",
|
||||
"fr": "Commandes",
|
||||
"it": "Comandi",
|
||||
"pt": "Comandos",
|
||||
"es": "Comandos",
|
||||
"tr": "Komutlar",
|
||||
"uk": "Команди"
|
||||
},
|
||||
"CHAT_INTERFACE$CONNECTING": {
|
||||
"en": "Connecting... (this may take 1-2 minutes)",
|
||||
"ja": "接続中...(1-2分かかる場合があります)",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { http, delay, HttpResponse } from "msw";
|
||||
import { Conversation, ResultSet } from "#/api/open-hands.types";
|
||||
import {
|
||||
Conversation,
|
||||
GetMicroagentsResponse,
|
||||
ResultSet,
|
||||
} from "#/api/open-hands.types";
|
||||
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
@@ -115,4 +119,54 @@ export const CONVERSATION_HANDLERS = [
|
||||
}
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}),
|
||||
|
||||
http.get("/api/conversations/:conversationId/microagents", async () => {
|
||||
const response: GetMicroagentsResponse = {
|
||||
microagents: [
|
||||
{
|
||||
name: "init",
|
||||
type: "agentskills",
|
||||
content: "Initialize an AGENTS.md file for the repository",
|
||||
triggers: ["/init"],
|
||||
},
|
||||
{
|
||||
name: "releasenotes",
|
||||
type: "agentskills",
|
||||
content: "Generate a changelog from the most recent release",
|
||||
triggers: ["/releasenotes"],
|
||||
},
|
||||
{
|
||||
name: "test-runner",
|
||||
type: "agentskills",
|
||||
content: "Run the test suite and report results",
|
||||
triggers: ["/test"],
|
||||
},
|
||||
{
|
||||
name: "code-search",
|
||||
type: "knowledge",
|
||||
content: "Search the codebase semantically",
|
||||
triggers: ["/search"],
|
||||
},
|
||||
{
|
||||
name: "docker",
|
||||
type: "agentskills",
|
||||
content: "Docker usage guide for container environments",
|
||||
triggers: ["docker", "container"],
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
type: "agentskills",
|
||||
content: "GitHub API interaction guide",
|
||||
triggers: ["github", "git"],
|
||||
},
|
||||
{
|
||||
name: "work_hosts",
|
||||
type: "repo",
|
||||
content: "Available hosts for web applications",
|
||||
triggers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -56,15 +56,31 @@ export const setStyleHeightPx = (el: HTMLElement, height: number): void => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if the user is on a mobile device
|
||||
* @returns True if the user is on a mobile device, false otherwise
|
||||
* Detect if the user is on a mobile device.
|
||||
* Touch support alone is not sufficient — touchscreen laptops have touch
|
||||
* but use a mouse/trackpad as primary input. We check that the primary
|
||||
* pointing device is coarse (finger) to avoid false positives.
|
||||
*/
|
||||
export const isMobileDevice = (): boolean =>
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
) ||
|
||||
"ontouchstart" in window ||
|
||||
navigator.maxTouchPoints > 0;
|
||||
export const isMobileDevice = (): boolean => {
|
||||
if (
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
if (!hasTouch) return false;
|
||||
|
||||
// If matchMedia is available, check whether the primary pointer is fine
|
||||
// (mouse/trackpad). Touchscreen laptops report fine, real mobile devices don't.
|
||||
if (typeof window.matchMedia === "function") {
|
||||
return !window.matchMedia("(pointer: fine)").matches;
|
||||
}
|
||||
|
||||
// Fallback: touch present but no matchMedia — assume mobile
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the current domain is the production domain
|
||||
|
||||
Reference in New Issue
Block a user