@parhelia/localization 0.1.12903 → 0.1.12905

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 (77) hide show
  1. package/dist/LocalizeItemCommand.d.ts +1 -7
  2. package/dist/LocalizeItemCommand.d.ts.map +1 -1
  3. package/dist/LocalizeItemCommand.js +18 -31
  4. package/dist/LocalizeItemDialog.d.ts.map +1 -1
  5. package/dist/LocalizeItemDialog.js +35 -97
  6. package/dist/LocalizeItemUtils.d.ts +2 -1
  7. package/dist/LocalizeItemUtils.d.ts.map +1 -1
  8. package/dist/LocalizeItemUtils.js +36 -78
  9. package/dist/api/discovery.d.ts +0 -25
  10. package/dist/api/discovery.d.ts.map +1 -1
  11. package/dist/api/discovery.js +2 -106
  12. package/dist/constants.d.ts +15 -0
  13. package/dist/constants.d.ts.map +1 -0
  14. package/dist/constants.js +21 -0
  15. package/dist/hooks/useTranslationWizard.d.ts.map +1 -1
  16. package/dist/hooks/useTranslationWizard.js +3 -3
  17. package/dist/index.d.ts +11 -10
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +32 -36
  20. package/dist/services/translationService.d.ts +10 -41
  21. package/dist/services/translationService.d.ts.map +1 -1
  22. package/dist/services/translationService.js +6 -48
  23. package/dist/settings/TranslationServicesPanel.d.ts.map +1 -1
  24. package/dist/settings/TranslationServicesPanel.js +36 -21
  25. package/dist/setup/LocalizationSetupStep.d.ts.map +1 -1
  26. package/dist/setup/LocalizationSetupStep.js +18 -29
  27. package/dist/sidebar/TranslationSidebar.d.ts.map +1 -1
  28. package/dist/sidebar/TranslationSidebar.js +10 -20
  29. package/dist/steps/MetadataInputStep.d.ts +4 -0
  30. package/dist/steps/MetadataInputStep.d.ts.map +1 -0
  31. package/dist/steps/MetadataInputStep.js +41 -0
  32. package/dist/steps/PromptCustomizationStep.d.ts +1 -1
  33. package/dist/steps/PromptCustomizationStep.d.ts.map +1 -1
  34. package/dist/steps/PromptCustomizationStep.js +56 -159
  35. package/dist/steps/ServiceLanguageSelectionStep.d.ts +1 -6
  36. package/dist/steps/ServiceLanguageSelectionStep.d.ts.map +1 -1
  37. package/dist/steps/ServiceLanguageSelectionStep.js +165 -92
  38. package/dist/steps/SubitemDiscoveryStep.d.ts +3 -0
  39. package/dist/steps/SubitemDiscoveryStep.d.ts.map +1 -0
  40. package/dist/steps/SubitemDiscoveryStep.js +313 -0
  41. package/dist/steps/index.d.ts +5 -0
  42. package/dist/steps/index.d.ts.map +1 -0
  43. package/dist/steps/index.js +4 -0
  44. package/dist/steps/types.d.ts +1 -17
  45. package/dist/steps/types.d.ts.map +1 -1
  46. package/dist/translation-center/BatchTranslationView.d.ts +8 -0
  47. package/dist/translation-center/BatchTranslationView.d.ts.map +1 -0
  48. package/dist/translation-center/BatchTranslationView.js +870 -0
  49. package/dist/translation-center/RecentTranslations.d.ts +2 -0
  50. package/dist/translation-center/RecentTranslations.d.ts.map +1 -0
  51. package/dist/translation-center/RecentTranslations.js +309 -0
  52. package/dist/translation-center/TranslationManagement.d.ts.map +1 -1
  53. package/dist/translation-center/TranslationManagement.js +15 -25
  54. package/dist/types.d.ts +0 -1
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/utils/createVersions.d.ts +14 -0
  57. package/dist/utils/createVersions.d.ts.map +1 -0
  58. package/dist/utils/createVersions.js +26 -0
  59. package/package.json +1 -1
  60. package/dist/steps/ItemSelectionStep.d.ts +0 -3
  61. package/dist/steps/ItemSelectionStep.d.ts.map +0 -1
  62. package/dist/steps/ItemSelectionStep.js +0 -24
  63. package/dist/steps/ItemSelectionTree.d.ts +0 -13
  64. package/dist/steps/ItemSelectionTree.d.ts.map +0 -1
  65. package/dist/steps/ItemSelectionTree.js +0 -327
  66. package/dist/steps/WizardStepShell.d.ts +0 -17
  67. package/dist/steps/WizardStepShell.d.ts.map +0 -1
  68. package/dist/steps/WizardStepShell.js +0 -11
  69. package/dist/translation-center/TranslationBatches.d.ts +0 -2
  70. package/dist/translation-center/TranslationBatches.d.ts.map +0 -1
  71. package/dist/translation-center/TranslationBatches.js +0 -1180
  72. package/dist/translation-center/TranslationsTitlebar.d.ts +0 -6
  73. package/dist/translation-center/TranslationsTitlebar.d.ts.map +0 -1
  74. package/dist/translation-center/TranslationsTitlebar.js +0 -25
  75. package/dist/translationEvents.d.ts +0 -6
  76. package/dist/translationEvents.d.ts.map +0 -1
  77. package/dist/translationEvents.js +0 -4
