@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
package/corpus/README.md CHANGED
@@ -30,4 +30,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
30
30
 
31
31
  - core files: 1735
32
32
  - toolkit files: 160
33
- - template files: 6778
33
+ - template files: 6787
@@ -1,5 +1,33 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.124.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 661ee71: Register the CRM template in the CLI template catalog.
8
+
9
+ `crm` is added to `TEMPLATES` as a **hidden** entry (dev port 8107,
10
+ `defaultMode: "dev"`), so `agent-native new` can scaffold it by name while it
11
+ stays out of the public catalog until it is explicitly unhidden. The mirrored
12
+ list in `@agent-native/shared-app-config` is updated to match.
13
+
14
+ Two cross-template specs are widened to cover it: the UI-primitives sync check
15
+ now includes `crm`, and the page-chat handoff check reads CRM's layout from
16
+ `app/components/layout/CrmLayout.tsx` and expects `requireActiveHandoff: false`,
17
+ since CRM's full-page chat lives on its own `/ask` route.
18
+
19
+ ## 0.124.0
20
+
21
+ ### Minor Changes
22
+
23
+ - e9dd025: `agent-native dev --inspect` (and `--inspect-brk`, optionally `=<port>`) now
24
+ attaches the Node inspector to **only** the Nitro API-server process, on a
25
+ single known port (default 9229). It selects Nitro's `node-process` dev runner
26
+ so the server is a real, attachable process, and injects `NODE_OPTIONS` through
27
+ a Vite preload that runs before Vite's own startup — so Vite, pnpm, and the CLI
28
+ are never inspected and there is exactly one debugger target. Set
29
+ `NITRO_DEV_RUNNER` yourself to override the runner.
30
+
3
31
  ## 0.123.2
4
32
 
