@open-mercato/search 0.4.7-main-1768da2e43 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ # Search Package — Standalone Developer Guide
2
+
3
+ `@open-mercato/search` provides fulltext, vector, and token-based search. Configure search for your entities via `search.ts` in your module.
4
+
5
+ ## Strategy Overview
6
+
7
+ | Strategy | Backend | Use when |
8
+ |----------|---------|----------|
9
+ | **Fulltext** | Meilisearch | Fast, typo-tolerant search (names, titles, descriptions) |
10
+ | **Vector** | OpenAI / Ollama | Semantic, meaning-based search ("find customers interested in X") |
11
+ | **Tokens** | PostgreSQL | Baseline keyword search, always available, no external services |
12
+
13
+ Strategies auto-degrade when their backend is not configured.
14
+
15
+ ## Adding Search to a Module
16
+
17
+ Create `src/modules/<module>/search.ts`:
18
+
19
+ ```typescript
20
+ import type { SearchModuleConfig, SearchBuildContext } from '@open-mercato/shared/modules/search'
21
+
22
+ export const searchConfig: SearchModuleConfig = {
23
+ entities: [{
24
+ entityId: 'my_module:my_entity', // MUST match entity registry
25
+ priority: 10,
26
+
27
+ // Fulltext: control field indexing
28
+ fieldPolicy: {
29
+ searchable: ['name', 'description'],
30
+ hashOnly: ['email', 'phone'], // exact match only
31
+ excluded: ['password', 'api_key'], // never indexed
32
+ },
33
+
34
+ // Vector: generate text for embeddings
35
+ buildSource: async (ctx: SearchBuildContext) => ({
36
+ text: [`Name: ${ctx.record.name}`, `Description: ${ctx.record.description}`],
37
+ presenter: { title: ctx.record.name, subtitle: ctx.record.status, icon: 'lucide:file', badge: 'Item' },
38
+ links: [{ href: `/backend/my-module/${ctx.record.id}`, label: 'View', kind: 'primary' }],
39
+ checksumSource: { record: ctx.record, customFields: ctx.customFields },
40
+ }),
41
+
42
+ // Tokens: format at search time
43
+ formatResult: async (ctx: SearchBuildContext) => ({
44
+ title: ctx.record.name ?? 'Unknown',
45
+ subtitle: ctx.record.status,
46
+ icon: 'lucide:file',
47
+ badge: 'Item',
48
+ }),
49
+
50
+ resolveUrl: async (ctx) => `/backend/my-module/${ctx.record.id}`,
51
+ }],
52
+ }
53
+ export default searchConfig
54
+ ```
55
+
56
+ Run `yarn generate` after creating the file.
57
+
58
+ ## MUST Rules
59
+
60
+ 1. **MUST create `search.ts`** for every module with searchable entities
61
+ 2. **MUST define `fieldPolicy.excluded`** for sensitive fields (passwords, tokens, SSNs)
62
+ 3. **MUST define `formatResult`** for every entity using the tokens strategy
63
+ 4. **MUST include `checksumSource`** in every `buildSource` return value
64
+ 5. **MUST NOT** include encrypted/sensitive fields in `buildSource` text
65
+ 6. **MUST NOT** use raw `fetch` against search API — use `apiCall`/`apiCallOrThrow`
66
+
67
+ ## Auto-Indexing
68
+
69
+ When CRUD routes have `indexer: { entityType }`, the search module automatically subscribes to entity CRUD events and indexes/removes records. No manual indexing code needed.
70
+
71
+ ## CLI Commands
72
+
73
+ ```bash
74
+ yarn mercato search status # Check strategies and connectivity
75
+ yarn mercato search query -q "term" --tenant <id> # Run a search
76
+ yarn mercato search reindex --tenant <id> # Reindex all entities
77
+ yarn mercato search reindex --entity my_module:my_entity --tenant <id> # Reindex specific entity
78
+ yarn mercato search test-meilisearch # Test Meilisearch connection
79
+ ```
80
+
81
+ ## Environment Variables
82
+
83
+ | Variable | Purpose |
84
+ |----------|---------|
85
+ | `MEILISEARCH_HOST` | Meilisearch URL (enables fulltext) |
86
+ | `MEILISEARCH_API_KEY` | Meilisearch auth key |
87
+ | `OPENAI_API_KEY` | OpenAI API key (enables vector search) |
88
+ | `OM_SEARCH_DEBUG` | Enable verbose debug logging |
89
+
90
+ ## Programmatic Search via DI
91
+
92
+ ```typescript
93
+ const searchService = container.resolve('searchService')
94
+ const results = await searchService.search('query', {
95
+ tenantId: 'tenant-123',
96
+ limit: 20,
97
+ strategies: ['fulltext', 'vector'],
98
+ })
99
+ ```
100
+
101
+ ## SearchBuildContext
102
+
103
+ Both `buildSource` and `formatResult` receive this context:
104
+
105
+ ```typescript
106
+ interface SearchBuildContext {
107
+ record: Record<string, unknown> // The database record
108
+ customFields: Record<string, unknown> // Custom fields (without cf: prefix)
109
+ tenantId?: string | null
110
+ organizationId?: string | null
111
+ queryEngine?: QueryEngine // For loading related entities
112
+ }
113
+ ```
114
+
115
+ Use `queryEngine` to load parent/related entities for richer presenter data.
@@ -1,4 +1,4 @@
1
- const TITLE_FIELDS = [
1
+ const TITLE_FIELDS_PRIMARY = [
2
2
  "display_name",
3
3
  "displayName",
4
4
  "name",
@@ -10,12 +10,10 @@ const TITLE_FIELDS = [
10
10
  "brandName",
11
11
  "legal_name",
12
12
  "legalName",
13
- "first_name",
14
- "firstName",
15
- "last_name",
16
- "lastName",
17
13
  "preferred_name",
18
- "preferredName",
14
+ "preferredName"
15
+ ];
16
+ const TITLE_FIELDS_SECONDARY = [
19
17
  "email",
20
18
  "primary_email",
21
19
  "primaryEmail",
@@ -25,6 +23,9 @@ const TITLE_FIELDS = [
25
23
  "identifier",
26
24
  "slug"
27
25
  ];
26
+ const FIRST_NAME_FIELDS = ["first_name", "firstName"];
27
+ const LAST_NAME_FIELDS = ["last_name", "lastName"];
28
+ const MAX_SUBTITLE_LENGTH = 120;
28
29
  const SUBTITLE_FIELDS = [
29
30
  "description",
30
31
  "summary",
@@ -49,6 +50,12 @@ function findFirstValue(doc, fields) {
49
50
  }
50
51
  return null;
51
52
  }
53
+ function buildNameFromParts(doc) {
54
+ const firstName = findFirstValue(doc, FIRST_NAME_FIELDS);
55
+ const lastName = findFirstValue(doc, LAST_NAME_FIELDS);
56
+ if (firstName && lastName) return `${firstName} ${lastName}`;
57
+ return firstName ?? lastName;
58
+ }
52
59
  function findAnyStringValue(doc, excludeFields) {
53
60
  const skipFields = /* @__PURE__ */ new Set([
54
61
  "id",
@@ -77,11 +84,24 @@ function formatEntityLabel(entityId) {
77
84
  const entityName = entityId.split(":")[1] ?? entityId;
78
85
  return entityName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
79
86
  }
87
+ function truncateSubtitle(value) {
88
+ if (value.length <= MAX_SUBTITLE_LENGTH) return value;
89
+ return value.slice(0, MAX_SUBTITLE_LENGTH).trimEnd();
90
+ }
80
91
  function extractFallbackPresenter(doc, entityId, recordId) {
81
92
  const entityLabel = formatEntityLabel(entityId);
82
- let title = findFirstValue(doc, TITLE_FIELDS);
93
+ let title = findFirstValue(doc, TITLE_FIELDS_PRIMARY);
94
+ if (!title) {
95
+ title = buildNameFromParts(doc);
96
+ }
97
+ if (!title) {
98
+ title = findFirstValue(doc, TITLE_FIELDS_SECONDARY);
99
+ }
83
100
  if (!title) {
84
- title = findAnyStringValue(doc, new Set(SUBTITLE_FIELDS));
101
+ title = findAnyStringValue(
102
+ doc,
103
+ /* @__PURE__ */ new Set([...SUBTITLE_FIELDS, ...FIRST_NAME_FIELDS, ...LAST_NAME_FIELDS])
104
+ );
85
105
  }
86
106
  if (!title) {
87
107
  const shortId = recordId.length > 8 ? recordId.slice(0, 8) + "..." : recordId;
@@ -97,7 +117,7 @@ function extractFallbackPresenter(doc, entityId, recordId) {
97
117
  }
98
118
  return {
99
119
  title,
100
- subtitle: subtitleParts.length > 0 ? subtitleParts.join(" \xB7 ").slice(0, 120) : void 0,
120
+ subtitle: subtitleParts.length > 0 ? truncateSubtitle(subtitleParts.join(" \xB7 ")) : void 0,
101
121
  badge: entityLabel
102
122
  };
103
123
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/fallback-presenter.ts"],
4
- "sourcesContent": ["import type { SearchResultPresenter } from '@open-mercato/shared/modules/search'\n\n// Fields to check for title, in priority order\nconst TITLE_FIELDS = [\n 'display_name', 'displayName',\n 'name', 'title', 'label',\n 'full_name', 'fullName',\n 'brand_name', 'brandName',\n 'legal_name', 'legalName',\n 'first_name', 'firstName',\n 'last_name', 'lastName',\n 'preferred_name', 'preferredName',\n 'email', 'primary_email', 'primaryEmail',\n 'code', 'sku', 'reference',\n 'identifier', 'slug',\n]\n\n// Fields to check for subtitle\nconst SUBTITLE_FIELDS = [\n 'description', 'summary', 'notes',\n 'email', 'primary_email', 'primaryEmail',\n 'phone', 'primary_phone', 'primaryPhone',\n 'status', 'type', 'kind', 'category',\n]\n\nfunction findFirstValue(doc: Record<string, unknown>, fields: string[]): string | null {\n for (const field of fields) {\n const value = doc[field]\n if (value != null && String(value).trim().length > 0) {\n return String(value).trim()\n }\n }\n return null\n}\n\nfunction findAnyStringValue(doc: Record<string, unknown>, excludeFields: Set<string>): string | null {\n // Skip these fields as they're not meaningful for display\n const skipFields = new Set([\n 'id', 'tenant_id', 'tenantId', 'organization_id', 'organizationId',\n 'created_at', 'createdAt', 'updated_at', 'updatedAt', 'deleted_at', 'deletedAt',\n ...excludeFields,\n ])\n\n for (const [key, value] of Object.entries(doc)) {\n if (skipFields.has(key)) continue\n if (key.startsWith('cf:') || key.startsWith('cf_')) continue\n if (typeof value === 'string' && value.trim().length > 0 && value.length < 200) {\n return value.trim()\n }\n }\n return null\n}\n\nfunction formatEntityLabel(entityId: string): string {\n const entityName = entityId.split(':')[1] ?? entityId\n return entityName\n .replace(/_/g, ' ')\n .replace(/\\b\\w/g, (c) => c.toUpperCase())\n}\n\n/**\n * Extract a presenter from doc fields when no search.ts config exists.\n *\n * TODO: This is a basic implementation. Future improvements could include:\n * - Entity-type specific field mappings\n * - Smarter field combination (e.g., first_name + last_name)\n * - Custom field (cf:*) inspection for user-defined display fields\n * - Configuration for default presenter fields per entity type\n */\nexport function extractFallbackPresenter(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n): SearchResultPresenter {\n const entityLabel = formatEntityLabel(entityId)\n\n // 1. Try common title fields\n let title = findFirstValue(doc, TITLE_FIELDS)\n\n // 2. If no title found, try any string field\n if (!title) {\n title = findAnyStringValue(doc, new Set(SUBTITLE_FIELDS))\n }\n\n // 3. Last resort: use entity label + truncated record ID\n if (!title) {\n const shortId = recordId.length > 8 ? recordId.slice(0, 8) + '...' : recordId\n title = `${entityLabel} ${shortId}`\n }\n\n // Build subtitle from multiple relevant fields to show more context\n const subtitleParts: string[] = []\n for (const field of SUBTITLE_FIELDS) {\n const value = doc[field]\n if (value != null && String(value).trim().length > 0 && String(value) !== title) {\n subtitleParts.push(String(value).trim())\n if (subtitleParts.length >= 3) break // Limit to 3 parts\n }\n }\n\n return {\n title,\n subtitle: subtitleParts.length > 0 ? subtitleParts.join(' \u00B7 ').slice(0, 120) : undefined,\n badge: entityLabel,\n }\n}\n"],
5
- "mappings": "AAGA,MAAM,eAAe;AAAA,EACnB;AAAA,EAAgB;AAAA,EAChB;AAAA,EAAQ;AAAA,EAAS;AAAA,EACjB;AAAA,EAAa;AAAA,EACb;AAAA,EAAc;AAAA,EACd;AAAA,EAAc;AAAA,EACd;AAAA,EAAc;AAAA,EACd;AAAA,EAAa;AAAA,EACb;AAAA,EAAkB;AAAA,EAClB;AAAA,EAAS;AAAA,EAAiB;AAAA,EAC1B;AAAA,EAAQ;AAAA,EAAO;AAAA,EACf;AAAA,EAAc;AAChB;AAGA,MAAM,kBAAkB;AAAA,EACtB;AAAA,EAAe;AAAA,EAAW;AAAA,EAC1B;AAAA,EAAS;AAAA,EAAiB;AAAA,EAC1B;AAAA,EAAS;AAAA,EAAiB;AAAA,EAC1B;AAAA,EAAU;AAAA,EAAQ;AAAA,EAAQ;AAC5B;AAEA,SAAS,eAAe,KAA8B,QAAiC;AACrF,aAAW,SAAS,QAAQ;AAC1B,UAAM,QAAQ,IAAI,KAAK;AACvB,QAAI,SAAS,QAAQ,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,GAAG;AACpD,aAAO,OAAO,KAAK,EAAE,KAAK;AAAA,IAC5B;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,mBAAmB,KAA8B,eAA2C;AAEnG,QAAM,aAAa,oBAAI,IAAI;AAAA,IACzB;AAAA,IAAM;AAAA,IAAa;AAAA,IAAY;AAAA,IAAmB;AAAA,IAClD;AAAA,IAAc;AAAA,IAAa;AAAA,IAAc;AAAA,IAAa;AAAA,IAAc;AAAA,IACpE,GAAG;AAAA,EACL,CAAC;AAED,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,GAAG,GAAG;AAC9C,QAAI,WAAW,IAAI,GAAG,EAAG;AACzB,QAAI,IAAI,WAAW,KAAK,KAAK,IAAI,WAAW,KAAK,EAAG;AACpD,QAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,KAAK,MAAM,SAAS,KAAK;AAC9E,aAAO,MAAM,KAAK;AAAA,IACpB;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,kBAAkB,UAA0B;AACnD,QAAM,aAAa,SAAS,MAAM,GAAG,EAAE,CAAC,KAAK;AAC7C,SAAO,WACJ,QAAQ,MAAM,GAAG,EACjB,QAAQ,SAAS,CAAC,MAAM,EAAE,YAAY,CAAC;AAC5C;AAWO,SAAS,yBACd,KACA,UACA,UACuB;AACvB,QAAM,cAAc,kBAAkB,QAAQ;AAG9C,MAAI,QAAQ,eAAe,KAAK,YAAY;AAG5C,MAAI,CAAC,OAAO;AACV,YAAQ,mBAAmB,KAAK,IAAI,IAAI,eAAe,CAAC;AAAA,EAC1D;AAGA,MAAI,CAAC,OAAO;AACV,UAAM,UAAU,SAAS,SAAS,IAAI,SAAS,MAAM,GAAG,CAAC,IAAI,QAAQ;AACrE,YAAQ,GAAG,WAAW,IAAI,OAAO;AAAA,EACnC;AAGA,QAAM,gBAA0B,CAAC;AACjC,aAAW,SAAS,iBAAiB;AACnC,UAAM,QAAQ,IAAI,KAAK;AACvB,QAAI,SAAS,QAAQ,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,KAAK,OAAO,KAAK,MAAM,OAAO;AAC/E,oBAAc,KAAK,OAAO,KAAK,EAAE,KAAK,CAAC;AACvC,UAAI,cAAc,UAAU,EAAG;AAAA,IACjC;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IACA,UAAU,cAAc,SAAS,IAAI,cAAc,KAAK,QAAK,EAAE,MAAM,GAAG,GAAG,IAAI;AAAA,IAC/E,OAAO;AAAA,EACT;AACF;",
4
+ "sourcesContent": ["import type { SearchResultPresenter } from '@open-mercato/shared/modules/search'\n\nconst TITLE_FIELDS_PRIMARY = [\n 'display_name', 'displayName',\n 'name', 'title', 'label',\n 'full_name', 'fullName',\n 'brand_name', 'brandName',\n 'legal_name', 'legalName',\n 'preferred_name', 'preferredName',\n]\n\nconst TITLE_FIELDS_SECONDARY = [\n 'email', 'primary_email', 'primaryEmail',\n 'code', 'sku', 'reference',\n 'identifier', 'slug',\n]\n\nconst FIRST_NAME_FIELDS = ['first_name', 'firstName']\nconst LAST_NAME_FIELDS = ['last_name', 'lastName']\nconst MAX_SUBTITLE_LENGTH = 120\n\n// Fields to check for subtitle\nconst SUBTITLE_FIELDS = [\n 'description', 'summary', 'notes',\n 'email', 'primary_email', 'primaryEmail',\n 'phone', 'primary_phone', 'primaryPhone',\n 'status', 'type', 'kind', 'category',\n]\n\nfunction findFirstValue(doc: Record<string, unknown>, fields: string[]): string | null {\n for (const field of fields) {\n const value = doc[field]\n if (value != null && String(value).trim().length > 0) {\n return String(value).trim()\n }\n }\n return null\n}\n\nfunction buildNameFromParts(doc: Record<string, unknown>): string | null {\n const firstName = findFirstValue(doc, FIRST_NAME_FIELDS)\n const lastName = findFirstValue(doc, LAST_NAME_FIELDS)\n if (firstName && lastName) return `${firstName} ${lastName}`\n return firstName ?? lastName\n}\n\nfunction findAnyStringValue(doc: Record<string, unknown>, excludeFields: Set<string>): string | null {\n // Skip these fields as they're not meaningful for display\n const skipFields = new Set([\n 'id', 'tenant_id', 'tenantId', 'organization_id', 'organizationId',\n 'created_at', 'createdAt', 'updated_at', 'updatedAt', 'deleted_at', 'deletedAt',\n ...excludeFields,\n ])\n\n for (const [key, value] of Object.entries(doc)) {\n if (skipFields.has(key)) continue\n if (key.startsWith('cf:') || key.startsWith('cf_')) continue\n if (typeof value === 'string' && value.trim().length > 0 && value.length < 200) {\n return value.trim()\n }\n }\n return null\n}\n\nfunction formatEntityLabel(entityId: string): string {\n const entityName = entityId.split(':')[1] ?? entityId\n return entityName\n .replace(/_/g, ' ')\n .replace(/\\b\\w/g, (c) => c.toUpperCase())\n}\n\nfunction truncateSubtitle(value: string): string {\n if (value.length <= MAX_SUBTITLE_LENGTH) return value\n return value.slice(0, MAX_SUBTITLE_LENGTH).trimEnd()\n}\n\n/**\n * Extract a presenter from doc fields when no search.ts config exists.\n *\n * TODO: This is a basic implementation. Future improvements could include:\n * - Entity-type specific field mappings\n * - Smarter field combination (e.g., first_name + last_name)\n * - Custom field (cf:*) inspection for user-defined display fields\n * - Configuration for default presenter fields per entity type\n */\nexport function extractFallbackPresenter(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n): SearchResultPresenter {\n const entityLabel = formatEntityLabel(entityId)\n\n let title = findFirstValue(doc, TITLE_FIELDS_PRIMARY)\n\n if (!title) {\n title = buildNameFromParts(doc)\n }\n\n if (!title) {\n title = findFirstValue(doc, TITLE_FIELDS_SECONDARY)\n }\n\n if (!title) {\n title = findAnyStringValue(\n doc,\n new Set([...SUBTITLE_FIELDS, ...FIRST_NAME_FIELDS, ...LAST_NAME_FIELDS]),\n )\n }\n\n if (!title) {\n const shortId = recordId.length > 8 ? recordId.slice(0, 8) + '...' : recordId\n title = `${entityLabel} ${shortId}`\n }\n\n const subtitleParts: string[] = []\n for (const field of SUBTITLE_FIELDS) {\n const value = doc[field]\n if (value != null && String(value).trim().length > 0 && String(value) !== title) {\n subtitleParts.push(String(value).trim())\n if (subtitleParts.length >= 3) break // Limit to 3 parts\n }\n }\n\n return {\n title,\n subtitle: subtitleParts.length > 0 ? truncateSubtitle(subtitleParts.join(' \u00B7 ')) : undefined,\n badge: entityLabel,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -139,6 +139,7 @@ function GlobalSearchDialog({
139
139
  const [error, setError] = React.useState(null);
140
140
  const [selectedIndex, setSelectedIndex] = React.useState(0);
141
141
  const inputRef = React.useRef(null);
142
+ const listRef = React.useRef(null);
142
143
  const abortRef = React.useRef(null);
143
144
  const t = useT();
144
145
  const [showScopeHint, setShowScopeHint] = React.useState(() => hasActiveOrganizationSelection());
@@ -263,6 +264,18 @@ function GlobalSearchDialog({
263
264
  return;
264
265
  }
265
266
  }, [results, selectedIndex, openResult]);
267
+ React.useEffect(() => {
268
+ const container = listRef.current;
269
+ const active = container?.querySelector('[data-active="true"]');
270
+ if (!container || !active) return;
271
+ const { top: containerTop, bottom: containerBottom } = container.getBoundingClientRect();
272
+ const { top: activeTop, bottom: activeBottom } = active.getBoundingClientRect();
273
+ if (activeTop < containerTop) {
274
+ container.scrollTop -= containerTop - activeTop;
275
+ } else if (activeBottom > containerBottom) {
276
+ container.scrollTop += activeBottom - containerBottom;
277
+ }
278
+ }, [selectedIndex]);
266
279
  const showVectorWarning = !embeddingConfigured && enabledStrategies.includes("vector") && !error;
267
280
  const selectedResult = results[selectedIndex];
268
281
  const selectedHasLink = selectedResult ? pickPrimaryLink(selectedResult) !== null : false;
@@ -308,14 +321,14 @@ function GlobalSearchDialog({
308
321
  showVectorWarning ? /* @__PURE__ */ jsx("p", { className: "rounded bg-amber-100 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-800 dark:text-amber-200", children: missingConfigMessage }) : null,
309
322
  showScopeHint ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("search.scopeHint.currentOrg", "Scoped to current organization") }) : null
310
323
  ] }),
311
- /* @__PURE__ */ jsxs("div", { className: "max-h-96 overflow-y-auto px-2 pb-3", children: [
324
+ /* @__PURE__ */ jsxs("div", { ref: listRef, className: "max-h-96 overflow-y-auto px-2 pb-3", children: [
312
325
  results.length === 0 && !loading && !error ? /* @__PURE__ */ jsx("div", { className: "px-4 py-6 text-sm text-muted-foreground", children: query.trim().length < MIN_QUERY_LENGTH ? t("search.dialog.empty.hint") : t("search.dialog.empty.none") }) : null,
313
326
  /* @__PURE__ */ jsx("ul", { className: "flex flex-col", children: results.map((result, index) => {
314
327
  const presenter = result.presenter;
315
328
  const isActive = index === selectedIndex;
316
329
  const hasLink = pickPrimaryLink(result) !== null;
317
330
  const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null;
318
- return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
331
+ return /* @__PURE__ */ jsx("li", { "data-active": isActive, children: /* @__PURE__ */ jsx(
319
332
  "button",
320
333
  {
321
334
  type: "button",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/search/frontend/components/GlobalSearchDialog.tsx"],
4
- "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n Search,\n Loader2,\n Zap,\n User,\n Users,\n Building,\n StickyNote,\n Briefcase,\n CheckSquare,\n FileText,\n Mail,\n Phone,\n Calendar,\n Clock,\n Star,\n Tag,\n Flag,\n Heart,\n Bookmark,\n Package,\n Truck,\n ShoppingCart,\n CreditCard,\n DollarSign,\n Target,\n Award,\n Trophy,\n Rocket,\n Lightbulb,\n MessageSquare,\n Bell,\n Settings,\n Globe,\n MapPin,\n Link,\n Folder,\n Database,\n Activity,\n} from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\nimport { Dialog, DialogContent, DialogTitle } from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport type { SearchResult, SearchResultLink, SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n getCurrentOrganizationScope,\n subscribeOrganizationScopeChanged,\n} from '@open-mercato/shared/lib/frontend/organizationEvents'\nimport { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'\nimport { parseSelectedOrganizationCookie } from '@open-mercato/core/modules/directory/utils/scopeCookies'\nimport { fetchGlobalSearchResults } from '../utils'\n\nconst MIN_QUERY_LENGTH = 2\n\n/** Default strategies used when none are configured */\nconst DEFAULT_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']\n\nfunction normalizeLinks(links?: SearchResultLink[] | null): SearchResultLink[] {\n if (!Array.isArray(links)) return []\n return links.filter((link) => typeof link?.href === 'string')\n}\n\nfunction pickPrimaryLink(result: SearchResult): string | null {\n if (result.url) return result.url\n const links = normalizeLinks(result.links)\n if (!links.length) return null\n const primary = links.find((link) => link.kind === 'primary')\n return (primary ?? links[0]).href\n}\n\nfunction hasActiveOrganizationSelection(): boolean {\n const fromEvent = getCurrentOrganizationScope().organizationId\n if (typeof fromEvent === 'string' && fromEvent.trim().length > 0) return true\n\n const cookieHeader = typeof document === 'undefined' ? null : document.cookie\n const cookieValue = parseSelectedOrganizationCookie(cookieHeader)\n if (!cookieValue) return false\n return !isAllOrganizationsSelection(cookieValue);\n}\n\nfunction humanizeSegment(segment: string): string {\n return segment\n .split(/[_-]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ')\n}\n\nconst ICON_MAP: Record<string, LucideIcon> = {\n bolt: Zap,\n zap: Zap,\n user: User,\n users: Users,\n building: Building,\n 'sticky-note': StickyNote,\n briefcase: Briefcase,\n 'check-square': CheckSquare,\n 'file-text': FileText,\n mail: Mail,\n phone: Phone,\n calendar: Calendar,\n clock: Clock,\n star: Star,\n tag: Tag,\n flag: Flag,\n heart: Heart,\n bookmark: Bookmark,\n package: Package,\n truck: Truck,\n 'shopping-cart': ShoppingCart,\n 'credit-card': CreditCard,\n 'dollar-sign': DollarSign,\n target: Target,\n award: Award,\n trophy: Trophy,\n rocket: Rocket,\n lightbulb: Lightbulb,\n 'message-square': MessageSquare,\n bell: Bell,\n settings: Settings,\n globe: Globe,\n 'map-pin': MapPin,\n link: Link,\n folder: Folder,\n database: Database,\n activity: Activity,\n}\n\nfunction resolveIcon(name?: string): LucideIcon | null {\n if (!name) return null\n return ICON_MAP[name.toLowerCase()] ?? null\n}\n\nfunction formatEntityId(entityId: string): string {\n if (!entityId.includes(':')) return humanizeSegment(entityId)\n const [module, entity] = entityId.split(':')\n return `${humanizeSegment(module)} \u00B7 ${humanizeSegment(entity)}`\n}\n\nexport type GlobalSearchDialogProps = {\n /** Whether embedding provider is configured for vector search */\n embeddingConfigured: boolean\n /** Message to show when embedding is not configured */\n missingConfigMessage: string\n /** Enabled strategies from tenant configuration (optional - uses defaults if not provided) */\n enabledStrategies?: SearchStrategyId[]\n}\n\nexport function GlobalSearchDialog({\n embeddingConfigured,\n missingConfigMessage,\n enabledStrategies: propStrategies,\n}: GlobalSearchDialogProps) {\n const router = useRouter()\n const [open, setOpen] = React.useState(false)\n const [query, setQuery] = React.useState('')\n const [results, setResults] = React.useState<SearchResult[]>([])\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [selectedIndex, setSelectedIndex] = React.useState(0)\n const inputRef = React.useRef<HTMLInputElement | null>(null)\n const abortRef = React.useRef<AbortController | null>(null)\n const t = useT()\n const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())\n\n React.useEffect(() => {\n setShowScopeHint(hasActiveOrganizationSelection())\n return subscribeOrganizationScopeChanged((detail) => {\n setShowScopeHint(Boolean(detail.organizationId && detail.organizationId.trim().length > 0))\n })\n }, [])\n\n // Use configured strategies or fall back to defaults\n const enabledStrategies = React.useMemo(() => {\n if (propStrategies && propStrategies.length > 0) {\n return propStrategies\n }\n return DEFAULT_STRATEGIES\n }, [propStrategies])\n\n const resetState = React.useCallback(() => {\n setQuery('')\n setResults([])\n setError(null)\n setSelectedIndex(0)\n setLoading(false)\n }, [])\n\n React.useEffect(() => {\n if (!open) {\n resetState()\n return\n }\n const handler = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {\n event.preventDefault()\n }\n }\n window.addEventListener('keydown', handler)\n return () => window.removeEventListener('keydown', handler)\n }, [open, resetState])\n\n React.useEffect(() => {\n const shortcut = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {\n event.preventDefault()\n setOpen((prev) => !prev)\n }\n }\n window.addEventListener('keydown', shortcut)\n return () => window.removeEventListener('keydown', shortcut)\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n const focusTimer = setTimeout(() => inputRef.current?.focus(), 50)\n return () => clearTimeout(focusTimer)\n }, [open])\n\n React.useEffect(() => {\n if (!open) return\n\n abortRef.current?.abort()\n if (query.trim().length < MIN_QUERY_LENGTH) {\n setResults([])\n setError(null)\n setLoading(false)\n return\n }\n\n const controller = new AbortController()\n abortRef.current = controller\n setLoading(true)\n\n const handle = setTimeout(async () => {\n try {\n const data = await fetchGlobalSearchResults(query, {\n limit: 10,\n signal: controller.signal,\n })\n setResults(data.results)\n setError(data.error ?? null)\n setSelectedIndex(0)\n } catch (err: unknown) {\n if (controller.signal.aborted) return\n const abortError = err as { name?: string }\n if (abortError?.name === 'AbortError') return\n setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed'))\n setResults([])\n } finally {\n if (!controller.signal.aborted) setLoading(false)\n }\n }, 220)\n\n return () => {\n clearTimeout(handle)\n controller.abort()\n }\n }, [open, query, enabledStrategies, t])\n\n const openResult = React.useCallback((result: SearchResult | undefined) => {\n if (!result) return\n const href = pickPrimaryLink(result)\n if (!href) return\n router.push(href)\n setOpen(false)\n }, [router])\n\n const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {\n if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {\n event.preventDefault()\n openResult(results[selectedIndex])\n return\n }\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSelectedIndex((prev) => (prev + 1) % Math.max(results.length || 1, 1))\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSelectedIndex((prev) => {\n if (!results.length) return 0\n return prev <= 0 ? results.length - 1 : prev - 1\n })\n return\n }\n if (event.key === 'Escape') {\n event.preventDefault()\n setOpen(false)\n return\n }\n if (event.key === 'Enter') {\n event.preventDefault()\n const target = results[selectedIndex]\n openResult(target)\n return\n }\n }, [results, selectedIndex, openResult])\n\n // Check if vector search is enabled but not configured\n const showVectorWarning = !embeddingConfigured && enabledStrategies.includes('vector') && !error\n\n // Check if selected result has a navigable link\n const selectedResult = results[selectedIndex]\n const selectedHasLink = selectedResult ? pickPrimaryLink(selectedResult) !== null : false\n\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setOpen(true)} className=\"hidden sm:inline-flex items-center gap-2\">\n <Search className=\"h-4 w-4\" />\n <span>{t('search.dialog.actions.search')}</span>\n <span className=\"ml-2 rounded border px-1 text-xs text-muted-foreground\">\u2318K</span>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"sm:hidden\"\n onClick={() => setOpen(true)}\n aria-label={t('search.dialog.actions.openGlobalSearch')}\n >\n <Search className=\"h-4 w-4\" />\n </Button>\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent className=\"max-w-xl p-0\" aria-describedby=\"global-search-description\">\n <DialogTitle className=\"sr-only\">\n {t('search.dialog.title', 'Global Search')}\n </DialogTitle>\n <span id=\"global-search-description\" className=\"sr-only\">\n {t('search.dialog.instructions')}\n </span>\n <div className=\"flex flex-col gap-3 border-b px-4 pb-3 pt-12\">\n <div className=\"flex items-center gap-2 rounded border bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring\">\n <Search className=\"h-4 w-4 text-muted-foreground\" />\n <TypedInput\n ref={inputRef}\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onKeyDown={handleKeyDown}\n placeholder={t('search.dialog.input.placeholder')}\n className=\"border-none px-0 shadow-none focus-visible:ring-0\"\n autoFocus\n />\n {loading && <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />}\n </div>\n\n {error ? (\n <p className=\"rounded bg-destructive/10 px-3 py-2 text-sm text-destructive\">{error}</p>\n ) : null}\n {showVectorWarning ? (\n <p className=\"rounded bg-amber-100 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-800 dark:text-amber-200\">{missingConfigMessage}</p>\n ) : null}\n {showScopeHint ? (\n <p className=\"text-xs text-muted-foreground\">\n {t('search.scopeHint.currentOrg', 'Scoped to current organization')}\n </p>\n ) : null}\n </div>\n <div className=\"max-h-96 overflow-y-auto px-2 pb-3\">\n {results.length === 0 && !loading && !error ? (\n <div className=\"px-4 py-6 text-sm text-muted-foreground\">\n {query.trim().length < MIN_QUERY_LENGTH\n ? t('search.dialog.empty.hint')\n : t('search.dialog.empty.none')}\n </div>\n ) : null}\n <ul className=\"flex flex-col\">\n {results.map((result, index) => {\n const presenter = result.presenter\n const isActive = index === selectedIndex\n const hasLink = pickPrimaryLink(result) !== null\n const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null\n return (\n <li key={`${result.entityId}:${result.recordId}`}>\n <button\n type=\"button\"\n onClick={() => openResult(result)}\n onMouseEnter={() => setSelectedIndex(index)}\n className={cn(\n 'w-full rounded-lg px-4 py-3 text-left transition border',\n isActive\n ? 'border-primary bg-primary/10 text-foreground shadow-sm'\n : 'border-transparent hover:border-muted-foreground/30 hover:bg-muted/60',\n !hasLink && 'opacity-60'\n )}\n >\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"flex flex-col gap-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className={cn('font-medium text-base whitespace-normal break-all', !hasLink && 'text-muted-foreground')}>{presenter?.title ?? result.recordId}</span>\n <span className=\"rounded-full border border-muted-foreground/30 px-2 py-0.5 text-xs text-muted-foreground\">\n {formatEntityId(result.entityId)}\n </span>\n {!hasLink && (\n <span className=\"rounded-full border border-amber-500/50 bg-amber-50 dark:bg-amber-900/20 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-400\">\n {t('search.dialog.noLink')}\n </span>\n )}\n </div>\n {presenter?.subtitle ? (\n <div className=\"text-sm text-muted-foreground whitespace-normal break-words\">{presenter.subtitle}</div>\n ) : null}\n {normalizeLinks(result.links).length ? (\n <div className=\"mt-1 flex flex-wrap items-center gap-2\">\n {normalizeLinks(result.links).map((link) => (\n <span\n key={`${link.href}`}\n className={cn(\n 'rounded-full border px-2 py-0.5 text-xs',\n link.kind === 'primary'\n ? 'border-primary text-primary'\n : 'border-muted-foreground/40 text-muted-foreground'\n )}\n >\n {link.label ?? link.href}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n {Icon ? (\n <div className=\"flex flex-col items-end gap-2\">\n <Icon className=\"h-5 w-5 text-muted-foreground\" />\n </div>\n ) : null}\n </div>\n </button>\n </li>\n )\n })}\n </ul>\n </div>\n <div className=\"flex items-center justify-between border-t px-4 py-3\">\n <span className=\"text-xs text-muted-foreground\">\n {selectedResult && !selectedHasLink\n ? t('search.dialog.noLinkHint')\n : t('search.dialog.shortcuts.hint')}\n </span>\n <div className=\"flex items-center gap-2\">\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setOpen(false)}>\n {t('search.dialog.actions.cancel')}\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n onClick={() => openResult(results[selectedIndex])}\n disabled={!results.length || !selectedHasLink}\n >\n {t('search.dialog.actions.openSelected')}\n </Button>\n </div>\n </div>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n\nexport default GlobalSearchDialog\nconst TypedInput = Input as React.ForwardRefExoticComponent<React.InputHTMLAttributes<HTMLInputElement> & React.RefAttributes<HTMLInputElement>>\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n Search,\n Loader2,\n Zap,\n User,\n Users,\n Building,\n StickyNote,\n Briefcase,\n CheckSquare,\n FileText,\n Mail,\n Phone,\n Calendar,\n Clock,\n Star,\n Tag,\n Flag,\n Heart,\n Bookmark,\n Package,\n Truck,\n ShoppingCart,\n CreditCard,\n DollarSign,\n Target,\n Award,\n Trophy,\n Rocket,\n Lightbulb,\n MessageSquare,\n Bell,\n Settings,\n Globe,\n MapPin,\n Link,\n Folder,\n Database,\n Activity,\n} from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\nimport { Dialog, DialogContent, DialogTitle } from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport type { SearchResult, SearchResultLink, SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n getCurrentOrganizationScope,\n subscribeOrganizationScopeChanged,\n} from '@open-mercato/shared/lib/frontend/organizationEvents'\nimport { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'\nimport { parseSelectedOrganizationCookie } from '@open-mercato/core/modules/directory/utils/scopeCookies'\nimport { fetchGlobalSearchResults } from '../utils'\n\nconst MIN_QUERY_LENGTH = 2\n\n/** Default strategies used when none are configured */\nconst DEFAULT_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']\n\nfunction normalizeLinks(links?: SearchResultLink[] | null): SearchResultLink[] {\n if (!Array.isArray(links)) return []\n return links.filter((link) => typeof link?.href === 'string')\n}\n\nfunction pickPrimaryLink(result: SearchResult): string | null {\n if (result.url) return result.url\n const links = normalizeLinks(result.links)\n if (!links.length) return null\n const primary = links.find((link) => link.kind === 'primary')\n return (primary ?? links[0]).href\n}\n\nfunction hasActiveOrganizationSelection(): boolean {\n const fromEvent = getCurrentOrganizationScope().organizationId\n if (typeof fromEvent === 'string' && fromEvent.trim().length > 0) return true\n\n const cookieHeader = typeof document === 'undefined' ? null : document.cookie\n const cookieValue = parseSelectedOrganizationCookie(cookieHeader)\n if (!cookieValue) return false\n return !isAllOrganizationsSelection(cookieValue);\n}\n\nfunction humanizeSegment(segment: string): string {\n return segment\n .split(/[_-]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ')\n}\n\nconst ICON_MAP: Record<string, LucideIcon> = {\n bolt: Zap,\n zap: Zap,\n user: User,\n users: Users,\n building: Building,\n 'sticky-note': StickyNote,\n briefcase: Briefcase,\n 'check-square': CheckSquare,\n 'file-text': FileText,\n mail: Mail,\n phone: Phone,\n calendar: Calendar,\n clock: Clock,\n star: Star,\n tag: Tag,\n flag: Flag,\n heart: Heart,\n bookmark: Bookmark,\n package: Package,\n truck: Truck,\n 'shopping-cart': ShoppingCart,\n 'credit-card': CreditCard,\n 'dollar-sign': DollarSign,\n target: Target,\n award: Award,\n trophy: Trophy,\n rocket: Rocket,\n lightbulb: Lightbulb,\n 'message-square': MessageSquare,\n bell: Bell,\n settings: Settings,\n globe: Globe,\n 'map-pin': MapPin,\n link: Link,\n folder: Folder,\n database: Database,\n activity: Activity,\n}\n\nfunction resolveIcon(name?: string): LucideIcon | null {\n if (!name) return null\n return ICON_MAP[name.toLowerCase()] ?? null\n}\n\nfunction formatEntityId(entityId: string): string {\n if (!entityId.includes(':')) return humanizeSegment(entityId)\n const [module, entity] = entityId.split(':')\n return `${humanizeSegment(module)} \u00B7 ${humanizeSegment(entity)}`\n}\n\nexport type GlobalSearchDialogProps = {\n /** Whether embedding provider is configured for vector search */\n embeddingConfigured: boolean\n /** Message to show when embedding is not configured */\n missingConfigMessage: string\n /** Enabled strategies from tenant configuration (optional - uses defaults if not provided) */\n enabledStrategies?: SearchStrategyId[]\n}\n\nexport function GlobalSearchDialog({\n embeddingConfigured,\n missingConfigMessage,\n enabledStrategies: propStrategies,\n}: GlobalSearchDialogProps) {\n const router = useRouter()\n const [open, setOpen] = React.useState(false)\n const [query, setQuery] = React.useState('')\n const [results, setResults] = React.useState<SearchResult[]>([])\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [selectedIndex, setSelectedIndex] = React.useState(0)\n const inputRef = React.useRef<HTMLInputElement | null>(null)\n const listRef = React.useRef<HTMLDivElement | null>(null)\n const abortRef = React.useRef<AbortController | null>(null)\n const t = useT()\n const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())\n\n React.useEffect(() => {\n setShowScopeHint(hasActiveOrganizationSelection())\n return subscribeOrganizationScopeChanged((detail) => {\n setShowScopeHint(Boolean(detail.organizationId && detail.organizationId.trim().length > 0))\n })\n }, [])\n\n // Use configured strategies or fall back to defaults\n const enabledStrategies = React.useMemo(() => {\n if (propStrategies && propStrategies.length > 0) {\n return propStrategies\n }\n return DEFAULT_STRATEGIES\n }, [propStrategies])\n\n const resetState = React.useCallback(() => {\n setQuery('')\n setResults([])\n setError(null)\n setSelectedIndex(0)\n setLoading(false)\n }, [])\n\n React.useEffect(() => {\n if (!open) {\n resetState()\n return\n }\n const handler = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {\n event.preventDefault()\n }\n }\n window.addEventListener('keydown', handler)\n return () => window.removeEventListener('keydown', handler)\n }, [open, resetState])\n\n React.useEffect(() => {\n const shortcut = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {\n event.preventDefault()\n setOpen((prev) => !prev)\n }\n }\n window.addEventListener('keydown', shortcut)\n return () => window.removeEventListener('keydown', shortcut)\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n const focusTimer = setTimeout(() => inputRef.current?.focus(), 50)\n return () => clearTimeout(focusTimer)\n }, [open])\n\n React.useEffect(() => {\n if (!open) return\n\n abortRef.current?.abort()\n if (query.trim().length < MIN_QUERY_LENGTH) {\n setResults([])\n setError(null)\n setLoading(false)\n return\n }\n\n const controller = new AbortController()\n abortRef.current = controller\n setLoading(true)\n\n const handle = setTimeout(async () => {\n try {\n const data = await fetchGlobalSearchResults(query, {\n limit: 10,\n signal: controller.signal,\n })\n setResults(data.results)\n setError(data.error ?? null)\n setSelectedIndex(0)\n } catch (err: unknown) {\n if (controller.signal.aborted) return\n const abortError = err as { name?: string }\n if (abortError?.name === 'AbortError') return\n setError(err instanceof Error ? err.message : t('search.dialog.errors.searchFailed'))\n setResults([])\n } finally {\n if (!controller.signal.aborted) setLoading(false)\n }\n }, 220)\n\n return () => {\n clearTimeout(handle)\n controller.abort()\n }\n }, [open, query, enabledStrategies, t])\n\n const openResult = React.useCallback((result: SearchResult | undefined) => {\n if (!result) return\n const href = pickPrimaryLink(result)\n if (!href) return\n router.push(href)\n setOpen(false)\n }, [router])\n\n const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {\n if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {\n event.preventDefault()\n openResult(results[selectedIndex])\n return\n }\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSelectedIndex((prev) => (prev + 1) % Math.max(results.length || 1, 1))\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSelectedIndex((prev) => {\n if (!results.length) return 0\n return prev <= 0 ? results.length - 1 : prev - 1\n })\n return\n }\n if (event.key === 'Escape') {\n event.preventDefault()\n setOpen(false)\n return\n }\n if (event.key === 'Enter') {\n event.preventDefault()\n const target = results[selectedIndex]\n openResult(target)\n return\n }\n }, [results, selectedIndex, openResult])\n\n React.useEffect(() => {\n const container = listRef.current\n const active = container?.querySelector<HTMLElement>('[data-active=\"true\"]')\n if (!container || !active) return\n const { top: containerTop, bottom: containerBottom } = container.getBoundingClientRect()\n const { top: activeTop, bottom: activeBottom } = active.getBoundingClientRect()\n if (activeTop < containerTop) {\n container.scrollTop -= containerTop - activeTop\n } else if (activeBottom > containerBottom) {\n container.scrollTop += activeBottom - containerBottom\n }\n }, [selectedIndex])\n\n // Check if vector search is enabled but not configured\n const showVectorWarning = !embeddingConfigured && enabledStrategies.includes('vector') && !error\n\n // Check if selected result has a navigable link\n const selectedResult = results[selectedIndex]\n const selectedHasLink = selectedResult ? pickPrimaryLink(selectedResult) !== null : false\n\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setOpen(true)} className=\"hidden sm:inline-flex items-center gap-2\">\n <Search className=\"h-4 w-4\" />\n <span>{t('search.dialog.actions.search')}</span>\n <span className=\"ml-2 rounded border px-1 text-xs text-muted-foreground\">\u2318K</span>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"sm:hidden\"\n onClick={() => setOpen(true)}\n aria-label={t('search.dialog.actions.openGlobalSearch')}\n >\n <Search className=\"h-4 w-4\" />\n </Button>\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent className=\"max-w-xl p-0\" aria-describedby=\"global-search-description\">\n <DialogTitle className=\"sr-only\">\n {t('search.dialog.title', 'Global Search')}\n </DialogTitle>\n <span id=\"global-search-description\" className=\"sr-only\">\n {t('search.dialog.instructions')}\n </span>\n <div className=\"flex flex-col gap-3 border-b px-4 pb-3 pt-12\">\n <div className=\"flex items-center gap-2 rounded border bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring\">\n <Search className=\"h-4 w-4 text-muted-foreground\" />\n <TypedInput\n ref={inputRef}\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onKeyDown={handleKeyDown}\n placeholder={t('search.dialog.input.placeholder')}\n className=\"border-none px-0 shadow-none focus-visible:ring-0\"\n autoFocus\n />\n {loading && <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />}\n </div>\n\n {error ? (\n <p className=\"rounded bg-destructive/10 px-3 py-2 text-sm text-destructive\">{error}</p>\n ) : null}\n {showVectorWarning ? (\n <p className=\"rounded bg-amber-100 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-800 dark:text-amber-200\">{missingConfigMessage}</p>\n ) : null}\n {showScopeHint ? (\n <p className=\"text-xs text-muted-foreground\">\n {t('search.scopeHint.currentOrg', 'Scoped to current organization')}\n </p>\n ) : null}\n </div>\n <div ref={listRef} className=\"max-h-96 overflow-y-auto px-2 pb-3\">\n {results.length === 0 && !loading && !error ? (\n <div className=\"px-4 py-6 text-sm text-muted-foreground\">\n {query.trim().length < MIN_QUERY_LENGTH\n ? t('search.dialog.empty.hint')\n : t('search.dialog.empty.none')}\n </div>\n ) : null}\n <ul className=\"flex flex-col\">\n {results.map((result, index) => {\n const presenter = result.presenter\n const isActive = index === selectedIndex\n const hasLink = pickPrimaryLink(result) !== null\n const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null\n return (\n <li key={`${result.entityId}:${result.recordId}`} data-active={isActive}>\n <button\n type=\"button\"\n onClick={() => openResult(result)}\n onMouseEnter={() => setSelectedIndex(index)}\n className={cn(\n 'w-full rounded-lg px-4 py-3 text-left transition border',\n isActive\n ? 'border-primary bg-primary/10 text-foreground shadow-sm'\n : 'border-transparent hover:border-muted-foreground/30 hover:bg-muted/60',\n !hasLink && 'opacity-60'\n )}\n >\n <div className=\"flex items-start justify-between gap-4\">\n <div className=\"flex flex-col gap-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className={cn('font-medium text-base whitespace-normal break-all', !hasLink && 'text-muted-foreground')}>{presenter?.title ?? result.recordId}</span>\n <span className=\"rounded-full border border-muted-foreground/30 px-2 py-0.5 text-xs text-muted-foreground\">\n {formatEntityId(result.entityId)}\n </span>\n {!hasLink && (\n <span className=\"rounded-full border border-amber-500/50 bg-amber-50 dark:bg-amber-900/20 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-400\">\n {t('search.dialog.noLink')}\n </span>\n )}\n </div>\n {presenter?.subtitle ? (\n <div className=\"text-sm text-muted-foreground whitespace-normal break-words\">{presenter.subtitle}</div>\n ) : null}\n {normalizeLinks(result.links).length ? (\n <div className=\"mt-1 flex flex-wrap items-center gap-2\">\n {normalizeLinks(result.links).map((link) => (\n <span\n key={`${link.href}`}\n className={cn(\n 'rounded-full border px-2 py-0.5 text-xs',\n link.kind === 'primary'\n ? 'border-primary text-primary'\n : 'border-muted-foreground/40 text-muted-foreground'\n )}\n >\n {link.label ?? link.href}\n </span>\n ))}\n </div>\n ) : null}\n </div>\n {Icon ? (\n <div className=\"flex flex-col items-end gap-2\">\n <Icon className=\"h-5 w-5 text-muted-foreground\" />\n </div>\n ) : null}\n </div>\n </button>\n </li>\n )\n })}\n </ul>\n </div>\n <div className=\"flex items-center justify-between border-t px-4 py-3\">\n <span className=\"text-xs text-muted-foreground\">\n {selectedResult && !selectedHasLink\n ? t('search.dialog.noLinkHint')\n : t('search.dialog.shortcuts.hint')}\n </span>\n <div className=\"flex items-center gap-2\">\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setOpen(false)}>\n {t('search.dialog.actions.cancel')}\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n onClick={() => openResult(results[selectedIndex])}\n disabled={!results.length || !selectedHasLink}\n >\n {t('search.dialog.actions.openSelected')}\n </Button>\n </div>\n </div>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n\nexport default GlobalSearchDialog\nconst TypedInput = Input as React.ForwardRefExoticComponent<React.InputHTMLAttributes<HTMLInputElement> & React.RefAttributes<HTMLInputElement>>\n"],
5
+ "mappings": ";AAyUI,mBAEI,KADF,YADF;AAvUJ,YAAY,WAAW;AACvB,SAAS,iBAAiB;AAC1B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,QAAQ,eAAe,mBAAmB;AACnD,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,UAAU;AAEnB,SAAS,YAAY;AACrB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,mCAAmC;AAC5C,SAAS,uCAAuC;AAChD,SAAS,gCAAgC;AAEzC,MAAM,mBAAmB;AAGzB,MAAM,qBAAyC,CAAC,YAAY,UAAU,QAAQ;AAE9E,SAAS,eAAe,OAAuD;AAC7E,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO,CAAC;AACnC,SAAO,MAAM,OAAO,CAAC,SAAS,OAAO,MAAM,SAAS,QAAQ;AAC9D;AAEA,SAAS,gBAAgB,QAAqC;AAC5D,MAAI,OAAO,IAAK,QAAO,OAAO;AAC9B,QAAM,QAAQ,eAAe,OAAO,KAAK;AACzC,MAAI,CAAC,MAAM,OAAQ,QAAO;AAC1B,QAAM,UAAU,MAAM,KAAK,CAAC,SAAS,KAAK,SAAS,SAAS;AAC5D,UAAQ,WAAW,MAAM,CAAC,GAAG;AAC/B;AAEA,SAAS,iCAA0C;AACjD,QAAM,YAAY,4BAA4B,EAAE;AAChD,MAAI,OAAO,cAAc,YAAY,UAAU,KAAK,EAAE,SAAS,EAAG,QAAO;AAEzE,QAAM,eAAe,OAAO,aAAa,cAAc,OAAO,SAAS;AACvE,QAAM,cAAc,gCAAgC,YAAY;AAChE,MAAI,CAAC,YAAa,QAAO;AACzB,SAAO,CAAC,4BAA4B,WAAW;AACjD;AAEA,SAAS,gBAAgB,SAAyB;AAChD,SAAO,QACJ,MAAM,OAAO,EACb,OAAO,OAAO,EACd,IAAI,CAAC,SAAS,KAAK,OAAO,CAAC,EAAE,YAAY,IAAI,KAAK,MAAM,CAAC,CAAC,EAC1D,KAAK,GAAG;AACb;AAEA,MAAM,WAAuC;AAAA,EAC3C,MAAM;AAAA,EACN,KAAK;AAAA,EACL,MAAM;AAAA,EACN,OAAO;AAAA,EACP,UAAU;AAAA,EACV,eAAe;AAAA,EACf,WAAW;AAAA,EACX,gBAAgB;AAAA,EAChB,aAAa;AAAA,EACb,MAAM;AAAA,EACN,OAAO;AAAA,EACP,UAAU;AAAA,EACV,OAAO;AAAA,EACP,MAAM;AAAA,EACN,KAAK;AAAA,EACL,MAAM;AAAA,EACN,OAAO;AAAA,EACP,UAAU;AAAA,EACV,SAAS;AAAA,EACT,OAAO;AAAA,EACP,iBAAiB;AAAA,EACjB,eAAe;AAAA,EACf,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,kBAAkB;AAAA,EAClB,MAAM;AAAA,EACN,UAAU;AAAA,EACV,OAAO;AAAA,EACP,WAAW;AAAA,EACX,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,UAAU;AACZ;AAEA,SAAS,YAAY,MAAkC;AACrD,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,SAAS,KAAK,YAAY,CAAC,KAAK;AACzC;AAEA,SAAS,eAAe,UAA0B;AAChD,MAAI,CAAC,SAAS,SAAS,GAAG,EAAG,QAAO,gBAAgB,QAAQ;AAC5D,QAAM,CAAC,QAAQ,MAAM,IAAI,SAAS,MAAM,GAAG;AAC3C,SAAO,GAAG,gBAAgB,MAAM,CAAC,SAAM,gBAAgB,MAAM,CAAC;AAChE;AAWO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AAAA,EACA,mBAAmB;AACrB,GAA4B;AAC1B,QAAM,SAAS,UAAU;AACzB,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAC5C,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAS,EAAE;AAC3C,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAyB,CAAC,CAAC;AAC/D,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAwB,IAAI;AAC5D,QAAM,CAAC,eAAe,gBAAgB,IAAI,MAAM,SAAS,CAAC;AAC1D,QAAM,WAAW,MAAM,OAAgC,IAAI;AAC3D,QAAM,UAAU,MAAM,OAA8B,IAAI;AACxD,QAAM,WAAW,MAAM,OAA+B,IAAI;AAC1D,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,eAAe,gBAAgB,IAAI,MAAM,SAAkB,MAAM,+BAA+B,CAAC;AAExG,QAAM,UAAU,MAAM;AACpB,qBAAiB,+BAA+B,CAAC;AACjD,WAAO,kCAAkC,CAAC,WAAW;AACnD,uBAAiB,QAAQ,OAAO,kBAAkB,OAAO,eAAe,KAAK,EAAE,SAAS,CAAC,CAAC;AAAA,IAC5F,CAAC;AAAA,EACH,GAAG,CAAC,CAAC;AAGL,QAAM,oBAAoB,MAAM,QAAQ,MAAM;AAC5C,QAAI,kBAAkB,eAAe,SAAS,GAAG;AAC/C,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,GAAG,CAAC,cAAc,CAAC;AAEnB,QAAM,aAAa,MAAM,YAAY,MAAM;AACzC,aAAS,EAAE;AACX,eAAW,CAAC,CAAC;AACb,aAAS,IAAI;AACb,qBAAiB,CAAC;AAClB,eAAW,KAAK;AAAA,EAClB,GAAG,CAAC,CAAC;AAEL,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,MAAM;AACT,iBAAW;AACX;AAAA,IACF;AACA,UAAM,UAAU,CAAC,UAAyB;AACxC,WAAK,MAAM,WAAW,MAAM,YAAY,MAAM,IAAI,YAAY,MAAM,KAAK;AACvE,cAAM,eAAe;AAAA,MACvB;AAAA,IACF;AACA,WAAO,iBAAiB,WAAW,OAAO;AAC1C,WAAO,MAAM,OAAO,oBAAoB,WAAW,OAAO;AAAA,EAC5D,GAAG,CAAC,MAAM,UAAU,CAAC;AAErB,QAAM,UAAU,MAAM;AACpB,UAAM,WAAW,CAAC,UAAyB;AACzC,WAAK,MAAM,WAAW,MAAM,YAAY,MAAM,IAAI,YAAY,MAAM,KAAK;AACvE,cAAM,eAAe;AACrB,gBAAQ,CAAC,SAAS,CAAC,IAAI;AAAA,MACzB;AAAA,IACF;AACA,WAAO,iBAAiB,WAAW,QAAQ;AAC3C,WAAO,MAAM,OAAO,oBAAoB,WAAW,QAAQ;AAAA,EAC7D,GAAG,CAAC,CAAC;AAEL,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,KAAM;AACX,UAAM,aAAa,WAAW,MAAM,SAAS,SAAS,MAAM,GAAG,EAAE;AACjE,WAAO,MAAM,aAAa,UAAU;AAAA,EACtC,GAAG,CAAC,IAAI,CAAC;AAET,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,KAAM;AAEX,aAAS,SAAS,MAAM;AACxB,QAAI,MAAM,KAAK,EAAE,SAAS,kBAAkB;AAC1C,iBAAW,CAAC,CAAC;AACb,eAAS,IAAI;AACb,iBAAW,KAAK;AAChB;AAAA,IACF;AAEA,UAAM,aAAa,IAAI,gBAAgB;AACvC,aAAS,UAAU;AACnB,eAAW,IAAI;AAEf,UAAM,SAAS,WAAW,YAAY;AACpC,UAAI;AACF,cAAM,OAAO,MAAM,yBAAyB,OAAO;AAAA,UACjD,OAAO;AAAA,UACP,QAAQ,WAAW;AAAA,QACrB,CAAC;AACD,mBAAW,KAAK,OAAO;AACvB,iBAAS,KAAK,SAAS,IAAI;AAC3B,yBAAiB,CAAC;AAAA,MACpB,SAAS,KAAc;AACrB,YAAI,WAAW,OAAO,QAAS;AAC/B,cAAM,aAAa;AACnB,YAAI,YAAY,SAAS,aAAc;AACvC,iBAAS,eAAe,QAAQ,IAAI,UAAU,EAAE,mCAAmC,CAAC;AACpF,mBAAW,CAAC,CAAC;AAAA,MACf,UAAE;AACA,YAAI,CAAC,WAAW,OAAO,QAAS,YAAW,KAAK;AAAA,MAClD;AAAA,IACF,GAAG,GAAG;AAEN,WAAO,MAAM;AACX,mBAAa,MAAM;AACnB,iBAAW,MAAM;AAAA,IACnB;AAAA,EACF,GAAG,CAAC,MAAM,OAAO,mBAAmB,CAAC,CAAC;AAEtC,QAAM,aAAa,MAAM,YAAY,CAAC,WAAqC;AACzE,QAAI,CAAC,OAAQ;AACb,UAAM,OAAO,gBAAgB,MAAM;AACnC,QAAI,CAAC,KAAM;AACX,WAAO,KAAK,IAAI;AAChB,YAAQ,KAAK;AAAA,EACf,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,gBAAgB,MAAM,YAAY,CAAC,UAAiD;AACxF,SAAK,MAAM,WAAW,MAAM,YAAY,MAAM,QAAQ,SAAS;AAC7D,YAAM,eAAe;AACrB,iBAAW,QAAQ,aAAa,CAAC;AACjC;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,aAAa;AAC7B,YAAM,eAAe;AACrB,uBAAiB,CAAC,UAAU,OAAO,KAAK,KAAK,IAAI,QAAQ,UAAU,GAAG,CAAC,CAAC;AACxE;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,WAAW;AAC3B,YAAM,eAAe;AACrB,uBAAiB,CAAC,SAAS;AACzB,YAAI,CAAC,QAAQ,OAAQ,QAAO;AAC5B,eAAO,QAAQ,IAAI,QAAQ,SAAS,IAAI,OAAO;AAAA,MACjD,CAAC;AACD;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,UAAU;AAC1B,YAAM,eAAe;AACrB,cAAQ,KAAK;AACb;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,SAAS;AACzB,YAAM,eAAe;AACrB,YAAM,SAAS,QAAQ,aAAa;AACpC,iBAAW,MAAM;AACjB;AAAA,IACF;AAAA,EACF,GAAG,CAAC,SAAS,eAAe,UAAU,CAAC;AAEvC,QAAM,UAAU,MAAM;AACpB,UAAM,YAAY,QAAQ;AAC1B,UAAM,SAAS,WAAW,cAA2B,sBAAsB;AAC3E,QAAI,CAAC,aAAa,CAAC,OAAQ;AAC3B,UAAM,EAAE,KAAK,cAAc,QAAQ,gBAAgB,IAAI,UAAU,sBAAsB;AACvF,UAAM,EAAE,KAAK,WAAW,QAAQ,aAAa,IAAI,OAAO,sBAAsB;AAC9E,QAAI,YAAY,cAAc;AAC5B,gBAAU,aAAa,eAAe;AAAA,IACxC,WAAW,eAAe,iBAAiB;AACzC,gBAAU,aAAa,eAAe;AAAA,IACxC;AAAA,EACF,GAAG,CAAC,aAAa,CAAC;AAGlB,QAAM,oBAAoB,CAAC,uBAAuB,kBAAkB,SAAS,QAAQ,KAAK,CAAC;AAG3F,QAAM,iBAAiB,QAAQ,aAAa;AAC5C,QAAM,kBAAkB,iBAAiB,gBAAgB,cAAc,MAAM,OAAO;AAEpF,SACE,iCACE;AAAA,yBAAC,UAAO,MAAK,UAAS,SAAQ,SAAQ,MAAK,MAAK,SAAS,MAAM,QAAQ,IAAI,GAAG,WAAU,4CACtF;AAAA,0BAAC,UAAO,WAAU,WAAU;AAAA,MAC5B,oBAAC,UAAM,YAAE,8BAA8B,GAAE;AAAA,MACzC,oBAAC,UAAK,WAAU,0DAAyD,qBAAE;AAAA,OAC7E;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,WAAU;AAAA,QACV,SAAS,MAAM,QAAQ,IAAI;AAAA,QAC3B,cAAY,EAAE,wCAAwC;AAAA,QAEtD,8BAAC,UAAO,WAAU,WAAU;AAAA;AAAA,IAC9B;AAAA,IACA,oBAAC,UAAO,MAAY,cAAc,SAChC,+BAAC,iBAAc,WAAU,gBAAe,oBAAiB,6BACvD;AAAA,0BAAC,eAAY,WAAU,WACpB,YAAE,uBAAuB,eAAe,GAC3C;AAAA,MACA,oBAAC,UAAK,IAAG,6BAA4B,WAAU,WAC5C,YAAE,4BAA4B,GACjC;AAAA,MACA,qBAAC,SAAI,WAAU,gDACb;AAAA,6BAAC,SAAI,WAAU,6GACb;AAAA,8BAAC,UAAO,WAAU,iCAAgC;AAAA,UAClD;AAAA,YAAC;AAAA;AAAA,cACC,KAAK;AAAA,cACL,OAAO;AAAA,cACP,UAAU,CAAC,UAAU,SAAS,MAAM,OAAO,KAAK;AAAA,cAChD,WAAW;AAAA,cACX,aAAa,EAAE,iCAAiC;AAAA,cAChD,WAAU;AAAA,cACV,WAAS;AAAA;AAAA,UACX;AAAA,UACC,WAAW,oBAAC,WAAQ,WAAU,8CAA6C;AAAA,WAC9E;AAAA,QAEC,QACC,oBAAC,OAAE,WAAU,gEAAgE,iBAAM,IACjF;AAAA,QACH,oBACC,oBAAC,OAAE,WAAU,kGAAkG,gCAAqB,IAClI;AAAA,QACH,gBACC,oBAAC,OAAE,WAAU,iCACV,YAAE,+BAA+B,gCAAgC,GACpE,IACE;AAAA,SACN;AAAA,MACA,qBAAC,SAAI,KAAK,SAAS,WAAU,sCAC1B;AAAA,gBAAQ,WAAW,KAAK,CAAC,WAAW,CAAC,QACpC,oBAAC,SAAI,WAAU,2CACZ,gBAAM,KAAK,EAAE,SAAS,mBACnB,EAAE,0BAA0B,IAC5B,EAAE,0BAA0B,GAClC,IACE;AAAA,QACJ,oBAAC,QAAG,WAAU,iBACX,kBAAQ,IAAI,CAAC,QAAQ,UAAU;AAC9B,gBAAM,YAAY,OAAO;AACzB,gBAAM,WAAW,UAAU;AAC3B,gBAAM,UAAU,gBAAgB,MAAM,MAAM;AAC5C,gBAAM,OAAO,WAAW,OAAO,YAAY,UAAU,IAAI,IAAI;AAC7D,iBACE,oBAAC,QAAiD,eAAa,UAC7D;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAS,MAAM,WAAW,MAAM;AAAA,cAChC,cAAc,MAAM,iBAAiB,KAAK;AAAA,cAC1C,WAAW;AAAA,gBACT;AAAA,gBACA,WACI,2DACA;AAAA,gBACJ,CAAC,WAAW;AAAA,cACd;AAAA,cAEA,+BAAC,SAAI,WAAU,0CACb;AAAA,qCAAC,SAAI,WAAU,uBACb;AAAA,uCAAC,SAAI,WAAU,qCACb;AAAA,wCAAC,UAAK,WAAW,GAAG,qDAAqD,CAAC,WAAW,uBAAuB,GAAI,qBAAW,SAAS,OAAO,UAAS;AAAA,oBACpJ,oBAAC,UAAK,WAAU,4FACb,yBAAe,OAAO,QAAQ,GACjC;AAAA,oBACC,CAAC,WACA,oBAAC,UAAK,WAAU,mIACb,YAAE,sBAAsB,GAC3B;AAAA,qBAEJ;AAAA,kBACC,WAAW,WACV,oBAAC,SAAI,WAAU,+DAA+D,oBAAU,UAAS,IAC/F;AAAA,kBACH,eAAe,OAAO,KAAK,EAAE,SAC5B,oBAAC,SAAI,WAAU,0CACZ,yBAAe,OAAO,KAAK,EAAE,IAAI,CAAC,SACjC;AAAA,oBAAC;AAAA;AAAA,sBAEC,WAAW;AAAA,wBACT;AAAA,wBACA,KAAK,SAAS,YACV,gCACA;AAAA,sBACN;AAAA,sBAEC,eAAK,SAAS,KAAK;AAAA;AAAA,oBARf,GAAG,KAAK,IAAI;AAAA,kBASnB,CACD,GACH,IACE;AAAA,mBACN;AAAA,gBACC,OACC,oBAAC,SAAI,WAAU,iCACb,8BAAC,QAAK,WAAU,iCAAgC,GAClD,IACE;AAAA,iBACN;AAAA;AAAA,UACF,KArDO,GAAG,OAAO,QAAQ,IAAI,OAAO,QAAQ,EAsD9C;AAAA,QAEJ,CAAC,GACH;AAAA,SACF;AAAA,MACA,qBAAC,SAAI,WAAU,wDACb;AAAA,4BAAC,UAAK,WAAU,iCACb,4BAAkB,CAAC,kBAChB,EAAE,0BAA0B,IAC5B,EAAE,8BAA8B,GACtC;AAAA,QACA,qBAAC,SAAI,WAAU,2BACb;AAAA,8BAAC,UAAO,MAAK,UAAS,SAAQ,SAAQ,MAAK,MAAK,SAAS,MAAM,QAAQ,KAAK,GACzE,YAAE,8BAA8B,GACnC;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,MAAK;AAAA,cACL,SAAS,MAAM,WAAW,QAAQ,aAAa,CAAC;AAAA,cAChD,UAAU,CAAC,QAAQ,UAAU,CAAC;AAAA,cAE7B,YAAE,oCAAoC;AAAA;AAAA,UACzC;AAAA,WACF;AAAA,SACF;AAAA,OACF,GACF;AAAA,KACF;AAEJ;AAEA,IAAO,6BAAQ;AACf,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.4.7-main-1768da2e43",
3
+ "version": "0.4.7",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -126,9 +126,9 @@
126
126
  "zod": "^4.0.0"
127
127
  },
128
128
  "peerDependencies": {
129
- "@open-mercato/core": "0.4.7-main-1768da2e43",
130
- "@open-mercato/queue": "0.4.7-main-1768da2e43",
131
- "@open-mercato/shared": "0.4.7-main-1768da2e43"
129
+ "@open-mercato/core": "0.4.7",
130
+ "@open-mercato/queue": "0.4.7",
131
+ "@open-mercato/shared": "0.4.7"
132
132
  },
133
133
  "devDependencies": {
134
134
  "@types/jest": "^30.0.0",
@@ -137,6 +137,5 @@
137
137
  },
138
138
  "publishConfig": {
139
139
  "access": "public"
140
- },
141
- "stableVersion": "0.4.6"
140
+ }
142
141
  }
@@ -0,0 +1,145 @@
1
+ import { extractFallbackPresenter } from '../lib/fallback-presenter'
2
+
3
+ describe('extractFallbackPresenter', () => {
4
+ it('prefers higher-priority title fields', () => {
5
+ const presenter = extractFallbackPresenter(
6
+ {
7
+ display_name: 'Display Name',
8
+ name: 'Name Value',
9
+ title: 'Title Value',
10
+ },
11
+ 'customers:person',
12
+ 'record-1',
13
+ )
14
+
15
+ expect(presenter.title).toBe('Display Name')
16
+ expect(presenter.badge).toBe('Person')
17
+ })
18
+
19
+ it('builds title from first_name and last_name when no higher-priority title exists', () => {
20
+ const presenter = extractFallbackPresenter(
21
+ {
22
+ first_name: 'Jan',
23
+ last_name: 'Kowalski',
24
+ },
25
+ 'customers:person',
26
+ 'record-2',
27
+ )
28
+
29
+ expect(presenter.title).toBe('Jan Kowalski')
30
+ })
31
+
32
+ it('builds title from firstName and lastName for camelCase records', () => {
33
+ const presenter = extractFallbackPresenter(
34
+ {
35
+ firstName: 'Anna',
36
+ lastName: 'Nowak',
37
+ },
38
+ 'customers:person',
39
+ 'record-3',
40
+ )
41
+
42
+ expect(presenter.title).toBe('Anna Nowak')
43
+ })
44
+
45
+ it('uses available single name part when only first_name exists', () => {
46
+ const presenter = extractFallbackPresenter(
47
+ {
48
+ first_name: 'Monika',
49
+ },
50
+ 'customers:person',
51
+ 'record-4',
52
+ )
53
+
54
+ expect(presenter.title).toBe('Monika')
55
+ })
56
+
57
+ it('prefers composed name from parts before email fallback', () => {
58
+ const presenter = extractFallbackPresenter(
59
+ {
60
+ first_name: 'Tomasz',
61
+ last_name: 'Brzęczyszczykiewicz',
62
+ email: 'tomasz@example.com',
63
+ },
64
+ 'customers:person',
65
+ 'record-5',
66
+ )
67
+
68
+ expect(presenter.title).toBe('Tomasz Brzęczyszczykiewicz')
69
+ })
70
+
71
+ it('truncates subtitle to at most 120 characters', () => {
72
+ const longDescription = 'A'.repeat(90)
73
+ const longSummary = 'B'.repeat(90)
74
+
75
+ const presenter = extractFallbackPresenter(
76
+ {
77
+ name: 'Long Subtitle Record',
78
+ description: longDescription,
79
+ summary: longSummary,
80
+ },
81
+ 'search:document',
82
+ 'record-6',
83
+ )
84
+
85
+ expect(presenter.subtitle).toBeDefined()
86
+ expect((presenter.subtitle ?? '').length).toBeLessThanOrEqual(120)
87
+ expect(presenter.subtitle).toContain('A')
88
+ })
89
+
90
+ it('does not duplicate title as subtitle part', () => {
91
+ const presenter = extractFallbackPresenter(
92
+ {
93
+ name: 'Acme Corp',
94
+ description: 'Acme Corp',
95
+ summary: 'Important customer',
96
+ },
97
+ 'customers:company',
98
+ 'record-7',
99
+ )
100
+
101
+ expect(presenter.title).toBe('Acme Corp')
102
+ expect(presenter.subtitle).toBe('Important customer')
103
+ })
104
+
105
+ it('falls back to entity label and short id when no string fields are available', () => {
106
+ const presenter = extractFallbackPresenter(
107
+ {
108
+ id: 'ignored',
109
+ created_at: '2026-01-01',
110
+ },
111
+ 'catalog:product_variant',
112
+ '1234567890abcdef',
113
+ )
114
+
115
+ expect(presenter.title).toBe('Product Variant 12345678...')
116
+ expect(presenter.badge).toBe('Product Variant')
117
+ })
118
+
119
+ it('excludes technical tenant/organization/timestamp fields from generic fallback title selection', () => {
120
+ const presenter = extractFallbackPresenter(
121
+ {
122
+ tenant_id: 'Tenant Name Should Not Be Used',
123
+ organizationId: 'Org Name Should Not Be Used',
124
+ createdAt: '2026-01-01T00:00:00.000Z',
125
+ },
126
+ 'customers:company',
127
+ 'abcdef1234567890',
128
+ )
129
+
130
+ expect(presenter.title).toBe('Company abcdef12...')
131
+ })
132
+
133
+ it('does not use cf:* or cf_* values as generic fallback title', () => {
134
+ const presenter = extractFallbackPresenter(
135
+ {
136
+ 'cf:custom_display_name': 'Custom Field Value',
137
+ cf_custom_name: 'Another Custom Field Value',
138
+ },
139
+ 'catalog:product',
140
+ 'fedcba9876543210',
141
+ )
142
+
143
+ expect(presenter.title).toBe('Product fedcba98...')
144
+ })
145
+ })
@@ -1,20 +1,24 @@
1
1
  import type { SearchResultPresenter } from '@open-mercato/shared/modules/search'
2
2
 
3
- // Fields to check for title, in priority order
4
- const TITLE_FIELDS = [
3
+ const TITLE_FIELDS_PRIMARY = [
5
4
  'display_name', 'displayName',
6
5
  'name', 'title', 'label',
7
6
  'full_name', 'fullName',
8
7
  'brand_name', 'brandName',
9
8
  'legal_name', 'legalName',
10
- 'first_name', 'firstName',
11
- 'last_name', 'lastName',
12
9
  'preferred_name', 'preferredName',
10
+ ]
11
+
12
+ const TITLE_FIELDS_SECONDARY = [
13
13
  'email', 'primary_email', 'primaryEmail',
14
14
  'code', 'sku', 'reference',
15
15
  'identifier', 'slug',
16
16
  ]
17
17
 
18
+ const FIRST_NAME_FIELDS = ['first_name', 'firstName']
19
+ const LAST_NAME_FIELDS = ['last_name', 'lastName']
20
+ const MAX_SUBTITLE_LENGTH = 120
21
+
18
22
  // Fields to check for subtitle
19
23
  const SUBTITLE_FIELDS = [
20
24
  'description', 'summary', 'notes',
@@ -33,6 +37,13 @@ function findFirstValue(doc: Record<string, unknown>, fields: string[]): string
33
37
  return null
34
38
  }
35
39
 
40
+ function buildNameFromParts(doc: Record<string, unknown>): string | null {
41
+ const firstName = findFirstValue(doc, FIRST_NAME_FIELDS)
42
+ const lastName = findFirstValue(doc, LAST_NAME_FIELDS)
43
+ if (firstName && lastName) return `${firstName} ${lastName}`
44
+ return firstName ?? lastName
45
+ }
46
+
36
47
  function findAnyStringValue(doc: Record<string, unknown>, excludeFields: Set<string>): string | null {
37
48
  // Skip these fields as they're not meaningful for display
38
49
  const skipFields = new Set([
@@ -58,6 +69,11 @@ function formatEntityLabel(entityId: string): string {
58
69
  .replace(/\b\w/g, (c) => c.toUpperCase())
59
70
  }
60
71
 
72
+ function truncateSubtitle(value: string): string {
73
+ if (value.length <= MAX_SUBTITLE_LENGTH) return value
74
+ return value.slice(0, MAX_SUBTITLE_LENGTH).trimEnd()
75
+ }
76
+
61
77
  /**
62
78
  * Extract a presenter from doc fields when no search.ts config exists.
63
79
  *
@@ -74,21 +90,28 @@ export function extractFallbackPresenter(
74
90
  ): SearchResultPresenter {
75
91
  const entityLabel = formatEntityLabel(entityId)
76
92
 
77
- // 1. Try common title fields
78
- let title = findFirstValue(doc, TITLE_FIELDS)
93
+ let title = findFirstValue(doc, TITLE_FIELDS_PRIMARY)
94
+
95
+ if (!title) {
96
+ title = buildNameFromParts(doc)
97
+ }
98
+
99
+ if (!title) {
100
+ title = findFirstValue(doc, TITLE_FIELDS_SECONDARY)
101
+ }
79
102
 
80
- // 2. If no title found, try any string field
81
103
  if (!title) {
82
- title = findAnyStringValue(doc, new Set(SUBTITLE_FIELDS))
104
+ title = findAnyStringValue(
105
+ doc,
106
+ new Set([...SUBTITLE_FIELDS, ...FIRST_NAME_FIELDS, ...LAST_NAME_FIELDS]),
107
+ )
83
108
  }
84
109
 
85
- // 3. Last resort: use entity label + truncated record ID
86
110
  if (!title) {
87
111
  const shortId = recordId.length > 8 ? recordId.slice(0, 8) + '...' : recordId
88
112
  title = `${entityLabel} ${shortId}`
89
113
  }
90
114
 
91
- // Build subtitle from multiple relevant fields to show more context
92
115
  const subtitleParts: string[] = []
93
116
  for (const field of SUBTITLE_FIELDS) {
94
117
  const value = doc[field]
@@ -100,7 +123,7 @@ export function extractFallbackPresenter(
100
123
 
101
124
  return {
102
125
  title,
103
- subtitle: subtitleParts.length > 0 ? subtitleParts.join(' · ').slice(0, 120) : undefined,
126
+ subtitle: subtitleParts.length > 0 ? truncateSubtitle(subtitleParts.join(' · ')) : undefined,
104
127
  badge: entityLabel,
105
128
  }
106
129
  }
@@ -166,6 +166,7 @@ export function GlobalSearchDialog({
166
166
  const [error, setError] = React.useState<string | null>(null)
167
167
  const [selectedIndex, setSelectedIndex] = React.useState(0)
168
168
  const inputRef = React.useRef<HTMLInputElement | null>(null)
169
+ const listRef = React.useRef<HTMLDivElement | null>(null)
169
170
  const abortRef = React.useRef<AbortController | null>(null)
170
171
  const t = useT()
171
172
  const [showScopeHint, setShowScopeHint] = React.useState<boolean>(() => hasActiveOrganizationSelection())
@@ -305,6 +306,19 @@ export function GlobalSearchDialog({
305
306
  }
306
307
  }, [results, selectedIndex, openResult])
307
308
 
309
+ React.useEffect(() => {
310
+ const container = listRef.current
311
+ const active = container?.querySelector<HTMLElement>('[data-active="true"]')
312
+ if (!container || !active) return
313
+ const { top: containerTop, bottom: containerBottom } = container.getBoundingClientRect()
314
+ const { top: activeTop, bottom: activeBottom } = active.getBoundingClientRect()
315
+ if (activeTop < containerTop) {
316
+ container.scrollTop -= containerTop - activeTop
317
+ } else if (activeBottom > containerBottom) {
318
+ container.scrollTop += activeBottom - containerBottom
319
+ }
320
+ }, [selectedIndex])
321
+
308
322
  // Check if vector search is enabled but not configured
309
323
  const showVectorWarning = !embeddingConfigured && enabledStrategies.includes('vector') && !error
310
324
 
@@ -364,7 +378,7 @@ export function GlobalSearchDialog({
364
378
  </p>
365
379
  ) : null}
366
380
  </div>
367
- <div className="max-h-96 overflow-y-auto px-2 pb-3">
381
+ <div ref={listRef} className="max-h-96 overflow-y-auto px-2 pb-3">
368
382
  {results.length === 0 && !loading && !error ? (
369
383
  <div className="px-4 py-6 text-sm text-muted-foreground">
370
384
  {query.trim().length < MIN_QUERY_LENGTH
@@ -379,7 +393,7 @@ export function GlobalSearchDialog({
379
393
  const hasLink = pickPrimaryLink(result) !== null
380
394
  const Icon = presenter?.icon ? resolveIcon(presenter.icon) : null
381
395
  return (
382
- <li key={`${result.entityId}:${result.recordId}`}>
396
+ <li key={`${result.entityId}:${result.recordId}`} data-active={isActive}>
383
397
  <button
384
398
  type="button"
385
399
  onClick={() => openResult(result)}