@happyvertical/smrt-content 0.43.9 → 0.44.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 (145) hide show
  1. package/agents/content-list.md +69 -0
  2. package/dist/chunks/content-corrections-BxfA3tW1.js +107 -0
  3. package/dist/chunks/content-corrections-BxfA3tW1.js.map +1 -0
  4. package/dist/chunks/content-query-B4cHfrig.js +4092 -0
  5. package/dist/chunks/content-query-B4cHfrig.js.map +1 -0
  6. package/dist/chunks/content-reviews-BWrYEQc6.js +124 -0
  7. package/dist/chunks/content-reviews-BWrYEQc6.js.map +1 -0
  8. package/dist/chunks/content-versions-OEYQVrUf.js +284 -0
  9. package/dist/chunks/content-versions-OEYQVrUf.js.map +1 -0
  10. package/dist/chunks/contents-BrGxWH3s.js +612 -0
  11. package/dist/chunks/contents-BrGxWH3s.js.map +1 -0
  12. package/dist/chunks/rolldown-runtime-D7D4PA-g.js +13 -0
  13. package/dist/content-chat-handlers.d.ts +1 -1
  14. package/dist/content-chat-handlers.d.ts.map +1 -1
  15. package/dist/content-governance.d.ts +2 -0
  16. package/dist/content-governance.d.ts.map +1 -1
  17. package/dist/content.d.ts +2 -2
  18. package/dist/content.d.ts.map +1 -1
  19. package/dist/index.js +142 -4717
  20. package/dist/index.js.map +1 -1
  21. package/dist/manifest.json +142 -5
  22. package/dist/server/content-list-actions.d.ts +123 -0
  23. package/dist/server/content-list-actions.d.ts.map +1 -0
  24. package/dist/server.d.ts +6 -0
  25. package/dist/server.d.ts.map +1 -0
  26. package/dist/server.js +874 -0
  27. package/dist/server.js.map +1 -0
  28. package/dist/smrt-knowledge.json +96 -9
  29. package/dist/svelte/components/ContentAgentChat.svelte +10 -0
  30. package/dist/svelte/components/ContentAgentChat.svelte.d.ts +10 -0
  31. package/dist/svelte/components/ContentAgentChat.svelte.d.ts.map +1 -1
  32. package/dist/svelte/components/ContentBodyEditor.svelte +9 -0
  33. package/dist/svelte/components/ContentBodyEditor.svelte.d.ts +9 -0
  34. package/dist/svelte/components/ContentBodyEditor.svelte.d.ts.map +1 -1
  35. package/dist/svelte/components/ContentBodyRenderer.svelte +3 -0
  36. package/dist/svelte/components/ContentBodyRenderer.svelte.d.ts +3 -0
  37. package/dist/svelte/components/ContentBodyRenderer.svelte.d.ts.map +1 -1
  38. package/dist/svelte/components/ContentClaimAuditTool.svelte +3 -0
  39. package/dist/svelte/components/ContentClaimAuditTool.svelte.d.ts +3 -0
  40. package/dist/svelte/components/ContentClaimAuditTool.svelte.d.ts.map +1 -1
  41. package/dist/svelte/components/ContentContributionForm.svelte +7 -0
  42. package/dist/svelte/components/ContentContributionForm.svelte.d.ts +7 -0
  43. package/dist/svelte/components/ContentContributionForm.svelte.d.ts.map +1 -1
  44. package/dist/svelte/components/ContentContributionInbox.svelte +8 -0
  45. package/dist/svelte/components/ContentContributionInbox.svelte.d.ts +8 -0
  46. package/dist/svelte/components/ContentContributionInbox.svelte.d.ts.map +1 -1
  47. package/dist/svelte/components/ContentContributionPortal.svelte +5 -0
  48. package/dist/svelte/components/ContentContributionPortal.svelte.d.ts +5 -0
  49. package/dist/svelte/components/ContentContributionPortal.svelte.d.ts.map +1 -1
  50. package/dist/svelte/components/ContentContributionTypeManager.svelte +3 -0
  51. package/dist/svelte/components/ContentContributionTypeManager.svelte.d.ts +3 -0
  52. package/dist/svelte/components/ContentContributionTypeManager.svelte.d.ts.map +1 -1
  53. package/dist/svelte/components/ContentContributorManager.svelte +3 -0
  54. package/dist/svelte/components/ContentContributorManager.svelte.d.ts +3 -0
  55. package/dist/svelte/components/ContentContributorManager.svelte.d.ts.map +1 -1
  56. package/dist/svelte/components/ContentCorrectionsTool.svelte +4 -0
  57. package/dist/svelte/components/ContentCorrectionsTool.svelte.d.ts +4 -0
  58. package/dist/svelte/components/ContentCorrectionsTool.svelte.d.ts.map +1 -1
  59. package/dist/svelte/components/ContentEditor.svelte +16 -0
  60. package/dist/svelte/components/ContentEditor.svelte.d.ts +16 -0
  61. package/dist/svelte/components/ContentEditor.svelte.d.ts.map +1 -1
  62. package/dist/svelte/components/ContentGovernanceAssignmentEditor.svelte +4 -0
  63. package/dist/svelte/components/ContentGovernanceAssignmentEditor.svelte.d.ts +4 -0
  64. package/dist/svelte/components/ContentGovernanceAssignmentEditor.svelte.d.ts.map +1 -1
  65. package/dist/svelte/components/ContentGovernanceManager.svelte +3 -0
  66. package/dist/svelte/components/ContentGovernanceManager.svelte.d.ts +3 -0
  67. package/dist/svelte/components/ContentGovernanceManager.svelte.d.ts.map +1 -1
  68. package/dist/svelte/components/ContentGovernancePanel.svelte +16 -0
  69. package/dist/svelte/components/ContentGovernancePanel.svelte.d.ts +16 -0
  70. package/dist/svelte/components/ContentGovernancePanel.svelte.d.ts.map +1 -1
  71. package/dist/svelte/components/ContentGovernancePolicyEditor.svelte +3 -0
  72. package/dist/svelte/components/ContentGovernancePolicyEditor.svelte.d.ts +3 -0
  73. package/dist/svelte/components/ContentGovernancePolicyEditor.svelte.d.ts.map +1 -1
  74. package/dist/svelte/components/ContentGovernanceProfileEditor.svelte +4 -0
  75. package/dist/svelte/components/ContentGovernanceProfileEditor.svelte.d.ts +4 -0
  76. package/dist/svelte/components/ContentGovernanceProfileEditor.svelte.d.ts.map +1 -1
  77. package/dist/svelte/components/ContentGovernanceTool.svelte +5 -0
  78. package/dist/svelte/components/ContentGovernanceTool.svelte.d.ts +5 -0
  79. package/dist/svelte/components/ContentGovernanceTool.svelte.d.ts.map +1 -1
  80. package/dist/svelte/components/ContentImageBrowser.svelte +10 -0
  81. package/dist/svelte/components/ContentImageBrowser.svelte.d.ts +10 -0
  82. package/dist/svelte/components/ContentImageBrowser.svelte.d.ts.map +1 -1
  83. package/dist/svelte/components/ContentImageChooser.svelte +4 -0
  84. package/dist/svelte/components/ContentImageChooser.svelte.d.ts +4 -0
  85. package/dist/svelte/components/ContentImageChooser.svelte.d.ts.map +1 -1
  86. package/dist/svelte/components/ContentList.svelte +1036 -24
  87. package/dist/svelte/components/ContentList.svelte.d.ts +11 -0
  88. package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
  89. package/dist/svelte/components/ContentMetadataFields.svelte +2 -0
  90. package/dist/svelte/components/ContentMetadataFields.svelte.d.ts +2 -0
  91. package/dist/svelte/components/ContentMetadataFields.svelte.d.ts.map +1 -1
  92. package/dist/svelte/components/ContentReferencesPanel.svelte +3 -0
  93. package/dist/svelte/components/ContentReferencesPanel.svelte.d.ts +3 -0
  94. package/dist/svelte/components/ContentReferencesPanel.svelte.d.ts.map +1 -1
  95. package/dist/svelte/components/ContentReviewStatusTray.svelte +5 -0
  96. package/dist/svelte/components/ContentReviewStatusTray.svelte.d.ts +5 -0
  97. package/dist/svelte/components/ContentReviewStatusTray.svelte.d.ts.map +1 -1
  98. package/dist/svelte/components/ContentStatusFields.svelte +2 -0
  99. package/dist/svelte/components/ContentStatusFields.svelte.d.ts +2 -0
  100. package/dist/svelte/components/ContentStatusFields.svelte.d.ts.map +1 -1
  101. package/dist/svelte/components/ContentTitleField.svelte +4 -0
  102. package/dist/svelte/components/ContentTitleField.svelte.d.ts +4 -0
  103. package/dist/svelte/components/ContentTitleField.svelte.d.ts.map +1 -1
  104. package/dist/svelte/components/ContentTransparencyReport.svelte +3 -0
  105. package/dist/svelte/components/ContentTransparencyReport.svelte.d.ts +3 -0
  106. package/dist/svelte/components/ContentTransparencyReport.svelte.d.ts.map +1 -1
  107. package/dist/svelte/components/ContentTransparencyTool.svelte +3 -0
  108. package/dist/svelte/components/ContentTransparencyTool.svelte.d.ts +3 -0
  109. package/dist/svelte/components/ContentTransparencyTool.svelte.d.ts.map +1 -1
  110. package/dist/svelte/components/ContentVersionsTool.svelte +3 -0
  111. package/dist/svelte/components/ContentVersionsTool.svelte.d.ts +3 -0
  112. package/dist/svelte/components/ContentVersionsTool.svelte.d.ts.map +1 -1
  113. package/dist/svelte/components/GovernedContentEditor.svelte +20 -0
  114. package/dist/svelte/components/GovernedContentEditor.svelte.d.ts +20 -0
  115. package/dist/svelte/components/GovernedContentEditor.svelte.d.ts.map +1 -1
  116. package/dist/svelte/content-list-controller.d.ts +3 -1
  117. package/dist/svelte/content-list-controller.d.ts.map +1 -1
  118. package/dist/svelte/content-list-controller.js +15 -1
  119. package/dist/svelte/content-list-workflows.d.ts +87 -0
  120. package/dist/svelte/content-list-workflows.d.ts.map +1 -0
  121. package/dist/svelte/content-list-workflows.js +199 -0
  122. package/dist/svelte/i18n.contribution.d.ts +109 -0
  123. package/dist/svelte/i18n.contribution.d.ts.map +1 -1
  124. package/dist/svelte/i18n.contribution.js +109 -0
  125. package/dist/svelte/index.d.ts +1 -0
  126. package/dist/svelte/index.d.ts.map +1 -1
  127. package/dist/svelte/index.js +2 -0
  128. package/dist/svelte/routes/ContentContributionsRoute.svelte +4 -0
  129. package/dist/svelte/routes/ContentContributionsRoute.svelte.d.ts +4 -0
  130. package/dist/svelte/routes/ContentContributionsRoute.svelte.d.ts.map +1 -1
  131. package/dist/svelte/routes/ContentFactsRoute.svelte +5 -0
  132. package/dist/svelte/routes/ContentFactsRoute.svelte.d.ts +5 -0
  133. package/dist/svelte/routes/ContentFactsRoute.svelte.d.ts.map +1 -1
  134. package/dist/svelte/routes/ContentGovernanceRoute.svelte +4 -0
  135. package/dist/svelte/routes/ContentGovernanceRoute.svelte.d.ts +4 -0
  136. package/dist/svelte/routes/ContentGovernanceRoute.svelte.d.ts.map +1 -1
  137. package/dist/svelte/routes/ContentWorkspaceRoute.svelte +5 -0
  138. package/dist/svelte/routes/ContentWorkspaceRoute.svelte.d.ts +5 -0
  139. package/dist/svelte/routes/ContentWorkspaceRoute.svelte.d.ts.map +1 -1
  140. package/dist/svelte/routes/PublishedArticleRoute.svelte +3 -0
  141. package/dist/svelte/routes/PublishedArticleRoute.svelte.d.ts +3 -0
  142. package/dist/svelte/routes/PublishedArticleRoute.svelte.d.ts.map +1 -1
  143. package/package.json +20 -14
  144. package/dist/chunks/content-transparency-Bh6axaZj.js +0 -606
  145. package/dist/chunks/content-transparency-Bh6axaZj.js.map +0 -1
