@ifc-lite/viewer 1.26.0 → 1.27.0

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.
Files changed (89) hide show
  1. package/.turbo/turbo-build.log +38 -31
  2. package/CHANGELOG.md +29 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-QeHK_Aud.js} +1 -1
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{deflate-Cnx0il6E.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DSq76AVM.js → exporters-B4LbZFeT.js} +1422 -1194
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-DiLcGTer.js → ids-DjsGFN10.js} +4 -4
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-BAH8IJVR.js → index-COYokSKc.js} +38319 -35469
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-BBPPLW-0.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-yLSpjW-V.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-8md211IW.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-BAC3a-eN.js} +1735 -1660
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-YafxjjGr.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-CkSLOiuu.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +10 -7
  38. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  39. package/src/components/mcp/data.ts +6 -0
  40. package/src/components/mcp/playground-dispatcher.ts +277 -0
  41. package/src/components/mcp/types.ts +2 -1
  42. package/src/components/ui/combo-input.tsx +163 -0
  43. package/src/components/ui/tabs.tsx +1 -1
  44. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  45. package/src/components/viewer/SearchInline.tsx +62 -2
  46. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  47. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  48. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  49. package/src/components/viewer/SearchModal.tsx +19 -6
  50. package/src/components/viewer/Viewport.tsx +15 -0
  51. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  52. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  53. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  54. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  55. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  56. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  57. package/src/generated/mcp-catalog.json +4 -0
  58. package/src/hooks/source-key.ts +35 -0
  59. package/src/hooks/useAlignmentLines3D.ts +1 -26
  60. package/src/hooks/useGridLines3D.ts +140 -0
  61. package/src/lib/length-unit-scale.ts +41 -0
  62. package/src/lib/lists/adapter.ts +136 -11
  63. package/src/lib/lists/export/csv.ts +47 -0
  64. package/src/lib/lists/export/index.ts +49 -0
  65. package/src/lib/lists/export/model.ts +111 -0
  66. package/src/lib/lists/export/pdf.ts +67 -0
  67. package/src/lib/lists/export/xlsx.ts +83 -0
  68. package/src/lib/lists/index.ts +2 -0
  69. package/src/lib/search/filter-evaluate.test.ts +81 -0
  70. package/src/lib/search/filter-evaluate.ts +59 -87
  71. package/src/lib/search/filter-match.ts +167 -0
  72. package/src/lib/search/filter-rules.test.ts +25 -0
  73. package/src/lib/search/filter-rules.ts +75 -2
  74. package/src/lib/search/filter-schema.ts +0 -0
  75. package/src/lib/slab-edit.test.ts +72 -0
  76. package/src/lib/slab-edit.ts +159 -19
  77. package/src/sdk/adapters/export-adapter.ts +3 -3
  78. package/src/sdk/adapters/query-adapter.ts +3 -3
  79. package/src/store/slices/listSlice.ts +6 -0
  80. package/src/store/slices/mutationSlice.ts +14 -6
  81. package/src/store/slices/searchSlice.ts +29 -3
  82. package/src/utils/nativeSpatialDataStore.ts +6 -0
  83. package/src/utils/serverDataModel.test.ts +6 -0
  84. package/src/utils/serverDataModel.ts +7 -0
  85. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  86. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  87. package/dist/assets/index-B9Ug2EqU.css +0 -1
  88. package/dist/assets/raw-BQrAgxwT.js +0 -1
  89. package/dist/assets/server-client-Bk4c1CPO.js +0 -626
@@ -32,15 +32,12 @@ import { COMMON_IFC_TYPES } from '@/lib/search/common-ifc-types';
32
32
  import {
33
33
  Rule,
34
34
  type FilterRule,
35
- type SetOp,
36
- type StringOp,
37
- type ValueOp,
38
- type NumericOp,
39
35
  type Combinator,
40
36
  } from '@/lib/search/filter-rules';