5
33
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.123.2",
3
+ "version": "0.124.1",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -931,14 +931,17 @@ async function renderChartBlock(args: {
931
931
  }
932
932
  const subtitle = subtitleParts.join(" · ");
933
933
 
934
- const alt = `${panel.title}: ${chartType} chart of ${input.series
934
+ // The card heading directly above already names the panel; repeating it here
935
+ // would read twice in a client that blocks images.
936
+ const alt = `${chartType} chart of ${input.series
935
937
  .map((series) => series.label)
936
938
  .join(", ")}`;
937
939
 
938
940
  try {
941
+ // Title and description stay in the surrounding HTML card: text there is
942
+ // selectable, wraps instead of truncating, and cannot be duplicated by the
943
+ // image beneath it.
939
944
  const svg = renderReportChartSvg({
940
- title: panel.title,
941
- ...(subtitle ? { subtitle } : {}),
942
945
  labels: input.labels,
943
946
  series: input.series,
944
947
  type: chartType,
@@ -218,17 +218,26 @@ function renderChartHeader({
218
218
  ? wrapToWidth(subtitle, 13, maxWidth, 2)
219
219
  : [subtitle];
220
220
 
221
+ // Email cards already render the title and description as HTML, so the report
222
+ // path passes neither and the header collapses to nothing. Legacy callers
223
+ // always supply a title, so their output is unchanged.
224
+ const collapsed = fit && !titleText && subtitleLines.length === 0;
225
+
221
226
  return {
222
- titleMarkup: `<text x="${HEADER_INSET}" y="${TITLE_BASELINE}" font-family="${fontFamily}" font-size="22" font-weight="700" fill="${theme.titleColor}">${escapeXml(titleText)}</text>`,
227
+ titleMarkup: collapsed
228
+ ? ""
229
+ : `<text x="${HEADER_INSET}" y="${TITLE_BASELINE}" font-family="${fontFamily}" font-size="22" font-weight="700" fill="${theme.titleColor}">${escapeXml(titleText)}</text>`,
223
230
  subtitleMarkup: subtitleLines
224
231
  .map(
225
232
  (line, index) =>
226
233
  `<text x="${HEADER_INSET}" y="${SUBTITLE_BASELINE + index * SUBTITLE_LINE_HEIGHT}" font-family="${fontFamily}" font-size="13" fill="${theme.tickColor}">${escapeXml(line)}</text>`,
227
234
  )
228
235
  .join(""),
229
- headerBottom: subtitleLines.length
230
- ? SUBTITLE_BASELINE + (subtitleLines.length - 1) * SUBTITLE_LINE_HEIGHT
231
- : TITLE_BASELINE,
236
+ headerBottom: collapsed
237
+ ? 0
238
+ : subtitleLines.length
239
+ ? SUBTITLE_BASELINE + (subtitleLines.length - 1) * SUBTITLE_LINE_HEIGHT
240
+ : TITLE_BASELINE,
232
241
  };
233
242
  }
234
243
 
@@ -428,7 +437,11 @@ function renderCartesianChartSvg({
428
437
  fontFamily,
429
438
  fit,
430
439
  });
431
- const legendTop = subtitle ? header.headerBottom + 4 : 42;
440
+ const legendTop = !header.headerBottom
441
+ ? 18
442
+ : subtitle
443
+ ? header.headerBottom + 4
444
+ : 42;
432
445
  // A static image has no tooltip to reveal which scale a series belongs to.
433
446
  const legendSeries = dualAxis
434
447
  ? series.map((entry) =>
@@ -48,6 +48,7 @@ import {
48
48
  ATTRIBUTE_TYPE_SPECS,
49
49
  storageColumnFor,
50
50
  type CrmAttributeStorageColumn,
51
+ type CrmAttributeType,
51
52
  } from "../shared/crm-attributes.js";
52
53
  import type {
53
54
  CrmActorType,
@@ -335,6 +336,183 @@ export function indexAttributes(
335
336
  return new Map(attributes.map((attribute) => [attribute.apiSlug, attribute]));
336
337
  }
337
338
 
339
+ // ---------------------------------------------------------------------------
340
+ // Seeding a list from its parent object, and the one-time copy that gives a
341
+ // new entry its first values.
342
+ //
343
+ // A list attribute is the list's OWN field: it is seeded from a record
344
+ // attribute and each entry's first value is copied from the record once, at
345
+ // insert. Nothing re-reads the record afterwards and nothing writes back — that
346
+ // separation is what lets a pipeline run over a HubSpot or Salesforce mirror
347
+ // without a provider write.
348
+ // ---------------------------------------------------------------------------
349
+
350
+ /** A parent-object attribute a list can seed from. Single-valued only: a
351
+ * list's stage, amount, and date are single by construction, and a multi
352
+ * source would not fit the seeded attribute's storage column. */
353
+ export interface CrmObjectAttribute {
354
+ id: string;
355
+ apiSlug: string;
356
+ label: string;
357
+ attributeType: CrmAttributeType;
358
+ /** Raw `config_json`, copied verbatim so a currency keeps its code. */
359
+ configJson: string;
360
+ position: number;
361
+ }
362
+
363
+ export async function loadCrmObjectAttributes(
364
+ db: CrmDb,
365
+ connectionId: string,
366
+ objectType: string,
367
+ ): Promise<CrmObjectAttribute[]> {
368
+ const rows = await db
369
+ .select({
370
+ id: schema.crmFieldPolicies.id,
371
+ apiSlug: schema.crmFieldPolicies.apiSlug,
372
+ fieldName: schema.crmFieldPolicies.fieldName,
373
+ label: schema.crmFieldPolicies.label,
374
+ attributeType: schema.crmFieldPolicies.attributeType,
375
+ configJson: schema.crmFieldPolicies.configJson,
376
+ position: schema.crmFieldPolicies.position,
377
+ })
378
+ .from(schema.crmFieldPolicies)
379
+ .where(
380
+ and(
381
+ eq(schema.crmFieldPolicies.target, "object"),
382
+ eq(schema.crmFieldPolicies.objectType, objectType),
383
+ eq(schema.crmFieldPolicies.connectionId, connectionId),
384
+ eq(schema.crmFieldPolicies.archived, false),
385
+ eq(schema.crmFieldPolicies.multi, false),
386
+ accessFilter(schema.crmFieldPolicies, schema.crmFieldPolicyShares),
387
+ ),
388
+ )
389
+ .limit(MAX_LIST_ATTRIBUTES);
390
+
391
+ return rows
392
+ .map((row) => ({
393
+ id: row.id,
394
+ // `api_slug` is null on rows written by the adapters, which predate the
395
+ // typed surface; `field_name` is what their values are keyed by.
396
+ apiSlug: row.apiSlug ?? row.fieldName,
397
+ label: row.label,
398
+ attributeType: row.attributeType,
399
+ configJson: row.configJson,
400
+ position: row.position,
401
+ }))
402
+ .sort(
403
+ (a, b) => a.position - b.position || a.apiSlug.localeCompare(b.apiSlug),
404
+ );
405
+ }
406
+
407
+ /** What one attribute's initial value did, per entry. `applied: false` is
408
+ * reported rather than dropped: "the record had no value" and "the record's
409
+ * value does not fit this list" are different facts about an empty card. */
410
+ export interface CrmEntryInitialValue {
411
+ attribute: string;
412
+ /** The record attribute the value was read from. */
413
+ from: string;
414
+ applied: boolean;
415
+ reason?: string;
416
+ }
417
+
418
+ /**
419
+ * The values a BRAND-NEW entry starts with, copied once from the record's own
420
+ * attributes that share the list attribute's slug and type.
421
+ *
422
+ * This is a snapshot at insert, not a binding. Nothing reads the record again,
423
+ * and `update-crm-list-entry` never writes it back, so an entry and its record
424
+ * diverge from here on — deliberately, because the record may be provider-owned
425
+ * and a pipeline must never need a provider write.
426
+ */
427
+ export async function initialCrmEntryValues(input: {
428
+ db: CrmDb;
429
+ connectionId: string;
430
+ record: { id: string; objectType: string };
431
+ attributes: Map<string, CrmListAttribute>;
432
+ /** Slugs the caller set explicitly. Neither copied nor reported: a value the
433
+ * caller chose was not initialized from anything. */
434
+ supplied?: ReadonlySet<string>;
435
+ }): Promise<{
436
+ values: Record<string, CrmValue>;
437
+ initialValues: CrmEntryInitialValue[];
438
+ }> {
439
+ if (input.attributes.size === 0) return { values: {}, initialValues: [] };
440
+
441
+ const sources = (
442
+ await loadCrmObjectAttributes(
443
+ input.db,
444
+ input.connectionId,
445
+ input.record.objectType,
446
+ )
447
+ ).filter((source) => {
448
+ if (input.supplied?.has(source.apiSlug)) return false;
449
+ const attribute = input.attributes.get(source.apiSlug);
450
+ // Same slug is not enough: a text `amount` on the object must not be copied
451
+ // into a currency `amount` on the list.
452
+ return (
453
+ !!attribute &&
454
+ !attribute.multi &&
455
+ attribute.attributeType === source.attributeType
456
+ );
457
+ });
458
+ if (sources.length === 0) return { values: {}, initialValues: [] };
459
+
460
+ const rows = await input.db
461
+ .select({
462
+ fieldName: schema.crmRecordFields.fieldName,
463
+ stringValue: schema.crmRecordFields.stringValue,
464
+ numberValue: schema.crmRecordFields.numberValue,
465
+ booleanValue: schema.crmRecordFields.booleanValue,
466
+ jsonValue: schema.crmRecordFields.jsonValue,
467
+ })
468
+ .from(schema.crmRecordFields)
469
+ .where(
470
+ and(
471
+ eq(schema.crmRecordFields.recordId, input.record.id),
472
+ // `entry_id IS NULL` is the record-vs-entry discriminator: another
473
+ // entry's value must never seed this one.
474
+ isNull(schema.crmRecordFields.entryId),
475
+ isNull(schema.crmRecordFields.activeUntil),
476
+ inArray(
477
+ schema.crmRecordFields.fieldName,
478
+ sources.map((source) => source.apiSlug),
479
+ ),
480
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
481
+ ),
482
+ );
483
+
484
+ const values: Record<string, CrmValue> = {};
485
+ const initialValues: CrmEntryInitialValue[] = [];
486
+ for (const row of rows) {
487
+ const attribute = input.attributes.get(row.fieldName);
488
+ if (!attribute) continue;
489
+ const value = decodeCrmAttributeValue(attribute, row);
490
+ // Nothing to copy is not a failure and is not reported as one.
491
+ if (value === null) continue;
492
+ if (ATTRIBUTE_TYPE_SPECS[attribute.attributeType].usesOptions) {
493
+ const enterable = attribute.options.some(
494
+ (option) => !option.archived && option.value === value,
495
+ );
496
+ if (!enterable) {
497
+ initialValues.push({
498
+ attribute: attribute.apiSlug,
499
+ from: row.fieldName,
500
+ applied: false,
501
+ reason: `The record's "${String(value)}" is not a live option of this list's "${attribute.label}". Add the option, or set the entry's value explicitly.`,
502
+ });
503
+ continue;
504
+ }
505
+ }
506
+ values[attribute.apiSlug] = value;
507
+ initialValues.push({
508
+ attribute: attribute.apiSlug,
509
+ from: row.fieldName,
510
+ applied: true,
511
+ });
512
+ }
513
+ return { values, initialValues };
514
+ }
515
+
338
516
  // ---------------------------------------------------------------------------
