@agent-native/core 0.124.0 → 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 (37) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -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/dist/collab/routes.d.ts +1 -1
  35. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  36. package/dist/observability/routes.d.ts +1 -1
  37. package/package.json +1 -1
@@ -4,17 +4,23 @@ import { and, eq } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
7
+ import {
8
+ legacyValueTypeFor,
9
+ type CrmAttributeType,
10
+ } from "../shared/crm-attributes.js";
7
11
  import { requireCrmScope } from "./_crm-action-utils.js";
8
12
  import {
9
13
  CrmListError,
10
14
  crmApiSlug,
15
+ loadCrmObjectAttributes,
11
16
  uniqueCrmListSlug,
17
+ type CrmObjectAttribute,
12
18
  } from "./_crm-list-utils.js";
13
19
 
14
20
  /**
15
- * A new list is useless without somewhere to move entries to, so it is seeded
16
- * with a `status` attribute. It lives on the list, not on the record: two
17
- * entries for the same account can sit in different stages.
21
+ * Stage options for a list whose parent object declares no status attribute to
22
+ * copy. A board with no options has no columns at all, and a status write with
23
+ * no options is a 422, so this is the floor rather than an empty stage.
18
24
  */
19
25
  const DEFAULT_STAGE_OPTIONS = [
20
26
  { value: "new", title: "New" },
@@ -23,6 +29,30 @@ const DEFAULT_STAGE_OPTIONS = [
23
29
  { value: "lost", title: "Lost" },
24
30
  ] as const;
25
31
 
32
+ /** Currency config for a seeded amount when the parent object has no currency
33
+ * attribute to copy one from. Matches the native adapter's own default. */
34
+ const DEFAULT_CURRENCY_CONFIG = JSON.stringify({ currency: { code: "USD" } });
35
+
36
+ interface SeedOption {
37
+ value: string;
38
+ title: string;
39
+ color: string | null;
40
+ position: number;
41
+ targetDays: number | null;
42
+ celebrate: boolean;
43
+ }
44
+
45
+ interface ListAttributeSeed {
46
+ id: string;
47
+ apiSlug: string;
48
+ label: string;
49
+ description: string;
50
+ attributeType: CrmAttributeType;
51
+ configJson: string;
52
+ position: number;
53
+ options: SeedOption[];
54
+ }
55
+
26
56
  async function resolveConnectionId(
27
57
  db: ReturnType<typeof getDb>,
28
58
  connectionId: string | undefined,
@@ -59,9 +89,54 @@ async function resolveConnectionId(
59
89
  return rows[0]!.id;
60
90
  }
61
91
 
92
+ /** The live options of the object's status attribute, in their own order, so a
93
+ * seeded board's columns read like the object's stages instead of a generic
94
+ * vocabulary the records never use. */
95
+ async function loadSourceOptions(
96
+ db: ReturnType<typeof getDb>,
97
+ attributeId: string,
98
+ ): Promise<SeedOption[]> {
99
+ const rows = await db
100
+ .select({
101
+ value: schema.crmAttributeOptions.value,
102
+ title: schema.crmAttributeOptions.title,
103
+ color: schema.crmAttributeOptions.color,
104
+ position: schema.crmAttributeOptions.position,
105
+ targetDays: schema.crmAttributeOptions.targetDays,
106
+ celebrate: schema.crmAttributeOptions.celebrate,
107
+ })
108
+ .from(schema.crmAttributeOptions)
109
+ .where(
110
+ and(
111
+ eq(schema.crmAttributeOptions.attributeId, attributeId),
112
+ eq(schema.crmAttributeOptions.archived, false),
113
+ accessFilter(
114
+ schema.crmAttributeOptions,
115
+ schema.crmAttributeOptionShares,
116
+ ),
117
+ ),
118
+ );
119
+ return rows.sort(
120
+ (a, b) => a.position - b.position || a.value.localeCompare(b.value),
121
+ );
122
+ }
123
+
124
+ /** Says what a seeded attribute is, in the one place a user sees it: the
125
+ * attribute's own description. The initial copy is stated as a copy so nobody
126
+ * reads a board as live-linked to its records. */
127
+ function seedDescription(
128
+ listName: string,
129
+ objectType: string,
130
+ source: CrmObjectAttribute | null,
131
+ ): string {
132
+ return source
133
+ ? `The list "${listName}" owns this value. Each entry starts from the ${objectType} record's ${source.label} when the record is added, and changes independently after that — editing it never writes the record.`
134
+ : `The list "${listName}" owns this value. It starts empty and is never written back to the ${objectType} record.`;
135
+ }
136
+
62
137
  export default defineAction({
63
138
  description:
64
- "Create a CRM list — a workflow overlay over one object type, with its own entry attributes. Lists and their entries are local-authoritative on every backend, including HubSpot and Salesforce, so a pipeline never needs a provider write. Seeds a Stage attribute unless seedStageAttribute is false.",
139
+ "Create a CRM list — a workflow overlay over one object type, with its own entry attributes. Lists and their entries are local-authoritative on every backend, including HubSpot and Salesforce, so a pipeline never needs a provider write. Unless seedAttributes is false, the list is seeded with its own Stage (status, with the parent object's stage options when it has any), an amount (currency), and the parent object's date attribute, so a new board has columns and a summable total immediately. A seeded attribute is a COPY of the object's shape, not a link to it: each entry's first value is copied from the record once when the record is added and never re-synced.",
65
140
  schema: z.object({
66
141
  connectionId: z.string().trim().min(1).max(128).optional(),
67
142
  name: z.string().trim().min(1).max(120),
@@ -75,7 +150,12 @@ export default defineAction({
75
150
  ),
76
151
  description: z.string().trim().max(500).optional(),
77
152
  position: z.number().int().min(0).max(100_000).optional(),
78
- seedStageAttribute: z.boolean().default(true),
153
+ seedAttributes: z
154
+ .boolean()
155
+ .default(true)
156
+ .describe(
157
+ "Seed the list's own stage, amount, and date attributes from the parent object. False creates a list with no attributes at all — its board has no columns until you add a status attribute.",
158
+ ),
79
159
  }),
80
160
  audit: {
81
161
  target: (_args, result) => {
@@ -107,9 +187,94 @@ export default defineAction({
107
187
  const listId = crypto.randomUUID();
108
188
  const now = new Date().toISOString();
109
189
 
110
- const stageAttributeId = args.seedStageAttribute
111
- ? crypto.randomUUID()
112
- : null;
190
+ const sources = args.seedAttributes
191
+ ? await loadCrmObjectAttributes(db, connectionId, args.parentObjectType)
192
+ : [];
193
+ const sourceOf = (type: CrmAttributeType): CrmObjectAttribute | null =>
194
+ sources.find((source) => source.attributeType === type) ?? null;
195
+ const statusSource = sourceOf("status");
196
+ const currencySource = sourceOf("currency");
197
+ const dateSource = sourceOf("date");
198
+
199
+ const stageOptions = statusSource
200
+ ? await loadSourceOptions(db, statusSource.id)
201
+ : [];
202
+
203
+ const seeds: ListAttributeSeed[] = args.seedAttributes
204
+ ? [
205
+ {
206
+ id: crypto.randomUUID(),
207
+ // The slug follows the object's attribute so an entry's first value
208
+ // has an unambiguous source; `stage` is the fallback name.
209
+ apiSlug: statusSource?.apiSlug ?? "stage",
210
+ // Qualified by the list: an opportunity's own Stage and this
211
+ // pipeline's Stage are different fields that a bare "Stage" on both
212
+ // makes indistinguishable in the record panel.
213
+ label: `${args.name} Stage`,
214
+ description: seedDescription(
215
+ args.name,
216
+ args.parentObjectType,
217
+ statusSource,
218
+ ),
219
+ attributeType: "status",
220
+ configJson: statusSource?.configJson ?? "{}",
221
+ position: 0,
222
+ options: stageOptions.length
223
+ ? stageOptions
224
+ : DEFAULT_STAGE_OPTIONS.map((option, index) => ({
225
+ value: option.value,
226
+ title: option.title,
227
+ color: null,
228
+ position: index,
229
+ targetDays: null,
230
+ celebrate: "celebrate" in option ? option.celebrate : false,
231
+ })),
232
+ },
233
+ {
234
+ id: crypto.randomUUID(),
235
+ apiSlug: currencySource?.apiSlug ?? "amount",
236
+ label: currencySource?.label ?? "Amount",
237
+ description: seedDescription(
238
+ args.name,
239
+ args.parentObjectType,
240
+ currencySource,
241
+ ),
242
+ attributeType: "currency",
243
+ configJson: currencySource?.configJson ?? DEFAULT_CURRENCY_CONFIG,
244
+ position: 1,
245
+ options: [],
246
+ },
247
+ ...(dateSource
248
+ ? [
249
+ {
250
+ id: crypto.randomUUID(),
251
+ apiSlug: dateSource.apiSlug,
252
+ label: dateSource.label,
253
+ description: seedDescription(
254
+ args.name,
255
+ args.parentObjectType,
256
+ dateSource,
257
+ ),
258
+ attributeType: "date" as const,
259
+ configJson: dateSource.configJson,
260
+ position: 2,
261
+ options: [],
262
+ },
263
+ ]
264
+ : []),
265
+ ]
266
+ : [];
267
+
268
+ // `(connection_id, object_type, field_name)` is unique and `object_type`
269
+ // mirrors the list id here, so two seeds may never claim one slug.
270
+ const claimed = new Set<string>();
271
+ const attributes = seeds.filter((seed) => {
272
+ if (claimed.has(seed.apiSlug)) return false;
273
+ claimed.add(seed.apiSlug);
274
+ return true;
275
+ });
276
+ const stageAttributeId =
277
+ attributes.find((seed) => seed.attributeType === "status")?.id ?? null;
113
278
 
114
279
  await db.transaction(async (tx) => {
115
280
  await tx.insert(schema.crmLists).values({
@@ -128,52 +293,60 @@ export default defineAction({
128
293
  updatedAt: now,
129
294
  });
130
295
 
131
- if (!stageAttributeId) return;
296
+ if (!attributes.length) return;
132
297
 
133
- await tx.insert(schema.crmFieldPolicies).values({
134
- id: stageAttributeId,
135
- connectionId,
136
- // `object_type` mirrors `target_id` for list attributes so the legacy
137
- // (connection_id, object_type, field_name) unique index keeps working.
138
- objectType: listId,
139
- fieldName: "stage",
140
- label: "Stage",
141
- valueType: "enum",
142
- storagePolicy: "local-authoritative",
143
- sensitive: false,
144
- readable: true,
145
- createable: true,
146
- updateable: true,
147
- required: false,
148
- attributeType: "status",
149
- target: "list",
150
- targetId: listId,
151
- apiSlug: "stage",
152
- multi: false,
153
- authority: "local-authoritative",
154
- historyTracked: true,
155
- uniqueValue: false,
156
- archived: false,
157
- position: 0,
158
- ...ownership,
159
- createdAt: now,
160
- updatedAt: now,
161
- });
298
+ await tx.insert(schema.crmFieldPolicies).values(
299
+ attributes.map((seed) => ({
300
+ id: seed.id,
301
+ connectionId,
302
+ // `object_type` mirrors `target_id` for list attributes so the legacy
303
+ // (connection_id, object_type, field_name) unique index keeps working.
304
+ objectType: listId,
305
+ fieldName: seed.apiSlug,
306
+ label: seed.label,
307
+ description: seed.description,
308
+ valueType: legacyValueTypeFor(seed.attributeType, false),
309
+ storagePolicy: "local-authoritative" as const,
310
+ sensitive: false,
311
+ readable: true,
312
+ createable: true,
313
+ updateable: true,
314
+ required: false,
315
+ attributeType: seed.attributeType,
316
+ target: "list" as const,
317
+ targetId: listId,
318
+ apiSlug: seed.apiSlug,
319
+ configJson: seed.configJson,
320
+ multi: false,
321
+ authority: "local-authoritative" as const,
322
+ historyTracked: true,
323
+ uniqueValue: false,
324
+ archived: false,
325
+ position: seed.position,
326
+ ...ownership,
327
+ createdAt: now,
328
+ updatedAt: now,
329
+ })),
330
+ );
162
331
 
163
- await tx.insert(schema.crmAttributeOptions).values(
164
- DEFAULT_STAGE_OPTIONS.map((option, index) => ({
332
+ const options = attributes.flatMap((seed) =>
333
+ seed.options.map((option, index) => ({
165
334
  id: crypto.randomUUID(),
166
- attributeId: stageAttributeId,
335
+ attributeId: seed.id,
167
336
  value: option.value,
168
337
  title: option.title,
338
+ color: option.color,
169
339
  position: index,
170
340
  archived: false,
171
- celebrate: "celebrate" in option ? option.celebrate : false,
341
+ targetDays: option.targetDays,
342
+ celebrate: option.celebrate,
172
343
  ...ownership,
173
344
  createdAt: now,
174
345
  updatedAt: now,
175
346
  })),
176
347
  );
348
+ if (options.length)
349
+ await tx.insert(schema.crmAttributeOptions).values(options);
177
350
  });
178
351
 
179
352
  const [list] = await db
@@ -189,6 +362,19 @@ export default defineAction({
189
362
  if (!list) {
190
363
  throw new Error("CRM list could not be verified after creation.");
191
364
  }
192
- return { ...list, stageAttributeId };
365
+ return {
366
+ ...list,
367
+ stageAttributeId,
368
+ seededAttributes: attributes.map((seed) => ({
369
+ id: seed.id,
370
+ apiSlug: seed.apiSlug,
371
+ label: seed.label,
372
+ attributeType: seed.attributeType,
373
+ /** The object attribute this one was copied from, if any. */
374
+ seededFrom:
375
+ sources.find((source) => source.apiSlug === seed.apiSlug)?.apiSlug ??
376
+ null,
377
+ })),
378
+ };
193
379
  },
194
380
  });
@@ -104,11 +104,19 @@ export default defineAction({
104
104
  ).id;
105
105
  const objectType = kindToObjectType[args.kind];
106
106
  const remoteId = crypto.randomUUID();
107
- const fields = {
108
- ...(args.fields as Record<string, CrmValue> | undefined),
109
- displayName: args.displayName,
110
- ...(args.kind === "person" ? {} : { name: args.displayName }),
111
- };
107
+ // accounts and opportunities already carry a `name` attribute, so only
108
+ // people (which have no `name` field) need a separate stored `displayName`
109
+ // attribute. Minting both would leave two attributes for one value.
110
+ const fields =
111
+ args.kind === "person"
112
+ ? {
113
+ ...(args.fields as Record<string, CrmValue> | undefined),
114
+ displayName: args.displayName,
115
+ }
116
+ : {
117
+ ...(args.fields as Record<string, CrmValue> | undefined),
118
+ name: args.displayName,
119
+ };
112
120
  const idempotencyKey = await scopedCrmIdempotencyKey({
113
121
  ...ownership,
114
122
  recordId: `${connectionId}:${objectType}`,
@@ -17,7 +17,7 @@ import { listEntryValuesSchema } from "./add-crm-record-to-list.js";
17
17
 
18
18
  export default defineAction({
19
19
  description:
20
- "Set values on a CRM list entry's own attributes and/or reorder it. Moving an entry to another stage is exactly this call: the previous value's row is closed and a new one opened, so time-in-stage is derivable from the entry's own history. Entry attributes belong to the list, not to the record — this never writes to the record or to a provider.",
20
+ "Set values on a CRM list entry's own attributes and/or reorder it. Moving an entry to another stage is exactly this call: the previous value's row is closed and a new one opened, so time-in-stage is derivable from the entry's own history. Entry attributes belong to the list, not to the record — this never writes to the record or to a provider. An entry's values were copied from the record once, when it was added; they are not bound to it, so changing one here leaves the record's own value alone and a later record change does not reach the entry.",
21
21
  schema: z.object({
22
22
  entryId: z.string().trim().min(1).max(128),
23
23
  values: listEntryValuesSchema.optional(),
@@ -1,6 +1,11 @@
1
1
  import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
2
2
  import { useT } from "@agent-native/core/client/i18n";
3
- import { IconSearch } from "@tabler/icons-react";
3
+ import {
4
+ IconBuilding,
5
+ IconRoute,
6
+ IconSearch,
7
+ IconUser,
8
+ } from "@tabler/icons-react";
4
9
  import {
5
10
  useInfiniteQuery,
6
11
  useQueries,
@@ -37,6 +42,17 @@ import {
37
42
  import { Input } from "@/components/ui/input";
38
43
  import type { CrmKind, CrmRecordSummary } from "@/lib/types";
39
44
 
45
+ /** The glyph the grid's icon column shows until a row is hovered, and the
46
+ * avatar shape that goes with it — round is a person, squircle is not. */
47
+ const KIND_ICONS: Record<
48
+ CrmKind,
49
+ React.ComponentType<{ className?: string }>
50
+ > = {
51
+ account: IconBuilding,
52
+ person: IconUser,
53
+ opportunity: IconRoute,
54
+ };
55
+
40
56
  /** One `list-crm-record-values` request per chunk; the action caps at 200. */
41
57
  const VALUES_CHUNK = 100;
42
58
  /** Column resize fires per mouse move; only the settled layout is persisted. */
@@ -355,7 +371,9 @@ export function RecordGrid({ kind, emptyTitle }: RecordGridProps) {
355
371
  // shell's <main> is a scroll container, not a flex column, so `flex-1` here
356
372
  // would collapse. Switch to flex-1 if <main> ever becomes display:flex.
357
373
  <div className="flex h-[calc(100vh-9.5rem)] min-h-[320px] flex-col">
358
- <div className="flex items-center gap-2 border-b border-border/70 px-5 py-2.5">
374
+ {/* No bottom rule here: the grid header carries its own top border, and
375
+ two adjacent lines read as a heavy divider. */}
376
+ <div className="flex items-center gap-2 px-5 py-2.5">
359
377
  <div className="flex w-full max-w-sm items-center gap-2 rounded-md border border-input bg-background px-3">
360
378
  <IconSearch className="size-4 shrink-0 text-muted-foreground" />
361
379
  <Input
@@ -381,6 +399,8 @@ export function RecordGrid({ kind, emptyTitle }: RecordGridProps) {
381
399
  onRetry={() => void list.refetch()}
382
400
  emptyTitle={emptyTitle}
383
401
  nameLabel={t(`grid.name.${kind}`)}
402
+ objectIcon={KIND_ICONS[kind]}
403
+ avatarShape={kind === "person" ? "person" : "company"}
384
404
  rowHref={(row) => `/records/${encodeURIComponent(row.id)}`}
385
405
  onCommitCell={commitCell}
386
406
  onAddToList={setAddToListIds}
@@ -1,6 +1,13 @@
1
1
  /**
2
- * The record page: attribute panel on the left, tabs in the middle, list
3
- * memberships and signals on the right.
2
+ * The record page: TWO panes. A resizable left pane carrying the record's
3
+ * typed attributes and its list memberships, and a main pane carrying the tabs
4
+ * and the signals section.
5
+ *
6
+ * There is deliberately no third pane. Lists and signals used to sit in one,
7
+ * which cost the main pane ~350px on every screen and gave the page three
8
+ * competing reading orders. Lists are label/value rows, so they belong beside
9
+ * the attributes; signals are an evidence feed, so they belong in the main
10
+ * pane. The app shell's own left sidebar and right agent rail are untouched.
4
11
  *
5
12
  * The page reads two actions on purpose. `get-crm-record` is the one that
6
13
  * verifies provider read-through permission for a mirrored record and carries
@@ -10,17 +17,25 @@
10
17
  */
11
18
 
12
19
  import { setClientAppState } from "@agent-native/core/client/application-state";
20
+ import { ExtensionSlot } from "@agent-native/core/client/extensions";
13
21
  import {
14
22
  useActionMutation,
15
23
  useActionQuery,
16
24
  } from "@agent-native/core/client/hooks";
17
25
  import { useT } from "@agent-native/core/client/i18n";
18
26
  import { useQueryClient } from "@tanstack/react-query";
19
- import { useEffect, useState } from "react";
27
+ import { useEffect, useRef, useState } from "react";
20
28
  import { toast } from "sonner";
21
29
 
22
30
  import { CrmSignalsPanel } from "@/components/crm/CrmSignalsPanel";
31
+ import { PANEL_SECTION_HEADING } from "@/components/crm/record/attribute-row";
23
32
  import { AttributePanel } from "@/components/crm/record/AttributePanel";
33
+ import {
34
+ clampPanelPercent,
35
+ DEFAULT_RECORD_PANEL_PERCENT,
36
+ readPanelPercent,
37
+ writePanelPercent,
38
+ } from "@/components/crm/record/panel-resize";
24
39
  import {
25
40
  applyEntryValue,
26
41
  applyFieldValue,
@@ -33,6 +48,10 @@ import {
33
48
  import { RecordHeader } from "@/components/crm/record/RecordHeader";
34
49
  import { RecordLists } from "@/components/crm/record/RecordLists";
35
50
  import { RecordTabs } from "@/components/crm/record/RecordTabs";
51
+ import {
52
+ RECORD_MAIN_MIN_WIDTH,
53
+ RECORD_PANEL_MIN_WIDTH,
54
+ } from "@/components/crm/shared/ui-tokens";
36
55
  import { LoadingRows, SetupEmptyState } from "@/components/crm/Surface";
37
56
  import { TAB_ID } from "@/lib/tab-id";
38
57
  import type { CrmRecordDetail } from "@/lib/types";
@@ -61,6 +80,29 @@ export function RecordWorkspace({
61
80
  const [tab, setTab] = useState<RecordTab>("activity");
62
81
  const queryClient = useQueryClient();
63
82
  const recordId = record?.id;
83
+ const regionRef = useRef<HTMLDivElement>(null);
84
+ const [panelPercent, setPanelPercent] = useState(readPanelPercent);
85
+ const [isResizing, setIsResizing] = useState(false);
86
+
87
+ function resizeTo(clientX: number) {
88
+ const region = regionRef.current;
89
+ if (!region) return;
90
+ const bounds = region.getBoundingClientRect();
91
+ if (bounds.width <= 0) return;
92
+ setPanelPercent(
93
+ clampPanelPercent(
94
+ ((clientX - bounds.left) / bounds.width) * 100,
95
+ bounds.width,
96
+ ),
97
+ );
98
+ }
99
+
100
+ function nudgePanel(deltaPercent: number) {
101
+ const width = regionRef.current?.getBoundingClientRect().width ?? 0;
102
+ const next = clampPanelPercent(panelPercent + deltaPercent, width);
103
+ setPanelPercent(next);
104
+ writePanelPercent(next);
105
+ }
64
106
 
65
107
  const pageParams = { recordId };
66
108
  const pageQuery = useActionQuery<CrmRecordPage>(
@@ -179,27 +221,100 @@ export function RecordWorkspace({
179
221
  recordUrlUnavailableReason={page?.recordUrlUnavailableReason ?? null}
180
222
  actions={actions}
181
223
  />
182
- <div className="flex min-h-0 flex-1 flex-col lg:flex-row">
183
- {/* A failed panel load is a visible error, not an empty attribute list:
184
- "this record has no attributes" and "we could not read them" are
185
- different answers and must not render the same. */}
186
- {pageQuery.error ? (
187
- <aside className="w-full border-border/70 p-5 lg:w-[22rem] lg:shrink-0 lg:border-r">
224
+ <div ref={regionRef} className="flex min-h-0 flex-1 flex-col lg:flex-row">
225
+ {/* The pane's floors live in CSS, not in the drag handler: a window
226
+ resize must not be able to strand the main pane under its minimum,
227
+ and only `min-width`/`max-width` hold at every width without a
228
+ resize observer. */}
229
+ <aside
230
+ style={
231
+ {
232
+ "--crm-record-panel": `${panelPercent}%`,
233
+ "--crm-record-panel-min": `${RECORD_PANEL_MIN_WIDTH}px`,
234
+ "--crm-record-panel-max": `min(60%, calc(100% - ${RECORD_MAIN_MIN_WIDTH}px))`,
235
+ } as React.CSSProperties
236
+ }
237
+ className="grid w-full content-start gap-5 border-hairline p-4 lg:w-[var(--crm-record-panel)] lg:min-w-[var(--crm-record-panel-min)] lg:max-w-[var(--crm-record-panel-max)] lg:shrink-0 lg:border-r"
238
+ >
239
+ {/* A failed panel load is a visible error, not an empty attribute
240
+ list: "this record has no attributes" and "we could not read them"
241
+ are different answers and must not render the same. */}
242
+ {pageQuery.error ? (
188
243
  <p className="text-sm text-destructive">
189
244
  {pageQuery.error instanceof Error
190
245
  ? pageQuery.error.message
191
246
  : t("record.panelLoadFailed")}
192
247
  </p>
193
- </aside>
194
- ) : page ? (
195
- <AttributePanel page={page} onCommit={commitField} />
196
- ) : (
197
- <aside className="w-full border-border/70 p-5 lg:w-[22rem] lg:shrink-0 lg:border-r">
248
+ ) : page ? (
249
+ <>
250
+ <AttributePanel page={page} onCommit={commitField} />
251
+ <RecordLists page={page} onEntryCommit={commitEntryValue} />
252
+ {/* Stable extension contract: changing this slot id or context is
253
+ a migration. */}
254
+ <ExtensionSlot
255
+ id="crm.record-sidebar.bottom"
256
+ context={{
257
+ recordId: page.record.id,
258
+ objectType: page.record.objectType,
259
+ kind: page.record.kind,
260
+ displayName: page.record.displayName,
261
+ provider: page.record.provider,
262
+ values: page.values,
263
+ }}
264
+ />
265
+ </>
266
+ ) : (
198
267
  <LoadingRows rows={6} />
199
- </aside>
200
- )}
268
+ )}
269
+ </aside>
270
+
271
+ <div
272
+ role="separator"
273
+ aria-orientation="vertical"
274
+ aria-label={t("record.resizePanel")}
275
+ aria-valuenow={Math.round(panelPercent)}
276
+ tabIndex={0}
277
+ data-resizing={isResizing ? "true" : undefined}
278
+ className="group/resize relative hidden w-1.5 shrink-0 cursor-col-resize touch-none lg:block"
279
+ onPointerDown={(event) => {
280
+ event.preventDefault();
281
+ event.currentTarget.setPointerCapture(event.pointerId);
282
+ setIsResizing(true);
283
+ }}
284
+ onPointerMove={(event) => {
285
+ if (!isResizing) return;
286
+ resizeTo(event.clientX);
287
+ }}
288
+ onPointerUp={(event) => {
289
+ if (!isResizing) return;
290
+ event.currentTarget.releasePointerCapture(event.pointerId);
291
+ setIsResizing(false);
292
+ writePanelPercent(panelPercent);
293
+ }}
294
+ onDoubleClick={() => {
295
+ setPanelPercent(DEFAULT_RECORD_PANEL_PERCENT);
296
+ writePanelPercent(DEFAULT_RECORD_PANEL_PERCENT);
297
+ }}
298
+ onKeyDown={(event) => {
299
+ if (event.key === "ArrowLeft") nudgePanel(-2);
300
+ else if (event.key === "ArrowRight") nudgePanel(2);
301
+ else return;
302
+ event.preventDefault();
303
+ }}
304
+ >
305
+ {/* The 100ms delay before the handle appears is deliberate: without
306
+ it, crossing the divider on the way somewhere else flickers. */}
307
+ <span className="pointer-events-none absolute inset-y-0 left-1/2 w-0.5 -translate-x-1/2 rounded-[3px] bg-primary opacity-0 transition-opacity delay-100 duration-100 group-hover/resize:opacity-100 group-focus-visible/resize:opacity-100 group-data-[resizing=true]/resize:opacity-100" />
308
+ </div>
201
309
 
202
- <div className="min-w-0 flex-1">
310
+ <div
311
+ style={
312
+ {
313
+ "--crm-record-main-min": `${RECORD_MAIN_MIN_WIDTH}px`,
314
+ } as React.CSSProperties
315
+ }
316
+ className="min-w-0 flex-1 lg:min-w-[var(--crm-record-main-min)]"
317
+ >
203
318
  <RecordTabs
204
319
  record={record}
205
320
  tab={tab}
@@ -207,23 +322,19 @@ export function RecordWorkspace({
207
322
  onCompleteTask={onCompleteTask}
208
323
  isCompletingTask={isCompletingTask}
209
324
  />
210
- </div>
211
-
212
- <div className="grid w-full content-start gap-6 border-border/70 p-5 lg:w-[22rem] lg:shrink-0 lg:border-l">
213
- {page ? (
214
- <RecordLists page={page} onEntryCommit={commitEntryValue} />
215
- ) : null}
216
- <section aria-labelledby="crm-record-signals">
217
- <h2
218
- id="crm-record-signals"
219
- className="mb-2 text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground"
220
- >
325
+ <section
326
+ aria-labelledby="crm-record-signals"
327
+ className="border-t border-hairline p-5 sm:p-6"
328
+ >
329
+ <h2 id="crm-record-signals" className={PANEL_SECTION_HEADING}>
221
330
  {t("record.signals")}
222
331
  </h2>
223
- <CrmSignalsPanel
224
- recordId={record.id}
225
- evidence={record.evidence ?? []}
226
- />
332
+ <div className="mt-3 max-w-2xl">
333
+ <CrmSignalsPanel
334
+ recordId={record.id}
335
+ evidence={record.evidence ?? []}
336
+ />
337
+ </div>
227
338
  </section>
228
339
  </div>
229
340
  </div>