From 2f8d93fe006d143656a20c2fc045e23e79352c4b Mon Sep 17 00:00:00 2001 From: Ricardo Cataldi Date: Tue, 9 Jun 2026 15:21:53 -0300 Subject: [PATCH 1/2] chore(ui): add eslint type declarations --- apps/ui/package.json | 3 ++- apps/ui/yarn.lock | 49 +++++++++++++++++++------------------------- 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/apps/ui/package.json b/apps/ui/package.json index 53f4d5d5..edde63bc 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -73,6 +73,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", + "@types/eslint": "8.56.12", "@types/jest": "^30.0.0", "@types/jest-axe": "^3.5.9", "@types/node": "^22.19.17", @@ -103,4 +104,4 @@ "typescript": "^5.7.2" }, "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" -} \ No newline at end of file +} diff --git a/apps/ui/yarn.lock b/apps/ui/yarn.lock index 6407eda4..a0d414da 100644 --- a/apps/ui/yarn.lock +++ b/apps/ui/yarn.lock @@ -1626,6 +1626,19 @@ resolved "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz" integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== +"@types/eslint@8.56.12": + version "8.56.12" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.12.tgz#1657c814ffeba4d2f84c0d4ba0f44ca7ea1ca53a" + integrity sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz" @@ -1677,6 +1690,11 @@ "@types/tough-cookie" "*" parse5 "^7.0.0" +"@types/json-schema@*": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" @@ -6792,16 +6810,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6903,14 +6912,7 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7431,16 +7433,7 @@ word-wrap@^1.2.5: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 55b72dc816215ae484493188adb56595e45755aa Mon Sep 17 00:00:00 2001 From: Ricardo Cataldi Date: Tue, 9 Jun 2026 16:46:21 -0300 Subject: [PATCH 2/2] feat(ui): migrate search feature module --- apps/ui/README.md | 3 +- apps/ui/app/(builder)/layout.tsx | 2 +- apps/ui/app/(deploy)/layout.tsx | 2 +- apps/ui/app/(retailer)/layout.tsx | 2 +- apps/ui/app/page.tsx | 2 +- apps/ui/app/product/ProductPageClient.tsx | 4 +- apps/ui/app/search/page.tsx | 352 +---------------- apps/ui/components/demo/ExecutiveDemoPage.tsx | 2 +- .../enrichment/AttributeDiffView.tsx | 4 +- apps/ui/components/organisms/ChatWidget.tsx | 8 +- .../search/components}/AppSearchBox.tsx | 6 +- .../search/components}/IntentPanel.tsx | 6 +- .../components}/SearchComparisonScorecard.tsx | 0 .../components}/SearchModeIndicator.tsx | 2 +- .../search/components}/SearchModeToggle.tsx | 4 +- .../features/search/components/SearchPage.tsx | 355 ++++++++++++++++++ .../search/components}/SearchResultCard.tsx | 12 +- .../search}/hooks/useIntelligentSearch.ts | 2 +- .../search}/hooks/useSemanticSearch.ts | 0 .../search}/hooks/useStreamingSearch.ts | 0 apps/ui/src/features/search/index.ts | 52 +++ .../features/search/internal}/appPages.ts | 0 .../features/search/internal}/matcher.ts | 0 .../search}/services/semanticSearchService.ts | 8 +- apps/ui/src/features/search/types/index.ts | 25 ++ .../truth/components/ProposalCard.tsx | 4 +- .../truth/components/ReviewQueueTable.tsx | 2 +- apps/ui/src/features/truth/index.ts | 4 +- .../components/ConfidenceBadge.tsx | 0 .../IntentClassificationDisplay.tsx | 2 +- .../components}/RelatedProductsRail.tsx | 2 +- .../shared/components}/UseCaseTags.tsx | 2 +- apps/ui/tests/unit/AppSearchBox.test.tsx | 2 +- apps/ui/tests/unit/ChatWidget.test.tsx | 3 +- apps/ui/tests/unit/SearchPage.test.tsx | 337 ++++++++--------- apps/ui/tests/unit/appSearchMatcher.test.ts | 4 +- apps/ui/tests/unit/appSearchUrlSeed.test.tsx | 2 +- .../tests/unit/enrichmentComponents.test.tsx | 12 +- apps/ui/tests/unit/pagesRender.test.tsx | 3 +- .../tests/unit/semanticSearchService.test.ts | 2 +- .../tests/unit/useIntelligentSearch.test.tsx | 55 ++- 41 files changed, 675 insertions(+), 614 deletions(-) rename apps/ui/{components/molecules => src/features/search/components}/AppSearchBox.tsx (98%) rename apps/ui/{components/enrichment => src/features/search/components}/IntentPanel.tsx (93%) rename apps/ui/{components/enrichment => src/features/search/components}/SearchComparisonScorecard.tsx (100%) rename apps/ui/{components/enrichment => src/features/search/components}/SearchModeIndicator.tsx (92%) rename apps/ui/{components/enrichment => src/features/search/components}/SearchModeToggle.tsx (94%) create mode 100644 apps/ui/src/features/search/components/SearchPage.tsx rename apps/ui/{components/enrichment => src/features/search/components}/SearchResultCard.tsx (89%) rename apps/ui/{lib => src/features/search}/hooks/useIntelligentSearch.ts (99%) rename apps/ui/{lib => src/features/search}/hooks/useSemanticSearch.ts (100%) rename apps/ui/{lib => src/features/search}/hooks/useStreamingSearch.ts (100%) create mode 100644 apps/ui/src/features/search/index.ts rename apps/ui/{lib/search => src/features/search/internal}/appPages.ts (100%) rename apps/ui/{lib/search => src/features/search/internal}/matcher.ts (100%) rename apps/ui/{lib => src/features/search}/services/semanticSearchService.ts (98%) create mode 100644 apps/ui/src/features/search/types/index.ts rename apps/ui/src/{features/truth => shared}/components/ConfidenceBadge.tsx (100%) rename apps/ui/{components/enrichment => src/shared/components}/IntentClassificationDisplay.tsx (94%) rename apps/ui/{components/enrichment => src/shared/components}/RelatedProductsRail.tsx (98%) rename apps/ui/{components/enrichment => src/shared/components}/UseCaseTags.tsx (95%) diff --git a/apps/ui/README.md b/apps/ui/README.md index 2839e7f9..418d5a08 100644 --- a/apps/ui/README.md +++ b/apps/ui/README.md @@ -37,7 +37,8 @@ yarn --cwd apps/ui type-check ## Feature import boundaries - Cross-feature deep imports are rejected by ESLint. Import features only via their public `index.ts`. -- **Migrated features**: `truth` (review queue, enrichment monitoring, schemas, analytics) +- Shared UI primitives that are reused by multiple features live under `src/shared/`. +- **Migrated features**: `truth` (review queue, enrichment monitoring, schemas, analytics), `search` (search route, app search, search hooks, semantic search service) ## Configuration notes diff --git a/apps/ui/app/(builder)/layout.tsx b/apps/ui/app/(builder)/layout.tsx index 5c3ecbb8..0c00a3ec 100644 --- a/apps/ui/app/(builder)/layout.tsx +++ b/apps/ui/app/(builder)/layout.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react'; -import { AppSearchBox } from '@/components/molecules/AppSearchBox'; import { LaneSwitch } from '@/components/shared/LaneSwitch'; import { SectionShell } from '@/components/shared/SectionShell'; +import { AppSearchBox } from '@/src/features/search'; /** * (builder) route group layout. diff --git a/apps/ui/app/(deploy)/layout.tsx b/apps/ui/app/(deploy)/layout.tsx index a39dd6d7..8acc6878 100644 --- a/apps/ui/app/(deploy)/layout.tsx +++ b/apps/ui/app/(deploy)/layout.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react'; -import { AppSearchBox } from '@/components/molecules/AppSearchBox'; import { LaneSwitch } from '@/components/shared/LaneSwitch'; import { SectionShell } from '@/components/shared/SectionShell'; +import { AppSearchBox } from '@/src/features/search'; /** * (deploy) route group layout. diff --git a/apps/ui/app/(retailer)/layout.tsx b/apps/ui/app/(retailer)/layout.tsx index 7f187638..a4a73d27 100644 --- a/apps/ui/app/(retailer)/layout.tsx +++ b/apps/ui/app/(retailer)/layout.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react'; -import { AppSearchBox } from '@/components/molecules/AppSearchBox'; import { LaneSwitch } from '@/components/shared/LaneSwitch'; import { SectionShell } from '@/components/shared/SectionShell'; +import { AppSearchBox } from '@/src/features/search'; /** * (retailer) route group layout. diff --git a/apps/ui/app/page.tsx b/apps/ui/app/page.tsx index 7df009df..31189ed8 100644 --- a/apps/ui/app/page.tsx +++ b/apps/ui/app/page.tsx @@ -1,11 +1,11 @@ import type { Metadata } from 'next'; -import { AppSearchBox } from '@/components/molecules/AppSearchBox'; import { CallToAction } from '@/components/molecules/CallToAction'; import { Hero } from '@/components/molecules/Hero'; import { ValuePropGrid } from '@/components/molecules/ValuePropGrid'; import { HomeShell } from '@/components/templates/HomeShell'; import { buildMetadata } from '@/lib/seo'; +import { AppSearchBox } from '@/src/features/search'; export const metadata: Metadata = buildMetadata({ section: 'home', diff --git a/apps/ui/app/product/ProductPageClient.tsx b/apps/ui/app/product/ProductPageClient.tsx index f68fc7ff..a6093ab5 100644 --- a/apps/ui/app/product/ProductPageClient.tsx +++ b/apps/ui/app/product/ProductPageClient.tsx @@ -15,8 +15,8 @@ import { useCategories } from '@/lib/hooks/useCategories'; import { mapApiProductToUiProduct } from '@/lib/utils/productMappers'; import { formatAgentResponse, type AgentMessageView } from '@/lib/utils/agentResponseCards'; import AgentMessageDisplay from '@/components/organisms/AgentMessageDisplay'; -import { UseCaseTags } from '@/components/enrichment/UseCaseTags'; -import { RelatedProductsRail } from '@/components/enrichment/RelatedProductsRail'; +import { RelatedProductsRail } from '@/src/shared/components/RelatedProductsRail'; +import { UseCaseTags } from '@/src/shared/components/UseCaseTags'; import { useRelatedProducts } from '@/lib/hooks/useRelatedProducts'; import agentApiClient from '@/lib/api/agentClient'; import { recordAgentInvocationTelemetry } from '@/lib/hooks/useAgentInvocationTelemetry'; diff --git a/apps/ui/app/search/page.tsx b/apps/ui/app/search/page.tsx index 2a3f73e9..90fd4576 100644 --- a/apps/ui/app/search/page.tsx +++ b/apps/ui/app/search/page.tsx @@ -1,351 +1,3 @@ -'use client'; +import { SearchPage } from '@/src/features/search'; -import React, { useEffect, useMemo, useState } from 'react'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { CommerceAgentLayout } from '@/components/templates/CommerceAgentLayout'; -import { SearchInput } from '@/components/molecules/SearchInput'; -import { Alert } from '@/components/molecules/Alert'; -import { Button } from '@/components/atoms/Button'; -import { SearchModeToggle } from '@/components/enrichment/SearchModeToggle'; -import { SearchModeIndicator } from '@/components/enrichment/SearchModeIndicator'; -import { IntentClassificationDisplay } from '@/components/enrichment/IntentClassificationDisplay'; -import { IntentPanel } from '@/components/enrichment/IntentPanel'; -import { SearchResultCard } from '@/components/enrichment/SearchResultCard'; -import { - SearchComparisonScorecard, - type SearchComparisonItem, -} from '@/components/enrichment/SearchComparisonScorecard'; -import { useIntelligentSearch } from '@/lib/hooks/useIntelligentSearch'; -import { useRelatedProducts } from '@/lib/hooks/useRelatedProducts'; -import { useAuth } from '@/contexts/AuthContext'; - -type ProxyErrorShape = { - status?: number; - details?: { - proxy?: { - failureKind?: 'config' | 'network' | 'upstream'; - remediation?: string[]; - }; - }; -}; - -type ProxyFailureShape = { - failureKind: 'config' | 'network' | 'upstream'; - remediation?: string[]; -}; - -function getProxyFailureError(error: unknown): ProxyFailureShape | null { - if (!error || typeof error !== 'object') { - return null; - } - - const proxyError = error as ProxyErrorShape; - const proxyFailure = proxyError.details?.proxy; - - if (proxyError.status !== 502 || !proxyFailure?.failureKind) { - return null; - } - - return { - failureKind: proxyFailure.failureKind, - remediation: proxyFailure.remediation, - }; -} - -function parseScore(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined; - } - - return Number(value.toFixed(2)); -} - -function toScorecardItems(items: Array>): SearchComparisonItem[] { - return items.map((item, index) => { - const fallbackSku = `item-${index + 1}`; - return { - sku: String(item.sku ?? item.id ?? fallbackSku), - score: parseScore(item.score ?? item.relevanceScore), - }; - }); -} - -export default function SearchPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const initialQuery = searchParams.get('q') ?? ''; - const [query, setQuery] = useState(initialQuery); - const { user } = useAuth(); - - const { - data, - baselineData, - rerankedData, - isLoading, - error, - refetch, - isFetching, - isReranking, - preference, - setPreference, - resolvedMode, - } = useIntelligentSearch(query, 20, { - userId: user?.user_id, - }); - const products = useMemo(() => data?.items ?? [], [data?.items]); - const relatedProductIds = useMemo(() => { - const ids = new Set(); - for (const product of products) { - for (const relatedId of product.complementaryProducts || []) { - ids.add(relatedId); - } - for (const relatedId of product.substituteProducts || []) { - ids.add(relatedId); - } - } - return Array.from(ids); - }, [products]); - const { data: relatedProductMap = {} } = useRelatedProducts(relatedProductIds); - const proxyFailure = getProxyFailureError(error); - const searchSource = data?.source; - const fallbackReason = data?.fallback_reason; - const isAgentDegradedFallback = data?.source === 'agent' && data?.degraded === true; - const degradedFallbackKeywords = data?.fallback_keywords || []; - const isIntelligentFallback = - data?.requested_mode === 'intelligent' && data?.source === 'crud'; - const showUnavailableAgentFallbackAlert = - isIntelligentFallback && fallbackReason !== 'agent_mock'; - const comparisonData = useMemo<{ - intelligent: SearchComparisonItem[]; - keyword: SearchComparisonItem[]; - } | null>(() => { - if (!query.trim()) { - return null; - } - - const keywordItems = toScorecardItems( - (baselineData?.items || []) as unknown as Array>, - ); - const intelligentItems = toScorecardItems( - (rerankedData?.items || []) as unknown as Array>, - ); - - if (keywordItems.length === 0 && intelligentItems.length === 0) { - return null; - } - - return { - keyword: keywordItems, - intelligent: intelligentItems, - }; - }, [query, baselineData?.items, rerankedData?.items]); - - const proxyFailureLabelByKind: Record<'config' | 'network' | 'upstream', string> = { - config: 'Catalog search proxy configuration is missing or invalid.', - network: 'Catalog search backend is temporarily unreachable.', - upstream: 'Catalog search backend returned a temporary gateway error.', - }; - const robotState = isLoading || isFetching || isReranking - ? 'thinking' - : query - ? 'talking' - : 'idle'; - - useEffect(() => { - setQuery(initialQuery); - }, [initialQuery]); - - const handleSearch = (value: string) => { - const trimmed = value.trim(); - setQuery(trimmed); - if (trimmed) { - router.push(`/search?q=${encodeURIComponent(trimmed)}`); - return; - } - - router.push('/search'); - }; - - return ( - -
-

