diff --git a/apps/desktop/src/components/main/body/ai.tsx b/apps/desktop/src/components/main/body/ai.tsx index d64189fd60..860b65d081 100644 --- a/apps/desktop/src/components/main/body/ai.tsx +++ b/apps/desktop/src/components/main/body/ai.tsx @@ -9,6 +9,7 @@ import { X, } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import type { ChatShortcut } from "@hypr/store"; import { Button } from "@hypr/ui/components/ui/button"; @@ -18,6 +19,7 @@ import { } from "@hypr/ui/components/ui/scroll-fade"; import { cn } from "@hypr/utils"; +import { useSettingsNavigation } from "../../../hooks/useSettingsNavigation"; import * as main from "../../../store/tinybase/store/main"; import { type Tab, useTabs } from "../../../store/zustand/tabs"; import { LLM } from "../../settings/ai/llm"; @@ -97,6 +99,46 @@ function AIView({ tab }: { tab: Extract }) { [updateAiTabState, tab], ); + const enabledMenuKeys: AITabKey[] = [ + "transcription", + "intelligence", + "templates", + "shortcuts", + ]; + const currentIndex = enabledMenuKeys.indexOf(activeTab); + + useHotkeys( + "ctrl+alt+left", + () => { + if (currentIndex > 0) { + setActiveTab(enabledMenuKeys[currentIndex - 1]); + } + }, + { + preventDefault: true, + enableOnFormTags: true, + enableOnContentEditable: true, + }, + [currentIndex, setActiveTab], + ); + + useHotkeys( + "ctrl+alt+right", + () => { + if (currentIndex >= 0 && currentIndex < enabledMenuKeys.length - 1) { + setActiveTab(enabledMenuKeys[currentIndex + 1]); + } + }, + { + preventDefault: true, + enableOnFormTags: true, + enableOnContentEditable: true, + }, + [currentIndex, setActiveTab], + ); + + useSettingsNavigation(ref, activeTab); + const menuItems: Array<{ key: AITabKey; label: string; diff --git a/apps/desktop/src/components/main/body/settings.tsx b/apps/desktop/src/components/main/body/settings.tsx index dbbc788849..7a4b963826 100644 --- a/apps/desktop/src/components/main/body/settings.tsx +++ b/apps/desktop/src/components/main/body/settings.tsx @@ -7,6 +7,7 @@ import { UserIcon, } from "lucide-react"; import { useCallback, useRef } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { Button } from "@hypr/ui/components/ui/button"; import { @@ -15,6 +16,7 @@ import { } from "@hypr/ui/components/ui/scroll-fade"; import { cn } from "@hypr/utils"; +import { useSettingsNavigation } from "../../../hooks/useSettingsNavigation"; import { type SettingsTab, type Tab, @@ -96,6 +98,40 @@ function SettingsView({ tab }: { tab: Extract }) { [updateSettingsTabState, tab], ); + const currentIndex = SECTIONS.findIndex((s) => s.id === activeTab); + + useHotkeys( + "ctrl+alt+left", + () => { + if (currentIndex > 0) { + setActiveTab(SECTIONS[currentIndex - 1].id); + } + }, + { + preventDefault: true, + enableOnFormTags: true, + enableOnContentEditable: true, + }, + [currentIndex, setActiveTab], + ); + + useHotkeys( + "ctrl+alt+right", + () => { + if (currentIndex < SECTIONS.length - 1) { + setActiveTab(SECTIONS[currentIndex + 1].id); + } + }, + { + preventDefault: true, + enableOnFormTags: true, + enableOnContentEditable: true, + }, + [currentIndex, setActiveTab], + ); + + useSettingsNavigation(ref, activeTab); + const renderContent = () => { switch (activeTab) { case "account": diff --git a/apps/desktop/src/components/settings/general/account.tsx b/apps/desktop/src/components/settings/general/account.tsx index a38aa69e41..eb77482129 100644 --- a/apps/desktop/src/components/settings/general/account.tsx +++ b/apps/desktop/src/components/settings/general/account.tsx @@ -422,7 +422,10 @@ function Container({ children?: ReactNode; }) { return ( -
+

{title}

{description && ( diff --git a/apps/desktop/src/components/settings/general/app-settings.tsx b/apps/desktop/src/components/settings/general/app-settings.tsx index 4079a6f048..47577386b8 100644 --- a/apps/desktop/src/components/settings/general/app-settings.tsx +++ b/apps/desktop/src/components/settings/general/app-settings.tsx @@ -65,12 +65,16 @@ function SettingRow({ onChange: (checked: boolean) => void; }) { return ( -
+

{title}

{description}

- +
); } diff --git a/apps/desktop/src/components/settings/general/main-language.tsx b/apps/desktop/src/components/settings/general/main-language.tsx index 29b86782f3..cd3bd7d576 100644 --- a/apps/desktop/src/components/settings/general/main-language.tsx +++ b/apps/desktop/src/components/settings/general/main-language.tsx @@ -44,7 +44,10 @@ export function MainLanguageView({ ); return ( -
+

Main language

diff --git a/apps/desktop/src/components/settings/general/notification.tsx b/apps/desktop/src/components/settings/general/notification.tsx index e89d3e52bb..b150ce3f72 100644 --- a/apps/desktop/src/components/settings/general/notification.tsx +++ b/apps/desktop/src/components/settings/general/notification.tsx @@ -237,7 +237,10 @@ export function NotificationSettingsView() {

{(field) => ( -
+

Event notifications

@@ -245,6 +248,7 @@ export function NotificationSettingsView() {

@@ -255,7 +259,10 @@ export function NotificationSettingsView() { {(field) => (
-
+

Microphone detection @@ -266,6 +273,7 @@ export function NotificationSettingsView() {

@@ -415,7 +423,10 @@ export function NotificationSettingsView() { {(field) => ( -
+

Respect Do-Not-Disturb mode @@ -426,6 +437,7 @@ export function NotificationSettingsView() {

+
@@ -80,6 +88,7 @@ function DownloadButtons() { return (
diff --git a/apps/desktop/src/hooks/useSettingsNavigation.ts b/apps/desktop/src/hooks/useSettingsNavigation.ts new file mode 100644 index 0000000000..e1fe06d11a --- /dev/null +++ b/apps/desktop/src/hooks/useSettingsNavigation.ts @@ -0,0 +1,168 @@ +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +const ITEM_SELECTOR = "[data-settings-item]"; +const ACTIVATE_SELECTOR = "[data-settings-activate]"; +const FALLBACK_ACTIVATE_SELECTOR = + 'button, [role="switch"], [role="combobox"], input, select, textarea, [tabindex="0"]'; + +function isEditableTarget(el: EventTarget | null): boolean { + if (!(el instanceof HTMLElement)) return false; + const tag = el.tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return true; + if (el.isContentEditable) return true; + return false; +} + +function hasOpenPopover(): boolean { + return ( + document.querySelector( + '[aria-expanded="true"], [data-state="open"][role="dialog"]', + ) !== null + ); +} + +function getItems(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(ITEM_SELECTOR)); +} + +function getActivator(item: HTMLElement): HTMLElement | null { + return ( + item.querySelector(ACTIVATE_SELECTOR) ?? + item.querySelector(FALLBACK_ACTIVATE_SELECTOR) + ); +} + +function setHighlight(container: HTMLElement, index: number) { + const items = getItems(container); + for (const item of items) { + item.removeAttribute("data-active"); + } + + if (index >= 0 && index < items.length) { + items[index].setAttribute("data-active", "true"); + items[index].scrollIntoView({ block: "nearest", behavior: "smooth" }); + } +} + +export function useSettingsNavigation( + scrollRef: RefObject, + panelKey: string, +) { + const [activeIndex, setActiveIndex] = useState(-1); + const activeIndexRef = useRef(activeIndex); + activeIndexRef.current = activeIndex; + + useEffect(() => { + setActiveIndex(-1); + const container = scrollRef.current; + if (container) { + setHighlight(container, -1); + } + }, [panelKey, scrollRef]); + + useEffect(() => { + const container = scrollRef.current; + if (container) { + setHighlight(container, activeIndex); + } + }, [activeIndex, scrollRef]); + + const navigate = useCallback( + (direction: "up" | "down") => { + const container = scrollRef.current; + if (!container) return; + if (isEditableTarget(document.activeElement)) return; + if (hasOpenPopover()) return; + + const items = getItems(container); + if (items.length === 0) return; + + const current = activeIndexRef.current; + + if (direction === "down") { + setActiveIndex(current < items.length - 1 ? current + 1 : current); + } else { + setActiveIndex(current > 0 ? current - 1 : 0); + } + }, + [scrollRef], + ); + + const activate = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + if (isEditableTarget(document.activeElement)) return; + if (hasOpenPopover()) return; + + const items = getItems(container); + const current = activeIndexRef.current; + if (current < 0 || current >= items.length) return; + + const activator = getActivator(items[current]); + if (activator) { + activator.click(); + } + }, [scrollRef]); + + useHotkeys( + "down", + (e) => { + if (isEditableTarget(document.activeElement) || hasOpenPopover()) return; + e.preventDefault(); + navigate("down"); + }, + { enableOnFormTags: false }, + [navigate], + ); + + useHotkeys( + "up", + (e) => { + if (isEditableTarget(document.activeElement) || hasOpenPopover()) return; + e.preventDefault(); + navigate("up"); + }, + { enableOnFormTags: false }, + [navigate], + ); + + useHotkeys( + "space, enter", + (e) => { + if (activeIndexRef.current < 0) return; + e.preventDefault(); + activate(); + }, + { enableOnFormTags: false }, + [activate], + ); + + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + + const handleClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const item = target.closest(ITEM_SELECTOR); + if (!item) return; + + const items = getItems(container); + const index = items.indexOf(item); + if (index >= 0) { + setActiveIndex(index); + } + }; + + container.addEventListener("click", handleClick); + return () => container.removeEventListener("click", handleClick); + }, [scrollRef, panelKey]); + + return { activeIndex }; +} diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index df24eb64a0..5b27b01ad1 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -205,3 +205,12 @@ border-radius: 2px; padding: 1px 0; } + +/* Settings keyboard navigation highlight */ +[data-settings-item][data-active="true"] { + background-color: rgb(245 245 245); + border-radius: 8px; + box-shadow: 0 0 0 2px rgb(163 163 163); + padding: 8px; + margin: -8px; +} diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts index ce2389cff6..b750a4ec95 100644 --- a/packages/store/src/zod.ts +++ b/packages/store/src/zod.ts @@ -258,7 +258,7 @@ export const generalSchema = z.object({ export const aiProviderSchema = z .object({ type: z.enum(["stt", "llm"]), - base_url: z.url().min(1), + base_url: z.string(), api_key: z.string(), }) .refine(