feat(frontend): display Agent Skills and Commands in slash menu (#12982)

Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
Dream
2026-02-27 08:46:38 -05:00
committed by GitHub
parent b8ab4bb44e
commit a29ed4d926
9 changed files with 801 additions and 25 deletions

View File

@@ -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("![alt text](image.png)")).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");
});
});

View File

@@ -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}

View File

@@ -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](url) → 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>
);
}

View File

@@ -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>

View 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,
};
};

View File

@@ -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",

View File

@@ -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分かかる場合があります",

View File

@@ -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);
}),
];

View File

@@ -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