@agent-native/core 0.123.2 → 0.124.1

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 (44) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +28 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +6 -3
  5. package/corpus/templates/analytics/server/lib/report-chart-svg.ts +18 -5
  6. package/corpus/templates/crm/actions/_crm-list-utils.ts +178 -0
  7. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +23 -2
  8. package/corpus/templates/crm/actions/create-crm-list.ts +229 -43
  9. package/corpus/templates/crm/actions/create-crm-record.ts +13 -5
  10. package/corpus/templates/crm/actions/update-crm-list-entry.ts +1 -1
  11. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +22 -2
  12. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +143 -32
  13. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +190 -75
  14. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +267 -115
  15. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +265 -131
  16. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +79 -100
  17. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +12 -8
  18. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +27 -31
  19. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +16 -13
  20. package/corpus/templates/crm/app/components/crm/record/attribute-row.tsx +108 -0
  21. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +201 -9
  22. package/corpus/templates/crm/app/components/crm/record/panel-resize.ts +61 -0
  23. package/corpus/templates/crm/app/components/crm/record/record-data.ts +5 -4
  24. package/corpus/templates/crm/app/components/crm/shared/README-tokens.md +103 -0
  25. package/corpus/templates/crm/app/components/crm/shared/RecordReferencePicker.tsx +117 -0
  26. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +84 -0
  27. package/corpus/templates/crm/app/components/crm/shared/ui-tokens.ts +89 -0
  28. package/corpus/templates/crm/app/global.css +170 -0
  29. package/corpus/templates/crm/app/i18n/en-US.ts +1 -0
  30. package/corpus/templates/crm/changelog/2026-07-26-a-half-typed-number-or-date-no-longer-saves-as-empty-the-fie.md +6 -0
  31. package/corpus/templates/crm/changelog/2026-07-26-a-new-pipeline-board-now-opens-with-stage-columns-a-summabl.md +6 -0
  32. package/corpus/templates/crm/changelog/2026-07-26-record-reference-fields-like-account-can-now-be-linked-from-.md +6 -0
  33. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-record-field-now-replaces-the-old-value-instead-o.md +6 -0
  34. package/corpus/templates/design/server/handlers/index-design-system-with-builder.ts +38 -21
  35. package/corpus/templates/slides/server/handlers/index-design-system-with-builder.ts +23 -20
  36. package/dist/collab/awareness.d.ts +2 -2
  37. package/dist/collab/awareness.d.ts.map +1 -1
  38. package/dist/collab/routes.d.ts +1 -1
  39. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  40. package/dist/notifications/routes.d.ts +3 -3
  41. package/dist/observability/routes.d.ts +3 -3
  42. package/dist/resources/handlers.d.ts +1 -1
  43. package/dist/server/transcribe-voice.d.ts +1 -1
  44. package/package.json +1 -1
@@ -9,10 +9,15 @@
9
9
  */
10
10
 
11
11
  import { useT } from "@agent-native/core/client/i18n";
12
- import { IconAlertTriangle } from "@tabler/icons-react";
12
+ import { IconAlertTriangle, IconPlus, IconX } from "@tabler/icons-react";
13
13
  import { useEffect, useRef, useState } from "react";
14
14
 
15
15
  import { Input } from "@/components/ui/input";