339
517
  // Entry attribute values
340
518
  // ---------------------------------------------------------------------------
@@ -13,6 +13,7 @@ import {
13
13
  CrmListError,
14
14
  crmActorFrom,
15
15
  indexAttributes,
16
+ initialCrmEntryValues,
16
17
  loadCrmListAttributes,
17
18
  requireCrmList,
18
19
  writeCrmListEntryValues,
@@ -31,7 +32,7 @@ export const listEntryValuesSchema = z
31
32
 
32
33
  export default defineAction({
33
34
  description:
34
- "Add a record to a CRM list as a new entry, with optional starting values for the list's own entry attributes. A record may hold more than one entry in the same list — two open deals for one company are two entries — so this never de-duplicates; it returns existingEntryIds for the caller to decide. The record's objectType must match the list's parentObjectType. The entry is local-authoritative even when the record mirrors HubSpot or Salesforce.",
35
+ "Add a record to a CRM list as a new entry, with optional starting values for the list's own entry attributes. Any list attribute you do not supply is INITIALIZED from the record's own attribute of the same slug and type — a one-time copy at insert, reported in initialValues, so a new board is populated. It is a copy and not a link: the entry never re-reads the record and editing the entry never writes the record. A record may hold more than one entry in the same list — two open deals for one company are two entries — so this never de-duplicates; it returns existingEntryIds for the caller to decide. The record's objectType must match the list's parentObjectType. The entry is local-authoritative even when the record mirrors HubSpot or Salesforce.",
35
36
  schema: z.object({
36
37
  listId: z.string().trim().min(1).max(128),
37
38
  recordId: z.string().trim().min(1).max(128),
@@ -68,6 +69,7 @@ export default defineAction({
68
69
  id: schema.crmRecords.id,
69
70
  objectType: schema.crmRecords.objectType,
70
71
  displayName: schema.crmRecords.displayName,
72
+ connectionId: schema.crmRecords.connectionId,
71
73
  })
72
74
  .from(schema.crmRecords)
73
75
  .where(
@@ -118,6 +120,18 @@ export default defineAction({
118
120
  .orderBy(desc(schema.crmListEntries.position))
119
121
  .limit(1);
120
122
 
123
+ // The entry's first values, copied once from the record. A value the caller
124
+ // supplied is left alone — including an explicit null, which is the caller
125
+ // saying "start this one empty".
126
+ const initial = await initialCrmEntryValues({
127
+ db,
128
+ connectionId: record.connectionId,
129
+ record,
130
+ attributes,
131
+ supplied: new Set(Object.keys(args.values ?? {})),
132
+ });
133
+ const values = { ...initial.values, ...(args.values ?? {}) };
134
+
121
135
  const entryId = crypto.randomUUID();
122
136
  const actor = crmActorFrom(ctx);
123
137
  const now = new Date().toISOString();
@@ -141,7 +155,7 @@ export default defineAction({
141
155
  recordId: record.id,
142
156
  entryId,
143
157
  attributes,
144
- values: args.values ?? {},
158
+ values,
145
159
  actor,
146
160
  ownership,
147
161
  now,
@@ -152,6 +166,13 @@ export default defineAction({
152
166
  entryId,
153
167
  listId: list.id,
154
168
  recordId: record.id,
169
+ /**
170
+ * What the entry's values were initialized from. An `applied: false` row
171
+ * is a value the record held that this list cannot represent — reported,
172
+ * never dropped silently, because an unexplained empty card is exactly
173
+ * how a board looks broken.
174
+ */
175
+ initialValues: initial.initialValues,
155
176
  /**
156
177
  * Entries this record already had in this list. Non-empty is normal, not
157
178
  * an error — the caller decides whether a second entry was intended.