diff --git a/.github/pr-media/site-tour/compass.png b/.github/pr-media/site-tour/compass.png new file mode 100644 index 00000000..d276dc97 Binary files /dev/null and b/.github/pr-media/site-tour/compass.png differ diff --git a/.github/pr-media/site-tour/eval-card-api_evaluations.png b/.github/pr-media/site-tour/eval-card-api_evaluations.png new file mode 100644 index 00000000..3a1bdef2 Binary files /dev/null and b/.github/pr-media/site-tour/eval-card-api_evaluations.png differ diff --git a/.github/pr-media/site-tour/experiments_ndeltas.png b/.github/pr-media/site-tour/experiments_ndeltas.png new file mode 100644 index 00000000..46174553 Binary files /dev/null and b/.github/pr-media/site-tour/experiments_ndeltas.png differ diff --git a/.github/pr-media/site-tour/experiments_redlines.png b/.github/pr-media/site-tour/experiments_redlines.png new file mode 100644 index 00000000..51de8341 Binary files /dev/null and b/.github/pr-media/site-tour/experiments_redlines.png differ diff --git a/.github/pr-media/site-tour/model-404.png b/.github/pr-media/site-tour/model-404.png new file mode 100644 index 00000000..990732a2 Binary files /dev/null and b/.github/pr-media/site-tour/model-404.png differ diff --git a/.github/pr-media/site-tour/old-ndeltas-404.png b/.github/pr-media/site-tour/old-ndeltas-404.png new file mode 100644 index 00000000..3aa8d45b Binary files /dev/null and b/.github/pr-media/site-tour/old-ndeltas-404.png differ diff --git a/.github/pr-media/site-tour/old-redlines-404.png b/.github/pr-media/site-tour/old-redlines-404.png new file mode 100644 index 00000000..3aa8d45b Binary files /dev/null and b/.github/pr-media/site-tour/old-redlines-404.png differ diff --git a/.github/pr-media/site-tour/pairs.png b/.github/pr-media/site-tour/pairs.png new file mode 100644 index 00000000..860c57f7 Binary files /dev/null and b/.github/pr-media/site-tour/pairs.png differ diff --git a/.github/pr-media/site-tour/regressions.png b/.github/pr-media/site-tour/regressions.png new file mode 100644 index 00000000..ea007931 Binary files /dev/null and b/.github/pr-media/site-tour/regressions.png differ diff --git a/.github/pr-media/site-tour/story.png b/.github/pr-media/site-tour/story.png new file mode 100644 index 00000000..932cf43c Binary files /dev/null and b/.github/pr-media/site-tour/story.png differ diff --git a/.github/pr-media/site-tour/v2.png b/.github/pr-media/site-tour/v2.png new file mode 100644 index 00000000..bc8f70bc Binary files /dev/null and b/.github/pr-media/site-tour/v2.png differ diff --git a/.github/pr-media/site-tour/vibes.png b/.github/pr-media/site-tour/vibes.png new file mode 100644 index 00000000..b1bf0b08 Binary files /dev/null and b/.github/pr-media/site-tour/vibes.png differ diff --git a/.github/pr-media/site-tour/what-is-an-eval.png b/.github/pr-media/site-tour/what-is-an-eval.png new file mode 100644 index 00000000..5caf4ed5 Binary files /dev/null and b/.github/pr-media/site-tour/what-is-an-eval.png differ diff --git a/.github/pr-media/site-tour/workshop.png b/.github/pr-media/site-tour/workshop.png new file mode 100644 index 00000000..ae01a3a5 Binary files /dev/null and b/.github/pr-media/site-tour/workshop.png differ diff --git a/package.json b/package.json index e8553287..6eaead1a 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,6 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report", "pr:screenshots": "node scripts/pr-screenshots.mjs", - "test:personality": "tsx src/cli/personality-test.ts", - "compare:personality": "tsx src/cli/compare-personality.ts", - "test:preference": "tsx src/cli/preference-test.ts", - "test:self-preference": "tsx src/cli/self-preference-test.ts", "cli": "tsx --no-cache -r ./src/cli/env-loader.js src/cli/index.ts", "cli:generate-pain-points": "pnpm cli generate-pain-points", "cli:annotate-pain-points": "pnpm cli annotate-pain-points", diff --git a/src/app/analysis/[configId]/[runLabel]/[timestamp]/ClientPage.tsx b/src/app/analysis/[configId]/[runLabel]/[timestamp]/ClientPage.tsx index 3042f479..3711c89a 100644 --- a/src/app/analysis/[configId]/[runLabel]/[timestamp]/ClientPage.tsx +++ b/src/app/analysis/[configId]/[runLabel]/[timestamp]/ClientPage.tsx @@ -25,7 +25,7 @@ import { BLUEPRINT_CONFIG_REPO_URL } from '@/lib/configConstants'; import { SinglePromptView } from '@/app/analysis/components/SinglePromptView'; import { AggregateAnalysisView } from '@/app/analysis/components/AggregateAnalysisView'; import { useToast } from '@/components/ui/use-toast'; -import { generateMinimalBlueprintYaml } from '@/app/sandbox/utils/yaml-generator'; +import { generateMinimalBlueprintYaml } from '@/lib/yaml-generator'; import { useAnalysis } from '@/app/analysis/context/AnalysisContext'; import AnalysisPageHeader from '@/app/analysis/components/AnalysisPageHeader'; import { fromSafeTimestamp, formatTimestampForDisplay } from '@/lib/timestampUtils'; diff --git a/src/app/api/sandbox/auto-create/route.ts b/src/app/api/sandbox/auto-create/route.ts index 5f020cad..28b93254 100644 --- a/src/app/api/sandbox/auto-create/route.ts +++ b/src/app/api/sandbox/auto-create/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { LLMApiCallOptions } from '@/lib/llm-clients/types'; import { getModelResponse } from '@/cli/services/llm-service'; import { checkForErrors } from '@/cli/utils/response-utils'; -import { parseWevalConfigFromResponse } from '@/app/sandbox/utils/json-response-parser'; +import { parseWevalConfigFromResponse } from '@/lib/json-response-parser'; import { fromZodError } from 'zod-validation-error'; import { z } from 'zod'; import { diff --git a/src/app/api/sandbox/auto-extend/route.ts b/src/app/api/sandbox/auto-extend/route.ts index 25ee8d33..9567c784 100644 --- a/src/app/api/sandbox/auto-extend/route.ts +++ b/src/app/api/sandbox/auto-extend/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getModelResponse } from '@/cli/services/llm-service'; import { checkForErrors } from '@/cli/utils/response-utils'; -import { parsePromptsFromResponse } from '@/app/sandbox/utils/json-response-parser'; -import { generateMinimalBlueprintYaml } from '@/app/sandbox/utils/yaml-generator'; +import { parsePromptsFromResponse } from '@/lib/json-response-parser'; +import { generateMinimalBlueprintYaml } from '@/lib/yaml-generator'; import { WevalConfig } from '@/types/shared'; import { z } from 'zod'; import { diff --git a/src/app/api/sandbox/auto-wiki/route.ts b/src/app/api/sandbox/auto-wiki/route.ts index 29ae339f..5dd80aad 100644 --- a/src/app/api/sandbox/auto-wiki/route.ts +++ b/src/app/api/sandbox/auto-wiki/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { getModelResponse } from '@/cli/services/llm-service'; import { checkForErrors } from '@/cli/utils/response-utils'; -import { parseWevalConfigFromResponse } from '@/app/sandbox/utils/json-response-parser'; +import { parseWevalConfigFromResponse } from '@/lib/json-response-parser'; import { fromZodError } from 'zod-validation-error'; import { EXPERT_PREAMBLE, diff --git a/src/app/api/story/__tests__/json-response-parser.test.ts b/src/app/api/story/__tests__/json-response-parser.test.ts index 4b3e5882..4cc1b876 100644 --- a/src/app/api/story/__tests__/json-response-parser.test.ts +++ b/src/app/api/story/__tests__/json-response-parser.test.ts @@ -1,5 +1,5 @@ import { vi } from 'vitest'; -import { parseWevalConfigFromResponse } from '@/app/sandbox/utils/json-response-parser'; +import { parseWevalConfigFromResponse } from '@/lib/json-response-parser'; // Mock the LLM service vi.mock('@/cli/services/llm-service', () => ({ @@ -10,7 +10,7 @@ vi.mock('@/cli/utils/response-utils', () => ({ checkForErrors: vi.fn(() => false) })); -vi.mock('@/app/sandbox/utils/yaml-generator', () => ({ +vi.mock('@/lib/yaml-generator', () => ({ generateMinimalBlueprintYaml: vi.fn((config) => `title: "${config.title}"\ndescription: "${config.description}"`) })); diff --git a/src/app/api/story/create/route.ts b/src/app/api/story/create/route.ts index e990f024..57c93bd2 100644 --- a/src/app/api/story/create/route.ts +++ b/src/app/api/story/create/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { configure } from '@/cli/config'; import { getLogger } from '@/utils/logger'; import { CREATOR_SYSTEM_PROMPT } from '../utils/prompt-constants'; -import { parseWevalConfigFromResponse } from '@/app/sandbox/utils/json-response-parser'; +import { parseWevalConfigFromResponse } from '@/lib/json-response-parser'; import { resilientLLMCall, validateStoryResponse } from '../utils/llm-resilience'; import { z } from 'zod'; diff --git a/src/app/api/story/export/__tests__/route.test.ts b/src/app/api/story/export/__tests__/route.test.ts index e62c49fe..efc0b900 100644 --- a/src/app/api/story/export/__tests__/route.test.ts +++ b/src/app/api/story/export/__tests__/route.test.ts @@ -15,7 +15,7 @@ vi.mock('@/utils/logger', () => ({ error: vi.fn(), })), })); -vi.mock('@/app/sandbox/utils/yaml-generator', () => ({ +vi.mock('@/lib/yaml-generator', () => ({ generateMinimalBlueprintYaml: vi.fn((obj) => `# Generated YAML\ntitle: ${obj.title || 'Test'}`), })); diff --git a/src/app/api/story/export/route.ts b/src/app/api/story/export/route.ts index 68eb9a03..b00429ca 100644 --- a/src/app/api/story/export/route.ts +++ b/src/app/api/story/export/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { saveJsonFile, getJsonFile } from '@/lib/storageService'; -import { generateMinimalBlueprintYaml } from '@/app/sandbox/utils/yaml-generator'; +import { generateMinimalBlueprintYaml } from '@/lib/yaml-generator'; import { getLogger } from '@/utils/logger'; import { z } from 'zod'; import type { ComparisonConfig } from '@/cli/types/cli_types'; diff --git a/src/app/api/story/update/route.ts b/src/app/api/story/update/route.ts index a27418e7..9909f51e 100644 --- a/src/app/api/story/update/route.ts +++ b/src/app/api/story/update/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { configure } from '@/cli/config'; import { getLogger } from '@/utils/logger'; import { UPDATER_SYSTEM_PROMPT } from '../utils/prompt-constants'; -import { parseWevalConfigFromResponse } from '@/app/sandbox/utils/json-response-parser'; +import { parseWevalConfigFromResponse } from '@/lib/json-response-parser'; import { resilientLLMCall, validateStoryResponse } from '../utils/llm-resilience'; import { z } from 'zod'; diff --git a/src/app/api/webhooks/github-pr/route.ts b/src/app/api/webhooks/github-pr/route.ts index c4146876..5a5d6670 100644 --- a/src/app/api/webhooks/github-pr/route.ts +++ b/src/app/api/webhooks/github-pr/route.ts @@ -6,7 +6,11 @@ import { callBackgroundFunction } from '@/lib/background-function-client'; import { prEvaluationLimiter, webhookIPLimiter, webhookGlobalLimiter } from '@/lib/webhook-rate-limiter'; import { checkPREvalLimits, formatLimitViolations, PR_EVAL_LIMITS } from '@/lib/pr-eval-limiter'; import { validateReservedPrefixes } from '@/lib/blueprint-parser'; +import { validateBlueprintSchema } from '@/lib/blueprint-validator'; +import { formatSchemaErrors } from '@/lib/blueprint-ingestion'; import { generateBlueprintIdFromPath, validateBlueprintId } from '@/app/utils/blueprintIdUtils'; +import type { ComparisonConfig } from '@/cli/types/cli_types'; +import type { ErrorObject } from 'ajv'; const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET; const UPSTREAM_OWNER = 'weval-org'; @@ -432,8 +436,25 @@ export async function POST(req: NextRequest) { // Parse blueprint for limit checking using the proper parser // This handles all multi-document formats correctly - const { parseAndNormalizeBlueprint } = await import('@/lib/blueprint-parser'); - const config = parseAndNormalizeBlueprint(content, 'yaml'); + let config: ComparisonConfig; + try { + const { parseAndNormalizeBlueprint } = await import('@/lib/blueprint-parser'); + config = parseAndNormalizeBlueprint(content, 'yaml'); + } catch (parseError: any) { + validationErrors.push({ filename: file.filename, error: `Parse error: ${parseError.message}` }); + continue; + } + + // Fail fast on canonical-schema violations so contributors get + // actionable feedback at PR time instead of a burned staging run. + const schemaCheck = validateBlueprintSchema(config, 'canonical'); + if (!schemaCheck.valid) { + validationErrors.push({ + filename: file.filename, + error: `Schema validation failed: ${formatSchemaErrors(schemaCheck.errors as ErrorObject[] | null)}`, + }); + continue; + } // Check PR evaluation limits // Note: checkPREvalLimits doesn't need auth for public model collections diff --git a/src/app/api/webhooks/github-push/route.ts b/src/app/api/webhooks/github-push/route.ts index 4e39b8dc..ded7d28c 100644 --- a/src/app/api/webhooks/github-push/route.ts +++ b/src/app/api/webhooks/github-push/route.ts @@ -4,6 +4,9 @@ import crypto from 'crypto'; import { callBackgroundFunction } from '@/lib/background-function-client'; import { webhookIPLimiter, webhookGlobalLimiter } from '@/lib/webhook-rate-limiter'; import { generateConfigContentHash } from '@/lib/hash-utils'; +import { parseSubmittedBlueprint } from '@/lib/blueprint-ingestion'; +import { resolveModelsInConfig } from '@/lib/blueprint-service'; +import { ComparisonConfig } from '@/cli/types/cli_types'; import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3'; const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET; @@ -155,35 +158,6 @@ async function hasBeenEvaluated(configId: string, contentHash: string): Promise< } } -/** - * Parse and validate blueprint - */ -async function parseBlueprint(content: string): Promise { - try { - const yaml = await import('js-yaml'); - const parsed = yaml.load(content); - - if (!parsed || typeof parsed !== 'object') { - return null; - } - - const config = parsed as any; - - if (!config.id || typeof config.id !== 'string') { - return null; - } - - if (!config.prompts || !Array.isArray(config.prompts) || config.prompts.length === 0) { - return null; - } - - return config; - } catch (error: any) { - console.error('[GitHub Push Webhook] Parse error:', error.message); - return null; - } -} - /** * Trigger production evaluation */ @@ -334,20 +308,40 @@ export async function POST(req: NextRequest) { continue; } - // Parse blueprint - const config = await parseBlueprint(content); - if (!config) { - console.error(`[GitHub Push Webhook] Invalid blueprint: ${file.filename}`); - results.errors.push(`${file.filename}: Invalid blueprint structure`); + // Parse with the canonical parser (multi-document YAML, path-derived ID, + // canonical schema validation) so merged blueprints behave identically + // to the PR-staging and scheduled-eval ingestion paths. + const parsed = parseSubmittedBlueprint(content, file.filename); + for (const warning of parsed.warnings) { + console.warn(`[GitHub Push Webhook] ${file.filename}: ${warning}`); + } + if (!parsed.config) { + console.error(`[GitHub Push Webhook] Invalid blueprint ${file.filename}: ${parsed.error}`); + results.errors.push(`${file.filename}: ${parsed.error}`); continue; } - // Calculate content hash - const modelIds = config.models?.map((m: any) => typeof m === 'string' ? m : m.id) || []; - const contentHash = generateConfigContentHash({ ...config, models: modelIds }); + // Resolve model collections (e.g. CORE) before hashing so the dedupe + // hash matches the scheduled-eval path, which hashes the resolved config. + let config: ComparisonConfig; + try { + config = await resolveModelsInConfig(parsed.config, GITHUB_TOKEN); + } catch (error: any) { + results.errors.push(`${file.filename}: Failed to resolve model collections: ${error.message}`); + continue; + } + if (!config.models || config.models.length === 0) { + results.errors.push(`${file.filename}: No models remain after collection resolution`); + continue; + } + + const contentHash = generateConfigContentHash(config); + + // parseSubmittedBlueprint always sets a path-derived id + const configId = config.id!; // Check if already evaluated - const alreadyEvaluated = await hasBeenEvaluated(config.id, contentHash); + const alreadyEvaluated = await hasBeenEvaluated(configId, contentHash); if (alreadyEvaluated) { console.log(`[GitHub Push Webhook] Blueprint ${config.id} already evaluated (hash: ${contentHash})`); diff --git a/src/app/components/NavBarV2.tsx b/src/app/components/NavBarV2.tsx deleted file mode 100644 index a0e48e1d..00000000 --- a/src/app/components/NavBarV2.tsx +++ /dev/null @@ -1,488 +0,0 @@ -'use client'; - -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import CIPLogo from '@/components/icons/CIPLogo'; -import Icon from '@/components/ui/icon'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import Fuse from 'fuse.js'; -import { AutocompleteEntry } from '@/cli/types/cli_types'; -import { cn } from '@/lib/utils'; - -const NAV_LINKS = [ - { href: '/', label: 'Home' }, - { href: '#', label: 'Our Methodology', disabled: true }, - { href: '#', label: 'About Weval', disabled: true }, -]; - -const ACTION_LINKS = [ - { href: '#', label: 'Suggest Eval', disabled: true }, - { href: '/sandbox', label: 'Create Eval', disabled: false, primary: true }, -]; - -export function NavBarV2() { - const router = useRouter(); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [searchFocused, setSearchFocused] = useState(false); - const [query, setQuery] = useState(''); - const [suggestions, setSuggestions] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(-1); - const [fuse, setFuse] = useState | null>(null); - const [isLoading, setIsLoading] = useState(true); - const inputRef = useRef(null); - const containerRef = useRef(null); - const listRef = useRef(null); - - // Load autocomplete index - useEffect(() => { - async function loadIndex() { - try { - const response = await fetch('/api/autocomplete'); - if (response.ok) { - const data = await response.json(); - setFuse(new Fuse(data, { - keys: [ - { name: 'title', weight: 2 }, - { name: 'keywords', weight: 1.5 }, - { name: 'tags', weight: 1.2 }, - { name: 'snippet', weight: 0.8 }, - { name: 'domain', weight: 1 }, - ], - threshold: 0.4, - includeScore: true, - minMatchCharLength: 2, - ignoreLocation: true, - })); - } - } catch (error) { - console.error('Failed to load autocomplete index:', error); - } finally { - setIsLoading(false); - } - } - loadIndex(); - }, []); - - // Update suggestions - useEffect(() => { - if (!fuse || query.length < 2) { - setSuggestions([]); - return; - } - const results = fuse.search(query); - setSuggestions(results.slice(0, 5).map(r => r.item)); - setSelectedIndex(-1); - }, [query, fuse]); - - // Close on click outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setSearchFocused(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // Escape key handling - useEffect(() => { - function handleEscape(event: KeyboardEvent) { - if (event.key === 'Escape') { - setSearchFocused(false); - setMobileMenuOpen(false); - inputRef.current?.blur(); - } - } - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, []); - - // Scroll selected into view - useEffect(() => { - if (selectedIndex >= 0 && listRef.current) { - const el = listRef.current.children[selectedIndex] as HTMLElement; - el?.scrollIntoView({ block: 'nearest' }); - } - }, [selectedIndex]); - - const handleSelect = useCallback((entry: AutocompleteEntry) => { - setQuery(''); - setSearchFocused(false); - router.push(`/analysis/${entry.configId}`); - }, [router]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - const showDropdown = searchFocused && (suggestions.length > 0 || query.length > 2); - - if (!showDropdown) { - if (e.key === 'Enter' && query.length > 2) { - router.push(`/all?q=${encodeURIComponent(query)}`); - setSearchFocused(false); - } - return; - } - - const totalOptions = suggestions.length + (query.length > 2 ? 1 : 0); - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setSelectedIndex(prev => prev < totalOptions - 1 ? prev + 1 : prev); - break; - case 'ArrowUp': - e.preventDefault(); - setSelectedIndex(prev => prev > 0 ? prev - 1 : -1); - break; - case 'Enter': - e.preventDefault(); - if (selectedIndex >= 0 && selectedIndex < suggestions.length) { - handleSelect(suggestions[selectedIndex]); - } else if (query.length > 2) { - router.push(`/all?q=${encodeURIComponent(query)}`); - setSearchFocused(false); - } - break; - } - }; - - const showDropdown = searchFocused && (suggestions.length > 0 || (query.length > 2 && !isLoading)); - const listboxId = 'navbar-search-listbox'; - const getOptionId = (idx: number) => `navbar-search-option-${idx}`; - - return ( - <> - {/* Mobile search overlay */} - {searchFocused && ( -
-
-
- - Search evaluations -
-
-
- -
- = 0 ? getOptionId(selectedIndex) : undefined} - placeholder="Search..." - value={query} - onChange={(e) => setQuery(e.target.value)} - onKeyDown={handleKeyDown} - className="w-full pl-11 pr-4 py-3 text-base rounded-lg bg-white dark:bg-card" - autoFocus - autoComplete="off" - /> - {showDropdown && ( -
-
    - {suggestions.map((entry, idx) => ( -
  • - -
  • - ))} - {query.length > 2 && ( -
  • - -
  • - )} -
-
- )} -
-
-
- )} - - {/* Mobile menu overlay */} - {mobileMenuOpen && ( -
-
-
- setMobileMenuOpen(false)}> - - - w - eval - - - -
- -
-
- {ACTION_LINKS.map(link => ( - !link.disabled && setMobileMenuOpen(false)} - className={cn( - "block px-4 py-3 rounded-lg text-center", - link.disabled - ? "text-muted-foreground bg-muted/50 cursor-not-allowed" - : link.primary - ? "bg-primary text-primary-foreground hover:bg-primary/90" - : "bg-muted hover:bg-muted/80" - )} - aria-disabled={link.disabled} - > - {link.label} - - ))} -
-
-
- )} - - {/* Main navbar */} -
-
-
- {/* Left: Logo + Nav Links */} -
- - - - w - eval - - - -
- - {/* Center: Search (desktop) */} -
-
-
- {isLoading ? ( - - ) : ( - - )} -
- = 0 ? getOptionId(selectedIndex) : undefined} - placeholder="Search evaluations..." - value={query} - onChange={(e) => { - setQuery(e.target.value); - setSearchFocused(true); - }} - onFocus={() => setSearchFocused(true)} - onKeyDown={handleKeyDown} - className="w-full pl-9 pr-4 py-2 text-sm rounded-lg bg-muted/50 border-transparent focus:bg-white dark:focus:bg-card focus:border-border" - disabled={isLoading} - autoComplete="off" - /> -
- {showDropdown && ( -
-
    - {suggestions.length > 0 ? ( - suggestions.map((entry, idx) => ( -
  • - -
  • - )) - ) : query.length > 2 ? ( -
  • - No matching evaluations -
  • - ) : null} - {query.length > 2 && ( -
  • - -
  • - )} -
-
- )} -
- - {/* Right: Action buttons (desktop) + Mobile controls */} -
- {/* Mobile search button */} - - - {/* Desktop action buttons */} -
- {ACTION_LINKS.map(link => ( - link.disabled ? ( - - ) : ( - - ) - ))} -
- - {/* Mobile menu button */} - -
-
-
-
- - ); -} diff --git a/src/app/components/SearchAutocomplete.tsx b/src/app/components/SearchAutocomplete.tsx deleted file mode 100644 index 7ee0542c..00000000 --- a/src/app/components/SearchAutocomplete.tsx +++ /dev/null @@ -1,351 +0,0 @@ -'use client'; - -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { useRouter } from 'next/navigation'; -import { Input } from '@/components/ui/input'; -import Icon from '@/components/ui/icon'; -import Fuse from 'fuse.js'; -import { AutocompleteEntry } from '@/cli/types/cli_types'; - -interface SearchAutocompleteProps { - placeholder?: string; - className?: string; -} - -export function SearchAutocomplete({ - placeholder = 'Search evaluations...', - className = '', -}: SearchAutocompleteProps) { - const router = useRouter(); - const [query, setQuery] = useState(''); - const [isOpen, setIsOpen] = useState(false); - const [isFocused, setIsFocused] = useState(false); - const [suggestions, setSuggestions] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(-1); - const [fuse, setFuse] = useState | null>(null); - const [isLoading, setIsLoading] = useState(true); - const inputRef = useRef(null); - const containerRef = useRef(null); - const listRef = useRef(null); - - // Load the autocomplete index on mount - useEffect(() => { - async function loadIndex() { - try { - const response = await fetch('/api/autocomplete'); - if (response.ok) { - const data = await response.json(); - setFuse(new Fuse(data, { - keys: [ - { name: 'title', weight: 2 }, - { name: 'keywords', weight: 1.5 }, - { name: 'tags', weight: 1.2 }, - { name: 'snippet', weight: 0.8 }, - { name: 'domain', weight: 1 }, - { name: 'topModel', weight: 0.5 }, - ], - threshold: 0.4, - includeScore: true, - minMatchCharLength: 2, - ignoreLocation: true, - })); - } - } catch (error) { - console.error('Failed to load autocomplete index:', error); - } finally { - setIsLoading(false); - } - } - loadIndex(); - }, []); - - // Update suggestions when query changes - useEffect(() => { - if (!fuse || query.length < 2) { - setSuggestions([]); - return; - } - - const results = fuse.search(query); - setSuggestions(results.slice(0, 6).map(r => r.item)); - setSelectedIndex(-1); - }, [query, fuse]); - - // Close dropdown when clicking outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false); - setIsFocused(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // Handle escape key to close mobile fullscreen - useEffect(() => { - function handleEscape(event: KeyboardEvent) { - if (event.key === 'Escape' && isFocused) { - setIsOpen(false); - setIsFocused(false); - inputRef.current?.blur(); - } - } - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isFocused]); - - // Scroll selected item into view - useEffect(() => { - if (selectedIndex >= 0 && listRef.current) { - const selectedElement = listRef.current.children[selectedIndex] as HTMLElement; - selectedElement?.scrollIntoView({ block: 'nearest' }); - } - }, [selectedIndex]); - - const handleSelect = useCallback((entry: AutocompleteEntry) => { - setQuery(''); - setIsOpen(false); - setIsFocused(false); - router.push(`/analysis/${entry.configId}`); - }, [router]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (!isOpen || suggestions.length === 0) { - if (e.key === 'Enter' && query.length > 2) { - router.push(`/all?q=${encodeURIComponent(query)}`); - setIsOpen(false); - setIsFocused(false); - } - return; - } - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setSelectedIndex(prev => - prev < suggestions.length ? prev + 1 : prev - ); - break; - case 'ArrowUp': - e.preventDefault(); - setSelectedIndex(prev => prev > 0 ? prev - 1 : -1); - break; - case 'Enter': - e.preventDefault(); - if (selectedIndex >= 0 && selectedIndex < suggestions.length) { - handleSelect(suggestions[selectedIndex]); - } else if (selectedIndex === suggestions.length && query.length > 2) { - // "Search all" option selected - router.push(`/all?q=${encodeURIComponent(query)}`); - setIsOpen(false); - setIsFocused(false); - } else if (query.length > 2) { - router.push(`/all?q=${encodeURIComponent(query)}`); - setIsOpen(false); - setIsFocused(false); - } - break; - case 'Escape': - e.preventDefault(); - setIsOpen(false); - setIsFocused(false); - inputRef.current?.blur(); - break; - } - }; - - const handleFocus = () => { - setIsOpen(true); - setIsFocused(true); - }; - - const handleClose = () => { - setIsOpen(false); - setIsFocused(false); - setQuery(''); - inputRef.current?.blur(); - }; - - const showDropdown = isOpen && (suggestions.length > 0 || (query.length > 2 && !isLoading)); - - // Generate unique IDs for accessibility - const listboxId = 'search-autocomplete-listbox'; - const getOptionId = (index: number) => `search-option-${index}`; - - return ( - <> - {/* Mobile fullscreen overlay */} - {isFocused && ( -