From c363ebc61cf95714dfdd3b4a6b78c8f86a440343 Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 13 Feb 2026 21:13:54 -0500 Subject: [PATCH 1/2] WIP: Various updates for AI review --- apps/server/convex/_generated/api.d.ts | 4 + apps/server/convex/benchmarks.ts | 28 +- ...atching.test.ts => model_matching.test.ts} | 49 ++-- .../{model-matching.ts => model_matching.ts} | 64 +++-- apps/web/src/components/circular-progress.tsx | 3 +- apps/web/src/components/model-info-panel.tsx | 122 ++++----- apps/web/src/components/model-selector.tsx | 254 +++++++++--------- apps/web/src/components/ui/tooltip.tsx | 5 +- apps/web/src/lib/auth-client.tsx | 78 ++---- apps/web/src/lib/benchmark-formatting.ts | 6 +- apps/web/src/providers/index.tsx | 7 +- apps/web/src/routes/__root.tsx | 42 ++- apps/web/src/styles.css | 17 ++ bun.lock | 1 + 14 files changed, 380 insertions(+), 300 deletions(-) rename apps/server/convex/lib/__tests__/{model-matching.test.ts => model_matching.test.ts} (65%) rename apps/server/convex/lib/{model-matching.ts => model_matching.ts} (57%) diff --git a/apps/server/convex/_generated/api.d.ts b/apps/server/convex/_generated/api.d.ts index d26b8854..dc6ba6b2 100644 --- a/apps/server/convex/_generated/api.d.ts +++ b/apps/server/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as auth from "../auth.js"; import type * as backgroundStream from "../backgroundStream.js"; +import type * as benchmarks from "../benchmarks.js"; import type * as chats from "../chats.js"; import type * as config_constants from "../config/constants.js"; import type * as crons from "../crons.js"; @@ -22,6 +23,7 @@ import type * as lib_billingUtils from "../lib/billingUtils.js"; import type * as lib_crypto from "../lib/crypto.js"; import type * as lib_dbStats from "../lib/dbStats.js"; import type * as lib_logger from "../lib/logger.js"; +import type * as lib_model_matching from "../lib/model_matching.js"; import type * as lib_origins from "../lib/origins.js"; import type * as lib_profiles from "../lib/profiles.js"; import type * as lib_rateLimitUtils from "../lib/rateLimitUtils.js"; @@ -46,6 +48,7 @@ import type { declare const fullApi: ApiFromModules<{ auth: typeof auth; backgroundStream: typeof backgroundStream; + benchmarks: typeof benchmarks; chats: typeof chats; "config/constants": typeof config_constants; crons: typeof crons; @@ -58,6 +61,7 @@ declare const fullApi: ApiFromModules<{ "lib/crypto": typeof lib_crypto; "lib/dbStats": typeof lib_dbStats; "lib/logger": typeof lib_logger; + "lib/model_matching": typeof lib_model_matching; "lib/origins": typeof lib_origins; "lib/profiles": typeof lib_profiles; "lib/rateLimitUtils": typeof lib_rateLimitUtils; diff --git a/apps/server/convex/benchmarks.ts b/apps/server/convex/benchmarks.ts index 13c2c1e5..14d0a990 100644 --- a/apps/server/convex/benchmarks.ts +++ b/apps/server/convex/benchmarks.ts @@ -1,12 +1,20 @@ import { v } from "convex/values"; import { internalAction, internalMutation, query } from "./_generated/server"; import { internal } from "./_generated/api"; -import { buildMatchingMap, POPULAR_MODEL_IDS, type AAModel } from "./lib/model-matching"; +import { buildMatchingMap, type AAModel } from "./lib/model_matching"; type AAModelsResponse = { data?: AAModel[]; }; +type OpenRouterModel = { + id: string; +}; + +type OpenRouterModelsResponse = { + data?: OpenRouterModel[]; +}; + const benchmarkValidator = v.object({ openRouterModelId: v.string(), aaSlug: v.string(), @@ -32,6 +40,14 @@ export const fetchAndStoreBenchmarks = internalAction({ } try { + const openRouterResponse = await fetch("https://openrouter.ai/api/v1/models"); + if (!openRouterResponse.ok) { + throw new Error(`Failed to fetch OpenRouter models: ${openRouterResponse.status}`); + } + const openRouterPayload = (await openRouterResponse.json()) as OpenRouterModelsResponse; + const openRouterIds = (openRouterPayload.data ?? []).map((m) => m.id); + console.log(`Fetched ${openRouterIds.length} OpenRouter model IDs`); + const response = await fetch("https://artificialanalysis.ai/api/v2/data/llms/models", { headers: { "x-api-key": apiKey, @@ -44,7 +60,7 @@ export const fetchAndStoreBenchmarks = internalAction({ const payload = (await response.json()) as AAModelsResponse; const aaModels = Array.isArray(payload.data) ? payload.data : []; - const matchingMap = buildMatchingMap(aaModels, [...POPULAR_MODEL_IDS]); + const matchingMap = buildMatchingMap(aaModels, openRouterIds); const benchmarks = aaModels.flatMap((model) => { const openRouterModelId = matchingMap.get(model.slug); @@ -56,9 +72,9 @@ export const fetchAndStoreBenchmarks = internalAction({ openRouterModelId, aaSlug: model.slug, aaCreatorName: model.model_creator.name, - intelligenceIndex: evaluations.artificial_analysis_intelligence_index ?? undefined, - codingIndex: evaluations.coding_index ?? undefined, - mathIndex: evaluations.math_index ?? undefined, + intelligenceIndex: evaluations.artificial_analysis_intelligence_index ?? undefined, + codingIndex: evaluations.artificial_analysis_coding_index ?? undefined, + mathIndex: evaluations.artificial_analysis_math_index ?? undefined, mmluPro: evaluations.mmlu_pro ?? undefined, gpqa: evaluations.gpqa ?? undefined, scicode: evaluations.scicode ?? undefined, @@ -122,3 +138,5 @@ export const getAllBenchmarks = query({ return await ctx.db.query("benchmarks").collect(); }, }); + + diff --git a/apps/server/convex/lib/__tests__/model-matching.test.ts b/apps/server/convex/lib/__tests__/model_matching.test.ts similarity index 65% rename from apps/server/convex/lib/__tests__/model-matching.test.ts rename to apps/server/convex/lib/__tests__/model_matching.test.ts index 5ed5c781..686984bc 100644 --- a/apps/server/convex/lib/__tests__/model-matching.test.ts +++ b/apps/server/convex/lib/__tests__/model_matching.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it } from "vitest"; import { MANUAL_OVERRIDES, - POPULAR_MODEL_IDS, buildMatchingMap, - matchAAtoOpenRouter, normalizeSlug, type AAModel, -} from "../model-matching"; +} from "../model_matching"; function createAAModel(slug: string, creatorSlug: string): AAModel { return { @@ -33,7 +31,7 @@ describe("normalizeSlug", () => { }); }); -describe("matchAAtoOpenRouter", () => { +describe("buildMatchingMap", () => { it.each([ ["claude-3-5-sonnet", "anthropic", "anthropic/claude-3.5-sonnet"], ["deepseek-v3", "deepseek", "deepseek/deepseek-chat"], @@ -42,34 +40,43 @@ describe("matchAAtoOpenRouter", () => { ["gemini-2-5-pro", "google", "google/gemini-2.5-pro"], ["grok-3", "x-ai", "x-ai/grok-3"], ])("matches manual override for %s", (aaSlug, creatorSlug, expected) => { - expect(matchAAtoOpenRouter(aaSlug, creatorSlug)).toBe(expected); + const map = buildMatchingMap( + [createAAModel(aaSlug, creatorSlug)], + [expected], + ); + expect(map.get(aaSlug)).toBe(expected); }); it("matches with normalized fallback when exact slug does not exist", () => { - expect(matchAAtoOpenRouter("gemini-2-0-flash-001", "google")).toBe("google/gemini-2.0-flash-001"); + const map = buildMatchingMap( + [createAAModel("gemini-2-0-flash-001", "google")], + ["google/gemini-2.0-flash-001"], + ); + expect(map.get("gemini-2-0-flash-001")).toBe("google/gemini-2.0-flash-001"); }); - it("returns null for unknown models instead of throwing", () => { - expect(() => matchAAtoOpenRouter("totally-unknown-model", "unknown")).not.toThrow(); - expect(matchAAtoOpenRouter("totally-unknown-model", "unknown")).toBeNull(); + it("returns no match for unknown models", () => { + const map = buildMatchingMap( + [createAAModel("totally-unknown-model", "unknown")], + ["openai/gpt-4o"], + ); + expect(map.has("totally-unknown-model")).toBe(false); }); - it("matches every popular model id", () => { - const matchedIds = new Set(); + it("every manual override resolves when its target is in the id set", () => { + const allTargetIds = [...new Set(Object.values(MANUAL_OVERRIDES))]; + const aaModels = Object.entries(MANUAL_OVERRIDES).map(([aaSlug, openRouterId]) => + createAAModel(aaSlug, openRouterId.split("/")[0] ?? ""), + ); - for (const [aaSlug, openRouterId] of Object.entries(MANUAL_OVERRIDES)) { - const creatorSlug = openRouterId.split("/")[0] ?? ""; - const result = matchAAtoOpenRouter(aaSlug, creatorSlug); - if (result) { - matchedIds.add(result); - } - } + const map = buildMatchingMap(aaModels, allTargetIds); - expect([...matchedIds].sort()).toEqual([...POPULAR_MODEL_IDS].sort()); + for (const targetId of allTargetIds) { + const matched = [...map.values()].includes(targetId); + expect(matched, `Expected ${targetId} to be matched by at least one override`).toBe(true); + } }); -}); -describe("buildMatchingMap", () => { it("builds an AA slug to OpenRouter id map", () => { const aaModels: AAModel[] = [ createAAModel("claude-3-5-sonnet", "anthropic"), diff --git a/apps/server/convex/lib/model-matching.ts b/apps/server/convex/lib/model_matching.ts similarity index 57% rename from apps/server/convex/lib/model-matching.ts rename to apps/server/convex/lib/model_matching.ts index 00789159..757a5160 100644 --- a/apps/server/convex/lib/model-matching.ts +++ b/apps/server/convex/lib/model_matching.ts @@ -7,46 +7,62 @@ export interface AAModel { evaluations: Record; } -export const POPULAR_MODEL_IDS = new Set([ - "anthropic/claude-sonnet-4", - "anthropic/claude-3.5-sonnet", - "anthropic/claude-3.7-sonnet", - "anthropic/claude-3.5-haiku", - "openai/gpt-4o", - "openai/gpt-4o-mini", - "openai/gpt-4.1", - "openai/gpt-4.1-mini", - "openai/o3-mini", - "google/gemini-2.5-flash", - "google/gemini-2.5-pro", - "google/gemini-2.0-flash-001", - "deepseek/deepseek-chat", - "deepseek/deepseek-chat-v3.1", - "deepseek/deepseek-r1", - "meta-llama/llama-3.3-70b-instruct", - "x-ai/grok-3", - "mistralai/mistral-large-2411", - "qwen/qwen-2.5-72b-instruct", -]); - export const MANUAL_OVERRIDES: Record = { + "claude-35-sonnet": "anthropic/claude-3.5-sonnet", + "claude-35-sonnet-june-24": "anthropic/claude-3.5-sonnet", "claude-3-5-sonnet": "anthropic/claude-3.5-sonnet", "claude-3-7-sonnet": "anthropic/claude-3.7-sonnet", + "claude-3-7-sonnet-thinking": "anthropic/claude-3.7-sonnet", "claude-3-5-haiku": "anthropic/claude-3.5-haiku", + "claude-4-sonnet": "anthropic/claude-sonnet-4", + "claude-4-sonnet-thinking": "anthropic/claude-sonnet-4", "claude-sonnet-4": "anthropic/claude-sonnet-4", + "claude-4-5-sonnet": "anthropic/claude-sonnet-4.5", + "claude-4-5-sonnet-thinking": "anthropic/claude-sonnet-4.5", + "claude-4-5-haiku": "anthropic/claude-haiku-4.5", + "claude-4-5-haiku-reasoning": "anthropic/claude-haiku-4.5", + "claude-opus-4-5": "anthropic/claude-opus-4.5", + "claude-opus-4-5-thinking": "anthropic/claude-opus-4.5", + "claude-opus-4-6": "anthropic/claude-opus-4.6", + "claude-opus-4-6-adaptive": "anthropic/claude-opus-4.6", + "claude-4-1-opus": "anthropic/claude-opus-4.1", + "claude-4-1-opus-thinking": "anthropic/claude-opus-4.1", + "claude-4-opus": "anthropic/claude-opus-4", + "claude-4-opus-thinking": "anthropic/claude-opus-4", "deepseek-v3": "deepseek/deepseek-chat", "deepseek-v3-0324": "deepseek/deepseek-chat-v3.1", "deepseek-r1": "deepseek/deepseek-r1", + "deepseek-r1-0120": "deepseek/deepseek-r1", + "llama-3-3-instruct-70b": "meta-llama/llama-3.3-70b-instruct", "llama-3-3-70b": "meta-llama/llama-3.3-70b-instruct", + "llama-3-1-instruct-405b": "meta-llama/llama-3.1-405b-instruct", + "llama-3-1-instruct-70b": "meta-llama/llama-3.1-70b-instruct", + "llama-3-1-instruct-8b": "meta-llama/llama-3.1-8b-instruct", + "llama-4-maverick": "meta-llama/llama-4-maverick", + "llama-4-scout": "meta-llama/llama-4-scout", "gpt-4o": "openai/gpt-4o", "gpt-4o-mini": "openai/gpt-4o-mini", "o3-mini": "openai/o3-mini", + "o3": "openai/o3", + "o4-mini": "openai/o4-mini", "grok-3": "x-ai/grok-3", + "grok-4": "x-ai/grok-4", + "mistral-large-2": "mistralai/mistral-large-2411", + "mistral-large-2407": "mistralai/mistral-large-2411", "mistral-large-2411": "mistralai/mistral-large-2411", + "mistral-large-3": "mistralai/mistral-large-2512", + "mistral-medium-3": "mistralai/mistral-medium-3", + "mistral-medium-3-1": "mistralai/mistral-medium-3.1", + "mistral-small-3": "mistralai/mistral-small-24b-instruct-2501", + "mistral-small-3-1": "mistralai/mistral-small-24b-instruct-2501", + "mistral-small-3-2": "mistralai/mistral-small-24b-instruct-2501", "qwen-2-5-72b-instruct": "qwen/qwen-2.5-72b-instruct", "gemini-2-5-flash": "google/gemini-2.5-flash", "gemini-2-5-pro": "google/gemini-2.5-pro", "gemini-2-0-flash": "google/gemini-2.0-flash-001", + "gemini-3-flash": "google/gemini-3-flash-preview", + "gemini-3-flash-reasoning": "google/gemini-3-flash-preview", + "gemini-3-pro": "google/gemini-3-pro-preview", "gpt-4-1": "openai/gpt-4.1", "gpt-4-1-mini": "openai/gpt-4.1-mini", }; @@ -95,10 +111,6 @@ export function normalizeSlug(slug: string): string { return normalized.replace(/(?:-\d{3,4})+$/, ""); } -export function matchAAtoOpenRouter(aaSlug: string, aaCreatorSlug: string): string | null { - return matchAAtoOpenRouterWithIds(aaSlug, aaCreatorSlug, POPULAR_MODEL_IDS); -} - export function buildMatchingMap( aaModels: AAModel[], openRouterIds: string[], diff --git a/apps/web/src/components/circular-progress.tsx b/apps/web/src/components/circular-progress.tsx index d9313bb6..1925574f 100644 --- a/apps/web/src/components/circular-progress.tsx +++ b/apps/web/src/components/circular-progress.tsx @@ -93,7 +93,8 @@ export function CircularProgress({ y={center} textAnchor="middle" dominantBaseline="central" - className={cn("font-semibold", colorClass.replace("text-", "fill-"))} + fill="currentColor" + className={cn("font-semibold", colorClass)} style={{ fontSize: `${fontSize}px` }} > {`${Math.round(clampedValue)}%`} diff --git a/apps/web/src/components/model-info-panel.tsx b/apps/web/src/components/model-info-panel.tsx index 2b6d37f4..ce60539b 100644 --- a/apps/web/src/components/model-info-panel.tsx +++ b/apps/web/src/components/model-info-panel.tsx @@ -44,7 +44,7 @@ function ProviderLogo({ ); } -function BrainIcon({ className }: { className?: string }) { +function ThinkingIcon({ className }: { className?: string }) { return ( ); @@ -85,7 +85,7 @@ function EyeIcon({ className }: { className?: string }) { ); } -function WrenchIcon({ className }: { className?: string }) { +function ToolIcon({ className }: { className?: string }) { return ( + ); @@ -131,14 +136,14 @@ function BenchmarkCard({ subBenchmarks: { label: string; value: number | undefined }[]; }) { return ( -
- - {label} -
+
+ + {label} +
{subBenchmarks.map((sub) => (
{sub.label} @@ -156,6 +161,7 @@ export function ModelInfoPanel({ model, className }: ModelInfoPanelProps) { const hasVision = model.modality?.includes("image"); const hasReasoning = model.reasoning; + const hasFeatures = hasReasoning || hasVision || model.toolCall || model.isFree; const evals = benchmark ? { @@ -176,89 +182,83 @@ export function ModelInfoPanel({ model, className }: ModelInfoPanelProps) { return (
-
+
-

+

{model.name}

- {model.description && ( -

- {model.description} -

- )} +

+ {model.provider} +

-
- {hasReasoning && ( - - - - )} - {hasVision && ( - - - - )} - {model.toolCall && ( - - - - )} - {model.isFree && ( - - Free - - )} -
+ {hasFeatures && ( +
+ {hasVision && ( + + + Vision + + )} + {hasReasoning && ( + + + Reasoning + + )} + {model.toolCall && ( + + + Tool Calling + + )} + {model.isFree && ( + + Free + + )} +
+ )} -
+
- Provider -

{model.provider}

+ Provider +

OpenRouter

- Developer -

+ Developer +

{benchmark?.aaCreatorName ?? model.provider}

{isLoading ? ( -
-
+
+
) : showBenchmarks && evals ? ( -
+
@@ -290,8 +290,8 @@ export function ModelInfoPanel({ model, className }: ModelInfoPanelProps) {
) : ( -
- +
+ Benchmarks unavailable for this model diff --git a/apps/web/src/components/model-selector.tsx b/apps/web/src/components/model-selector.tsx index 72229372..c9beded7 100644 --- a/apps/web/src/components/model-selector.tsx +++ b/apps/web/src/components/model-selector.tsx @@ -5,9 +5,10 @@ import { cn } from "@/lib/utils"; import { getModelById, useModelStore, useModels } from "@/stores/model"; import { useFavoriteModels } from "@/hooks/use-favorite-models"; import { useUIStore } from "@/stores/ui"; -import { CheckIcon, ChevronDownIcon, SearchIcon } from "@/components/icons"; +import { ChevronDownIcon, SearchIcon } from "@/components/icons"; import { ModelInfoPanel } from "@/components/model-info-panel"; -import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; + +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; function useIsMobile() { @@ -65,7 +66,7 @@ function ProviderLogo({ providerId, className }: { providerId: string; className ); } -function BrainIcon({ className }: { className?: string }) { +function ThinkingIcon({ className }: { className?: string }) { return ( ); @@ -102,7 +103,7 @@ function EyeIcon({ className }: { className?: string }) { ); } -function WrenchIcon({ className }: { className?: string }) { +function ToolIcon({ className }: { className?: string }) { return ( + ); @@ -162,9 +168,10 @@ function ModelItem({ onSelect, onHover, onInfoClick, + onInfoHover, + onInfoClear, onToggleFavorite, dataIndex, - isMobile, }: { model: Model; isSelected: boolean; @@ -173,9 +180,10 @@ function ModelItem({ onSelect: () => void; onHover: () => void; onInfoClick: (e: React.MouseEvent) => void; + onInfoHover: () => void; + onInfoClear: () => void; onToggleFavorite: (e: React.MouseEvent) => void; dataIndex: number; - isMobile: boolean; }) { const hasVision = model.modality?.includes("image"); const hasReasoning = model.reasoning; @@ -184,125 +192,92 @@ function ModelItem({
{ onHover(); onInfoClear(); }} onKeyDown={(e) => e.key === "Enter" && onSelect()} role="option" tabIndex={0} aria-selected={isSelected} className={cn( - "group relative flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-left outline-none transition-all duration-200 ease-out md:py-2.5", - "min-h-[44px] md:min-h-0", - isHighlighted - ? "bg-accent/90 shadow-sm" - : "hover:bg-accent/50 active:bg-accent/70", - isSelected && "bg-accent/60", + "group relative flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left outline-none transition-all duration-150 ease-out", + isHighlighted && !isSelected && "bg-accent/60", + !isHighlighted && !isSelected && "hover:bg-accent/40 active:bg-accent/60", + isSelected && "bg-accent/40", )} > - + {model.name} -
- {hasReasoning && ( - - - - )} - {hasVision && ( - - - - )} - {model.toolCall && ( - - - - )} - {model.isFree && ( - +
+ + + {model.isFree && ( + Free )} - {isMobile ? ( - - ) : ( - - - - - -
- -
-
-
+ + {hasVision && ( + + } className="flex size-6 items-center justify-center rounded-lg bg-sky-500/15 text-sky-400"> + + + Vision + )} - - - - {isSelected && ( - - - - )} + {hasReasoning && ( + + } className="flex size-6 items-center justify-center rounded-lg bg-amber-500/15 text-amber-400"> + + + Reasoning + + )} + {model.toolCall && ( + + } className="flex size-6 items-center justify-center rounded-lg bg-violet-500/15 text-violet-400"> + + + Tool Use + + )} + + +
); @@ -330,6 +305,20 @@ export function ModelSelector({ const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, openAbove: false }); const [hasEverOpened, setHasEverOpened] = useState(false); const [visible, setVisible] = useState(false); + const [hoveredInfoModel, setHoveredInfoModel] = useState(null); + + const infoHoverTimerRef = useRef | null>(null); + + const showInfoPanel = useCallback((model: Model) => { + if (infoHoverTimerRef.current) clearTimeout(infoHoverTimerRef.current); + infoHoverTimerRef.current = setTimeout(() => setHoveredInfoModel(model), 250); + }, []); + + const hideInfoPanel = useCallback(() => { + if (infoHoverTimerRef.current) clearTimeout(infoHoverTimerRef.current); + infoHoverTimerRef.current = null; + setHoveredInfoModel(null); + }, []); const isMobile = useIsMobile(); const triggerRef = useRef(null); @@ -400,13 +389,13 @@ export function ModelSelector({ }, [filteredModels]); useEffect(() => { - setHighlightedIndex(0); + setHighlightedIndex(-1); }, [deferredQuery, selectedProvider, showFavoritesOnly]); const calculateDropdownPosition = useCallback(() => { if (!triggerRef.current || isMobile) return; const rect = triggerRef.current.getBoundingClientRect(); - const dropdownHeight = 480; + const dropdownHeight = 520; const spaceAbove = rect.top; const spaceBelow = window.innerHeight - rect.bottom; @@ -440,7 +429,7 @@ export function ModelSelector({ }); setQuery(""); - setHighlightedIndex(0); + setHighlightedIndex(-1); setSelectedProvider(null); setShowFavoritesOnly(favorites.size > 0); @@ -453,8 +442,9 @@ export function ModelSelector({ flushSync(() => { setVisible(false); }); + hideInfoPanel(); triggerRef.current?.focus(); - }, []); + }, [hideInfoPanel]); const handleSelect = useCallback( (modelId: string) => { @@ -479,11 +469,11 @@ export function ModelSelector({ switch (e.key) { case "ArrowDown": e.preventDefault(); - setHighlightedIndex((prev) => (prev < flatList.length - 1 ? prev + 1 : prev)); + setHighlightedIndex((prev) => (prev < flatList.length - 1 ? Math.max(0, prev + 1) : prev)); break; case "ArrowUp": e.preventDefault(); - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0)); break; case "Enter": e.preventDefault(); @@ -514,6 +504,12 @@ export function ModelSelector({ } }, [highlightedIndex, open]); + useEffect(() => { + return () => { + if (infoHoverTimerRef.current) clearTimeout(infoHoverTimerRef.current); + }; + }, []); + useEffect(() => { if (!open) return; @@ -686,7 +682,7 @@ export function ModelSelector({
)} -
+
{isLoading ? (
Loading models... @@ -730,9 +726,10 @@ export function ModelSelector({ onSelect={() => handleSelect(model.id)} onHover={() => setHighlightedIndex(index)} onInfoClick={() => onInfoOpen?.(model)} + onInfoHover={() => {}} + onInfoClear={() => {}} onToggleFavorite={(e) => handleToggleFavorite(e, model.id)} dataIndex={index} - isMobile={isMobile} /> )) )} @@ -768,15 +765,23 @@ export function ModelSelector({ position: 'fixed', top: dropdownPosition.top, left: dropdownPosition.left, - height: Math.min(480, window.innerHeight - 100), }} className={cn( - "z-[9999] flex w-[420px] rounded-2xl border border-border bg-popover text-popover-foreground shadow-2xl", - dropdownPosition.openAbove ? "origin-bottom-left" : "origin-top-left", + "z-[9999] flex items-start", "transition-all duration-150 ease-out", visible ? "scale-100 opacity-100" : "scale-95 opacity-0 pointer-events-none", + dropdownPosition.openAbove ? "origin-bottom-left" : "origin-top-left", + )} + onMouseLeave={hideInfoPanel} + > +
-
+
setHighlightedIndex(-1)} className="flex-1 space-y-0.5 overflow-y-auto overscroll-contain p-2 scrollbar-thin"> {isLoading ? (
Loading models... @@ -904,9 +909,10 @@ export function ModelSelector({ onSelect={() => handleSelect(model.id)} onHover={() => setHighlightedIndex(index)} onInfoClick={() => onInfoOpen?.(model)} + onInfoHover={() => showInfoPanel(model)} + onInfoClear={hideInfoPanel} onToggleFavorite={(e) => handleToggleFavorite(e, model.id)} dataIndex={index} - isMobile={isMobile} /> )) )} @@ -944,6 +950,12 @@ export function ModelSelector({
+ {hoveredInfoModel && ( +
+ +
+ )} +
), document.body )} diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index f18c04c5..af705b19 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -31,13 +31,14 @@ function TooltipContent({ sideOffset = 4, align = "center", alignOffset = 0, + positionerClassName, children, ...props }: TooltipPrimitive.Popup.Props & Pick< TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" - >) { + > & { positionerClassName?: string }) { return ( (), - - setItem: (key: string, value: string) => { - const cached = inMemoryStorage._store.get(key); - // Only write if value actually changed (prevents infinite loops) - if (cached === value) { - return; - } - inMemoryStorage._store.set(key, value); - }, - - getItem: (key: string) => { - return inMemoryStorage._store.get(key) ?? null; - }, - - removeItem: (key: string) => { - inMemoryStorage._store.delete(key); - }, -}; - -/** - * Better Auth client with Convex integration - * - * SECURITY: Uses in-memory-only storage to reduce XSS token theft risk. - * Session tokens are never written to localStorage or sessionStorage, - * eliminating persistence and narrowing the attack surface. - * The actual authentication is maintained via HttpOnly, Secure, SameSite cookies - * which are inaccessible to JavaScript. - * - * The in-memory storage also prevents the infinite session loop caused by - * crossDomainClient's $sessionSignal notification by deduplicating writes. + * Better Auth client with Convex integration. */ export const authClient = createAuthClient({ baseURL: env.CONVEX_SITE_URL, - // Disable aggressive session refetching to prevent API spam sessionOptions: { refetchOnWindowFocus: false, refetchInterval: 0, @@ -74,11 +29,7 @@ export const authClient = createAuthClient({ }, plugins: [ convexAuthPlugin(), - crossDomainClient({ - storage: inMemoryStorage, - // Disable local session cache - we manage caching ourselves - disableCache: true, - }), + crossDomainClient(), ], }); @@ -96,6 +47,13 @@ interface SessionUser { image: string | null; } +export type InitialAuthUser = { + id: string; + email: string; + name: string; + image: string | null; +} | null; + interface SessionData { user: SessionUser | null; session: { id: string; token: string } | null; @@ -115,12 +73,20 @@ const AuthContext = createContext(null); * Non-reactive auth provider that fetches session once and caches it. * This prevents the infinite loop caused by $sessionSignal notifications. */ -export function StableAuthProvider({ children }: { children: ReactNode }) { - const [sessionData, setSessionData] = useState({ - user: null, - session: null, +export function StableAuthProvider({ + children, + initialUser, +}: { + children: ReactNode; + initialUser?: InitialAuthUser; +}) { + const [sessionData, setSessionData] = useState(() => { + if (initialUser) { + return { user: initialUser, session: null }; + } + return { user: null, session: null }; }); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(!initialUser); const fetchedRef = useRef(false); const fetchingRef = useRef(false); diff --git a/apps/web/src/lib/benchmark-formatting.ts b/apps/web/src/lib/benchmark-formatting.ts index 58fbf3eb..b372b4b2 100644 --- a/apps/web/src/lib/benchmark-formatting.ts +++ b/apps/web/src/lib/benchmark-formatting.ts @@ -38,13 +38,13 @@ export function getBenchmarkColor(score: number | null): string { if (score === null) { return "text-muted-foreground"; } - if (score >= 70) { + if (score >= 30) { return "text-emerald-500"; } - if (score >= 40) { + if (score >= 15) { return "text-amber-500"; } - return "text-red-500"; + return "text-rose-500"; } /** diff --git a/apps/web/src/providers/index.tsx b/apps/web/src/providers/index.tsx index 34660f57..a826114c 100644 --- a/apps/web/src/providers/index.tsx +++ b/apps/web/src/providers/index.tsx @@ -4,7 +4,7 @@ import { ConvexProviderWithAuth, useMutation, useQuery } from "convex/react"; import { Toaster } from "sonner"; import { api } from "@server/convex/_generated/api"; import { convexClient } from "../lib/convex"; -import { StableAuthProvider, authClient, useAuth } from "../lib/auth-client"; +import { StableAuthProvider, authClient, useAuth, type InitialAuthUser } from "../lib/auth-client"; import { prefetchModels } from "../stores/model"; import { useProviderStore } from "../stores/provider"; import { useOpenRouterStore } from "../stores/openrouter"; @@ -51,6 +51,7 @@ const queryClient = new QueryClient({ interface ProvidersProps { children: React.ReactNode; + initialUser?: InitialAuthUser; } function useStableConvexAuth() { @@ -196,7 +197,7 @@ function ConvexAuthWrapper({ children }: { children: React.ReactNode }) { ); } -export function Providers({ children }: ProvidersProps) { +export function Providers({ children, initialUser }: ProvidersProps) { const [isClient, setIsClient] = useState(false); useEffect(() => { @@ -218,7 +219,7 @@ export function Providers({ children }: ProvidersProps) { return ( - + {content} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 982c13db..436883d1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -6,6 +6,8 @@ import { useNavigate, useRouterState, } from "@tanstack/react-router"; +import { createServerFn } from "@tanstack/react-start"; +import { getRequest } from "@tanstack/react-start/server"; import { Providers } from "../providers"; import { SidebarInset, SidebarProvider } from "../components/ui/sidebar"; import { NavigationProgress } from "../components/navigation-progress"; @@ -15,15 +17,52 @@ import { usePostHogPageView } from "../providers/posthog"; import { convexClient } from "../lib/convex"; import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts"; import { ShortcutsDialog } from "@/components/shortcuts-dialog"; +import type { InitialAuthUser } from "../lib/auth-client"; import appCss from "../styles.css?url"; +const CONVEX_SITE_URL = import.meta.env.VITE_CONVEX_SITE_URL; + +const getSessionOnServer = createServerFn({ method: "GET" }).handler( + async () => { + if (!CONVEX_SITE_URL) return null; + try { + const request = getRequest(); + const cookie = request.headers.get("cookie"); + if (!cookie) return null; + + const response = await fetch(`${CONVEX_SITE_URL}/api/auth/session`, { + headers: { cookie }, + }); + if (!response.ok) return null; + + const data = (await response.json()) as { + user?: { id: string; email: string; name: string; image?: string | null } | null; + } | null; + if (!data?.user) return null; + + return { + id: data.user.id, + email: data.user.email, + name: data.user.name || data.user.email.split("@")[0] || "User", + image: data.user.image ?? null, + }; + } catch { + return null; + } + }, +); + const SITE_URL = "https://osschat.dev"; const SITE_NAME = "osschat"; const SITE_DESCRIPTION = "Open source AI chat with 350+ models. Access GPT-4, Claude, Gemini, and more through one beautiful interface. Free tier available, no API key required."; const SITE_TAGLINE = "One interface. Every AI model."; export const Route = createRootRoute({ + beforeLoad: async () => { + const initialUser = await getSessionOnServer(); + return { initialUser: initialUser as InitialAuthUser }; + }, head: () => ({ meta: [ // Basic @@ -128,9 +167,10 @@ export const Route = createRootRoute({ }); function RootComponent() { + const { initialUser } = Route.useRouteContext(); return ( - + diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index f560f551..4ece84ea 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -179,6 +179,23 @@ .scrollbar-none::-webkit-scrollbar { display: none; } + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: oklch(0.4 0 0 / 0.3) transparent; + } + .scrollbar-thin::-webkit-scrollbar { + width: 4px; + } + .scrollbar-thin::-webkit-scrollbar-track { + background: transparent; + } + .scrollbar-thin::-webkit-scrollbar-thumb { + background: oklch(0.4 0 0 / 0.3); + border-radius: 9999px; + } + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background: oklch(0.5 0 0 / 0.4); + } } @keyframes shimmer { diff --git a/bun.lock b/bun.lock index 0b601fd3..5f74d59c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "openchat", From 6d1f40763655b4547cbe035120f1a962cff51a0d Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 13 Feb 2026 21:23:15 -0500 Subject: [PATCH 2/2] fix: apply AI review suggestions from Cubic - auth-client: keep loading=true until fetchSession completes - benchmark-formatting: update stale JSDoc comment - __root.tsx: add timeout to auth fetch (3s) - styles.css: use theme variables for scrollbar colors - model-selector: hoist TooltipProvider outside ModelItem loop --- apps/web/src/components/model-selector.tsx | 118 +++++++++++---------- apps/web/src/lib/auth-client.tsx | 2 +- apps/web/src/lib/benchmark-formatting.ts | 2 +- apps/web/src/routes/__root.tsx | 1 + apps/web/src/styles.css | 6 +- 5 files changed, 66 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/model-selector.tsx b/apps/web/src/components/model-selector.tsx index c9beded7..e745a47b 100644 --- a/apps/web/src/components/model-selector.tsx +++ b/apps/web/src/components/model-selector.tsx @@ -237,32 +237,30 @@ function ModelItem({ )} - - {hasVision && ( - - } className="flex size-6 items-center justify-center rounded-lg bg-sky-500/15 text-sky-400"> - - - Vision - - )} - {hasReasoning && ( - - } className="flex size-6 items-center justify-center rounded-lg bg-amber-500/15 text-amber-400"> - - - Reasoning - - )} - {model.toolCall && ( - - } className="flex size-6 items-center justify-center rounded-lg bg-violet-500/15 text-violet-400"> - - - Tool Use - - )} - + {hasVision && ( + + } className="flex size-6 items-center justify-center rounded-lg bg-sky-500/15 text-sky-400"> + + + Vision + + )} + {hasReasoning && ( + + } className="flex size-6 items-center justify-center rounded-lg bg-amber-500/15 text-amber-400"> + + + Reasoning + + )} + {model.toolCall && ( + + } className="flex size-6 items-center justify-center rounded-lg bg-violet-500/15 text-violet-400"> + + + Tool Use + + )}
@@ -899,22 +899,24 @@ export function ModelSelector({ ) : null}
) : ( - flatList.map((model, index) => ( - handleSelect(model.id)} - onHover={() => setHighlightedIndex(index)} - onInfoClick={() => onInfoOpen?.(model)} - onInfoHover={() => showInfoPanel(model)} - onInfoClear={hideInfoPanel} - onToggleFavorite={(e) => handleToggleFavorite(e, model.id)} - dataIndex={index} - /> - )) + + {flatList.map((model, index) => ( + handleSelect(model.id)} + onHover={() => setHighlightedIndex(index)} + onInfoClick={() => onInfoOpen?.(model)} + onInfoHover={() => showInfoPanel(model)} + onInfoClear={hideInfoPanel} + onToggleFavorite={(e) => handleToggleFavorite(e, model.id)} + dataIndex={index} + /> + ))} + )}
diff --git a/apps/web/src/lib/auth-client.tsx b/apps/web/src/lib/auth-client.tsx index 64c96f0b..3bcfbee2 100644 --- a/apps/web/src/lib/auth-client.tsx +++ b/apps/web/src/lib/auth-client.tsx @@ -86,7 +86,7 @@ export function StableAuthProvider({ } return { user: null, session: null }; }); - const [loading, setLoading] = useState(!initialUser); + const [loading, setLoading] = useState(true); const fetchedRef = useRef(false); const fetchingRef = useRef(false); diff --git a/apps/web/src/lib/benchmark-formatting.ts b/apps/web/src/lib/benchmark-formatting.ts index b372b4b2..c136d479 100644 --- a/apps/web/src/lib/benchmark-formatting.ts +++ b/apps/web/src/lib/benchmark-formatting.ts @@ -30,7 +30,7 @@ export function formatIndex(value: number | null | undefined): string { /** * Returns a Tailwind color class for a benchmark score. - * Uses discrete color buckets: green (≥70), amber (40-69), red (<40), gray (null). + * Uses discrete color buckets: emerald (≥30), amber (15-29), rose (<15), gray (null). * @param score - A numeric score or null * @returns Tailwind text color class */ diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 436883d1..93c61f06 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -33,6 +33,7 @@ const getSessionOnServer = createServerFn({ method: "GET" }).handler( const response = await fetch(`${CONVEX_SITE_URL}/api/auth/session`, { headers: { cookie }, + signal: AbortSignal.timeout(3000), }); if (!response.ok) return null; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4ece84ea..cb1ecd59 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -181,7 +181,7 @@ } .scrollbar-thin { scrollbar-width: thin; - scrollbar-color: oklch(0.4 0 0 / 0.3) transparent; + scrollbar-color: color-mix(in oklch, var(--muted-foreground) 30%, transparent) transparent; } .scrollbar-thin::-webkit-scrollbar { width: 4px; @@ -190,11 +190,11 @@ background: transparent; } .scrollbar-thin::-webkit-scrollbar-thumb { - background: oklch(0.4 0 0 / 0.3); + background: color-mix(in oklch, var(--muted-foreground) 30%, transparent); border-radius: 9999px; } .scrollbar-thin::-webkit-scrollbar-thumb:hover { - background: oklch(0.5 0 0 / 0.4); + background: color-mix(in oklch, var(--muted-foreground) 40%, transparent); } }