From 2cd5e66f1eddb8e9a692bedb171dcd1085fa753b Mon Sep 17 00:00:00 2001 From: Maya Gore Date: Sun, 22 Feb 2026 13:39:12 -0600 Subject: [PATCH] Fix functions page crash on 404 + add skeleton loaders (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Functions page and homepage crashed entirely when any single Functions.retrieve() returned 404 — Promise.all rejects if any promise fails. Wrap each retrieve in try/catch so failed functions are silently skipped instead of crashing the page. Add skeleton loading cards (functions browse) and skeleton detail page (function detail) for a smoother loading experience. Fix array input schema crash in SchemaFormBuilder (#115). Add 5 default profiles (Nano through Giga Max) auto-selected when no function-specific profile exists. Co-Authored-By: Muhammad Hozaifa Ali Co-Authored-By: Claude Opus 4.6 --- .../app/functions/[...slug]/page.tsx | 181 +++++++++--------- objectiveai-web/app/functions/page.tsx | 74 ++++--- objectiveai-web/app/page.tsx | 40 ++-- .../SchemaForm/SchemaFormBuilder.tsx | 4 +- .../SchemaForm/fields/ArrayField.tsx | 2 +- .../components/ui/SkeletonCard.tsx | 45 +++++ .../components/ui/SkeletonFunctionDetails.tsx | 53 +++++ objectiveai-web/components/ui/index.ts | 2 + objectiveai-web/lib/profiles.ts | 47 +++++ 9 files changed, 304 insertions(+), 144 deletions(-) create mode 100644 objectiveai-web/components/ui/SkeletonCard.tsx create mode 100644 objectiveai-web/components/ui/SkeletonFunctionDetails.tsx create mode 100644 objectiveai-web/lib/profiles.ts diff --git a/objectiveai-web/app/functions/[...slug]/page.tsx b/objectiveai-web/app/functions/[...slug]/page.tsx index 6518cde01..d74bcf292 100644 --- a/objectiveai-web/app/functions/[...slug]/page.tsx +++ b/objectiveai-web/app/functions/[...slug]/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { createPublicClient } from "../../../lib/client"; import { deriveDisplayName, DEV_EXECUTION_OPTIONS } from "../../../lib/objectiveai"; import { PINNED_COLOR_ANIMATION_MS } from "../../../lib/constants"; +import { DEFAULT_PROFILES } from "../../../lib/profiles"; import { loadReasoningModels } from "../../../lib/reasoning-models"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { useObjectiveAI } from "../../../hooks/useObjectiveAI"; @@ -17,6 +18,8 @@ import { simplifySplitItems, toDisplayItem, getDisplayMode } from "../../../lib/ import { compileFunctionInputSplit, type FunctionConfig } from "../../../lib/wasm-validation"; import { Functions, EnsembleLlm } from "objectiveai"; import { ObjectiveAIFetchError } from "objectiveai"; +import { SkeletonFunctionDetails } from "../../../components/ui"; + interface FunctionDetails { owner: string; repository: string; @@ -48,8 +51,14 @@ export default function FunctionDetailPage({ params }: { params: Promise<{ slug: const slugKey = `${owner}/${repository}`; const [functionDetails, setFunctionDetails] = useState(null); - const [availableProfiles, setAvailableProfiles] = useState<{ owner: string; repository: string; commit: string }[]>([]); const [selectedProfileIndex, setSelectedProfileIndex] = useState(0); + const [availableProfiles, setAvailableProfiles] = useState>(DEFAULT_PROFILES); const [isLoadingDetails, setIsLoadingDetails] = useState(true); const [loadError, setLoadError] = useState(null); @@ -128,47 +137,60 @@ export default function FunctionDetailPage({ params }: { params: Promise<{ slug: // Fetch function details directly (works for all functions, regardless of profiles) const details = await Functions.retrieve(publicClient, "github", owner, repository, null); + const category = details.type === "vector.function" ? "Ranking" : "Scoring"; + + setFunctionDetails({ + owner, + repository, + commit: details.commit || "", + name: deriveDisplayName(repository), + description: details.description || `${deriveDisplayName(repository)} function`, + category, + type: details.type as "scalar.function" | "vector.function", + inputSchema: (details as { input_schema?: Record }).input_schema || null, + }); + // Try to get available profiles (separately, so function loads even if no profiles exist) - let profiles: { owner: string; repository: string; commit: string }[] = []; + let functionProfiles: Array<{ owner: string; repository: string; commit: string; label: string; description: string }> = []; try { const pairs = await Functions.listPairs(publicClient); const matchingPairs = pairs.data.filter( (p: { function: { owner: string; repository: string } }) => p.function.owner === owner && p.function.repository === repository ); - profiles = matchingPairs.map((p: { profile: { owner: string; repository: string; commit: string } }) => p.profile); + functionProfiles = matchingPairs.map( + (p: { profile: { owner: string; repository: string; commit: string } }) => ({ + owner: p.profile.owner, + repository: p.profile.repository, + commit: p.profile.commit, + label: deriveDisplayName(p.profile.repository), + description: `${p.profile.owner}/${p.profile.repository}`, + }) + ); } catch { // If pairs fetch fails, continue to fallback - profiles = []; + functionProfiles = []; } // Fallback: try fetching profile from same repo (CLI puts profile.json in the function repo) - if (profiles.length === 0) { + if (functionProfiles.length === 0) { try { const profile = await Functions.Profiles.retrieve(publicClient, "github", owner, repository, null); - profiles = [{ owner, repository, commit: profile.commit }]; + functionProfiles = [{ + owner, + repository, + commit: profile.commit, + label: deriveDisplayName(repository), + description: `${owner}/${repository}`, + }]; } catch { // Genuinely no profile exists for this function } } - setAvailableProfiles(profiles); - if (profiles.length > 0) { - setSelectedProfileIndex(0); - } - - const category = details.type === "vector.function" ? "Ranking" : "Scoring"; - - setFunctionDetails({ - owner, - repository, - commit: details.commit || "", - name: deriveDisplayName(repository), - description: details.description || `${deriveDisplayName(repository)} function`, - category, - type: details.type as "scalar.function" | "vector.function", - inputSchema: (details as { input_schema?: Record }).input_schema || null, - }); + // Function-specific profiles first, then defaults + setAvailableProfiles([...functionProfiles, ...DEFAULT_PROFILES]); + setSelectedProfileIndex(0); } catch (err) { setLoadError(err instanceof Error ? err.message : "Failed to load function"); } finally { @@ -697,22 +719,7 @@ export default function FunctionDetailPage({ params }: { params: Promise<{ slug: // Loading state if (isLoadingDetails) { - return ( -
-
-
-

Loading function...

-
-
- ); + return ; } // Error state @@ -824,47 +831,45 @@ export default function FunctionDetailPage({ params }: { params: Promise<{ slug: {renderInputFields()}
- {availableProfiles.length > 1 && ( -
-
- )} + Learned weights for this function + + + + {/* Reasoning Options */}
- {isRunning ? "Running..." : availableProfiles.length === 0 ? "No Profile Available" : "Execute"} + {isRunning ? "Running..." : "Execute"} - {availableProfiles.length === 0 && !isLoadingDetails && ( -

- This function has no profile yet. A profile with learned weights is required for execution. -

- )}
{/* Right - Results */} @@ -1328,6 +1322,7 @@ export default function FunctionDetailPage({ params }: { params: Promise<{ slug: )} + ); diff --git a/objectiveai-web/app/functions/page.tsx b/objectiveai-web/app/functions/page.tsx index 01ef8cd49..78b399f05 100644 --- a/objectiveai-web/app/functions/page.tsx +++ b/objectiveai-web/app/functions/page.tsx @@ -7,7 +7,7 @@ import { createPublicClient } from "../../lib/client"; import { deriveCategory, deriveDisplayName } from "../../lib/objectiveai"; import { NAV_HEIGHT_CALCULATION_DELAY_MS, STICKY_BAR_HEIGHT, STICKY_SEARCH_OVERLAP } from "../../lib/constants"; import { useResponsive } from "../../hooks/useResponsive"; -import { LoadingSpinner, ErrorAlert, EmptyState } from "../../components/ui"; +import { ErrorAlert, EmptyState, SkeletonCard } from "../../components/ui"; // Function item type for UI interface FunctionItem { @@ -60,36 +60,38 @@ export default function FunctionsPage() { } } - // Fetch details for each unique function via API route - const functionItems: FunctionItem[] = await Promise.all( - Array.from(uniqueFunctions.values()).map(async (fn) => { - const slug = `${fn.owner}/${fn.repository}`; - - // Fetch full function details via SDK - const details = await Functions.retrieve(client, "github", fn.owner, fn.repository, fn.commit); - - const category = deriveCategory(details); - const name = deriveDisplayName(fn.repository); - - // Extract tags from repository name - const tags = fn.repository.split("-").filter((t: string) => t.length > 2); - if (details.type === "vector.function") tags.push("ranking"); - else tags.push("scoring"); - - return { - slug, - owner: fn.owner, - repository: fn.repository, - commit: fn.commit, - name, - description: details.description || `${name} function`, - category, - tags, - }; + // Fetch details for each unique function (gracefully skip any that 404) + const results = await Promise.all( + Array.from(uniqueFunctions.values()).map(async (fn): Promise => { + try { + const slug = `${fn.owner}/${fn.repository}`; + + const details = await Functions.retrieve(client, "github", fn.owner, fn.repository, fn.commit); + + const category = deriveCategory(details); + const name = deriveDisplayName(fn.repository); + + const tags = fn.repository.split("-").filter((t: string) => t.length > 2); + if (details.type === "vector.function") tags.push("ranking"); + else tags.push("scoring"); + + return { + slug, + owner: fn.owner, + repository: fn.repository, + commit: fn.commit, + name, + description: details.description || `${name} function`, + category, + tags, + }; + } catch { + return null; + } }) ); - setFunctions(functionItems); + setFunctions(results.filter((item): item is FunctionItem => item !== null)); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load functions"); } finally { @@ -398,7 +400,21 @@ export default function FunctionsPage() { )} {isLoading && ( - +
+ {Array.from({ length: 9 }).map((_, i) => ( + + ))} +
)} {error && !isLoading && ( diff --git a/objectiveai-web/app/page.tsx b/objectiveai-web/app/page.tsx index c8fcadf6c..a98aa6ff2 100644 --- a/objectiveai-web/app/page.tsx +++ b/objectiveai-web/app/page.tsx @@ -52,31 +52,33 @@ export default function Home() { // Limit to FEATURED_COUNT const limitedFunctions = Array.from(uniqueFunctions.values()).slice(0, FEATURED_COUNT); - const functionItems: FeaturedFunction[] = await Promise.all( - limitedFunctions.map(async (fn) => { - const slug = `${fn.owner}/${fn.repository}`; - // Fetch full function details via SDK - const details = await Functions.retrieve(client, "github", fn.owner, fn.repository, fn.commit); + const results = await Promise.all( + limitedFunctions.map(async (fn): Promise => { + try { + const slug = `${fn.owner}/${fn.repository}`; + const details = await Functions.retrieve(client, "github", fn.owner, fn.repository, fn.commit); - const category = deriveCategory(details); - const name = deriveDisplayName(fn.repository); + const category = deriveCategory(details); + const name = deriveDisplayName(fn.repository); - // Extract tags from repository name - const tags = fn.repository.split("-").filter((t: string) => t.length > 2); - if (details.type === "vector.function") tags.push("ranking"); - else tags.push("scoring"); + const tags = fn.repository.split("-").filter((t: string) => t.length > 2); + if (details.type === "vector.function") tags.push("ranking"); + else tags.push("scoring"); - return { - slug, - name, - description: details.description || `${name} function`, - category, - tags, - }; + return { + slug, + name, + description: details.description || `${name} function`, + category, + tags, + }; + } catch { + return null; + } }) ); - setFunctions(functionItems); + setFunctions(results.filter((item): item is FeaturedFunction => item !== null)); } catch { // Silent failure - page still renders, just without featured functions } finally { diff --git a/objectiveai-web/components/SchemaForm/SchemaFormBuilder.tsx b/objectiveai-web/components/SchemaForm/SchemaFormBuilder.tsx index a8906e1b9..3157b52cb 100644 --- a/objectiveai-web/components/SchemaForm/SchemaFormBuilder.tsx +++ b/objectiveai-web/components/SchemaForm/SchemaFormBuilder.tsx @@ -3,7 +3,7 @@ import { useEffect, useCallback, useMemo } from "react"; import type { SchemaFormBuilderProps, ValidationError } from "./types"; import { validateValue } from "./validation"; -import { getDefaultValue } from "./utils"; +import { getDefaultValue, valueMatchesSchema } from "./utils"; import SchemaField from "./SchemaField"; import { useIsMobile } from "@/hooks/useIsMobile"; @@ -52,7 +52,7 @@ export default function SchemaFormBuilder({ // Initialize with default values if value is null/undefined const effectiveValue = useMemo(() => { - if (value === null || value === undefined) { + if (value === null || value === undefined || !valueMatchesSchema(value, schema)) { return getDefaultValue(schema); } return value; diff --git a/objectiveai-web/components/SchemaForm/fields/ArrayField.tsx b/objectiveai-web/components/SchemaForm/fields/ArrayField.tsx index 8873230dc..0be455f47 100644 --- a/objectiveai-web/components/SchemaForm/fields/ArrayField.tsx +++ b/objectiveai-web/components/SchemaForm/fields/ArrayField.tsx @@ -19,7 +19,7 @@ export default function ArrayField({ isMobile, depth = 0, }: ArrayFieldProps) { - const items = value ?? []; + const items = Array.isArray(value) ? value : []; const canAdd = schema.maxItems == null || items.length < schema.maxItems; const canRemove = items.length > (schema.minItems ?? 0); diff --git a/objectiveai-web/components/ui/SkeletonCard.tsx b/objectiveai-web/components/ui/SkeletonCard.tsx new file mode 100644 index 000000000..c03126021 --- /dev/null +++ b/objectiveai-web/components/ui/SkeletonCard.tsx @@ -0,0 +1,45 @@ +"use client"; + +/** + * Skeleton loading placeholder for function cards on the browse page. + * Matches the dimensions of a rendered function card. + */ +export function SkeletonCard() { + return ( +
+ {/* Category tag */} +
+ {/* Title */} +
+ {/* Description lines */} +
+
+
+
+
+ {/* Tags */} +
+
+
+
+ {/* Open link */} +
+
+ ); +} diff --git a/objectiveai-web/components/ui/SkeletonFunctionDetails.tsx b/objectiveai-web/components/ui/SkeletonFunctionDetails.tsx new file mode 100644 index 000000000..d383427a5 --- /dev/null +++ b/objectiveai-web/components/ui/SkeletonFunctionDetails.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useIsMobile } from "../../hooks/useIsMobile"; + +/** + * Full-page skeleton for the function detail page. + * Matches the layout of a loaded function page. + */ +export function SkeletonFunctionDetails() { + const isMobile = useIsMobile(); + + return ( +
+
+ {/* Breadcrumbs */} +
+
+ / +
+
+ {/* Title */} +
+ {/* Description */} +
+
+ {/* Tags row */} +
+
+
+
+
+ {/* Input area */} +
+ {/* Button */} +
+
+
+ ); +} diff --git a/objectiveai-web/components/ui/index.ts b/objectiveai-web/components/ui/index.ts index 5c1543d6a..d0861cfcc 100644 --- a/objectiveai-web/components/ui/index.ts +++ b/objectiveai-web/components/ui/index.ts @@ -15,3 +15,5 @@ export { LoadingSpinner } from "./LoadingSpinner"; export { ErrorAlert } from "./ErrorAlert"; export { EmptyState } from "./EmptyState"; export { FormField } from "./FormField"; +export { SkeletonCard } from "./SkeletonCard"; +export { SkeletonFunctionDetails } from "./SkeletonFunctionDetails"; diff --git a/objectiveai-web/lib/profiles.ts b/objectiveai-web/lib/profiles.ts new file mode 100644 index 000000000..70a2f6274 --- /dev/null +++ b/objectiveai-web/lib/profiles.ts @@ -0,0 +1,47 @@ +export interface DefaultProfile { + owner: string; + repository: string; + commit: null; + label: string; + description: string; +} + +export const DEFAULT_PROFILES: DefaultProfile[] = [ + { + owner: "ObjectiveAI", + repository: "profile-nano", + commit: null, + label: "Nano", + description: "4 non-reasoning LLMs, horizontal scaling", + }, + { + owner: "ObjectiveAI", + repository: "profile-mini", + commit: null, + label: "Mini", + description: "3x nano, stronger consensus", + }, + { + owner: "ObjectiveAI", + repository: "profile-standard", + commit: null, + label: "Standard", + description: "Mini's base + 5 reasoning LLMs", + }, + { + owner: "ObjectiveAI", + repository: "profile-giga", + commit: null, + label: "Giga", + description: "Standard + frontier models", + }, + { + owner: "ObjectiveAI", + repository: "profile-giga-max", + commit: null, + label: "Giga Max", + description: "3x frontier models, lightweight tie-breakers", + }, +]; + +export const DEFAULT_PROFILE_INDEX = 0; // Nano