Search

-
- - Demo flow - - - - Open popup comparison - -
- - {data?.trace_id ? 'View search trace' : 'View Agent Activity'} - - - -
- {query ? : null} - {resolvedMode === 'intelligent' && ( - - )} -
- {query && showUnavailableAgentFallbackAlert ? ( - - Results are from CRUD catalog search because the agent path was unavailable. - - ) : null} - {query && isAgentDegradedFallback ? ( - -
-

- {data?.degraded_message - || 'Showing the best available catalog guidance while intelligent generation is temporarily unavailable.'} -

- {degradedFallbackKeywords.length > 0 ? ( -

- Fallback keywords: {degradedFallbackKeywords.join(', ')} -

- ) : null} -
-
- ) : null} - - {query && ( -
- {isReranking ? ( -
- Reranking baseline results in the background... -
- ) : null} - {comparisonData ? ( - - ) : null} -
- )} -
- - {query ? ( -
- - {resolvedMode === 'intelligent' ? 'Intelligent Search' : 'Keyword Search'} • Source:{' '} - {searchSource === 'agent' ? 'Agent API' : 'CRUD Catalog'} - -
- ) : null} - - {isLoading ? ( -
- {Array.from({ length: 6 }).map((_, index) => ( -
- ))} -
- ) : products.length === 0 ? ( -

- {query ? 'No products matched your search.' : 'Search for products above.'} -

- ) : ( -
- {products.map((product) => ( - - ))} -
- )} - - {proxyFailure && query && ( - -
-

{proxyFailureLabelByKind[proxyFailure.failureKind]}

- -
-
- )} - - - ); -} +export default SearchPage; diff --git a/apps/ui/components/demo/ExecutiveDemoPage.tsx b/apps/ui/components/demo/ExecutiveDemoPage.tsx index 805a2721..8b5a0b27 100644 --- a/apps/ui/components/demo/ExecutiveDemoPage.tsx +++ b/apps/ui/components/demo/ExecutiveDemoPage.tsx @@ -27,8 +27,8 @@ import { useOrders } from '@/lib/hooks/useOrders'; import { useProductSimilarity } from '@/lib/hooks/useProductSimilarity'; import { useProducts } from '@/lib/hooks/useProducts'; import { useReturns } from '@/lib/hooks/useReturns'; -import { useStreamingSearch } from '@/lib/hooks/useStreamingSearch'; import { useTruthAnalyticsSummary } from '@/src/features/truth'; +import { useStreamingSearch } from '@/src/features/search'; import type { AgentHealthCardMetric, AgentModelUsageRow } from '@/lib/types/api'; import { formatAgentResponse } from '@/lib/utils/agentResponseCards'; import { mapApiProductsToUi } from '@/lib/utils/productMappers'; diff --git a/apps/ui/components/enrichment/AttributeDiffView.tsx b/apps/ui/components/enrichment/AttributeDiffView.tsx index 208f833a..d15d0b87 100644 --- a/apps/ui/components/enrichment/AttributeDiffView.tsx +++ b/apps/ui/components/enrichment/AttributeDiffView.tsx @@ -1,8 +1,8 @@ import React from 'react'; import type { EnrichmentAttributeDiff } from '@/lib/types/api'; import { Card } from '../molecules/Card'; -import { ConfidenceBadge } from '@/src/features/truth'; -import { IntentClassificationDisplay } from './IntentClassificationDisplay'; +import { ConfidenceBadge } from '@/src/shared/components/ConfidenceBadge'; +import { IntentClassificationDisplay } from '@/src/shared/components/IntentClassificationDisplay'; export interface AttributeDiffViewProps { diffs: EnrichmentAttributeDiff[]; diff --git a/apps/ui/components/organisms/ChatWidget.tsx b/apps/ui/components/organisms/ChatWidget.tsx index 49704b2a..3416690f 100644 --- a/apps/ui/components/organisms/ChatWidget.tsx +++ b/apps/ui/components/organisms/ChatWidget.tsx @@ -6,9 +6,11 @@ import { usePathname, useSearchParams } from 'next/navigation'; import { Button } from '@/components/atoms/Button'; import { FiMessageSquare, FiSend, FiMinimize2, FiRefreshCw } from 'react-icons/fi'; import { Card } from '@/components/molecules/Card'; -import { SearchComparisonScorecard } from '@/components/enrichment/SearchComparisonScorecard'; -import { semanticSearchService } from '@/lib/services/semanticSearchService'; -import type { StreamingSearchCallbacks } from '@/lib/services/semanticSearchService'; +import { + SearchComparisonScorecard, + semanticSearchService, + type StreamingSearchCallbacks, +} from '@/src/features/search'; type ProductPreview = { sku: string; diff --git a/apps/ui/components/molecules/AppSearchBox.tsx b/apps/ui/src/features/search/components/AppSearchBox.tsx similarity index 98% rename from apps/ui/components/molecules/AppSearchBox.tsx rename to apps/ui/src/features/search/components/AppSearchBox.tsx index 15183282..242606e4 100644 --- a/apps/ui/components/molecules/AppSearchBox.tsx +++ b/apps/ui/src/features/search/components/AppSearchBox.tsx @@ -12,8 +12,8 @@ import { useState, } from 'react'; -import { type AppAudience, AUDIENCE_FILTER } from '@/lib/search/appPages'; -import { buildDocsSearchUrl, searchAppPages, type SearchHit } from '@/lib/search/matcher'; +import { type AppAudience, AUDIENCE_FILTER } from '../internal/appPages'; +import { buildDocsSearchUrl, searchAppPages, type SearchHit } from '../internal/matcher'; /** * AppSearchBox — lightweight in-app search (Issue #1022). @@ -21,7 +21,7 @@ import { buildDocsSearchUrl, searchAppPages, type SearchHit } from '@/lib/search * Per ADR-034 + capability 42 the platform ships TWO search boxes, not one: * - mkdocs Material's built-in search serves `/docs/*`. * - This component serves `/retailers/*`, `/builders/*`, `/deploy/*`, and - * home, indexing the audience-IA page manifest in `lib/search/appPages.ts`. + * home, indexing the audience-IA page manifest in `features/search/internal/appPages.ts`. * * Cross-discovery is achieved by an explicit "Search the docs instead →" * footer link inside this dropdown (and a reciprocal link injected into the diff --git a/apps/ui/components/enrichment/IntentPanel.tsx b/apps/ui/src/features/search/components/IntentPanel.tsx similarity index 93% rename from apps/ui/components/enrichment/IntentPanel.tsx rename to apps/ui/src/features/search/components/IntentPanel.tsx index 87c3bf3f..39944286 100644 --- a/apps/ui/components/enrichment/IntentPanel.tsx +++ b/apps/ui/src/features/search/components/IntentPanel.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { Card } from '../molecules/Card'; -import { ConfidenceBadge } from '@/src/features/truth'; -import type { SemanticSearchIntent } from '@/lib/services/semanticSearchService'; +import { Card } from '@/components/molecules/Card'; +import { ConfidenceBadge } from '@/src/shared/components/ConfidenceBadge'; +import type { SemanticSearchIntent } from '../services/semanticSearchService'; export interface IntentPanelProps { mode: 'keyword' | 'intelligent'; diff --git a/apps/ui/components/enrichment/SearchComparisonScorecard.tsx b/apps/ui/src/features/search/components/SearchComparisonScorecard.tsx similarity index 100% rename from apps/ui/components/enrichment/SearchComparisonScorecard.tsx rename to apps/ui/src/features/search/components/SearchComparisonScorecard.tsx diff --git a/apps/ui/components/enrichment/SearchModeIndicator.tsx b/apps/ui/src/features/search/components/SearchModeIndicator.tsx similarity index 92% rename from apps/ui/components/enrichment/SearchModeIndicator.tsx rename to apps/ui/src/features/search/components/SearchModeIndicator.tsx index 0efd047f..5b4063a0 100644 --- a/apps/ui/components/enrichment/SearchModeIndicator.tsx +++ b/apps/ui/src/features/search/components/SearchModeIndicator.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Badge } from '../atoms/Badge'; +import { Badge } from '@/components/atoms/Badge'; export interface SearchModeIndicatorProps { source: 'agent' | 'crud' | 'fallback'; diff --git a/apps/ui/components/enrichment/SearchModeToggle.tsx b/apps/ui/src/features/search/components/SearchModeToggle.tsx similarity index 94% rename from apps/ui/components/enrichment/SearchModeToggle.tsx rename to apps/ui/src/features/search/components/SearchModeToggle.tsx index bad278b3..244c98f1 100644 --- a/apps/ui/components/enrichment/SearchModeToggle.tsx +++ b/apps/ui/src/features/search/components/SearchModeToggle.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Badge } from '../atoms/Badge'; -import type { IntelligentSearchPreference } from '@/lib/hooks/useIntelligentSearch'; +import { Badge } from '@/components/atoms/Badge'; +import type { IntelligentSearchPreference } from '../hooks/useIntelligentSearch'; export interface SearchModeToggleProps { preference: IntelligentSearchPreference; diff --git a/apps/ui/src/features/search/components/SearchPage.tsx b/apps/ui/src/features/search/components/SearchPage.tsx new file mode 100644 index 00000000..3107a402 --- /dev/null +++ b/apps/ui/src/features/search/components/SearchPage.tsx @@ -0,0 +1,355 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; + +import { Button } from '@/components/atoms/Button'; +import { Alert } from '@/components/molecules/Alert'; +import { SearchInput } from '@/components/molecules/SearchInput'; +import { CommerceAgentLayout } from '@/components/templates/CommerceAgentLayout'; +import { useAuth } from '@/contexts/AuthContext'; +import { useRelatedProducts } from '@/lib/hooks/useRelatedProducts'; +import { IntentClassificationDisplay } from '@/src/shared/components/IntentClassificationDisplay'; + +import { useIntelligentSearch } from '../hooks/useIntelligentSearch'; +import { IntentPanel } from './IntentPanel'; +import { + SearchComparisonScorecard, + type SearchComparisonItem, +} from './SearchComparisonScorecard'; +import { SearchModeIndicator } from './SearchModeIndicator'; +import { SearchModeToggle } from './SearchModeToggle'; +import { SearchResultCard } from './SearchResultCard'; + +type ProxyErrorShape = { + status?: number; + details?: { + proxy?: { + failureKind?: 'config' | 'network' | 'upstream'; + remediation?: string[]; + }; + }; +}; + +type ProxyFailureShape = { + failureKind: 'config' | 'network' | 'upstream'; + remediation?: string[]; +}; + +function getProxyFailureError(error: unknown): ProxyFailureShape | null { + if (!error || typeof error !== 'object') { + return null; + } + + const proxyError = error as ProxyErrorShape; + const proxyFailure = proxyError.details?.proxy; + + if (proxyError.status !== 502 || !proxyFailure?.failureKind) { + return null; + } + + return { + failureKind: proxyFailure.failureKind, + remediation: proxyFailure.remediation, + }; +} + +function parseScore(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined; + } + + return Number(value.toFixed(2)); +} + +function toScorecardItems(items: Array>): SearchComparisonItem[] { + return items.map((item, index) => { + const fallbackSku = `item-${index + 1}`; + return { + sku: String(item.sku ?? item.id ?? fallbackSku), + score: parseScore(item.score ?? item.relevanceScore), + }; + }); +} + +export function SearchPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const initialQuery = searchParams.get('q') ?? ''; + const [query, setQuery] = useState(initialQuery); + const { user } = useAuth(); + + const { + data, + baselineData, + rerankedData, + isLoading, + error, + refetch, + isFetching, + isReranking, + preference, + setPreference, + resolvedMode, + } = useIntelligentSearch(query, 20, { + userId: user?.user_id, + }); + const products = useMemo(() => data?.items ?? [], [data?.items]); + const relatedProductIds = useMemo(() => { + const ids = new Set(); + for (const product of products) { + for (const relatedId of product.complementaryProducts || []) { + ids.add(relatedId); + } + for (const relatedId of product.substituteProducts || []) { + ids.add(relatedId); + } + } + return Array.from(ids); + }, [products]); + const { data: relatedProductMap = {} } = useRelatedProducts(relatedProductIds); + const proxyFailure = getProxyFailureError(error); + const searchSource = data?.source; + const fallbackReason = data?.fallback_reason; + const isAgentDegradedFallback = data?.source === 'agent' && data?.degraded === true; + const degradedFallbackKeywords = data?.fallback_keywords || []; + const isIntelligentFallback = + data?.requested_mode === 'intelligent' && data?.source === 'crud'; + const showUnavailableAgentFallbackAlert = + isIntelligentFallback && fallbackReason !== 'agent_mock'; + const comparisonData = useMemo<{ + intelligent: SearchComparisonItem[]; + keyword: SearchComparisonItem[]; + } | null>(() => { + if (!query.trim()) { + return null; + } + + const keywordItems = toScorecardItems( + (baselineData?.items || []) as unknown as Array>, + ); + const intelligentItems = toScorecardItems( + (rerankedData?.items || []) as unknown as Array>, + ); + + if (keywordItems.length === 0 && intelligentItems.length === 0) { + return null; + } + + return { + keyword: keywordItems, + intelligent: intelligentItems, + }; + }, [query, baselineData?.items, rerankedData?.items]); + + const proxyFailureLabelByKind: Record<'config' | 'network' | 'upstream', string> = { + config: 'Catalog search proxy configuration is missing or invalid.', + network: 'Catalog search backend is temporarily unreachable.', + upstream: 'Catalog search backend returned a temporary gateway error.', + }; + const robotState = isLoading || isFetching || isReranking + ? 'thinking' + : query + ? 'talking' + : 'idle'; + + useEffect(() => { + setQuery(initialQuery); + }, [initialQuery]); + + const handleSearch = (value: string) => { + const trimmed = value.trim(); + setQuery(trimmed); + if (trimmed) { + router.push(`/search?q=${encodeURIComponent(trimmed)}`); + return; + } + + router.push('/search'); + }; + + return ( + +
+

Search

+
+ + Demo flow + + + + Open popup comparison + +
+ + {data?.trace_id ? 'View search trace' : 'View Agent Activity'} + + + +
+ {query ? : null} + {resolvedMode === 'intelligent' && ( + + )} +
+ {query && showUnavailableAgentFallbackAlert ? ( + + Results are from CRUD catalog search because the agent path was unavailable. + + ) : null} + {query && isAgentDegradedFallback ? ( + +
+

+ {data?.degraded_message + || 'Showing the best available catalog guidance while intelligent generation is temporarily unavailable.'} +

+ {degradedFallbackKeywords.length > 0 ? ( +

+ Fallback keywords: {degradedFallbackKeywords.join(', ')} +

+ ) : null} +
+
+ ) : null} + + {query && ( +
+ {isReranking ? ( +
+ Reranking baseline results in the background... +
+ ) : null} + {comparisonData ? ( + + ) : null} +
+ )} +
+ + {query ? ( +
+ + {resolvedMode === 'intelligent' ? 'Intelligent Search' : 'Keyword Search'} • Source:{' '} + {searchSource === 'agent' ? 'Agent API' : 'CRUD Catalog'} + +
+ ) : null} + + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ) : products.length === 0 ? ( +

+ {query ? 'No products matched your search.' : 'Search for products above.'} +

+ ) : ( +
+ {products.map((product) => ( + + ))} +
+ )} + + {proxyFailure && query && ( + +
+

{proxyFailureLabelByKind[proxyFailure.failureKind]}

+ +
+
+ )} + + + ); +} + +export default SearchPage; \ No newline at end of file diff --git a/apps/ui/components/enrichment/SearchResultCard.tsx b/apps/ui/src/features/search/components/SearchResultCard.tsx similarity index 89% rename from apps/ui/components/enrichment/SearchResultCard.tsx rename to apps/ui/src/features/search/components/SearchResultCard.tsx index 0075cae3..7c646c9a 100644 --- a/apps/ui/components/enrichment/SearchResultCard.tsx +++ b/apps/ui/src/features/search/components/SearchResultCard.tsx @@ -1,12 +1,12 @@ import React from 'react'; import Link from 'next/link'; import Image from 'next/image'; -import { Card } from '../molecules/Card'; -import { Badge } from '../atoms/Badge'; -import { PriceDisplay } from '../molecules/PriceDisplay'; -import { UseCaseTags } from './UseCaseTags'; -import { RelatedProductsRail } from './RelatedProductsRail'; -import type { Product } from '../types'; +import { Badge } from '@/components/atoms/Badge'; +import { Card } from '@/components/molecules/Card'; +import { PriceDisplay } from '@/components/molecules/PriceDisplay'; +import type { Product } from '@/components/types'; +import { RelatedProductsRail } from '@/src/shared/components/RelatedProductsRail'; +import { UseCaseTags } from '@/src/shared/components/UseCaseTags'; export interface SearchResultCardProps { product: Product; diff --git a/apps/ui/lib/hooks/useIntelligentSearch.ts b/apps/ui/src/features/search/hooks/useIntelligentSearch.ts similarity index 99% rename from apps/ui/lib/hooks/useIntelligentSearch.ts rename to apps/ui/src/features/search/hooks/useIntelligentSearch.ts index b707a746..efddc9af 100644 --- a/apps/ui/lib/hooks/useIntelligentSearch.ts +++ b/apps/ui/src/features/search/hooks/useIntelligentSearch.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useSemanticSearch } from './useSemanticSearch'; -import { productService } from '../services/productService'; +import { productService } from '@/lib/services/productService'; import type { SemanticSearchContext } from '../services/semanticSearchService'; export type IntelligentSearchPreference = 'auto' | 'keyword' | 'intelligent'; diff --git a/apps/ui/lib/hooks/useSemanticSearch.ts b/apps/ui/src/features/search/hooks/useSemanticSearch.ts similarity index 100% rename from apps/ui/lib/hooks/useSemanticSearch.ts rename to apps/ui/src/features/search/hooks/useSemanticSearch.ts diff --git a/apps/ui/lib/hooks/useStreamingSearch.ts b/apps/ui/src/features/search/hooks/useStreamingSearch.ts similarity index 100% rename from apps/ui/lib/hooks/useStreamingSearch.ts rename to apps/ui/src/features/search/hooks/useStreamingSearch.ts diff --git a/apps/ui/src/features/search/index.ts b/apps/ui/src/features/search/index.ts new file mode 100644 index 00000000..be7a480b --- /dev/null +++ b/apps/ui/src/features/search/index.ts @@ -0,0 +1,52 @@ +/** + * Search Feature - Public Facade + * + * This is the single public entry point for the search bounded context UI. + * App routes, components, and tests should import search capabilities from here + * instead of deep-linking into feature internals. + */ + +// ===== TYPES ===== +export type { + AppAudience, + AppPage, + AppSearchBoxProps, + IntentPanelProps, + IntelligentSearchOptions, + IntelligentSearchPreference, + IntelligentSearchStage, + SearchComparisonItem, + SearchDegradedReason, + SearchHit, + SearchModeIndicatorProps, + SearchModePreference, + SearchModeToggleProps, + SearchResultCardProps, + SearchResultType, + SemanticSearchContext, + SemanticSearchIntent, + SemanticSearchRequest, + SemanticSearchResponse, + StreamingSearchCallbacks, +} from './types'; + +// ===== COMPONENTS ===== +export { AppSearchBox } from './components/AppSearchBox'; +export { IntentPanel } from './components/IntentPanel'; +export { SearchComparisonScorecard } from './components/SearchComparisonScorecard'; +export { SearchModeIndicator } from './components/SearchModeIndicator'; +export { SearchModeToggle } from './components/SearchModeToggle'; +export { SearchPage } from './components/SearchPage'; +export { SearchResultCard } from './components/SearchResultCard'; + +// ===== HOOKS ===== +export { useIntelligentSearch } from './hooks/useIntelligentSearch'; +export { useSemanticSearch } from './hooks/useSemanticSearch'; +export { useStreamingSearch } from './hooks/useStreamingSearch'; + +// ===== SERVICES ===== +export { semanticSearchService } from './services/semanticSearchService'; + +// ===== INTERNAL HELPERS EXPOSED FOR TESTED APP-SEARCH CONTRACTS ===== +export { APP_PAGES, AUDIENCE_FILTER } from './internal/appPages'; +export { buildDocsSearchUrl, searchAppPages } from './internal/matcher'; \ No newline at end of file diff --git a/apps/ui/lib/search/appPages.ts b/apps/ui/src/features/search/internal/appPages.ts similarity index 100% rename from apps/ui/lib/search/appPages.ts rename to apps/ui/src/features/search/internal/appPages.ts diff --git a/apps/ui/lib/search/matcher.ts b/apps/ui/src/features/search/internal/matcher.ts similarity index 100% rename from apps/ui/lib/search/matcher.ts rename to apps/ui/src/features/search/internal/matcher.ts diff --git a/apps/ui/lib/services/semanticSearchService.ts b/apps/ui/src/features/search/services/semanticSearchService.ts similarity index 98% rename from apps/ui/lib/services/semanticSearchService.ts rename to apps/ui/src/features/search/services/semanticSearchService.ts index 607a197f..62a58455 100644 --- a/apps/ui/lib/services/semanticSearchService.ts +++ b/apps/ui/src/features/search/services/semanticSearchService.ts @@ -4,17 +4,17 @@ * Uses agent API (APIM) when configured, falls back to CRUD search. */ -import agentApiClient from '../api/agentClient'; +import agentApiClient from '@/lib/api/agentClient'; import { resolveAgentApiClientBaseUrl } from '@/app/api/_shared/base-url-resolver'; import { recordAgentInvocationTelemetry } from '@/lib/hooks/useAgentInvocationTelemetry'; import { getCurrentPageSessionId } from '@/lib/hooks/usePageSession'; -import { productService } from './productService'; +import { productService } from '@/lib/services/productService'; import { mapAcpProductsToUi, mapApiProductsToUi, type AcpProduct, -} from '../utils/productMappers'; -import type { Product as UiProduct } from '../../components/types'; +} from '@/lib/utils/productMappers'; +import type { Product as UiProduct } from '@/components/types'; const AGENT_API_BASE_URL = resolveAgentApiClientBaseUrl().baseUrl || ''; const MOCK_TITLE_PATTERN = /\bmock\b/i; diff --git a/apps/ui/src/features/search/types/index.ts b/apps/ui/src/features/search/types/index.ts new file mode 100644 index 00000000..38460b97 --- /dev/null +++ b/apps/ui/src/features/search/types/index.ts @@ -0,0 +1,25 @@ +export type { + SearchDegradedReason, + SearchModePreference, + SearchResultType, + SemanticSearchContext, + SemanticSearchIntent, + SemanticSearchRequest, + SemanticSearchResponse, + StreamingSearchCallbacks, +} from '../services/semanticSearchService'; + +export type { + IntelligentSearchOptions, + IntelligentSearchPreference, + IntelligentSearchStage, +} from '../hooks/useIntelligentSearch'; + +export type { AppAudience, AppPage } from '../internal/appPages'; +export type { SearchHit } from '../internal/matcher'; +export type { AppSearchBoxProps } from '../components/AppSearchBox'; +export type { SearchComparisonItem } from '../components/SearchComparisonScorecard'; +export type { SearchResultCardProps } from '../components/SearchResultCard'; +export type { SearchModeIndicatorProps } from '../components/SearchModeIndicator'; +export type { SearchModeToggleProps } from '../components/SearchModeToggle'; +export type { IntentPanelProps } from '../components/IntentPanel'; \ No newline at end of file diff --git a/apps/ui/src/features/truth/components/ProposalCard.tsx b/apps/ui/src/features/truth/components/ProposalCard.tsx index 1f244202..69c3cf3f 100644 --- a/apps/ui/src/features/truth/components/ProposalCard.tsx +++ b/apps/ui/src/features/truth/components/ProposalCard.tsx @@ -2,10 +2,10 @@ import React, { useState } from 'react'; import { cn } from '@/components/utils'; -import { ConfidenceBadge } from './ConfidenceBadge'; import { Badge } from '@/components/atoms/Badge'; -import { IntentClassificationDisplay } from '@/components/enrichment/IntentClassificationDisplay'; import { ImageEvidenceGallery } from '@/components/enrichment/ImageEvidenceGallery'; +import { ConfidenceBadge } from '@/src/shared/components/ConfidenceBadge'; +import { IntentClassificationDisplay } from '@/src/shared/components/IntentClassificationDisplay'; import type { ProposedAttribute } from '../types'; export interface ProposalCardProps { diff --git a/apps/ui/src/features/truth/components/ReviewQueueTable.tsx b/apps/ui/src/features/truth/components/ReviewQueueTable.tsx index d4596646..c4f1f71f 100644 --- a/apps/ui/src/features/truth/components/ReviewQueueTable.tsx +++ b/apps/ui/src/features/truth/components/ReviewQueueTable.tsx @@ -3,7 +3,7 @@ import React from 'react'; import Link from 'next/link'; import { cn } from '@/components/utils'; -import { ConfidenceBadge } from './ConfidenceBadge'; +import { ConfidenceBadge } from '@/src/shared/components/ConfidenceBadge'; import type { ReviewQueueItem } from '../types'; export type SortKey = 'confidence_asc' | 'confidence_desc' | 'date_asc' | 'date_desc'; diff --git a/apps/ui/src/features/truth/index.ts b/apps/ui/src/features/truth/index.ts index 5a1bdf2f..2a944a99 100644 --- a/apps/ui/src/features/truth/index.ts +++ b/apps/ui/src/features/truth/index.ts @@ -48,8 +48,8 @@ export type { AuditTimelineProps } from './components/AuditTimeline'; export { CompletenessBar } from './components/CompletenessBar'; export type { CompletenessBarProps } from './components/CompletenessBar'; -export { ConfidenceBadge } from './components/ConfidenceBadge'; -export type { ConfidenceBadgeProps } from './components/ConfidenceBadge'; +export { ConfidenceBadge } from '@/src/shared/components/ConfidenceBadge'; +export type { ConfidenceBadgeProps } from '@/src/shared/components/ConfidenceBadge'; export { ProposalCard } from './components/ProposalCard'; export type { ProposalCardProps } from './components/ProposalCard'; diff --git a/apps/ui/src/features/truth/components/ConfidenceBadge.tsx b/apps/ui/src/shared/components/ConfidenceBadge.tsx similarity index 100% rename from apps/ui/src/features/truth/components/ConfidenceBadge.tsx rename to apps/ui/src/shared/components/ConfidenceBadge.tsx diff --git a/apps/ui/components/enrichment/IntentClassificationDisplay.tsx b/apps/ui/src/shared/components/IntentClassificationDisplay.tsx similarity index 94% rename from apps/ui/components/enrichment/IntentClassificationDisplay.tsx rename to apps/ui/src/shared/components/IntentClassificationDisplay.tsx index 981415e5..ad364e8b 100644 --- a/apps/ui/components/enrichment/IntentClassificationDisplay.tsx +++ b/apps/ui/src/shared/components/IntentClassificationDisplay.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ConfidenceBadge } from '@/src/features/truth'; +import { ConfidenceBadge } from './ConfidenceBadge'; export interface IntentClassificationDisplayProps { intent?: string; diff --git a/apps/ui/components/enrichment/RelatedProductsRail.tsx b/apps/ui/src/shared/components/RelatedProductsRail.tsx similarity index 98% rename from apps/ui/components/enrichment/RelatedProductsRail.tsx rename to apps/ui/src/shared/components/RelatedProductsRail.tsx index 6d18617a..da6fbdfe 100644 --- a/apps/ui/components/enrichment/RelatedProductsRail.tsx +++ b/apps/ui/src/shared/components/RelatedProductsRail.tsx @@ -1,7 +1,7 @@ import React from 'react'; import Link from 'next/link'; import Image from 'next/image'; -import type { Product } from '../types'; +import type { Product } from '@/components/types'; export interface RelatedProductsRailProps { title: string; diff --git a/apps/ui/components/enrichment/UseCaseTags.tsx b/apps/ui/src/shared/components/UseCaseTags.tsx similarity index 95% rename from apps/ui/components/enrichment/UseCaseTags.tsx rename to apps/ui/src/shared/components/UseCaseTags.tsx index f3ae9406..fa3500f4 100644 --- a/apps/ui/components/enrichment/UseCaseTags.tsx +++ b/apps/ui/src/shared/components/UseCaseTags.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Badge } from '../atoms/Badge'; +import { Badge } from '@/components/atoms/Badge'; export interface UseCaseTagsProps { useCases?: string[]; diff --git a/apps/ui/tests/unit/AppSearchBox.test.tsx b/apps/ui/tests/unit/AppSearchBox.test.tsx index 08626895..48166a4d 100644 --- a/apps/ui/tests/unit/AppSearchBox.test.tsx +++ b/apps/ui/tests/unit/AppSearchBox.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; -import { AppSearchBox } from '@/components/molecules/AppSearchBox'; +import { AppSearchBox } from '@/src/features/search'; describe('AppSearchBox', () => { it('renders the audience-scoped placeholder', () => { diff --git a/apps/ui/tests/unit/ChatWidget.test.tsx b/apps/ui/tests/unit/ChatWidget.test.tsx index aff77e59..cb2b9643 100644 --- a/apps/ui/tests/unit/ChatWidget.test.tsx +++ b/apps/ui/tests/unit/ChatWidget.test.tsx @@ -14,7 +14,8 @@ jest.mock('next/navigation', () => ({ }), })); -jest.mock('../../lib/services/semanticSearchService', () => ({ +jest.mock('@/src/features/search', () => ({ + ...jest.requireActual('@/src/features/search'), semanticSearchService: { searchWithMode: (...args: unknown[]) => searchWithModeMock(...args), searchStream: (...args: unknown[]) => searchStreamMock(...args), diff --git a/apps/ui/tests/unit/SearchPage.test.tsx b/apps/ui/tests/unit/SearchPage.test.tsx index 1e06cf97..1c6f3170 100644 --- a/apps/ui/tests/unit/SearchPage.test.tsx +++ b/apps/ui/tests/unit/SearchPage.test.tsx @@ -4,6 +4,8 @@ import SearchPage from '../../app/search/page'; const push = jest.fn(); const getParam = jest.fn(); +const mockUseQuery = jest.fn(); +const mockPrefetchQuery = jest.fn(); jest.mock('next/navigation', () => ({ useRouter: () => ({ @@ -15,12 +17,14 @@ jest.mock('next/navigation', () => ({ usePathname: () => '/search', })); -const mockUseIntelligentSearch = jest.fn(); const mockUseRelatedProducts = jest.fn(); const mockUseAuth = jest.fn(); -jest.mock('../../lib/hooks/useIntelligentSearch', () => ({ - useIntelligentSearch: (...args: unknown[]) => mockUseIntelligentSearch(...args), +jest.mock('@tanstack/react-query', () => ({ + useQuery: (...args: unknown[]) => mockUseQuery(...args), + useQueryClient: () => ({ + prefetchQuery: mockPrefetchQuery, + }), })); jest.mock('../../lib/hooks/useRelatedProducts', () => ({ @@ -43,12 +47,54 @@ jest.mock('../../components/organisms/Navigation', () => ({ ), })); -describe('SearchPage', () => { - const setPreference = jest.fn(); +function buildSearchQueryResult(overrides: Record = {}) { + return { + data: { + items: [], + source: 'agent', + mode: 'intelligent', + intent: null, + subqueries: [], + }, + isLoading: false, + error: null, + isFetching: false, + refetch: jest.fn(), + ...overrides, + }; +} + +function readQueryOptions(options: unknown): { enabled?: boolean; queryKey?: unknown[] } { + if (!options || typeof options !== 'object') { + return {}; + } + + return options as { enabled?: boolean; queryKey?: unknown[] }; +} + +function configureSearchQueryResults({ + baseline = buildSearchQueryResult(), + rerank = buildSearchQueryResult({ data: undefined }), +}: { + baseline?: ReturnType; + rerank?: ReturnType; +} = {}) { + mockUseQuery.mockImplementation((options: unknown) => { + const { enabled, queryKey } = readQueryOptions(options); + if (enabled === false) { + return buildSearchQueryResult({ data: undefined }); + } + return queryKey?.[4] === 'rerank' ? rerank : baseline; + }); +} + +describe('SearchPage', () => { beforeEach(() => { + jest.clearAllMocks(); push.mockClear(); - setPreference.mockClear(); + window.localStorage.clear(); + window.sessionStorage.clear(); mockUseAuth.mockReturnValue({ user: { user_id: 'customer-123', @@ -56,69 +102,43 @@ describe('SearchPage', () => { }); getParam.mockReturnValue('headphones'); mockUseRelatedProducts.mockReturnValue({ data: {} }); - mockUseIntelligentSearch.mockReturnValue({ - data: { - items: [], - source: 'agent', - mode: 'intelligent', - intent: null, - subqueries: [], - }, - isLoading: false, - error: null, - isFetching: false, - refetch: jest.fn(), - isReranking: false, - baselineData: { - items: [], - source: 'agent', - mode: 'intelligent', - }, - rerankedData: undefined, - preference: 'intelligent', - setPreference, - resolvedMode: 'intelligent', - }); + configureSearchQueryResults(); }); it('prefills the query from the URL and shows intelligent mode badge', () => { render(); - expect(mockUseIntelligentSearch).toHaveBeenCalledWith('headphones', 20, { - userId: 'customer-123', - }); + expect(mockUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: expect.arrayContaining([ + 'semantic-search', + 'headphones', + 20, + 'intelligent', + 'baseline', + ]), + }), + ); expect(screen.getByDisplayValue('headphones')).toBeInTheDocument(); expect(screen.getAllByText('Intelligent Search').length).toBeGreaterThan(0); expect(screen.getByText('No products matched your search.')).toBeInTheDocument(); }); it('shows intent panel details in intelligent mode when available', () => { - mockUseIntelligentSearch.mockReturnValue({ - data: { - items: [], - source: 'agent', - mode: 'intelligent', - intent: { - intent: 'use_case_lookup', - confidence: 0.88, - entities: { category: 'audio' }, + configureSearchQueryResults({ + baseline: buildSearchQueryResult({ + data: { + items: [], + source: 'agent', + mode: 'intelligent', + intent: { + intent: 'use_case_lookup', + confidence: 0.88, + entities: { category: 'audio' }, + }, + subqueries: ['wireless noise cancelling'], }, - subqueries: ['wireless noise cancelling'], - }, - isLoading: false, - error: null, - isFetching: false, - refetch: jest.fn(), - isReranking: false, - baselineData: { - items: [], - source: 'agent', - mode: 'intelligent', - }, - rerankedData: undefined, - preference: 'intelligent', - setPreference, - resolvedMode: 'intelligent', + }), }); render(); @@ -131,8 +151,12 @@ describe('SearchPage', () => { it('changes search mode preference from toggle', () => { render(); - fireEvent.click(screen.getByRole('radio', { name: 'Search mode Intelligent' })); - expect(setPreference).toHaveBeenCalledWith('intelligent'); + fireEvent.click(screen.getByRole('radio', { name: 'Search mode Keyword' })); + expect(window.localStorage.getItem('hp.search.mode.preference')).toBe('keyword'); + expect(screen.getByRole('radio', { name: 'Search mode Keyword' })).toHaveAttribute( + 'aria-checked', + 'true', + ); }); it('updates the URL when searching', () => { @@ -152,29 +176,19 @@ describe('SearchPage', () => { it('shows a recoverable proxy error with retry affordance on 502 failures', () => { const refetch = jest.fn(); - mockUseIntelligentSearch.mockReturnValue({ - data: { items: [], source: 'crud', mode: 'keyword', intent: null, subqueries: [] }, - isLoading: false, - isFetching: false, - error: { - status: 502, - details: { - proxy: { - failureKind: 'network', + configureSearchQueryResults({ + baseline: buildSearchQueryResult({ + data: { items: [], source: 'crud', mode: 'keyword', intent: null, subqueries: [] }, + error: { + status: 502, + details: { + proxy: { + failureKind: 'network', + }, }, }, - }, - refetch, - isReranking: false, - baselineData: { - items: [], - source: 'crud', - mode: 'keyword', - }, - rerankedData: undefined, - preference: 'auto', - setPreference, - resolvedMode: 'keyword', + refetch, + }), }); render(); @@ -188,30 +202,18 @@ describe('SearchPage', () => { }); it('does not show unavailable warning for agent mock fallback', () => { - mockUseIntelligentSearch.mockReturnValue({ - data: { - items: [], - source: 'crud', - mode: 'keyword', - requested_mode: 'intelligent', - fallback_reason: 'agent_mock', - intent: null, - subqueries: [], - }, - isLoading: false, - error: null, - isFetching: false, - refetch: jest.fn(), - isReranking: false, - baselineData: { - items: [], - source: 'crud', - mode: 'keyword', - }, - rerankedData: undefined, - preference: 'intelligent', - setPreference, - resolvedMode: 'intelligent', + configureSearchQueryResults({ + baseline: buildSearchQueryResult({ + data: { + items: [], + source: 'crud', + mode: 'keyword', + requested_mode: 'intelligent', + fallback_reason: 'agent_mock', + intent: null, + subqueries: [], + }, + }), }); render(); @@ -222,30 +224,18 @@ describe('SearchPage', () => { }); it('shows unavailable warning for agent_unavailable fallback', () => { - mockUseIntelligentSearch.mockReturnValue({ - data: { - items: [], - source: 'crud', - mode: 'keyword', - requested_mode: 'intelligent', - fallback_reason: 'agent_unavailable', - intent: null, - subqueries: [], - }, - isLoading: false, - error: null, - isFetching: false, - refetch: jest.fn(), - isReranking: false, - baselineData: { - items: [], - source: 'crud', - mode: 'keyword', - }, - rerankedData: undefined, - preference: 'intelligent', - setPreference, - resolvedMode: 'intelligent', + configureSearchQueryResults({ + baseline: buildSearchQueryResult({ + data: { + items: [], + source: 'crud', + mode: 'keyword', + requested_mode: 'intelligent', + fallback_reason: 'agent_unavailable', + intent: null, + subqueries: [], + }, + }), }); render(); @@ -256,34 +246,22 @@ describe('SearchPage', () => { }); it('shows degraded fallback warning when agent model synthesis fails', () => { - mockUseIntelligentSearch.mockReturnValue({ - data: { - items: [], - source: 'agent', - mode: 'intelligent', - requested_mode: 'intelligent', - degraded: true, - degraded_reason: 'model_timeout', - degraded_message: - 'Showing the best available catalog guidance while intelligent generation is temporarily unavailable.', - fallback_keywords: ['winter', 'jacket', 'boots'], - intent: null, - subqueries: [], - }, - isLoading: false, - error: null, - isFetching: false, - refetch: jest.fn(), - isReranking: false, - baselineData: { - items: [], - source: 'agent', - mode: 'intelligent', - }, - rerankedData: undefined, - preference: 'intelligent', - setPreference, - resolvedMode: 'intelligent', + configureSearchQueryResults({ + baseline: buildSearchQueryResult({ + data: { + items: [], + source: 'agent', + mode: 'intelligent', + requested_mode: 'intelligent', + degraded: true, + degraded_reason: 'model_timeout', + degraded_message: + 'Showing the best available catalog guidance while intelligent generation is temporarily unavailable.', + fallback_keywords: ['winter', 'jacket', 'boots'], + intent: null, + subqueries: [], + }, + }), }); render(); @@ -293,33 +271,26 @@ describe('SearchPage', () => { }); it('announces reranking progress with a polite status message', () => { - mockUseIntelligentSearch.mockReturnValue({ - data: { - items: [], - source: 'crud', - mode: 'keyword', - intent: null, - subqueries: [], - }, - baselineData: { - items: [ - { - sku: 'sku-1', - title: 'Sample Product', - }, - ], - source: 'crud', - mode: 'keyword', - }, - rerankedData: undefined, - isLoading: false, - error: null, - isFetching: true, - isReranking: true, - refetch: jest.fn(), - preference: 'auto', - setPreference, - resolvedMode: 'keyword', + window.localStorage.setItem('hp.search.mode.preference', 'auto'); + configureSearchQueryResults({ + baseline: buildSearchQueryResult({ + data: { + items: [ + { + sku: 'sku-1', + title: 'Sample Product', + }, + ], + source: 'crud', + mode: 'keyword', + intent: null, + subqueries: [], + }, + }), + rerank: buildSearchQueryResult({ + data: undefined, + isFetching: true, + }), }); render(); diff --git a/apps/ui/tests/unit/appSearchMatcher.test.ts b/apps/ui/tests/unit/appSearchMatcher.test.ts index 16f1121f..5d70cfda 100644 --- a/apps/ui/tests/unit/appSearchMatcher.test.ts +++ b/apps/ui/tests/unit/appSearchMatcher.test.ts @@ -1,8 +1,8 @@ import { buildDocsSearchUrl, searchAppPages, -} from '@/lib/search/matcher'; -import { AUDIENCE_FILTER } from '@/lib/search/appPages'; + AUDIENCE_FILTER, +} from '@/src/features/search'; describe('searchAppPages', () => { it('returns first-paint suggestions when query is empty', () => { diff --git a/apps/ui/tests/unit/appSearchUrlSeed.test.tsx b/apps/ui/tests/unit/appSearchUrlSeed.test.tsx index 3fa9537d..d952a9fd 100644 --- a/apps/ui/tests/unit/appSearchUrlSeed.test.tsx +++ b/apps/ui/tests/unit/appSearchUrlSeed.test.tsx @@ -9,7 +9,7 @@ */ import { fireEvent, render, screen } from '@testing-library/react'; -import { AppSearchBox } from '@/components/molecules/AppSearchBox'; +import { AppSearchBox } from '@/src/features/search'; describe('AppSearchBox URL ?q= seed (Issue #1022 reciprocal cross-link)', () => { beforeEach(() => { diff --git a/apps/ui/tests/unit/enrichmentComponents.test.tsx b/apps/ui/tests/unit/enrichmentComponents.test.tsx index 8f73f42d..a56fa2d1 100644 --- a/apps/ui/tests/unit/enrichmentComponents.test.tsx +++ b/apps/ui/tests/unit/enrichmentComponents.test.tsx @@ -2,11 +2,13 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { EnrichmentPipelineStatus } from '../../components/enrichment/EnrichmentPipelineStatus'; import { AttributeDiffView } from '../../components/enrichment/AttributeDiffView'; -import { SearchModeIndicator } from '../../components/enrichment/SearchModeIndicator'; -import { IntentPanel } from '../../components/enrichment/IntentPanel'; -import { UseCaseTags } from '../../components/enrichment/UseCaseTags'; -import { RelatedProductsRail } from '../../components/enrichment/RelatedProductsRail'; -import { SearchResultCard } from '../../components/enrichment/SearchResultCard'; +import { + IntentPanel, + SearchModeIndicator, + SearchResultCard, +} from '@/src/features/search'; +import { RelatedProductsRail } from '@/src/shared/components/RelatedProductsRail'; +import { UseCaseTags } from '@/src/shared/components/UseCaseTags'; describe('enrichment components', () => { it('renders pipeline status badge', () => { diff --git a/apps/ui/tests/unit/pagesRender.test.tsx b/apps/ui/tests/unit/pagesRender.test.tsx index 6a4e22e1..37346585 100644 --- a/apps/ui/tests/unit/pagesRender.test.tsx +++ b/apps/ui/tests/unit/pagesRender.test.tsx @@ -528,7 +528,8 @@ jest.mock('../../lib/hooks/useAgentMonitor', () => ({ isTracingUnavailableError: () => false, })); -jest.mock('../../lib/hooks/useStreamingSearch', () => ({ +jest.mock('@/src/features/search', () => ({ + AppSearchBox: () =>
, useStreamingSearch: () => ({ results: null, answerText: '', diff --git a/apps/ui/tests/unit/semanticSearchService.test.ts b/apps/ui/tests/unit/semanticSearchService.test.ts index f050cc13..25355659 100644 --- a/apps/ui/tests/unit/semanticSearchService.test.ts +++ b/apps/ui/tests/unit/semanticSearchService.test.ts @@ -1,4 +1,4 @@ -import semanticSearchService from '../../lib/services/semanticSearchService'; +import { semanticSearchService } from '@/src/features/search'; import agentApiClient from '../../lib/api/agentClient'; import { recordAgentInvocationTelemetry } from '../../lib/hooks/useAgentInvocationTelemetry'; import { productService } from '../../lib/services/productService'; diff --git a/apps/ui/tests/unit/useIntelligentSearch.test.tsx b/apps/ui/tests/unit/useIntelligentSearch.test.tsx index 97479c7f..bb7785cb 100644 --- a/apps/ui/tests/unit/useIntelligentSearch.test.tsx +++ b/apps/ui/tests/unit/useIntelligentSearch.test.tsx @@ -1,14 +1,11 @@ import { act, renderHook } from '@testing-library/react'; -import { useIntelligentSearch } from '../../lib/hooks/useIntelligentSearch'; +import { useIntelligentSearch } from '@/src/features/search'; -const mockUseSemanticSearch = jest.fn(); +const mockUseQuery = jest.fn(); const mockPrefetchQuery = jest.fn(); -jest.mock('../../lib/hooks/useSemanticSearch', () => ({ - useSemanticSearch: (...args: unknown[]) => mockUseSemanticSearch(...args), -})); - jest.mock('@tanstack/react-query', () => ({ + useQuery: (...args: unknown[]) => mockUseQuery(...args), useQueryClient: () => ({ prefetchQuery: mockPrefetchQuery, }), @@ -30,22 +27,26 @@ describe('useIntelligentSearch', () => { }; } + function readQueryOptions(options: unknown): { enabled?: boolean; queryKey?: unknown[] } { + if (!options || typeof options !== 'object') { + return {}; + } + + return options as { enabled?: boolean; queryKey?: unknown[] }; + } + beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); window.localStorage.clear(); window.sessionStorage.clear(); - mockUseSemanticSearch.mockImplementation(( - _query: string, - _limit: number, - mode: 'keyword' | 'intelligent' | 'auto', - _context: unknown, - enabled = true, - ) => { + mockUseQuery.mockImplementation((options: unknown) => { + const { enabled, queryKey } = readQueryOptions(options); if (!enabled) { return buildQueryResult({ data: undefined }); } + const mode = queryKey?.[3]; if (mode === 'intelligent') { return buildQueryResult({ data: { @@ -72,15 +73,17 @@ describe('useIntelligentSearch', () => { expect(result.current.preference).toBe('intelligent'); expect(result.current.resolvedMode).toBe('intelligent'); - expect(mockUseSemanticSearch).toHaveBeenCalledWith( - 'headphones', - 20, - 'intelligent', + expect(mockUseQuery).toHaveBeenCalledWith( expect.objectContaining({ - search_stage: 'baseline', - session_id: expect.any(String), + enabled: true, + queryKey: expect.arrayContaining([ + 'semantic-search', + 'headphones', + 20, + 'intelligent', + 'baseline', + ]), }), - true, ); }); @@ -109,13 +112,9 @@ describe('useIntelligentSearch', () => { let rerankReady = false; window.localStorage.setItem('hp.search.mode.preference', 'auto'); - mockUseSemanticSearch.mockImplementation(( - _query: string, - _limit: number, - mode: 'keyword' | 'intelligent' | 'auto', - _context: unknown, - enabled = true, - ) => { + mockUseQuery.mockImplementation((options: unknown) => { + const { enabled, queryKey } = readQueryOptions(options); + const mode = queryKey?.[3]; if (mode === 'keyword') { return buildQueryResult({ data: { @@ -176,7 +175,7 @@ describe('useIntelligentSearch', () => { it('prefetches related product cards from search results', () => { window.localStorage.setItem('hp.search.mode.preference', 'keyword'); - mockUseSemanticSearch.mockReturnValue({ + mockUseQuery.mockReturnValue({ data: { items: [ {