diff --git a/frontend/__tests__/components/features/chat/slash-command-menu.test.tsx b/frontend/__tests__/components/features/chat/slash-command-menu.test.tsx new file mode 100644 index 0000000000..461a78735d --- /dev/null +++ b/frontend/__tests__/components/features/chat/slash-command-menu.test.tsx @@ -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( + , + ); + expect(container.innerHTML).toBe(""); + }); + + it("renders all items with slash commands as primary text", () => { + renderWithProviders( + , + ); + + 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( + , + ); + + 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( + , + ); + + const options = screen.getAllByRole("option"); + await user.click(options[1]); + + expect(onSelect).toHaveBeenCalledWith(defaultItems[1]); + }); + + it("displays skill descriptions", () => { + renderWithProviders( + , + ); + + // 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( + , + ); + + 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"); + }); +}); diff --git a/frontend/src/components/features/chat/components/chat-input-container.tsx b/frontend/src/components/features/chat/components/chat-input-container.tsx index e950ce785b..ef67069de5 100644 --- a/frontend/src/components/features/chat/components/chat-input-container.tsx +++ b/frontend/src/components/features/chat/components/chat-input-container.tsx @@ -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; @@ -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({ - + {/* Wrapper so the slash menu anchors just above the input row, + not above the entire (possibly resized) container */} +
+ {isSlashMenuOpen && onSlashSelect && ( + + )} + + +
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; +} + +function SlashCommandMenuItem({ + item, + isSelected, + onSelect, + ref, +}: SlashCommandMenuItemProps) { + const description = useMemo( + () => (item.skill.content ? getSkillDescription(item.skill.content) : null), + [item.skill.content], + ); + + return ( + + ); +} + +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 ( +
+
+ {t("CHAT_INTERFACE$COMMANDS")} +
+ {items.map((item, index) => ( + { + itemRefs.current[index] = el; + }} + /> + ))} +
+ ); +} diff --git a/frontend/src/components/features/chat/custom-chat-input.tsx b/frontend/src/components/features/chat/custom-chat-input.tsx index 624457b35b..5fd92fdcd6 100644 --- a/frontend/src/components/features/chat/custom-chat-input.tsx +++ b/frontend/src/components/features/chat/custom-chat-input.tsx @@ -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); + // 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} /> diff --git a/frontend/src/hooks/chat/use-slash-command.ts b/frontend/src/hooks/chat/use-slash-command.ts new file mode 100644 index 0000000000..dd1a50439e --- /dev/null +++ b/frontend/src/hooks/chat/use-slash-command.ts @@ -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, +) => { + 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 "/" 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, + }; +}; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index dd02d391d0..51bbd1ac0d 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index a39cc33dcc..98a1c0c580 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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分かかる場合があります)", diff --git a/frontend/src/mocks/conversation-handlers.ts b/frontend/src/mocks/conversation-handlers.ts index 1ec536fd92..7d23d0f728 100644 --- a/frontend/src/mocks/conversation-handlers.ts +++ b/frontend/src/mocks/conversation-handlers.ts @@ -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); + }), ]; diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 3c7e58f398..863c8bf9a0 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -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