@@ -33,6 +33,8 @@ export interface ContentListUrlStateBinding {
33
33
  <script lang="ts">
34
34
  import {
35
35
  DataTable,
36
+ type DataSurfaceActionResult,
37
+ type DataSurfaceIdentity,
36
38
  type DataTableColumn,
37
39
  type DataTableViewState,
38
40
  } from '@happyvertical/smrt-ui/data';
@@ -74,6 +76,7 @@ import {
74
76
  } from '../content-list-controller.js';
75
77
  import {
76
78
  CONTENT_LIST_QUERY_DEFAULT_PAGE_SIZE,
79
+ type ContentListDataQueryRequest,
77
80
  type ContentListQueryDrop,
78
81
  type ContentListQueryDropReason,
79
82
  type ContentListQueryNotices,
@@ -94,6 +97,7 @@ import {
94
97
  type ContentListJob,
95
98
  type ContentListJobBinding,
96
99
  type ContentListJobSnapshot,
100
+ type ContentListJobTarget,
97
101
  } from '../content-list-runtime.js';
98
102
  import {
99
103
  type ContentListSavedView,
@@ -101,6 +105,15 @@ import {
101
105
  restoreContentListSavedView,
102
106
  toContentListSavedViewInput,
103
107
  } from '../content-list-saved-views.js';
108
+ import {
109
+ type ContentListWorkflowBinding,
110
+ ContentListWorkflowError,
111
+ type ContentListWorkflowErrorReason,
112
+ type ContentListWorkflowId,
113
+ type ContentListWorkflowRequest,
114
+ CONTENT_LIST_WORKFLOW_OPTIONS,
115
+ contentListWorkflowOutcomes,
116
+ } from '../content-list-workflows.js';
104
117
  import {
105
118
  applyContentListViewState,
106
119
  type ContentListStateDrop,
@@ -114,10 +127,170 @@ import ImageThumbnail from './ImageThumbnail.svelte';
114
127
 
115
128
  const { t } = useI18n();
116
129
 
130
+ const WORKFLOW_LABEL_MESSAGES: Record<
131
+ ContentListWorkflowId,
132
+ keyof typeof M
133
+ > = {
134
+ 'move-to-trash': M['content.content_list.workflow_move_to_trash'],
135
+ 'mark-draft': M['content.content_list.workflow_mark_draft'],
136
+ 'submit-review': M['content.content_list.workflow_submit_review'],
137
+ publish: M['content.content_list.workflow_publish'],
138
+ archive: M['content.content_list.workflow_archive'],
139
+ restore: M['content.content_list.workflow_restore'],
140
+ 'automated-review': M['content.content_list.workflow_automated_review'],
141
+ 'format-body': M['content.content_list.workflow_format_body'],
142
+ categorize: M['content.content_list.workflow_categorize'],
143
+ optimize: M['content.content_list.workflow_optimize'],
144
+ };
145
+
146
+ const WORKFLOW_CONSEQUENCE_MESSAGES: Record<string, keyof typeof M> = {
147
+ 'Content leaves active views and becomes restorable from trash.':
148
+ M['content.content_list.workflow_consequence_trash'],
149
+ 'Published content is no longer public.':
150
+ M['content.content_list.workflow_consequence_unpublished'],
151
+ 'Content enters the review queue.':
152
+ M['content.content_list.workflow_consequence_review_queue'],
153
+ 'Content becomes public and may create a publication snapshot.':
154
+ M['content.content_list.workflow_consequence_published'],
155
+ 'Content leaves trash.':
156
+ M['content.content_list.workflow_consequence_leave_trash'],
157
+ 'Publishing makes content public.':
158
+ M['content.content_list.workflow_consequence_restore_published'],
159
+ 'Creates a review result and may create a review version.':
160
+ M['content.content_list.workflow_consequence_review_result'],
161
+ 'The persisted content body may change.':
162
+ M['content.content_list.workflow_consequence_body_change'],
163
+ 'The content category changes.':
164
+ M['content.content_list.workflow_consequence_category_change'],
165
+ 'Content fields may be revised or completed.':
166
+ M['content.content_list.workflow_consequence_fields_change'],
167
+ };
168
+
169
+ const WORKFLOW_REASON_MESSAGES: Record<string, keyof typeof M> = {
170
+ not_deleted: M['content.content_list.workflow_reason_not_deleted'],
171
+ publish_readiness_failed:
172
+ M['content.content_list.workflow_reason_publish_readiness_failed'],
173
+ deleted: M['content.content_list.workflow_reason_deleted'],
174
+ already_draft: M['content.content_list.workflow_reason_already_draft'],
175
+ requires_draft: M['content.content_list.workflow_reason_requires_draft'],
176
+ invalid_status: M['content.content_list.workflow_reason_invalid_status'],
177
+ already_archived: M['content.content_list.workflow_reason_already_archived'],
178
+ governance_unavailable:
179
+ M['content.content_list.workflow_reason_governance_unavailable'],
180
+ review_policy_unavailable:
181
+ M['content.content_list.workflow_reason_review_policy_unavailable'],
182
+ review_kind_mismatch:
183
+ M['content.content_list.workflow_reason_review_kind_mismatch'],
184
+ handler_unavailable:
185
+ M['content.content_list.workflow_reason_handler_unavailable'],
186
+ requires_draft_or_review:
187
+ M['content.content_list.workflow_reason_requires_draft_or_review'],
188
+ not_found_or_denied:
189
+ M['content.content_list.workflow_reason_not_found_or_denied'],
190
+ row_revision_drifted:
191
+ M['content.content_list.workflow_reason_row_revision_drifted'],
192
+ permission_denied:
193
+ M['content.content_list.workflow_reason_permission_denied'],
194
+ not_found: M['content.content_list.workflow_reason_not_found'],
195
+ stale_query_fingerprint:
196
+ M['content.content_list.workflow_reason_stale_query_fingerprint'],
197
+ matching_count_drifted:
198
+ M['content.content_list.workflow_reason_matching_count_drifted'],
199
+ limit_exceeded: M['content.content_list.workflow_reason_limit_exceeded'],
200
+ invalid_request: M['content.content_list.workflow_reason_invalid_request'],
201
+ invalid_payload: M['content.content_list.workflow_reason_invalid_payload'],
202
+ confirmation_required:
203
+ M['content.content_list.workflow_reason_confirmation_required'],
204
+ stale_preview: M['content.content_list.workflow_reason_stale_preview'],
205
+ denied: M['content.content_list.workflow_reason_denied'],
206
+ unknown_permission:
207
+ M['content.content_list.workflow_reason_unknown_permission'],
208
+ execution_failed:
209
+ M['content.content_list.workflow_reason_execution_failed'],
210
+ idempotency_conflict:
211
+ M['content.content_list.workflow_reason_idempotency_conflict'],
212
+ };
213
+
214
+ const WORKFLOW_TRANSPORT_MESSAGES: Record<
215
+ ContentListWorkflowErrorReason,
216
+ keyof typeof M
217
+ > = {
218
+ network_failure: M['content.content_list.workflow_transport_network_failure'],
219
+ invalid_json: M['content.content_list.workflow_transport_invalid_json'],
220
+ http_error: M['content.content_list.workflow_transport_http_error'],
221
+ invalid_result: M['content.content_list.workflow_transport_invalid_result'],
222
+ invalid_job_status:
223
+ M['content.content_list.workflow_transport_invalid_job_status'],
224
+ invalid_job_result:
225
+ M['content.content_list.workflow_transport_invalid_job_result'],
226
+ };
227
+
228
+ function workflowLabel(id: ContentListWorkflowId): string {
229
+ return t(WORKFLOW_LABEL_MESSAGES[id]);
230
+ }
231
+
232
+ function workflowConsequenceLabel(consequence: string): string {
233
+ return t(
234
+ WORKFLOW_CONSEQUENCE_MESSAGES[consequence] ??
235
+ M['content.content_list.workflow_consequence_unknown'],
236
+ );
237
+ }
238
+
239
+ function workflowReasonLabel(
240
+ reason: string | undefined,
241
+ fallback: keyof typeof M,
242
+ params?: Record<string, string | number>,
243
+ ): string {
244
+ return t((reason && WORKFLOW_REASON_MESSAGES[reason]) ?? fallback, params);
245
+ }
246
+
247
+ function workflowExceptionMessage(
248
+ error: unknown,
249
+ fallback: keyof typeof M,
250
+ ): string {
251
+ if (error instanceof ContentListWorkflowError) {
252
+ return t(WORKFLOW_TRANSPORT_MESSAGES[error.reason], {
253
+ status: error.status ?? '',
254
+ });
255
+ }
256
+ return t(fallback);
257
+ }
258
+
259
+ function workflowScopeLabel(scope: string): string {
260
+ switch (scope) {
261
+ case 'explicit-ids':
262
+ return t(M['content.content_list.workflow_scope_explicit_ids']);
263
+ case 'current-page':
264
+ return t(M['content.content_list.workflow_scope_current_page']);
265
+ case 'all-matching':
266
+ return t(M['content.content_list.workflow_scope_all_matching']);
267
+ default:
268
+ return scope;
269
+ }
270
+ }
271
+
272
+ function workflowJobStatusLabel(status: string): string {
273
+ switch (status) {
274
+ case 'queued':
275
+ return t(M['content.content_list.job_queued']);
276
+ case 'running':
277
+ return t(M['content.content_list.job_running']);
278
+ case 'succeeded':
279
+ return t(M['content.content_list.job_succeeded']);
280
+ case 'failed':
281
+ return t(M['content.content_list.job_failed']);
282
+ case 'cancelled':
283
+ return t(M['content.content_list.workflow_status_cancelled']);
284
+ default:
285
+ return status;
286
+ }
287
+ }
288
+
117
289
  /** One reported refusal, from a restore or from the query translation. */
118
290
  type ContentListDropNotice = ContentListStateDrop | ContentListQueryDrop;
119
291
 
120
292
  interface Props {
293
+ /** Base URL for content API requests. */
121
294
  apiBaseUrl?: string;
122
295
  /**
123
296
  * Client-side rows. Ignored when `query` is supplied — the server then owns
@@ -125,12 +298,19 @@ interface Props {
125
298
  * source of truth.
126
299
  */
127
300
  contents?: ContentData[];
301
+ /** Filter content by this type; no filtering applied when omitted. */
128
302
  type?: string;
303
+ /** Initial view layout mode for the content list. */
129
304
  defaultViewMode?: ContentListViewMode;
305
+ /** Invoked when the user requests to edit a content item. */
130
306
  onEdit: (content: ContentData) => void;
307
+ /** Invoked when the user requests to delete a content item. */
131
308
  onDelete: (content: ContentData) => void;
309
+ /** Invoked when the user requests to create new content. */
132
310
  onAdd: () => void;
311
+ /** Optional UI controls rendered above the content list. */
133
312
  controls?: Snippet;
313
+ /** Returns a URL to view the published content; null hides the view link. */
134
314
  getViewHref?: (content: ContentData) => string | null;
135
315
  /** Announced uniformly by every presentation; #2455 extends it. */
136
316
  loading?: boolean;
@@ -153,6 +333,8 @@ interface Props {
153
333
  savedViews?: ContentListSavedViewStore;
154
334
  /** Shared background-workflow state. The same binding guards submissions. */
155
335
  jobs?: ContentListJobBinding;
336
+ /** Opt-in, authenticated preview/apply client for bulk workflows (#2453). */
337
+ workflows?: ContentListWorkflowBinding;
156
338
  }
157
339
 
158
340
  let {
@@ -173,6 +355,7 @@ let {
173
355
  urlState = undefined,
174
356
  savedViews = undefined,
175
357
  jobs = undefined,
358
+ workflows = undefined,
176
359
  }: Props = $props();
177
360
 
178
361
  const initialQuery = untrack(() => query);
@@ -391,6 +574,34 @@ $effect(() => {
391
574
  }
392
575
  });
393
576
  });
577
+ let settledWorkflowQuery = $state<ContentListDataQueryRequest | null>(null);
578
+ let settledWorkflowFingerprint = $state<string | null>(null);
579
+ let settledWorkflowRevision = $state<string | null>(null);
580
+ let selectedWorkflow = $state<ContentListWorkflowId>('mark-draft');
581
+ let workflowCategory = $state('');
582
+ let workflowRestoreStatus = $state<'draft' | 'review' | 'published'>('draft');
583
+ let workflowFormat = $state<'markdown' | 'html'>('markdown');
584
+ let workflowReviewKind = $state('');
585
+ let workflowPolicyKey = $state('');
586
+ let workflowInstructions = $state('');
587
+ let workflowPending = $state(false);
588
+ let pageHeaderSelectionOnly = $state(false);
589
+ let workflowPreview = $state<import('@happyvertical/smrt-ui/data').DataSurfaceActionResult | null>(null);
590
+ let workflowResult = $state<import('@happyvertical/smrt-ui/data').DataSurfaceActionResult | null>(null);
591
+ let workflowError = $state<string | null>(null);
592
+ let workflowConfirmOpen = $state(false);
593
+ let workflowIntentAtPreview = '';
594
+ let workflowIdempotencyKey = '';
595
+ let workflowQueuedJobs = $state<
596
+ Array<{
597
+ intent: string;
598
+ jobId: string;
599
+ actionId: ContentListWorkflowId;
600
+ requestId: string;
601
+ identity: DataSurfaceIdentity;
602
+ target: ContentListJobTarget;
603
+ }>
604
+ >([]);
394
605
 
395
606
  $effect(() =>
396
607
  controller.subscribe((transition) => {
@@ -637,6 +848,36 @@ function refreshQuery(): void {
637
848
  })
638
849
  .catch(() => undefined);
639
850
  }
851
+
852
+ function settleWorkflowQueryResult(
853
+ result: unknown,
854
+ request: ContentListDataQueryRequest,
855
+ signature: string,
856
+ ): void {
857
+ settledSignature = signature;
858
+ resultNotices = readContentListQueryNotices(result);
859
+ if (result !== null && typeof result === 'object' && !Array.isArray(result)) {
860
+ const envelope = result as Record<string, unknown>;
861
+ settledWorkflowQuery = request;
862
+ settledWorkflowFingerprint =
863
+ typeof envelope.queryFingerprint === 'string'
864
+ ? envelope.queryFingerprint
865
+ : null;
866
+ const freshness = envelope.freshness;
867
+ settledWorkflowRevision =
868
+ freshness !== null &&
869
+ typeof freshness === 'object' &&
870
+ !Array.isArray(freshness) &&
871
+ typeof (freshness as Record<string, unknown>).asOf === 'string'
872
+ ? String((freshness as Record<string, unknown>).asOf)
873
+ : null;
874
+ return;
875
+ }
876
+ settledWorkflowQuery = null;
877
+ settledWorkflowFingerprint = null;
878
+ settledWorkflowRevision = null;
879
+ }
880
+
640
881
  /**
641
882
  * A retry re-reads the same query, so its answer replaces the rendered rows —
642
883
  * and therefore has to replace the completeness flags that describe them too.
@@ -648,13 +889,20 @@ function refreshQuery(): void {
648
889
  function retryQuery(): void {
649
890
  if (!queryBinding) return;
650
891
  const signature = executedSignature;
892
+ const request = executedWorkflowQuery;
651
893
  void queryBinding
652
894
  .retry()
653
895
  .then((result) => {
654
896
  // `retry()` resolves undefined when there is no request to repeat, and a
655
897
  // newer query may have superseded this one while it was in flight.
656
- if (result === undefined || executedSignature !== signature) return;
657
- resultNotices = readContentListQueryNotices(result);
898
+ if (
899
+ result === undefined ||
900
+ request === null ||
901
+ executedSignature !== signature ||
902
+ signature === undefined
903
+ )
904
+ return;
905
+ settleWorkflowQueryResult(result, request, signature);
658
906
  })
659
907
  .catch(() => undefined);
660
908
  }
@@ -680,9 +928,9 @@ const querySignature = $derived(
680
928
  );
681
929
 
682
930
  let executedSignature: string | undefined;
931
+ let executedWorkflowQuery: ContentListDataQueryRequest | null = null;
683
932
  let publishedUrlSignature: string | undefined;
684
933
 
685
-
686
934
  $effect(() => {
687
935
  const signature = querySignature;
688
936
  untrack(() => {
@@ -727,15 +975,16 @@ $effect(() => {
727
975
  // unhandled one. The resolved envelope carries the server's completeness
728
976
  // flags, which the binding itself does not expose.
729
977
  activeQueryKey = contentListQueryRequestKey(translated.request);
978
+ executedWorkflowQuery = translated.request;
730
979
  void queryBinding
731
980
  .execute(translated.request)
732
981
  .then((result) => {
733
982
  // A newer query may have superseded this one while it was in flight.
734
983
  if (executedSignature !== signature) return;
735
984
  // The binding's rows and total now describe THIS query, so the page
736
- // may be clamped against them.
737
- settledSignature = signature;
738
- resultNotices = readContentListQueryNotices(result);
985
+ // may be clamped against them and workflows may bind to the exact
986
+ // settled request.
987
+ settleWorkflowQueryResult(result, translated.request, signature);
739
988
  })
740
989
  .catch(() => undefined);
741
990
  }
@@ -833,15 +1082,48 @@ const identifiedRowKeys = $derived(
833
1082
  const selectablePageRowIds = $derived(
834
1083
  selectableContentListRowIds(pageRows.filter((row) => !rowPending(row))),
835
1084
  );
1085
+ const allMatchingSelected = $derived(tableState.selection.scope === 'allMatching');
836
1086
  const allPageSelected = $derived(
837
- selectablePageRowIds.length > 0 &&
838
- selectablePageRowIds.every((rowId) => selectedRowKeys.has(String(rowId))),
1087
+ allMatchingSelected ||
1088
+ (selectablePageRowIds.length > 0 &&
1089
+ selectablePageRowIds.every((rowId) => selectedRowKeys.has(String(rowId)))),
839
1090
  );
840
1091
  const somePageSelected = $derived(
841
1092
  !allPageSelected &&
842
1093
  selectablePageRowIds.some((rowId) => selectedRowKeys.has(String(rowId))),
843
1094
  );
844
- const selectedCount = $derived(tableState.selectedRowIds.length);
1095
+ const selectedCount = $derived(
1096
+ tableState.selection.scope === 'allMatching'
1097
+ ? tableState.selection.expectedCount
1098
+ : tableState.selectedRowIds.length,
1099
+ );
1100
+ const exactMatchingCount = $derived(
1101
+ settledSignature === querySignature
1102
+ ? contentListQueryExactTotal(queryBinding?.total)
1103
+ : undefined,
1104
+ );
1105
+ const canSelectAllMatching = $derived(
1106
+ Boolean(
1107
+ workflows &&
1108
+ serverBacked &&
1109
+ settledWorkflowQuery &&
1110
+ settledWorkflowFingerprint &&
1111
+ settledWorkflowRevision &&
1112
+ exactMatchingCount !== undefined &&
1113
+ exactMatchingCount > 0 &&
1114
+ exactMatchingCount <= (workflows?.maxSelectionSize ?? 200),
1115
+ ),
1116
+ );
1117
+ const workflowPayloadValid = $derived(
1118
+ selectedWorkflow !== 'categorize' || workflowCategory.trim().length > 0,
1119
+ );
1120
+ const workflowDuplicateQueued = $derived(
1121
+ workflowQueuedJobs.some((job) => job.intent === workflowIntentSignature()),
1122
+ );
1123
+ const workflowQueuedJob = $derived(
1124
+ workflowQueuedJobs.find((job) => job.intent === workflowIntentSignature()) ??
1125
+ workflowQueuedJobs[0],
1126
+ );
845
1127
 
846
1128
  /** Synthetic ids the adapter minted for rows that carry no durable identity. */
847
1129
  const unidentifiedRowKeys = $derived(
@@ -976,7 +1258,22 @@ const surfaceOptions = $derived(
976
1258
  registry: dataSurface.registry,
977
1259
  descriptor:
978
1260
  dataSurface.descriptor ??
979
- buildContentListSurfaceDescriptor({ columnLabels }),
1261
+ (() => {
1262
+ const descriptor = buildContentListSurfaceDescriptor({
1263
+ columnLabels,
1264
+ includeWorkflows: workflows !== undefined,
1265
+ });
1266
+ return {
1267
+ ...descriptor,
1268
+ identity: workflows?.identity ?? descriptor.identity,
1269
+ limits: {
1270
+ ...descriptor.limits,
1271
+ ...(workflows?.maxSelectionSize === undefined
1272
+ ? {}
1273
+ : { maxSelectionSize: workflows.maxSelectionSize }),
1274
+ },
1275
+ };
1276
+ })(),
980
1277
  }
981
1278
  : undefined,
982
1279
  );
@@ -1154,14 +1451,19 @@ async function deleteSelectedView() {
1154
1451
  }
1155
1452
 
1156
1453
  function isSelected(row: ContentListRow): boolean {
1157
- return selectedRowKeys.has(String(row.id));
1454
+ return allMatchingSelected || selectedRowKeys.has(String(row.id));
1158
1455
  }
1159
1456
 
1160
1457
  function rowPending(row: ContentListRow): boolean {
1161
1458
  return (
1162
1459
  jobSnapshot.pendingRowIds.has(String(row.id)) ||
1163
1460
  (activeQueryKey !== undefined &&
1164
- jobSnapshot.pendingQueryKeys.has(activeQueryKey))
1461
+ jobSnapshot.pendingQueryKeys.has(activeQueryKey)) ||
1462
+ workflowQueuedJobs.some((job) =>
1463
+ job.target.kind === 'query'
1464
+ ? activeQueryKey !== undefined && job.target.queryKey === activeQueryKey
1465
+ : job.target.rowIds.includes(String(row.id)),
1466
+ )
1165
1467
  );
1166
1468
  }
1167
1469
 
@@ -1185,29 +1487,558 @@ function formattedLastUpdated(value: number): string {
1185
1487
  }
1186
1488
 
1187
1489
  function toggleRow(row: ContentListRow) {
1188
- if (!row.identified || rowPending(row)) return;
1490
+ if (
1491
+ workflowPending ||
1492
+ !row.identified ||
1493
+ rowPending(row) ||
1494
+ allMatchingSelected
1495
+ )
1496
+ return;
1497
+ pageHeaderSelectionOnly = false;
1189
1498
  controller.dispatch({ type: 'toggleRowSelection', rowId: row.id });
1190
1499
  }
1191
1500
 
1192
1501
  function togglePageSelection() {
1193
- const remaining = tableState.selectedRowIds.filter(
1194
- (rowId) =>
1195
- !selectablePageRowIds.some(
1196
- (pageRowId) => String(pageRowId) === String(rowId),
1502
+ if (workflowPending) return;
1503
+ if (allMatchingSelected) {
1504
+ clearSelection();
1505
+ return;
1506
+ }
1507
+ const rowKey = (rowId: string | number) =>
1508
+ `${typeof rowId}:${String(rowId)}`;
1509
+ const pageKeys = new Set(selectablePageRowIds.map(rowKey));
1510
+ if (allPageSelected) {
1511
+ pageHeaderSelectionOnly = false;
1512
+ controller.dispatch({
1513
+ type: 'setSelectedRows',
1514
+ rowIds: tableState.selectedRowIds.filter(
1515
+ (rowId) => !pageKeys.has(rowKey(rowId)),
1197
1516
  ),
1517
+ });
1518
+ return;
1519
+ }
1520
+ const selected = new Map(
1521
+ tableState.selectedRowIds.map((rowId) => [rowKey(rowId), rowId]),
1198
1522
  );
1523
+ pageHeaderSelectionOnly =
1524
+ serverBacked && tableState.selectedRowIds.length === 0;
1525
+ for (const rowId of selectablePageRowIds) selected.set(rowKey(rowId), rowId);
1199
1526
  controller.dispatch({
1200
1527
  type: 'setSelectedRows',
1201
- rowIds: allPageSelected
1202
- ? remaining
1203
- : [...remaining, ...selectablePageRowIds],
1528
+ rowIds: [...selected.values()],
1529
+ });
1530
+ }
1531
+
1532
+ function selectAllMatching() {
1533
+ if (
1534
+ workflowPending ||
1535
+ !canSelectAllMatching ||
1536
+ !settledWorkflowFingerprint ||
1537
+ !settledWorkflowRevision ||
1538
+ exactMatchingCount === undefined
1539
+ ) return;
1540
+ pageHeaderSelectionOnly = false;
1541
+ controller.dispatch({
1542
+ type: 'selectAllMatching',
1543
+ queryFingerprint: settledWorkflowFingerprint,
1544
+ queryRevision: settledWorkflowRevision,
1545
+ expectedCount: exactMatchingCount,
1204
1546
  });
1205
1547
  }
1206
1548
 
1207
1549
  function clearSelection() {
1550
+ if (workflowPending) return;
1551
+ pageHeaderSelectionOnly = false;
1208
1552
  controller.dispatch({ type: 'setSelectedRows', rowIds: [] });
1209
1553
  }
1210
1554
 
1555
+ function workflowPayload(): Record<string, string> | undefined {
1556
+ switch (selectedWorkflow) {
1557
+ case 'categorize':
1558
+ return { category: workflowCategory.trim() };
1559
+ case 'restore':
1560
+ return { status: workflowRestoreStatus };
1561
+ case 'format-body':
1562
+ return { format: workflowFormat };
1563
+ case 'automated-review': {
1564
+ const payload: Record<string, string> = {};
1565
+ if (workflowReviewKind.trim()) payload.kind = workflowReviewKind.trim();
1566
+ if (workflowPolicyKey.trim()) payload.policyKey = workflowPolicyKey.trim();
1567
+ return payload;
1568
+ }
1569
+ case 'optimize':
1570
+ return workflowInstructions.trim()
1571
+ ? { instructions: workflowInstructions.trim() }
1572
+ : {};
1573
+ default:
1574
+ return {};
1575
+ }
1576
+ }
1577
+
1578
+ function workflowSelection(): ContentListWorkflowRequest['selection'] | null {
1579
+ const selection = tableState.selection;
1580
+ if (selection.scope === 'allMatching') {
1581
+ return { scope: 'all-matching', queryFingerprint: selection.queryFingerprint };
1582
+ }
1583
+ const currentPageKeys = new Set(pageRows.map((row) => String(row.id)));
1584
+ const selectablePageKeys = new Set(selectablePageRowIds.map(String));
1585
+ const effectiveRowIds =
1586
+ selection.scope === 'page' || pageHeaderSelectionOnly
1587
+ ? selection.rowIds.filter((rowId) => {
1588
+ const key = String(rowId);
1589
+ return !currentPageKeys.has(key) || selectablePageKeys.has(key);
1590
+ })
1591
+ : selection.rowIds;
1592
+ if (
1593
+ serverBacked &&
1594
+ pageHeaderSelectionOnly &&
1595
+ selectablePageRowIds.length === pageRows.length &&
1596
+ effectiveRowIds.length === selectablePageRowIds.length &&
1597
+ selectablePageRowIds.every((rowId) =>
1598
+ effectiveRowIds.some(
1599
+ (selectedId) => String(selectedId) === String(rowId),
1600
+ ),
1601
+ )
1602
+ ) {
1603
+ return { scope: 'current-page' };
1604
+ }
1605
+ if (effectiveRowIds.length === 0) return null;
1606
+ return { scope: 'explicit-ids', rowIds: effectiveRowIds };
1607
+ }
1608
+
1609
+ function workflowJobTarget(
1610
+ request: ContentListWorkflowRequest,
1611
+ ): ContentListJobTarget {
1612
+ if (request.selection.scope === 'all-matching' && activeQueryKey !== undefined)
1613
+ return { kind: 'query', queryKey: activeQueryKey };
1614
+ if (request.selection.scope === 'explicit-ids')
1615
+ return { kind: 'rows', rowIds: request.selection.rowIds.map(String) };
1616
+ return { kind: 'rows', rowIds: selectablePageRowIds.map(String) };
1617
+ }
1618
+
1619
+ function workflowIntentSignature(): string {
1620
+ const selection = workflowSelection();
1621
+ if (selection?.scope === 'explicit-ids') {
1622
+ return JSON.stringify([
1623
+ selectedWorkflow,
1624
+ workflowPayload(),
1625
+ {
1626
+ scope: selection.scope,
1627
+ rowIds: [...selection.rowIds].sort((left, right) => {
1628
+ const leftKey = `${typeof left}:${String(left)}`;
1629
+ const rightKey = `${typeof right}:${String(right)}`;
1630
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
1631
+ }),
1632
+ },
1633
+ ]);
1634
+ }
1635
+ return JSON.stringify([
1636
+ selectedWorkflow,
1637
+ workflowPayload(),
1638
+ tableState.selection,
1639
+ settledWorkflowFingerprint,
1640
+ querySignature,
1641
+ ]);
1642
+ }
1643
+
1644
+ function createWorkflowRequest(
1645
+ phase: 'preview' | 'apply',
1646
+ ): ContentListWorkflowRequest | null {
1647
+ const selection = workflowSelection();
1648
+ if (!selection) return null;
1649
+ const queryRequired = selection.scope !== 'explicit-ids';
1650
+ if (
1651
+ queryRequired &&
1652
+ (!settledWorkflowQuery || settledSignature !== querySignature)
1653
+ ) return null;
1654
+ if (
1655
+ selection.scope === 'all-matching' &&
1656
+ selection.queryFingerprint !== settledWorkflowFingerprint
1657
+ ) return null;
1658
+ return {
1659
+ version: 1,
1660
+ requestId: globalThis.crypto?.randomUUID?.() ?? `content-workflow-${Date.now()}`,
1661
+ identity: workflows?.identity ?? { surfaceId: 'content-list', kind: 'table' },
1662
+ actionId: selectedWorkflow,
1663
+ phase,
1664
+ selection,
1665
+ payload: workflowPayload(),
1666
+ expectedRevision: workflows?.revision ?? 0,
1667
+ target: {
1668
+ ...(queryRequired && settledWorkflowQuery
1669
+ ? { query: settledWorkflowQuery }
1670
+ : {}),
1671
+ expectedCount:
1672
+ selection.scope === 'explicit-ids'
1673
+ ? selection.rowIds.length
1674
+ : selectedCount,
1675
+ },
1676
+ };
1677
+ }
1678
+
1679
+ function workflowIdentityMatches(
1680
+ actual: DataSurfaceIdentity | null | undefined,
1681
+ expected: DataSurfaceIdentity,
1682
+ ): boolean {
1683
+ return (
1684
+ actual !== null &&
1685
+ actual !== undefined &&
1686
+ actual.surfaceId === expected.surfaceId &&
1687
+ actual.kind === expected.kind &&
1688
+ (expected.subject
1689
+ ? actual.subject?.type === expected.subject.type &&
1690
+ actual.subject.id === expected.subject.id
1691
+ : actual.subject === undefined)
1692
+ );
1693
+ }
1694
+
1695
+ function workflowResultMatchesRequest(
1696
+ result: DataSurfaceActionResult,
1697
+ request: ContentListWorkflowRequest,
1698
+ ): boolean {
1699
+ return (
1700
+ result.version === 1 &&
1701
+ result.requestId === request.requestId &&
1702
+ result.phase === request.phase &&
1703
+ result.actionId === request.actionId &&
1704
+ workflowIdentityMatches(result.identity, request.identity)
1705
+ );
1706
+ }
1707
+
1708
+ function workflowResultMessage(result: import('@happyvertical/smrt-ui/data').DataSurfaceActionResult): string {
1709
+ const details = result.details ?? {};
1710
+ const accepted = typeof details.accepted === 'number' ? details.accepted : 0;
1711
+ if (details.background === true) {
1712
+ return t(M['content.content_list.workflow_result_background'], {
1713
+ accepted,
1714
+ });
1715
+ }
1716
+ const skipped = typeof details.skipped === 'number' ? details.skipped : 0;
1717
+ const failed = typeof details.failed === 'number' ? details.failed : 0;
1718
+ return t(M['content.content_list.workflow_result_summary'], {
1719
+ accepted,
1720
+ skipped,
1721
+ failed,
1722
+ });
1723
+ }
1724
+
1725
+ function applyWorkflowSelectionOutcomes(
1726
+ result: import('@happyvertical/smrt-ui/data').DataSurfaceActionResult,
1727
+ ): boolean {
1728
+ const outcomes = contentListWorkflowOutcomes(result);
1729
+ const rowKey = (rowId: string | number) =>
1730
+ `${typeof rowId}:${String(rowId)}`;
1731
+ if (tableState.selection.scope === 'allMatching') {
1732
+ const uniqueOutcomes = new Set(outcomes.map((outcome) => rowKey(outcome.rowId)));
1733
+ if (
1734
+ outcomes.length !== tableState.selection.expectedCount ||
1735
+ uniqueOutcomes.size !== outcomes.length
1736
+ ) {
1737
+ workflowError = t(
1738
+ M['content.content_list.workflow_incomplete_outcomes'],
1739
+ );
1740
+ return false;
1741
+ }
1742
+ controller.dispatch({
1743
+ type: 'setSelectedRows',
1744
+ rowIds: outcomes
1745
+ .filter((outcome) => outcome.status !== 'accepted')
1746
+ .map((outcome) => outcome.rowId),
1747
+ });
1748
+ return true;
1749
+ }
1750
+ const accepted = new Set(
1751
+ outcomes
1752
+ .filter((outcome) => outcome.status === 'accepted')
1753
+ .map((outcome) => rowKey(outcome.rowId)),
1754
+ );
1755
+ controller.dispatch({
1756
+ type: 'setSelectedRows',
1757
+ rowIds: tableState.selectedRowIds.filter(
1758
+ (rowId) => !accepted.has(rowKey(rowId)),
1759
+ ),
1760
+ });
1761
+ return true;
1762
+ }
1763
+
1764
+ function workflowPreviewMessage(): string {
1765
+ const details = workflowPreview?.details ?? {};
1766
+ const count = typeof details.count === 'number' ? details.count : selectedCount;
1767
+ const labels = Array.isArray(details.representativeLabels)
1768
+ ? details.representativeLabels.filter((entry): entry is string => typeof entry === 'string')
1769
+ : [];
1770
+ const consequences = Array.isArray(details.consequences)
1771
+ ? details.consequences.filter((entry): entry is string => typeof entry === 'string')
1772
+ : [];
1773
+ const skipped = typeof details.skipped === 'number' ? details.skipped : 0;
1774
+ const resolvedScope =
1775
+ typeof details.resolvedScope === 'string'
1776
+ ? details.resolvedScope
1777
+ : workflowSelection()?.scope;
1778
+ const ineligible = Array.isArray(details.ineligible)
1779
+ ? details.ineligible.flatMap((entry) => {
1780
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
1781
+ return [];
1782
+ const outcome = entry as Record<string, unknown>;
1783
+ if (
1784
+ typeof outcome.rowId !== 'string' &&
1785
+ typeof outcome.rowId !== 'number'
1786
+ )
1787
+ return [];
1788
+ const reason =
1789
+ typeof outcome.reason === 'string'
1790
+ ? ` (${workflowReasonLabel(
1791
+ outcome.reason,
1792
+ M['content.content_list.workflow_reason_unknown'],
1793
+ )})`
1794
+ : '';
1795
+ return [`${String(outcome.rowId)}${reason}`];
1796
+ })
1797
+ : [];
1798
+ return [
1799
+ resolvedScope
1800
+ ? t(M['content.content_list.workflow_preview_scope'], {
1801
+ scope: workflowScopeLabel(resolvedScope),
1802
+ })
1803
+ : '',
1804
+ t(
1805
+ count === 1
1806
+ ? M['content.content_list.workflow_preview_count_one']
1807
+ : M['content.content_list.workflow_preview_count_many'],
1808
+ { count },
1809
+ ),
1810
+ labels.length
1811
+ ? t(M['content.content_list.workflow_preview_examples'], {
1812
+ labels: labels.join(', '),
1813
+ })
1814
+ : '',
1815
+ skipped
1816
+ ? t(M['content.content_list.workflow_preview_ineligible_count'], {
1817
+ count: skipped,
1818
+ })
1819
+ : '',
1820
+ ineligible.length
1821
+ ? t(M['content.content_list.workflow_preview_ineligible'], {
1822
+ items: ineligible.join(', '),
1823
+ })
1824
+ : '',
1825
+ ...consequences.map(workflowConsequenceLabel),
1826
+ ].filter(Boolean).join(' ');
1827
+ }
1828
+
1829
+ async function previewWorkflow() {
1830
+ if (!workflows || workflowPending || workflowDuplicateQueued) return;
1831
+ const request = createWorkflowRequest('preview');
1832
+ if (!request) return;
1833
+ workflowPending = true;
1834
+ workflowError = null;
1835
+ workflowResult = null;
1836
+ workflowIdempotencyKey = '';
1837
+ try {
1838
+ const result = await workflows.client.preview(request);
1839
+ if (!workflowResultMatchesRequest(result, request)) {
1840
+ workflowError = t(M['content.content_list.workflow_preview_mismatch']);
1841
+ return;
1842
+ }
1843
+ if (!result.ok) {
1844
+ workflowError = workflowReasonLabel(
1845
+ result.reason,
1846
+ M['content.content_list.workflow_preview_failed'],
1847
+ );
1848
+ return;
1849
+ }
1850
+ workflowPreview = result;
1851
+ workflowIntentAtPreview = workflowIntentSignature();
1852
+ workflowIdempotencyKey =
1853
+ globalThis.crypto?.randomUUID?.() ?? `content-workflow-apply-${Date.now()}`;
1854
+ workflowConfirmOpen = true;
1855
+ } catch (error) {
1856
+ workflowError = workflowExceptionMessage(
1857
+ error,
1858
+ M['content.content_list.workflow_preview_failed'],
1859
+ );
1860
+ } finally {
1861
+ workflowPending = false;
1862
+ }
1863
+ }
1864
+
1865
+ async function applyWorkflow() {
1866
+ if (!workflows || workflowPending || !workflowPreview?.confirmationToken) return;
1867
+ if (workflowIntentAtPreview !== workflowIntentSignature()) {
1868
+ workflowConfirmOpen = false;
1869
+ workflowPreview = null;
1870
+ workflowIdempotencyKey = '';
1871
+ workflowError = t(M['content.content_list.workflow_selection_changed']);
1872
+ return;
1873
+ }
1874
+ const request = createWorkflowRequest('apply');
1875
+ if (!request) return;
1876
+ request.confirmationToken = workflowPreview.confirmationToken;
1877
+ request.idempotencyKey = workflowIdempotencyKey;
1878
+ const appliedIntent = workflowIntentAtPreview;
1879
+ const appliedTarget = workflowJobTarget(request);
1880
+ workflowPending = true;
1881
+ workflowError = null;
1882
+ try {
1883
+ const result = await workflows.client.apply(request);
1884
+ if (!workflowResultMatchesRequest(result, request)) {
1885
+ workflowError = t(M['content.content_list.workflow_apply_mismatch']);
1886
+ return;
1887
+ }
1888
+ if (appliedIntent !== workflowIntentSignature()) {
1889
+ if (
1890
+ result.ok &&
1891
+ result.details?.background === true &&
1892
+ typeof result.details.jobId === 'string' &&
1893
+ !workflowQueuedJobs.some((job) => job.intent === appliedIntent)
1894
+ ) {
1895
+ workflowQueuedJobs = [
1896
+ ...workflowQueuedJobs,
1897
+ {
1898
+ intent: appliedIntent,
1899
+ jobId: result.details.jobId,
1900
+ actionId: request.actionId,
1901
+ requestId:
1902
+ typeof result.details.jobRequestId === 'string'
1903
+ ? result.details.jobRequestId
1904
+ : request.requestId,
1905
+ identity: request.identity,
1906
+ target: appliedTarget,
1907
+ },
1908
+ ];
1909
+ }
1910
+ workflowError = t(M['content.content_list.workflow_changed_applying']);
1911
+ workflowPreview = null;
1912
+ workflowIdempotencyKey = '';
1913
+ workflowConfirmOpen = false;
1914
+ return;
1915
+ }
1916
+ workflowResult = result;
1917
+ if (!result.ok) {
1918
+ workflowError = workflowReasonLabel(
1919
+ result.reason,
1920
+ M['content.content_list.workflow_failed'],
1921
+ );
1922
+ workflowPreview = null;
1923
+ workflowIdempotencyKey = '';
1924
+ workflowConfirmOpen = false;
1925
+ return;
1926
+ }
1927
+ if (result.details?.background !== true) {
1928
+ applyWorkflowSelectionOutcomes(result);
1929
+ refreshQuery();
1930
+ } else if (
1931
+ typeof result.details.jobId === 'string' &&
1932
+ !workflowQueuedJobs.some((job) => job.intent === workflowIntentAtPreview)
1933
+ ) {
1934
+ workflowQueuedJobs = [
1935
+ ...workflowQueuedJobs,
1936
+ {
1937
+ intent: workflowIntentAtPreview,
1938
+ jobId: result.details.jobId,
1939
+ actionId: request.actionId,
1940
+ requestId:
1941
+ typeof result.details.jobRequestId === 'string'
1942
+ ? result.details.jobRequestId
1943
+ : request.requestId,
1944
+ identity: request.identity,
1945
+ target: appliedTarget,
1946
+ },
1947
+ ];
1948
+ }
1949
+ workflowPreview = null;
1950
+ workflowIdempotencyKey = '';
1951
+ workflowConfirmOpen = false;
1952
+ } catch (error) {
1953
+ workflowError = workflowExceptionMessage(
1954
+ error,
1955
+ M['content.content_list.workflow_failed'],
1956
+ );
1957
+ } finally {
1958
+ workflowPending = false;
1959
+ }
1960
+ }
1961
+
1962
+ async function checkWorkflowJob() {
1963
+ if (!workflows?.client.status || !workflowQueuedJob || workflowPending) return;
1964
+ const queuedJob = workflowQueuedJob;
1965
+ workflowPending = true;
1966
+ workflowError = null;
1967
+ try {
1968
+ const job = await workflows.client.status(queuedJob.jobId);
1969
+ const matchesCurrentIntent =
1970
+ queuedJob.intent === workflowIntentSignature();
1971
+ if (job.status === 'queued' || job.status === 'running') {
1972
+ workflowError = t(M['content.content_list.workflow_job_still'], {
1973
+ id: job.jobId,
1974
+ status: workflowJobStatusLabel(job.status),
1975
+ });
1976
+ return;
1977
+ }
1978
+ if (job.status === 'succeeded' && !job.result) {
1979
+ workflowError = t(M['content.content_list.workflow_job_missing_result'], {
1980
+ id: job.jobId,
1981
+ });
1982
+ return;
1983
+ }
1984
+ if (
1985
+ job.result &&
1986
+ (job.result.version !== 1 ||
1987
+ job.result.phase !== 'apply' ||
1988
+ job.result.actionId !== queuedJob.actionId ||
1989
+ job.result.requestId !== queuedJob.requestId ||
1990
+ !workflowIdentityMatches(job.result.identity, queuedJob.identity))
1991
+ ) {
1992
+ workflowError = t(M['content.content_list.workflow_job_wrong_workflow'], {
1993
+ id: job.jobId,
1994
+ });
1995
+ return;
1996
+ }
1997
+ workflowQueuedJobs = workflowQueuedJobs.filter(
1998
+ (queued) => queued.jobId !== job.jobId,
1999
+ );
2000
+ if (!matchesCurrentIntent) {
2001
+ if (job.status === 'succeeded' && job.result?.ok) refreshQuery();
2002
+ workflowError = t(
2003
+ M['content.content_list.workflow_changed_checking_job'],
2004
+ );
2005
+ return;
2006
+ }
2007
+ if (job.status === 'succeeded' && job.result) {
2008
+ if (matchesCurrentIntent) workflowResult = job.result;
2009
+ if (!job.result.ok) {
2010
+ workflowError = workflowReasonLabel(
2011
+ job.result.reason,
2012
+ M['content.content_list.workflow_job_failed'],
2013
+ { id: job.jobId },
2014
+ );
2015
+ } else if (matchesCurrentIntent) {
2016
+ applyWorkflowSelectionOutcomes(job.result);
2017
+ }
2018
+ if (job.result.ok) refreshQuery();
2019
+ }
2020
+ if (job.status !== 'succeeded') {
2021
+ workflowError = workflowReasonLabel(
2022
+ job.reason,
2023
+ M['content.content_list.workflow_job_status'],
2024
+ { id: job.jobId, status: workflowJobStatusLabel(job.status) },
2025
+ );
2026
+ }
2027
+ } catch (error) {
2028
+ workflowError = workflowExceptionMessage(
2029
+ error,
2030
+ M['content.content_list.workflow_job_failed'],
2031
+ );
2032
+ } finally {
2033
+ workflowPending = false;
2034
+ }
2035
+ }
2036
+
2037
+ function cancelWorkflowConfirmation() {
2038
+ if (workflowPending) return;
2039
+ workflowConfirmOpen = false;
2040
+ }
2041
+
1211
2042
  function handlePageChange(page: number) {
1212
2043
  controller.dispatch({ type: 'setPage', page });
1213
2044
  }
@@ -1303,6 +2134,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1303
2134
  <Checkbox
1304
2135
  checked={allPageSelected}
1305
2136
  indeterminate={somePageSelected}
2137
+ disabled={workflowPending}
1306
2138
  aria-label={t(M['content.content_list.select_all'])}
1307
2139
  onchange={togglePageSelection}
1308
2140
  />
@@ -1311,7 +2143,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1311
2143
  {#snippet selectCell({ row }: { row: ContentListRow })}
1312
2144
  <Checkbox
1313
2145
  checked={isSelected(row)}
1314
- disabled={!row.identified || rowPending(row)}
2146
+ disabled={workflowPending || !row.identified || rowPending(row) || allMatchingSelected}
1315
2147
  aria-label={selectRowLabel(row)}
1316
2148
  title={row.identified
1317
2149
  ? rowPending(row)
@@ -1526,6 +2358,18 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1526
2358
  <rect x="3" y="14" width="7" height="7"></rect>
1527
2359
  </svg>
1528
2360
  </Button>
2361
+
2362
+ {#if workflowQueuedJobs.length > 0 && workflows?.client.status}
2363
+ <Button
2364
+ variant="ghost"
2365
+ size="sm"
2366
+ type="button"
2367
+ disabled={workflowPending}
2368
+ onclick={() => void checkWorkflowJob()}
2369
+ >
2370
+ {t(M['content.content_list.check_job'])}
2371
+ </Button>
2372
+ {/if}
1529
2373
  <Button
1530
2374
  variant="ghost"
1531
2375
  size="sm"
@@ -1694,6 +2538,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1694
2538
  <Checkbox
1695
2539
  checked={allPageSelected}
1696
2540
  indeterminate={somePageSelected}
2541
+ disabled={workflowPending}
1697
2542
  aria-label={t(M['content.content_list.select_all'])}
1698
2543
  onchange={togglePageSelection}
1699
2544
  />
@@ -1702,13 +2547,136 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1702
2547
  {t(M['content.content_list.selection_count'], { count: selectedCount })}
1703
2548
  </span>
1704
2549
  {#if selectedCount > 0}
1705
- <Button variant="ghost" size="sm" type="button" class="clear-selection" onclick={clearSelection}>
2550
+ <Button variant="ghost" size="sm" type="button" class="clear-selection" disabled={workflowPending} onclick={clearSelection}>
1706
2551
  {t(M['content.content_list.clear_selection'])}
1707
2552
  </Button>
1708
2553
  {/if}
2554
+ {#if canSelectAllMatching && !allMatchingSelected && exactMatchingCount !== undefined && exactMatchingCount > selectedCount}
2555
+ <Button
2556
+ variant="ghost"
2557
+ size="sm"
2558
+ type="button"
2559
+ class="select-all-matching"
2560
+ disabled={workflowPending}
2561
+ onclick={selectAllMatching}
2562
+ >
2563
+ {t(M['content.content_list.select_all_matching'], {
2564
+ count: exactMatchingCount,
2565
+ })}
2566
+ </Button>
2567
+ {/if}
1709
2568
  </div>
1710
2569
  {/if}
1711
2570
 
2571
+ {#if workflows && (selectedCount > 0 || workflowError || workflowResult)}
2572
+ <section class="content-workflows" aria-label={t(M['content.content_list.bulk_workflows'])}>
2573
+ <Select
2574
+ aria-label={t(M['content.content_list.bulk_workflow'])}
2575
+ value={selectedWorkflow}
2576
+ disabled={workflowPending}
2577
+ onchange={(event: Event) => {
2578
+ selectedWorkflow = (event.currentTarget as HTMLSelectElement).value as ContentListWorkflowId;
2579
+ workflowPreview = null;
2580
+ workflowIdempotencyKey = '';
2581
+ workflowResult = null;
2582
+ workflowError = null;
2583
+ }}
2584
+ >
2585
+ {#each CONTENT_LIST_WORKFLOW_OPTIONS as option (option.id)}
2586
+ <option value={option.id}>{workflowLabel(option.id)}</option>
2587
+ {/each}
2588
+ </Select>
2589
+
2590
+ {#if selectedWorkflow === 'categorize'}
2591
+ <Input
2592
+ aria-label={t(M['content.content_list.category_path'])}
2593
+ placeholder={t(M['content.content_list.category_path'])}
2594
+ value={workflowCategory}
2595
+ disabled={workflowPending}
2596
+ oninput={(event: Event) => workflowCategory = (event.currentTarget as HTMLInputElement).value}
2597
+ />
2598
+ {:else if selectedWorkflow === 'restore'}
2599
+ <Select
2600
+ aria-label={t(M['content.content_list.restore_status'])}
2601
+ value={workflowRestoreStatus}
2602
+ disabled={workflowPending}
2603
+ onchange={(event: Event) => workflowRestoreStatus = (event.currentTarget as HTMLSelectElement).value as typeof workflowRestoreStatus}
2604
+ >
2605
+ <option value="draft">{t(M['content.content_list.workflow_draft'])}</option>
2606
+ <option value="review">{t(M['content.content_list.workflow_review'])}</option>
2607
+ <option value="published">{t(M['content.content_list.workflow_published'])}</option>
2608
+ </Select>
2609
+ {:else if selectedWorkflow === 'format-body'}
2610
+ <Select
2611
+ aria-label={t(M['content.content_list.body_format'])}
2612
+ value={workflowFormat}
2613
+ disabled={workflowPending}
2614
+ onchange={(event: Event) => workflowFormat = (event.currentTarget as HTMLSelectElement).value as typeof workflowFormat}
2615
+ >
2616
+ <option value="markdown">{t(M['content.content_list.workflow_markdown'])}</option>
2617
+ <option value="html">{t(M['content.content_list.workflow_html'])}</option>
2618
+ </Select>
2619
+ {:else if selectedWorkflow === 'automated-review'}
2620
+ <Select
2621
+ aria-label={t(M['content.content_list.review_kind'])}
2622
+ value={workflowReviewKind}
2623
+ disabled={workflowPending}
2624
+ onchange={(event: Event) => workflowReviewKind = (event.currentTarget as HTMLSelectElement).value}
2625
+ >
2626
+ <option value="">{t(M['content.content_list.default_review_kind'])}</option>
2627
+ <option value="facts">{t(M['content.content_list.workflow_facts'])}</option>
2628
+ <option value="safety">{t(M['content.content_list.workflow_safety'])}</option>
2629
+ <option value="custom">{t(M['content.content_list.workflow_custom'])}</option>
2630
+ </Select>
2631
+ <Input
2632
+ aria-label={t(M['content.content_list.review_policy_key'])}
2633
+ placeholder={t(M['content.content_list.policy_key_optional'])}
2634
+ value={workflowPolicyKey}
2635
+ disabled={workflowPending}
2636
+ oninput={(event: Event) => workflowPolicyKey = (event.currentTarget as HTMLInputElement).value}
2637
+ />
2638
+ {:else if selectedWorkflow === 'optimize'}
2639
+ <Input
2640
+ aria-label={t(M['content.content_list.optimization_instructions'])}
2641
+ placeholder={t(M['content.content_list.optimization_instructions_optional'])}
2642
+ value={workflowInstructions}
2643
+ disabled={workflowPending}
2644
+ oninput={(event: Event) => workflowInstructions = (event.currentTarget as HTMLInputElement).value}
2645
+ />
2646
+ {/if}
2647
+
2648
+ <Button
2649
+ variant="ghost"
2650
+ size="sm"
2651
+ type="button"
2652
+ disabled={
2653
+ workflowPending || !workflowPayloadValid || workflowDuplicateQueued
2654
+ }
2655
+ onclick={() => void previewWorkflow()}
2656
+ >
2657
+ {workflowPending
2658
+ ? t(M['content.content_list.workflow_working'])
2659
+ : workflowDuplicateQueued
2660
+ ? t(M['content.content_list.workflow_job_queued'])
2661
+ : t(M['content.content_list.workflow_preview'])}
2662
+ </Button>
2663
+
2664
+ {#if workflowError}
2665
+ <p class="content-workflows__error" role="alert">{workflowError}</p>
2666
+ {/if}
2667
+ {#if workflowResult?.ok}
2668
+ <p class="content-workflows__result" role="status">
2669
+ {workflowResultMessage(workflowResult)}
2670
+ {#if typeof workflowResult.details?.jobId === 'string'}
2671
+ {t(M['content.content_list.job_queued_progress'], {
2672
+ id: workflowResult.details.jobId,
2673
+ })}
2674
+ {/if}
2675
+ </p>
2676
+ {/if}
2677
+ </section>
2678
+ {/if}
2679
+
1712
2680
  {#if refreshing && viewMode !== 'compact'}
1713
2681
  <!-- DataTable announces its own refresh; the card views need their own. -->
1714
2682
  <p class="content-refreshing" role="status" aria-live="polite">
@@ -1754,7 +2722,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1754
2722
  <div class="content-row__select">
1755
2723
  <Checkbox
1756
2724
  checked={isSelected(row)}
1757
- disabled={!row.identified || rowPending(row)}
2725
+ disabled={workflowPending || !row.identified || rowPending(row) || allMatchingSelected}
1758
2726
  aria-label={selectRowLabel(row)}
1759
2727
  title={row.identified
1760
2728
  ? rowPending(row)
@@ -1848,7 +2816,7 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1848
2816
  <div class="content-header__eyebrow">
1849
2817
  <Checkbox
1850
2818
  checked={isSelected(row)}
1851
- disabled={!row.identified || rowPending(row)}
2819
+ disabled={workflowPending || !row.identified || rowPending(row) || allMatchingSelected}
1852
2820
  aria-label={selectRowLabel(row)}
1853
2821
  title={row.identified
1854
2822
  ? rowPending(row)
@@ -1945,6 +2913,23 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
1945
2913
  oncancel={cancelDelete}
1946
2914
  />
1947
2915
 
2916
+ <ConfirmDialog
2917
+ open={workflowConfirmOpen}
2918
+ title={t(M['content.content_list.workflow_confirm'], {
2919
+ workflow: selectedWorkflow
2920
+ ? workflowLabel(selectedWorkflow)
2921
+ : t(M['content.content_list.workflow_noun']),
2922
+ })}
2923
+ message={workflowPreviewMessage()}
2924
+ confirmLabel={workflowPending
2925
+ ? t(M['content.content_list.workflow_applying'])
2926
+ : t(M['content.content_list.workflow_apply'])}
2927
+ cancelLabel={t(M['content.content_list.cancel'])}
2928
+ destructive={CONTENT_LIST_WORKFLOW_OPTIONS.find((option) => option.id === selectedWorkflow)?.sensitivity === 'sensitive'}
2929
+ onconfirm={() => void applyWorkflow()}
2930
+ oncancel={cancelWorkflowConfirmation}
2931
+ />
2932
+
1948
2933
  <style>
1949
2934
  .content-list-wrapper {
1950
2935
  display: flex;
@@ -2059,6 +3044,33 @@ const tableColumns: DataTableColumn<ContentListRow>[] = $derived([
2059
3044
  cursor: pointer;
2060
3045
  }
2061
3046
 
3047
+ .content-workflows {
3048
+ display: flex;
3049
+ align-items: center;
3050
+ flex-wrap: wrap;
3051
+ gap: 0.75rem;
3052
+ margin-bottom: 1rem;
3053
+ padding: 0.75rem;
3054
+ border: 1px solid var(--smrt-color-outline-variant);
3055
+ border-radius: 0.65rem;
3056
+ background: var(--smrt-color-surface-container-low);
3057
+ }
3058
+
3059
+ .content-workflows__error,
3060
+ .content-workflows__result {
3061
+ flex-basis: 100%;
3062
+ margin: 0;
3063
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
3064
+ }
3065
+
3066
+ .content-workflows__error {
3067
+ color: var(--smrt-color-error);
3068
+ }
3069
+
3070
+ .content-workflows__result {
3071
+ color: var(--smrt-color-on-surface-variant);
3072
+ }
3073
+
2062
3074
  .content-header__eyebrow,
2063
3075
  .content-row__eyebrow {
2064
3076
  display: flex;