41
37
  import {
42
38
  discoverFilterSchema,
43
39
  discoverPropertyAndQuantitySchema,
40
+ discoverFilterValues,
44
41
  } from '@/lib/search/filter-schema';
45
42
  import {
46
43
  loadSavedFilters,
@@ -48,33 +45,7 @@ import {
48
45
  deleteSavedFilter,
49
46
  type SavedFilterPreset,
50
47
  } from '@/lib/search/saved-filters';
51
-
52
- // ── Op constants ──────────────────────────────────────────────────────
53
-
54
- const SET_OPS: SetOp[] = ['in', 'notIn'];
55
- const STRING_OPS: StringOp[] = ['eq', 'ne', 'contains', 'notContains', 'startsWith'];
56
- const VALUE_OPS: ValueOp[] = [
57
- 'eq', 'ne', 'contains', 'notContains', 'gt', 'gte', 'lt', 'lte', 'isSet', 'isNotSet',
58
- ];
59
- const NUMERIC_OPS: NumericOp[] = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'];
60
-
61
- const OP_LABEL: Record<string, string> = {
62
- in: 'is one of', notIn: 'is not one of',
63
- eq: '=', ne: '≠',
64
- contains: 'contains', notContains: 'does not contain',
65
- startsWith: 'starts with',
66
- gt: '>', gte: '≥', lt: '<', lte: '≤',
67
- isSet: 'is set', isNotSet: 'is not set',
68
- };
69
-
70
- const RULE_KIND_LABEL: Record<FilterRule['kind'], string> = {
71
- storey: 'Storey',
72
- ifcType: 'IFC Type',
73
- predefinedType: 'Predefined Type',
74
- name: 'Name',
75
- property: 'Property',
76
- quantity: 'Quantity',
77
- };
48
+ import { RuleRow, RULE_KIND_LABEL } from './SearchModal.filter.editors';
78
49
 
79
50
  export function SearchModalFilterBuilder() {
80
51
  const {
@@ -91,6 +62,7 @@ export function SearchModalFilterBuilder() {
91
62
  clearFilterRules,
92
63
  setFilterSchema,
93
64
  setFilterPsetQtoSchema,
65
+ setFilterValueSchema,
94
66
  setSearchFilter,
95
67
  } = useViewerStore(
96
68
  useShallow((s) => ({
@@ -107,6 +79,7 @@ export function SearchModalFilterBuilder() {
107
79
  clearFilterRules: s.clearFilterRules,
108
80
  setFilterSchema: s.setFilterSchema,
109
81
  setFilterPsetQtoSchema: s.setFilterPsetQtoSchema,
82
+ setFilterValueSchema: s.setFilterValueSchema,
110
83
  setSearchFilter: s.setSearchFilter,
111
84
  })),
112
85
  );
@@ -134,6 +107,20 @@ export function SearchModalFilterBuilder() {
134
107
  setFilterPsetQtoSchema(activeModelId, discoverPropertyAndQuantitySchema(activeStore));
135
108
  }, [activeModelId, activeStore, filter.rules, schemaMap, setFilterPsetQtoSchema]);
136
109
 
110
+ // Lazy value discovery — distinct material / classification / property
111
+ // values for the chip value suggestions. Fired the first time a rule that
112
+ // benefits from them (property, material, classification) appears.
113
+ useEffect(() => {
114
+ if (!activeModelId || !activeStore) return;
115
+ const entry = schemaMap.get(activeModelId);
116
+ if (entry?.values) return;
117
+ const needs = filter.rules.some(
118
+ (r) => r.kind === 'property' || r.kind === 'material' || r.kind === 'classification',
119
+ );
120
+ if (!needs) return;
121
+ setFilterValueSchema(activeModelId, discoverFilterValues(activeStore));
122
+ }, [activeModelId, activeStore, filter.rules, schemaMap, setFilterValueSchema]);
123
+
137
124
  const ifcTypeOptions = useMemo<string[]>(() => {
138
125
  if (schemaEntry?.basic.ifcTypes && schemaEntry.basic.ifcTypes.length > 0) {
139
126
  return schemaEntry.basic.ifcTypes;
@@ -153,6 +140,9 @@ export function SearchModalFilterBuilder() {
153
140
  case 'name': rule = Rule.name('contains', ''); break;
154
141
  case 'property': rule = Rule.property('', '', 'eq', ''); break;
155
142
  case 'quantity': rule = Rule.quantity('', '', 'gt', 0); break;
143
+ case 'material': rule = Rule.material('contains', ''); break;
144
+ case 'classification': rule = Rule.classification('', 'contains', ''); break;
145
+ case 'elevation': rule = Rule.elevation('gt', 0); break;
156
146
  }
157
147
  addFilterRule(rule);
158
148
  }, [addFilterRule]);
@@ -254,7 +244,8 @@ export function SearchModalFilterBuilder() {
254
244
  <div className="flex flex-col gap-2">
255
245
  {filter.rules.length === 0 && (
256
246
  <p className="rounded border border-dashed border-zinc-300 bg-zinc-50 px-3 py-3 text-center text-xs italic text-muted-foreground dark:border-zinc-800 dark:bg-zinc-900/30">
257
- Add a rule to start filtering — pick by storey, IFC type, name, property, or quantity.
247
+ Add a rule to start filtering — pick by storey, IFC type, name,
248
+ property, quantity, material, classification, or elevation.
258
249
  </p>
259
250
  )}
260
251
  {filter.rules.map((rule, i) => (
@@ -264,6 +255,7 @@ export function SearchModalFilterBuilder() {
264
255
  ifcTypeOptions={ifcTypeOptions}
265
256
  storeyOptions={storeyOptions}
266
257
  psetQto={schemaEntry?.psetQto ?? null}
258
+ valueSchema={schemaEntry?.values ?? null}
267
259
  onChange={(next) => updateFilterRule(i, next)}
268
260
  onRemove={() => removeFilterRule(i)}
269
261
  />
@@ -400,367 +392,6 @@ function AddRuleMenu({
400
392
  );
401
393
  }
402
394
 
403
- interface RuleRowProps {
404
- rule: FilterRule;
405
- ifcTypeOptions: string[];
406
- storeyOptions: ReadonlyArray<readonly [string, number | null]>;
407
- psetQto: { psets: ReadonlyArray<readonly [string, ReadonlyArray<string>]>; qtos: ReadonlyArray<readonly [string, ReadonlyArray<readonly [string, string]>]> } | null;
408
- onChange: (next: FilterRule) => void;
409
- onRemove: () => void;
410
- }
411
-
412
- function RuleRow({ rule, ifcTypeOptions, storeyOptions, psetQto, onChange, onRemove }: RuleRowProps) {
413
- return (
414
- <div className="flex flex-wrap items-center gap-1.5 rounded border border-zinc-200 bg-white px-2 py-1.5 dark:border-zinc-800 dark:bg-zinc-950">
415
- <span className="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
416
- {RULE_KIND_LABEL[rule.kind]}
417
- </span>
418
-
419
- {rule.kind === 'storey' && (
420
- <SetRuleEditor
421
- values={rule.values}
422
- op={rule.op}
423
- options={storeyOptions.map(([name, elev]) => ({
424
- label: elev != null ? `${name} (${elev.toFixed(2)} m)` : name,
425
- value: name,
426
- }))}
427
- onChange={(values, op) => onChange(Rule.storey(values, op))}
428
- />
429
- )}
430
-
431
- {rule.kind === 'ifcType' && (
432
- <SetRuleEditor
433
- values={rule.values}
434
- op={rule.op}
435
- options={ifcTypeOptions.map((t) => ({ label: t, value: t }))}
436
- onChange={(values, op) => onChange(Rule.ifcType(values, op))}
437
- />
438
- )}
439
-
440
- {rule.kind === 'predefinedType' && (
441
- <PredefinedTypeEditor
442
- values={rule.values}
443
- op={rule.op}
444
- onChange={(values, op) => onChange(Rule.predefinedType(values, op))}
445
- />
446
- )}
447
-
448
- {rule.kind === 'name' && (
449
- <NameEditor
450
- op={rule.op}
451
- value={rule.value}
452
- onChange={(op, value) => onChange(Rule.name(op, value))}
453
- />
454
- )}
455
-
456
- {rule.kind === 'property' && (
457
- <PropertyEditor rule={rule} psetQto={psetQto} onChange={onChange} />
458
- )}
459
-
460
- {rule.kind === 'quantity' && (
461
- <QuantityEditor rule={rule} psetQto={psetQto} onChange={onChange} />
462
- )}
463
-
464
- <button
465
- type="button"
466
- onClick={onRemove}
467
- aria-label="Remove rule"
468
- className="ml-auto rounded p-1 text-muted-foreground hover:bg-zinc-100 hover:text-foreground dark:hover:bg-zinc-800"
469
- >
470
- <Trash2 className="h-3 w-3" />
471
- </button>
472
- </div>
473
- );
474
- }
475
-
476
- // ── Per-kind editors ──────────────────────────────────────────────────
477
-
478
- interface SetRuleEditorProps {
479
- values: string[];
480
- op: SetOp;
481
- options: Array<{ label: string; value: string }>;
482
- onChange: (values: string[], op: SetOp) => void;
483
- }
484
-
485
- function SetRuleEditor({ values, op, options, onChange }: SetRuleEditorProps) {
486
- const toggle = (v: string) => {
487
- const next = values.includes(v) ? values.filter((x) => x !== v) : [...values, v];
488
- onChange(next, op);
489
- };
490
- return (
491
- <>
492
- <OpDropdown ops={SET_OPS} value={op} onChange={(next) => onChange(values, next)} />
493
- <DropdownMenu>
494
- <DropdownMenuTrigger asChild>
495
- <Button variant="outline" size="sm" className="h-7 gap-1 text-xs font-mono">
496
- {values.length === 0 ? 'Pick values…' : `${values.length} selected`}
497
- </Button>
498
- </DropdownMenuTrigger>
499
- <DropdownMenuContent align="start" className="max-h-72 overflow-y-auto">
500
- {options.length === 0 && (
501
- <DropdownMenuItem disabled className="text-muted-foreground italic">
502
- No options available — load a model first.
503
- </DropdownMenuItem>
504
- )}
505
- {options.map((o) => (
506
- <DropdownMenuItem
507
- key={o.value}
508
- onSelect={(e) => {
509
- // Keep the menu open for multi-select.
510
- e.preventDefault();
511
- toggle(o.value);
512
- }}
513
- className="font-mono"
514
- >
515
- <span className="mr-2 inline-block w-3 text-center">
516
- {values.includes(o.value) ? '✓' : ''}
517
- </span>
518
- {o.label}
519
- </DropdownMenuItem>
520
- ))}
521
- </DropdownMenuContent>
522
- </DropdownMenu>
523
- {values.length > 0 && (
524
- <div className="flex flex-wrap items-center gap-1">
525
- {values.map((v) => (
526
- <span
527
- key={v}
528
- className="inline-flex items-center gap-1 rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-mono dark:bg-zinc-800"
529
- >
530
- {v}
531
- <button
532
- type="button"
533
- aria-label={`Remove ${v}`}
534
- onClick={() => toggle(v)}
535
- className="text-muted-foreground hover:text-foreground"
536
- >
537
- ×
538
- </button>
539
- </span>
540
- ))}
541
- </div>
542
- )}
543
- </>
544
- );
545
- }
546
-
547
- function PredefinedTypeEditor({
548
- values,
549
- op,
550
- onChange,
551
- }: {
552
- values: string[];
553
- op: SetOp;
554
- onChange: (values: string[], op: SetOp) => void;
555
- }) {
556
- // Predefined types aren't materialised in the parser today — pick
557
- // them via free-text. The user enters comma-separated values.
558
- const text = values.join(', ');
559
- return (
560
- <>
561
- <OpDropdown ops={SET_OPS} value={op} onChange={(next) => onChange(values, next)} />
562
- <Input
563
- placeholder="e.g. SOLIDWALL, PARTITIONING"
564
- value={text}
565
- onChange={(e) =>
566
- onChange(
567
- e.target.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0),
568
- op,
569
- )
570
- }
571
- className="h-7 w-72 text-xs font-mono"
572
- />
573
- </>
574
- );
575
- }
576
-
577
- function NameEditor({
578
- op,
579
- value,
580
- onChange,
581
- }: {
582
- op: StringOp;
583
- value: string;
584
- onChange: (op: StringOp, value: string) => void;
585
- }) {
586
- return (
587
- <>
588
- <OpDropdown ops={STRING_OPS} value={op} onChange={(next) => onChange(next, value)} />
589
- <Input
590
- placeholder="text"
591
- value={value}
592
- onChange={(e) => onChange(op, e.target.value)}
593
- className="h-7 w-56 text-xs font-mono"
594
- />
595
- </>
596
- );
597
- }
598
-
599
- interface PropertyEditorProps {
600
- rule: Extract<FilterRule, { kind: 'property' }>;
601
- psetQto: RuleRowProps['psetQto'];
602
- onChange: (next: FilterRule) => void;
603
- }
604
-
605
- function PropertyEditor({ rule, psetQto, onChange }: PropertyEditorProps) {
606
- const psetNames = useMemo(() => (psetQto ? psetQto.psets.map(([n]) => n) : []), [psetQto]);
607
- const propNames = useMemo(() => {
608
- if (!psetQto) return [];
609
- const entry = psetQto.psets.find(([n]) => n === rule.setName);
610
- return entry ? Array.from(entry[1]) : [];
611
- }, [psetQto, rule.setName]);
612
-
613
- const valueless = rule.op === 'isSet' || rule.op === 'isNotSet';
614
-
615
- return (
616
- <>
617
- <FreeOrPickInput
618
- placeholder="Pset_… (e.g. Pset_WallCommon)"
619
- value={rule.setName}
620
- options={psetNames}
621
- widthClass="w-52"
622
- onChange={(next) => onChange({ ...rule, setName: next, propertyName: '' })}
623
- />
624
- <span className="text-muted-foreground">.</span>
625
- <FreeOrPickInput
626
- placeholder="prop name"
627
- value={rule.propertyName}
628
- options={propNames}
629
- widthClass="w-44"
630
- onChange={(next) => onChange({ ...rule, propertyName: next })}
631
- />
632
- <OpDropdown ops={VALUE_OPS} value={rule.op} onChange={(next) => onChange({ ...rule, op: next })} />
633
- {!valueless && (
634
- <Input
635
- placeholder="value"
636
- value={rule.value}
637
- onChange={(e) => onChange({ ...rule, value: e.target.value })}
638
- className="h-7 w-40 text-xs font-mono"
639
- />
640
- )}
641
- </>
642
- );
643
- }
644
-
645
- interface QuantityEditorProps {
646
- rule: Extract<FilterRule, { kind: 'quantity' }>;
647
- psetQto: RuleRowProps['psetQto'];
648
- onChange: (next: FilterRule) => void;
649
- }
650
-
651
- function QuantityEditor({ rule, psetQto, onChange }: QuantityEditorProps) {
652
- const qsetNames = useMemo(() => (psetQto ? psetQto.qtos.map(([n]) => n) : []), [psetQto]);
653
- const qtyNames = useMemo(() => {
654
- if (!psetQto) return [];
655
- const entry = psetQto.qtos.find(([n]) => n === rule.setName);
656
- return entry ? entry[1].map(([n]) => n) : [];
657
- }, [psetQto, rule.setName]);
658
-
659
- return (
660
- <>
661
- <FreeOrPickInput
662
- placeholder="Qto_… (e.g. Qto_WallBaseQuantities)"
663
- value={rule.setName}
664
- options={qsetNames}
665
- widthClass="w-56"
666
- onChange={(next) => onChange({ ...rule, setName: next, quantityName: '' })}
667
- />
668
- <span className="text-muted-foreground">.</span>
669
- <FreeOrPickInput
670
- placeholder="quantity name"
671
- value={rule.quantityName}
672
- options={qtyNames}
673
- widthClass="w-44"
674
- onChange={(next) => onChange({ ...rule, quantityName: next })}
675
- />
676
- <OpDropdown ops={NUMERIC_OPS} value={rule.op} onChange={(next) => onChange({ ...rule, op: next })} />
677
- <Input
678
- type="number"
679
- placeholder="value"
680
- value={rule.value}
681
- onChange={(e) => onChange({ ...rule, value: Number.parseFloat(e.target.value) || 0 })}
682
- className="h-7 w-32 text-xs font-mono"
683
- />
684
- </>
685
- );
686
- }
687
-
688
- // ── Building-block widgets ───────────────────────────────────────────
689
-
690
- function OpDropdown<T extends string>({
691
- ops,
692
- value,
693
- onChange,
694
- }: {
695
- ops: ReadonlyArray<T>;
696
- value: T;
697
- onChange: (next: T) => void;
698
- }) {
699
- return (
700
- <DropdownMenu>
701
- <DropdownMenuTrigger asChild>
702
- <Button variant="outline" size="sm" className="h-7 min-w-[3.5rem] gap-1 text-xs font-mono">
703
- {OP_LABEL[value] ?? value}
704
- </Button>
705
- </DropdownMenuTrigger>
706
- <DropdownMenuContent>
707
- {ops.map((op) => (
708
- <DropdownMenuItem key={op} onSelect={() => onChange(op)} className="font-mono">
709
- {OP_LABEL[op] ?? op}
710
- <span className="ml-2 text-[10px] text-muted-foreground">{op}</span>
711
- </DropdownMenuItem>
712
- ))}
713
- </DropdownMenuContent>
714
- </DropdownMenu>
715
- );
716
- }
717
-
718
- /**
719
- * Free-text input that exposes a small dropdown of known options when
720
- * the schema knows them. Users can either pick from the menu or type a
721
- * value not present in the schema (useful for typos / custom psets).
722
- */
723
- function FreeOrPickInput({
724
- placeholder,
725
- value,
726
- options,
727
- widthClass,
728
- onChange,
729
- }: {
730
- placeholder: string;
731
- value: string;
732
- options: ReadonlyArray<string>;
733
- widthClass: string;
734
- onChange: (next: string) => void;
735
- }) {
736
- return (
737
- <div className="relative inline-flex items-center gap-1">
738
- <Input
739
- placeholder={placeholder}
740
- value={value}
741
- onChange={(e) => onChange(e.target.value)}
742
- className={`h-7 ${widthClass} text-xs font-mono`}
743
- />
744
- {options.length > 0 && (
745
- <DropdownMenu>
746
- <DropdownMenuTrigger asChild>
747
- <Button variant="ghost" size="sm" className="h-7 px-1 text-[10px] text-muted-foreground" title="Pick from schema">
748
-
749
- </Button>
750
- </DropdownMenuTrigger>
751
- <DropdownMenuContent align="start" className="max-h-72 overflow-y-auto">
752
- {options.map((o) => (
753
- <DropdownMenuItem key={o} onSelect={() => onChange(o)} className="font-mono">
754
- {o}
755
- </DropdownMenuItem>
756
- ))}
757
- </DropdownMenuContent>
758
- </DropdownMenu>
759
- )}
760
- </div>
761
- );
762
- }
763
-
764
395
  function truncate(s: string, max: number): string {
765
396
  return s.length <= max ? s : s.slice(0, max - 1) + '…';
766
397
  }