@happyvertical/smrt-content 0.44.0 → 0.45.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 (52) hide show
  1. package/agents/content-list.md +93 -14
  2. package/dist/chunks/{content-corrections-BxfA3tW1.js → content-corrections-B4mPd_th.js} +6 -3
  3. package/dist/chunks/content-corrections-B4mPd_th.js.map +1 -0
  4. package/dist/chunks/{content-query-B4cHfrig.js → content-query-BV41W2Dq.js} +6 -6
  5. package/dist/chunks/{content-query-B4cHfrig.js.map → content-query-BV41W2Dq.js.map} +1 -1
  6. package/dist/chunks/{content-reviews-BWrYEQc6.js → content-reviews-lBoqJY7Y.js} +6 -3
  7. package/dist/chunks/content-reviews-lBoqJY7Y.js.map +1 -0
  8. package/dist/chunks/{content-versions-OEYQVrUf.js → content-versions-dZSBU8UF.js} +6 -3
  9. package/dist/chunks/content-versions-dZSBU8UF.js.map +1 -0
  10. package/dist/chunks/{contents-BrGxWH3s.js → contents-Ba7JHu2K.js} +2 -2
  11. package/dist/chunks/{contents-BrGxWH3s.js.map → contents-Ba7JHu2K.js.map} +1 -1
  12. package/dist/content-contribution.d.ts +1 -1
  13. package/dist/content-contribution.d.ts.map +1 -1
  14. package/dist/index.js +12 -9
  15. package/dist/index.js.map +1 -1
  16. package/dist/manifest.json +2268 -4345
  17. package/dist/server/content-list-actions.d.ts +10 -3
  18. package/dist/server/content-list-actions.d.ts.map +1 -1
  19. package/dist/server/content-list-data-surface.d.ts +29 -0
  20. package/dist/server/content-list-data-surface.d.ts.map +1 -0
  21. package/dist/server/content-list-lifecycle.d.ts +20 -0
  22. package/dist/server/content-list-lifecycle.d.ts.map +1 -0
  23. package/dist/server.d.ts +2 -0
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/server.js +247 -25
  26. package/dist/server.js.map +1 -1
  27. package/dist/smrt-knowledge.json +4291 -2510
  28. package/dist/svelte/components/ContentList.svelte +538 -40
  29. package/dist/svelte/components/ContentList.svelte.d.ts +6 -1
  30. package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
  31. package/dist/svelte/components/ContentListLifecyclePanel.svelte +387 -0
  32. package/dist/svelte/components/ContentListLifecyclePanel.svelte.d.ts +17 -0
  33. package/dist/svelte/components/ContentListLifecyclePanel.svelte.d.ts.map +1 -0
  34. package/dist/svelte/content-list-controller.d.ts +10 -0
  35. package/dist/svelte/content-list-controller.d.ts.map +1 -1
  36. package/dist/svelte/content-list-controller.js +111 -26
  37. package/dist/svelte/content-list-data-surface.d.ts +45 -0
  38. package/dist/svelte/content-list-data-surface.d.ts.map +1 -0
  39. package/dist/svelte/content-list-data-surface.js +196 -0
  40. package/dist/svelte/content-list-lifecycle.d.ts +132 -0
  41. package/dist/svelte/content-list-lifecycle.d.ts.map +1 -0
  42. package/dist/svelte/content-list-lifecycle.js +439 -0
  43. package/dist/svelte/i18n.contribution.d.ts +27 -0
  44. package/dist/svelte/i18n.contribution.d.ts.map +1 -1
  45. package/dist/svelte/i18n.contribution.js +27 -0
  46. package/dist/svelte/index.d.ts +2 -0
  47. package/dist/svelte/index.d.ts.map +1 -1
  48. package/dist/svelte/index.js +2 -0
  49. package/package.json +15 -15
  50. package/dist/chunks/content-corrections-BxfA3tW1.js.map +0 -1
  51. package/dist/chunks/content-reviews-BWrYEQc6.js.map +0 -1
  52. package/dist/chunks/content-versions-OEYQVrUf.js.map +0 -1
@@ -45,6 +45,16 @@ import { Button, Pagination } from '@happyvertical/smrt-ui/ui';
45
45
  import type { Snippet } from 'svelte';
46
46
  import { untrack } from 'svelte';
47
47
  import type { ContentData } from '../../mock-smrt-client.js';