16
+ import {
17
+ Popover,
18
+ PopoverContent,
19
+ PopoverTrigger,
20
+ } from "@/components/ui/popover";
16
21
  import {
17
22
  Select,
18
23
  SelectContent,
@@ -29,13 +34,20 @@ import type {
29
34
  import {
30
35
  activeOptions,
31
36
  editorDraftFor,
37
+ editorInputType,
38
+ referenceMembers,
39
+ referenceSearchKind,
40
+ toggleReferenceValue,
32
41
  valueSpecFor,
33
42
  } from "../shared/attribute-value";
34
43
  import {
35
44
  AttributeOptionChip,
36
45
  AttributeRating,
37
46
  } from "../shared/AttributeValueParts";
47
+ import { RecordReferencePicker } from "../shared/RecordReferencePicker";
48
+ import { overlayProps } from "../shared/ui-tokens";
38
49
  import {
50
+ fieldEditability,
39
51
  fieldInputValue,
40
52
  formatFieldValue,
41
53
  parseFieldInput,
@@ -48,6 +60,7 @@ const CLEAR_OPTION = "__crm_clear__";
48
60
  type EditableAttribute = Pick<
49
61
  CrmAttributeDefinition,
50
62
  | "apiSlug"
63
+ | "label"
51
64
  | "attributeType"
52
65
  | "multi"
53
66
  | "options"
@@ -134,6 +147,9 @@ export function FieldEditor({
134
147
  const [state, setState] = useState(() => editorDraftFor(undefined, seed));
135
148
  const [error, setError] = useState<string | null>(null);
136
149
  const inputRef = useRef<HTMLInputElement>(null);
150
+ // Set the moment the input takes focus, cleared by the click that follows.
151
+ // See `selectOnFocus` below.
152
+ const selectedOnFocus = useRef(false);
137
153
 
138
154
  // Re-seeded during render, not from an effect: an effect re-seed lands after
139
155
  // the browser has already applied the keystroke it is about to overwrite.
@@ -146,12 +162,64 @@ export function FieldEditor({
146
162
  const input = inputRef.current;
147
163
  if (!input) return;
148
164
  input.focus();
149
- // Select, not just focus the grid does the same. A bare focus leaves the
150
- // caret inside the existing text, so the first thing typed is inserted into
151
- // the old value rather than replacing it.
165
+ // Select here as well as in `onFocus`, not instead of it. A programmatic
166
+ // `focus()` fires no focus event while the document itself is unfocused
167
+ // (a background tab, a devtools-driven run), so an `onFocus`-only select
168
+ // silently leaves the caret at the end of the old value and the first
169
+ // thing typed is appended to it.
152
170
  input.select();
153
171
  }, [autoFocus]);
154
172
 
173
+ /**
174
+ * Select the whole value whenever the field takes focus, so the next thing
175
+ * typed *replaces* it.
176
+ *
177
+ * This hangs off focus rather than off `autoFocus` alone because
178
+ * `FieldEditor` is also rendered as an always-live input (list entry rows),
179
+ * where nothing ever mounts it focused: a caller that does not pass
180
+ * `autoFocus` would otherwise get an editor that appends to the old value.
181
+ * Selecting is the editor's own property, not something each caller has to
182
+ * remember — that is how the same append bug came back twice.
183
+ *
184
+ * The `mouseUp` half matters: when focus came from a click, the browser
185
+ * places the caret on mouse-up and would drop the selection made here.
186
+ */
187
+ const selectOnFocus = {
188
+ onFocus: (event: React.FocusEvent<HTMLInputElement>) => {
189
+ selectedOnFocus.current = true;
190
+ event.currentTarget.select();
191
+ },
192
+ onMouseUp: (event: React.MouseEvent<HTMLInputElement>) => {
193
+ if (!selectedOnFocus.current) return;
194
+ selectedOnFocus.current = false;
195
+ event.preventDefault();
196
+ },
197
+ };
198
+
199
+ const input = editorInputType(attribute);
200
+
201
+ /**
202
+ * Commit what the control actually holds, and refuse when it cannot say.
203
+ *
204
+ * `validity.badInput` is the browser reporting "the user typed something I
205
+ * cannot express" — a half-written date, a stray `e` in a number. In that
206
+ * state `value` is the empty string, which `parse` reads as *cleared* and
207
+ * would store as null. Committing it turns a typo into silent data loss, so
208
+ * the editor says so and keeps the value the record already has.
209
+ */
210
+ function commitFromInput(node: HTMLInputElement) {
211
+ selectedOnFocus.current = false;
212
+ if (node.validity.badInput) {
213
+ setError(
214
+ input.type === "date" || input.type === "datetime-local"
215
+ ? t("record.invalidDate")
216
+ : t("record.invalidNumber"),
217
+ );
218
+ return;
219
+ }
220
+ commit(node.value);
221
+ }
222
+
155
223
  function commit(raw: string | boolean) {
156
224
  const parsed = parseFieldInput(attribute, raw);
157
225
  if (!parsed.ok) {
@@ -171,6 +239,21 @@ export function FieldEditor({
171
239
  onDone?.();
172
240
  }
173
241
 
242
+ /**
243
+ * Commit a typed value the picker produced directly. It skips text parsing —
244
+ * a reference display name may contain the comma a multi value splits on —
245
+ * but not the editability gate, which is what keeps a doomed write off the
246
+ * wire.
247
+ */
248
+ function commitValue(next: CrmValue) {
249
+ if (!fieldEditability(attribute).editable) {
250
+ setError(t("record.notEditable"));
251
+ return;
252
+ }
253
+ setError(null);
254
+ onCommit(next);
255
+ }
256
+
174
257
  const spec = valueSpecFor(attribute);
175
258
 
176
259
  if (spec.control === "checkbox") {
@@ -209,27 +292,41 @@ export function FieldEditor({
209
292
  );
210
293
  }
211
294
 
212
- // A multi value is a comma-separated list, which no typed input accepts.
213
- const inputType = attribute.multi ? "text" : spec.inputType;
295
+ if (spec.control === "reference") {
296
+ return (
297
+ <div className="grid gap-1">
298
+ <ReferenceField
299
+ attribute={attribute}
300
+ value={value}
301
+ autoFocus={autoFocus}
302
+ onCommit={commitValue}
303
+ onDone={onDone}
304
+ />
305
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
306
+ </div>
307
+ );
308
+ }
214
309
 
215
310
  return (
216
311
  <div className="grid gap-1">
217
312
  <Input
218
313
  ref={inputRef}
219
314
  className="h-8"
220
- type={inputType}
315
+ type={input.type}
316
+ {...(input.inputMode ? { inputMode: input.inputMode } : {})}
221
317
  value={draft}
222
318
  aria-label={attribute.apiSlug}
223
319
  placeholder={attribute.multi ? t("record.multiValueHint") : undefined}
224
320
  maxLength={4_000}
321
+ {...selectOnFocus}
225
322
  onChange={(event) =>
226
323
  setState({ ...current, draft: event.target.value })
227
324
  }
228
- onBlur={() => commit(draft)}
325
+ onBlur={(event) => commitFromInput(event.currentTarget)}
229
326
  onKeyDown={(event) => {
230
327
  if (event.key === "Enter") {
231
328
  event.preventDefault();
232
- commit(draft);
329
+ commitFromInput(event.currentTarget);
233
330
  }
234
331
  if (event.key === "Escape") {
235
332
  event.preventDefault();
@@ -243,3 +340,98 @@ export function FieldEditor({
243
340
  </div>
244
341
  );
245
342
  }
343
+
344
+ /**
345
+ * A link to another record: a searchable popover, and the committed value as
346
+ * chips. `multi` toggles membership instead of replacing, and a chip carries
347
+ * its own unlink control — the popover would otherwise be the only way to
348
+ * remove one, which needs the user to search for a record they can already see.
349
+ */
350
+ function ReferenceField({
351
+ attribute,
352
+ value,
353
+ autoFocus,
354
+ onCommit,
355
+ onDone,
356
+ }: {
357
+ attribute: EditableAttribute;
358
+ value: CrmValue | undefined;
359
+ /** The row was just activated, so open the search straight away rather than
360
+ * making the user click the value twice to get to a picker. */
361
+ autoFocus: boolean;
362
+ onCommit: (next: CrmValue) => void;
363
+ onDone?: () => void;
364
+ }) {
365
+ const t = useT();
366
+ const [open, setOpen] = useState(autoFocus);
367
+ const members = referenceMembers(value);
368
+
369
+ function pick(displayName: string) {
370
+ onCommit(toggleReferenceValue(value, displayName, attribute.multi));
371
+ if (!attribute.multi) {
372
+ setOpen(false);
373
+ onDone?.();
374
+ }
375
+ }
376
+
377
+ // `toggleReferenceValue` is a *pick*: on a single reference it replaces, so
378
+ // reusing it here would re-write the value the user is trying to remove.
379
+ function unlink(member: string) {
380
+ onCommit(
381
+ attribute.multi ? toggleReferenceValue(value, member, true) : null,
382
+ );
383
+ }
384
+
385
+ return (
386
+ <Popover
387
+ open={open}
388
+ onOpenChange={(next) => {
389
+ setOpen(next);
390
+ if (!next) onDone?.();
391
+ }}
392
+ >
393
+ <div className="flex flex-wrap items-center gap-1">
394
+ {members.map((member) => (
395
+ <span key={member} className="inline-flex items-center gap-0.5">
396
+ <AttributeOptionChip token={{ label: member }} />
397
+ <button
398
+ type="button"
399
+ className="cursor-pointer text-content-ghost hover:text-foreground"
400
+ aria-label={t("record.clearValue")}
401
+ onClick={() => unlink(member)}
402
+ >
403
+ <IconX className="size-3.5" />
404
+ </button>
405
+ </span>
406
+ ))}
407
+ <PopoverTrigger asChild>
408
+ <button
409
+ type="button"
410
+ {...overlayProps({
411
+ className:
412
+ "cursor-pointer rounded-lg px-2 py-1 text-left text-sm",
413
+ })}
414
+ aria-label={attribute.apiSlug}
415
+ >
416
+ {members.length ? (
417
+ <IconPlus className="size-3.5" />
418
+ ) : (
419
+ <span className="text-muted-foreground">
420
+ {t("record.fieldEmpty")}
421
+ </span>
422
+ )}
423
+ </button>
424
+ </PopoverTrigger>
425
+ </div>
426
+ <PopoverContent align="start" className="p-0">
427
+ <RecordReferencePicker
428
+ label={attribute.label}
429
+ kind={referenceSearchKind(attribute)}
430
+ selected={members}
431
+ onPick={pick}
432
+ onCancel={() => setOpen(false)}
433
+ />
434
+ </PopoverContent>
435
+ </Popover>
436
+ );
437
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Width of the record page's left pane.
3
+ *
4
+ * Stored as a percentage rather than pixels: a pixel width saved on a wide
5
+ * monitor reopens as a completely different layout on a laptop. The pane's
6
+ * floors are enforced in CSS (`min-width` / `max-width`) so a window resize
7
+ * cannot strand the main pane below its minimum; this clamp exists so the
8
+ * *stored* value stays sane too.
9
+ */
10
+
11
+ import {
12
+ RECORD_MAIN_MIN_WIDTH,
13
+ RECORD_PANEL_MIN_WIDTH,
14
+ } from "../shared/ui-tokens";
15
+
16
+ export const DEFAULT_RECORD_PANEL_PERCENT = 32;
17
+
18
+ /** Attio caps the pane at 60% of the record region however wide it gets. */
19
+ const MAX_PANEL_PERCENT = 60;
20
+
21
+ const STORAGE_KEY = "crm.record.panel-percent";
22
+
23
+ export function clampPanelPercent(
24
+ percent: number,
25
+ containerWidth: number,
26
+ ): number {
27
+ if (!Number.isFinite(percent)) return DEFAULT_RECORD_PANEL_PERCENT;
28
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0)
29
+ return DEFAULT_RECORD_PANEL_PERCENT;
30
+ const min = (RECORD_PANEL_MIN_WIDTH / containerWidth) * 100;
31
+ const max = Math.min(
32
+ MAX_PANEL_PERCENT,
33
+ ((containerWidth - RECORD_MAIN_MIN_WIDTH) / containerWidth) * 100,
34
+ );
35
+ // Too narrow to satisfy both floors: the main pane wins, because a record
36
+ // page whose content is 200px wide is useless in a way a thin panel is not.
37
+ if (max <= min) return Math.max(0, Math.min(100, max));
38
+ return Math.min(max, Math.max(min, percent));
39
+ }
40
+
41
+ export function readPanelPercent(): number {
42
+ if (typeof localStorage === "undefined") return DEFAULT_RECORD_PANEL_PERCENT;
43
+ try {
44
+ const stored = Number(localStorage.getItem(STORAGE_KEY));
45
+ return Number.isFinite(stored) && stored > 0
46
+ ? stored
47
+ : DEFAULT_RECORD_PANEL_PERCENT;
48
+ } catch {
49
+ return DEFAULT_RECORD_PANEL_PERCENT;
50
+ }
51
+ }
52
+
53
+ export function writePanelPercent(percent: number): void {
54
+ if (typeof localStorage === "undefined") return;
55
+ try {
56
+ localStorage.setItem(STORAGE_KEY, String(percent));
57
+ } catch {
58
+ // A full or blocked localStorage costs the user a remembered width, which
59
+ // is not worth interrupting a drag over.
60
+ }
61
+ }
@@ -96,10 +96,10 @@ export interface CrmRecordPage {
96
96
  // ---------------------------------------------------------------------------
97
97
 
98
98
  /**
99
- * Controls this panel implements. The registry says a `record-reference` is
100
- * edited with a record picker; a labelled panel row has no picker yet, so the
101
- * field is honestly locked here instead of offering a text input that would
102
- * store whatever display name the user typed.
99
+ * Controls this panel implements. A type whose control is missing here is
100
+ * locked with `unsupported-type` rather than offered a text input that would
101
+ * store whatever the user typed so this set and the branches in
102
+ * `field-editors.tsx` have to move together.
103
103
  */
104
104
  const PANEL_CONTROLS: ReadonlySet<CrmAttributeControl> = new Set([
105
105
  "text",
@@ -109,6 +109,7 @@ const PANEL_CONTROLS: ReadonlySet<CrmAttributeControl> = new Set([
109
109
  "datetime",
110
110
  "options",
111
111
  "rating",
112
+ "reference",
112
113
  ]);
113
114
 
114
115
  export type FieldLockReason =
@@ -0,0 +1,103 @@
1
+ # CRM surface tokens
2
+
3
+ The grid, record page, and board all style from this layer. Declared in
4
+ `app/global.css`; the JS-only half is `ui-tokens.ts` in this directory.
5
+
6
+ **Never hardcode a hex, a shadow, or a duration in a component.** If something
7
+ here has no token, add the token rather than the literal.
8
+
9
+ ## Cheat sheet
10
+
11
+ | Need | Use |
12
+ | ------------------------ | ----------------------------------------------------------------------------------------------------- |
13
+ | Row height (36px, fixed) | `h-9`, or `var(--crm-row-h)` / `ROW_HEIGHT` for virtualizer math |
14
+ | Header height (40px) | `h-10`, or `var(--crm-header-h)` / `HEADER_HEIGHT` |
15
+ | Header cell padding | `px-3` (12px), border-top **and** border-bottom |
16
+ | Body cell padding | `px-3 pt-2` (`8px 12px 0`) |
17
+ | Divider (both axes) | `border-hairline` — near-invisible by design, do not reach for `border-border` |
18
+ | Hover overlay | `overlayProps()` → `crm-overlay` |
19
+ | Selection overlay | `overlayProps({ selected })`; cells pass `soft: true` |
20
+ | Chip / record-ref | `rounded-chip py-0.5 pr-1.5 pl-1 gap-1.5 self-start` |
21
+ | Status / select pill | `rounded-badge` (4px), tinted fill, **no border** |
22
+ | Avatar | person `rounded-full`, company `rounded-avatar-company` (30%), both `ring-1 ring-inset ring-hairline` |
23
+ | Elevation | `shadow-e1` resting · `shadow-e2` menu/popover/dragging card · `shadow-e3` dialog |
24
+ | Sticky-column shadow | `var(--crm-shadow-sticky)` |
25
+ | Motion | see below |
26
+
27
+ ## Type
28
+
29
+ `letter-spacing: -0.02em` is the app default, already applied at `html`. Body
30
+ and caption sizes (`p`, `small`, `.text-sm`, `.text-xs`) relax to `-0.01em`
31
+ automatically. Both rules use `:where()`, so `tracking-ui` / `tracking-body`
32
+ override them wherever a surface needs to be explicit. **Do not add
33
+ `tracking-*` to routine text** — the default is already correct.
34
+
35
+ Weights are 400/500/600. `font-bold` is capped at 600 in the theme, so a stray
36
+ one is harmless, but write `font-semibold`.
37
+
38
+ Grid header labels are `text-sm font-medium text-content-secondary` — same size
39
+ as body, not uppercase, not smaller.
40
+
41
+ Numeric cells: `tabular-nums`.
42
+
43
+ ## Content colors
44
+
45
+ Alpha over the surface, not solid greys, so they composite correctly on tinted
46
+ cells and under hover overlays.
47
+
48
+ - `text-foreground` — primary
49
+ - `text-content-secondary` (63%) — header labels, metadata
50
+ - `text-content-tertiary` (50%) — read-only cell values, placeholders
51
+ - `text-content-ghost` (38%) — disabled, empty-value hints
52
+
53
+ Prefer these over `text-muted-foreground` on grid/record/board surfaces.
54
+
55
+ ## Radii
56
+
57
+ `rounded-badge` 4 · `rounded-lg` 8 (buttons, menu items) · `rounded-chip` 10 ·
58
+ `rounded-row` 11 · `rounded-card` 12 · `rounded-column` 16 · `rounded-panel` 20.
59
+
60
+ ## Motion
61
+
62
+ | Token | Class | ms |
63
+ | ---------------------- | -------------- | ----------------------------------------------------------- |
64
+ | `--motion-fast` | `duration-80` | 80 — popovers/dialogs, with `scale .97→1` |
65
+ | `--motion-comfortable` | `duration-140` | 140 — **the default; bare `transition-*` is already 140ms** |
66
+ | `--motion-breezy` | `duration-200` | 200 — chip hover, sticky-shadow fade |
67
+ | `--motion-sluggish` | `duration-300` | 300 |
68
+ | `--motion-sloth` | `duration-400` | 400 |
69
+
70
+ Use `duration-[var(--motion-breezy)]` when the value must follow the token
71
+ (e.g. inline styles or JS-driven timing); `MOTION.breezy` for `setTimeout`.
72
+
73
+ Rules: animate only color, opacity, box-shadow and transform — **never
74
+ layout**. Never `transition-all`; list the properties. Nothing on an
75
+ interaction exceeds 200ms. `ease-drop` is the drop-confirm curve.
76
+ `prefers-reduced-motion` zeroes all of it globally — do not re-handle it.
77
+
78
+ ## The overlay primitive
79
+
80
+ Hover and selection animate the **opacity of an overlay layer**. They are not
81
+ background-color swaps: a swap cannot composite over a tinted status cell and
82
+ cannot cross-fade.
83
+
84
+ ```tsx
85
+ import { overlayProps } from "@/components/crm/shared/ui-tokens";
86
+
87
+ <div
88
+ {...overlayProps({
89
+ selected: isSelected,
90
+ className: "flex h-9 items-stretch",
91
+ })}
92
+ />;
93
+ ```
94
+
95
+ Content at 4% for hover, accent at 10% for selection; cells pass `soft: true`
96
+ for the 8% content variant. Nesting is intended — a hovered cell inside a
97
+ hovered row stacks to ~8%, which is the behavior we want.
98
+
99
+ `data-hovered="true"` forces the hover state for keyboard focus or drag-over.
100
+
101
+ Selection tint derives from `--crm-accent`, which currently points at
102
+ `--primary` because our skin has no brand hue. Repoint that one var if it gains
103
+ one; nothing else should reference an accent directly.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The record search behind every `record-reference` / `actor-reference` editor.
3
+ *
4
+ * It lives here rather than in either surface because a grid cell and a panel
5
+ * row must resolve a link the same way: same action, same scope, same "no
6
+ * matches" wording. Only the wrapper differs — the grid opens it from a cell
7
+ * popover, the panel from a labelled row.
8
+ */
9
+
10
+ import { useActionQuery } from "@agent-native/core/client/hooks";
11
+ import { useT } from "@agent-native/core/client/i18n";
12
+ import { IconCheck } from "@tabler/icons-react";
13
+ import { useEffect, useRef, useState } from "react";
14
+
15
+ interface CrmReferenceMatch {
16
+ id: string;
17
+ displayName: string;
18
+ subtitle?: string;
19
+ }
20
+
21
+ /** Two characters, so a picker never asks the server for "every record". */
22
+ const MIN_QUERY_LENGTH = 2;
23
+
24
+ export function RecordReferencePicker({
25
+ label,
26
+ kind,
27
+ selected = [],
28
+ onPick,
29
+ onCancel,
30
+ }: {
31
+ /** The attribute's label; shown in the footer hint. */
32
+ label: string;
33
+ /** Narrow the search to one record kind, or `null` to search all kinds. */
34
+ kind: string | null;
35
+ /** Values already linked, so a multi reference can show and toggle them. */
36
+ selected?: string[];
37
+ onPick: (displayName: string) => void;
38
+ onCancel: () => void;
39
+ }) {
40
+ const t = useT();
41
+ const [search, setSearch] = useState("");
42
+ const inputRef = useRef<HTMLInputElement>(null);
43
+ useEffect(() => {
44
+ inputRef.current?.focus();
45
+ }, []);
46
+
47
+ const trimmed = search.trim();
48
+ const results = useActionQuery<{ records?: CrmReferenceMatch[] }>(
49
+ "list-crm-records" as never,
50
+ {
51
+ ...(trimmed ? { query: trimmed } : {}),
52
+ ...(kind ? { kind } : {}),
53
+ limit: 8,
54
+ } as never,
55
+ { enabled: trimmed.length >= MIN_QUERY_LENGTH } as never,
56
+ );
57
+ const records = results.data?.records ?? [];
58
+ const chosen = new Set(selected);
59
+
60
+ return (
61
+ <div className="w-64">
62
+ <input
63
+ ref={inputRef}
64
+ value={search}
65
+ onChange={(event) => setSearch(event.target.value)}
66
+ onKeyDown={(event) => {
67
+ if (event.key === "Escape") onCancel();
68
+ }}
69
+ placeholder={t("grid.searchRecords")}
70
+ className="w-full border-b border-border/70 bg-transparent px-3 py-2 text-sm outline-none"
71
+ />
72
+ <div className="max-h-56 overflow-y-auto py-1">
73
+ {trimmed.length < MIN_QUERY_LENGTH ? (
74
+ <p className="px-3 py-2 text-xs text-muted-foreground">
75
+ {t("grid.searchToLink")}
76
+ </p>
77
+ ) : results.isError ? (
78
+ // A failed search is said out loud: "no matches" would claim the
79
+ // record does not exist.
80
+ <p className="px-3 py-2 text-xs text-destructive">
81
+ {t("grid.searchFailed")}
82
+ </p>
83
+ ) : records.length === 0 ? (
84
+ <p className="px-3 py-2 text-xs text-muted-foreground">
85
+ {results.isLoading ? t("grid.searching") : t("grid.noMatches")}
86
+ </p>
87
+ ) : (
88
+ records.map((record) => (
89
+ <button
90
+ key={record.id}
91
+ type="button"
92
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-muted"
93
+ onClick={() => onPick(record.displayName)}
94
+ >
95
+ <span className="min-w-0 flex-1">
96
+ <span className="block truncate font-medium">
97
+ {record.displayName}
98
+ </span>
99
+ {record.subtitle ? (
100
+ <span className="block truncate text-xs text-muted-foreground">
101
+ {record.subtitle}
102
+ </span>
103
+ ) : null}
104
+ </span>
105
+ {chosen.has(record.displayName) ? (
106
+ <IconCheck className="size-3.5 shrink-0" />
107
+ ) : null}
108
+ </button>
109
+ ))
110
+ )}
111
+ </div>
112
+ <p className="border-t border-border/60 px-3 py-1.5 text-[11px] text-muted-foreground">
113
+ {t("grid.referenceHint", { attribute: label })}
114
+ </p>
115
+ </div>
116
+ );
117
+ }