@parhelia/localization 0.1.12902 → 0.1.12904

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