48
+ import type { ContentListLifecycleBinding } from '../content-list-lifecycle.js';
49
+ import {
50
+ readContentListLifecycleSummary,
51
+ reconcileContentListLifecycleSelection,
52
+ } from '../content-list-lifecycle.js';
53
+ import {
54
+ registerContentListDataSurface,
55
+ type ContentListSurfaceContext,
56
+ type ContentListSurfaceHandle,
57
+ } from '../content-list-data-surface.js';
48
58
  import {
49
59
  applyContentListFilter,
50
60
  buildContentListColumns,
@@ -123,10 +133,109 @@ import {
123
133
  readContentListViewStateFromSearchParams,
124
134
  } from '../content-list-url-state.js';
125
135
  import { M } from '../i18n.contribution.js';
136
+ import ContentListLifecyclePanel from './ContentListLifecyclePanel.svelte';
126
137
  import ImageThumbnail from './ImageThumbnail.svelte';
127
138
 
128
139
  const { t } = useI18n();
129
140
 
141
+ const DEFAULT_CONTENT_LIST_SURFACE_IDENTITY: DataSurfaceIdentity = {
142
+ surfaceId: 'content-list',
143
+ kind: 'table',
144
+ };
145
+
146
+ function sameSurfaceIdentity(
147
+ left: DataSurfaceIdentity,
148
+ right: DataSurfaceIdentity,
149
+ ): boolean {
150
+ return (
151
+ left.kind === right.kind &&
152
+ left.surfaceId === right.surfaceId &&
153
+ left.subject?.type === right.subject?.type &&
154
+ left.subject?.id === right.subject?.id
155
+ );
156
+ }
157
+
158
+ function resolveSurfaceAuthority(
159
+ lifecycle: ContentListLifecycleBinding | undefined,
160
+ workflows: ContentListWorkflowBinding | undefined,
161
+ ): {
162
+ identity?: DataSurfaceIdentity;
163
+ maxSelectionSize?: number;
164
+ lifecycle?: ContentListLifecycleBinding;
165
+ workflows?: ContentListWorkflowBinding;
166
+ } {
167
+ const lifecycleIdentity = lifecycle
168
+ ? (lifecycle.identity ?? DEFAULT_CONTENT_LIST_SURFACE_IDENTITY)
169
+ : undefined;
170
+ const workflowIdentity = workflows
171
+ ? (workflows.identity ?? DEFAULT_CONTENT_LIST_SURFACE_IDENTITY)
172
+ : undefined;
173
+ if (
174
+ lifecycleIdentity &&
175
+ workflowIdentity &&
176
+ !sameSurfaceIdentity(lifecycleIdentity, workflowIdentity)
177
+ ) {
178
+ throw new Error(
179
+ 'ContentList lifecycle and workflow bindings must use the same data surface identity',
180
+ );
181
+ }
182
+ const bindings = [lifecycle, workflows].filter(
183
+ (binding): binding is ContentListLifecycleBinding | ContentListWorkflowBinding =>
184
+ binding !== undefined,
185
+ );
186
+ const maxSelectionSize =
187
+ bindings.length > 0
188
+ ? Math.min(...bindings.map((binding) => binding.maxSelectionSize ?? 200))
189
+ : undefined;
190
+ return {
191
+ ...(lifecycleIdentity ?? workflowIdentity
192
+ ? { identity: lifecycleIdentity ?? workflowIdentity }
193
+ : {}),
194
+ ...(maxSelectionSize === undefined ? {} : { maxSelectionSize }),
195
+ ...(lifecycle && lifecycleIdentity
196
+ ? {
197
+ lifecycle: {
198
+ ...lifecycle,
199
+ identity: lifecycleIdentity,
200
+ ...(maxSelectionSize === undefined ? {} : { maxSelectionSize }),
201
+ },
202
+ }
203
+ : {}),
204
+ ...(workflows && workflowIdentity
205
+ ? {
206
+ workflows: {
207
+ ...workflows,
208
+ identity: workflowIdentity,
209
+ ...(maxSelectionSize === undefined ? {} : { maxSelectionSize }),
210
+ },
211
+ }
212
+ : {}),
213
+ };
214
+ }
215
+
216
+ function reconcileSurfaceDescriptor(
217
+ descriptor: import('@happyvertical/smrt-ui/data').DataSurfaceDescriptor,
218
+ authority: ReturnType<typeof resolveSurfaceAuthority>,
219
+ ): import('@happyvertical/smrt-ui/data').DataSurfaceDescriptor {
220
+ if (
221
+ authority.identity &&
222
+ !sameSurfaceIdentity(descriptor.identity, authority.identity)
223
+ ) {
224
+ throw new Error(
225
+ 'ContentList custom data surface descriptor must use the lifecycle and workflow identity',
226
+ );
227
+ }
228
+ if (
229
+ authority.maxSelectionSize !== undefined &&
230
+ descriptor.limits.maxSelectionSize !== authority.maxSelectionSize
231
+ ) {
232
+ throw new Error(
233
+ 'ContentList custom data surface descriptor must use the lifecycle and workflow selection limit',
234
+ );
235
+ }
236
+ return descriptor;
237
+ }
238
+
130
239
  const WORKFLOW_LABEL_MESSAGES: Record<
131
240
  ContentListWorkflowId,
132
241
  keyof typeof M
@@ -318,7 +427,7 @@ interface Props {
318
427
  error?: string | null;
319
428
  /** Retry affordance rendered with an error. */
320
429
  onRetry?: () => void;
321
- /** Opt-in agent addressability. Non-table presentations land with #2456. */
430
+ /** Opt-in agent addressability shared by every list presentation. */
322
431
  dataSurface?: ContentListDataSurface;
323
432
  /**
324
433
  * Opt-in server-backed rows (#2452). `bind()` is called exactly once, during
@@ -335,6 +444,10 @@ interface Props {
335
444
  jobs?: ContentListJobBinding;
336
445
  /** Opt-in, authenticated preview/apply client for bulk workflows (#2453). */
337
446
  workflows?: ContentListWorkflowBinding;
447
+ /** Opt-in, server-authoritative trash/restore/permanent-delete lifecycle. */
448
+ lifecycle?: ContentListLifecycleBinding;
449
+ /** Locks the status query to deleted content and exposes trash actions. */
450
+ lifecycleMode?: 'active' | 'trash';
338
451
  }
339
452
 
340
453
  let {
@@ -356,8 +469,14 @@ let {
356
469
  savedViews = undefined,
357
470
  jobs = undefined,
358
471
  workflows = undefined,
472
+ lifecycle = undefined,
473
+ lifecycleMode = 'active',
359
474
  }: Props = $props();
360
475
 
476
+ const surfaceAuthority = $derived(
477
+ resolveSurfaceAuthority(lifecycle, workflows),
478
+ );
479
+
361
480
  const initialQuery = untrack(() => query);
362
481
 
363
482
  // One controller owns search, filters, sorting, paging, and selection for
@@ -425,6 +544,7 @@ const urlStateOptions: ContentListUrlStateOptions = {
425
544
 
426
545
  const controller = createContentListController({
427
546
  type: untrack(() => type),
547
+ ...(untrack(() => lifecycleMode) === 'trash' ? { status: 'deleted' } : {}),
428
548
  // Local mode keeps the historical unpaginated list.
429
549
  ...(serverPageSize === null ? {} : { pageSize: serverPageSize }),
430
550
  });
@@ -454,6 +574,18 @@ let resultNotices = $state<ContentListQueryNotices>({
454
574
  * its own total ever arrived.
455
575
  */
456
576
  let settledSignature = $state<string | undefined>(undefined);
577
+ let settledLifecycleQuery = $state<ContentListDataQueryRequest | undefined>(
578
+ undefined,
579
+ );
580
+ let settledLifecycleFingerprint = $state<string | undefined>(undefined);
581
+ let settledSurfaceMetadata = $state<
582
+ | {
583
+ signature: string;
584
+ queryFingerprint: string | null;
585
+ totalRows: number | null;
586
+ }
587
+ | undefined
588
+ >(undefined);
457
589
 
458
590
  function toSearchParams(
459
591
  input: URLSearchParams | string | null | undefined,
@@ -480,22 +612,34 @@ if (initialUrlState?.params !== undefined && initialUrlState?.params !== null) {
480
612
  // the same link just restored. Folding the lock into the restored patch keeps
481
613
  // it to one `replaceState`, and leaves the effect's first run a no-op.
482
614
  const initialLockedType = normalizeContentListTypeLock(untrack(() => type));
483
- const patch =
484
- initialLockedType === null
485
- ? reading.state
486
- : {
487
- ...reading.state,
488
- filters: [
489
- ...(reading.state.filters ?? []).filter(
490
- (filter) => filter.columnId !== CONTENT_LIST_TYPE_FILTER_ID,
491
- ),
492
- {
493
- columnId: CONTENT_LIST_TYPE_FILTER_ID,
494
- operator: 'equals' as const,
495
- value: initialLockedType,
496
- },
497
- ],
498
- };
615
+ const initialTrashMode = untrack(() => lifecycleMode) === 'trash';
616
+ const lockedFilters = [...(reading.state.filters ?? [])].filter(
617
+ (filter) =>
618
+ (initialLockedType === null ||
619
+ filter.columnId !== CONTENT_LIST_TYPE_FILTER_ID) &&
620
+ (!initialTrashMode ||
621
+ filter.columnId !== CONTENT_LIST_STATUS_FILTER_ID),
622
+ );
623
+ if (initialLockedType !== null) {
624
+ lockedFilters.push({
625
+ columnId: CONTENT_LIST_TYPE_FILTER_ID,
626
+ operator: 'equals',
627
+ value: initialLockedType,
628
+ });
629
+ }
630
+ if (initialTrashMode) {
631
+ lockedFilters.push({
632
+ columnId: CONTENT_LIST_STATUS_FILTER_ID,
633
+ operator: 'equals',
634
+ value: 'deleted',
635
+ });
636
+ }
637
+ const patch = {
638
+ ...reading.state,
639
+ ...(initialLockedType !== null || initialTrashMode
640
+ ? { filters: lockedFilters }
641
+ : {}),
642
+ };
499
643
  applyContentListViewState(controller, patch, restoreOptions);
500
644
  restoreDrops = reading.dropped;
501
645
  }
@@ -515,6 +659,7 @@ const queryRequestOptions: ContentListQueryRequestOptions | undefined =
515
659
  }
516
660
  : undefined;
517
661
  const serverBacked = queryBinding !== undefined;
662
+ let locallyRemovedLifecycleIds = $state<Set<string>>(new Set());
518
663
 
519
664
  let snapshot = $state(controller.snapshot());
520
665
  let viewMode: ContentListViewMode = $state(untrack(() => defaultViewMode));
@@ -532,6 +677,11 @@ const MAX_DEFERRED_JOB_COMPLETIONS = 50;
532
677
  let completedJobs = $state<ContentListJob[]>([]);
533
678
  let completedJobsOverflowed = $state(false);
534
679
  let activeQueryKey = $state<string | undefined>(undefined);
680
+ let lifecycleRefreshPending = $state(false);
681
+ let surfaceRoot: HTMLDivElement | undefined;
682
+ let surfaceHighlighted = $state(false);
683
+ let surfaceHighlightTimeout: ReturnType<typeof setTimeout> | undefined;
684
+ let surfaceHandle: ContentListSurfaceHandle | undefined;
535
685
 
536
686
  // Job state is subscribed once so hosts can use a framework-free controller.
537
687
  // Only transitions observed after the initial snapshot are completion events;
@@ -697,8 +847,20 @@ const columnLabels = $derived({
697
847
 
698
848
  const queryColumns = $derived(buildContentListColumns(columnLabels));
699
849
  const sourceContents = $derived(
700
- queryBinding ? contentListQueryRowsToContents(queryBinding.rows) : contents,
850
+ (queryBinding
851
+ ? contentListQueryRowsToContents(queryBinding.rows)
852
+ : contents
853
+ ).filter((content) => !locallyRemovedLifecycleIds.has(String(content.id))),
701
854
  );
855
+
856
+ $effect(() => {
857
+ if (!queryBinding || refreshing || isLoading) return;
858
+ const removed = locallyRemovedLifecycleIds;
859
+ if (removed.size === 0) return;
860
+ const visible = new Set(queryBinding.rows.map((row) => String(row.id)));
861
+ const retained = new Set([...removed].filter((id) => visible.has(id)));
862
+ if (retained.size !== removed.size) locallyRemovedLifecycleIds = retained;
863
+ });
702
864
  const rows = $derived(toContentListRows(sourceContents));
703
865
  const visibleRowIds = $derived(
704
866
  new Set(rows.filter((row) => row.identified).map((row) => String(row.id))),
@@ -843,21 +1005,58 @@ function refreshQuery(): void {
843
1005
  void queryBinding
844
1006
  .refresh()
845
1007
  .then((result) => {
846
- if (result !== undefined && activeQueryKey === key)
847
- resultNotices = readContentListQueryNotices(result);
1008
+ if (result !== undefined && activeQueryKey === key) {
1009
+ if (executedWorkflowQuery) {
1010
+ settleWorkflowQueryResult(
1011
+ result,
1012
+ executedWorkflowQuery,
1013
+ querySignature,
1014
+ );
1015
+ } else {
1016
+ resultNotices = readContentListQueryNotices(result);
1017
+ }
1018
+ settleLifecycleResult(result, lifecycleQueryRequest, querySignature);
1019
+ }
848
1020
  })
849
1021
  .catch(() => undefined);
850
1022
  }
851
1023
 
1024
+ function requestLifecycleRefresh(): void {
1025
+ if (!queryBinding?.refresh) return;
1026
+ if (refreshing || isLoading) {
1027
+ lifecycleRefreshPending = true;
1028
+ return;
1029
+ }
1030
+ lifecycleRefreshPending = false;
1031
+ refreshQuery();
1032
+ }
1033
+
1034
+ $effect(() => {
1035
+ if (!lifecycleRefreshPending || refreshing || isLoading) return;
1036
+ lifecycleRefreshPending = false;
1037
+ refreshQuery();
1038
+ });
1039
+
852
1040
  function settleWorkflowQueryResult(
853
1041
  result: unknown,
854
1042
  request: ContentListDataQueryRequest,
855
1043
  signature: string,
856
1044
  ): void {
857
1045
  settledSignature = signature;
1046
+ const envelope =
1047
+ result !== null && typeof result === 'object' && !Array.isArray(result)
1048
+ ? (result as Record<string, unknown>)
1049
+ : undefined;
1050
+ settledSurfaceMetadata = {
1051
+ signature,
1052
+ queryFingerprint:
1053
+ typeof envelope?.queryFingerprint === 'string'
1054
+ ? envelope.queryFingerprint
1055
+ : null,
1056
+ totalRows: surfaceMetadataTotalRows(envelope?.total),
1057
+ };
858
1058
  resultNotices = readContentListQueryNotices(result);
859
- if (result !== null && typeof result === 'object' && !Array.isArray(result)) {
860
- const envelope = result as Record<string, unknown>;
1059
+ if (envelope) {
861
1060
  settledWorkflowQuery = request;
862
1061
  settledWorkflowFingerprint =
863
1062
  typeof envelope.queryFingerprint === 'string'
@@ -878,6 +1077,38 @@ function settleWorkflowQueryResult(
878
1077
  settledWorkflowRevision = null;
879
1078
  }
880
1079
 
1080
+ function surfaceMetadataTotalRows(total: unknown): number | null {
1081
+ if (!total || typeof total !== 'object' || Array.isArray(total)) return null;
1082
+ const record = total as Record<string, unknown>;
1083
+ return (record.kind === 'exact' || record.kind === 'estimated') &&
1084
+ typeof record.value === 'number' &&
1085
+ Number.isSafeInteger(record.value) &&
1086
+ record.value >= 0
1087
+ ? record.value
1088
+ : null;
1089
+ }
1090
+
1091
+ function settleLifecycleResult(
1092
+ result: unknown,
1093
+ request: ContentListDataQueryRequest | undefined,
1094
+ signature: string,
1095
+ ): void {
1096
+ if (
1097
+ !request ||
1098
+ result === null ||
1099
+ typeof result !== 'object' ||
1100
+ Array.isArray(result)
1101
+ ) {
1102
+ settledLifecycleQuery = undefined;
1103
+ settledLifecycleFingerprint = undefined;
1104
+ return;
1105
+ }
1106
+ const fingerprint = (result as Record<string, unknown>).queryFingerprint;
1107
+ settledLifecycleQuery = request;
1108
+ settledLifecycleFingerprint =
1109
+ typeof fingerprint === 'string' ? fingerprint : undefined;
1110
+ settledSignature = signature;
1111
+ }
881
1112
  /**
882
1113
  * A retry re-reads the same query, so its answer replaces the rendered rows —
883
1114
  * and therefore has to replace the completeness flags that describe them too.
@@ -903,6 +1134,7 @@ function retryQuery(): void {
903
1134
  )
904
1135
  return;
905
1136
  settleWorkflowQueryResult(result, request, signature);
1137
+ settleLifecycleResult(result, lifecycleQueryRequest, signature);
906
1138
  })
907
1139
  .catch(() => undefined);
908
1140
  }
@@ -930,6 +1162,7 @@ const querySignature = $derived(
930
1162
  let executedSignature: string | undefined;
931
1163
  let executedWorkflowQuery: ContentListDataQueryRequest | null = null;
932
1164
  let publishedUrlSignature: string | undefined;
1165
+ let lifecycleQueryRequest: ContentListDataQueryRequest | undefined;
933
1166
 
934
1167
  $effect(() => {
935
1168
  const signature = querySignature;
@@ -976,6 +1209,7 @@ $effect(() => {
976
1209
  // flags, which the binding itself does not expose.
977
1210
  activeQueryKey = contentListQueryRequestKey(translated.request);
978
1211
  executedWorkflowQuery = translated.request;
1212
+ lifecycleQueryRequest = translated.request;
979
1213
  void queryBinding
980
1214
  .execute(translated.request)
981
1215
  .then((result) => {
@@ -985,6 +1219,7 @@ $effect(() => {
985
1219
  // may be clamped against them and workflows may bind to the exact
986
1220
  // settled request.
987
1221
  settleWorkflowQueryResult(result, translated.request, signature);
1222
+ settleLifecycleResult(result, translated.request, signature);
988
1223
  })
989
1224
  .catch(() => undefined);
990
1225
  }
@@ -1111,7 +1346,7 @@ const canSelectAllMatching = $derived(
1111
1346
  settledWorkflowRevision &&
1112
1347
  exactMatchingCount !== undefined &&
1113
1348
  exactMatchingCount > 0 &&
1114
- exactMatchingCount <= (workflows?.maxSelectionSize ?? 200),
1349
+ exactMatchingCount <= (surfaceAuthority.maxSelectionSize ?? 200),
1115
1350
  ),
1116
1351
  );
1117
1352
  const workflowPayloadValid = $derived(
@@ -1124,6 +1359,23 @@ const workflowQueuedJob = $derived(
1124
1359
  workflowQueuedJobs.find((job) => job.intent === workflowIntentSignature()) ??
1125
1360
  workflowQueuedJobs[0],
1126
1361
  );
1362
+ const lifecycleViewKey = $derived(
1363
+ JSON.stringify([
1364
+ lifecycleMode,
1365
+ querySignature,
1366
+ [...tableState.selectedRowIds].map(String).sort(),
1367
+ ]),
1368
+ );
1369
+ const lifecycleQuery = $derived(
1370
+ serverBacked && settledSignature === querySignature
1371
+ ? settledLifecycleQuery
1372
+ : undefined,
1373
+ );
1374
+ const lifecycleExactMatchingCount = $derived(
1375
+ serverBacked && settledSignature === querySignature
1376
+ ? contentListQueryExactTotal(queryBinding?.total)
1377
+ : queryRows.length,
1378
+ );
1127
1379
 
1128
1380
  /** Synthetic ids the adapter minted for rows that carry no durable identity. */
1129
1381
  const unidentifiedRowKeys = $derived(
@@ -1153,6 +1405,23 @@ $effect(() => {
1153
1405
  );
1154
1406
  });
1155
1407
 
1408
+ // Trash is a view, not an editable select value. Keep its status predicate
1409
+ // locked against URL restores and agent-driven filter commands.
1410
+ $effect(() => {
1411
+ if (lifecycleMode !== 'trash') return;
1412
+ if (
1413
+ isContentListFilterExactly(
1414
+ tableState,
1415
+ CONTENT_LIST_STATUS_FILTER_ID,
1416
+ 'deleted',
1417
+ )
1418
+ )
1419
+ return;
1420
+ untrack(() =>
1421
+ applyContentListFilter(controller, CONTENT_LIST_STATUS_FILTER_ID, 'deleted'),
1422
+ );
1423
+ });
1424
+
1156
1425
  /**
1157
1426
  * What each toolbar select may display, and whether it can display the live
1158
1427
  * predicate at all.
@@ -1257,20 +1526,30 @@ const surfaceOptions = $derived(
1257
1526
  ? {
1258
1527
  registry: dataSurface.registry,
1259
1528
  descriptor:
1260
- dataSurface.descriptor ??
1529
+ dataSurface.descriptor
1530
+ ? reconcileSurfaceDescriptor(
1531
+ dataSurface.descriptor,
1532
+ surfaceAuthority,
1533
+ )
1534
+ :
1261
1535
  (() => {
1262
1536
  const descriptor = buildContentListSurfaceDescriptor({
1263
1537
  columnLabels,
1538
+ serverBacked: initialQuery !== undefined,
1539
+ lifecycle: lifecycle !== undefined,
1540
+ lifecycleMode,
1264
1541
  includeWorkflows: workflows !== undefined,
1542
+ refresh: queryBinding?.refresh !== undefined,
1543
+ retry: retryHandler !== undefined,
1265
1544
  });
1266
1545
  return {
1267
1546
  ...descriptor,
1268
- identity: workflows?.identity ?? descriptor.identity,
1547
+ identity: surfaceAuthority.identity ?? descriptor.identity,
1269
1548
  limits: {
1270
1549
  ...descriptor.limits,
1271
- ...(workflows?.maxSelectionSize === undefined
1550
+ ...(surfaceAuthority.maxSelectionSize === undefined
1272
1551
  ? {}
1273
- : { maxSelectionSize: workflows.maxSelectionSize }),
1552
+ : { maxSelectionSize: surfaceAuthority.maxSelectionSize }),
1274
1553
  },
1275
1554
  };
1276
1555
  })(),
@@ -1312,6 +1591,164 @@ const queryWarnings = $derived<ReadonlyArray<string>>(
1312
1591
  boundResultNotices?.warnings ??
1313
1592
  resultNotices.warnings,
1314
1593
  );
1594
+
1595
+ function surfaceLastUpdated(value: unknown): string | null {
1596
+ if (value instanceof Date) return value.toISOString();
1597
+ if (typeof value === 'string') return value;
1598
+ if (typeof value === 'number' && Number.isFinite(value)) {
1599
+ return new Date(value).toISOString();
1600
+ }
1601
+ return null;
1602
+ }
1603
+
1604
+ const matchingSurfaceMetadata = $derived(
1605
+ serverBacked && settledSurfaceMetadata?.signature === querySignature
1606
+ ? settledSurfaceMetadata
1607
+ : undefined,
1608
+ );
1609
+
1610
+ const surfaceContext = $derived<ContentListSurfaceContext>({
1611
+ viewMode,
1612
+ queryFingerprint: serverBacked
1613
+ ? (matchingSurfaceMetadata?.queryFingerprint ?? null)
1614
+ : null,
1615
+ totalRows: serverBacked
1616
+ ? (matchingSurfaceMetadata?.totalRows ?? null)
1617
+ : Number.isSafeInteger(totalRowCount)
1618
+ ? totalRowCount
1619
+ : null,
1620
+ freshness: {
1621
+ stale,
1622
+ refreshing,
1623
+ offline,
1624
+ lastUpdated: surfaceLastUpdated(lastUpdated),
1625
+ truncated: queryTruncated,
1626
+ warnings: queryWarnings,
1627
+ },
1628
+ });
1629
+
1630
+ function focusSurface(): void {
1631
+ if (!surfaceRoot) return;
1632
+ surfaceRoot.tabIndex = -1;
1633
+ surfaceRoot.focus();
1634
+ }
1635
+
1636
+ // ContentList owns the registration so grid, detailed, compact, empty, and
1637
+ // loading presentations all expose one stable identity and revision stream.
1638
+ // This ref is deliberately non-reactive: registration input changes must
1639
+ // advance a stable identity revision, but normal command acknowledgements must
1640
+ // not themselves cause a registration teardown/replay window.
1641
+ const surfaceRevision = { value: undefined as number | undefined };
1642
+
1643
+ function acceptsSurfaceTableCommand(
1644
+ command: import('@happyvertical/smrt-ui/data').DataTableCommand,
1645
+ ): boolean {
1646
+ if (command.type === 'setPageSize' && serverPageSize !== null) {
1647
+ if (command.pageSize === null || command.pageSize > maxPageSize) return false;
1648
+ }
1649
+ if (command.type === 'setPage') {
1650
+ if (command.page > maxReachablePage) return false;
1651
+ if (
1652
+ tableState.pageSize !== null &&
1653
+ clampableRowCount !== undefined &&
1654
+ // A server binding retains the previous query's exact total until its
1655
+ // replacement settles. That count cannot bound a command for the new
1656
+ // query: it may be smaller, and would reject a valid page before the
1657
+ // new query gets a chance to report its own total.
1658
+ (!serverBacked || settledSignature === querySignature) &&
1659
+ command.page > Math.max(1, Math.ceil(clampableRowCount / tableState.pageSize))
1660
+ )
1661
+ return false;
1662
+ }
1663
+ if (command.type === 'setFilters') {
1664
+ if (
1665
+ lockedType !== null &&
1666
+ !isContentListFilterExactly(
1667
+ { ...tableState, filters: command.filters },
1668
+ CONTENT_LIST_TYPE_FILTER_ID,
1669
+ lockedType,
1670
+ )
1671
+ )
1672
+ return false;
1673
+ if (
1674
+ lifecycleMode === 'trash' &&
1675
+ !isContentListFilterExactly(
1676
+ { ...tableState, filters: command.filters },
1677
+ CONTENT_LIST_STATUS_FILTER_ID,
1678
+ 'deleted',
1679
+ )
1680
+ )
1681
+ return false;
1682
+ }
1683
+ return command.type !== 'reset' || (lockedType === null && lifecycleMode !== 'trash');
1684
+ }
1685
+
1686
+ $effect(() => {
1687
+ const surface = surfaceOptions;
1688
+ if (!surface) {
1689
+ surfaceHandle = undefined;
1690
+ return;
1691
+ }
1692
+ const handle = registerContentListDataSurface({
1693
+ ...surface,
1694
+ controller,
1695
+ context: untrack(() => surfaceContext),
1696
+ initialRevision:
1697
+ surfaceRevision.value === undefined ? 0 : surfaceRevision.value + 1,
1698
+ onRevision: (revision) => {
1699
+ surfaceRevision.value = revision;
1700
+ },
1701
+ acceptsTableCommand: acceptsSurfaceTableCommand,
1702
+ setViewMode: (next) => {
1703
+ viewMode = next;
1704
+ },
1705
+ ...(queryBinding?.refresh
1706
+ ? {
1707
+ refresh: async () => {
1708
+ await refreshQuery();
1709
+ return true;
1710
+ },
1711
+ }
1712
+ : {}),
1713
+ ...(retryHandler
1714
+ ? {
1715
+ retry: async () => {
1716
+ await retryHandler();
1717
+ return true;
1718
+ },
1719
+ }
1720
+ : {}),
1721
+ focus: focusSurface,
1722
+ reveal: () => surfaceRoot?.scrollIntoView({ block: 'nearest' }),
1723
+ highlight: () => {
1724
+ if (surfaceHighlightTimeout !== undefined) {
1725
+ clearTimeout(surfaceHighlightTimeout);
1726
+ }
1727
+ surfaceHighlighted = true;
1728
+ const timeout = setTimeout(() => {
1729
+ if (surfaceHandle === handle) surfaceHighlighted = false;
1730
+ if (surfaceHighlightTimeout === timeout) {
1731
+ surfaceHighlightTimeout = undefined;
1732
+ }
1733
+ }, 1_000);
1734
+ surfaceHighlightTimeout = timeout;
1735
+ },
1736
+ });
1737
+ surfaceHandle = handle;
1738
+ return () => {
1739
+ if (surfaceHandle === handle) surfaceHandle = undefined;
1740
+ if (surfaceHighlightTimeout !== undefined) {
1741
+ clearTimeout(surfaceHighlightTimeout);
1742
+ surfaceHighlightTimeout = undefined;
1743
+ }
1744
+ surfaceHighlighted = false;
1745
+ handle.destroy();
1746
+ };
1747
+ });
1748
+
1749
+ $effect(() => {
1750
+ surfaceHandle?.update(surfaceContext);
1751
+ });
1315
1752
  /** Identity of the current set of refusals, so a dismissal is not permanent. */
1316
1753
  const dropNoticeKey = $derived(
1317
1754
  dropNotices.length > 0 || queryTruncated || queryWarnings.length > 0
@@ -1606,6 +2043,16 @@ function workflowSelection(): ContentListWorkflowRequest['selection'] | null {
1606
2043
  return { scope: 'explicit-ids', rowIds: effectiveRowIds };
1607
2044
  }
1608
2045
 
2046
+ function workflowSelectionCount(
2047
+ selection: ContentListWorkflowRequest['selection'],
2048
+ ): number {
2049
+ if (selection.scope === 'explicit-ids') return selection.rowIds.length;
2050
+ if (selection.scope === 'current-page') return selectablePageRowIds.length;
2051
+ return tableState.selection.scope === 'allMatching'
2052
+ ? tableState.selection.expectedCount
2053
+ : 0;
2054
+ }
2055
+
1609
2056
  function workflowJobTarget(
1610
2057
  request: ContentListWorkflowRequest,
1611
2058
  ): ContentListJobTarget {
@@ -1646,6 +2093,8 @@ function createWorkflowRequest(
1646
2093
  ): ContentListWorkflowRequest | null {
1647
2094
  const selection = workflowSelection();
1648
2095
  if (!selection) return null;
2096
+ const selectionCount = workflowSelectionCount(selection);
2097
+ if (selectionCount > (surfaceAuthority.maxSelectionSize ?? 200)) return null;
1649
2098
  const queryRequired = selection.scope !== 'explicit-ids';
1650
2099
  if (
1651
2100
  queryRequired &&
@@ -1658,7 +2107,8 @@ function createWorkflowRequest(
1658
2107
  return {
1659
2108
  version: 1,
1660
2109
  requestId: globalThis.crypto?.randomUUID?.() ?? `content-workflow-${Date.now()}`,
1661
- identity: workflows?.identity ?? { surfaceId: 'content-list', kind: 'table' },
2110
+ identity:
2111
+ surfaceAuthority.identity ?? DEFAULT_CONTENT_LIST_SURFACE_IDENTITY,
1662
2112
  actionId: selectedWorkflow,
1663
2113
  phase,
1664
2114
  selection,
@@ -1668,10 +2118,7 @@ function createWorkflowRequest(
1668
2118
  ...(queryRequired && settledWorkflowQuery
1669
2119
  ? { query: settledWorkflowQuery }
1670
2120
  : {}),
1671
- expectedCount:
1672
- selection.scope === 'explicit-ids'
1673
- ? selection.rowIds.length
1674
- : selectedCount,
2121
+ expectedCount: selectionCount,
1675
2122
  },
1676
2123
  };
1677
2124
  }
@@ -2052,7 +2499,29 @@ function handleFilter(columnId: string, value: string) {
2052
2499
  }
2053
2500
 
2054
2501
  function rowActions(row: ContentListRow) {
2055
- return contentListRowActions(row, { getViewHref });
2502
+ return contentListRowActions(row, {
2503
+ getViewHref,
2504
+ canEdit: lifecycleMode !== 'trash',
2505
+ canDelete: lifecycleMode !== 'trash' && lifecycle === undefined,
2506
+ });
2507
+ }
2508
+
2509
+ function handleLifecycleComplete(
2510
+ result: import('@happyvertical/smrt-ui/data').DataSurfaceActionResult,
2511
+ ) {
2512
+ const removed = new Set(locallyRemovedLifecycleIds);
2513
+ for (const outcome of readContentListLifecycleSummary(result).outcomes) {
2514
+ if (outcome.status === 'accepted') removed.add(String(outcome.rowId));
2515
+ }
2516
+ locallyRemovedLifecycleIds = removed;
2517
+ controller.dispatch({
2518
+ type: 'setSelectedRows',
2519
+ rowIds: reconcileContentListLifecycleSelection(
2520
+ tableState.selectedRowIds,
2521
+ result,
2522
+ ),
2523
+ });
2524
+ requestLifecycleRefresh();
2056
2525
  }
2057
2526
 
2058
2527
  function viewHref(row: ContentListRow): string | null {
@@ -2127,7 +2596,11 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2127
2596
  </script>
2128
2597
 
2129
2598
  {#snippet tableEmptyState()}
2130
- <p class="table-empty-state">{t(M['content.content_list.empty'])}</p>
2599
+ <p class="table-empty-state">
2600
+ {lifecycleMode === 'trash'
2601
+ ? t(M['content.content_list.trash_empty'])
2602
+ : t(M['content.content_list.empty'])}
2603
+ </p>
2131
2604
  {/snippet}
2132
2605
 
2133
2606
  {#snippet selectHeader()}
@@ -2226,7 +2699,11 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2226
2699
  </div>
2227
2700
  {/snippet}
2228
2701
 
2229
- <div class="content-list-wrapper">
2702
+ <div
2703
+ class="content-list-wrapper"
2704
+ class:data-surface-highlighted={surfaceHighlighted}
2705
+ bind:this={surfaceRoot}
2706
+ >
2230
2707
 
2231
2708
  <div class="content-controls">
2232
2709
  <div class="search-filters">
@@ -2267,6 +2744,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2267
2744
  </Select>
2268
2745
  {/if}
2269
2746
 
2747
+ {#if lifecycleMode !== 'trash'}
2270
2748
  <Select
2271
2749
  aria-label={t(M['content.content_list.filter_status'])}
2272
2750
  value={selectedStatus}
@@ -2289,6 +2767,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2289
2767
  </option>
2290
2768
  {/if}
2291
2769
  </Select>
2770
+ {/if}
2292
2771
 
2293
2772
  {#if savedViews}
2294
2773
  <div class="saved-views" role="group" aria-label={t(M['content.content_list.saved_views'])}>
@@ -2568,6 +3047,19 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2568
3047
  </div>
2569
3048
  {/if}
2570
3049
 
3050
+ {#if lifecycle}
3051
+ <ContentListLifecyclePanel
3052
+ binding={surfaceAuthority.lifecycle ?? lifecycle}
3053
+ mode={lifecycleMode}
3054
+ selectedRowIds={tableState.selectedRowIds}
3055
+ query={lifecycleQuery}
3056
+ queryFingerprint={settledLifecycleFingerprint}
3057
+ exactMatchingCount={lifecycleExactMatchingCount}
3058
+ viewKey={lifecycleViewKey}
3059
+ oncomplete={handleLifecycleComplete}
3060
+ />
3061
+ {/if}
3062
+
2571
3063
  {#if workflows && (selectedCount > 0 || workflowError || workflowResult)}
2572
3064
  <section class="content-workflows" aria-label={t(M['content.content_list.bulk_workflows'])}>
2573
3065
  <Select
@@ -2686,9 +3178,9 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2686
3178
 
2687
3179
  {#if viewMode === 'compact'}
2688
3180
  <!--
2689
- The compact table stays mounted for empty and loading results: it owns
2690
- the mounted data surface, so unmounting it on a zero-row query would
2691
- unregister the surface and leave an agent unable to undo its own search.
3181
+ The compact table stays mounted for empty and loading results so its
3182
+ table semantics and operator affordances remain stable across queries.
3183
+ ContentList itself owns the agent-addressable surface in every view.
2692
3184
  -->
2693
3185
  <div class="content-table-wrapper">
2694
3186
  <DataTable
@@ -2701,7 +3193,6 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2701
3193
  loading={isLoading}
2702
3194
  caption={t(M['content.content_list.table_caption'])}
2703
3195
  rowLabel={(row: ContentListRow) => row.title}
2704
- dataSurface={surfaceOptions}
2705
3196
  empty={tableEmptyState}
2706
3197
  />
2707
3198
  </div>
@@ -2711,7 +3202,9 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2711
3202
  </div>
2712
3203
  {:else if pageRows.length === 0}
2713
3204
  <div class="state-panel empty-state">
2714
- {t(M['content.content_list.empty'])}
3205
+ {lifecycleMode === 'trash'
3206
+ ? t(M['content.content_list.trash_empty'])
3207
+ : t(M['content.content_list.empty'])}
2715
3208
  </div>
2716
3209
  {:else if viewMode === 'detailed'}
2717
3210
  <div class="content-detailed">
@@ -2937,6 +3430,11 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2937
3430
  width: 100%;
2938
3431
  }
2939
3432
 
3433
+ .content-list-wrapper.data-surface-highlighted {
3434
+ outline: 2px solid var(--smrt-color-primary);
3435
+ outline-offset: 0.25rem;
3436
+ }
3437
+
2940
3438
  .content-controls {
2941
3439
  display: flex;
2942
3440
  justify-content: space-between;