feat(components): add McpConfigTabs component for MCP configuration tabs

This commit is contained in:
Fatih Kadir Akın
2025-12-16 20:44:09 +03:00
parent 884c614d7d
commit a5d80b85d9
7 changed files with 533 additions and 219 deletions

6
public/mcp-dark.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" fill="none">
<path d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177" stroke="white" stroke-width="12" stroke-linecap="round"/>
<path d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52" stroke="white" stroke-width="12" stroke-linecap="round"/>
<path d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822" stroke="white" stroke-width="12" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 869 B

View File

@@ -9,6 +9,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { McpConfigTabs } from "@/components/mcp/mcp-config-tabs";
export const metadata = {
title: "API Documentation - prompts.chat",
@@ -61,19 +62,11 @@ export default async function ApiDocsPage() {
Using with MCP Clients
</h2>
<p className="text-muted-foreground">
Add prompts.chat to your MCP client configuration:
Add prompts.chat to your MCP client configuration. Choose your client and connection type below:
</p>
<div className="bg-muted rounded-lg p-4 font-mono text-sm overflow-x-auto">
<pre>{`{
"mcpServers": {
"prompts-chat": {
"url": "${baseUrl}/api/mcp"
}
}
}`}</pre>
</div>
<p className="text-muted-foreground">
Compatible with Claude Desktop, Cursor, Windsurf, and other MCP-enabled tools.
<McpConfigTabs baseUrl={baseUrl} className="[&_button]:text-sm [&_button]:px-3 [&_button]:py-1.5 [&_pre]:text-sm [&_pre]:p-4" />
<p className="text-muted-foreground text-sm">
<strong>Remote</strong> connects directly to prompts.chat API. <strong>Local</strong> runs the MCP server locally via npx.
</p>
</section>

View File

@@ -7,6 +7,7 @@ import { InfinitePromptList } from "@/components/prompts/infinite-prompt-list";
import { PromptFilters } from "@/components/prompts/prompt-filters";
import { FilterProvider } from "@/components/prompts/filter-context";
import { HFDataStudioDropdown } from "@/components/prompts/hf-data-studio-dropdown";
import { McpServerPopup, McpIcon } from "@/components/mcp/mcp-server-popup";
import { db } from "@/lib/db";
import { isAISearchEnabled, semanticSearch } from "@/lib/ai/embeddings";
import { isAIGenerationEnabled } from "@/lib/ai/generation";
@@ -180,7 +181,10 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) {
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
{!config.homepage?.useCloneBranding && (
<HFDataStudioDropdown aiGenerationEnabled={aiGenerationAvailable} />
<div className="flex items-center gap-2">
<HFDataStudioDropdown aiGenerationEnabled={aiGenerationAvailable} />
<McpServerPopup />
</div>
)}
<Button size="sm" className="h-8 text-xs w-full sm:w-auto" asChild>
<Link href="/prompts/new">

View File

@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type Client = "cursor" | "claude-code" | "vscode" | "codex" | "windsurf" | "gemini";
type Mode = "remote" | "local";
interface McpConfigTabsProps {
baseUrl?: string;
/** URL parameters to append (e.g., users, categories, tags) */
queryParams?: string;
className?: string;
/** External mode control */
mode?: "remote" | "local";
onModeChange?: (mode: "remote" | "local") => void;
/** Hide the mode toggle (when controlled externally) */
hideModeToggle?: boolean;
}
const CLIENT_LABELS: Record<Client, string> = {
cursor: "Cursor",
"claude-code": "Claude",
vscode: "VS Code",
codex: "Codex",
windsurf: "Windsurf",
gemini: "Gemini",
};
const NPM_PACKAGE = "@fkadev/prompts.chat-mcp";
function getConfig(client: Client, mode: Mode, mcpUrl: string): string {
const packageName = NPM_PACKAGE;
switch (client) {
case "cursor":
if (mode === "remote") {
return JSON.stringify({
mcpServers: {
"prompts.chat": {
url: mcpUrl,
},
},
}, null, 2);
} else {
return JSON.stringify({
mcpServers: {
"prompts.chat": {
command: "npx",
args: ["-y", packageName],
},
},
}, null, 2);
}
case "claude-code":
if (mode === "remote") {
return `claude mcp add --transport http prompts.chat ${mcpUrl}`;
} else {
return `claude mcp add prompts.chat -- npx -y ${packageName}`;
}
case "vscode":
if (mode === "remote") {
return JSON.stringify({
mcp: {
servers: {
"prompts.chat": {
type: "http",
url: mcpUrl,
},
},
},
}, null, 2);
} else {
return JSON.stringify({
mcp: {
servers: {
"prompts.chat": {
type: "stdio",
command: "npx",
args: ["-y", packageName],
},
},
},
}, null, 2);
}
case "codex":
if (mode === "remote") {
return `[mcp_servers.prompts_chat]
url = "${mcpUrl}"`;
} else {
return `[mcp_servers.prompts_chat]
command = "npx"
args = ["-y", "${packageName}"]`;
}
case "windsurf":
if (mode === "remote") {
return JSON.stringify({
mcpServers: {
"prompts.chat": {
serverUrl: mcpUrl,
},
},
}, null, 2);
} else {
return JSON.stringify({
mcpServers: {
"prompts.chat": {
command: "npx",
args: ["-y", packageName],
},
},
}, null, 2);
}
case "gemini":
if (mode === "remote") {
return `gemini mcp add prompts.chat --transport sse ${mcpUrl}`;
} else {
return `gemini mcp add prompts.chat -- npx -y ${packageName}`;
}
default:
return "";
}
}
export function McpConfigTabs({ baseUrl, queryParams, className, mode, onModeChange, hideModeToggle }: McpConfigTabsProps) {
const [selectedClient, setSelectedClient] = useState<Client>("vscode");
const [internalMode, setInternalMode] = useState<Mode>("remote");
const [copied, setCopied] = useState(false);
const selectedMode = mode ?? internalMode;
const handleModeChange = (newMode: Mode) => {
if (onModeChange) {
onModeChange(newMode);
} else {
setInternalMode(newMode);
}
};
const base = baseUrl || (typeof window !== "undefined" ? window.location.origin : "https://prompts.chat");
const mcpUrl = queryParams ? `${base}/api/mcp?${queryParams}` : `${base}/api/mcp`;
const config = getConfig(selectedClient, selectedMode, mcpUrl);
const handleCopy = async () => {
await navigator.clipboard.writeText(config);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const clients: Client[] = ["vscode", "windsurf", "cursor", "claude-code", "codex", "gemini"];
return (
<div className={cn("space-y-2", className)}>
{/* Client Tabs */}
<div className="flex gap-0.5 overflow-x-auto">
{clients.map((client) => (
<button
key={client}
onClick={() => setSelectedClient(client)}
className={cn(
"px-2 py-1 text-[11px] font-medium rounded transition-colors whitespace-nowrap",
selectedClient === client
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{CLIENT_LABELS[client]}
</button>
))}
</div>
{/* Mode Toggle - only show if not hidden */}
{!hideModeToggle && (
<div className="flex gap-0.5">
<button
onClick={() => handleModeChange("remote")}
className={cn(
"px-2 py-1 text-[11px] font-medium rounded transition-colors",
selectedMode === "remote"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Remote
</button>
<button
onClick={() => handleModeChange("local")}
className={cn(
"px-2 py-1 text-[11px] font-medium rounded transition-colors",
selectedMode === "local"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Local
</button>
</div>
)}
{/* Config Display */}
<div className="relative">
<div dir="ltr" className="bg-muted rounded-md p-2 font-mono text-[11px] overflow-x-auto text-left">
<pre className="whitespace-pre">{config}</pre>
</div>
<Button
variant="ghost"
size="sm"
className="absolute top-1 right-1 h-6 w-6 p-0"
onClick={handleCopy}
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
);
}

View File

@@ -2,30 +2,36 @@
import { useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Check, Copy, Plus, X } from "lucide-react";
import { Plus, X, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { analyticsMcp } from "@/lib/analytics";
import { McpConfigTabs } from "./mcp-config-tabs";
// MCP Logo component
function McpIcon({ className }: { className?: string }) {
// MCP Logo component - shows dark version in dark mode
export function McpIcon({ className }: { className?: string }) {
return (
<img
src="/mcp.svg"
alt="MCP"
className={className}
/>
<>
<img
src="/mcp.svg"
alt="MCP"
className={cn(className, "dark:hidden")}
/>
<img
src="/mcp-dark.svg"
alt="MCP"
className={cn(className, "hidden dark:block")}
/>
</>
);
}
export { McpIcon };
interface McpServerPopupProps {
/** Pre-filled users (usernames) */
initialUsers?: string[];
@@ -44,7 +50,8 @@ export function McpServerPopup({
baseUrl,
}: McpServerPopupProps) {
const t = useTranslations("mcp");
const [copied, setCopied] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [mcpMode, setMcpMode] = useState<"remote" | "local">("remote");
const [users, setUsers] = useState<string[]>(initialUsers);
const [categories, setCategories] = useState<string[]>(initialCategories);
const [tags, setTags] = useState<string[]>(initialTags);
@@ -52,9 +59,8 @@ export function McpServerPopup({
const [categoryInput, setCategoryInput] = useState("");
const [tagInput, setTagInput] = useState("");
// Build the MCP URL
const mcpUrl = useMemo(() => {
const base = baseUrl || (typeof window !== "undefined" ? window.location.origin : "https://prompts.chat");
// Build query params for MCP URL
const queryParams = useMemo(() => {
const params = new URLSearchParams();
if (users.length > 0) {
@@ -67,31 +73,8 @@ export function McpServerPopup({
params.set("tags", tags.join(","));
}
const queryString = params.toString();
return `${base}/api/mcp${queryString ? `?${queryString}` : ""}`;
}, [baseUrl, users, categories, tags]);
// Generate the JSON config
const configJson = useMemo(() => {
return JSON.stringify(
{
mcpServers: {
"prompts-chat": {
url: mcpUrl,
},
},
},
null,
2
);
}, [mcpUrl]);
const handleCopy = async () => {
await navigator.clipboard.writeText(configJson);
analyticsMcp.copyCommand("config_json");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return params.toString();
}, [users, categories, tags]);
const addUser = () => {
const value = userInput.trim().replace(/^@/, "");
@@ -129,134 +112,148 @@ export function McpServerPopup({
<span className="hidden sm:inline">{t("button")}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[calc(100vw-2rem)] sm:w-[420px] p-4" sideOffset={8} collisionPadding={16}>
<div className="space-y-4">
{/* Header */}
<PopoverContent align="end" className="w-[calc(100vw-2rem)] sm:w-[480px] p-3" sideOffset={8} collisionPadding={16}>
<div className="space-y-2">
{/* Header with Mode Toggle */}
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm flex items-center gap-2">
<div className="flex items-center gap-2">
<McpIcon className="h-4 w-4" />
{t("title")}
</h3>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1"
onClick={handleCopy}
>
{copied ? (
<>
<Check className="h-3 w-3" />
{t("copied")}
</>
) : (
<>
<Copy className="h-3 w-3" />
{t("copy")}
</>
)}
</Button>
<h3 className="font-semibold text-sm">{t("title")}</h3>
</div>
<div className="flex gap-0.5">
<button
onClick={() => setMcpMode("remote")}
className={cn(
"px-2 py-1 text-[11px] font-medium rounded transition-colors",
mcpMode === "remote"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Remote
</button>
<button
onClick={() => setMcpMode("local")}
className={cn(
"px-2 py-1 text-[11px] font-medium rounded transition-colors",
mcpMode === "local"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Local
</button>
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground">
<p className="text-[11px] text-muted-foreground">
{t("description")}
</p>
{/* Config JSON */}
<div dir="ltr" className="bg-muted rounded-md p-3 font-mono text-xs overflow-x-auto text-left">
<pre className="whitespace-pre">{configJson}</pre>
</div>
{/* Config Tabs */}
<McpConfigTabs baseUrl={baseUrl} queryParams={queryParams || undefined} mode={mcpMode} hideModeToggle />
{/* Filters */}
<div className="space-y-3 border-t pt-3">
<p className="text-xs text-muted-foreground">{t("customizeFilters")}</p>
{/* Collapsible Filters */}
<div className="border-t pt-2">
<button
onClick={() => setFiltersOpen(!filtersOpen)}
className="flex items-center justify-between w-full text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
<span>{t("customizeFilters")}</span>
<ChevronDown className={cn("h-3 w-3 transition-transform", filtersOpen && "rotate-180")} />
</button>
{/* Users */}
<div className="space-y-1.5">
<label className="text-xs font-medium">{t("users")}</label>
<div className="flex gap-1.5">
<Input
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addUser()}
placeholder={t("userPlaceholder")}
className="h-7 text-xs flex-1"
/>
<Button size="sm" variant="outline" className="h-7 px-2" onClick={addUser}>
<Plus className="h-3 w-3" />
</Button>
</div>
{users.length > 0 && (
<div className="flex flex-wrap gap-1">
{users.map((user) => (
<Badge key={user} variant="secondary" className="text-xs gap-1 pr-1">
@{user}
<button onClick={() => removeUser(user)} className="hover:text-destructive">
<X className="h-3 w-3" />
</button>
</Badge>
))}
{filtersOpen && (
<div className="space-y-2 mt-2">
{/* Users */}
<div className="space-y-1">
<label className="text-[11px] font-medium">{t("users")}</label>
<div className="flex gap-1">
<Input
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addUser()}
placeholder={t("userPlaceholder")}
className="h-6 text-[11px] flex-1"
/>
<Button size="sm" variant="outline" className="h-6 px-1.5" onClick={addUser}>
<Plus className="h-3 w-3" />
</Button>
</div>
{users.length > 0 && (
<div className="flex flex-wrap gap-1">
{users.map((user) => (
<Badge key={user} variant="secondary" className="text-[10px] gap-0.5 pr-0.5 h-5">
@{user}
<button onClick={() => removeUser(user)} className="hover:text-destructive">
<X className="h-2.5 w-2.5" />
</button>
</Badge>
))}
</div>
)}
</div>
)}
</div>
{/* Categories */}
<div className="space-y-1.5">
<label className="text-xs font-medium">{t("categories")}</label>
<div className="flex gap-1.5">
<Input
value={categoryInput}
onChange={(e) => setCategoryInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addCategory()}
placeholder={t("categoryPlaceholder")}
className="h-7 text-xs flex-1"
/>
<Button size="sm" variant="outline" className="h-7 px-2" onClick={addCategory}>
<Plus className="h-3 w-3" />
</Button>
</div>
{categories.length > 0 && (
<div className="flex flex-wrap gap-1">
{categories.map((cat) => (
<Badge key={cat} variant="secondary" className="text-xs gap-1 pr-1">
{cat}
<button onClick={() => removeCategory(cat)} className="hover:text-destructive">
<X className="h-3 w-3" />
</button>
</Badge>
))}
{/* Categories */}
<div className="space-y-1">
<label className="text-[11px] font-medium">{t("categories")}</label>
<div className="flex gap-1">
<Input
value={categoryInput}
onChange={(e) => setCategoryInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addCategory()}
placeholder={t("categoryPlaceholder")}
className="h-6 text-[11px] flex-1"
/>
<Button size="sm" variant="outline" className="h-6 px-1.5" onClick={addCategory}>
<Plus className="h-3 w-3" />
</Button>
</div>
{categories.length > 0 && (
<div className="flex flex-wrap gap-1">
{categories.map((cat) => (
<Badge key={cat} variant="secondary" className="text-[10px] gap-0.5 pr-0.5 h-5">
{cat}
<button onClick={() => removeCategory(cat)} className="hover:text-destructive">
<X className="h-2.5 w-2.5" />
</button>
</Badge>
))}
</div>
)}
</div>
)}
</div>
{/* Tags */}
<div className="space-y-1.5">
<label className="text-xs font-medium">{t("tags")}</label>
<div className="flex gap-1.5">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTag()}
placeholder={t("tagPlaceholder")}
className="h-7 text-xs flex-1"
/>
<Button size="sm" variant="outline" className="h-7 px-2" onClick={addTag}>
<Plus className="h-3 w-3" />
</Button>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs gap-1 pr-1">
{tag}
<button onClick={() => removeTag(tag)} className="hover:text-destructive">
<X className="h-3 w-3" />
</button>
</Badge>
))}
{/* Tags */}
<div className="space-y-1">
<label className="text-[11px] font-medium">{t("tags")}</label>
<div className="flex gap-1">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTag()}
placeholder={t("tagPlaceholder")}
className="h-6 text-[11px] flex-1"
/>
<Button size="sm" variant="outline" className="h-6 px-1.5" onClick={addTag}>
<Plus className="h-3 w-3" />
</Button>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] gap-0.5 pr-0.5 h-5">
{tag}
<button onClick={() => removeTag(tag)} className="hover:text-destructive">
<X className="h-2.5 w-2.5" />
</button>
</Badge>
))}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
</PopoverContent>

View File

@@ -15,7 +15,7 @@ import {
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { X, Sparkles, Search } from "lucide-react";
import { X, Sparkles, Search, SlidersHorizontal } from "lucide-react";
import { analyticsSearch } from "@/lib/analytics";
interface PromptFiltersProps {
@@ -49,6 +49,7 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
const searchParams = useSearchParams();
const t = useTranslations();
const [tagSearch, setTagSearch] = useState("");
const [showFilters, setShowFilters] = useState(false);
const { setFilterPending } = useFilterContext();
const debounceRef = useRef<NodeJS.Timeout | null>(null);
@@ -76,64 +77,140 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
const hasFilters = currentFilters.q || currentFilters.type || currentFilters.category || currentFilters.tag || currentFilters.sort;
const activeFilterCount = [currentFilters.type, currentFilters.category, currentFilters.tag, currentFilters.sort && currentFilters.sort !== "newest"].filter(Boolean).length;
return (
<div className="space-y-4 p-4 border rounded-lg text-sm">
<div className="flex items-center justify-between h-6">
<span className="font-medium text-xs uppercase text-muted-foreground">{t("search.filters")}</span>
<Button
variant="ghost"
size="sm"
className={`h-6 text-xs px-2 ${hasFilters ? "visible" : "invisible"}`}
onClick={clearFilters}
>
<X className="h-3 w-3 mr-1" />{t("search.clear")}
</Button>
<div className="space-y-4 p-0 pt-4 border-t lg:pt-4 lg:p-4 lg:border lg:rounded-lg text-sm">
{/* Mobile: Compact search with filter toggle */}
<div className="lg:hidden space-y-3">
{/* Search bar with AI toggle and filter toggle */}
<div className="flex gap-1.5 items-center">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("search.placeholder")}
className="h-8 text-xs pl-8"
defaultValue={currentFilters.q}
onChange={(e) => {
const value = e.target.value;
setFilterPending(true);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
if (value) {
analyticsSearch.search(value, currentFilters.ai === "1");
}
updateFilter("q", value || null);
}, 300);
}}
/>
</div>
{aiSearchEnabled && (
<div className="flex items-center gap-1 shrink-0">
<Sparkles className="h-3 w-3 text-primary" />
<Switch
id="ai-search-mobile"
checked={currentFilters.ai === "1"}
onCheckedChange={(checked) => {
analyticsSearch.aiSearchToggle(checked);
updateFilter("ai", checked ? "1" : null);
}}
/>
</div>
)}
<Button
variant="outline"
size="sm"
className="h-8 px-2 relative shrink-0"
onClick={() => setShowFilters(!showFilters)}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
{activeFilterCount > 0 && (
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-primary text-[10px] text-primary-foreground flex items-center justify-center">
{activeFilterCount}
</span>
)}
</Button>
</div>
{/* Collapsible filters */}
{showFilters && (
<div className="space-y-4 pt-2 border-t">
<div className="flex items-center justify-between">
<span className="font-medium text-xs uppercase text-muted-foreground">{t("search.filters")}</span>
<Button
variant="ghost"
size="sm"
className={`h-6 text-xs px-2 ${hasFilters ? "visible" : "invisible"}`}
onClick={clearFilters}
>
<X className="h-3 w-3 mr-1" />{t("search.clear")}
</Button>
</div>
{/* Mobile filters content rendered below */}
</div>
)}
</div>
{/* Search */}
<div className="space-y-1.5">
<Label className="text-xs">{t("search.search")}</Label>
<Input
placeholder={t("search.placeholder")}
className="h-8 text-sm"
defaultValue={currentFilters.q}
onChange={(e) => {
const value = e.target.value;
// Show loading immediately
setFilterPending(true);
// Clear previous debounce
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
// Debounce the actual navigation
debounceRef.current = setTimeout(() => {
if (value) {
analyticsSearch.search(value, currentFilters.ai === "1");
{/* Desktop: Full filters */}
<div className="hidden lg:block space-y-4">
<div className="flex items-center justify-between h-6">
<span className="font-medium text-xs uppercase text-muted-foreground">{t("search.filters")}</span>
<Button
variant="ghost"
size="sm"
className={`h-6 text-xs px-2 ${hasFilters ? "visible" : "invisible"}`}
onClick={clearFilters}
>
<X className="h-3 w-3 mr-1" />{t("search.clear")}
</Button>
</div>
{/* Search */}
<div className="space-y-1.5">
<Label className="text-xs">{t("search.search")}</Label>
<Input
placeholder={t("search.placeholder")}
className="h-8 text-sm"
defaultValue={currentFilters.q}
onChange={(e) => {
const value = e.target.value;
setFilterPending(true);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
updateFilter("q", value || null);
}, 300);
}}
/>
</div>
{/* AI Search Toggle */}
{aiSearchEnabled && (
<div className="flex items-center justify-between py-1">
<Label className="text-xs flex items-center gap-1.5 cursor-pointer" htmlFor="ai-search">
<Sparkles className="h-3 w-3 text-primary" />
{t("search.aiSearch")}
</Label>
<Switch
id="ai-search"
checked={currentFilters.ai === "1"}
onCheckedChange={(checked) => {
analyticsSearch.aiSearchToggle(checked);
updateFilter("ai", checked ? "1" : null);
debounceRef.current = setTimeout(() => {
if (value) {
analyticsSearch.search(value, currentFilters.ai === "1");
}
updateFilter("q", value || null);
}, 300);
}}
/>
</div>
)}
{/* AI Search Toggle */}
{aiSearchEnabled && (
<div className="flex items-center justify-between py-1">
<Label className="text-xs flex items-center gap-1.5 cursor-pointer" htmlFor="ai-search">
<Sparkles className="h-3 w-3 text-primary" />
{t("search.aiSearch")}
</Label>
<Switch
id="ai-search"
checked={currentFilters.ai === "1"}
onCheckedChange={(checked) => {
analyticsSearch.aiSearchToggle(checked);
updateFilter("ai", checked ? "1" : null);
}}
/>
</div>
)}
</div>
{/* Shared filters - shown on desktop always, on mobile when showFilters is true */}
<div className={`space-y-4 ${showFilters ? "block" : "hidden"} lg:block`}>
{/* Type filter */}
<div className="space-y-1.5">
<Label className="text-xs">{t("prompts.promptType")}</Label>
@@ -259,6 +336,7 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -351,7 +351,9 @@ function createServer(options: ServerOptions = {}) {
}
try {
const result = await extra.sendRequest(
// Add timeout to prevent hanging if client doesn't support elicitation
const timeoutMs = 10000; // 10 seconds
const elicitationPromise = extra.sendRequest(
{
method: "elicitation/create",
params: {
@@ -367,6 +369,12 @@ function createServer(options: ServerOptions = {}) {
ElicitResultSchema
);
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Elicitation timeout")), timeoutMs)
);
const result = await Promise.race([elicitationPromise, timeoutPromise]);
if (result.action === "accept" && result.content) {
let filledContent = prompt.content;
for (const [key, value] of Object.entries(result.content)) {