diff --git a/src/components/shared/ComponentDetail/ComponentDetail.tsx b/src/components/shared/ComponentDetail/ComponentDetail.tsx new file mode 100644 index 000000000..6c1609dfb --- /dev/null +++ b/src/components/shared/ComponentDetail/ComponentDetail.tsx @@ -0,0 +1,357 @@ +import { CodeViewer } from "@/components/shared/CodeViewer"; +import { CopyText } from "@/components/shared/CopyText/CopyText"; +import { GithubDetails } from "@/components/shared/TaskDetails/GithubDetails"; +import { Badge } from "@/components/ui/badge"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { useHydrateComponentReference } from "@/hooks/useHydrateComponentReference"; +import { cn } from "@/lib/utils"; +import type { + ComponentReference, + InputSpec, + OutputSpec, +} from "@/utils/componentSpec"; +import { TOP_NAV_HEIGHT } from "@/utils/constants"; + +// ─── Compact I/O table ─────────────────────────────────────────────────────── + +// Minimal I/O row: name on the left, type+req/opt on the right, optional +// description on its own line. No box borders — a subtle divider between +// rows is enough to read as a list. +const IORow = ({ + name, + type, + required, + defaultValue, + description, + isLast, +}: { + name: string; + type?: unknown; + required?: boolean; + defaultValue?: unknown; + description?: string; + isLast?: boolean; +}) => ( +
+ + + {name} + + {type !== undefined && type !== null && ( + + {String(type)} + + )} + + {required ? "required" : "optional"} + + + {description && ( + + {description} + + )} + {defaultValue !== undefined && ( + + Default: {String(defaultValue)} + + )} +
+); + +const IOSection = ({ + label, + rows, +}: { + label: string; + rows: ReadonlyArray<{ + name: string; + type?: unknown; + required?: boolean; + defaultValue?: unknown; + description?: string; + }>; +}) => ( + // align="stretch" so the row list fills the section's width; BlockStack + // defaults to items-start otherwise. + + + {label} + +
+ {rows.map((row, idx) => ( + + ))} +
+
+); + +const CompactIO = ({ + inputs, + outputs, +}: { + inputs?: InputSpec[]; + outputs?: OutputSpec[]; +}) => ( + + {inputs && inputs.length > 0 && ( + ({ + name: i.name, + type: i.type, + required: !i.optional, + defaultValue: i.default, + description: i.description, + }))} + /> + )} + {outputs && outputs.length > 0 && ( + ({ + name: o.name, + type: o.type, + description: o.description, + }))} + /> + )} + +); + +// ─── Detail panel (suspends on hydration) ─────────────────────────────────── + +interface ComponentDetailProps { + /** + * The reference to render. Callers pass whatever they have — a hydrated ref + * (no network needed) or a stub with `digest`+`url` (one hydration round-trip). + * Hydration is keyed by digest/url, so cache is shared with other usages. + */ + reference: ComponentReference; + /** + * - `split` (V1 default): metadata+I/O on the left, sticky source code panel + * on the right. Best for full-bleed detail pages. + * - `stacked`: single column — metadata, I/O, then source code in a card with + * a capped height. Best for narrower detail panes alongside other content. + */ + layout?: "split" | "stacked"; + /** + * CSS height for the source code panel. In `split` layout this is the sticky + * right column's height (defaults to the remaining viewport height under the + * top nav). In `stacked` layout this caps the inline source card's height. + */ + sourcePanelHeight?: string; +} + +export const ComponentDetail = ({ + reference, + layout = "split", + sourcePanelHeight, +}: ComponentDetailProps) => { + const hydrated = useHydrateComponentReference(reference); + + if (!hydrated?.spec) { + return ( + + Could not load component details. + + ); + } + + const { spec } = hydrated; + const annotations = spec.metadata?.annotations ?? {}; + const author = + typeof annotations.author === "string" ? annotations.author : undefined; + const canonicalUrl = + typeof annotations.canonical_location === "string" + ? annotations.canonical_location + : undefined; + const gitRemoteUrl = + typeof annotations.git_remote_url === "string" + ? annotations.git_remote_url + : undefined; + const gitRemoteBranch = + typeof annotations.git_remote_branch === "string" + ? annotations.git_remote_branch + : undefined; + const gitRelativeDir = + typeof annotations.git_relative_dir === "string" + ? annotations.git_relative_dir + : undefined; + const componentYamlPath = + typeof annotations.component_yaml_path === "string" + ? annotations.component_yaml_path + : undefined; + const documentationPath = + typeof annotations.documentation_path === "string" + ? annotations.documentation_path + : undefined; + + let reconstructedUrl: string | undefined; + let documentationUrl: string | undefined; + + if (gitRemoteUrl && gitRemoteBranch && gitRelativeDir) { + const repoPath = gitRemoteUrl + .replace(/^https:\/\/github\.com\//, "") + .replace(/\.git$/, ""); + const buildGitHubUrl = (filePath: string) => + `https://github.com/${repoPath}/blob/${gitRemoteBranch}/${gitRelativeDir}/${filePath}`; + if (!hydrated.url && componentYamlPath) + reconstructedUrl = buildGitHubUrl(componentYamlPath); + if (documentationPath) documentationUrl = buildGitHubUrl(documentationPath); + } + + const hasIO = + (spec.inputs && spec.inputs.length > 0) || + (spec.outputs && spec.outputs.length > 0); + + // ── Shared sub-blocks ────────────────────────────────────────────────── + const header = ( + + {spec.name ?? hydrated.digest ?? ""} + {author && ( + + {author} + + )} + {hydrated.digest && ( + + + {hydrated.digest} + + + )} + + ); + + const description = spec.description && ( + // `[overflow-wrap:anywhere]` breaks at any character so long URLs/paths + // wrap inside the pane. `break-words` is insufficient — it only breaks + // at word boundaries, which doesn't help for URLs without spaces. + + {spec.description} + + ); + + const githubLinks = ( + + ); + + const io = hasIO && ; + + // ── Stacked layout ──────────────────────────────────────────────────── + // Single column — metadata, links, I/O, then source. Wraps source in a + // card so it's visually distinct from the rest and capped to a fixed + // height to keep the page scannable in narrower containers. + if (layout === "stacked") { + const stackedSourceHeight = sourcePanelHeight ?? "60vh"; + return ( + // align="stretch" so every child fills the container's width. Without + // this, BlockStack defaults to `items-start` and children collapse to + // their intrinsic width, which looks broken in wide panes. + + {header} + {description} + {githubLinks} + {io} + {hydrated.text && ( + // CodeViewer already provides its own dark frame + header bar, so + // no extra border/card chrome around it — keeps the look minimal. +
+ +
+ )} +
+ ); + } + + // ── Split layout (V1 default) ───────────────────────────────────────── + const splitSourceHeight = + sourcePanelHeight ?? `calc(100vh - ${TOP_NAV_HEIGHT + 48}px)`; + return ( + +
+ {header} + {description} + {githubLinks} + {io} +
+ + {hydrated.text && ( +
+
+ + Source + +
+ +
+
+
+ )} +
+ ); +}; + +export const ComponentDetailSkeleton = () => ( + +
+ + + + +
+ +
+); diff --git a/src/components/ui/typography.tsx b/src/components/ui/typography.tsx index 47663d0c2..69fdddbe4 100644 --- a/src/components/ui/typography.tsx +++ b/src/components/ui/typography.tsx @@ -67,6 +67,9 @@ interface TextProps */ className?: string; + /** HTML id used to associate text with form controls and ARIA descriptions. */ + id?: string; + /** Native browser tooltip text */ title?: string; } diff --git a/src/flags.ts b/src/flags.ts index f4e5c7287..46150c548 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -58,7 +58,7 @@ export const ExistingFlags: ConfigFlags = { ["component-search-v2"]: { name: "Component Search V2", description: - "Show the experimental Components V2 page in the dashboard. Uses placeholder data for now.", + "Show the experimental Components V2 page that searches across standard, published, registered, and user component sources, with optional AI rerank.", default: false, category: "beta", }, diff --git a/src/hooks/useComponentSearchSettings.test.ts b/src/hooks/useComponentSearchSettings.test.ts new file mode 100644 index 000000000..6200db766 --- /dev/null +++ b/src/hooks/useComponentSearchSettings.test.ts @@ -0,0 +1,149 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { useComponentSearchSettings } from "./useComponentSearchSettings"; + +const STORAGE_KEY = "tangle.componentSearchV2.config"; + +describe("useComponentSearchSettings", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("returns defaults when nothing is stored", () => { + const { result } = renderHook(() => useComponentSearchSettings()); + + expect(result.current.config).toEqual({ + apiBase: "", + apiKey: "", + model: "", + }); + expect(result.current.isConfigured).toBe(false); + }); + + it("reads stored values from localStorage", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-4o-mini", + }), + ); + + const { result } = renderHook(() => useComponentSearchSettings()); + + expect(result.current.config).toEqual({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-4o-mini", + }); + expect(result.current.isConfigured).toBe(true); + }); + + it("isConfigured requires apiBase, apiKey, and model", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "", + }), + ); + + const { result } = renderHook(() => useComponentSearchSettings()); + expect(result.current.isConfigured).toBe(false); + }); + + it("update() writes to localStorage and merges partial values", () => { + const { result } = renderHook(() => useComponentSearchSettings()); + + act(() => { + result.current.update({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-4o-mini", + }); + }); + + expect(result.current.config.model).toBe("gpt-4o-mini"); + expect(result.current.isConfigured).toBe(true); + + act(() => { + result.current.update({ model: "claude-3-5-haiku" }); + }); + + const stored = JSON.parse( + window.localStorage.getItem(STORAGE_KEY) as string, + ); + expect(stored).toEqual({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "claude-3-5-haiku", + }); + }); + + it("clear() removes the stored config", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-4o-mini", + }), + ); + + const { result } = renderHook(() => useComponentSearchSettings()); + + act(() => { + result.current.clear(); + }); + + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(result.current.isConfigured).toBe(false); + }); + + it("migrates legacy `thinkingModel` into `model` when model is unset", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + thinkingModel: "gpt-5-mini", + }), + ); + + const { result } = renderHook(() => useComponentSearchSettings()); + expect(result.current.config.model).toBe("gpt-5-mini"); + }); + + it("preserves legacy `thinkingModel` precedence when both models exist", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + apiBase: "https://api.example.com/v1", + apiKey: "sk-test", + model: "gpt-4o-mini", + thinkingModel: "gpt-5-mini", + }), + ); + + const { result } = renderHook(() => useComponentSearchSettings()); + expect(result.current.config.model).toBe("gpt-5-mini"); + }); + + it("falls back to defaults when stored JSON is malformed", () => { + window.localStorage.setItem(STORAGE_KEY, "not json"); + + const { result } = renderHook(() => useComponentSearchSettings()); + expect(result.current.config).toEqual({ + apiBase: "", + apiKey: "", + model: "", + }); + }); +}); diff --git a/src/hooks/useComponentSearchSettings.ts b/src/hooks/useComponentSearchSettings.ts index 84cc4d565..f11cd8af4 100644 --- a/src/hooks/useComponentSearchSettings.ts +++ b/src/hooks/useComponentSearchSettings.ts @@ -16,17 +16,14 @@ const STORAGE_KEY = "tangle.componentSearchV2.config"; export interface ComponentSearchConfig { apiBase: string; apiKey: string; - /** Fast / default model. */ + /** Model id used for AI search reranking (any OpenAI-compatible model). */ model: string; - /** Better-quality model used when the "Thinking" toggle is on. */ - thinkingModel: string; } const DEFAULTS: ComponentSearchConfig = { apiBase: "", apiKey: "", - model: "gemini-2.5-flash-lite", - thinkingModel: "gpt-5-mini", + model: "", }; function readStoredConfig(): ComponentSearchConfig { @@ -36,18 +33,32 @@ function readStoredConfig(): ComponentSearchConfig { if (!raw) return DEFAULTS; const parsed: unknown = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return DEFAULTS; - const p = parsed as Partial; + const record = parsed; + const legacyThinking = + "thinkingModel" in record && + typeof record.thinkingModel === "string" && + record.thinkingModel.trim().length > 0 + ? record.thinkingModel + : ""; + const storedModel = + "model" in record && + typeof record.model === "string" && + record.model.trim().length > 0 + ? record.model + : ""; + // Migration: previous reranking used `thinkingModel` when present, even if + // `model` was also stored. Preserve that precedence for existing users. + const model = legacyThinking || storedModel || DEFAULTS.model; return { - apiBase: typeof p.apiBase === "string" ? p.apiBase : DEFAULTS.apiBase, - apiKey: typeof p.apiKey === "string" ? p.apiKey : DEFAULTS.apiKey, - model: - typeof p.model === "string" && p.model.trim().length > 0 - ? p.model - : DEFAULTS.model, - thinkingModel: - typeof p.thinkingModel === "string" && p.thinkingModel.trim().length > 0 - ? p.thinkingModel - : DEFAULTS.thinkingModel, + apiBase: + "apiBase" in record && typeof record.apiBase === "string" + ? record.apiBase + : DEFAULTS.apiBase, + apiKey: + "apiKey" in record && typeof record.apiKey === "string" + ? record.apiKey + : DEFAULTS.apiKey, + model, }; } catch { return DEFAULTS; @@ -116,7 +127,10 @@ export function useComponentSearchSettings() { window.dispatchEvent(new Event("tangle:component-search-config")); }; - const isConfigured = config.apiBase.length > 0 && config.apiKey.length > 0; + const isConfigured = + config.apiBase.length > 0 && + config.apiKey.length > 0 && + config.model.length > 0; return { config, update, clear, isConfigured }; } diff --git a/src/hooks/useNaturalLanguageComponentSearch.ts b/src/hooks/useNaturalLanguageComponentSearch.ts index 94f22b0df..37ae09db1 100644 --- a/src/hooks/useNaturalLanguageComponentSearch.ts +++ b/src/hooks/useNaturalLanguageComponentSearch.ts @@ -25,15 +25,10 @@ interface RerankVariables { export function useNaturalLanguageComponentRerank() { const { config, isConfigured } = useComponentSearchSettings(); - // Prefer the thinking model for rerank — rerank is the moment we *want* - // careful judgment, and the payload is small enough that latency is fine. - // Fall back to the default model when no thinking model is configured. - const model = config.thinkingModel || config.model; - const mutation = useMutation({ mutationFn: ({ query, candidates }) => rerankComponentsByNaturalLanguage(query, candidates, { - model, + model: config.model, apiBase: config.apiBase, apiKey: config.apiKey, }), diff --git a/src/providers/ComponentLibraryProvider/libraries/setup.ts b/src/providers/ComponentLibraryProvider/libraries/setup.ts new file mode 100644 index 000000000..b72c351f2 --- /dev/null +++ b/src/providers/ComponentLibraryProvider/libraries/setup.ts @@ -0,0 +1,28 @@ +import { GitHubFlatComponentLibrary } from "@/components/shared/GitHubLibrary/githubFlatComponentLibrary"; +import { isGitHubLibraryConfiguration } from "@/components/shared/GitHubLibrary/types"; + +import { registerLibraryFactory } from "./factory"; + +/** + * Idempotent registration of library factories. The provider already registers + * the same factories at module load, but the dashboard search page reads + * libraries from Dexie directly (without mounting `ComponentLibraryProvider`, + * which is editor-scoped and depends on `ComponentSpecProvider`). Anywhere + * that needs to instantiate a stored library can call this first. + */ +let registered = false; + +export function ensureLibraryFactoriesRegistered() { + if (registered) return; + + registerLibraryFactory("github", (library) => { + if (!isGitHubLibraryConfiguration(library.configuration)) { + throw new Error( + `GitHub library configuration is not valid for "${library.id}"`, + ); + } + return new GitHubFlatComponentLibrary(library.configuration.repo_name); + }); + + registered = true; +} diff --git a/src/routes/Dashboard/DashboardComponentsV2View.test.tsx b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx new file mode 100644 index 000000000..00d80e0f3 --- /dev/null +++ b/src/routes/Dashboard/DashboardComponentsV2View.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + SourceFilterBar, + type SourceFilterOption, +} from "./DashboardComponentsV2View"; + +const options: SourceFilterOption[] = [ + { + source: { kind: "standard", label: "Standard", id: "standard" }, + count: 2, + }, + { + source: { kind: "registered", label: "GitHub", id: "github-lib" }, + count: 3, + }, + { + source: { kind: "user", label: "User", id: "user" }, + count: 1, + }, +]; + +describe("SourceFilterBar", () => { + it("toggles source buttons and exposes active state", () => { + const onToggle = vi.fn(); + const onEnableAll = vi.fn(); + + render( + , + ); + + expect( + screen.getByRole("button", { + name: "Hide Standard source (2 components)", + }), + ).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByRole("button", { name: "Show GitHub source (3 components)" }), + ).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click( + screen.getByRole("button", { name: "Show GitHub source (3 components)" }), + ); + expect(onToggle).toHaveBeenCalledWith("registered:github-lib"); + + fireEvent.click(screen.getByRole("button", { name: "Show all" })); + expect(onEnableAll).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/routes/Dashboard/DashboardComponentsV2View.tsx b/src/routes/Dashboard/DashboardComponentsV2View.tsx index d736182df..5ff2931e4 100644 --- a/src/routes/Dashboard/DashboardComponentsV2View.tsx +++ b/src/routes/Dashboard/DashboardComponentsV2View.tsx @@ -1,7 +1,14 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { type ChangeEvent, useDeferredValue, useState } from "react"; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { useLiveQuery } from "dexie-react-hooks"; +import { type ChangeEvent, useEffect, useState } from "react"; +import { listApiPublishedComponentsGet } from "@/api/sdk.gen"; +import { + ComponentDetail, + ComponentDetailSkeleton, +} from "@/components/shared/ComponentDetail/ComponentDetail"; +import { SuspenseWrapper } from "@/components/shared/SuspenseWrapper"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; @@ -9,20 +16,31 @@ import { Input } from "@/components/ui/input"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; +import { QuickTooltip } from "@/components/ui/tooltip"; import { Heading, Paragraph, Text } from "@/components/ui/typography"; import { getComponentQueryKey } from "@/hooks/useHydrateComponentReference"; import { useNaturalLanguageComponentRerank } from "@/hooks/useNaturalLanguageComponentSearch"; +import useToastNotification from "@/hooks/useToastNotification"; +import { cn } from "@/lib/utils"; +import { useBackend } from "@/providers/BackendProvider"; import { fetchUserComponents, - filterToUniqueByDigest, flattenFolders, } from "@/providers/ComponentLibraryProvider/componentLibrary"; +import { createLibraryObject } from "@/providers/ComponentLibraryProvider/libraries/factory"; +import { ensureLibraryFactoriesRegistered } from "@/providers/ComponentLibraryProvider/libraries/setup"; +import { + LibraryDB, + type StoredLibrary, +} from "@/providers/ComponentLibraryProvider/libraries/storage"; import { buildSearchIndex, + type ComponentSource, type IndexEntry, type LexicalMatch, lexicalSearch, type MatchField, + type SourcedReference, } from "@/services/componentSearchIndex"; import { fetchAndStoreComponentLibrary, @@ -33,18 +51,40 @@ import { NaturalLanguageSearchConfigError, type RerankedMatch, } from "@/services/naturalLanguageComponentSearchService"; +import type { ComponentFolder } from "@/types/componentLibrary"; import type { ComponentReference, HydratedComponentReference, } from "@/utils/componentSpec"; -import { HOURS } from "@/utils/constants"; +import { componentMetadata } from "@/utils/componentTracking"; +import { HOURS, TOP_NAV_HEIGHT } from "@/utils/constants"; import { getComponentName } from "@/utils/getComponentName"; +import { tracking } from "@/utils/tracking"; import { APP_ROUTES } from "../router"; +import { copyComponentReferenceToClipboard } from "../v2/shared/clipboard/copyComponentReferenceToClipboard"; // Repeated Tailwind combos extracted as named constants. const PANEL_CLASS = "p-3 rounded-lg bg-card border border-border"; -const PAGE_CLASS = "max-w-7xl"; + +// Maps V2's richer ComponentSource.kind onto the analytics-tracking taxonomy +// (see analytics-tracking skill: `component_source` enum is fixed). +const TRACKING_SOURCE_BY_KIND = { + standard: "library", + registered: "library", + published: "published", + user: "user", +} as const; + +// Source identity is communicated by the package icon's colour instead of a +// text badge — cleaner card, and the same colour shows up consistently across +// the list. Hover for the human-readable source name. +const SOURCE_ICON_TONE_BY_KIND: Record = { + standard: "text-blue-500", + published: "text-emerald-500", + registered: "text-violet-500", + user: "text-amber-500", +}; /** How many lexical hits to display (and to feed into rerank). */ const LEXICAL_RESULT_LIMIT = 20; @@ -56,75 +96,320 @@ const MATCH_FIELD_LABEL: Record = { implementation: "command", }; +export interface SourceFilterOption { + source: ComponentSource; + count: number; +} + +export function sourceFilterKey(source: ComponentSource): string { + return `${source.kind}:${source.id}`; +} + +export function createSourceFilterOptions( + index: IndexEntry[], +): SourceFilterOption[] { + const optionsByKey = new Map(); + + for (const entry of index) { + const key = sourceFilterKey(entry.source); + const option = optionsByKey.get(key); + if (option) { + option.count += 1; + } else { + optionsByKey.set(key, { source: entry.source, count: 1 }); + } + } + + return Array.from(optionsByKey.values()); +} + +interface SourceFilterBarProps { + options: SourceFilterOption[]; + disabledSourceKeys: string[]; + onToggle: (sourceKey: string) => void; + onEnableAll: () => void; +} + +export const SourceFilterBar = ({ + options, + disabledSourceKeys, + onToggle, + onEnableAll, +}: SourceFilterBarProps) => { + if (options.length <= 1) return null; + + const disabled = new Set(disabledSourceKeys); + const activeCount = options.filter( + (option) => !disabled.has(sourceFilterKey(option.source)), + ).length; + + return ( + + + + Sources + + {options.map(({ source, count }) => { + const key = sourceFilterKey(source); + const active = !disabled.has(key); + return ( + + ); + })} + {activeCount < options.length && ( + + )} + + {activeCount === 0 && ( + + No sources selected. Turn on at least one source to show components. + + )} + + ); +}; + +// Built-in sources are constants — only registered libraries vary per row. +const STANDARD_SOURCE: ComponentSource = { + kind: "standard", + label: "Standard", + id: "standard", +}; +const PUBLISHED_SOURCE: ComponentSource = { + kind: "published", + label: "Published", + id: "published", +}; +const USER_SOURCE: ComponentSource = { + kind: "user", + label: "User", + id: "user", +}; + +function registeredSource(library: StoredLibrary): ComponentSource { + return { kind: "registered", label: library.name, id: library.id }; +} + type ComponentLibraryFolder = Parameters[0]; type UserFolder = { components?: ComponentReference[] }; interface ComponentCardProps { reference: ComponentReference; + source?: ComponentSource; matchedFields?: MatchField[]; reason?: string; + isSelected?: boolean; + /** Position within the current result list — passed to analytics. */ + position?: number; + /** Whether the user had typed a query when this card was rendered. */ + hadQuery?: boolean; + onSelect: (reference: ComponentReference) => void; } const ComponentCard = ({ reference, + source, matchedFields, reason, + isSelected, + position, + hadQuery, + onSelect, }: ComponentCardProps) => { const name = getComponentName(reference); const description = reference.spec?.description; const publishedBy = reference.published_by; + const trackingSource = source + ? TRACKING_SOURCE_BY_KIND[source.kind] + : "unknown"; return ( - - - - - {name} - - {matchedFields?.map((field) => ( - - matched: {MATCH_FIELD_LABEL[field]} - - ))} - - {publishedBy && ( - - by {publishedBy} - - )} - {description && {description}} - {reason && ( - - Why: {reason} - + ); }; /** - * Build the flat, deduped list of unhydrated component references from the - * two library sources. Hydration happens separately because the static YAML - * library returns refs with only `url` and `digest` set — we need to fetch - * each YAML to get name/description/inputs/outputs. + * Merge every component source the rest of the app knows about into a single + * deduped, source-attributed list. + * + * Order matters: the first occurrence of a digest wins. Priority is + * `standard > published > registered > user` so the most canonical label + * sticks when the same component appears in multiple places. */ -function collectRawReferences( - componentLibrary: ComponentLibraryFolder | undefined, - userFolder: UserFolder | undefined, -): ComponentReference[] { - const standard = componentLibrary ? flattenFolders(componentLibrary) : []; - const user = userFolder?.components ?? []; - return filterToUniqueByDigest([...standard, ...user]); +function collectAllSourcedReferences({ + standardLibrary, + publishedRefs, + registeredSourced, + userFolder, +}: { + standardLibrary: ComponentLibraryFolder | undefined; + publishedRefs: ComponentReference[]; + registeredSourced: SourcedReference[]; + userFolder: UserFolder | undefined; +}): SourcedReference[] { + const all: SourcedReference[] = []; + + if (standardLibrary) { + for (const ref of flattenFolders(standardLibrary)) { + all.push({ reference: ref, source: STANDARD_SOURCE }); + } + } + for (const ref of publishedRefs) { + all.push({ reference: ref, source: PUBLISHED_SOURCE }); + } + for (const sr of registeredSourced) { + all.push(sr); + } + for (const ref of userFolder?.components ?? []) { + all.push({ reference: ref, source: USER_SOURCE }); + } + + // Dedupe by digest, preserving the first occurrence (which carries the + // higher-priority source label). Refs without digests are dropped — the + // search index requires them for LLM round-trip anyway. + const seen = new Set(); + const out: SourcedReference[] = []; + for (const item of all) { + const digest = item.reference.digest; + if (!digest || seen.has(digest)) continue; + seen.add(digest); + out.push(item); + } + return out; } export const DashboardComponentsV2View = () => { const queryClient = useQueryClient(); + const { backendUrl, configured, available } = useBackend(); const [query, setQuery] = useState(""); + const [disabledSourceKeys, setDisabledSourceKeys] = useState([]); - // Deferred query lets the input stay snappy while the (cheap) lexical - // search runs against the deferred value. React 19 native — no debounce - // library, no useEffect timers. - const deferredQuery = useDeferredValue(query); + // Detail-pane selection lives in the URL so refreshes preserve it and the + // selection can be linked-to. The V2 route has no validateSearch defined. + const navigate = useNavigate(); + const { component: selectedDigest } = useSearch({ strict: false }) as { + component?: string; + }; + const selectComponent = (reference: ComponentReference) => { + navigate({ + to: APP_ROUTES.DASHBOARD_COMPONENTS_V2, + search: { component: reference.digest }, + }); + }; + const closeDetail = () => { + navigate({ to: APP_ROUTES.DASHBOARD_COMPONENTS_V2, search: {} }); + }; + + // Close detail on Escape — only when something is open, so we don't fight + // other Esc handlers (e.g. inside Inputs). Navigate inline so the effect has + // no callback dep that would re-bind on every render. + useEffect(() => { + if (!selectedDigest) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + navigate({ to: APP_ROUTES.DASHBOARD_COMPONENTS_V2, search: {} }); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [selectedDigest, navigate]); + + // The dashboard search page doesn't mount `ComponentLibraryProvider` (which + // is editor-scoped), so the GitHub library factory isn't auto-registered. + // This runs once and is idempotent. + useEffect(() => { + ensureLibraryFactoriesRegistered(); + }, []); const { data: componentLibrary, isLoading: libraryLoading } = useQuery({ queryKey: ["componentLibrary"], @@ -139,11 +424,94 @@ export const DashboardComponentsV2View = () => { refetchOnMount: "always", }); - const rawReferences = collectRawReferences(componentLibrary, userFolder); + // Published components (backend). Gated on the backend being reachable; if + // it isn't, we silently search without published rather than erroring — + // matches the V1 dashboard behaviour. + const { data: publishedRefs = [], isLoading: publishedLoading } = useQuery({ + queryKey: ["component-search-v2", "published", backendUrl], + enabled: configured && available, + staleTime: HOURS, + queryFn: async (): Promise => { + const result = await listApiPublishedComponentsGet({}); + if (result.response.status !== 200 || !result.data) return []; + const list = result.data.published_components ?? []; + return list + .filter((c) => !c.deprecated) + .map((c) => ({ + digest: c.digest, + // Backend may return null; normalize to undefined to fit ComponentReference. + name: c.name ?? undefined, + url: c.url ?? `${backendUrl}/api/components/${c.digest}`, + published_by: c.published_by, + })); + }, + }); + + // Dexie is only the source of which libraries are registered. Fetching + // remote/GitHub library contents stays in TanStack Query so loading, errors, + // and cache lifetime follow the rest of the app's server-state conventions. + const registeredLibraries = useLiveQuery( + async () => { + ensureLibraryFactoriesRegistered(); + return LibraryDB.component_libraries.toArray(); + }, + [], + [], + ); + + const { data: registeredSourced = [], isLoading: registeredQueryLoading } = + useQuery({ + queryKey: [ + "component-search-v2", + "registered-libraries", + registeredLibraries, + ], + enabled: registeredLibraries !== undefined, + staleTime: HOURS, + queryFn: async (): Promise => { + if (!registeredLibraries || registeredLibraries.length === 0) return []; + + const results = await Promise.allSettled( + registeredLibraries.map(async (storage) => { + const lib = createLibraryObject(storage); + const folder: ComponentFolder = await lib.getComponents({}); + return { storage, folder }; + }), + ); + + const out: SourcedReference[] = []; + for (const result of results) { + if (result.status !== "fulfilled") { + // One broken library shouldn't kill the whole search. + console.warn( + "Components V2: registered library failed to load", + result.reason, + ); + continue; + } + const source = registeredSource(result.value.storage); + for (const ref of flattenFolders(result.value.folder)) { + out.push({ reference: ref, source }); + } + } + return out; + }, + }); + + const registeredLoading = + registeredLibraries === undefined || registeredQueryLoading; + + const allSourced = collectAllSourcedReferences({ + standardLibrary: componentLibrary, + publishedRefs, + registeredSourced, + userFolder, + }); + // Fingerprint of which refs are in play. Changes when the library set // changes, so the hydration cache invalidates appropriately. - const referencesFingerprint = rawReferences - .map((r) => r.digest ?? r.url ?? "") + const referencesFingerprint = allSourced + .map((s) => s.reference.digest ?? s.reference.url ?? "") .sort() .join("|"); @@ -151,18 +519,22 @@ export const DashboardComponentsV2View = () => { // background refetch shouldn't flip the page back to a skeleton state. const { data: hydratedReferences, isLoading: hydrating } = useQuery({ queryKey: ["component-search-v2", "hydrate-library", referencesFingerprint], - enabled: rawReferences.length > 0, + enabled: allSourced.length > 0, staleTime: HOURS, queryFn: async () => { const results = await Promise.all( - rawReferences.map((ref) => + allSourced.map((sourced) => // Reuse the same cache key as useHydrateComponentReference so // individual component cards elsewhere in the app share hydration. queryClient .ensureQueryData({ - queryKey: ["component", "hydrate", getComponentQueryKey(ref)], + queryKey: [ + "component", + "hydrate", + getComponentQueryKey(sourced.reference), + ], staleTime: HOURS, - queryFn: () => hydrateComponentReference(ref), + queryFn: () => hydrateComponentReference(sourced.reference), }) .catch(() => null), ), @@ -171,17 +543,35 @@ export const DashboardComponentsV2View = () => { }, }); - // The search index is a pure derivation from hydrated refs. React - // Compiler will memoize this; `hydratedReferences` is a stable reference - // from React Query when nothing has changed. - const index: IndexEntry[] = buildSearchIndex(hydratedReferences ?? []); - const total = index.length; + // Pair hydrated refs back with their source by digest. Hydration preserves + // digests, so this is a straightforward join. + const sourceByDigest = new Map( + allSourced.map((s) => [s.reference.digest, s.source] as const), + ); + const sourcedHydrated: SourcedReference[] = []; + for (const reference of hydratedReferences ?? []) { + const source = sourceByDigest.get(reference.digest); + if (!source) continue; + sourcedHydrated.push({ reference, source }); + } + + // The search index is a pure derivation. React Compiler will memoize this. + const index: IndexEntry[] = buildSearchIndex(sourcedHydrated); + const sourceFilterOptions = createSourceFilterOptions(index); + const disabledSourceKeySet = new Set(disabledSourceKeys); + const filteredIndex = index.filter( + (entry) => !disabledSourceKeySet.has(sourceFilterKey(entry.source)), + ); + const total = filteredIndex.length; + const totalAcrossSources = index.length; // Alphabetical order for the browse-all view. Predictable scrolling beats // "whatever order the library happened to load in." - const sortedIndex = [...index].sort((a, b) => a.name.localeCompare(b.name)); + const sortedIndex = [...filteredIndex].sort((a, b) => + a.name.localeCompare(b.name), + ); - const lexicalMatches: LexicalMatch[] = lexicalSearch(index, deferredQuery, { + const lexicalMatches: LexicalMatch[] = lexicalSearch(filteredIndex, query, { limit: LEXICAL_RESULT_LIMIT, }); @@ -201,8 +591,6 @@ export const DashboardComponentsV2View = () => { const handleQueryChange = (event: ChangeEvent) => { setQuery(event.target.value); - // Any edit invalidates the rerank. Cheaper to drop it than to think - // about staleness. if (rerankedFor !== null) { setRerankedFor(null); resetRerank(); @@ -223,8 +611,33 @@ export const DashboardComponentsV2View = () => { rerank({ query: trimmed, candidates }); }; - const isLoadingLibrary = libraryLoading || userLoading || hydrating; - const noLibraryData = !isLoadingLibrary && total === 0; + const handleSourceToggle = (sourceKey: string) => { + setDisabledSourceKeys((current) => + current.includes(sourceKey) + ? current.filter((key) => key !== sourceKey) + : [...current, sourceKey], + ); + if (rerankedFor !== null) { + setRerankedFor(null); + resetRerank(); + } + }; + + const handleEnableAllSources = () => { + setDisabledSourceKeys([]); + if (rerankedFor !== null) { + setRerankedFor(null); + resetRerank(); + } + }; + + const isLoadingLibrary = + libraryLoading || + userLoading || + publishedLoading || + registeredLoading || + hydrating; + const noLibraryData = !isLoadingLibrary && totalAcrossSources === 0; const trimmedQuery = query.trim(); const isEmpty = trimmedQuery.length === 0; const isConfigError = rerankError instanceof NaturalLanguageSearchConfigError; @@ -239,117 +652,272 @@ export const DashboardComponentsV2View = () => { ? mergeRerankIntoLexical(rerankData.matches, lexicalMatches) : lexicalMatches.map((m) => ({ ...m, reason: undefined })); - return ( - - - Components V2 - - Type to search your component library. Results match on name, - description, inputs/outputs, and container command. Use AI search to - rerank with an LLM when literal matching isn't enough. - - - - - - - + // Resolve the full reference for the selected digest. Prefer the already- + // hydrated copy (no extra network), fall back to the un-hydrated index + // entry, then to a backend stub. The shared ComponentDetail will suspend on + // hydration as needed and shares cache with the rest of the app. + const selectedReference: ComponentReference | undefined = (() => { + if (!selectedDigest) return undefined; + const hydrated = sourcedHydrated.find( + (s) => s.reference.digest === selectedDigest, + ); + if (hydrated) return hydrated.reference; + const indexed = allSourced.find( + (s) => s.reference.digest === selectedDigest, + ); + if (indexed) return indexed.reference; + return { + digest: selectedDigest, + url: `${backendUrl}/api/components/${selectedDigest}`, + }; + })(); + const isDetailOpen = Boolean(selectedDigest); + const notify = useToastNotification(); + + const handleCopyToPipeline = async () => { + if (!selectedReference) return; + try { + await copyComponentReferenceToClipboard(selectedReference); + notify( + "Component copied. Paste (Cmd/Ctrl+V) into a pipeline to add it.", + "success", + ); + } catch { + notify( + "Couldn't copy to clipboard. Check browser permissions and try again.", + "error", + ); + } + }; - {isLoadingLibrary && ( + // Render helpers — keeps the JSX below tidy. These read the closed-over + // state from the surrounding component; React Compiler memoises them. + const renderResults = () => { + if (isLoadingLibrary) { + return ( - )} - - {noLibraryData && ( + ); + } + if (noLibraryData) { + return ( No components found in your library. - )} - - {!isLoadingLibrary && isEmpty && !noLibraryData && ( - + ); + } + if (total === 0) { + return ( + + No components in the selected sources. + + ); + } + if (isEmpty) { + return ( + - {total} components in your library. Start typing to search. + {total} component{total === 1 ? "" : "s"} in selected sources. Start + typing to search. - {sortedIndex.map((entry) => ( - + {sortedIndex.map((entry, idx) => ( + ))} - )} - - {!isEmpty && lexicalMatches.length === 0 && !isLoadingLibrary && ( + ); + } + if (lexicalMatches.length === 0) { + return ( No components matched “{trimmedQuery}”. Try different terms or check for typos. - )} - - {!isConfigured && !isEmpty && lexicalMatches.length > 0 && ( - - - AI search unavailable - - - Configure an OpenAI-compatible API key to use AI search. Lexical - results above are unaffected. - - - - Configure in Settings → - - - - )} - - {rerankError && !isConfigError && rerankError instanceof Error && ( - - AI search failed: {rerankError.message} + ); + } + return ( + + + {rerankActive + ? `AI-reranked ${displayedResults.length} result${displayedResults.length === 1 ? "" : "s"} for “${trimmedQuery}”` + : `${displayedResults.length} result${displayedResults.length === 1 ? "" : "s"} for “${trimmedQuery}”`} - )} + {displayedResults.map((result, idx) => ( + + ))} + + ); + }; - {!isEmpty && displayedResults.length > 0 && ( - - - {rerankActive - ? `AI-reranked ${displayedResults.length} result${displayedResults.length === 1 ? "" : "s"} for “${trimmedQuery}”` - : `${displayedResults.length} result${displayedResults.length === 1 ? "" : "s"} for “${trimmedQuery}”`} - - {displayedResults.map((result) => ( - + {/* Header zone: page title, description, search input. shrink-0 so it + never gets squeezed by the body below. */} +
+ + + Components V2 + + Type to search across every component source — standard library, + your published components, registered libraries, and local user + components. Results match on name, description, inputs/outputs, + and container command. Use AI search to rerank with an LLM when + literal matching isn't enough. + + + + - ))} + + + - )} - +
+ + {/* Body zone: two scroll columns. `min-h-0` is required so the flex + children can shrink and scroll instead of growing the parent. */} +
+ {/* Results column — own scroll. When detail is open, narrows to a + fixed width with a divider; otherwise fills the whole body. */} +
+ {/* AI-search-unavailable banner and rerank error live in the + results column — they describe what just happened to the search + the user is looking at. */} + {!isConfigured && !isEmpty && lexicalMatches.length > 0 && ( + + + AI search unavailable + + + Configure an OpenAI-compatible API key to use AI search. Lexical + results above are unaffected. + + + + Configure in Settings → + + + + )} + {rerankError && !isConfigError && rerankError instanceof Error && ( + + AI search failed: {rerankError.message} + + )} + {renderResults()} +
+ + {/* Detail column — own scroll. Close button sticky to the top of + this column's scroll viewport so it stays reachable. */} + {isDetailOpen && selectedReference && ( +
+ {/* Sticky action row: copy + close. `float-right` here is + intentional — it lets the row sit above the content without + taking flow space, and the detail's first heading flows up + next to it. Wrapping in a sticky inline-block keeps both + buttons pinned together. */} +
+ + + + +
+ }> + + +
+ )} +
+ ); }; diff --git a/src/routes/Dashboard/DashboardComponentsView.tsx b/src/routes/Dashboard/DashboardComponentsView.tsx index 867dc608e..55870c360 100644 --- a/src/routes/Dashboard/DashboardComponentsView.tsx +++ b/src/routes/Dashboard/DashboardComponentsView.tsx @@ -3,11 +3,11 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; import { type ReactNode, useEffect, useState } from "react"; import type { ListPublishedComponentsResponse } from "@/api/types.gen"; -import { CodeViewer } from "@/components/shared/CodeViewer"; -import { CopyText } from "@/components/shared/CopyText/CopyText"; +import { + ComponentDetail, + ComponentDetailSkeleton, +} from "@/components/shared/ComponentDetail/ComponentDetail"; import { SuspenseWrapper } from "@/components/shared/SuspenseWrapper"; -import { GithubDetails } from "@/components/shared/TaskDetails/GithubDetails"; -import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, @@ -17,8 +17,7 @@ import { Icon } from "@/components/ui/icon"; import { Input } from "@/components/ui/input"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Skeleton } from "@/components/ui/skeleton"; -import { Heading, Paragraph, Text } from "@/components/ui/typography"; -import { useHydrateComponentReference } from "@/hooks/useHydrateComponentReference"; +import { Paragraph, Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; import { useAnalytics } from "@/providers/AnalyticsProvider"; import { useBackend } from "@/providers/BackendProvider"; @@ -29,11 +28,7 @@ import { import { APP_ROUTES } from "@/routes/router"; import { fetchAndStoreComponentLibrary } from "@/services/componentService"; import type { ComponentFolder } from "@/types/componentLibrary"; -import type { - ComponentReference, - InputSpec, - OutputSpec, -} from "@/utils/componentSpec"; +import type { ComponentReference } from "@/utils/componentSpec"; import { componentMetadata } from "@/utils/componentTracking"; import { TOP_NAV_HEIGHT } from "@/utils/constants"; import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling"; @@ -365,243 +360,12 @@ const ComponentList = ({ ); }; -// ─── Compact I/O table ─────────────────────────────────────────────────────── - -const IORow = ({ - name, - type, - required, - defaultValue, - description, -}: { - name: string; - type?: unknown; - required?: boolean; - defaultValue?: unknown; - description?: string; -}) => ( -
-
- - {name} - - {description && ( - - {description} - - )} - {defaultValue !== undefined && ( - - Default: {String(defaultValue)} - - )} -
- - {type ? String(type) : "—"} - - - {required ? "req" : "opt"} - -
-); - -const CompactIO = ({ - inputs, - outputs, -}: { - inputs?: InputSpec[]; - outputs?: OutputSpec[]; -}) => ( - - {inputs && inputs.length > 0 && ( -
- - Inputs - -
- {inputs.map((input) => ( - - ))} -
-
- )} - {outputs && outputs.length > 0 && ( -
- - Outputs - -
- {outputs.map((output) => ( - - ))} -
-
- )} -
-); - -// ─── Detail Panel (Suspense) ──────────────────────────────────────────────── - -const ComponentDetailInner = ({ digest }: { digest: string }) => { - const { backendUrl } = useBackend(); - const componentRef: ComponentReference = { - digest, - url: `${backendUrl}/api/components/${digest}`, - }; - const hydrated = useHydrateComponentReference(componentRef); - - if (!hydrated?.spec) { - return ( - - Could not load component details. - - ); - } - - const { spec } = hydrated; - const annotations = spec.metadata?.annotations ?? {}; - const author = - typeof annotations.author === "string" ? annotations.author : undefined; - const canonicalUrl = - typeof annotations.canonical_location === "string" - ? annotations.canonical_location - : undefined; - const gitRemoteUrl = - typeof annotations.git_remote_url === "string" - ? annotations.git_remote_url - : undefined; - const gitRemoteBranch = - typeof annotations.git_remote_branch === "string" - ? annotations.git_remote_branch - : undefined; - const gitRelativeDir = - typeof annotations.git_relative_dir === "string" - ? annotations.git_relative_dir - : undefined; - const componentYamlPath = - typeof annotations.component_yaml_path === "string" - ? annotations.component_yaml_path - : undefined; - const documentationPath = - typeof annotations.documentation_path === "string" - ? annotations.documentation_path - : undefined; - - let reconstructedUrl: string | undefined; - let documentationUrl: string | undefined; - - if (gitRemoteUrl && gitRemoteBranch && gitRelativeDir) { - const repoPath = gitRemoteUrl - .replace(/^https:\/\/github\.com\//, "") - .replace(/\.git$/, ""); - const buildGitHubUrl = (filePath: string) => - `https://github.com/${repoPath}/blob/${gitRemoteBranch}/${gitRelativeDir}/${filePath}`; - if (!hydrated.url && componentYamlPath) - reconstructedUrl = buildGitHubUrl(componentYamlPath); - if (documentationPath) documentationUrl = buildGitHubUrl(documentationPath); - } - - const hasIO = - (spec.inputs && spec.inputs.length > 0) || - (spec.outputs && spec.outputs.length > 0); - - return ( - // Side-by-side layout: info+IO on left, source code sticky on right - - {/* Left: metadata + I/O — flows naturally with the page scroll */} -
- {/* Header */} - - {spec.name ?? digest} - {author && ( - - {author} - - )} - {hydrated.digest && ( - - - {hydrated.digest} - - - )} - - - {spec.description && ( - - {spec.description} - - )} - - - - {hasIO && } -
- - {/* Right: source code — sticky so it stays in view while left side scrolls */} - {hydrated.text && ( -
-
- - Source - -
- -
-
-
- )} -
- ); -}; - // ─── Main View ────────────────────────────────────────────────────────────── export function DashboardComponentsView() { const [query, setQuery] = useState(""); const navigate = useNavigate(); + const { backendUrl } = useBackend(); // useSearch strict:false is required here — this route has no validateSearch defined const { component: selectedDigest } = useSearch({ strict: false }) as { component?: string; @@ -639,20 +403,13 @@ export function DashboardComponentsView() { {/* Right: detail panel — single scroll, source sticky on right */}
{selectedDigest ? ( - -
- - - - -
- - - } - > - + }> + ) : ( diff --git a/src/routes/Settings/sections/AgentSettings.test.tsx b/src/routes/Settings/sections/AgentSettings.test.tsx new file mode 100644 index 000000000..99e16c088 --- /dev/null +++ b/src/routes/Settings/sections/AgentSettings.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AgentSettings } from "./AgentSettings"; + +const STORAGE_KEY = "tangle.componentSearchV2.config"; +const mockNotify = vi.fn(); + +vi.mock("@/hooks/useToastNotification", () => ({ + default: () => mockNotify, +})); + +describe("AgentSettings", () => { + beforeEach(() => { + window.localStorage.clear(); + mockNotify.mockClear(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("shows inline feedback instead of saving when model is blank", () => { + render(); + + fireEvent.change(screen.getByLabelText("API base URL"), { + target: { value: "https://api.example.com/v1" }, + }); + fireEvent.change(screen.getByLabelText("API key"), { + target: { value: "sk-test" }, + }); + fireEvent.change(screen.getByLabelText("Model id"), { + target: { value: " " }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect( + screen.getByText("Enter a model id before saving."), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Model id")).toHaveAttribute( + "aria-invalid", + "true", + ); + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(mockNotify).not.toHaveBeenCalledWith( + "Agent settings saved", + "success", + ); + }); +}); diff --git a/src/routes/Settings/sections/AgentSettings.tsx b/src/routes/Settings/sections/AgentSettings.tsx index 661dfa4e2..6a043de78 100644 --- a/src/routes/Settings/sections/AgentSettings.tsx +++ b/src/routes/Settings/sections/AgentSettings.tsx @@ -3,6 +3,7 @@ import { type FormEvent, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Separator } from "@/components/ui/separator"; import { Heading, Paragraph, Text } from "@/components/ui/typography"; @@ -21,23 +22,36 @@ export function AgentSettings() { const [apiBase, setApiBase] = useState(config.apiBase); const [apiKey, setApiKey] = useState(config.apiKey); + const [model, setModel] = useState(config.model); + const [modelError, setModelError] = useState(null); const [showKey, setShowKey] = useState(false); const [testing, setTesting] = useState(false); // Keep the form in sync if `config` changes externally (other tab, etc.). // We snapshot the saved values and rehydrate the inputs whenever they // differ — not on every render — so the user can keep editing. - const savedRef = useRef({ apiBase: config.apiBase, apiKey: config.apiKey }); + const savedRef = useRef({ + apiBase: config.apiBase, + apiKey: config.apiKey, + model: config.model, + }); useEffect(() => { if ( savedRef.current.apiBase !== config.apiBase || - savedRef.current.apiKey !== config.apiKey + savedRef.current.apiKey !== config.apiKey || + savedRef.current.model !== config.model ) { - savedRef.current = { apiBase: config.apiBase, apiKey: config.apiKey }; + savedRef.current = { + apiBase: config.apiBase, + apiKey: config.apiKey, + model: config.model, + }; setApiBase(config.apiBase); setApiKey(config.apiKey); + setModel(config.model); + setModelError(null); } - }, [config.apiBase, config.apiKey]); + }, [config.apiBase, config.apiKey, config.model]); // Abort in-flight test connections if the user navigates away. const testAbortRef = useRef(null); @@ -51,11 +65,24 @@ export function AgentSettings() { event.preventDefault(); const trimmedBase = apiBase.trim(); const trimmedKey = apiKey.trim(); + const trimmedModel = model.trim(); // Reflect the trimmed values back into the inputs so what the user sees // matches what's stored. setApiBase(trimmedBase); setApiKey(trimmedKey); - update({ apiBase: trimmedBase, apiKey: trimmedKey }); + setModel(trimmedModel); + + if (!trimmedModel) { + setModelError("Enter a model id before saving."); + return; + } + + setModelError(null); + update({ + apiBase: trimmedBase, + apiKey: trimmedKey, + model: trimmedModel, + }); notify("Agent settings saved", "success"); }; @@ -63,6 +90,8 @@ export function AgentSettings() { clear(); setApiBase(""); setApiKey(""); + setModel(""); + setModelError(null); setShowKey(false); notify("Agent settings cleared", "success"); }; @@ -129,10 +158,9 @@ export function AgentSettings() {
- - API base URL - + - - API key - + + + + { + setModel(e.target.value); + if (modelError) setModelError(null); + }} + aria-label="Model id" + aria-invalid={modelError ? true : undefined} + aria-describedby={ + modelError + ? "agent-settings-model-error agent-settings-model-hint" + : "agent-settings-model-hint" + } + autoComplete="off" + spellCheck={false} + /> + {modelError && ( + + {modelError} + + )} + + Model id sent to the provider for AI search reranking. Must be + available on the provider above. + + +