diff --git a/surfsense_web/components/icons/providers/github.svg b/surfsense_web/components/icons/providers/github.svg new file mode 100644 index 000000000..7a51b8e0e --- /dev/null +++ b/surfsense_web/components/icons/providers/github.svg @@ -0,0 +1 @@ +Github \ No newline at end of file diff --git a/surfsense_web/components/icons/providers/index.ts b/surfsense_web/components/icons/providers/index.ts index 2afed7fa5..aefa2a053 100644 --- a/surfsense_web/components/icons/providers/index.ts +++ b/surfsense_web/components/icons/providers/index.ts @@ -10,6 +10,7 @@ export { default as DeepInfraIcon } from "./deepinfra.svg"; export { default as DeepSeekIcon } from "./deepseek.svg"; export { default as FireworksAiIcon } from "./fireworksai.svg"; export { default as GeminiIcon } from "./gemini.svg"; +export { default as GitHubModelsIcon } from "./github.svg"; export { default as GroqIcon } from "./groq.svg"; export { default as HuggingFaceIcon } from "./huggingface.svg"; export { default as MiniMaxIcon } from "./minimax.svg"; diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index 26937e18b..0b8708269 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -6,9 +6,12 @@ import { Bot, Check, ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, Edit3, - Eye, ImageIcon, + ScanEye, Layers, Plus, Search, @@ -69,6 +72,7 @@ const PROVIDER_NAMES: Record = { DEEPSEEK: "DeepSeek", MISTRAL: "Mistral", COHERE: "Cohere", + GITHUB_MODELS: "GitHub Models", GROQ: "Groq", OLLAMA: "Ollama", TOGETHER_AI: "Together AI", @@ -274,17 +278,40 @@ export function ModelSelector({ const [searchQuery, setSearchQuery] = useState(""); const [selectedProvider, setSelectedProvider] = useState("all"); const [focusedIndex, setFocusedIndex] = useState(-1); - const [showScrollIndicator, setShowScrollIndicator] = useState(true); + const [modelScrollPos, setModelScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const [sidebarScrollPos, setSidebarScrollPos] = useState<"top" | "middle" | "bottom">("top"); const providerSidebarRef = useRef(null); const modelListRef = useRef(null); const searchInputRef = useRef(null); const isMobile = useIsMobile(); + const handleModelListScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setModelScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + }, []); + + const handleSidebarScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + if (isMobile) { + const atStart = el.scrollLeft <= 2; + const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; + setSidebarScrollPos(atStart ? "top" : atEnd ? "bottom" : "middle"); + } else { + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setSidebarScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + } + }, [isMobile]); + // Reset search + provider when tab changes + // biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger useEffect(() => { setSelectedProvider("all"); setSearchQuery(""); setFocusedIndex(-1); + setModelScrollPos("top"); }, [activeTab]); // Reset on open @@ -295,8 +322,9 @@ export function ModelSelector({ } }, [open]); - // Cmd/Ctrl+M shortcut + // Cmd/Ctrl+M shortcut (desktop only) useEffect(() => { + if (isMobile) return; const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "m") { e.preventDefault(); @@ -305,9 +333,10 @@ export function ModelSelector({ }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, []); + }, [isMobile]); // Focus search input on open + // biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger to re-focus on tab switch useEffect(() => { if (open && !isMobile) { requestAnimationFrame(() => searchInputRef.current?.focus()); @@ -677,6 +706,7 @@ export function ModelSelector({ ); // ─── Keyboard navigation ─── + // biome-ignore lint/correctness/useExhaustiveDependencies: searchQuery and selectedProvider are intentional triggers to reset focus useEffect(() => { setFocusedIndex(-1); }, [searchQuery, selectedProvider]); @@ -767,24 +797,35 @@ export function ModelSelector({ return (
+ {!isMobile && sidebarScrollPos !== "top" && ( +
+ +
+ )} + {isMobile && sidebarScrollPos !== "top" && ( +
+ +
+ )}
{ - const t = e.currentTarget; - setShowScrollIndicator( - t.scrollHeight - t.scrollTop > - t.clientHeight + 10, - ); - }} + onScroll={handleSidebarScroll} className={cn( isMobile - ? "flex flex-row gap-0.5 px-2 py-1.5 overflow-x-auto border-b border-border/40" + ? "flex flex-row gap-0.5 px-1 py-1.5 overflow-x-auto [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar-track]:bg-transparent" : "flex flex-col gap-0.5 p-1 overflow-y-auto flex-1 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar-track]:bg-transparent", )} + style={isMobile ? { + maskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, + WebkitMaskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, + } : { + maskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, + WebkitMaskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`, + }} > {activeProviders.map((provider, idx) => { const isAll = provider === "all"; @@ -849,18 +890,23 @@ export function ModelSelector({ )} {isConfigured ? ` (${count})` - : " — not configured"} + : " (not configured)"} ); })}
- {!isMobile && showScrollIndicator && ( -
+ {!isMobile && sidebarScrollPos !== "bottom" && ( +
)} + {isMobile && sidebarScrollPos !== "bottom" && ( +
+ +
+ )}
); }; @@ -889,19 +935,26 @@ export function ModelSelector({ key={`${activeTab}-${item.isGlobal ? "g" : "u"}-${config.id}`} data-model-index={index} role="option" + tabIndex={isMobile ? -1 : 0} aria-selected={isSelected} onClick={() => handleSelectItem(item)} + onKeyDown={isMobile ? undefined : (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectItem(item); + } + }} onMouseEnter={() => setFocusedIndex(index)} className={cn( - "group flex items-start gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer", - "transition-all duration-150 mx-1", - "hover:bg-accent/40 active:scale-[0.99]", + "group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer", + "transition-all duration-150 mx-2", + "hover:bg-accent/40", isSelected && "bg-primary/6 dark:bg-primary/8", - isFocused && "bg-accent/50 ring-1 ring-primary/20", + isFocused && "bg-accent/50", )} > {/* Provider icon */} -
+
{getProviderIcon(config.provider as string, { isAutoMode, className: "size-5", @@ -931,8 +984,8 @@ export function ModelSelector({ {!isAutoMode && hasCitations && ( Citations @@ -981,7 +1034,7 @@ export function ModelSelector({ : "Add Vision Model"; return ( -
+
{/* Tab header */}
@@ -999,7 +1052,7 @@ export function ModelSelector({ }, { value: "vision" as const, - icon: Eye, + icon: ScanEye, label: "Vision", }, ] as const @@ -1028,7 +1081,7 @@ export function ModelSelector({ "flex", isMobile ? "flex-col h-[60vh]" - : "flex-row h-[420px]", + : "flex-row h-[380px]", )} > {/* Provider sidebar */} @@ -1037,33 +1090,30 @@ export function ModelSelector({ {/* Main content */}
{/* Search */} -
- +
+ setSearchQuery(e.target.value) } - onKeyDown={handleKeyDown} - autoFocus={!isMobile} + onKeyDown={isMobile ? undefined : handleKeyDown} role="combobox" aria-expanded={true} aria-controls="model-selector-list" className={cn( - "w-full pl-8 pr-3 py-1.5 text-xs rounded-lg", - "bg-secondary/30 border border-border/40", - "focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40", - "placeholder:text-muted-foreground/50", - "transition-[box-shadow,border-color] duration-200", + "w-full pl-8 pr-3 py-2.5 text-sm bg-transparent", + "focus:outline-none", + "placeholder:text-muted-foreground", )} />
{/* Provider header when filtered */} {selectedProvider !== "all" && ( -
+
{getProviderIcon(selectedProvider, { className: "size-4", })} @@ -1085,10 +1135,15 @@ export function ModelSelector({ id="model-selector-list" ref={modelListRef} role="listbox" - className="overflow-y-auto flex-1 py-1" + className="overflow-y-auto flex-1 py-1 space-y-1 flex flex-col" + onScroll={handleModelListScroll} + style={{ + maskImage: `linear-gradient(to bottom, ${modelScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${modelScrollPos === "bottom" ? "black" : "transparent"})`, + WebkitMaskImage: `linear-gradient(to bottom, ${modelScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${modelScrollPos === "bottom" ? "black" : "transparent"})`, + }} > {currentDisplayItems.length === 0 ? ( -
+
{selectedProvider !== "all" && !configuredProviderSet.has( selectedProvider, @@ -1116,22 +1171,21 @@ export function ModelSelector({

{addHandler && ( )} - ) : ( + ) : searchQuery ? ( <> - +

No models found

@@ -1140,13 +1194,22 @@ export function ModelSelector({ term

+ ) : ( + <> +

+ No models configured +

+

+ Configure models in your search space settings +

+ )}
) : ( <> {globalItems.length > 0 && ( <> -
+
Global Models
{globalItems.map((item, i) => @@ -1163,7 +1226,7 @@ export function ModelSelector({ )} {userItems.length > 0 && ( <> -
+
Your Configurations
{userItems.map((item, i) => @@ -1180,7 +1243,7 @@ export function ModelSelector({ {/* Add model button */} {addHandler && ( -
+