@@ -1,1180 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState, useMemo } from "react";
3
- import React from "react";
4
- import { Button, Badge, Select, Input, UserPicker, Popover, PopoverContent, PopoverTrigger, ContentTree, fetchItemStubs, searchUsers as searchBackendUsers, useEditContext, getLanguages, SimpleIconButton, DeleteIcon, } from "@parhelia/core";
5
- import { listBatches, getTranslationProviders, listBatchTranslationJobs, retryBatchTranslation, abortBatch, deleteBatch, } from "../services/translationService";
6
- import { TRANSLATION_BATCH_STARTED_EVENT, } from "../translationEvents";
7
- import { useDebouncedCallback } from "use-debounce";
8
- import { X, ChevronDown, Languages, Loader2, Calendar, CheckCircle2, User as UserIcon2, Cloud, Globe, FileText, FolderTree, ExternalLink, Check, AlertCircle, CircleStop, Hourglass, Copy, Sparkles, RotateCcw, } from "lucide-react";
9
- import { toast } from "sonner";
10
- // Wrappers for lucide-react icons to work with React 19
11
- const XIcon = (props) => React.createElement(X, props);
12
- const ChevronDownIcon = (props) => React.createElement(ChevronDown, props);
13
- const LanguagesIcon = (props) => React.createElement(Languages, props);
14
- const LoaderIcon = (props) => React.createElement(Loader2, props);
15
- const CalendarIcon = (props) => React.createElement(Calendar, props);
16
- const StatusIcon = (props) => React.createElement(CheckCircle2, props);
17
- const UserFilterIcon = (props) => React.createElement(UserIcon2, props);
18
- const ProviderIcon = (props) => React.createElement(Cloud, props);
19
- const GlobeIcon = (props) => React.createElement(Globe, props);
20
- const ItemIcon = (props) => React.createElement(FileText, props);
21
- const FolderTreeIcon = (props) => React.createElement(FolderTree, props);
22
- const ExternalLinkIcon = (props) => React.createElement(ExternalLink, props);
23
- const CheckIcon = (props) => React.createElement(Check, props);
24
- const AlertCircleIcon = (props) => React.createElement(AlertCircle, props);
25
- const AbortIcon = (props) => React.createElement(CircleStop, props);
26
- const QueuedIcon = (props) => React.createElement(Hourglass, props);
27
- const CopyIcon = (props) => React.createElement(Copy, props);
28
- const SparklesIcon = (props) => React.createElement(Sparkles, props);
29
- const RetryIcon = (props) => React.createElement(RotateCcw, props);
30
- const DATE_RANGE_OPTIONS = [
31
- { value: "lastHour", label: "Last Hour" },
32
- { value: "last24hours", label: "Last 24 Hours" },
33
- { value: "today", label: "Today" },
34
- { value: "last7days", label: "Last 7 Days" },
35
- { value: "last30days", label: "Last 30 Days" },
36
- { value: "last90days", label: "Last 90 Days" },
37
- { value: "last6months", label: "Last 6 Months" },
38
- { value: "lastYear", label: "Last Year" },
39
- { value: "allTime", label: "All Time" },
40
- ];
41
- const STATUS_OPTIONS = [
42
- { value: "all", label: "All Status" },
43
- { value: "in-progress", label: "In Progress" },
44
- { value: "completed", label: "Completed" },
45
- { value: "errors", label: "Errors Only" },
46
- { value: "aborted", label: "Aborted" },
47
- ];
48
- // Maps the UI status filter to the DB status string stored on the batch row.
49
- const STATUS_FILTER_TO_DB = {
50
- "in-progress": "In Progress",
51
- completed: "Completed",
52
- errors: "Error",
53
- aborted: "Aborted",
54
- };
55
- function isTerminalBatchStatus(status) {
56
- return status === "Completed" || status === "Error" || status === "Aborted";
57
- }
58
- function isActiveBatchStatus(status) {
59
- return status === "Pending" || status === "In Progress";
60
- }
61
- function isRetriableJob(job) {
62
- return job?.status === "Error";
63
- }
64
- function buildRetryJobRequest(job) {
65
- if (!job.itemId || !job.sourceLanguage || !job.targetLanguage)
66
- return null;
67
- return {
68
- sourceTranslationId: job.id,
69
- itemId: job.itemId,
70
- sourceLanguage: job.sourceLanguage,
71
- targetLanguage: job.targetLanguage,
72
- metadata: job.metadata,
73
- };
74
- }
75
- function normalizeGuid(value) {
76
- return (value ?? "")
77
- .toString()
78
- .replace(/[{}()]/g, "")
79
- .toLowerCase();
80
- }
81
- function dateRangeToFromUtc(range) {
82
- const now = new Date();
83
- const cutoff = new Date(now);
84
- switch (range) {
85
- case "lastHour":
86
- cutoff.setTime(now.getTime() - 60 * 60 * 1000);
87
- break;
88
- case "last24hours":
89
- cutoff.setTime(now.getTime() - 24 * 60 * 60 * 1000);
90
- break;
91
- case "today":
92
- cutoff.setHours(0, 0, 0, 0);
93
- break;
94
- case "last7days":
95
- cutoff.setDate(now.getDate() - 7);
96
- break;
97
- case "last30days":
98
- cutoff.setDate(now.getDate() - 30);
99
- break;
100
- case "last90days":
101
- cutoff.setDate(now.getDate() - 90);
102
- break;
103
- case "last6months":
104
- cutoff.setMonth(now.getMonth() - 6);
105
- break;
106
- case "lastYear":
107
- cutoff.setFullYear(now.getFullYear() - 1);
108
- break;
109
- case "allTime":
110
- return undefined;
111
- }
112
- return cutoff.toISOString();
113
- }
114
- export function TranslationBatches() {
115
- const editContext = useEditContext();
116
- const [batches, setBatches] = useState([]);
117
- const [providers, setProviders] = useState([]);
118
- const [sitecoreLanguages, setSitecoreLanguages] = useState([]);
119
- const [isLoading, setIsLoading] = useState(false);
120
- const [isLoadingMore, setIsLoadingMore] = useState(false);
121
- const [hasMore, setHasMore] = useState(true);
122
- const [currentOffset, setCurrentOffset] = useState(0);
123
- const [itemIdOrPathInput, setItemIdOrPathInput] = useState("");
124
- const [itemPickerOpen, setItemPickerOpen] = useState(false);
125
- const [expandedBatchId, setExpandedBatchId] = useState(null);
126
- const [expandedItems, setExpandedItems] = useState(new Set());
127
- const [batchJobs, setBatchJobs] = useState({});
128
- const [loadingJobs, setLoadingJobs] = useState(new Set());
129
- const [abortingBatchIds, setAbortingBatchIds] = useState(new Set());
130
- const [deletingBatchIds, setDeletingBatchIds] = useState(new Set());
131
- const [retryingKeys, setRetryingKeys] = useState(new Set());
132
- // Cache of userName → displayName so we can render full names on batch rows
133
- // without re-querying the user service every render.
134
- const [userDisplayNames, setUserDisplayNames] = useState({});
135
- // Monotonic request id — only the most recent in-flight request is allowed
136
- // to write to state, so a slower earlier load can't clobber a newer one
137
- // (e.g. when the user changes filters quickly).
138
- const requestIdRef = useRef(0);
139
- const currentUserName = editContext?.user?.name || "all";
140
- const isLimitedPreviewUser = editContext?.user?.isLimitedPreviewUser ?? false;
141
- const [filters, setFilters] = useState({
142
- dateRange: "last30days",
143
- status: "all",
144
- user: currentUserName,
145
- provider: "all",
146
- targetLanguage: "all",
147
- itemIdOrPath: "",
148
- itemIncludeSubitems: false,
149
- });
150
- // Enforce current user filter for limited preview users
151
- useEffect(() => {
152
- if (isLimitedPreviewUser &&
153
- currentUserName !== "all" &&
154
- filters.user !== currentUserName) {
155
- setFilters((prev) => ({ ...prev, user: currentUserName }));
156
- }
157
- }, [isLimitedPreviewUser, currentUserName, filters.user]);
158
- const effectiveFilters = useMemo(() => {
159
- const userFilter = isLimitedPreviewUser ? currentUserName : filters.user;
160
- const trimmedItem = filters.itemIdOrPath.trim();
161
- return {
162
- fromUtc: dateRangeToFromUtc(filters.dateRange),
163
- status: filters.status === "all"
164
- ? undefined
165
- : STATUS_FILTER_TO_DB[filters.status],
166
- user: userFilter === "all" ? undefined : userFilter,
167
- provider: filters.provider === "all" ? undefined : filters.provider,
168
- targetLanguage: filters.targetLanguage === "all" ? undefined : filters.targetLanguage,
169
- itemIdOrPath: trimmedItem ? trimmedItem : undefined,
170
- itemIncludeSubitems: trimmedItem && filters.itemIncludeSubitems ? true : undefined,
171
- };
172
- }, [filters, isLimitedPreviewUser, currentUserName]);
173
- const loadRecentBatches = useCallback(async (offset, append, filtersToUse) => {
174
- const myRequestId = ++requestIdRef.current;
175
- if (offset === 0) {
176
- setIsLoading(true);
177
- }
178
- else {
179
- setIsLoadingMore(true);
180
- }
181
- try {
182
- const limit = 100;
183
- const res = await listBatches(limit, offset, filtersToUse);
184
- // Drop stale responses — a newer request has already superseded this one.
185
- if (myRequestId !== requestIdRef.current)
186
- return;
187
- const maybeData = res?.data ?? res;
188
- const newBatches = Array.isArray(maybeData)
189
- ? maybeData
190
- : [];
191
- if (append) {
192
- setBatches((prev) => {
193
- const map = new Map();
194
- for (const b of prev)
195
- map.set(b.id, b);
196
- for (const b of newBatches)
197
- map.set(b.id, b);
198
- return Array.from(map.values());
199
- });
200
- }
201
- else {
202
- const map = new Map();
203
- for (const b of newBatches)
204
- map.set(b.id, b);
205
- setBatches(Array.from(map.values()));
206
- }
207
- setHasMore(newBatches.length === limit);
208
- setCurrentOffset(offset + limit);
209
- }
210
- catch (error) {
211
- if (myRequestId !== requestIdRef.current)
212
- return;
213
- console.error("Failed to load batches:", error);
214
- }
215
- finally {
216
- if (myRequestId === requestIdRef.current) {
217
- setIsLoading(false);
218
- setIsLoadingMore(false);
219
- }
220
- }
221
- }, []);
222
- const debouncedReload = useDebouncedCallback(() => loadRecentBatches(0, false, effectiveFilters), 800);
223
- const debouncedSetItemFilter = useDebouncedCallback((value) => setFilters((prev) => ({ ...prev, itemIdOrPath: value })), 400);
224
- const loadMore = () => {
225
- if (!isLoadingMore && hasMore) {
226
- loadRecentBatches(currentOffset, true, effectiveFilters);
227
- }
228
- };
229
- // Load providers
230
- useEffect(() => {
231
- const loadProviders = async () => {
232
- try {
233
- const res = await getTranslationProviders();
234
- const maybeProviders = res?.data ?? res;
235
- setProviders(Array.isArray(maybeProviders)
236
- ? maybeProviders
237
- : []);
238
- }
239
- catch (error) {
240
- console.error("Failed to load translation providers:", error);
241
- }
242
- };
243
- loadProviders();
244
- }, []);
245
- // Load Sitecore languages once for the Target Language dropdown
246
- useEffect(() => {
247
- const loadLanguages = async () => {
248
- try {
249
- const res = await getLanguages();
250
- const langs = (res?.data ?? res ?? []);
251
- setSitecoreLanguages(Array.isArray(langs) ? langs : []);
252
- }
253
- catch (error) {
254
- console.error("Failed to load Sitecore languages:", error);
255
- }
256
- };
257
- loadLanguages();
258
- }, []);
259
- // Reload when filters change (resets pagination)
260
- useEffect(() => {
261
- loadRecentBatches(0, false, effectiveFilters);
262
- }, [loadRecentBatches, effectiveFilters]);
263
- // Seed the cache with the current user's full name so rows authored by us
264
- // resolve immediately, without waiting on a searchUsers round-trip.
265
- useEffect(() => {
266
- const me = editContext?.user;
267
- if (!me?.name)
268
- return;
269
- const fullName = me.fullName?.trim();
270
- if (!fullName)
271
- return;
272
- setUserDisplayNames((prev) => {
273
- if (prev[me.name] === fullName)
274
- return prev;
275
- return { ...prev, [me.name]: fullName };
276
- });
277
- }, [editContext?.user]);
278
- // Resolve `initiatedByUser` to a full display name for every batch we haven't
279
- // looked up yet. Runs once per unique username.
280
- useEffect(() => {
281
- const unresolved = new Set();
282
- for (const b of batches) {
283
- const name = b.initiatedByUser?.trim();
284
- if (!name)
285
- continue;
286
- if (userDisplayNames[name] !== undefined)
287
- continue;
288
- unresolved.add(name);
289
- }
290
- if (unresolved.size === 0)
291
- return;
292
- let cancelled = false;
293
- (async () => {
294
- const updates = {};
295
- await Promise.all(Array.from(unresolved).map(async (userName) => {
296
- try {
297
- const results = await searchBackendUsers(userName, 5);
298
- const exact = results.find((r) => r.userName?.toLowerCase() === userName.toLowerCase()) ?? results[0];
299
- updates[userName] = exact?.displayName?.trim() || userName;
300
- }
301
- catch {
302
- updates[userName] = userName;
303
- }
304
- }));
305
- if (cancelled || Object.keys(updates).length === 0)
306
- return;
307
- setUserDisplayNames((prev) => ({ ...prev, ...updates }));
308
- })();
309
- return () => {
310
- cancelled = true;
311
- };
312
- }, [batches, userDisplayNames]);
313
- // Keep refs to the latest reload + expansion state so the socket listener
314
- // can be registered once and survive editContext identity changes.
315
- // Without this, a context recreation (triggered e.g. by EditorShell's
316
- // requestRefresh("immediate") on batch-completed) tears down the listener
317
- // and cancels the pending debouncedReload before it fires.
318
- const debouncedReloadRef = useRef(debouncedReload);
319
- useEffect(() => {
320
- debouncedReloadRef.current = debouncedReload;
321
- }, [debouncedReload]);
322
- const expandedBatchIdRef = useRef(expandedBatchId);
323
- useEffect(() => {
324
- expandedBatchIdRef.current = expandedBatchId;
325
- }, [expandedBatchId]);
326
- const loadBatchJobsRef = useRef(null);
327
- // Listen for batch-related events to refresh the list
328
- useEffect(() => {
329
- const removeSocketMessageListener = editContext?.addSocketMessageListener((message) => {
330
- const batchIdFromPayload = normalizeGuid(message?.payload?.batchId ?? message?.payload?.BatchId);
331
- if (message.type === "batch-completed" ||
332
- ((message.type === "translation-started" ||
333
- message.type === "translation-finished" ||
334
- message.type === "translation-error" ||
335
- message.type === "translation-aborted") &&
336
- batchIdFromPayload)) {
337
- debouncedReloadRef.current();
338
- // If the impacted batch is currently expanded, refresh its per-job
339
- // list too — otherwise the language chips keep their stale spinner.
340
- if (batchIdFromPayload &&
341
- expandedBatchIdRef.current === batchIdFromPayload) {
342
- void loadBatchJobsRef.current?.(batchIdFromPayload);
343
- }
344
- }
345
- });
346
- return () => {
347
- if (removeSocketMessageListener) {
348
- removeSocketMessageListener();
349
- }
350
- // Intentionally do NOT cancel the pending debouncedReload here —
351
- // editContext can change identity between message arrival and the
352
- // debounce expiry, and cancelling on re-subscription would drop the
353
- // refresh that brings the UI to its final state.
354
- };
355
- }, [editContext]);
356
- const searchTranslationUsers = useCallback((query, limit) => searchBackendUsers(query, limit), []);
357
- // Languages available in the Target Language dropdown — sourced from Sitecore so
358
- // the list is stable regardless of the current filter selection.
359
- const languageOptions = useMemo(() => sitecoreLanguages
360
- .map((l) => l.languageCode || l.name)
361
- .filter((code) => !!code)
362
- .sort(), [sitecoreLanguages]);
363
- // Group by date. Filtering happens server-side, so we operate on `batches` directly.
364
- const groupedBatches = useMemo(() => {
365
- if (batches.length === 0)
366
- return [];
367
- const dedupMap = new Map();
368
- for (const b of batches)
369
- dedupMap.set(b.id, b);
370
- const sorted = Array.from(dedupMap.values()).sort((a, b) => new Date(b.startedAtUtc || b.createdAtUtc || b.lastUpdatedUtc || "").getTime() -
371
- new Date(a.startedAtUtc || a.createdAtUtc || a.lastUpdatedUtc || "").getTime());
372
- const list = sorted.map((b) => ({
373
- id: b.id,
374
- timestamp: b.startedAtUtc ||
375
- b.createdAtUtc ||
376
- b.lastUpdatedUtc ||
377
- new Date().toISOString(),
378
- info: b,
379
- }));
380
- const now = new Date();
381
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
382
- const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
383
- const thisWeek = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
384
- const dateGroups = [];
385
- const todayBatches = [];
386
- const yesterdayBatches = [];
387
- const thisWeekBatches = [];
388
- const olderBatches = [];
389
- for (const b of list) {
390
- const d = new Date(b.timestamp);
391
- const day = new Date(d.getFullYear(), d.getMonth(), d.getDate());
392
- if (day.getTime() === today.getTime())
393
- todayBatches.push(b);
394
- else if (day.getTime() === yesterday.getTime())
395
- yesterdayBatches.push(b);
396
- else if (day >= thisWeek)
397
- thisWeekBatches.push(b);
398
- else
399
- olderBatches.push(b);
400
- }
401
- if (todayBatches.length)
402
- dateGroups.push({ label: "Today", batches: todayBatches });
403
- if (yesterdayBatches.length)
404
- dateGroups.push({ label: "Yesterday", batches: yesterdayBatches });
405
- if (thisWeekBatches.length)
406
- dateGroups.push({ label: "This Week", batches: thisWeekBatches });
407
- if (olderBatches.length)
408
- dateGroups.push({ label: "Older", batches: olderBatches });
409
- return dateGroups;
410
- }, [batches]);
411
- const loadBatchJobs = useCallback(async (batchId) => {
412
- setLoadingJobs((prev) => {
413
- const next = new Set(prev);
414
- next.add(batchId);
415
- return next;
416
- });
417
- try {
418
- const res = await listBatchTranslationJobs(batchId);
419
- const data = (res?.data ??
420
- res ??
421
- []);
422
- const jobs = Array.isArray(data) ? data : [];
423
- setBatchJobs((prev) => ({ ...prev, [batchId]: jobs }));
424
- return jobs;
425
- }
426
- catch (error) {
427
- console.error("Failed to load batch jobs:", error);
428
- return [];
429
- }
430
- finally {
431
- setLoadingJobs((prev) => {
432
- const next = new Set(prev);
433
- next.delete(batchId);
434
- return next;
435
- });
436
- }
437
- }, []);
438
- useEffect(() => {
439
- loadBatchJobsRef.current = loadBatchJobs;
440
- }, [loadBatchJobs]);
441
- useEffect(() => {
442
- const handleTranslationBatchStarted = (event) => {
443
- const detail = event
444
- .detail;
445
- const batchId = normalizeGuid(detail?.batchId);
446
- void (async () => {
447
- await loadRecentBatches(0, false, effectiveFilters);
448
- if (!batchId)
449
- return;
450
- setExpandedBatchId(batchId);
451
- setExpandedItems(new Set());
452
- await loadBatchJobs(batchId);
453
- })();
454
- };
455
- window.addEventListener(TRANSLATION_BATCH_STARTED_EVENT, handleTranslationBatchStarted);
456
- return () => {
457
- window.removeEventListener(TRANSLATION_BATCH_STARTED_EVENT, handleTranslationBatchStarted);
458
- };
459
- }, [effectiveFilters, loadBatchJobs, loadRecentBatches]);
460
- const toggleBatch = useCallback((batchId) => {
461
- setExpandedBatchId((prev) => {
462
- const next = prev === batchId ? null : batchId;
463
- if (next && !batchJobs[next]) {
464
- void loadBatchJobs(next);
465
- }
466
- return next;
467
- });
468
- setExpandedItems(new Set());
469
- }, [batchJobs, loadBatchJobs]);
470
- const toggleItem = useCallback((key) => {
471
- setExpandedItems((prev) => {
472
- const next = new Set(prev);
473
- if (next.has(key))
474
- next.delete(key);
475
- else
476
- next.add(key);
477
- return next;
478
- });
479
- }, []);
480
- const handleAbortBatch = useCallback((batchId) => {
481
- editContext?.confirm({
482
- header: "Abort Translation Batch",
483
- message: "Abort all pending and in-progress jobs in this batch? This stops remaining translation work, but it does not roll back content that was already written.",
484
- acceptLabel: "Abort",
485
- showCancel: true,
486
- accept: async () => {
487
- setAbortingBatchIds((prev) => new Set(prev).add(batchId));
488
- try {
489
- const result = await abortBatch(batchId);
490
- if (result.type !== "success") {
491
- toast.error(result.details ||
492
- result.summary ||
493
- "Failed to abort translation batch.");
494
- return;
495
- }
496
- toast.success("Translation batch aborted.");
497
- await loadRecentBatches(0, false, effectiveFilters);
498
- if (expandedBatchId === batchId) {
499
- await loadBatchJobs(batchId);
500
- }
501
- }
502
- finally {
503
- setAbortingBatchIds((prev) => {
504
- const next = new Set(prev);
505
- next.delete(batchId);
506
- return next;
507
- });
508
- }
509
- },
510
- });
511
- }, [
512
- editContext,
513
- effectiveFilters,
514
- expandedBatchId,
515
- loadBatchJobs,
516
- loadRecentBatches,
517
- ]);
518
- const handleDeleteBatch = useCallback((batchId) => {
519
- editContext?.confirm({
520
- header: "Delete Translation History",
521
- message: "Delete this translation batch from history? This removes only translation job records; it does not delete Sitecore items or translated content.",
522
- acceptLabel: "Delete",
523
- showCancel: true,
524
- accept: async () => {
525
- setDeletingBatchIds((prev) => new Set(prev).add(batchId));
526
- try {
527
- const result = await deleteBatch(batchId);
528
- if (result.type !== "success") {
529
- toast.error(result.details ||
530
- result.summary ||
531
- "Failed to delete translation batch.");
532
- return;
533
- }
534
- toast.success("Translation batch deleted.");
535
- setBatches((prev) => prev.filter((batch) => batch.id !== batchId));
536
- setBatchJobs((prev) => {
537
- const next = { ...prev };
538
- delete next[batchId];
539
- return next;
540
- });
541
- setExpandedBatchId((prev) => (prev === batchId ? null : prev));
542
- }
543
- finally {
544
- setDeletingBatchIds((prev) => {
545
- const next = new Set(prev);
546
- next.delete(batchId);
547
- return next;
548
- });
549
- }
550
- },
551
- });
552
- }, [editContext]);
553
- const handleRetryJobs = useCallback(async (batchId, provider, jobs, retryKey) => {
554
- const sessionId = editContext?.sessionId;
555
- if (!sessionId) {
556
- toast.error("Cannot retry translations without an active session.");
557
- return;
558
- }
559
- const retryJobs = jobs
560
- .filter(isRetriableJob)
561
- .map(buildRetryJobRequest)
562
- .filter((job) => job != null);
563
- if (retryJobs.length === 0) {
564
- toast.error("No failed translations are available to retry.");
565
- return;
566
- }
567
- setRetryingKeys((prev) => new Set(prev).add(retryKey));
568
- try {
569
- const result = await retryBatchTranslation({
570
- sessionId,
571
- sourceBatchId: batchId,
572
- provider,
573
- jobs: retryJobs,
574
- });
575
- if (result.type !== "success") {
576
- toast.error(result.details ||
577
- result.summary ||
578
- "Failed to retry translations.");
579
- return;
580
- }
581
- const payload = (result?.data ?? result);
582
- const startedCount = payload.started?.length ?? 0;
583
- const skippedCount = payload.skipped?.length ?? 0;
584
- if (startedCount === 0) {
585
- toast.error(skippedCount > 0
586
- ? "No translations were retried. They may already have been retried."
587
- : "No translations were retried.");
588
- }
589
- else {
590
- toast.success(`Retry queued for ${startedCount} translation${startedCount !== 1 ? "s" : ""}.`);
591
- if (skippedCount > 0) {
592
- toast.error(`${skippedCount} translation${skippedCount !== 1 ? "s were" : " was"} skipped.`);
593
- }
594
- }
595
- await loadRecentBatches(0, false, effectiveFilters);
596
- if (expandedBatchId === batchId || batchJobs[batchId]) {
597
- await loadBatchJobs(batchId);
598
- }
599
- }
600
- finally {
601
- setRetryingKeys((prev) => {
602
- const next = new Set(prev);
603
- next.delete(retryKey);
604
- return next;
605
- });
606
- }
607
- }, [
608
- batchJobs,
609
- editContext?.sessionId,
610
- effectiveFilters,
611
- expandedBatchId,
612
- loadBatchJobs,
613
- loadRecentBatches,
614
- ]);
615
- const handleRetryBatch = useCallback(async (batch) => {
616
- const batchId = batch.id;
617
- const jobs = batchJobs[batchId] ?? (await loadBatchJobs(batchId));
618
- await handleRetryJobs(batchId, batch.provider, jobs, `batch:${batchId}`);
619
- }, [batchJobs, handleRetryJobs, loadBatchJobs]);
620
- const openItemInEditor = useCallback(async (itemId, language) => {
621
- const normalized = (itemId ?? "").toString().toLowerCase();
622
- editContext?.setShowAgentsWorkspaceEditor(true);
623
- await editContext?.loadItem({ id: normalized, language, version: 0 });
624
- }, [editContext]);
625
- const totalBatches = groupedBatches.reduce((sum, group) => sum + group.batches.length, 0);
626
- const isMobile = editContext?.isMobile ?? false;
627
- const hasActiveFilters = filters.dateRange !== "last30days" ||
628
- filters.status !== "all" ||
629
- filters.user !== currentUserName ||
630
- filters.provider !== "all" ||
631
- filters.targetLanguage !== "all" ||
632
- filters.itemIdOrPath.trim() !== "";
633
- const filterLabelClass = "flex items-center gap-1.5 text-[10px] font-medium tracking-[0.06em] text-neutral-grey-50";
634
- return (_jsxs("div", { className: "flex h-full flex-col min-h-0 bg-background", "data-testid": "translation-batches", children: [_jsx("div", { className: "shrink-0 px-4 pt-4 pb-3 md:px-6", children: _jsxs("div", { className: "flex flex-wrap items-end gap-x-3 gap-y-2", children: [_jsxs("div", { className: "flex min-w-[160px] flex-1 flex-col gap-1", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(CalendarIcon, { className: "h-3 w-3" }), "Date Range"] }), _jsx(Select, { className: "w-full bg-neutral-grey-5", options: DATE_RANGE_OPTIONS, value: filters.dateRange, onValueChange: (value) => setFilters((prev) => ({
635
- ...prev,
636
- dateRange: value,
637
- })), placeholder: "Select date range" })] }), _jsxs("div", { className: "flex min-w-[140px] flex-1 flex-col gap-1", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(StatusIcon, { className: "h-3 w-3" }), "Status"] }), _jsx(Select, { className: "w-full bg-neutral-grey-5", options: STATUS_OPTIONS, value: filters.status, onValueChange: (value) => setFilters((prev) => ({
638
- ...prev,
639
- status: value,
640
- })), placeholder: "Select status" })] }), _jsxs("div", { className: "flex min-w-[200px] flex-1 flex-col gap-1", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(UserFilterIcon, { className: "h-3 w-3" }), "User"] }), _jsx(UserPicker, { value: isLimitedPreviewUser
641
- ? currentUserName
642
- : filters.user === "all"
643
- ? null
644
- : filters.user, displayValue: isLimitedPreviewUser || filters.user === currentUserName
645
- ? `${editContext?.user?.fullName?.trim() || currentUserName} (You)`
646
- : undefined, onChange: (user) => setFilters((prev) => ({
647
- ...prev,
648
- user: user?.userName ?? "all",
649
- })), disabled: isLimitedPreviewUser, placeholder: "All Users", buttonClassName: "h-[27px] w-full justify-start rounded-md border bg-neutral-grey-5 text-xs font-medium", allowClear: true, clearLabel: "All Users", searchUsers: searchTranslationUsers })] }), _jsxs("div", { className: "flex min-w-[160px] flex-1 flex-col gap-1", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(ProviderIcon, { className: "h-3 w-3" }), "Provider"] }), _jsx(Select, { className: "w-full bg-neutral-grey-5", options: [
650
- { value: "all", label: "All Providers" },
651
- ...providers.map((p) => ({
652
- value: p.name,
653
- label: p.displayName || p.name,
654
- })),
655
- ], value: filters.provider, onValueChange: (value) => setFilters((prev) => ({ ...prev, provider: value })), placeholder: "Select provider" })] }), _jsxs("div", { className: "flex min-w-[160px] flex-1 flex-col gap-1", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(GlobeIcon, { className: "h-3 w-3" }), "Target Language"] }), _jsx(Select, { className: "w-full bg-neutral-grey-5", options: [
656
- { value: "all", label: "All Languages" },
657
- ...languageOptions.map((lang) => ({
658
- value: lang,
659
- label: lang,
660
- })),
661
- ], value: filters.targetLanguage, onValueChange: (value) => setFilters((prev) => ({ ...prev, targetLanguage: value })), placeholder: "Select language" })] }), _jsxs("div", { className: "flex min-w-[200px] flex-1 flex-col gap-1", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("label", { className: filterLabelClass, children: [_jsx(ItemIcon, { className: "h-3 w-3" }), "Item ID / Path"] }), _jsxs("label", { className: "flex items-center gap-1 text-[10px] text-neutral-grey-50 cursor-pointer select-none", children: [_jsx("input", { type: "checkbox", checked: filters.itemIncludeSubitems, onChange: (e) => setFilters((prev) => ({
662
- ...prev,
663
- itemIncludeSubitems: e.target.checked,
664
- })), className: "h-3 w-3 accent-highlight-100 cursor-pointer" }), "Include subitems"] })] }), _jsxs("div", { className: "relative", children: [_jsx(Input, { value: itemIdOrPathInput, onChange: (e) => {
665
- const next = e.target.value;
666
- setItemIdOrPathInput(next);
667
- debouncedSetItemFilter(next);
668
- }, placeholder: "GUID or /sitecore/content/\u2026", className: "h-[27px] w-full bg-neutral-grey-5 text-xs pr-14" }), _jsxs("div", { className: "absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-1", children: [itemIdOrPathInput && (_jsx("button", { type: "button", onClick: () => {
669
- setItemIdOrPathInput("");
670
- debouncedSetItemFilter.cancel();
671
- setFilters((prev) => ({
672
- ...prev,
673
- itemIdOrPath: "",
674
- itemIncludeSubitems: false,
675
- }));
676
- }, className: "text-neutral-grey-50 hover:text-neutral-grey-100 cursor-pointer", "aria-label": "Clear", children: _jsx(XIcon, { className: "h-3.5 w-3.5" }) })), _jsxs(Popover, { open: itemPickerOpen, onOpenChange: setItemPickerOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", className: "text-neutral-grey-50 hover:text-neutral-grey-100 cursor-pointer", "aria-label": "Browse content tree", title: "Browse content tree", children: _jsx(FolderTreeIcon, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", sideOffset: 6, className: "w-[28rem] max-w-[calc(100vw-2rem)] p-0", children: [_jsx("div", { className: "flex items-center justify-between gap-2 border-b border-border-default px-3 py-2 text-[11px] text-neutral-grey-50", children: _jsx("span", { children: "Pick item to filter by" }) }), _jsx("div", { className: "max-h-80 overflow-auto p-2", children: _jsx(ContentTree, { language: "en", selectionMode: "single", rootItemIds: ["0de95ae4-41ab-4d01-9eb0-67441b7c2450"], onSelectionChange: (items) => {
677
- const picked = items?.[0];
678
- if (!picked)
679
- return;
680
- const applyValue = (value) => {
681
- setItemIdOrPathInput(value);
682
- debouncedSetItemFilter.cancel();
683
- setFilters((prev) => ({
684
- ...prev,
685
- itemIdOrPath: value,
686
- }));
687
- setItemPickerOpen(false);
688
- };
689
- if (picked.path) {
690
- applyValue(picked.path);
691
- return;
692
- }
693
- // Fall back: resolve the item's path via a stub fetch
694
- // so the input shows a human-readable path instead of a GUID.
695
- fetchItemStubs([
696
- { id: picked.id, language: "en", version: 1 },
697
- ])
698
- .then((stubs) => {
699
- const stubPath = stubs?.[0]?.path;
700
- applyValue(stubPath || picked.id);
701
- })
702
- .catch(() => applyValue(picked.id));
703
- } }) })] })] })] })] })] })] }) }), _jsx("div", { className: "flex-1 overflow-auto p-4 md:p-6 min-h-0", children: isLoading && batches.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "flex items-center gap-2 text-neutral-grey-50", children: [_jsx(LoaderIcon, { className: "h-5 w-5 animate-spin text-highlight-100" }), "Loading recent translations..."] }) })) : groupedBatches.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "text-center text-neutral-grey-50", children: [_jsx(LanguagesIcon, { className: "h-8 w-8 block mx-auto mb-2 text-neutral-grey-15" }), _jsx("p", { className: "font-medium text-neutral-grey-100", children: "No translations found" }), _jsx("p", { className: "text-sm mt-1", children: (() => {
704
- const hasActiveFilters = filters.dateRange !== "last30days" ||
705
- filters.status !== "all" ||
706
- filters.user !== currentUserName ||
707
- filters.provider !== "all" ||
708
- filters.targetLanguage !== "all" ||
709
- filters.itemIdOrPath.trim() !== "";
710
- return hasActiveFilters
711
- ? "Try adjusting your filters or start a translation to see it appear here"
712
- : "Start a translation to see it appear here";
713
- })() })] }) })) : (_jsxs("div", { className: "space-y-10", children: [groupedBatches.map((dateGroup) => (_jsxs("div", { children: [_jsxs("div", { className: "flex items-baseline gap-2 mb-2", children: [_jsx("h2", { className: "text-[11px] font-medium tracking-[0.08em] text-neutral-grey-50", children: dateGroup.label }), _jsx("span", { className: "flex-1 ml-2 border-t border-border-default" })] }), _jsx("div", { className: "divide-y divide-border-default/70", children: dateGroup.batches.map((b) => {
714
- const batchDate = new Date(b.timestamp);
715
- const now = new Date();
716
- const isToday = batchDate.toDateString() === now.toDateString();
717
- const timeDisplay = isToday
718
- ? batchDate.toLocaleTimeString()
719
- : batchDate.toLocaleDateString() +
720
- " " +
721
- batchDate.toLocaleTimeString();
722
- const info = b.info;
723
- const totalJobs = info?.expectedJobs ?? 0;
724
- const itemsCount = typeof info?.itemsCount === "number"
725
- ? info.itemsCount
726
- : undefined;
727
- const languagesCsv = info?.languages;
728
- const languages = languagesCsv
729
- ? languagesCsv.split(",")
730
- : [];
731
- const completedJobs = info?.completedJobs ?? 0;
732
- const errorJobs = info?.errorJobs ?? 0;
733
- const anyError = errorJobs > 0;
734
- const anyInProgress = isActiveBatchStatus(info?.status);
735
- const anyAborted = info?.status === "Aborted";
736
- const progressPct = totalJobs > 0
737
- ? Math.min(100, Math.round((completedJobs / totalJobs) * 100))
738
- : 0;
739
- const showProgress = totalJobs > 0 && (anyInProgress || anyError);
740
- const statusDotClass = anyError
741
- ? "bg-feedback-red"
742
- : anyInProgress
743
- ? "bg-highlight-100"
744
- : anyAborted
745
- ? "bg-feedback-orange"
746
- : "bg-feedback-green/70";
747
- const metaParts = [];
748
- metaParts.push(_jsxs("span", { className: "text-neutral-grey-100", children: [totalJobs, " translation", totalJobs !== 1 ? "s" : ""] }, "trans"));
749
- if (typeof itemsCount === "number") {
750
- metaParts.push(_jsxs("span", { children: [itemsCount, " item", itemsCount !== 1 ? "s" : ""] }, "items"));
751
- }
752
- if (languages.length > 0) {
753
- metaParts.push(_jsxs("span", { title: languages.join(", "), children: [languages.length, " language", languages.length !== 1 ? "s" : ""] }, "langs"));
754
- }
755
- if (info?.provider) {
756
- metaParts.push(_jsx("span", { children: info.provider }, "prov"));
757
- }
758
- if (info?.totalCost != null) {
759
- metaParts.push(_jsxs("span", { children: ["Cost:", " ", _jsx("span", { className: "font-medium text-neutral-grey-100", children: formatUsdCost(info.totalCost) })] }, "cost"));
760
- }
761
- if (info?.initiatedByUser) {
762
- const resolved = userDisplayNames[info.initiatedByUser];
763
- metaParts.push(_jsx("span", { title: info.initiatedByUser, children: resolved || info.initiatedByUser }, "user"));
764
- }
765
- const isExpanded = expandedBatchId === b.id;
766
- const jobs = batchJobs[b.id];
767
- const isLoadingThis = loadingJobs.has(b.id);
768
- const canRetry = anyError;
769
- const canAbort = isActiveBatchStatus(info?.status);
770
- const canDelete = isTerminalBatchStatus(info?.status);
771
- const isRetryingBatch = retryingKeys.has(`batch:${b.id}`);
772
- const isAborting = abortingBatchIds.has(b.id);
773
- const isDeleting = deletingBatchIds.has(b.id);
774
- // Stable test-id status used by Playwright assertions.
775
- // Matches the visual status dot/spinner logic above.
776
- const testStatus = anyError
777
- ? "error"
778
- : anyInProgress
779
- ? "in-progress"
780
- : anyAborted
781
- ? "aborted"
782
- : "completed";
783
- return (_jsxs("div", { "data-testid": `batch-row-${b.id}`, "data-batch-status": testStatus, children: [_jsx("div", { role: "button", tabIndex: 0, className: "group w-full text-left px-3 md:px-4 py-3 hover:bg-neutral-grey-5 transition-colors cursor-pointer", onClick: () => toggleBatch(b.id), onKeyDown: (e) => {
784
- if (e.key === "Enter" || e.key === " ") {
785
- e.preventDefault();
786
- toggleBatch(b.id);
787
- }
788
- }, children: _jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2.5", children: [anyInProgress ? (_jsx(LoaderIcon, { className: "h-3 w-3 shrink-0 animate-spin text-highlight-100" })) : (_jsx("span", { className: `h-1.5 w-1.5 shrink-0 rounded-full ${statusDotClass}`, "aria-hidden": "true" })), _jsx("span", { className: "text-neutral-grey-100 truncate", title: timeDisplay, children: (() => {
789
- let parsedName;
790
- if (info?.metadata) {
791
- try {
792
- const m = typeof info.metadata === "string"
793
- ? JSON.parse(info.metadata)
794
- : info.metadata;
795
- const candidate = m?.name;
796
- if (typeof candidate === "string" &&
797
- candidate.trim()) {
798
- parsedName = candidate.trim();
799
- }
800
- }
801
- catch {
802
- // Metadata isn't JSON — fall through to timestamp.
803
- }
804
- }
805
- return (parsedName ??
806
- `Translation Batch · ${timeDisplay}`);
807
- })() }), showProgress && (_jsxs("span", { className: `inline-flex items-center gap-1 text-[11px] tabular-nums ${anyError ? "text-feedback-red" : "text-highlight-100"}`, "data-testid": `batch-progress-${b.id}`, children: [completedJobs, "/", totalJobs, anyError && (_jsxs("span", { className: "text-feedback-red", children: ["\u00B7 ", errorJobs, " error", errorJobs !== 1 ? "s" : ""] }))] }))] }), _jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12px] text-neutral-grey-50 pl-4", children: [metaParts.map((part, i) => (_jsxs(React.Fragment, { children: [i > 0 && (_jsx("span", { className: "text-neutral-grey-15", children: "\u00B7" })), part] }, i))), info?.lastUpdatedUtc && (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-neutral-grey-15", children: "\u00B7" }), _jsx("span", { children: new Date(info.lastUpdatedUtc).toLocaleString() })] }))] })] }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [canRetry && (_jsx(SimpleIconButton, { onClick: (event) => {
808
- event.stopPropagation();
809
- void handleRetryBatch(info);
810
- }, icon: _jsx(RetryIcon, { className: `h-3.5 w-3.5 ${isRetryingBatch ? "animate-spin" : ""}`, strokeWidth: 1.5 }), label: "Retry failed translations", disabled: isRetryingBatch ||
811
- isLoadingThis ||
812
- isAborting ||
813
- isDeleting, className: "p-0! text-feedback-red hover:text-feedback-red" })), canAbort && (_jsx(SimpleIconButton, { onClick: (event) => {
814
- event.stopPropagation();
815
- handleAbortBatch(b.id);
816
- }, icon: _jsx(AbortIcon, { className: `h-3.5 w-3.5 ${isAborting ? "animate-spin" : ""}`, strokeWidth: 1.5 }), label: "Abort translation batch", disabled: isAborting || isDeleting, className: "p-0! text-feedback-orange hover:text-feedback-orange" })), canDelete && (_jsx(SimpleIconButton, { onClick: (event) => {
817
- event.stopPropagation();
818
- handleDeleteBatch(b.id);
819
- }, icon: _jsx(DeleteIcon, { size: "md" }), label: "Delete translation history", disabled: isDeleting || isAborting, className: "p-0! text-neutral-grey-50 hover:bg-neutral-grey-5 hover:text-neutral-grey-50" })), _jsx("div", { className: `text-neutral-grey-15 transition-transform ${isExpanded ? "rotate-180" : ""} group-hover:text-neutral-grey-50`, children: _jsx(ChevronDownIcon, { className: "h-4 w-4" }) })] })] }) }), isExpanded && (_jsxs(_Fragment, { children: [_jsx(CustomPromptPanel, { prompts: parseCustomPrompts(info?.metadata) }), _jsx(BatchItemList, { batchId: b.id, batchProvider: info?.provider, jobs: jobs, isLoading: isLoadingThis, expandedItems: expandedItems, languages: sitecoreLanguages, itemFilter: filters.itemIdOrPath.trim(), itemIncludeSubitems: filters.itemIncludeSubitems, statusFilter: filters.status, batchIsTerminal: isTerminalBatchStatus(info?.status), batchStartedAtUtc: info?.startedAtUtc ?? info?.createdAtUtc, retryingKeys: retryingKeys, onToggleItem: toggleItem, onOpenItem: openItemInEditor, onRetryJobs: handleRetryJobs })] }))] }, b.id));
820
- }) })] }, dateGroup.label))), hasMore && (_jsx("div", { className: "flex justify-center pt-6", children: _jsx(Button, { variant: "outline", size: "sm", onClick: loadMore, disabled: isLoadingMore, children: isLoadingMore ? (_jsxs(_Fragment, { children: [_jsx(LoaderIcon, { className: "h-4 w-4 animate-spin text-highlight-100" }), "Loading more..."] })) : (_jsxs(_Fragment, { children: [_jsx(ChevronDownIcon, { className: "h-4 w-4" }), "Load More Batches"] })) }) }))] })) })] }));
821
- }
822
- function isLikelyGuid(value) {
823
- return /^[{(]?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[)}]?$/i.test(value.trim());
824
- }
825
- function parseItemName(metadata, fallback) {
826
- if (!metadata)
827
- return fallback;
828
- try {
829
- const parsed = typeof metadata === "string" ? JSON.parse(metadata) : metadata;
830
- const name = parsed?.name ?? parsed?.itemName ?? parsed?.displayName;
831
- if (typeof name === "string" && name.trim())
832
- return name.trim();
833
- }
834
- catch {
835
- // not JSON
836
- }
837
- return fallback;
838
- }
839
- function parseCustomPrompts(metadata) {
840
- if (!metadata)
841
- return [];
842
- try {
843
- const parsed = typeof metadata === "string" ? JSON.parse(metadata) : metadata;
844
- const scd = parsed?.serviceCustomData;
845
- if (!scd || typeof scd !== "object")
846
- return [];
847
- const out = [];
848
- for (const [provider, value] of Object.entries(scd)) {
849
- const v = value;
850
- const prompt = (v?.customPrompt || "").trim();
851
- if (v?.enableCustomPrompt && prompt) {
852
- out.push({ provider, prompt });
853
- }
854
- }
855
- return out;
856
- }
857
- catch {
858
- return [];
859
- }
860
- }
861
- function parseTranslationStatistics(metadata) {
862
- if (!metadata)
863
- return null;
864
- try {
865
- const parsed = typeof metadata === "string" ? JSON.parse(metadata) : metadata;
866
- const stats = parsed?.statistics ?? parsed?.translationStats;
867
- if (!stats || typeof stats !== "object")
868
- return null;
869
- const fieldCount = Number(stats.fieldCount);
870
- const nonEmptyFieldCount = Number(stats.nonEmptyFieldCount ?? stats.translatedFieldCount ?? fieldCount);
871
- const emptyFieldCount = Number(stats.emptyFieldCount ?? Math.max(0, fieldCount - nonEmptyFieldCount));
872
- const sourceCharacterCount = Number(stats.sourceCharacterCount ??
873
- stats.textCharacterCount ??
874
- stats.characterCount);
875
- if (!Number.isFinite(fieldCount) ||
876
- !Number.isFinite(nonEmptyFieldCount) ||
877
- !Number.isFinite(emptyFieldCount) ||
878
- !Number.isFinite(sourceCharacterCount)) {
879
- return null;
880
- }
881
- return {
882
- fieldCount,
883
- nonEmptyFieldCount,
884
- emptyFieldCount,
885
- sourceCharacterCount,
886
- };
887
- }
888
- catch {
889
- return null;
890
- }
891
- }
892
- function formatCount(value) {
893
- return new Intl.NumberFormat().format(value);
894
- }
895
- function formatUsdCost(value) {
896
- return new Intl.NumberFormat("en-US", {
897
- style: "currency",
898
- currency: "USD",
899
- minimumFractionDigits: value > 0 && value < 0.01 ? 4 : 2,
900
- maximumFractionDigits: value > 0 && value < 0.01 ? 6 : 2,
901
- }).format(value);
902
- }
903
- function itemStatusDot(jobs) {
904
- const hasError = jobs.some((j) => j.status === "Error");
905
- const hasInProgress = jobs.some((j) => j.status === "In Progress");
906
- const hasPending = jobs.some((j) => j.status === "Pending");
907
- const hasAborted = jobs.some((j) => j.status === "Aborted");
908
- if (hasError)
909
- return { cls: "bg-feedback-red", label: "Has errors" };
910
- if (hasInProgress)
911
- return { cls: "bg-highlight-100", label: "In progress" };
912
- if (hasPending)
913
- return { cls: "bg-neutral-grey-15", label: "Queued" };
914
- if (hasAborted)
915
- return { cls: "bg-feedback-orange", label: "Aborted" };
916
- return { cls: "bg-feedback-green/70", label: "Completed" };
917
- }
918
- function CustomPromptPanel({ prompts }) {
919
- const [openProvider, setOpenProvider] = React.useState(null);
920
- const [copiedProvider, setCopiedProvider] = React.useState(null);
921
- const copyTimerRef = React.useRef(null);
922
- React.useEffect(() => {
923
- return () => {
924
- if (copyTimerRef.current)
925
- clearTimeout(copyTimerRef.current);
926
- };
927
- }, []);
928
- if (prompts.length === 0)
929
- return null;
930
- const handleCopy = async (provider, prompt) => {
931
- try {
932
- await navigator.clipboard.writeText(prompt);
933
- setCopiedProvider(provider);
934
- if (copyTimerRef.current)
935
- clearTimeout(copyTimerRef.current);
936
- copyTimerRef.current = setTimeout(() => setCopiedProvider(null), 1500);
937
- }
938
- catch {
939
- toast.error("Failed to copy prompt to clipboard");
940
- }
941
- };
942
- return (_jsx("div", { className: "bg-neutral-grey-5/60 border-t border-border-default/50", children: prompts.map((p) => {
943
- const isOpen = openProvider === p.provider;
944
- const justCopied = copiedProvider === p.provider;
945
- return (_jsxs("div", { children: [_jsxs("div", { role: "button", tabIndex: 0, style: { paddingLeft: "2rem" }, className: "group flex items-center gap-2 pr-3 md:pr-4 py-2 text-[12px] text-neutral-grey-50 hover:bg-neutral-grey-5 transition-colors cursor-pointer", onClick: () => setOpenProvider(isOpen ? null : p.provider), onKeyDown: (e) => {
946
- if (e.key === "Enter" || e.key === " ") {
947
- e.preventDefault();
948
- setOpenProvider(isOpen ? null : p.provider);
949
- }
950
- }, children: [_jsx(SparklesIcon, { className: "h-3 w-3 shrink-0 text-highlight-100", strokeWidth: 1.75 }), _jsx("span", { className: "text-neutral-grey-100", children: "Custom prompt" }), _jsx("span", { className: "text-neutral-grey-15", children: "\u00B7" }), _jsx("span", { children: p.provider }), _jsxs("span", { className: "ml-auto flex items-center gap-2", children: [isOpen && (_jsx(SimpleIconButton, { onClick: (event) => {
951
- event.stopPropagation();
952
- void handleCopy(p.provider, p.prompt);
953
- }, icon: justCopied ? (_jsx(CheckIcon, { className: "h-3.5 w-3.5", strokeWidth: 1.5 })) : (_jsx(CopyIcon, { className: "h-3.5 w-3.5", strokeWidth: 1.5 })), label: justCopied ? "Copied" : "Copy prompt", className: "p-0! text-neutral-grey-50 hover:text-neutral-grey-100" })), _jsx(ChevronDownIcon, { className: `h-3.5 w-3.5 text-neutral-grey-15 transition-transform group-hover:text-neutral-grey-50 ${isOpen ? "rotate-180" : ""}` })] })] }), isOpen && (_jsx("div", { style: { paddingLeft: "2.5rem" }, className: "pr-3 md:pr-4 pb-3", children: _jsx("pre", { className: "max-h-64 overflow-y-auto whitespace-pre-wrap break-words rounded border border-border-default/50 bg-white p-2 font-mono text-[12px] text-neutral-grey-100", children: p.prompt }) }))] }, p.provider));
954
- }) }));
955
- }
956
- function BatchItemList({ batchId, batchProvider, jobs, isLoading, expandedItems, languages, itemFilter, itemIncludeSubitems, statusFilter, batchIsTerminal, batchStartedAtUtc, retryingKeys, onToggleItem, onOpenItem, onRetryJobs, }) {
957
- const languageMap = React.useMemo(() => {
958
- const map = new Map();
959
- for (const l of languages) {
960
- const code = (l.languageCode || l.name || "").toLowerCase();
961
- if (code)
962
- map.set(code, l);
963
- }
964
- return map;
965
- }, [languages]);
966
- // When the batch is effectively terminal, any job still reported as
967
- // active in the DB is stale (worker died / queue cleared). Override its
968
- // display status to "Aborted" so chips don't spin forever.
969
- const effectiveJobs = React.useMemo(() => {
970
- if (!jobs)
971
- return jobs;
972
- if (!batchIsTerminal)
973
- return jobs;
974
- return jobs.map((j) => {
975
- if (j.status === "In Progress" || j.status === "Pending") {
976
- return { ...j, status: "Aborted" };
977
- }
978
- return j;
979
- });
980
- }, [jobs, batchIsTerminal]);
981
- const filteredJobs = React.useMemo(() => {
982
- if (!effectiveJobs)
983
- return effectiveJobs;
984
- const filter = itemFilter.trim();
985
- if (!filter)
986
- return effectiveJobs;
987
- // When subitems is enabled and the filter is a GUID, derive the root path
988
- // from any job in this batch that matches the GUID — then fall through to
989
- // prefix-matching on path. If no such job is in the batch we keep only
990
- // the exact id match (best-effort: descendants without their root in the
991
- // same batch can't be inferred here).
992
- let pathPrefix = null;
993
- if (isLikelyGuid(filter)) {
994
- const normalized = normalizeGuid(filter);
995
- if (itemIncludeSubitems) {
996
- const rootJob = effectiveJobs.find((j) => normalizeGuid((j.itemId || "").toString()) === normalized);
997
- pathPrefix = rootJob?.itemPath ? rootJob.itemPath.toLowerCase() : null;
998
- }
999
- if (!pathPrefix) {
1000
- return effectiveJobs.filter((j) => normalizeGuid((j.itemId || "").toString()) === normalized);
1001
- }
1002
- }
1003
- else if (filter.startsWith("/")) {
1004
- pathPrefix = filter.toLowerCase();
1005
- }
1006
- else {
1007
- return effectiveJobs;
1008
- }
1009
- if (itemIncludeSubitems) {
1010
- const prefix = pathPrefix;
1011
- const prefixSlash = prefix.endsWith("/") ? prefix : prefix + "/";
1012
- return effectiveJobs.filter((j) => {
1013
- const p = (j.itemPath || "").toLowerCase();
1014
- return p === prefix || p.startsWith(prefixSlash);
1015
- });
1016
- }
1017
- return effectiveJobs.filter((j) => (j.itemPath || "").toLowerCase() === pathPrefix);
1018
- }, [effectiveJobs, itemFilter, itemIncludeSubitems]);
1019
- // Keep all jobs of items that have at least one job matching the selected status —
1020
- // this way the per-item language overview stays intact even when filtering.
1021
- const statusFilteredJobs = React.useMemo(() => {
1022
- if (!filteredJobs)
1023
- return filteredJobs;
1024
- if (statusFilter === "all")
1025
- return filteredJobs;
1026
- const targetDbStatus = STATUS_FILTER_TO_DB[statusFilter];
1027
- const matchingItemIds = new Set();
1028
- for (const j of filteredJobs) {
1029
- if (j.status === targetDbStatus) {
1030
- matchingItemIds.add((j.itemId || "").toString().toLowerCase());
1031
- }
1032
- }
1033
- if (matchingItemIds.size === 0)
1034
- return [];
1035
- return filteredJobs.filter((j) => matchingItemIds.has((j.itemId || "").toString().toLowerCase()));
1036
- }, [filteredJobs, statusFilter]);
1037
- if (isLoading && !jobs) {
1038
- return (_jsxs("div", { className: "px-3 md:px-4 py-4 pl-8 text-[12px] text-neutral-grey-50", children: [_jsx(LoaderIcon, { className: "inline h-3.5 w-3.5 mr-1.5 animate-spin text-highlight-100" }), "Loading items\u2026"] }));
1039
- }
1040
- if (!statusFilteredJobs || statusFilteredJobs.length === 0) {
1041
- const hasItemFilter = itemFilter.trim() !== "";
1042
- const hasStatusFilter = statusFilter !== "all";
1043
- return (_jsx("div", { className: "px-3 md:px-4 py-3 pl-8 text-[12px] text-neutral-grey-50", children: hasItemFilter || hasStatusFilter
1044
- ? "No items match the current filter."
1045
- : "No items in this batch." }));
1046
- }
1047
- const byItem = new Map();
1048
- for (const job of statusFilteredJobs) {
1049
- const key = (job.itemId ?? "").toString().toLowerCase();
1050
- if (!byItem.has(key))
1051
- byItem.set(key, []);
1052
- byItem.get(key).push(job);
1053
- }
1054
- return (_jsx("div", { className: "bg-neutral-grey-5/60 divide-y divide-border-default/50 border-t border-border-default/50", children: Array.from(byItem.entries()).map(([itemId, itemJobs]) => {
1055
- const isItemExpanded = expandedItems.has(itemId);
1056
- const itemAnyInProgress = itemJobs.some((j) => j.status === "In Progress");
1057
- const itemAnyPending = itemJobs.some((j) => j.status === "Pending");
1058
- const itemIsQueued = !itemAnyInProgress && itemAnyPending;
1059
- const status = itemStatusDot(itemJobs);
1060
- const firstJob = itemJobs[0];
1061
- const itemPath = firstJob?.itemPath ?? null;
1062
- const serverName = firstJob?.itemName ?? null;
1063
- const metadataName = parseItemName(firstJob?.metadata, "");
1064
- const displayName = metadataName || serverName || itemId;
1065
- const langs = itemJobs.map((j) => j.targetLanguage).filter(Boolean);
1066
- const uniqueLangs = Array.from(new Set(langs));
1067
- const titleAttr = `${itemPath ?? ""}\n${itemId}`.trim();
1068
- const itemErrorJobs = itemJobs.filter(isRetriableJob);
1069
- const itemRetryKey = `item:${batchId}:${itemId}`;
1070
- const isRetryingItem = retryingKeys.has(itemRetryKey);
1071
- return (_jsxs("div", { children: [_jsx("div", { role: "button", tabIndex: 0, style: { paddingLeft: "2.5rem" }, className: "group w-full pr-3 md:pr-4 py-2 hover:bg-neutral-grey-5 transition-colors cursor-pointer text-left", onClick: () => onToggleItem(itemId), onKeyDown: (e) => {
1072
- if (e.key === "Enter" || e.key === " ") {
1073
- e.preventDefault();
1074
- onToggleItem(itemId);
1075
- }
1076
- }, children: _jsxs("div", { className: "flex items-start gap-2.5 min-w-0", children: [_jsx("span", { className: "mt-1 shrink-0", children: itemAnyInProgress ? (_jsx(LoaderIcon, { className: "h-3 w-3 animate-spin text-highlight-100" })) : itemIsQueued ? (_jsx(QueuedIcon, { className: "h-3 w-3 text-neutral-grey-50", strokeWidth: 1.75, "aria-label": "Queued" })) : (_jsx("span", { className: `block h-1.5 w-1.5 rounded-full ${status.cls}`, "aria-hidden": "true" })) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("span", { className: "text-[13px] text-neutral-grey-100 truncate", title: titleAttr, children: displayName }), _jsxs("span", { className: "text-[11px] text-neutral-grey-50 shrink-0", children: ["\u00B7 ", uniqueLangs.length, " language", uniqueLangs.length !== 1 ? "s" : ""] })] }), itemPath && (_jsx("div", { className: "text-[11px] text-neutral-grey-50 truncate font-mono", title: titleAttr, children: itemPath }))] }), itemErrorJobs.length > 0 && (_jsx(SimpleIconButton, { onClick: (event) => {
1077
- event.stopPropagation();
1078
- void onRetryJobs(batchId, batchProvider, itemErrorJobs, itemRetryKey);
1079
- }, icon: _jsx(RetryIcon, { className: `h-3.5 w-3.5 ${isRetryingItem ? "animate-spin" : ""}`, strokeWidth: 1.5 }), label: "Retry failed translations for this item", disabled: isRetryingItem, className: "mt-0.5 p-0! text-feedback-red hover:text-feedback-red" })), _jsx("span", { className: "mt-1 shrink-0 text-neutral-grey-15 group-hover:text-neutral-grey-50", children: _jsx(ChevronDownIcon, { className: `h-3.5 w-3.5 transition-transform ${isItemExpanded ? "rotate-180" : ""}` }) })] }) }), isItemExpanded && (_jsx("div", { style: { paddingLeft: "4rem" }, className: "pr-3 md:pr-4 pb-3 pt-1 flex flex-wrap gap-1.5", children: itemJobs.map((job, idx) => {
1080
- const lang = languageMap.get((job.targetLanguage || "").toLowerCase());
1081
- const retryKey = `job:${job.id ?? `${job.itemId}:${job.sourceLanguage}:${job.targetLanguage}`}`;
1082
- return (_jsx(LanguageJobChip, { job: job, language: lang, batchStartedAtUtc: batchStartedAtUtc, onOpen: () => onOpenItem(job.itemId, job.targetLanguage), isRetrying: retryingKeys.has(retryKey), onRetry: () => onRetryJobs(batchId, batchProvider, [job], retryKey) }, `${job.id ?? idx}-${job.targetLanguage}`));
1083
- }) }))] }, itemId));
1084
- }) }));
1085
- }
1086
- function LanguageJobChip({ job, language, batchStartedAtUtc, onOpen, isRetrying = false, onRetry, }) {
1087
- const [open, setOpen] = React.useState(false);
1088
- const jobStatus = job.status;
1089
- const jobInProgress = jobStatus === "In Progress";
1090
- const jobPending = jobStatus === "Pending";
1091
- const jobError = jobStatus === "Error";
1092
- const jobAborted = jobStatus === "Aborted";
1093
- const langName = language?.name || job.targetLanguage;
1094
- const chipCls = jobError
1095
- ? "border-feedback-red bg-feedback-red-light text-feedback-red hover:bg-feedback-red-light"
1096
- : jobAborted
1097
- ? "border-feedback-orange bg-feedback-orange-light text-feedback-orange hover:bg-feedback-orange-light"
1098
- : jobInProgress
1099
- ? "border-highlight-100/30 bg-highlight-10 text-highlight-100 hover:brightness-95"
1100
- : jobPending
1101
- ? "border-border-default bg-neutral-grey-5 text-neutral-grey-50 hover:bg-neutral-grey-5"
1102
- : "border-border-default bg-background text-neutral-grey-100 hover:bg-neutral-grey-5";
1103
- const statusBadgeCls = jobError
1104
- ? "bg-feedback-red-light text-feedback-red ring-1 ring-inset ring-feedback-red"
1105
- : jobAborted
1106
- ? "bg-feedback-orange-light text-feedback-orange ring-1 ring-inset ring-feedback-orange"
1107
- : jobInProgress
1108
- ? "bg-highlight-10 text-highlight-100 ring-1 ring-inset ring-highlight-100/20"
1109
- : jobPending
1110
- ? "bg-neutral-grey-5 text-neutral-grey-50 ring-1 ring-inset ring-border-default"
1111
- : "bg-feedback-green-light text-feedback-green ring-1 ring-inset ring-feedback-green";
1112
- const timestamp = job.timestamp ? new Date(job.timestamp) : null;
1113
- const timestampValid = timestamp && !isNaN(timestamp.getTime());
1114
- const claimedAt = job.claimedAtUtc ? new Date(job.claimedAtUtc) : null;
1115
- const claimedAtValid = !!(claimedAt && !isNaN(claimedAt.getTime()));
1116
- const lastHeartbeat = job.lastHeartbeatUtc
1117
- ? new Date(job.lastHeartbeatUtc)
1118
- : null;
1119
- const lastHeartbeatValid = !!(lastHeartbeat && !isNaN(lastHeartbeat.getTime()));
1120
- // Fall back to the batch's start time when the job didn't record one of its
1121
- // own — older jobs (and provider-less paths) skip ClaimedAtUtc.
1122
- const batchStart = batchStartedAtUtc ? new Date(batchStartedAtUtc) : null;
1123
- const batchStartValid = !!(batchStart && !isNaN(batchStart.getTime()));
1124
- const startMs = claimedAtValid
1125
- ? claimedAt.getTime()
1126
- : batchStartValid
1127
- ? batchStart.getTime()
1128
- : null;
1129
- const durationApproximated = !claimedAtValid && batchStartValid;
1130
- // Duration of the run when we have both ends. For terminal statuses we use
1131
- // `timestamp` as the end. For active jobs we keep it ticking against now.
1132
- const durationMs = (() => {
1133
- if (startMs == null)
1134
- return null;
1135
- const end = jobInProgress || jobPending
1136
- ? Date.now()
1137
- : timestampValid
1138
- ? timestamp.getTime()
1139
- : null;
1140
- if (end == null)
1141
- return null;
1142
- const ms = end - startMs;
1143
- return ms >= 0 ? ms : null;
1144
- })();
1145
- const formatDuration = (ms) => {
1146
- if (ms < 1000)
1147
- return "<1s";
1148
- const totalSec = Math.round(ms / 1000);
1149
- if (totalSec < 60)
1150
- return `${totalSec}s`;
1151
- const m = Math.floor(totalSec / 60);
1152
- const s = totalSec % 60;
1153
- if (m < 60)
1154
- return s === 0 ? `${m}m` : `${m}m ${s}s`;
1155
- const h = Math.floor(m / 60);
1156
- const rm = m % 60;
1157
- return rm === 0 ? `${h}h` : `${h}h ${rm}m`;
1158
- };
1159
- const attemptCount = typeof job.attemptCount === "number" ? job.attemptCount : 0;
1160
- const statistics = parseTranslationStatistics(job.metadata);
1161
- const fieldText = statistics
1162
- ? statistics.nonEmptyFieldCount === statistics.fieldCount
1163
- ? formatCount(statistics.fieldCount)
1164
- : `${formatCount(statistics.nonEmptyFieldCount)}/${formatCount(statistics.fieldCount)}`
1165
- : null;
1166
- const emptyFieldText = statistics && statistics.emptyFieldCount > 0
1167
- ? `${formatCount(statistics.emptyFieldCount)} empty`
1168
- : null;
1169
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: `inline-flex items-center gap-1.5 rounded-md border badge-pad-sm text-[12px] transition-colors cursor-pointer ${chipCls}`, children: [language?.icon ? (_jsx("img", { src: language.icon, alt: langName, className: "h-3.5 shrink-0" })) : null, _jsx("span", { className: "font-mono tracking-tight", children: job.targetLanguage }), jobInProgress && (_jsx(LoaderIcon, { className: "h-3 w-3 shrink-0 animate-spin" })), jobPending && (_jsx(QueuedIcon, { className: "h-3 w-3 shrink-0", strokeWidth: 1.75, "aria-label": "Queued" })), jobError && _jsx(AlertCircleIcon, { className: "h-3 w-3 shrink-0" }), !jobInProgress && !jobError && !jobPending && !jobAborted && (_jsx(CheckIcon, { className: "h-3 w-3 shrink-0 text-feedback-green" }))] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 6, className: "w-[22rem] max-w-[calc(100vw-2rem)] p-0", children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border-default px-3 py-2", children: [language?.icon ? (_jsx("img", { src: language.icon, alt: langName, className: "h-4 shrink-0" })) : null, _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[13px] font-medium text-neutral-grey-100 truncate", children: langName }), _jsxs("div", { className: "text-[11px] text-neutral-grey-50 font-mono truncate", children: [job.targetLanguage, job.sourceLanguage ? ` ← ${job.sourceLanguage}` : ""] })] }), _jsxs(Badge, { size: "sm", className: statusBadgeCls, children: [jobInProgress && (_jsx(LoaderIcon, { className: "h-2.5 w-2.5 animate-spin" })), jobStatus || "Unknown"] })] }), _jsxs("dl", { className: "divide-y divide-border-default/60", children: [claimedAtValid && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Claimed" }), _jsx("dd", { className: "text-[12px] text-neutral-grey-100", children: claimedAt.toLocaleString() })] })), timestampValid && (jobInProgress || jobPending) && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Updated" }), _jsx("dd", { className: "text-[12px] text-neutral-grey-100", children: timestamp.toLocaleString() })] })), durationMs != null && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: jobInProgress || jobPending ? "Running" : "Duration" }), _jsxs("dd", { className: "text-[12px] tabular-nums text-neutral-grey-100", title: durationApproximated
1170
- ? "Approximate — measured from batch start"
1171
- : undefined, children: [durationApproximated ? "~" : "", formatDuration(durationMs)] })] })), statistics && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Fields" }), _jsxs("dd", { className: "text-[12px] tabular-nums text-neutral-grey-100", children: [fieldText, emptyFieldText ? (_jsxs("span", { className: "ml-1.5 text-neutral-grey-50", children: ["(", emptyFieldText, ")"] })) : null] })] })), statistics && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Text" }), _jsxs("dd", { className: "text-[12px] tabular-nums text-neutral-grey-100", children: [formatCount(statistics.sourceCharacterCount), " chars"] })] })), job.totalCost != null && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Cost" }), _jsx("dd", { className: "text-[12px] tabular-nums text-neutral-grey-100", children: formatUsdCost(job.totalCost) })] })), lastHeartbeatValid && (jobInProgress || jobPending) && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Heartbeat" }), _jsx("dd", { className: "text-[12px] text-neutral-grey-100", children: lastHeartbeat.toLocaleString() })] })), attemptCount > 1 && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Attempts" }), _jsx("dd", { className: "text-[12px] tabular-nums text-neutral-grey-100", children: attemptCount })] })), job.hash && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Hash" }), _jsx("dd", { className: "text-[11px] font-mono text-neutral-grey-100 break-all", children: job.hash })] })), job.message && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: jobError ? "Error" : jobAborted ? "Aborted" : "Message" }), _jsx("dd", { className: `text-[12px] break-words ${jobError ? "text-feedback-red" : jobAborted ? "text-feedback-orange" : "text-neutral-grey-100"}`, children: job.message })] })), job.batchId && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Batch" }), _jsx("dd", { className: "text-[11px] font-mono text-neutral-grey-50 break-all", children: job.batchId })] }))] }), _jsxs("div", { className: "flex justify-end gap-2 border-t border-border-default px-3 py-2", children: [jobError && onRetry && (_jsxs(Button, { size: "sm", variant: "outline", disabled: isRetrying, onClick: (e) => {
1172
- e.stopPropagation();
1173
- void onRetry();
1174
- setOpen(false);
1175
- }, className: "gap-1.5", children: [_jsx(RetryIcon, { className: `h-3.5 w-3.5 ${isRetrying ? "animate-spin" : ""}` }), "Retry"] })), _jsxs(Button, { size: "sm", variant: "default", onClick: (e) => {
1176
- e.stopPropagation();
1177
- void onOpen();
1178
- setOpen(false);
1179
- }, className: "gap-1.5", children: [_jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5" }), "Open in editor"] })] })] })] }));
1180
- }